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.
This commit is contained in:
ZhenYi 2026-05-18 20:43:42 +08:00
parent 3df7ae78c9
commit f77955074e
7 changed files with 715 additions and 545 deletions

View File

@ -2,6 +2,7 @@
import React from "react" import React from "react"
import { applyThemePreset } from "@/components/theme/ThemePresetSelector" import { applyThemePreset } from "@/components/theme/ThemePresetSelector"
import { loadThemeVars } from "@/lib/theme-vars"
type Theme = "dark" | "light" | "system" type Theme = "dark" | "light" | "system"
type ResolvedTheme = "dark" | "light" type ResolvedTheme = "dark" | "light"
@ -121,6 +122,8 @@ export function ThemeProvider({
applyThemePreset(storedPreset) applyThemePreset(storedPreset)
} }
loadThemeVars()
if (restoreTransitions) { if (restoreTransitions) {
restoreTransitions() restoreTransitions()
} }

View File

@ -1,164 +1,168 @@
import { useState, useEffect } from "react"; import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"
import { Loader2, RotateCcw, Check } from "lucide-react"; import { Input } from "@/components/ui/input"
import { THEME_VARIABLES, THEME_CATEGORIES, type ThemeVariable } from "@/config/theme-variables"; import { Separator } from "@/components/ui/separator"
import { t } from "@/i18n/T"; import { Loader2, RotateCcw, Check } from "lucide-react"
import {
const STORAGE_KEY = "custom-theme-vars"; THEME_VARIABLES,
THEME_CATEGORIES,
interface CustomTheme { type ThemeVariable,
[key: string]: string; } from "@/config/theme-variables"
} import { t } from "@/i18n/T"
import {
function applyThemeVars(vars: CustomTheme) { applyThemeVars,
const root = document.documentElement; getStoredThemeVars,
Object.entries(vars).forEach(([key, value]) => { removeThemeVars,
root.style.setProperty(`--${key}`, value); resetAllThemeVars,
}); saveThemeVars,
} type ThemeVarMap,
} from "@/lib/theme-vars"
export function useThemeCustomization() { export function useThemeCustomization() {
const [customVars, setCustomVars] = useState<CustomTheme>(() => { const [customVars, setCustomVars] = useState<ThemeVarMap>(() =>
const stored = localStorage.getItem(STORAGE_KEY); getStoredThemeVars()
if (stored) { )
try { const [hasChanges, setHasChanges] = useState(false)
return JSON.parse(stored) as CustomTheme;
} catch {
console.error("Failed to parse stored theme vars");
return {};
}
}
return {};
});
const [hasChanges, setHasChanges] = useState(false);
useEffect(() => { useEffect(() => {
applyThemeVars(customVars); applyThemeVars(customVars)
}, [customVars]); }, [customVars])
const updateVar = (key: string, value: string) => { const updateVar = (key: string, value: string) => {
setCustomVars((prev) => { setCustomVars((prev) => {
const next = { ...prev, [key]: value }; const next = { ...prev, [key]: value }
setHasChanges(true); setHasChanges(true)
return next; return next
}); })
}; }
const resetVar = (key: string) => { const resetVar = (key: string) => {
setCustomVars((prev) => { setCustomVars((prev) => {
const next = { ...prev }; const next = { ...prev }
delete next[key]; delete next[key]
document.documentElement.style.removeProperty(`--${key}`); removeThemeVars([key])
setHasChanges(true); setHasChanges(true)
return next; return next
}); })
}; }
const save = () => { const save = () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(customVars)); saveThemeVars(customVars)
setHasChanges(false); setHasChanges(false)
}; }
const resetAll = () => { const resetAll = () => {
setCustomVars({}); setCustomVars({})
localStorage.removeItem(STORAGE_KEY); resetAllThemeVars(customVars)
THEME_VARIABLES.forEach((v) => { setHasChanges(false)
document.documentElement.style.removeProperty(`--${v.key}`); }
});
setHasChanges(false);
};
return { customVars, updateVar, resetVar, save, resetAll, hasChanges }; return { customVars, updateVar, resetVar, save, resetAll, hasChanges }
} }
export function ThemeCustomization({ className }: { className?: string }) { export function ThemeCustomization({ className }: { className?: string }) {
const { customVars, updateVar, resetVar, save, resetAll, hasChanges } = useThemeCustomization(); const { customVars, updateVar, resetVar, save, resetAll, hasChanges } =
const [saving, setSaving] = useState(false); useThemeCustomization()
const [saved, setSaved] = useState(false); const [saving, setSaving] = useState(false)
const [activeCategory, setActiveCategory] = useState<string>("surfaces"); const [saved, setSaved] = useState(false)
const [activeCategory, setActiveCategory] = useState<string>("surfaces")
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true)
save(); save()
setSaved(true); setSaved(true)
setSaving(false); setSaving(false)
setTimeout(() => setSaved(false), 2000); setTimeout(() => setSaved(false), 2000)
}; }
const getValue = (v: ThemeVariable): string => { const getValue = (v: ThemeVariable): string => customVars[v.key] ?? ""
return customVars[v.key] ?? ""; const isModified = (key: string): boolean => key in customVars
};
const isModified = (key: string): boolean => key in customVars;
return ( return (
<div className={className}> <div className={className}>
{/* Category Tabs */} <div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2 mb-6"> {THEME_CATEGORIES.map((cat) => {
{THEME_CATEGORIES.map((cat) => ( const active = activeCategory === cat.key
return (
<button <button
key={cat.key} key={cat.key}
onClick={() => setActiveCategory(cat.key)} onClick={() => setActiveCategory(cat.key)}
className="px-3 py-1.5 rounded-md text-[13px] transition-all" className="rounded-full border px-3 py-1.5 text-[13px] transition-all"
style={{ style={{
backgroundColor: activeCategory === cat.key ? "var(--accent)" : "var(--surface-elevated)", backgroundColor: active
color: activeCategory === cat.key ? "var(--accent-fg)" : "var(--text-secondary)", ? "var(--accent)"
border: `1px solid ${activeCategory === cat.key ? "var(--accent)" : "var(--border-default)"}`, : "var(--surface-elevated)",
color: active ? "var(--accent-fg)" : "var(--text-secondary)",
border: `1px solid ${active ? "var(--accent)" : "var(--border-default)"}`,
}} }}
> >
{cat.label} {cat.label}
</button> </button>
))} )
})}
</div> </div>
{/* Variables Grid */} <Separator className="my-5" />
<div className="space-y-4">
{THEME_VARIABLES.filter((v) => v.category === activeCategory).map((variable) => ( <div className="grid gap-3">
{THEME_VARIABLES.filter((v) => v.category === activeCategory).map(
(variable) => (
<div <div
key={variable.key} key={variable.key}
className="flex items-center gap-4 p-3 rounded-lg" className="flex flex-col gap-4 rounded-2xl border p-4 lg:flex-row lg:items-center"
style={{ style={{
backgroundColor: "var(--surface-elevated)", backgroundColor: "var(--surface-elevated)",
border: `1px solid ${isModified(variable.key) ? "var(--accent)" : "var(--border-default)"}`, borderColor: isModified(variable.key)
? "var(--accent)"
: "var(--border-default)",
}} }}
> >
{/* Preview */}
<div <div
className="w-10 h-10 rounded-md shrink-0 border" className="size-10 shrink-0 rounded-xl border"
style={{ backgroundColor: variable.defaultLight }} style={{ backgroundColor: variable.defaultLight }}
title={variable.label} title={variable.label}
/> />
{/* Info */} <div className="min-w-0 flex-1">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[14px] font-medium" style={{ color: "var(--text-primary)" }}> <span
className="text-[14px] font-medium"
style={{ color: "var(--text-primary)" }}
>
{variable.label} {variable.label}
</span> </span>
{isModified(variable.key) && ( {isModified(variable.key) && (
<span className="text-[11px] px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--accent-muted)", color: "var(--accent)" }}> <Badge
variant="secondary"
className="rounded-full px-2 py-0.5 text-[11px]"
>
</span> </Badge>
)} )}
</div> </div>
<code className="text-[11px]" style={{ color: "var(--text-muted)" }}> <code
className="text-[11px]"
style={{ color: "var(--text-muted)" }}
>
--{variable.key} --{variable.key}
</code> </code>
{variable.description && ( {variable.description && (
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}> <p
className="mt-0.5 text-[12px]"
style={{ color: "var(--text-muted)" }}
>
{variable.description} {variable.description}
</p> </p>
)} )}
</div> </div>
{/* Input */} <div className="flex shrink-0 items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<Input <Input
value={getValue(variable)} value={getValue(variable)}
onChange={(e) => updateVar(variable.key, e.target.value)} onChange={(e) => updateVar(variable.key, e.target.value)}
placeholder={variable.defaultLight} placeholder={variable.defaultLight}
className="w-[200px] font-mono text-[13px]" className="w-full max-w-[220px] font-mono text-[13px]"
style={{ style={{
backgroundColor: "var(--input-bg)", backgroundColor: "var(--input-bg)",
borderColor: "var(--border-default)", borderColor: "var(--border-default)",
@ -168,29 +172,37 @@ export function ThemeCustomization({ className }: { className?: string }) {
{isModified(variable.key) && ( {isModified(variable.key) && (
<button <button
onClick={() => resetVar(variable.key)} onClick={() => resetVar(variable.key)}
className="p-1.5 rounded" className="rounded-full p-1.5 text-[var(--text-muted)] transition-colors hover:bg-[var(--hover-bg)] hover:text-[var(--text-primary)]"
title={t("settings.appearance_page.theme_customization.reset_default")} title={t(
style={{ color: "var(--text-muted)", backgroundColor: "transparent" }} "settings.appearance_page.theme_customization.reset_default"
)}
> >
<RotateCcw className="w-4 h-4" /> <RotateCcw className="size-4" />
</button> </button>
)} )}
</div> </div>
</div> </div>
))} )
)}
</div> </div>
{/* Actions */} <div className="mt-6 flex flex-wrap items-center gap-3 border-t border-[var(--border-subtle)] pt-4">
<div className="flex items-center gap-4 mt-6 pt-4" style={{ borderTop: "1px solid var(--border-subtle)" }}>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={saving || !hasChanges} disabled={saving || !hasChanges}
size="sm" size="sm"
style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }} style={{
backgroundColor: "var(--accent)",
color: "var(--accent-fg)",
}}
> >
{saving && <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" />} {saving && (
{saved ? <Check className="w-3.5 h-3.5 mr-2" /> : null} <Loader2 data-icon="inline-start" className="animate-spin" />
{saved ? t("settings.appearance_page.theme_customization.saved") : t("settings.appearance_page.theme_customization.save_theme")} )}
{saved ? <Check data-icon="inline-start" /> : null}
{saved
? t("settings.appearance_page.theme_customization.saved")
: t("settings.appearance_page.theme_customization.save_theme")}
</Button> </Button>
<Button <Button
onClick={resetAll} onClick={resetAll}
@ -205,26 +217,5 @@ export function ThemeCustomization({ className }: { className?: string }) {
</span> </span>
</div> </div>
</div> </div>
); )
}
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");
}
}
} }

