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

View File

@ -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<CustomTheme>(() => {
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<ThemeVarMap>(() =>
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<string>("surfaces");
const { customVars, updateVar, resetVar, save, resetAll, hasChanges } =
useThemeCustomization()
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [activeCategory, setActiveCategory] = useState<string>("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 (
<div className={className}>
{/* Category Tabs */}
<div className="flex flex-wrap gap-2 mb-6">
{THEME_CATEGORIES.map((cat) => (
<button
key={cat.key}
onClick={() => setActiveCategory(cat.key)}
className="px-3 py-1.5 rounded-md text-[13px] transition-all"
style={{
backgroundColor: activeCategory === cat.key ? "var(--accent)" : "var(--surface-elevated)",
color: activeCategory === cat.key ? "var(--accent-fg)" : "var(--text-secondary)",
border: `1px solid ${activeCategory === cat.key ? "var(--accent)" : "var(--border-default)"}`,
}}
>
{cat.label}
</button>
))}
<div className="flex flex-wrap gap-2">
{THEME_CATEGORIES.map((cat) => {
const active = activeCategory === cat.key
return (
<button
key={cat.key}
onClick={() => setActiveCategory(cat.key)}
className="rounded-full border px-3 py-1.5 text-[13px] transition-all"
style={{
backgroundColor: active
? "var(--accent)"
: "var(--surface-elevated)",
color: active ? "var(--accent-fg)" : "var(--text-secondary)",
border: `1px solid ${active ? "var(--accent)" : "var(--border-default)"}`,
}}
>
{cat.label}
</button>
)
})}
</div>
{/* Variables Grid */}
<div className="space-y-4">
{THEME_VARIABLES.filter((v) => v.category === activeCategory).map((variable) => (
<div
key={variable.key}
className="flex items-center gap-4 p-3 rounded-lg"
style={{
backgroundColor: "var(--surface-elevated)",
border: `1px solid ${isModified(variable.key) ? "var(--accent)" : "var(--border-default)"}`,
}}
>
{/* Preview */}
<div
className="w-10 h-10 rounded-md shrink-0 border"
style={{ backgroundColor: variable.defaultLight }}
title={variable.label}
/>
<Separator className="my-5" />
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[14px] font-medium" style={{ color: "var(--text-primary)" }}>
{variable.label}
</span>
{isModified(variable.key) && (
<span className="text-[11px] px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--accent-muted)", color: "var(--accent)" }}>
<div className="grid gap-3">
{THEME_VARIABLES.filter((v) => v.category === activeCategory).map(
(variable) => (
<div
key={variable.key}
className="flex flex-col gap-4 rounded-2xl border p-4 lg:flex-row lg:items-center"
style={{
backgroundColor: "var(--surface-elevated)",
borderColor: isModified(variable.key)
? "var(--accent)"
: "var(--border-default)",
}}
>
<div
className="size-10 shrink-0 rounded-xl border"
style={{ backgroundColor: variable.defaultLight }}
title={variable.label}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className="text-[14px] font-medium"
style={{ color: "var(--text-primary)" }}
>
{variable.label}
</span>
{isModified(variable.key) && (
<Badge
variant="secondary"
className="rounded-full px-2 py-0.5 text-[11px]"
>
</Badge>
)}
</div>
<code
className="text-[11px]"
style={{ color: "var(--text-muted)" }}
>
--{variable.key}
</code>
{variable.description && (
<p
className="mt-0.5 text-[12px]"
style={{ color: "var(--text-muted)" }}
>
{variable.description}
</p>
)}
</div>
<code className="text-[11px]" style={{ color: "var(--text-muted)" }}>
--{variable.key}
</code>
{variable.description && (
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
{variable.description}
</p>
)}
</div>
{/* Input */}
<div className="flex items-center gap-2 shrink-0">
<Input
value={getValue(variable)}
onChange={(e) => 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) && (
<button
onClick={() => resetVar(variable.key)}
className="p-1.5 rounded"
title={t("settings.appearance_page.theme_customization.reset_default")}
style={{ color: "var(--text-muted)", backgroundColor: "transparent" }}
>
<RotateCcw className="w-4 h-4" />
</button>
)}
<div className="flex shrink-0 items-center gap-2">
<Input
value={getValue(variable)}
onChange={(e) => 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) && (
<button
onClick={() => resetVar(variable.key)}
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"
)}
>
<RotateCcw className="size-4" />
</button>
)}
</div>
</div>
</div>
))}
)
)}
</div>
{/* Actions */}
<div className="flex items-center gap-4 mt-6 pt-4" style={{ borderTop: "1px solid var(--border-subtle)" }}>
<div className="mt-6 flex flex-wrap items-center gap-3 border-t border-[var(--border-subtle)] pt-4">
<Button
onClick={handleSave}
disabled={saving || !hasChanges}
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" />}
{saved ? <Check className="w-3.5 h-3.5 mr-2" /> : null}
{saved ? t("settings.appearance_page.theme_customization.saved") : t("settings.appearance_page.theme_customization.save_theme")}
{saving && (
<Loader2 data-icon="inline-start" className="animate-spin" />
)}
{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
onClick={resetAll}
@ -205,26 +217,5 @@ export function ThemeCustomization({ className }: { className?: string }) {
</span>
</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 { 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<string, string> = {
"color-background": "background",
@ -31,119 +33,139 @@ const THEME_COLOR_ALIASES: Record<string, string> = {
"color-sidebar-accent-foreground": "sidebar-accent-foreground",
"color-sidebar-border": "sidebar-border",
"color-sidebar-ring": "sidebar-ring",
};
}
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)",
"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<string>(() => {
return localStorage.getItem(STORAGE_KEY) || DEFAULT_PRESET;
});
const [presetId, setPresetIdState] = useState<string>(
() => 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 (
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-3 md:grid-cols-2">
{presets.map((preset) => {
const active = presetId === preset.id;
const active = presetId === preset.id
return (
<button
key={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={{
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 gap-0.5 items-stretch self-stretch">
<div className="flex shrink-0 items-stretch gap-0.5 self-stretch">
<div
className="w-7 h-8 rounded-l-md flex items-center justify-center"
style={{ backgroundColor: preset.preview.bg, border: "1px solid var(--border-subtle)" }}
className="flex w-7 items-center justify-center rounded-l-md"
style={{
backgroundColor: preset.preview.bg,
border: "1px solid var(--border-subtle)",
}}
>
<div
className="w-4 h-3 rounded-sm"
className="size-4 rounded-sm"
style={{ backgroundColor: preset.preview.fg }}
/>
</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 }}
>
<div
className="w-4 h-3 rounded-sm"
className="size-4 rounded-sm"
style={{ backgroundColor: "rgba(255,255,255,0.85)" }}
/>
</div>
</div>
<div className="min-w-0">
<div className="text-[14px] font-medium" style={{ color: "var(--text-primary)" }}>
<div className="min-w-0 flex-1">
<div
className="text-[14px] font-medium"
style={{ color: "var(--text-primary)" }}
>
{preset.name}
</div>
<div className="text-[12px] truncate" style={{ color: "var(--text-muted)" }}>
<div
className="truncate text-[12px]"
style={{ color: "var(--text-muted)" }}
>
{preset.description}
</div>
</div>
{active && (
<div
className="ml-auto w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: "var(--accent)" }}
/>
<Badge className="ml-auto rounded-full px-2 py-0.5 text-[11px]">
Selected
</Badge>
)}
</button>
);
)
})}
</div>
);
)
}

View File

@ -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<ThemeVars>({
"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<ThemeVars>({
"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<ThemeVars>({
"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<ThemeVars>({
"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;
}
}

View File

@ -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;
}
}
pre {
background-color: transparent;
}
}

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()
}