Frontend: - migrate from Vite to Astro (astro.config.mjs, pages/, layouts/) - add admin panel, settings page, command palette, error boundary - refactor App.tsx, MascotChatbot, Sidebar, Header, DashboardLayout - update API client, WebSocket, auth, dashboard features Backend: - add admin module and config routes - refactor middlewares, Redis connection, WebSocket server/bridge - add runtime config loader Discord Gateway: - refactor AI moderation: circuit breaker, concurrency limiter, fallback processor - add media analysis client, Seaxng search, user profile learner - add new drizzle migration Shared: - extend database schema, add new config fields
283 lines
8.4 KiB
Markdown
283 lines
8.4 KiB
Markdown
# Theme Architecture — The Chameleon Engine
|
|
|
|
> *"The only constant in design is change — a theme system embraces it."*
|
|
> — Unknown
|
|
|
|
---
|
|
|
|
## 🎯 Filosofi Theme
|
|
|
|
Sistem theme BETE dibangun di atas **CSS Custom Properties**:
|
|
1. **Separation of value from token** — Nilai warna tidak pernah dirujuk langsung
|
|
2. **Single source of truth** — Satu set CSS variables, dua tema (dark/light)
|
|
3. **Runtime switching** — Tema bisa diganti tanpa reload
|
|
4. **Component-agnostic** — Komponen tidak tahu tema apa yang aktif
|
|
|
|
---
|
|
|
|
## 🧬 Theme Architecture
|
|
|
|
```
|
|
CSS Custom Properties (oklch values)
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────┐
|
|
│ :root / [data-theme] │ ← Tema didefinisikan di level root
|
|
│ --clr-surface-base: oklch(...) │
|
|
│ --clr-primary: oklch(...) │
|
|
│ --clr-text: oklch(...) │
|
|
└──────────────┬──────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────┐
|
|
│ Tailwind Config Mapping │ ← Map CSS vars ke Tailwind utilities
|
|
│ colors: { │
|
|
│ background: "oklch(var(--...))" │
|
|
│ } │
|
|
└──────────────┬──────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────┐
|
|
│ Component Styles │ ← Komponen pakai Tailwind/CSS vars
|
|
│ <div className="bg-card" /> │
|
|
│ .card { background: var(--clr..) } │
|
|
└─────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 🌗 Theme Definitions
|
|
|
|
### Dark Theme (Default)
|
|
|
|
```css
|
|
[data-theme="dark"] {
|
|
/* Surfaces */
|
|
--clr-surface-base: oklch(0.11 0.010 286);
|
|
--clr-surface-elevated: oklch(0.14 0.015 286);
|
|
--clr-surface-overlay: oklch(0.17 0.020 286);
|
|
--clr-surface-sunken: oklch(0.08 0.005 286);
|
|
--clr-border: oklch(0.22 0.020 286);
|
|
|
|
/* Text */
|
|
--clr-text: oklch(0.95 0.005 286);
|
|
--clr-text-secondary: oklch(0.70 0.015 286);
|
|
--clr-text-tertiary: oklch(0.50 0.020 286);
|
|
--clr-text-inverse: oklch(0.11 0.010 286);
|
|
|
|
/* Brand - brighter in dark */
|
|
--clr-primary: oklch(0.62 0.150 255);
|
|
--clr-primary-bg: oklch(0.25 0.060 255 / 0.20);
|
|
--clr-primary-400: oklch(0.62 0.150 255);
|
|
--clr-primary-500: oklch(0.55 0.175 255);
|
|
--clr-primary-600: oklch(0.47 0.160 255);
|
|
|
|
/* Interactive */
|
|
--clr-interactive-hover: oklch(0.20 0.025 286);
|
|
--clr-interactive-active: oklch(0.24 0.030 286);
|
|
--clr-interactive-selected: oklch(0.25 0.060 255 / 0.15);
|
|
|
|
/* Shadows */
|
|
--sh-card: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
--sh-hover: 0 4px 16px rgba(0, 0, 0, 0.4);
|
|
--sh-elevated: 0 8px 32px rgba(0, 0, 0, 0.5);
|
|
--sh-modal: 0 16px 48px rgba(0, 0, 0, 0.6);
|
|
|
|
/* Glass */
|
|
--glass-bg: oklch(0.15 0.015 286 / 0.60);
|
|
--glass-border: oklch(0.25 0.030 286 / 0.20);
|
|
}
|
|
```
|
|
|
|
### Light Theme
|
|
|
|
```css
|
|
[data-theme="light"] {
|
|
/* Surfaces */
|
|
--clr-surface-base: oklch(0.97 0.002 286);
|
|
--clr-surface-elevated: oklch(1.00 0.000 286);
|
|
--clr-surface-overlay: oklch(0.95 0.003 286);
|
|
--clr-surface-sunken: oklch(0.92 0.004 286);
|
|
--clr-border: oklch(0.87 0.005 286);
|
|
|
|
/* Text */
|
|
--clr-text: oklch(0.11 0.010 286);
|
|
--clr-text-secondary: oklch(0.50 0.020 286);
|
|
--clr-text-tertiary: oklch(0.70 0.025 286);
|
|
--clr-text-inverse: oklch(0.97 0.005 286);
|
|
|
|
/* Brand - standard in light */
|
|
--clr-primary: oklch(0.55 0.175 255);
|
|
--clr-primary-bg: oklch(0.90 0.060 255 / 0.25);
|
|
--clr-primary-400: oklch(0.55 0.175 255);
|
|
--clr-primary-500: oklch(0.47 0.160 255);
|
|
--clr-primary-600: oklch(0.40 0.140 255);
|
|
|
|
/* Interactive */
|
|
--clr-interactive-hover: oklch(0.90 0.005 286);
|
|
--clr-interactive-active: oklch(0.85 0.008 286);
|
|
--clr-interactive-selected: oklch(0.90 0.060 255 / 0.3);
|
|
|
|
/* Shadows — lighter in light theme */
|
|
--sh-card: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
--sh-hover: 0 4px 16px rgba(0, 0, 0, 0.12);
|
|
--sh-elevated: 0 8px 24px rgba(0, 0, 0, 0.08);
|
|
--sh-modal: 0 16px 48px rgba(0, 0, 0, 0.12);
|
|
|
|
/* Glass — lighter opacity */
|
|
--glass-bg: oklch(0.97 0.002 286 / 0.50);
|
|
--glass-border: oklch(0.87 0.005 286 / 0.30);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🔄 Theme Switching
|
|
|
|
### React Implementation
|
|
|
|
```tsx
|
|
// hooks/useTheme.ts
|
|
type Theme = 'light' | 'dark';
|
|
|
|
function useTheme() {
|
|
const [theme, setTheme] = useState<Theme>(() => {
|
|
// 1. Check localStorage
|
|
const stored = localStorage.getItem('theme');
|
|
if (stored === 'light' || stored === 'dark') return stored;
|
|
|
|
// 2. Check system preference
|
|
return window.matchMedia('(prefers-color-scheme: light)').matches
|
|
? 'light'
|
|
: 'dark';
|
|
});
|
|
|
|
useEffect(() => {
|
|
// Apply theme to document
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
localStorage.setItem('theme', theme);
|
|
|
|
// Toggle Tailwind dark class
|
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
|
}, [theme]);
|
|
|
|
const toggle = useCallback(() => {
|
|
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
|
}, []);
|
|
|
|
return { theme, setTheme, toggle } as const;
|
|
}
|
|
```
|
|
|
|
### Scream-Free Architecture
|
|
|
|
Theme switching **tidak perlu re-render seluruh komponen**. Karena CSS variables diubah di `:root`, browser secara otomatis me-repain semua elemen yang menggunakan var tersebut.
|
|
|
|
---
|
|
|
|
## 🎯 Token Mapping Rules
|
|
|
|
| Design Token | CSS Variable | Tailwind Mapping |
|
|
|-------------|-------------|------------------|
|
|
| Page background | `--clr-surface-base` | `bg-background` |
|
|
| Card surface | `--clr-surface-elevated` | `bg-card` |
|
|
| Body text | `--clr-text` | `text-foreground` |
|
|
| Secondary text | `--clr-text-secondary` | `text-muted-foreground` |
|
|
| Primary button | `--clr-primary` | `bg-primary` |
|
|
| Primary text on button | `--clr-text-on-primary` | `text-primary-foreground` |
|
|
| Border | `--clr-border` | `border-border` |
|
|
| Card shadow | `--sh-card` | `shadow-sm` |
|
|
|
|
---
|
|
|
|
## 🎨 System Theme (prefers-color-scheme)
|
|
|
|
```css
|
|
/* Default: dark */
|
|
:root { /* dark variables */ }
|
|
|
|
/* System light */
|
|
@media (prefers-color-scheme: light) {
|
|
:root { /* light variables */ }
|
|
}
|
|
|
|
/* Manual override via data-theme */
|
|
[data-theme="dark"] { /* dark variables */ }
|
|
[data-theme="light"] { /* light variables */ }
|
|
```
|
|
|
|
**Priority:**
|
|
1. `data-theme` attribute (manual override) — **highest**
|
|
2. `prefers-color-scheme` (system) — **medium**
|
|
3. Default (dark) — **fallback**
|
|
|
|
---
|
|
|
|
## 📦 Theme-aware Component Pattern
|
|
|
|
```tsx
|
|
// Komponen tidak perlu tahu theme — cukup pakai CSS vars
|
|
function ThemeAwareCard() {
|
|
return (
|
|
<div className="rounded-xl border border-border bg-card text-card-foreground shadow-sm">
|
|
{/* Konten — styling otomatis berubah sesuai theme */}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Dark-mode Specific Adjustments
|
|
|
|
```css
|
|
/* Hanya untuk theme dark */
|
|
[data-theme="dark"] .particle-orbs {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
/* Hanya untuk theme light */
|
|
[data-theme="light"] .particle-orbs {
|
|
opacity: 0.3;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## ⚠️ Theme Anti-Patterns
|
|
|
|
### ❌ Color hardcoding
|
|
```css
|
|
/* ❌ JANGAN — tidak akan berubah saat theme switch */
|
|
.card { background: #1e1e2e; }
|
|
|
|
/* ✅ CSS variable — otomatis mengikuti theme */
|
|
.card { background: var(--clr-surface-elevated); }
|
|
```
|
|
|
|
### ❌ Theme-specific logic in components
|
|
```tsx
|
|
// ❌ JANGAN — komponen tahu soal theme
|
|
function Card() {
|
|
const { theme } = useTheme();
|
|
return <div className={theme === 'dark' ? 'bg-gray-800' : 'bg-white'} />;
|
|
}
|
|
|
|
// ✅ Komponen tidak perlu tahu — CSS vars handle semua
|
|
function Card() {
|
|
return <div className="bg-card" />;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🔗 Referensi
|
|
|
|
| Sumber | Konsep |
|
|
|--------|--------|
|
|
| [CSS Custom Properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) | CSS vars |
|
|
| [prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) | System theme |
|
|
| [OKLCH in CSS](https://evilmartians.com/chronicles/oklch-in-css-why-quit-rgb-hsl) | Color space |
|
|
|
|
---
|
|
|
|
*"Tema adalah kulit yang berganti — esensi tetap sama, wajah yang baru."* ❄️🩵
|