View File

@ -1,8 +1,10 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react"
import { getAllPresets } from "@/config/theme-presets"; 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 STORAGE_KEY = "app-theme-preset"
const DEFAULT_PRESET = "soft-mono"; const DEFAULT_PRESET = "soft-mono"
const THEME_COLOR_ALIASES: Record<string, string> = { const THEME_COLOR_ALIASES: Record<string, string> = {
"color-background": "background", "color-background": "background",
@ -31,119 +33,139 @@ const THEME_COLOR_ALIASES: Record<string, string> = {
"color-sidebar-accent-foreground": "sidebar-accent-foreground", "color-sidebar-accent-foreground": "sidebar-accent-foreground",
"color-sidebar-border": "sidebar-border", "color-sidebar-border": "sidebar-border",
"color-sidebar-ring": "sidebar-ring", "color-sidebar-ring": "sidebar-ring",
}; }
const DERIVED_THEME_TOKENS: Record<string, string> = { const DERIVED_THEME_TOKENS: Record<string, string> = {
"surface-secondary":
"color-mix(in oklch, var(--surface-elevated) 72%, var(--surface-ground))",
"accent-bg": "color-mix(in oklch, var(--accent) 8%, transparent)", "accent-bg": "color-mix(in oklch, var(--accent) 8%, transparent)",
"success-alpha10": "color-mix(in oklch, var(--success) 10%, transparent)", "success-alpha10": "color-mix(in oklch, var(--success) 10%, transparent)",
"warning-alpha10": "color-mix(in oklch, var(--warning) 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)", "status-offline": "var(--text-muted)",
"text-tertiary": "color-mix(in oklch, var(--text-muted) 70%, var(--surface-ground))", "text-tertiary":
"heatmap-0": "color-mix(in oklch, var(--surface-ground) 82%, var(--border-default))", "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-1": "color-mix(in oklch, var(--success) 18%, var(--surface-ground))",
"heatmap-2": "color-mix(in oklch, var(--success) 36%, 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-3": "color-mix(in oklch, var(--success) 58%, var(--surface-ground))",
"heatmap-4": "color-mix(in oklch, var(--success) 78%, var(--surface-ground))", "heatmap-4": "color-mix(in oklch, var(--success) 78%, var(--surface-ground))",
}; }
export function useThemePreset() { export function useThemePreset() {
const [presetId, setPresetIdState] = useState<string>(() => { const [presetId, setPresetIdState] = useState<string>(
return localStorage.getItem(STORAGE_KEY) || DEFAULT_PRESET; () => localStorage.getItem(STORAGE_KEY) || DEFAULT_PRESET
}); )
const setPresetId = useCallback((id: string) => { const setPresetId = useCallback((id: string) => {
localStorage.setItem(STORAGE_KEY, id); localStorage.setItem(STORAGE_KEY, id)
setPresetIdState(id); setPresetIdState(id)
}, []); }, [])
useEffect(() => { useEffect(() => {
applyThemePreset(presetId); applyThemePreset(presetId)
}, [presetId]); loadThemeVars()
}, [presetId])
return { presetId, setPresetId }; return { presetId, setPresetId }
} }
export function applyThemePreset(presetId: string) { export function applyThemePreset(presetId: string) {
const presets = getAllPresets(); const presets = getAllPresets()
const preset = presets.find((p) => p.id === presetId); const preset = presets.find((p) => p.id === presetId)
if (!preset) return; if (!preset) return
const isDark = document.documentElement.classList.contains("dark"); const isDark = document.documentElement.classList.contains("dark")
const vars = isDark && preset.varsDark ? preset.varsDark : preset.vars; const vars = isDark && preset.varsDark ? preset.varsDark : preset.vars
const root = document.documentElement; const root = document.documentElement
Object.entries(vars).forEach(([key, value]) => { 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]) => { 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]) => { 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() { export function ThemePresetSelector() {
const { presetId, setPresetId } = useThemePreset(); const { presetId, setPresetId } = useThemePreset()
const presets = getAllPresets(); const presets = getAllPresets()
return ( return (
<div className="grid grid-cols-2 gap-3"> <div className="grid gap-3 md:grid-cols-2">
{presets.map((preset) => { {presets.map((preset) => {
const active = presetId === preset.id; const active = presetId === preset.id
return ( return (
<button <button
key={preset.id} key={preset.id}
onClick={() => setPresetId(preset.id)} onClick={() => setPresetId(preset.id)}
className="flex items-center gap-3 p-3 rounded-lg border-2 text-left transition-all" className="flex items-center gap-3 rounded-2xl border p-3 text-left transition-all"
style={{ style={{
borderColor: active ? "var(--accent)" : "var(--border-default)", borderColor: active ? "var(--accent)" : "var(--border-default)",
backgroundColor: active ? "var(--hover-bg-strong)" : "var(--surface-elevated)", backgroundColor: active
? "var(--hover-bg-strong)"
: "var(--surface-elevated)",
boxShadow: active
? "0 0 0 1px color-mix(in oklch, var(--accent) 18%, transparent)"
: "none",
}} }}
> >
{/* Preview swatches: primary (button), accent, bg */} <div className="flex shrink-0 items-stretch gap-0.5 self-stretch">
<div className="flex shrink-0 gap-0.5 items-stretch self-stretch">
<div <div
className="w-7 h-8 rounded-l-md flex items-center justify-center" className="flex w-7 items-center justify-center rounded-l-md"
style={{ backgroundColor: preset.preview.bg, border: "1px solid var(--border-subtle)" }} style={{
backgroundColor: preset.preview.bg,
border: "1px solid var(--border-subtle)",
}}
> >
<div <div
className="w-4 h-3 rounded-sm" className="size-4 rounded-sm"
style={{ backgroundColor: preset.preview.fg }} style={{ backgroundColor: preset.preview.fg }}
/> />
</div> </div>
<div <div
className="w-7 h-8 flex items-center justify-center" className="flex w-7 items-center justify-center"
style={{ backgroundColor: preset.preview.accent }} style={{ backgroundColor: preset.preview.accent }}
> >
<div <div
className="w-4 h-3 rounded-sm" className="size-4 rounded-sm"
style={{ backgroundColor: "rgba(255,255,255,0.85)" }} style={{ backgroundColor: "rgba(255,255,255,0.85)" }}
/> />
</div> </div>
</div> </div>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="text-[14px] font-medium" style={{ color: "var(--text-primary)" }}> <div
className="text-[14px] font-medium"
style={{ color: "var(--text-primary)" }}
>
{preset.name} {preset.name}
</div> </div>
<div className="text-[12px] truncate" style={{ color: "var(--text-muted)" }}> <div
className="truncate text-[12px]"
style={{ color: "var(--text-muted)" }}
>
{preset.description} {preset.description}
</div> </div>
</div> </div>
{active && ( {active && (
<div <Badge className="ml-auto rounded-full px-2 py-0.5 text-[11px]">
className="ml-auto w-2.5 h-2.5 rounded-full shrink-0" Selected
style={{ backgroundColor: "var(--accent)" }} </Badge>
/>
)} )}
</button> </button>
); )
})} })}
</div> </div>
); )
} }

View File

@ -30,13 +30,13 @@ export type ThemeVarKey =
| "sidebar-border" | "sidebar-ring" | "sidebar-border" | "sidebar-ring"
// Semantic surfaces // Semantic surfaces
| "surface-rail" | "surface-sidebar" | "surface-ground" | "surface-rail" | "surface-sidebar" | "surface-ground"
| "surface-elevated" | "surface-overlay" | "surface-elevated" | "surface-secondary" | "surface-overlay"
// Semantic borders // Semantic borders
| "border-subtle" | "border-default" | "border-strong" | "border-subtle" | "border-default" | "border-strong"
// Semantic text // Semantic text
| "text-primary" | "text-secondary" | "text-muted" | "text-inverse" | "text-primary" | "text-secondary" | "text-muted" | "text-tertiary" | "text-inverse"
// Accent variants // Accent variants
| "accent-hover" | "accent-fg" | "accent-muted" | "accent-hover" | "accent-fg" | "accent-muted" | "accent-bg"
// Status indicators // Status indicators
| "status-online" | "status-idle" | "status-dnd" | "status-offline" | "status-online" | "status-idle" | "status-dnd" | "status-offline"
// Feedback tokens // Feedback tokens
@ -111,6 +111,7 @@ const BASE_LIGHT = Object.freeze<ThemeVars>({
"surface-sidebar": "oklch(0.97 0 0)", "surface-sidebar": "oklch(0.97 0 0)",
"surface-ground": "oklch(1 0 0)", "surface-ground": "oklch(1 0 0)",
"surface-elevated": "oklch(1 0 0)", "surface-elevated": "oklch(1 0 0)",
"surface-secondary": "oklch(0.99 0 0)",
"surface-overlay": "oklch(1 0 0 / 90%)", "surface-overlay": "oklch(1 0 0 / 90%)",
// Semantic borders // Semantic borders
"border-subtle": "oklch(0.90 0 0 / 40%)", "border-subtle": "oklch(0.90 0 0 / 40%)",
@ -120,11 +121,13 @@ const BASE_LIGHT = Object.freeze<ThemeVars>({
"text-primary": "oklch(0.13 0 0)", "text-primary": "oklch(0.13 0 0)",
"text-secondary": "oklch(0.40 0 0)", "text-secondary": "oklch(0.40 0 0)",
"text-muted": "oklch(0.55 0 0)", "text-muted": "oklch(0.55 0 0)",
"text-tertiary": "oklch(0.70 0 0)",
"text-inverse": "oklch(0.985 0 0)", "text-inverse": "oklch(0.985 0 0)",
// Accent variants // Accent variants
"accent-hover": "oklch(0.15 0 0)", "accent-hover": "oklch(0.15 0 0)",
"accent-fg": "oklch(1 0 0)", "accent-fg": "oklch(1 0 0)",
"accent-muted": "oklch(0.13 0 0 / 12%)", "accent-muted": "oklch(0.13 0 0 / 12%)",
"accent-bg": "oklch(0.13 0 0 / 8%)",
// Status // Status
"status-online": "oklch(0.55 0.15 155)", "status-online": "oklch(0.55 0.15 155)",
"status-idle": "oklch(0.68 0.15 80)", "status-idle": "oklch(0.68 0.15 80)",
@ -204,6 +207,7 @@ const BASE_DARK = Object.freeze<ThemeVars>({
"surface-sidebar": "oklch(0.15 0 0)", "surface-sidebar": "oklch(0.15 0 0)",
"surface-ground": "oklch(0.13 0 0)", "surface-ground": "oklch(0.13 0 0)",
"surface-elevated": "oklch(0.18 0 0)", "surface-elevated": "oklch(0.18 0 0)",
"surface-secondary": "oklch(0.16 0 0)",
"surface-overlay": "oklch(0.10 0 0 / 95%)", "surface-overlay": "oklch(0.10 0 0 / 95%)",
// Semantic borders // Semantic borders
"border-subtle": "oklch(0.30 0 0 / 30%)", "border-subtle": "oklch(0.30 0 0 / 30%)",
@ -213,11 +217,13 @@ const BASE_DARK = Object.freeze<ThemeVars>({
"text-primary": "oklch(0.97 0 0)", "text-primary": "oklch(0.97 0 0)",
"text-secondary": "oklch(0.80 0 0)", "text-secondary": "oklch(0.80 0 0)",
"text-muted": "oklch(0.65 0 0)", "text-muted": "oklch(0.65 0 0)",
"text-tertiary": "oklch(0.55 0 0)",
"text-inverse": "oklch(0.13 0 0)", "text-inverse": "oklch(0.13 0 0)",
// Accent variants // Accent variants
"accent-hover": "oklch(0.78 0.15 264)", "accent-hover": "oklch(0.78 0.15 264)",
"accent-fg": "oklch(0.10 0 0)", "accent-fg": "oklch(0.10 0 0)",
"accent-muted": "oklch(0.70 0.15 264 / 20%)", "accent-muted": "oklch(0.70 0.15 264 / 20%)",
"accent-bg": "oklch(0.70 0.15 264 / 8%)",
// Status // Status
"status-online": "oklch(0.72 0.17 155)", "status-online": "oklch(0.72 0.17 155)",
"status-idle": "oklch(0.78 0.15 80)", "status-idle": "oklch(0.78 0.15 80)",
@ -270,7 +276,7 @@ const PRESETS: ThemePreset[] = [
id: "pure-bw", id: "pure-bw",
name: "纯黑纯白", name: "纯黑纯白",
description: "高对比度黑白主题", description: "高对比度黑白主题",
preview: {bg: "#FFFFFF", fg: "#1A1A1A", accent: "#000000"}, preview: {bg: "#FAFAFA", fg: "#111111", accent: "#111111"},
vars: light({ vars: light({
foreground: "oklch(0 0 0)", foreground: "oklch(0 0 0)",
"card-foreground": "oklch(0 0 0)", "card-foreground": "oklch(0 0 0)",
@ -296,16 +302,19 @@ const PRESETS: ThemePreset[] = [
"sidebar-ring": "oklch(0 0 0)", "sidebar-ring": "oklch(0 0 0)",
// Semantic // Semantic
"surface-overlay": "oklch(1 0 0 / 96%)", "surface-overlay": "oklch(1 0 0 / 96%)",
"surface-secondary": "oklch(0.96 0 0)",
"border-subtle": "oklch(0.75 0 0 / 40%)", "border-subtle": "oklch(0.75 0 0 / 40%)",
"border-default": "oklch(0.15 0 0)", "border-default": "oklch(0.15 0 0)",
"border-strong": "oklch(0 0 0)", "border-strong": "oklch(0 0 0)",
"text-primary": "oklch(0 0 0)", "text-primary": "oklch(0 0 0)",
"text-secondary": "oklch(0.20 0 0)", "text-secondary": "oklch(0.20 0 0)",
"text-muted": "oklch(0.40 0 0)", "text-muted": "oklch(0.40 0 0)",
"text-tertiary": "oklch(0.58 0 0)",
"text-inverse": "oklch(1 0 0)", "text-inverse": "oklch(1 0 0)",
"accent-hover": "oklch(0.10 0 0)", "accent-hover": "oklch(0.10 0 0)",
"accent-fg": "oklch(1 0 0)", "accent-fg": "oklch(1 0 0)",
"accent-muted": "oklch(0 0 0 / 12%)", "accent-muted": "oklch(0 0 0 / 12%)",
"accent-bg": "oklch(0 0 0 / 8%)",
"hover-bg": "oklch(0.92 0 0)", "hover-bg": "oklch(0.92 0 0)",
"hover-bg-strong": "oklch(0.84 0 0)", "hover-bg-strong": "oklch(0.84 0 0)",
"input-bg": "oklch(0.97 0 0)", "input-bg": "oklch(0.97 0 0)",
@ -352,15 +361,18 @@ const PRESETS: ThemePreset[] = [
"surface-sidebar": "oklch(0.05 0 0)", "surface-sidebar": "oklch(0.05 0 0)",
"surface-ground": "oklch(0 0 0)", "surface-ground": "oklch(0 0 0)",
"surface-elevated": "oklch(0.06 0 0)", "surface-elevated": "oklch(0.06 0 0)",
"surface-secondary": "oklch(0.08 0 0)",
"border-default": "oklch(1 0 0 / 15%)", "border-default": "oklch(1 0 0 / 15%)",
"border-strong": "oklch(1 0 0 / 30%)", "border-strong": "oklch(1 0 0 / 30%)",
"text-primary": "oklch(1 0 0)", "text-primary": "oklch(1 0 0)",
"text-secondary": "oklch(0.80 0 0)", "text-secondary": "oklch(0.80 0 0)",
"text-muted": "oklch(0.60 0 0)", "text-muted": "oklch(0.60 0 0)",
"text-tertiary": "oklch(0.48 0 0)",
"text-inverse": "oklch(0 0 0)", "text-inverse": "oklch(0 0 0)",
"accent-hover": "oklch(0.90 0 0)", "accent-hover": "oklch(0.90 0 0)",
"accent-fg": "oklch(0 0 0)", "accent-fg": "oklch(0 0 0)",
"accent-muted": "oklch(1 0 0 / 12%)", "accent-muted": "oklch(1 0 0 / 12%)",
"accent-bg": "oklch(1 0 0 / 6%)",
"hover-bg": "oklch(1 0 0 / 6%)", "hover-bg": "oklch(1 0 0 / 6%)",
"hover-bg-strong": "oklch(1 0 0 / 12%)", "hover-bg-strong": "oklch(1 0 0 / 12%)",
"input-bg": "oklch(0.03 0 0)", "input-bg": "oklch(0.03 0 0)",
@ -389,7 +401,7 @@ const PRESETS: ThemePreset[] = [
id: "soft-mono", id: "soft-mono",
name: "柔和灰度", name: "柔和灰度",
description: "层次分明的灰度主题,默认推荐", description: "层次分明的灰度主题,默认推荐",
preview: {bg: "#FFFFFF", fg: "#1A1A1A", accent: "#1A1A1A"}, preview: {bg: "#F7F7F6", fg: "#222222", accent: "#222222"},
vars: light(), vars: light(),
varsDark: dark(), varsDark: dark(),
}, },
@ -423,11 +435,13 @@ const PRESETS: ThemePreset[] = [
"surface-rail": "oklch(0.08 0 0)", "surface-rail": "oklch(0.08 0 0)",
"surface-sidebar": "oklch(0.10 0 0)", "surface-sidebar": "oklch(0.10 0 0)",
"surface-elevated": "oklch(0.16 0 0)", "surface-elevated": "oklch(0.16 0 0)",
"surface-secondary": "oklch(0.14 0 0)",
"border-default": "oklch(0.28 0 0)", "border-default": "oklch(0.28 0 0)",
"text-inverse": "oklch(0.10 0 0)", "text-inverse": "oklch(0.10 0 0)",
"accent-hover": "oklch(0.85 0 0)", "accent-hover": "oklch(0.85 0 0)",
"accent-fg": "oklch(0.08 0 0)", "accent-fg": "oklch(0.08 0 0)",
"accent-muted": "oklch(0.985 0 0 / 12%)", "accent-muted": "oklch(0.985 0 0 / 12%)",
"accent-bg": "oklch(0.985 0 0 / 6%)",
"input-bg": "oklch(0.15 0 0)", "input-bg": "oklch(0.15 0 0)",
"input-ring": "oklch(0.985 0 0)", "input-ring": "oklch(0.985 0 0)",
}), }),
@ -453,11 +467,13 @@ const PRESETS: ThemePreset[] = [
"surface-rail": "oklch(0.08 0 0)", "surface-rail": "oklch(0.08 0 0)",
"surface-sidebar": "oklch(0.10 0 0)", "surface-sidebar": "oklch(0.10 0 0)",
"surface-elevated": "oklch(0.16 0 0)", "surface-elevated": "oklch(0.16 0 0)",
"surface-secondary": "oklch(0.14 0 0)",
"border-default": "oklch(0.28 0 0)", "border-default": "oklch(0.28 0 0)",
"text-inverse": "oklch(0.10 0 0)", "text-inverse": "oklch(0.10 0 0)",
"accent-hover": "oklch(0.85 0 0)", "accent-hover": "oklch(0.85 0 0)",
"accent-fg": "oklch(0.08 0 0)", "accent-fg": "oklch(0.08 0 0)",
"accent-muted": "oklch(0.985 0 0 / 12%)", "accent-muted": "oklch(0.985 0 0 / 12%)",
"accent-bg": "oklch(0.985 0 0 / 6%)",
"input-bg": "oklch(0.15 0 0)", "input-bg": "oklch(0.15 0 0)",
"input-ring": "oklch(0.985 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-rail": "oklch(0.94 0.020 95)",
"surface-sidebar": "oklch(0.96 0.020 95)", "surface-sidebar": "oklch(0.96 0.020 95)",
"surface-ground": "oklch(0.98 0.022 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%)", "surface-overlay": "oklch(0.96 0.018 95 / 95%)",
"border-subtle": "oklch(0.88 0.018 95 / 50%)", "border-subtle": "oklch(0.88 0.018 95 / 50%)",
"border-default": "oklch(0.86 0.018 95)", "border-default": "oklch(0.86 0.018 95)",
@ -831,10 +848,12 @@ const PRESETS: ThemePreset[] = [
"text-primary": "oklch(0.47 0.035 205)", "text-primary": "oklch(0.47 0.035 205)",
"text-secondary": "oklch(0.57 0.028 205)", "text-secondary": "oklch(0.57 0.028 205)",
"text-muted": "oklch(0.65 0.018 200)", "text-muted": "oklch(0.65 0.018 200)",
"text-tertiary": "oklch(0.62 0.020 200)",
"text-inverse": "oklch(0.98 0.022 95)", "text-inverse": "oklch(0.98 0.022 95)",
"accent-hover": "oklch(0.45 0.22 250)", "accent-hover": "oklch(0.45 0.22 250)",
"accent-fg": "oklch(0.98 0.022 95)", "accent-fg": "oklch(0.98 0.022 95)",
"accent-muted": "oklch(0.52 0.22 250 / 15%)", "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": "oklch(0.93 0.018 95 / 70%)",
"hover-bg-strong": "oklch(0.89 0.018 95)", "hover-bg-strong": "oklch(0.89 0.018 95)",
"input-bg": "oklch(0.99 0.010 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-sidebar": "oklch(0.18 0.040 200)",
"surface-ground": "oklch(0.22 0.042 200)", "surface-ground": "oklch(0.22 0.042 200)",
"surface-elevated": "oklch(0.26 0.035 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%)", "surface-overlay": "oklch(0.14 0.040 200 / 95%)",
"border-subtle": "oklch(1 0 0 / 5%)", "border-subtle": "oklch(1 0 0 / 5%)",
"border-default": "oklch(1 0 0 / 10%)", "border-default": "oklch(1 0 0 / 10%)",
@ -894,10 +914,12 @@ const PRESETS: ThemePreset[] = [
"text-primary": "oklch(0.78 0.025 200)", "text-primary": "oklch(0.78 0.025 200)",
"text-secondary": "oklch(0.65 0.020 200)", "text-secondary": "oklch(0.65 0.020 200)",
"text-muted": "oklch(0.55 0.018 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)", "text-inverse": "oklch(0.22 0.042 200)",
"accent-hover": "oklch(0.60 0.22 250)", "accent-hover": "oklch(0.60 0.22 250)",
"accent-fg": "oklch(0.22 0.042 200)", "accent-fg": "oklch(0.22 0.042 200)",
"accent-muted": "oklch(0.55 0.22 250 / 20%)", "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": "oklch(1 0 0 / 5%)",
"hover-bg-strong": "oklch(1 0 0 / 10%)", "hover-bg-strong": "oklch(1 0 0 / 10%)",
"input-bg": "oklch(0.20 0.040 200)", "input-bg": "oklch(0.20 0.040 200)",
@ -955,6 +977,7 @@ const PRESETS: ThemePreset[] = [
"surface-sidebar": "oklch(0.15 0.012 110)", "surface-sidebar": "oklch(0.15 0.012 110)",
"surface-ground": "oklch(0.18 0.012 110)", "surface-ground": "oklch(0.18 0.012 110)",
"surface-elevated": "oklch(0.22 0.010 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%)", "surface-overlay": "oklch(0.10 0.012 110 / 95%)",
"border-subtle": "oklch(1 0 0 / 5%)", "border-subtle": "oklch(1 0 0 / 5%)",
"border-default": "oklch(1 0 0 / 10%)", "border-default": "oklch(1 0 0 / 10%)",
@ -962,10 +985,12 @@ const PRESETS: ThemePreset[] = [
"text-primary": "oklch(0.97 0.008 95)", "text-primary": "oklch(0.97 0.008 95)",
"text-secondary": "oklch(0.82 0.014 110)", "text-secondary": "oklch(0.82 0.014 110)",
"text-muted": "oklch(0.64 0.018 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)", "text-inverse": "oklch(0.12 0.012 110)",
"accent-hover": "oklch(0.84 0.21 140)", "accent-hover": "oklch(0.84 0.21 140)",
"accent-fg": "oklch(0.08 0.012 110)", "accent-fg": "oklch(0.08 0.012 110)",
"accent-muted": "oklch(0.78 0.20 140 / 18%)", "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": "oklch(1 0 0 / 5%)",
"hover-bg-strong": "oklch(1 0 0 / 10%)", "hover-bg-strong": "oklch(1 0 0 / 10%)",
"input-bg": "oklch(0.16 0.012 110)", "input-bg": "oklch(0.16 0.012 110)",
@ -1112,6 +1137,7 @@ const PRESETS: ThemePreset[] = [
"surface-sidebar": "oklch(0.13 0.03 30)", "surface-sidebar": "oklch(0.13 0.03 30)",
"surface-ground": "oklch(0.15 0.03 30)", "surface-ground": "oklch(0.15 0.03 30)",
"surface-elevated": "oklch(0.20 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%)", "surface-overlay": "oklch(0.10 0.03 30 / 95%)",
// Semantic borders // Semantic borders
"border-subtle": "oklch(0.30 0.02 30 / 30%)", "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-primary": "oklch(0.95 0.01 80)",
"text-secondary": "oklch(0.80 0.015 80)", "text-secondary": "oklch(0.80 0.015 80)",
"text-muted": "oklch(0.65 0.02 30)", "text-muted": "oklch(0.65 0.02 30)",
"text-tertiary": "oklch(0.58 0.018 30)",
"text-inverse": "oklch(0.15 0.03 30)", "text-inverse": "oklch(0.15 0.03 30)",
// Accent variants // Accent variants
"accent-hover": "oklch(0.77 0.22 49)", "accent-hover": "oklch(0.77 0.22 49)",
"accent-fg": "oklch(0.15 0.03 30)", "accent-fg": "oklch(0.15 0.03 30)",
"accent-muted": "oklch(0.72 0.22 49 / 20%)", "accent-muted": "oklch(0.72 0.22 49 / 20%)",
"accent-bg": "oklch(0.72 0.22 49 / 8%)",
// Hover overlays // Hover overlays
"hover-bg": "oklch(1 0 0 / 5%)", "hover-bg": "oklch(1 0 0 / 5%)",
"hover-bg-strong": "oklch(1 0 0 / 10%)", "hover-bg-strong": "oklch(1 0 0 / 10%)",

View File

@ -9,16 +9,14 @@
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
body { body {
font-family: Inter, font-family: var(--font-sans);
system-ui,
sans-serif;
} }
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--font-heading: var(--font-sans); --font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif; --font-sans: "Geist Variable", sans-serif;
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@ -139,19 +137,24 @@ body {
--surface-sidebar: oklch(0.97 0 0); --surface-sidebar: oklch(0.97 0 0);
--surface-ground: oklch(1 0 0); --surface-ground: oklch(1 0 0);
--surface-elevated: 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%); --surface-overlay: oklch(1 0 0 / 90%);
/* Borders */ /* Borders */
--border-subtle: oklch(0.90 0 0 / 40%); --border-subtle: oklch(0.9 0 0 / 40%);
--border-default: oklch(0.88 0 0); --border-default: oklch(0.88 0 0);
--border-strong: oklch(0.80 0 0); --border-strong: oklch(0.8 0 0);
/* Text */ /* Text */
--text-primary: oklch(0.13 0 0); --text-primary: oklch(0.13 0 0);
--text-secondary: oklch(0.40 0 0); --text-secondary: oklch(0.4 0 0);
--text-muted: oklch(0.55 0 0); --text-muted: oklch(0.55 0 0);
--text-inverse: oklch(0.985 0 0); --text-inverse: oklch(0.985 0 0);
--text-tertiary: oklch(0.70 0 0); --text-tertiary: oklch(0.7 0 0);
/* Brand accent — Discord blurple as reference */ /* Brand accent — Discord blurple as reference */
--accent: oklch(0.25 0 0); --accent: oklch(0.25 0 0);
@ -163,28 +166,28 @@ body {
/* Status */ /* Status */
--status-online: oklch(0.55 0 0); --status-online: oklch(0.55 0 0);
--status-idle: oklch(0.60 0 0); --status-idle: oklch(0.6 0 0);
--status-dnd: oklch(0.50 0 0); --status-dnd: oklch(0.5 0 0);
--status-offline: oklch(0.60 0 0); --status-offline: oklch(0.6 0 0);
/* Semantic */ /* Semantic */
--success: oklch(0.50 0 0); --success: oklch(0.5 0 0);
--success-alpha10: oklch(0.50 0 0 / 10%); --success-alpha10: oklch(0.5 0 0 / 10%);
--warning: oklch(0.60 0 0); --warning: oklch(0.6 0 0);
--warning-alpha10: oklch(0.60 0 0 / 10%); --warning-alpha10: oklch(0.6 0 0 / 10%);
--destructive: oklch(0.45 0 0); --destructive: oklch(0.45 0 0);
--destructive-alpha10: oklch(0.45 0 0 / 10%); --destructive-alpha10: oklch(0.45 0 0 / 10%);
--info: oklch(0.50 0 0); --info: oklch(0.5 0 0);
/* Role colors (from Discord) */ /* Role colors (from Discord) */
--role-red: oklch(0.50 0 0); --role-red: oklch(0.5 0 0);
--role-orange: oklch(0.55 0 0); --role-orange: oklch(0.55 0 0);
--role-yellow: oklch(0.60 0 0); --role-yellow: oklch(0.6 0 0);
--role-green: oklch(0.50 0 0); --role-green: oklch(0.5 0 0);
--role-blue: oklch(0.55 0 0); --role-blue: oklch(0.55 0 0);
--role-purple: oklch(0.55 0 0); --role-purple: oklch(0.55 0 0);
--role-pink: oklch(0.55 0 0); --role-pink: oklch(0.55 0 0);
--role-gray: oklch(0.50 0 0); --role-gray: oklch(0.5 0 0);
/* Interactive */ /* Interactive */
--interactive: oklch(0.97 0 0); --interactive: oklch(0.97 0 0);
@ -193,18 +196,18 @@ body {
/* Hover */ /* Hover */
--hover-bg: oklch(0.95 0 0 / 70%); --hover-bg: oklch(0.95 0 0 / 70%);
--hover-bg-strong: oklch(0.90 0 0); --hover-bg-strong: oklch(0.9 0 0);
/* Input */ /* Input */
--input-bg: oklch(0.98 0 0); --input-bg: oklch(0.98 0 0);
--input-placeholder: oklch(0.50 0 0); --input-placeholder: oklch(0.5 0 0);
--input-ring: oklch(0.30 0 0); --input-ring: oklch(0.3 0 0);
/* Heatmap (contribution graph) */ /* Heatmap (contribution graph) */
--heatmap-0: oklch(0.93 0 0); --heatmap-0: oklch(0.93 0 0);
--heatmap-1: oklch(0.75 0.10 155); --heatmap-1: oklch(0.75 0.1 155);
--heatmap-2: oklch(0.62 0.15 155); --heatmap-2: oklch(0.62 0.15 155);
--heatmap-3: oklch(0.50 0.15 155); --heatmap-3: oklch(0.5 0.15 155);
--heatmap-4: oklch(0.38 0.15 155); --heatmap-4: oklch(0.38 0.15 155);
} }
@ -213,45 +216,50 @@ body {
--surface-sidebar: oklch(0.15 0 0); --surface-sidebar: oklch(0.15 0 0);
--surface-ground: oklch(0.13 0 0); --surface-ground: oklch(0.13 0 0);
--surface-elevated: oklch(0.18 0 0); --surface-elevated: oklch(0.18 0 0);
--surface-overlay: oklch(0.10 0 0 / 95%); --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-subtle: oklch(0.3 0 0 / 30%);
--border-default: oklch(0.35 0 0); --border-default: oklch(0.35 0 0);
--border-strong: oklch(0.50 0 0); --border-strong: oklch(0.5 0 0);
--text-primary: oklch(0.97 0 0); --text-primary: oklch(0.97 0 0);
--text-secondary: oklch(0.80 0 0); --text-secondary: oklch(0.8 0 0);
--text-muted: oklch(0.65 0 0); --text-muted: oklch(0.65 0 0);
--text-inverse: oklch(0.13 0 0); --text-inverse: oklch(0.13 0 0);
--text-tertiary: oklch(0.55 0 0); --text-tertiary: oklch(0.55 0 0);
--accent: oklch(0.70 0.15 264); --accent: oklch(0.7 0.15 264);
--accent-hover: oklch(0.78 0.15 264); --accent-hover: oklch(0.78 0.15 264);
--accent-fg: oklch(0.10 0 0); --accent-fg: oklch(0.1 0 0);
--accent-muted: oklch(0.70 0.15 264 / 20%); --accent-muted: oklch(0.7 0.15 264 / 20%);
--accent-bg: oklch(0.70 0.15 264 / 12%); --accent-bg: oklch(0.7 0.15 264 / 12%);
--accent-rgb: 88, 101, 242; --accent-rgb: 88, 101, 242;
--status-online: oklch(0.72 0.17 155); --status-online: oklch(0.72 0.17 155);
--status-idle: oklch(0.78 0.15 80); --status-idle: oklch(0.78 0.15 80);
--status-dnd: oklch(0.65 0.20 25); --status-dnd: oklch(0.65 0.2 25);
--status-offline: oklch(0.55 0 0); --status-offline: oklch(0.55 0 0);
--success: oklch(0.72 0.17 155); --success: oklch(0.72 0.17 155);
--success-alpha10: oklch(0.72 0.17 155 / 10%); --success-alpha10: oklch(0.72 0.17 155 / 10%);
--warning: oklch(0.78 0.15 90); --warning: oklch(0.78 0.15 90);
--warning-alpha10: oklch(0.78 0.15 90 / 10%); --warning-alpha10: oklch(0.78 0.15 90 / 10%);
--destructive: oklch(0.70 0.20 25); --destructive: oklch(0.7 0.2 25);
--destructive-alpha10: oklch(0.70 0.20 25 / 10%); --destructive-alpha10: oklch(0.7 0.2 25 / 10%);
--info: oklch(0.70 0.15 250); --info: oklch(0.7 0.15 250);
--role-red: oklch(0.65 0.20 20); --role-red: oklch(0.65 0.2 20);
--role-orange: oklch(0.72 0.18 50); --role-orange: oklch(0.72 0.18 50);
--role-yellow: oklch(0.78 0.16 85); --role-yellow: oklch(0.78 0.16 85);
--role-green: oklch(0.70 0.17 155); --role-green: oklch(0.7 0.17 155);
--role-blue: oklch(0.70 0.20 250); --role-blue: oklch(0.7 0.2 250);
--role-purple: oklch(0.65 0.20 290); --role-purple: oklch(0.65 0.2 290);
--role-pink: oklch(0.65 0.20 340); --role-pink: oklch(0.65 0.2 340);
--role-gray: oklch(0.58 0 0); --role-gray: oklch(0.58 0 0);
--interactive: oklch(0.18 0 0); --interactive: oklch(0.18 0 0);
@ -263,13 +271,13 @@ body {
--input-bg: oklch(0.15 0 0); --input-bg: oklch(0.15 0 0);
--input-placeholder: oklch(0.55 0 0); --input-placeholder: oklch(0.55 0 0);
--input-ring: oklch(0.70 0.15 264); --input-ring: oklch(0.7 0.15 264);
/* Heatmap (contribution graph) */ /* Heatmap (contribution graph) */
--heatmap-0: oklch(0.20 0 0); --heatmap-0: oklch(0.2 0 0);
--heatmap-1: oklch(0.30 0.08 155); --heatmap-1: oklch(0.3 0.08 155);
--heatmap-2: oklch(0.45 0.12 155); --heatmap-2: oklch(0.45 0.12 155);
--heatmap-3: oklch(0.60 0.15 155); --heatmap-3: oklch(0.6 0.15 155);
--heatmap-4: oklch(0.75 0.15 155); --heatmap-4: oklch(0.75 0.15 155);
} }
@ -279,11 +287,36 @@ body {
} }
body { body {
@apply bg-background text-foreground; @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 { html {
@apply font-sans; @apply scroll-smooth font-sans;
}
::selection {
background: color-mix(in oklch, var(--accent) 22%, transparent);
color: var(--foreground);
} }
} }
@ -390,7 +423,8 @@ body {
.app-scrollbar { .app-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: color-mix(in oklch, var(--text-muted) 34%, transparent) transparent; scrollbar-color: color-mix(in oklch, var(--text-muted) 34%, transparent)
transparent;
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }

13
src/lib/color.ts Normal file
View File

@ -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];
}

79
src/lib/theme-vars.ts Normal file
View File

@ -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<string>) {
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()
}