From f77955074e67a072bc380e03e960d87775ba5af2 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Mon, 18 May 2026 20:43:42 +0800 Subject: [PATCH] feat(ui): redesign theme system with CSS variable architecture Refactor index.css to use CSS custom properties for all theme tokens. Add theme-vars.ts and color.ts utility modules. Update theme-presets to use new variable structure. Overhaul ThemeCustomization and ThemePresetSelector components. --- src/components/theme-provider.tsx | 3 + src/components/theme/ThemeCustomization.tsx | 343 +++++----- src/components/theme/ThemePresetSelector.tsx | 126 ++-- src/config/theme-presets.ts | 40 +- src/index.css | 656 ++++++++++--------- src/lib/color.ts | 13 + src/lib/theme-vars.ts | 79 +++ 7 files changed, 715 insertions(+), 545 deletions(-) create mode 100644 src/lib/color.ts create mode 100644 src/lib/theme-vars.ts diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx index 909faf3..22c8592 100644 --- a/src/components/theme-provider.tsx +++ b/src/components/theme-provider.tsx @@ -2,6 +2,7 @@ import React from "react" import { applyThemePreset } from "@/components/theme/ThemePresetSelector" +import { loadThemeVars } from "@/lib/theme-vars" type Theme = "dark" | "light" | "system" type ResolvedTheme = "dark" | "light" @@ -121,6 +122,8 @@ export function ThemeProvider({ applyThemePreset(storedPreset) } + loadThemeVars() + if (restoreTransitions) { restoreTransitions() } diff --git a/src/components/theme/ThemeCustomization.tsx b/src/components/theme/ThemeCustomization.tsx index 9bd3e6b..3543175 100644 --- a/src/components/theme/ThemeCustomization.tsx +++ b/src/components/theme/ThemeCustomization.tsx @@ -1,196 +1,208 @@ -import { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Loader2, RotateCcw, Check } from "lucide-react"; -import { THEME_VARIABLES, THEME_CATEGORIES, type ThemeVariable } from "@/config/theme-variables"; -import { t } from "@/i18n/T"; - -const STORAGE_KEY = "custom-theme-vars"; - -interface CustomTheme { - [key: string]: string; -} - -function applyThemeVars(vars: CustomTheme) { - const root = document.documentElement; - Object.entries(vars).forEach(([key, value]) => { - root.style.setProperty(`--${key}`, value); - }); -} +import { useEffect, useState } from "react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { Loader2, RotateCcw, Check } from "lucide-react" +import { + THEME_VARIABLES, + THEME_CATEGORIES, + type ThemeVariable, +} from "@/config/theme-variables" +import { t } from "@/i18n/T" +import { + applyThemeVars, + getStoredThemeVars, + removeThemeVars, + resetAllThemeVars, + saveThemeVars, + type ThemeVarMap, +} from "@/lib/theme-vars" export function useThemeCustomization() { - const [customVars, setCustomVars] = useState(() => { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - try { - return JSON.parse(stored) as CustomTheme; - } catch { - console.error("Failed to parse stored theme vars"); - return {}; - } - } - return {}; - }); - const [hasChanges, setHasChanges] = useState(false); + const [customVars, setCustomVars] = useState(() => + getStoredThemeVars() + ) + const [hasChanges, setHasChanges] = useState(false) useEffect(() => { - applyThemeVars(customVars); - }, [customVars]); + applyThemeVars(customVars) + }, [customVars]) const updateVar = (key: string, value: string) => { setCustomVars((prev) => { - const next = { ...prev, [key]: value }; - setHasChanges(true); - return next; - }); - }; + const next = { ...prev, [key]: value } + setHasChanges(true) + return next + }) + } const resetVar = (key: string) => { setCustomVars((prev) => { - const next = { ...prev }; - delete next[key]; - document.documentElement.style.removeProperty(`--${key}`); - setHasChanges(true); - return next; - }); - }; + const next = { ...prev } + delete next[key] + removeThemeVars([key]) + setHasChanges(true) + return next + }) + } const save = () => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(customVars)); - setHasChanges(false); - }; + saveThemeVars(customVars) + setHasChanges(false) + } const resetAll = () => { - setCustomVars({}); - localStorage.removeItem(STORAGE_KEY); - THEME_VARIABLES.forEach((v) => { - document.documentElement.style.removeProperty(`--${v.key}`); - }); - setHasChanges(false); - }; + setCustomVars({}) + resetAllThemeVars(customVars) + setHasChanges(false) + } - return { customVars, updateVar, resetVar, save, resetAll, hasChanges }; + return { customVars, updateVar, resetVar, save, resetAll, hasChanges } } export function ThemeCustomization({ className }: { className?: string }) { - const { customVars, updateVar, resetVar, save, resetAll, hasChanges } = useThemeCustomization(); - const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); - const [activeCategory, setActiveCategory] = useState("surfaces"); + const { customVars, updateVar, resetVar, save, resetAll, hasChanges } = + useThemeCustomization() + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [activeCategory, setActiveCategory] = useState("surfaces") const handleSave = async () => { - setSaving(true); - save(); - setSaved(true); - setSaving(false); - setTimeout(() => setSaved(false), 2000); - }; + setSaving(true) + save() + setSaved(true) + setSaving(false) + setTimeout(() => setSaved(false), 2000) + } - const getValue = (v: ThemeVariable): string => { - return customVars[v.key] ?? ""; - }; - - const isModified = (key: string): boolean => key in customVars; + const getValue = (v: ThemeVariable): string => customVars[v.key] ?? "" + const isModified = (key: string): boolean => key in customVars return (
- {/* Category Tabs */} -
- {THEME_CATEGORIES.map((cat) => ( - - ))} +
+ {THEME_CATEGORIES.map((cat) => { + const active = activeCategory === cat.key + return ( + + ) + })}
- {/* Variables Grid */} -
- {THEME_VARIABLES.filter((v) => v.category === activeCategory).map((variable) => ( -
- {/* Preview */} -
+ - {/* Info */} -
-
- - {variable.label} - - {isModified(variable.key) && ( - - 已修改 +
+ {THEME_VARIABLES.filter((v) => v.category === activeCategory).map( + (variable) => ( +
+
+ +
+
+ + {variable.label} + {isModified(variable.key) && ( + + 已修改 + + )} +
+ + --{variable.key} + + {variable.description && ( +

+ {variable.description} +

)}
- - --{variable.key} - - {variable.description && ( -

- {variable.description} -

- )} -
- {/* Input */} -
- updateVar(variable.key, e.target.value)} - placeholder={variable.defaultLight} - className="w-[200px] font-mono text-[13px]" - style={{ - backgroundColor: "var(--input-bg)", - borderColor: "var(--border-default)", - color: "var(--text-primary)", - }} - /> - {isModified(variable.key) && ( - - )} +
+ updateVar(variable.key, e.target.value)} + placeholder={variable.defaultLight} + className="w-full max-w-[220px] font-mono text-[13px]" + style={{ + backgroundColor: "var(--input-bg)", + borderColor: "var(--border-default)", + color: "var(--text-primary)", + }} + /> + {isModified(variable.key) && ( + + )} +
-
- ))} + ) + )}
- {/* Actions */} -
+
- ); + ) } - -export function resetAllThemeVars() { - localStorage.removeItem(STORAGE_KEY); - THEME_VARIABLES.forEach((v) => { - document.documentElement.style.removeProperty(`--${v.key}`); - }); -} - -export function loadThemeVars() { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - try { - const vars: CustomTheme = JSON.parse(stored); - Object.entries(vars).forEach(([key, value]) => { - document.documentElement.style.setProperty(`--${key}`, value); - }); - } catch { - console.error("Failed to parse stored theme vars"); - } - } -} \ No newline at end of file diff --git a/src/components/theme/ThemePresetSelector.tsx b/src/components/theme/ThemePresetSelector.tsx index 76c9f60..00435a8 100644 --- a/src/components/theme/ThemePresetSelector.tsx +++ b/src/components/theme/ThemePresetSelector.tsx @@ -1,8 +1,10 @@ -import { useState, useEffect, useCallback } from "react"; -import { getAllPresets } from "@/config/theme-presets"; +import { useState, useEffect, useCallback } from "react" +import { Badge } from "@/components/ui/badge" +import { getAllPresets } from "@/config/theme-presets" +import { loadThemeVars } from "@/lib/theme-vars" -const STORAGE_KEY = "app-theme-preset"; -const DEFAULT_PRESET = "soft-mono"; +const STORAGE_KEY = "app-theme-preset" +const DEFAULT_PRESET = "soft-mono" const THEME_COLOR_ALIASES: Record = { "color-background": "background", @@ -31,119 +33,139 @@ const THEME_COLOR_ALIASES: Record = { "color-sidebar-accent-foreground": "sidebar-accent-foreground", "color-sidebar-border": "sidebar-border", "color-sidebar-ring": "sidebar-ring", -}; +} const DERIVED_THEME_TOKENS: Record = { + "surface-secondary": + "color-mix(in oklch, var(--surface-elevated) 72%, var(--surface-ground))", "accent-bg": "color-mix(in oklch, var(--accent) 8%, transparent)", "success-alpha10": "color-mix(in oklch, var(--success) 10%, transparent)", "warning-alpha10": "color-mix(in oklch, var(--warning) 10%, transparent)", - "destructive-alpha10": "color-mix(in oklch, var(--destructive) 10%, transparent)", + "destructive-alpha10": + "color-mix(in oklch, var(--destructive) 10%, transparent)", "status-offline": "var(--text-muted)", - "text-tertiary": "color-mix(in oklch, var(--text-muted) 70%, var(--surface-ground))", - "heatmap-0": "color-mix(in oklch, var(--surface-ground) 82%, var(--border-default))", + "text-tertiary": + "color-mix(in oklch, var(--text-muted) 70%, var(--surface-ground))", + "heatmap-0": + "color-mix(in oklch, var(--surface-ground) 82%, var(--border-default))", "heatmap-1": "color-mix(in oklch, var(--success) 18%, var(--surface-ground))", "heatmap-2": "color-mix(in oklch, var(--success) 36%, var(--surface-ground))", "heatmap-3": "color-mix(in oklch, var(--success) 58%, var(--surface-ground))", "heatmap-4": "color-mix(in oklch, var(--success) 78%, var(--surface-ground))", -}; +} export function useThemePreset() { - const [presetId, setPresetIdState] = useState(() => { - return localStorage.getItem(STORAGE_KEY) || DEFAULT_PRESET; - }); + const [presetId, setPresetIdState] = useState( + () => localStorage.getItem(STORAGE_KEY) || DEFAULT_PRESET + ) const setPresetId = useCallback((id: string) => { - localStorage.setItem(STORAGE_KEY, id); - setPresetIdState(id); - }, []); + localStorage.setItem(STORAGE_KEY, id) + setPresetIdState(id) + }, []) useEffect(() => { - applyThemePreset(presetId); - }, [presetId]); + applyThemePreset(presetId) + loadThemeVars() + }, [presetId]) - return { presetId, setPresetId }; + return { presetId, setPresetId } } export function applyThemePreset(presetId: string) { - const presets = getAllPresets(); - const preset = presets.find((p) => p.id === presetId); - if (!preset) return; + const presets = getAllPresets() + const preset = presets.find((p) => p.id === presetId) + if (!preset) return - const isDark = document.documentElement.classList.contains("dark"); - const vars = isDark && preset.varsDark ? preset.varsDark : preset.vars; + const isDark = document.documentElement.classList.contains("dark") + const vars = isDark && preset.varsDark ? preset.varsDark : preset.vars - const root = document.documentElement; + const root = document.documentElement Object.entries(vars).forEach(([key, value]) => { - if (value) root.style.setProperty(`--${key}`, value); - }); + if (value) root.style.setProperty(`--${key}`, value) + }) Object.entries(THEME_COLOR_ALIASES).forEach(([alias, source]) => { - root.style.setProperty(`--${alias}`, `var(--${source})`); - }); + if (alias in vars) return + root.style.setProperty(`--${alias}`, `var(--${source})`) + }) Object.entries(DERIVED_THEME_TOKENS).forEach(([key, value]) => { - root.style.setProperty(`--${key}`, value); - }); + if (key in vars) return + root.style.setProperty(`--${key}`, value) + }) } export function ThemePresetSelector() { - const { presetId, setPresetId } = useThemePreset(); - const presets = getAllPresets(); + const { presetId, setPresetId } = useThemePreset() + const presets = getAllPresets() return ( -
+
{presets.map((preset) => { - const active = presetId === preset.id; + const active = presetId === preset.id return ( - ); + ) })}
- ); + ) } diff --git a/src/config/theme-presets.ts b/src/config/theme-presets.ts index 5d014bd..59a253a 100644 --- a/src/config/theme-presets.ts +++ b/src/config/theme-presets.ts @@ -30,13 +30,13 @@ export type ThemeVarKey = | "sidebar-border" | "sidebar-ring" // Semantic surfaces | "surface-rail" | "surface-sidebar" | "surface-ground" - | "surface-elevated" | "surface-overlay" + | "surface-elevated" | "surface-secondary" | "surface-overlay" // Semantic borders | "border-subtle" | "border-default" | "border-strong" // Semantic text - | "text-primary" | "text-secondary" | "text-muted" | "text-inverse" + | "text-primary" | "text-secondary" | "text-muted" | "text-tertiary" | "text-inverse" // Accent variants - | "accent-hover" | "accent-fg" | "accent-muted" + | "accent-hover" | "accent-fg" | "accent-muted" | "accent-bg" // Status indicators | "status-online" | "status-idle" | "status-dnd" | "status-offline" // Feedback tokens @@ -111,6 +111,7 @@ const BASE_LIGHT = Object.freeze({ "surface-sidebar": "oklch(0.97 0 0)", "surface-ground": "oklch(1 0 0)", "surface-elevated": "oklch(1 0 0)", + "surface-secondary": "oklch(0.99 0 0)", "surface-overlay": "oklch(1 0 0 / 90%)", // Semantic borders "border-subtle": "oklch(0.90 0 0 / 40%)", @@ -120,11 +121,13 @@ const BASE_LIGHT = Object.freeze({ "text-primary": "oklch(0.13 0 0)", "text-secondary": "oklch(0.40 0 0)", "text-muted": "oklch(0.55 0 0)", + "text-tertiary": "oklch(0.70 0 0)", "text-inverse": "oklch(0.985 0 0)", // Accent variants "accent-hover": "oklch(0.15 0 0)", "accent-fg": "oklch(1 0 0)", "accent-muted": "oklch(0.13 0 0 / 12%)", + "accent-bg": "oklch(0.13 0 0 / 8%)", // Status "status-online": "oklch(0.55 0.15 155)", "status-idle": "oklch(0.68 0.15 80)", @@ -204,6 +207,7 @@ const BASE_DARK = Object.freeze({ "surface-sidebar": "oklch(0.15 0 0)", "surface-ground": "oklch(0.13 0 0)", "surface-elevated": "oklch(0.18 0 0)", + "surface-secondary": "oklch(0.16 0 0)", "surface-overlay": "oklch(0.10 0 0 / 95%)", // Semantic borders "border-subtle": "oklch(0.30 0 0 / 30%)", @@ -213,11 +217,13 @@ const BASE_DARK = Object.freeze({ "text-primary": "oklch(0.97 0 0)", "text-secondary": "oklch(0.80 0 0)", "text-muted": "oklch(0.65 0 0)", + "text-tertiary": "oklch(0.55 0 0)", "text-inverse": "oklch(0.13 0 0)", // Accent variants "accent-hover": "oklch(0.78 0.15 264)", "accent-fg": "oklch(0.10 0 0)", "accent-muted": "oklch(0.70 0.15 264 / 20%)", + "accent-bg": "oklch(0.70 0.15 264 / 8%)", // Status "status-online": "oklch(0.72 0.17 155)", "status-idle": "oklch(0.78 0.15 80)", @@ -270,7 +276,7 @@ const PRESETS: ThemePreset[] = [ id: "pure-bw", name: "纯黑纯白", description: "高对比度黑白主题", - preview: {bg: "#FFFFFF", fg: "#1A1A1A", accent: "#000000"}, + preview: {bg: "#FAFAFA", fg: "#111111", accent: "#111111"}, vars: light({ foreground: "oklch(0 0 0)", "card-foreground": "oklch(0 0 0)", @@ -296,16 +302,19 @@ const PRESETS: ThemePreset[] = [ "sidebar-ring": "oklch(0 0 0)", // Semantic "surface-overlay": "oklch(1 0 0 / 96%)", + "surface-secondary": "oklch(0.96 0 0)", "border-subtle": "oklch(0.75 0 0 / 40%)", "border-default": "oklch(0.15 0 0)", "border-strong": "oklch(0 0 0)", "text-primary": "oklch(0 0 0)", "text-secondary": "oklch(0.20 0 0)", "text-muted": "oklch(0.40 0 0)", + "text-tertiary": "oklch(0.58 0 0)", "text-inverse": "oklch(1 0 0)", "accent-hover": "oklch(0.10 0 0)", "accent-fg": "oklch(1 0 0)", "accent-muted": "oklch(0 0 0 / 12%)", + "accent-bg": "oklch(0 0 0 / 8%)", "hover-bg": "oklch(0.92 0 0)", "hover-bg-strong": "oklch(0.84 0 0)", "input-bg": "oklch(0.97 0 0)", @@ -352,15 +361,18 @@ const PRESETS: ThemePreset[] = [ "surface-sidebar": "oklch(0.05 0 0)", "surface-ground": "oklch(0 0 0)", "surface-elevated": "oklch(0.06 0 0)", + "surface-secondary": "oklch(0.08 0 0)", "border-default": "oklch(1 0 0 / 15%)", "border-strong": "oklch(1 0 0 / 30%)", "text-primary": "oklch(1 0 0)", "text-secondary": "oklch(0.80 0 0)", "text-muted": "oklch(0.60 0 0)", + "text-tertiary": "oklch(0.48 0 0)", "text-inverse": "oklch(0 0 0)", "accent-hover": "oklch(0.90 0 0)", "accent-fg": "oklch(0 0 0)", "accent-muted": "oklch(1 0 0 / 12%)", + "accent-bg": "oklch(1 0 0 / 6%)", "hover-bg": "oklch(1 0 0 / 6%)", "hover-bg-strong": "oklch(1 0 0 / 12%)", "input-bg": "oklch(0.03 0 0)", @@ -389,7 +401,7 @@ const PRESETS: ThemePreset[] = [ id: "soft-mono", name: "柔和灰度", description: "层次分明的灰度主题,默认推荐", - preview: {bg: "#FFFFFF", fg: "#1A1A1A", accent: "#1A1A1A"}, + preview: {bg: "#F7F7F6", fg: "#222222", accent: "#222222"}, vars: light(), varsDark: dark(), }, @@ -423,11 +435,13 @@ const PRESETS: ThemePreset[] = [ "surface-rail": "oklch(0.08 0 0)", "surface-sidebar": "oklch(0.10 0 0)", "surface-elevated": "oklch(0.16 0 0)", + "surface-secondary": "oklch(0.14 0 0)", "border-default": "oklch(0.28 0 0)", "text-inverse": "oklch(0.10 0 0)", "accent-hover": "oklch(0.85 0 0)", "accent-fg": "oklch(0.08 0 0)", "accent-muted": "oklch(0.985 0 0 / 12%)", + "accent-bg": "oklch(0.985 0 0 / 6%)", "input-bg": "oklch(0.15 0 0)", "input-ring": "oklch(0.985 0 0)", }), @@ -453,11 +467,13 @@ const PRESETS: ThemePreset[] = [ "surface-rail": "oklch(0.08 0 0)", "surface-sidebar": "oklch(0.10 0 0)", "surface-elevated": "oklch(0.16 0 0)", + "surface-secondary": "oklch(0.14 0 0)", "border-default": "oklch(0.28 0 0)", "text-inverse": "oklch(0.10 0 0)", "accent-hover": "oklch(0.85 0 0)", "accent-fg": "oklch(0.08 0 0)", "accent-muted": "oklch(0.985 0 0 / 12%)", + "accent-bg": "oklch(0.985 0 0 / 6%)", "input-bg": "oklch(0.15 0 0)", "input-ring": "oklch(0.985 0 0)", }), @@ -824,6 +840,7 @@ const PRESETS: ThemePreset[] = [ "surface-rail": "oklch(0.94 0.020 95)", "surface-sidebar": "oklch(0.96 0.020 95)", "surface-ground": "oklch(0.98 0.022 95)", + "surface-secondary": "oklch(0.95 0.018 95)", "surface-overlay": "oklch(0.96 0.018 95 / 95%)", "border-subtle": "oklch(0.88 0.018 95 / 50%)", "border-default": "oklch(0.86 0.018 95)", @@ -831,10 +848,12 @@ const PRESETS: ThemePreset[] = [ "text-primary": "oklch(0.47 0.035 205)", "text-secondary": "oklch(0.57 0.028 205)", "text-muted": "oklch(0.65 0.018 200)", + "text-tertiary": "oklch(0.62 0.020 200)", "text-inverse": "oklch(0.98 0.022 95)", "accent-hover": "oklch(0.45 0.22 250)", "accent-fg": "oklch(0.98 0.022 95)", "accent-muted": "oklch(0.52 0.22 250 / 15%)", + "accent-bg": "oklch(0.52 0.22 250 / 8%)", "hover-bg": "oklch(0.93 0.018 95 / 70%)", "hover-bg-strong": "oklch(0.89 0.018 95)", "input-bg": "oklch(0.99 0.010 95)", @@ -887,6 +906,7 @@ const PRESETS: ThemePreset[] = [ "surface-sidebar": "oklch(0.18 0.040 200)", "surface-ground": "oklch(0.22 0.042 200)", "surface-elevated": "oklch(0.26 0.035 200)", + "surface-secondary": "oklch(0.29 0.035 200)", "surface-overlay": "oklch(0.14 0.040 200 / 95%)", "border-subtle": "oklch(1 0 0 / 5%)", "border-default": "oklch(1 0 0 / 10%)", @@ -894,10 +914,12 @@ const PRESETS: ThemePreset[] = [ "text-primary": "oklch(0.78 0.025 200)", "text-secondary": "oklch(0.65 0.020 200)", "text-muted": "oklch(0.55 0.018 200)", + "text-tertiary": "oklch(0.50 0.018 200)", "text-inverse": "oklch(0.22 0.042 200)", "accent-hover": "oklch(0.60 0.22 250)", "accent-fg": "oklch(0.22 0.042 200)", "accent-muted": "oklch(0.55 0.22 250 / 20%)", + "accent-bg": "oklch(0.55 0.22 250 / 8%)", "hover-bg": "oklch(1 0 0 / 5%)", "hover-bg-strong": "oklch(1 0 0 / 10%)", "input-bg": "oklch(0.20 0.040 200)", @@ -955,6 +977,7 @@ const PRESETS: ThemePreset[] = [ "surface-sidebar": "oklch(0.15 0.012 110)", "surface-ground": "oklch(0.18 0.012 110)", "surface-elevated": "oklch(0.22 0.010 110)", + "surface-secondary": "oklch(0.20 0.010 110)", "surface-overlay": "oklch(0.10 0.012 110 / 95%)", "border-subtle": "oklch(1 0 0 / 5%)", "border-default": "oklch(1 0 0 / 10%)", @@ -962,10 +985,12 @@ const PRESETS: ThemePreset[] = [ "text-primary": "oklch(0.97 0.008 95)", "text-secondary": "oklch(0.82 0.014 110)", "text-muted": "oklch(0.64 0.018 110)", + "text-tertiary": "oklch(0.56 0.016 110)", "text-inverse": "oklch(0.12 0.012 110)", "accent-hover": "oklch(0.84 0.21 140)", "accent-fg": "oklch(0.08 0.012 110)", "accent-muted": "oklch(0.78 0.20 140 / 18%)", + "accent-bg": "oklch(0.78 0.20 140 / 8%)", "hover-bg": "oklch(1 0 0 / 5%)", "hover-bg-strong": "oklch(1 0 0 / 10%)", "input-bg": "oklch(0.16 0.012 110)", @@ -1112,6 +1137,7 @@ const PRESETS: ThemePreset[] = [ "surface-sidebar": "oklch(0.13 0.03 30)", "surface-ground": "oklch(0.15 0.03 30)", "surface-elevated": "oklch(0.20 0.03 30)", + "surface-secondary": "oklch(0.18 0.03 30)", "surface-overlay": "oklch(0.10 0.03 30 / 95%)", // Semantic borders "border-subtle": "oklch(0.30 0.02 30 / 30%)", @@ -1121,11 +1147,13 @@ const PRESETS: ThemePreset[] = [ "text-primary": "oklch(0.95 0.01 80)", "text-secondary": "oklch(0.80 0.015 80)", "text-muted": "oklch(0.65 0.02 30)", + "text-tertiary": "oklch(0.58 0.018 30)", "text-inverse": "oklch(0.15 0.03 30)", // Accent variants "accent-hover": "oklch(0.77 0.22 49)", "accent-fg": "oklch(0.15 0.03 30)", "accent-muted": "oklch(0.72 0.22 49 / 20%)", + "accent-bg": "oklch(0.72 0.22 49 / 8%)", // Hover overlays "hover-bg": "oklch(1 0 0 / 5%)", "hover-bg-strong": "oklch(1 0 0 / 10%)", @@ -1162,4 +1190,4 @@ export function getPreset(id: string): ThemePreset | undefined { export function getAllPresets(): readonly ThemePreset[] { return PRESETS; -} \ No newline at end of file +} diff --git a/src/index.css b/src/index.css index b498ef4..808860a 100644 --- a/src/index.css +++ b/src/index.css @@ -9,123 +9,121 @@ @plugin "@tailwindcss/typography"; body { - font-family: Inter, - system-ui, - sans-serif; + font-family: var(--font-sans); } @custom-variant dark (&:is(.dark *)); @theme inline { - --font-heading: var(--font-sans); - --font-sans: 'Geist Variable', sans-serif; - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --color-foreground: var(--foreground); - --color-background: var(--background); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); + --font-heading: var(--font-sans); + --font-sans: "Geist Variable", sans-serif; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); } :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); } /* ───────────────────────────────────────────── @@ -134,211 +132,246 @@ body { to OKLCH — auto light/dark via .dark class ───────────────────────────────────────────── */ :root { - /* Surfaces (layer hierarchy, 3 layers only) */ - --surface-rail: oklch(0.98 0 0); - --surface-sidebar: oklch(0.97 0 0); - --surface-ground: oklch(1 0 0); - --surface-elevated: oklch(1 0 0); - --surface-overlay: oklch(1 0 0 / 90%); + /* Surfaces (layer hierarchy, 3 layers only) */ + --surface-rail: oklch(0.98 0 0); + --surface-sidebar: oklch(0.97 0 0); + --surface-ground: oklch(1 0 0); + --surface-elevated: oklch(1 0 0); + --surface-secondary: color-mix( + in oklch, + var(--surface-elevated) 72%, + var(--surface-ground) + ); + --surface-overlay: oklch(1 0 0 / 90%); - /* Borders */ - --border-subtle: oklch(0.90 0 0 / 40%); - --border-default: oklch(0.88 0 0); - --border-strong: oklch(0.80 0 0); + /* Borders */ + --border-subtle: oklch(0.9 0 0 / 40%); + --border-default: oklch(0.88 0 0); + --border-strong: oklch(0.8 0 0); - /* Text */ - --text-primary: oklch(0.13 0 0); - --text-secondary: oklch(0.40 0 0); - --text-muted: oklch(0.55 0 0); - --text-inverse: oklch(0.985 0 0); - --text-tertiary: oklch(0.70 0 0); + /* Text */ + --text-primary: oklch(0.13 0 0); + --text-secondary: oklch(0.4 0 0); + --text-muted: oklch(0.55 0 0); + --text-inverse: oklch(0.985 0 0); + --text-tertiary: oklch(0.7 0 0); - /* Brand accent — Discord blurple as reference */ - --accent: oklch(0.25 0 0); - --accent-hover: oklch(0.15 0 0); - --accent-fg: oklch(0.985 0 0); - --accent-muted: oklch(0.25 0 0 / 15%); - --accent-bg: oklch(0.25 0 0 / 8%); - --accent-rgb: 88, 101, 242; + /* Brand accent — Discord blurple as reference */ + --accent: oklch(0.25 0 0); + --accent-hover: oklch(0.15 0 0); + --accent-fg: oklch(0.985 0 0); + --accent-muted: oklch(0.25 0 0 / 15%); + --accent-bg: oklch(0.25 0 0 / 8%); + --accent-rgb: 88, 101, 242; - /* Status */ - --status-online: oklch(0.55 0 0); - --status-idle: oklch(0.60 0 0); - --status-dnd: oklch(0.50 0 0); - --status-offline: oklch(0.60 0 0); + /* Status */ + --status-online: oklch(0.55 0 0); + --status-idle: oklch(0.6 0 0); + --status-dnd: oklch(0.5 0 0); + --status-offline: oklch(0.6 0 0); - /* Semantic */ - --success: oklch(0.50 0 0); - --success-alpha10: oklch(0.50 0 0 / 10%); - --warning: oklch(0.60 0 0); - --warning-alpha10: oklch(0.60 0 0 / 10%); - --destructive: oklch(0.45 0 0); - --destructive-alpha10: oklch(0.45 0 0 / 10%); - --info: oklch(0.50 0 0); + /* Semantic */ + --success: oklch(0.5 0 0); + --success-alpha10: oklch(0.5 0 0 / 10%); + --warning: oklch(0.6 0 0); + --warning-alpha10: oklch(0.6 0 0 / 10%); + --destructive: oklch(0.45 0 0); + --destructive-alpha10: oklch(0.45 0 0 / 10%); + --info: oklch(0.5 0 0); - /* Role colors (from Discord) */ - --role-red: oklch(0.50 0 0); - --role-orange: oklch(0.55 0 0); - --role-yellow: oklch(0.60 0 0); - --role-green: oklch(0.50 0 0); - --role-blue: oklch(0.55 0 0); - --role-purple: oklch(0.55 0 0); - --role-pink: oklch(0.55 0 0); - --role-gray: oklch(0.50 0 0); + /* Role colors (from Discord) */ + --role-red: oklch(0.5 0 0); + --role-orange: oklch(0.55 0 0); + --role-yellow: oklch(0.6 0 0); + --role-green: oklch(0.5 0 0); + --role-blue: oklch(0.55 0 0); + --role-purple: oklch(0.55 0 0); + --role-pink: oklch(0.55 0 0); + --role-gray: oklch(0.5 0 0); - /* Interactive */ - --interactive: oklch(0.97 0 0); - --interactive-hover: oklch(0.92 0 0); - --interactive-active: oklch(0.88 0 0); + /* Interactive */ + --interactive: oklch(0.97 0 0); + --interactive-hover: oklch(0.92 0 0); + --interactive-active: oklch(0.88 0 0); - /* Hover */ - --hover-bg: oklch(0.95 0 0 / 70%); - --hover-bg-strong: oklch(0.90 0 0); + /* Hover */ + --hover-bg: oklch(0.95 0 0 / 70%); + --hover-bg-strong: oklch(0.9 0 0); - /* Input */ - --input-bg: oklch(0.98 0 0); - --input-placeholder: oklch(0.50 0 0); - --input-ring: oklch(0.30 0 0); + /* Input */ + --input-bg: oklch(0.98 0 0); + --input-placeholder: oklch(0.5 0 0); + --input-ring: oklch(0.3 0 0); - /* Heatmap (contribution graph) */ - --heatmap-0: oklch(0.93 0 0); - --heatmap-1: oklch(0.75 0.10 155); - --heatmap-2: oklch(0.62 0.15 155); - --heatmap-3: oklch(0.50 0.15 155); - --heatmap-4: oklch(0.38 0.15 155); + /* Heatmap (contribution graph) */ + --heatmap-0: oklch(0.93 0 0); + --heatmap-1: oklch(0.75 0.1 155); + --heatmap-2: oklch(0.62 0.15 155); + --heatmap-3: oklch(0.5 0.15 155); + --heatmap-4: oklch(0.38 0.15 155); } .dark { - --surface-rail: oklch(0.12 0 0); - --surface-sidebar: oklch(0.15 0 0); - --surface-ground: oklch(0.13 0 0); - --surface-elevated: oklch(0.18 0 0); - --surface-overlay: oklch(0.10 0 0 / 95%); + --surface-rail: oklch(0.12 0 0); + --surface-sidebar: oklch(0.15 0 0); + --surface-ground: oklch(0.13 0 0); + --surface-elevated: oklch(0.18 0 0); + --surface-secondary: color-mix( + in oklch, + var(--surface-elevated) 72%, + var(--surface-ground) + ); + --surface-overlay: oklch(0.1 0 0 / 95%); - --border-subtle: oklch(0.30 0 0 / 30%); - --border-default: oklch(0.35 0 0); - --border-strong: oklch(0.50 0 0); + --border-subtle: oklch(0.3 0 0 / 30%); + --border-default: oklch(0.35 0 0); + --border-strong: oklch(0.5 0 0); - --text-primary: oklch(0.97 0 0); - --text-secondary: oklch(0.80 0 0); - --text-muted: oklch(0.65 0 0); - --text-inverse: oklch(0.13 0 0); - --text-tertiary: oklch(0.55 0 0); + --text-primary: oklch(0.97 0 0); + --text-secondary: oklch(0.8 0 0); + --text-muted: oklch(0.65 0 0); + --text-inverse: oklch(0.13 0 0); + --text-tertiary: oklch(0.55 0 0); - --accent: oklch(0.70 0.15 264); - --accent-hover: oklch(0.78 0.15 264); - --accent-fg: oklch(0.10 0 0); - --accent-muted: oklch(0.70 0.15 264 / 20%); - --accent-bg: oklch(0.70 0.15 264 / 12%); - --accent-rgb: 88, 101, 242; + --accent: oklch(0.7 0.15 264); + --accent-hover: oklch(0.78 0.15 264); + --accent-fg: oklch(0.1 0 0); + --accent-muted: oklch(0.7 0.15 264 / 20%); + --accent-bg: oklch(0.7 0.15 264 / 12%); + --accent-rgb: 88, 101, 242; - --status-online: oklch(0.72 0.17 155); - --status-idle: oklch(0.78 0.15 80); - --status-dnd: oklch(0.65 0.20 25); - --status-offline: oklch(0.55 0 0); + --status-online: oklch(0.72 0.17 155); + --status-idle: oklch(0.78 0.15 80); + --status-dnd: oklch(0.65 0.2 25); + --status-offline: oklch(0.55 0 0); - --success: oklch(0.72 0.17 155); - --success-alpha10: oklch(0.72 0.17 155 / 10%); - --warning: oklch(0.78 0.15 90); - --warning-alpha10: oklch(0.78 0.15 90 / 10%); - --destructive: oklch(0.70 0.20 25); - --destructive-alpha10: oklch(0.70 0.20 25 / 10%); - --info: oklch(0.70 0.15 250); + --success: oklch(0.72 0.17 155); + --success-alpha10: oklch(0.72 0.17 155 / 10%); + --warning: oklch(0.78 0.15 90); + --warning-alpha10: oklch(0.78 0.15 90 / 10%); + --destructive: oklch(0.7 0.2 25); + --destructive-alpha10: oklch(0.7 0.2 25 / 10%); + --info: oklch(0.7 0.15 250); - --role-red: oklch(0.65 0.20 20); - --role-orange: oklch(0.72 0.18 50); - --role-yellow: oklch(0.78 0.16 85); - --role-green: oklch(0.70 0.17 155); - --role-blue: oklch(0.70 0.20 250); - --role-purple: oklch(0.65 0.20 290); - --role-pink: oklch(0.65 0.20 340); - --role-gray: oklch(0.58 0 0); + --role-red: oklch(0.65 0.2 20); + --role-orange: oklch(0.72 0.18 50); + --role-yellow: oklch(0.78 0.16 85); + --role-green: oklch(0.7 0.17 155); + --role-blue: oklch(0.7 0.2 250); + --role-purple: oklch(0.65 0.2 290); + --role-pink: oklch(0.65 0.2 340); + --role-gray: oklch(0.58 0 0); - --interactive: oklch(0.18 0 0); - --interactive-hover: oklch(0.25 0 0); - --interactive-active: oklch(0.32 0 0); + --interactive: oklch(0.18 0 0); + --interactive-hover: oklch(0.25 0 0); + --interactive-active: oklch(0.32 0 0); - --hover-bg: oklch(0.22 0 0 / 60%); - --hover-bg-strong: oklch(0.28 0 0); + --hover-bg: oklch(0.22 0 0 / 60%); + --hover-bg-strong: oklch(0.28 0 0); - --input-bg: oklch(0.15 0 0); - --input-placeholder: oklch(0.55 0 0); - --input-ring: oklch(0.70 0.15 264); + --input-bg: oklch(0.15 0 0); + --input-placeholder: oklch(0.55 0 0); + --input-ring: oklch(0.7 0.15 264); - /* Heatmap (contribution graph) */ - --heatmap-0: oklch(0.20 0 0); - --heatmap-1: oklch(0.30 0.08 155); - --heatmap-2: oklch(0.45 0.12 155); - --heatmap-3: oklch(0.60 0.15 155); - --heatmap-4: oklch(0.75 0.15 155); + /* Heatmap (contribution graph) */ + --heatmap-0: oklch(0.2 0 0); + --heatmap-1: oklch(0.3 0.08 155); + --heatmap-2: oklch(0.45 0.12 155); + --heatmap-3: oklch(0.6 0.15 155); + --heatmap-4: oklch(0.75 0.15 155); } @layer base { - * { - @apply border-border outline-ring/50; - } + * { + @apply border-border outline-ring/50; + } - body { - @apply bg-background text-foreground; - } + body { + @apply bg-background text-foreground antialiased; + min-height: 100vh; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + background-attachment: fixed; + background-image: + radial-gradient( + circle at top left, + color-mix(in oklch, var(--accent) 7%, transparent) 0, + transparent 28% + ), + radial-gradient( + circle at top right, + color-mix(in oklch, var(--accent) 5%, transparent) 0, + transparent 24% + ), + linear-gradient( + to bottom, + color-mix(in oklch, var(--surface-ground) 92%, var(--accent) 8%), + var(--surface-ground) + ); + } - html { - @apply font-sans; - } + html { + @apply scroll-smooth font-sans; + } + + ::selection { + background: color-mix(in oklch, var(--accent) 22%, transparent); + color: var(--foreground); + } } /* ─── Settings Modal open/close animation ─── */ .settings-dialog[data-state="open"] { - animation: settings-modal-open 0.2s cubic-bezier(0.4, 0, 0.2, 1) both; + animation: settings-modal-open 0.2s cubic-bezier(0.4, 0, 0.2, 1) both; } .settings-dialog[data-state="closed"] { - animation: settings-modal-close 0.15s cubic-bezier(0.4, 0, 0.2, 1) both; + animation: settings-modal-close 0.15s cubic-bezier(0.4, 0, 0.2, 1) both; } @keyframes settings-modal-open { - from { - opacity: 0; - } - to { - opacity: 1; - } + from { + opacity: 0; + } + to { + opacity: 1; + } } @keyframes settings-modal-close { - from { - opacity: 1; - } - to { - opacity: 0; - } + from { + opacity: 1; + } + to { + opacity: 0; + } } [data-slot="dialog-overlay"][data-state="open"] { - animation: settings-overlay-open 0.2s cubic-bezier(0.4, 0, 0.2, 1) both; + animation: settings-overlay-open 0.2s cubic-bezier(0.4, 0, 0.2, 1) both; } [data-slot="dialog-overlay"][data-state="closed"] { - animation: settings-overlay-close 0.15s cubic-bezier(0.4, 0, 0.2, 1) both; + animation: settings-overlay-close 0.15s cubic-bezier(0.4, 0, 0.2, 1) both; } @keyframes settings-overlay-open { - from { - opacity: 0; - } - to { - opacity: 1; - } + from { + opacity: 0; + } + to { + opacity: 1; + } } @keyframes settings-overlay-close { - from { - opacity: 1; - } - to { - opacity: 0; - } + from { + opacity: 1; + } + to { + opacity: 0; + } } /* ───────────────────────────────────────────── @@ -347,81 +380,82 @@ body { dark mode text never falls back to defaults ───────────────────────────────────────────── */ :root .prose { - --tw-prose-body: var(--text-primary); - --tw-prose-headings: var(--text-primary); - --tw-prose-lead: var(--text-secondary); - --tw-prose-links: var(--accent); - --tw-prose-bold: var(--text-primary); - --tw-prose-counters: var(--text-secondary); - --tw-prose-bullets: var(--text-muted); - --tw-prose-hr: var(--border-default); - --tw-prose-quotes: var(--text-secondary); - --tw-prose-quote-borders: var(--border-default); - --tw-prose-captions: var(--text-muted); - --tw-prose-kbd: var(--text-primary); - --tw-prose-kbd-shadows: oklch(0.13 0 0 / 10%); - --tw-prose-code: var(--accent); - --tw-prose-pre-code: var(--text-primary); - --tw-prose-pre-bg: var(--surface-elevated); - --tw-prose-th-borders: var(--border-default); - --tw-prose-td-borders: var(--border-subtle); + --tw-prose-body: var(--text-primary); + --tw-prose-headings: var(--text-primary); + --tw-prose-lead: var(--text-secondary); + --tw-prose-links: var(--accent); + --tw-prose-bold: var(--text-primary); + --tw-prose-counters: var(--text-secondary); + --tw-prose-bullets: var(--text-muted); + --tw-prose-hr: var(--border-default); + --tw-prose-quotes: var(--text-secondary); + --tw-prose-quote-borders: var(--border-default); + --tw-prose-captions: var(--text-muted); + --tw-prose-kbd: var(--text-primary); + --tw-prose-kbd-shadows: oklch(0.13 0 0 / 10%); + --tw-prose-code: var(--accent); + --tw-prose-pre-code: var(--text-primary); + --tw-prose-pre-bg: var(--surface-elevated); + --tw-prose-th-borders: var(--border-default); + --tw-prose-td-borders: var(--border-subtle); } .dark .prose { - --tw-prose-body: var(--text-primary); - --tw-prose-headings: var(--text-primary); - --tw-prose-lead: var(--text-secondary); - --tw-prose-links: var(--accent); - --tw-prose-bold: var(--text-primary); - --tw-prose-counters: var(--text-secondary); - --tw-prose-bullets: var(--text-muted); - --tw-prose-hr: var(--border-default); - --tw-prose-quotes: var(--text-secondary); - --tw-prose-quote-borders: var(--border-default); - --tw-prose-captions: var(--text-muted); - --tw-prose-kbd: var(--text-primary); - --tw-prose-kbd-shadows: oklch(0.97 0 0 / 10%); - --tw-prose-code: var(--accent); - --tw-prose-pre-code: var(--text-primary); - --tw-prose-pre-bg: var(--surface-elevated); - --tw-prose-th-borders: var(--border-default); - --tw-prose-td-borders: var(--border-subtle); + --tw-prose-body: var(--text-primary); + --tw-prose-headings: var(--text-primary); + --tw-prose-lead: var(--text-secondary); + --tw-prose-links: var(--accent); + --tw-prose-bold: var(--text-primary); + --tw-prose-counters: var(--text-secondary); + --tw-prose-bullets: var(--text-muted); + --tw-prose-hr: var(--border-default); + --tw-prose-quotes: var(--text-secondary); + --tw-prose-quote-borders: var(--border-default); + --tw-prose-captions: var(--text-muted); + --tw-prose-kbd: var(--text-primary); + --tw-prose-kbd-shadows: oklch(0.97 0 0 / 10%); + --tw-prose-code: var(--accent); + --tw-prose-pre-code: var(--text-primary); + --tw-prose-pre-bg: var(--surface-elevated); + --tw-prose-th-borders: var(--border-default); + --tw-prose-td-borders: var(--border-subtle); } .app-scrollbar { - scrollbar-width: thin; - scrollbar-color: color-mix(in oklch, var(--text-muted) 34%, transparent) transparent; - scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: color-mix(in oklch, var(--text-muted) 34%, transparent) + transparent; + scrollbar-gutter: stable; } .app-scrollbar::-webkit-scrollbar { - width: 10px; - height: 10px; + width: 10px; + height: 10px; } .app-scrollbar::-webkit-scrollbar-track { - background: transparent; + background: transparent; } .app-scrollbar::-webkit-scrollbar-thumb { - background: color-mix(in oklch, var(--text-muted) 28%, transparent); - border: 3px solid transparent; - border-radius: 999px; - background-clip: padding-box; + background: color-mix(in oklch, var(--text-muted) 28%, transparent); + border: 3px solid transparent; + border-radius: 999px; + background-clip: padding-box; } .app-scrollbar:hover::-webkit-scrollbar-thumb { - background: color-mix(in oklch, var(--text-secondary) 42%, transparent); - border: 2px solid transparent; - background-clip: padding-box; + background: color-mix(in oklch, var(--text-secondary) 42%, transparent); + border: 2px solid transparent; + background-clip: padding-box; } .app-scrollbar[data-scrollbar="room"]::-webkit-scrollbar { - width: 12px; + width: 12px; } .pre_nobackground { - pre { - background-color: transparent; - } -} \ No newline at end of file + pre { + background-color: transparent; + } +} diff --git a/src/lib/color.ts b/src/lib/color.ts new file mode 100644 index 0000000..d8b7829 --- /dev/null +++ b/src/lib/color.ts @@ -0,0 +1,13 @@ +export const AVATAR_COLORS = [ + "#6366f1", "#8b5cf6", "#d946ef", "#ec4899", "#f43f5e", + "#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", + "#06b6d4", "#3b82f6", "#2563eb", "#7c3aed", "#c026d3", +]; + +export function hashColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]; +} \ No newline at end of file diff --git a/src/lib/theme-vars.ts b/src/lib/theme-vars.ts new file mode 100644 index 0000000..1fb135c --- /dev/null +++ b/src/lib/theme-vars.ts @@ -0,0 +1,79 @@ +export interface ThemeVarMap { + [key: string]: string +} + +export const CUSTOM_THEME_VARS_STORAGE_KEY = "custom-theme-vars" + +function getRoot() { + if (typeof document === "undefined") { + return null + } + + return document.documentElement +} + +function readStoredThemeVars(): ThemeVarMap { + if (typeof window === "undefined") { + return {} + } + + const stored = localStorage.getItem(CUSTOM_THEME_VARS_STORAGE_KEY) + if (!stored) { + return {} + } + + try { + return JSON.parse(stored) as ThemeVarMap + } catch { + console.error("Failed to parse stored theme vars") + return {} + } +} + +export function applyThemeVars(vars: ThemeVarMap) { + const root = getRoot() + if (!root) { + return + } + + Object.entries(vars).forEach(([key, value]) => { + root.style.setProperty(`--${key}`, value) + }) +} + +export function removeThemeVars(keys: Iterable) { + const root = getRoot() + if (!root) { + return + } + + for (const key of keys) { + root.style.removeProperty(`--${key}`) + } +} + +export function loadThemeVars() { + const vars = readStoredThemeVars() + applyThemeVars(vars) + return vars +} + +export function resetAllThemeVars(vars?: ThemeVarMap) { + if (typeof window !== "undefined") { + localStorage.removeItem(CUSTOM_THEME_VARS_STORAGE_KEY) + } + + removeThemeVars(Object.keys(vars ?? readStoredThemeVars())) +} + +export function saveThemeVars(vars: ThemeVarMap) { + if (typeof window !== "undefined") { + localStorage.setItem(CUSTOM_THEME_VARS_STORAGE_KEY, JSON.stringify(vars)) + } + + applyThemeVars(vars) +} + +export function getStoredThemeVars() { + return readStoredThemeVars() +}