feat: migrate frontend to Astro + expand AI moderation + backend admin/runtime config
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
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
# Color System — Éclat Spectral
|
||||
|
||||
> *"Color is the keyboard, the eyes are the harmonies, the soul is the piano with many strings."*
|
||||
> — Wassily Kandinsky, diadaptasi untuk sistem desain modern.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Filosofi Warna
|
||||
|
||||
Sistem warna BETE dibangun di atas tiga fondasi:
|
||||
|
||||
1. **OKLCH** — Color space perceptually uniform untuk konsistensi antar device
|
||||
2. **HCT (Hue-Chroma-Tone)** — Sistem warna Material You yang adaptif
|
||||
3. **Semantic Tokens** — Abstraksi makna, bukan nilai literal
|
||||
|
||||
Setiap warna memiliki **lightness (L), chroma (C), hue (H)** yang independen. Ini memungkinkan:
|
||||
- **Scale generation** — Variasi lightness dari 0–100% dengan chroma yang sama
|
||||
- **Theming** — Cukup ganti hue, seluruh tema bergeser
|
||||
- **Accessibility** — Kontras dihitung dari lightness, cocok untuk WCAG AA/AAA
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Primary Palette
|
||||
|
||||
### Brand Spectrum: "Aetherial Blue"
|
||||
|
||||
Rona utama IMPHNEN — biru yang dingin seperti es Amphoreus, namun hangat dalam interaksi.
|
||||
|
||||
```css
|
||||
/* OKLCH Base — Primary */
|
||||
--clr-primary-50: oklch(0.95 0.025 255);
|
||||
--clr-primary-100: oklch(0.90 0.045 255);
|
||||
--clr-primary-200: oklch(0.80 0.080 255);
|
||||
--clr-primary-300: oklch(0.70 0.120 255);
|
||||
--clr-primary-400: oklch(0.62 0.150 255);
|
||||
--clr-primary-500: oklch(0.55 0.175 255); /* ★ Base primary */
|
||||
--clr-primary-600: oklch(0.47 0.160 255);
|
||||
--clr-primary-700: oklch(0.40 0.140 255);
|
||||
--clr-primary-800: oklch(0.32 0.115 255);
|
||||
--clr-primary-900: oklch(0.25 0.090 255);
|
||||
--clr-primary-950: oklch(0.18 0.060 255);
|
||||
```
|
||||
|
||||
**Deskripsi Hue 255°:** Biru jernih dengan sedikit cyan — warna langit senja di Amphoreus. Tidak terlalu agresif seperti biru korporat (#007bff), tidak terlalu playful seperti cyan (#00bcd4).
|
||||
|
||||
### Neutral Spectrum: "Glacial Scale"
|
||||
|
||||
Abu-abu yang tidak hangat (tidak kekuningan) dan tidak dingin (tidak kebiruan) — benar-benar netral.
|
||||
|
||||
```css
|
||||
--clr-neutral-50: oklch(0.985 0.001 286);
|
||||
--clr-neutral-100: oklch(0.970 0.001 286);
|
||||
--clr-neutral-200: oklch(0.920 0.003 286);
|
||||
--clr-neutral-300: oklch(0.870 0.005 286);
|
||||
--clr-neutral-400: oklch(0.750 0.010 286);
|
||||
--clr-neutral-500: oklch(0.620 0.015 286); /* ★ Base neutral */
|
||||
--clr-neutral-600: oklch(0.500 0.020 286);
|
||||
--clr-neutral-700: oklch(0.380 0.025 286);
|
||||
--clr-neutral-800: oklch(0.260 0.030 286);
|
||||
--clr-neutral-900: oklch(0.180 0.030 286);
|
||||
--clr-neutral-950: oklch(0.110 0.025 286);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌈 Extended Palette
|
||||
|
||||
### Accent Colors
|
||||
|
||||
| Palette | Hue | Chroma | Base (500) | Karakter |
|
||||
|---------|-----|--------|------------|----------|
|
||||
| **Ruby** (Destructive) | 25° | 0.165 | `oklch(0.55 0.165 25)` | Darah — urgensi, error |
|
||||
| **Emerald** (Success) | 145° | 0.130 | `oklch(0.60 0.130 145)` | Pertumbuhan — sukses, aman |
|
||||
| **Amber** (Warning) | 75° | 0.120 | `oklch(0.70 0.120 75)` | Matahari — peringatan, atensi |
|
||||
| **Amethyst** (Premium) | 285° | 0.100 | `oklch(0.55 0.100 285)` | Mewah — fitur premium, VIP |
|
||||
| **Rose** (AI / Feminine) | 350° | 0.110 | `oklch(0.60 0.110 350)` | Kecerdasan — AI analysis, insight |
|
||||
| **Cyan** (Info) | 200° | 0.120 | `oklch(0.65 0.120 200)` | Informasi — tooltip, hint |
|
||||
|
||||
Setiap palette memiliki scale 50–950 mengikuti pola primary. Contoh:
|
||||
|
||||
```css
|
||||
--clr-ruby-500: oklch(0.55 0.165 25);
|
||||
--clr-ruby-600: oklch(0.47 0.150 25);
|
||||
--clr-emerald-500: oklch(0.60 0.130 145);
|
||||
--clr-amber-500: oklch(0.70 0.120 75);
|
||||
```
|
||||
|
||||
### Semantic Surface Colors — Dark Theme
|
||||
|
||||
```css
|
||||
/* Base surfaces */
|
||||
--clr-surface-base: oklch(0.11 0.010 286); /* Darkest bg */
|
||||
--clr-surface-elevated: oklch(0.14 0.015 286); /* Card surface */
|
||||
--clr-surface-overlay: oklch(0.17 0.020 286); /* Modal/dropdown */
|
||||
--clr-surface-sunken: oklch(0.08 0.005 286); /* Input bg */
|
||||
|
||||
/* Interactive states */
|
||||
--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); /* Primary tint */
|
||||
```
|
||||
|
||||
### Semantic Surface Colors — Light Theme
|
||||
|
||||
```css
|
||||
--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-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);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📐 Text Colors & Accessibility
|
||||
|
||||
### Foreground Scale
|
||||
|
||||
```css
|
||||
--clr-text-primary: oklch(0.95 0.005 286); /* High emphasis — body */
|
||||
--clr-text-secondary: oklch(0.70 0.015 286); /* Medium emphasis — metadata */
|
||||
--clr-text-tertiary: oklch(0.50 0.020 286); /* Low emphasis — placeholder */
|
||||
--clr-text-disabled: oklch(0.35 0.020 286); /* Disabled state */
|
||||
--clr-text-inverse: oklch(0.11 0.010 286); /* On colored backgrounds */
|
||||
|
||||
/* On-brand backgrounds */
|
||||
--clr-text-on-primary: oklch(0.97 0.005 286); /* Text on primary bg */
|
||||
--clr-text-on-destructive: oklch(0.97 0.005 286); /* Text on destructive bg */
|
||||
```
|
||||
|
||||
### Kontras Minimum
|
||||
|
||||
| Level | Rasio | Usage | Elemen |
|
||||
|-------|-------|-------|--------|
|
||||
| **AA** | 4.5:1 | Body text normal | `--clr-text-primary` di atas surface |
|
||||
| **AA Large** | 3:1 | Teks ≥18px/≥14px bold | Heading, label |
|
||||
| **AAA** | 7:1 | Teks penting | Legal, alert, critical info |
|
||||
|
||||
### Verifikasi Kontras
|
||||
|
||||
```typescript
|
||||
// utils/contrast.ts
|
||||
function meetsWCAGAA(foreground: OklchColor, background: OklchColor): boolean {
|
||||
const contrast = relativeLuminance(background) / relativeLuminance(foreground);
|
||||
return contrast >= 4.5;
|
||||
}
|
||||
|
||||
// Helper function untuk mendapatkan lightness aman
|
||||
function accessibleLightness(hue: number, chroma: number, bgLightness: number): number {
|
||||
// Mencari lightness minimum yang memenuhi 4.5:1
|
||||
for (let l = 1.0; l > 0; l -= 0.01) {
|
||||
if (getContrastRatio(l, chroma, hue, bgLightness) >= 4.5) return l;
|
||||
}
|
||||
return 0.5;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🪞 Glass & Frosted Effects
|
||||
|
||||
Estetika glassmorphism menggunakan **opacity + backdrop-blur**:
|
||||
|
||||
```css
|
||||
/* Glass card — frosted glass */
|
||||
--glass-bg: oklch(0.15 0.015 286 / 0.60);
|
||||
--glass-border: oklch(0.25 0.030 286 / 0.20);
|
||||
--glass-blur: 16px;
|
||||
--glass-shadow: 0 8px 32px oklch(0 0 0 / 0.25);
|
||||
|
||||
/* Glass strong — modal/dialog */
|
||||
--glass-strong-bg: oklch(0.18 0.020 286 / 0.85);
|
||||
--glass-strong-blur: 24px;
|
||||
|
||||
/* Glass subtle — sidebar */
|
||||
--glass-subtle-bg: oklch(var(--clr-surface-base) / 0.50);
|
||||
--glass-subtle-blur: 8px;
|
||||
```
|
||||
|
||||
### Menggabungkan di CSS:
|
||||
|
||||
```css
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Glow & Light Effects
|
||||
|
||||
### Glow Tokens
|
||||
|
||||
```css
|
||||
/* Primary glow — untuk elemen interaktif, loading state */
|
||||
--glow-primary: 0 0 20px oklch(0.55 0.175 255 / 0.3);
|
||||
--glow-primary-soft: 0 0 12px oklch(0.55 0.175 255 / 0.15);
|
||||
|
||||
/* Success glow — untuk badge, notifikasi sukses */
|
||||
--glow-success: 0 0 16px oklch(0.60 0.130 145 / 0.25);
|
||||
|
||||
/* Destructive glow — untuk alert error */
|
||||
--glow-error: 0 0 16px oklch(0.55 0.165 25 / 0.25);
|
||||
|
||||
/* AI glow — untuk analysis badge, pulsing indicator */
|
||||
--glow-ai: 0 0 20px oklch(0.60 0.110 350 / 0.25);
|
||||
```
|
||||
|
||||
### Pulse Animation dengan Glow
|
||||
|
||||
```css
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: var(--glow-primary-soft);
|
||||
}
|
||||
50% {
|
||||
box-shadow: var(--glow-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-analysis-badge {
|
||||
animation: glow-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌗 Theme Tokens
|
||||
|
||||
### Dark Mode (Default)
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
/* Base */
|
||||
--clr-base: var(--clr-surface-base);
|
||||
--clr-elevated: var(--clr-surface-elevated);
|
||||
--clr-overlay: var(--clr-surface-overlay);
|
||||
--clr-sunken: var(--clr-surface-sunken);
|
||||
|
||||
/* Text */
|
||||
--clr-text: var(--clr-text-primary);
|
||||
--clr-text-muted: var(--clr-text-secondary);
|
||||
|
||||
/* Brand adjustments for dark */
|
||||
--clr-primary: var(--clr-primary-400); /* Lebih terang di dark */
|
||||
--clr-primary-bg: oklch(0.25 0.060 255 / 0.20);
|
||||
}
|
||||
```
|
||||
|
||||
### Light Mode
|
||||
|
||||
```css
|
||||
[data-theme="light"] {
|
||||
--clr-base: oklch(0.97 0.002 286);
|
||||
--clr-elevated: oklch(1.00 0.000 286);
|
||||
--clr-overlay: oklch(0.95 0.003 286);
|
||||
--clr-sunken: oklch(0.92 0.004 286);
|
||||
|
||||
--clr-text: oklch(0.11 0.010 286);
|
||||
--clr-text-muted: oklch(0.50 0.020 286);
|
||||
|
||||
--clr-primary: var(--clr-primary-500); /* Standard di light */
|
||||
--clr-primary-bg: oklch(0.90 0.060 255 / 0.25);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Moderation Severity Colors
|
||||
|
||||
Sistem moderasi menggunakan gradasi keparahan dari aman hingga kritis:
|
||||
|
||||
```css
|
||||
--clr-severity-safe: oklch(0.60 0.130 145); /* Emerald — aman */
|
||||
--clr-severity-low: oklch(0.70 0.120 75); /* Amber — rendah */
|
||||
--clr-severity-medium: oklch(0.65 0.150 50); /* Orange — sedang */
|
||||
--clr-severity-high: oklch(0.60 0.150 30); /* Red-orange — tinggi */
|
||||
--clr-severity-critical: oklch(0.55 0.165 25); /* Ruby — kritis */
|
||||
```
|
||||
|
||||
### Background Variations (untuk chip/badge)
|
||||
|
||||
```css
|
||||
--clr-severity-safe-bg: oklch(0.60 0.130 145 / 0.15);
|
||||
--clr-severity-low-bg: oklch(0.70 0.120 75 / 0.15);
|
||||
--clr-severity-medium-bg: oklch(0.65 0.150 50 / 0.15);
|
||||
--clr-severity-high-bg: oklch(0.60 0.150 30 / 0.15);
|
||||
--clr-severity-critical-bg: oklch(0.55 0.165 25 / 0.15);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Color Usage Decision Tree
|
||||
|
||||
```
|
||||
Butuh warna untuk...
|
||||
│
|
||||
├── Surface / Background → Gunakan --clr-surface-* (base/elevated/overlay)
|
||||
│
|
||||
├── Text → Gunakan --clr-text-* (primary/secondary/tertiary)
|
||||
│
|
||||
├── Interactive element →
|
||||
│ ├── Button utama → --clr-primary, --clr-primary-hover
|
||||
│ ├── Button danger → --clr-ruby-500
|
||||
│ ├── Link → --clr-primary-400 (dark) / --clr-primary-600 (light)
|
||||
│ └── Input focus → --clr-primary ring
|
||||
│
|
||||
├── Status indicator →
|
||||
│ ├── Success → --clr-emerald-* atau --clr-severity-safe
|
||||
│ ├── Warning → --clr-amber-* atau --clr-severity-low
|
||||
│ ├── Error → --clr-ruby-* atau --clr-severity-critical
|
||||
│ └── Info → --clr-cyan-*
|
||||
│
|
||||
├── Data visualization → Gunakan palette chart (lihat patterns/07)
|
||||
│
|
||||
└── Moderation badge → Gunakan --clr-severity-*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Anti-Patterns (Yang Tidak Boleh Dilakukan)
|
||||
|
||||
### ❌ Hardcoded HEX/RGB
|
||||
```css
|
||||
/* ❌ JANGAN — tidak akan terpengaruh theme switching */
|
||||
.notification-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
/* ✅ Gunakan token semantic */
|
||||
.notification-success {
|
||||
background: var(--clr-emerald-100);
|
||||
color: var(--clr-emerald-800);
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Langsung pakai Tailwind utility colors
|
||||
```tsx
|
||||
{/* ❌ JANGAN — hardcoded ke skema tertentu */}
|
||||
<Badge className="bg-emerald-100 text-emerald-700" />
|
||||
|
||||
{/* ✅ Gunakan semantic variant */}
|
||||
<Badge variant="success" />
|
||||
```
|
||||
|
||||
### ❌ Mengabaikan kontras
|
||||
```css
|
||||
/* ❌ JANGAN — teks abu-abu di atas abu-abu */
|
||||
.metadata {
|
||||
color: oklch(0.65 0.015 286); /* L=0.65 */
|
||||
background: oklch(0.70 0.010 286); /* L=0.70 — rasio ~1.1:1! */
|
||||
}
|
||||
|
||||
/* ✅ Minimum kontras 3:1 untuk secondary text */
|
||||
.metadata {
|
||||
color: oklch(0.50 0.020 286); /* L=0.50 */
|
||||
background: var(--clr-surface-base); /* L=0.11 */
|
||||
/* Rasio ~6:1 — aman */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Referensi
|
||||
|
||||
| Sumber | Konsep |
|
||||
|--------|--------|
|
||||
| [OKLCH Color Picker](https://oklch.com/) | Visualisasi OKLCH color space |
|
||||
| [WCAG Contrast Checker](https://webaim.org/resources/contrastchecker/) | Verifikasi kontras |
|
||||
| [Material HCT](https://material.io/blog/science-of-color-design) | Hue-Chroma-Tone system |
|
||||
| [Tailwind CSS OKLCH](https://tailwindcss.com/docs/colors#using-custom-colors) | Implementasi OKLCH |
|
||||
|
||||
---
|
||||
|
||||
*"Warna adalah ingatan yang tak pernah pudar — dibiaskan melalui prisma es Amphoreus."* ❄️🩵
|
||||
@@ -0,0 +1,371 @@
|
||||
# Typography — The Voice of Glass
|
||||
|
||||
> *"Typography is the craft of endowing human language with a durable visual form."*
|
||||
> — Robert Bringhurst, *The Elements of Typographic Style*
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Filosofi Tipografi
|
||||
|
||||
Tipografi BETE dibangun di atas tiga pilar:
|
||||
|
||||
1. **Hierarki melalui weight & size** — Bukan sekadar memperbesar heading, tapi memberi bobot makna
|
||||
2. **Ritme vertikal yang konsisten** — Setiap elemen teks berada dalam grid ritme 4px
|
||||
3. **Keterbacaan sebagai prioritas utama** — Sebelum estetika, sebelum gaya
|
||||
|
||||
---
|
||||
|
||||
## 📐 Type Scale: Fluid Modular Scale
|
||||
|
||||
Kita menggunakan **modular scale** 1.25 (major third) yang **fluid** — menyesuaikan antara viewport.
|
||||
|
||||
```css
|
||||
/* Font size scale — fluid, minor third (1.125) hingga major third (1.25) */
|
||||
--fs-xs: clamp(0.69rem, 0.69rem + 0.01vw, 0.75rem); /* 11–12px */
|
||||
--fs-sm: clamp(0.81rem, 0.81rem + 0.02vw, 0.88rem); /* 13–14px */
|
||||
--fs-base: clamp(0.94rem, 0.94rem + 0.03vw, 1.00rem); /* 15–16px ★ */
|
||||
--fs-md: clamp(1.06rem, 1.06rem + 0.04vw, 1.13rem); /* 17–18px */
|
||||
--fs-lg: clamp(1.19rem, 1.19rem + 0.06vw, 1.25rem); /* 19–20px */
|
||||
--fs-xl: clamp(1.31rem, 1.31rem + 0.08vw, 1.50rem); /* 21–24px */
|
||||
--fs-2xl: clamp(1.50rem, 1.50rem + 0.12vw, 1.88rem); /* 24–30px */
|
||||
--fs-3xl: clamp(1.69rem, 1.69rem + 0.18vw, 2.25rem); /* 27–36px */
|
||||
--fs-4xl: clamp(1.88rem, 1.88rem + 0.26vw, 2.81rem); /* 30–45px */
|
||||
--fs-5xl: clamp(2.25rem, 2.25rem + 0.38vw, 3.50rem); /* 36–56px */
|
||||
--fs-6xl: clamp(2.50rem, 2.50rem + 0.50vw, 4.00rem); /* 40–64px */
|
||||
```
|
||||
|
||||
> **Mengapa clamp()?** Font size yang terlalu besar di mobile dan terlalu kecil di desktop adalah masalah UX klasik. Dengan `clamp()`, kita dapatkan ukuran yang optimal di setiap viewport tanpa media query.
|
||||
|
||||
### Line Height
|
||||
|
||||
```css
|
||||
--lh-tight: 1.15; /* Heading besar, display text */
|
||||
--lh-normal: 1.50; /* Body text, paragraphs */
|
||||
--lh-relaxed: 1.65; /* Long-form reading */
|
||||
--lh-compact: 1.25; /* UI labels, badges, small text */
|
||||
```
|
||||
|
||||
### Font Weight Tokens
|
||||
|
||||
```css
|
||||
--fw-light: 300;
|
||||
--fw-regular: 400;
|
||||
--fw-medium: 500;
|
||||
--fw-semibold: 600;
|
||||
--fw-bold: 700;
|
||||
--fw-extrabold: 800;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔤 Font Family
|
||||
|
||||
### Primary: "Outfit" — Modern Geometric Sans
|
||||
|
||||
```css
|
||||
--ff-sans: 'Outfit', system-ui, -apple-system, sans-serif;
|
||||
--ff-display: 'Outfit', system-ui, -apple-system, sans-serif;
|
||||
```
|
||||
|
||||
Mengapa **Outfit** menggantikan Poppins?
|
||||
- **Geometric precision** — Bentuk huruf yang bersih, cocok untuk UI modern
|
||||
- **Low x-height** — Memberi kesan elegan dan lega
|
||||
- **Variable font support** — Satu file untuk semua weight, performa lebih baik
|
||||
- **Open-source** — SIL Open Font License
|
||||
|
||||
### Monospace: "JetBrains Mono"
|
||||
|
||||
```css
|
||||
--ff-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
```
|
||||
|
||||
Untuk: ID, timestamp, kode, data teknis, metrik.
|
||||
|
||||
### Loading Strategy
|
||||
|
||||
```html
|
||||
<!-- Variable font — cukup satu file untuk seluruh weight -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300..800&family=JetBrains+Mono:wght@400..700&display=swap" rel="stylesheet" />
|
||||
```
|
||||
|
||||
```css
|
||||
/* Fallback font stack dengan @font-face untuk cache lokal */
|
||||
@font-face {
|
||||
font-family: 'Outfit Fallback';
|
||||
src: local('Segoe UI'), local('Roboto'), local('Helvetica Neue');
|
||||
size-adjust: 95%; /* Mengurangi layout shift (CLS) */
|
||||
ascent-override: 90%;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Type Styles — The Complete Catalog
|
||||
|
||||
### Display / Hero
|
||||
|
||||
```css
|
||||
.display-1 {
|
||||
font-family: var(--ff-display);
|
||||
font-size: var(--fs-6xl);
|
||||
font-weight: var(--fw-extrabold);
|
||||
line-height: var(--lh-tight);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.display-2 {
|
||||
font-family: var(--ff-display);
|
||||
font-size: var(--fs-5xl);
|
||||
font-weight: var(--fw-bold);
|
||||
line-height: var(--lh-tight);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
```
|
||||
|
||||
### Headings
|
||||
|
||||
```css
|
||||
.h1 {
|
||||
font-family: var(--ff-sans);
|
||||
font-size: var(--fs-4xl);
|
||||
font-weight: var(--fw-bold);
|
||||
line-height: var(--lh-tight);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.h2 {
|
||||
font-family: var(--ff-sans);
|
||||
font-size: var(--fs-3xl);
|
||||
font-weight: var(--fw-semibold);
|
||||
line-height: var(--lh-tight);
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.h3 {
|
||||
font-family: var(--ff-sans);
|
||||
font-size: var(--fs-2xl);
|
||||
font-weight: var(--fw-semibold);
|
||||
line-height: var(--lh-tight);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.h4 {
|
||||
font-family: var(--ff-sans);
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: var(--fw-medium);
|
||||
line-height: var(--lh-normal);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
```
|
||||
|
||||
### Body
|
||||
|
||||
```css
|
||||
.body-large {
|
||||
font-family: var(--ff-sans);
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-regular);
|
||||
line-height: var(--lh-relaxed);
|
||||
}
|
||||
|
||||
.body {
|
||||
font-family: var(--ff-sans);
|
||||
font-size: var(--fs-base);
|
||||
font-weight: var(--fw-regular);
|
||||
line-height: var(--lh-normal);
|
||||
}
|
||||
|
||||
.body-small {
|
||||
font-family: var(--ff-sans);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-regular);
|
||||
line-height: var(--lh-normal);
|
||||
}
|
||||
|
||||
.body-compact {
|
||||
font-family: var(--ff-sans);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
line-height: var(--lh-compact);
|
||||
}
|
||||
```
|
||||
|
||||
### UI / Label
|
||||
|
||||
```css
|
||||
.label {
|
||||
font-family: var(--ff-sans);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
line-height: var(--lh-compact);
|
||||
letter-spacing: 0.06em; /* UPPERCASE labels get wider tracking */
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.caption {
|
||||
font-family: var(--ff-sans);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-regular);
|
||||
line-height: var(--lh-normal);
|
||||
color: var(--clr-text-secondary);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--ff-mono);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-regular);
|
||||
line-height: var(--lh-normal);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-family: var(--ff-sans);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-medium);
|
||||
line-height: 1;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Type Table — Mapping ke Penggunaan
|
||||
|
||||
| Token | Penggunaan | Contoh |
|
||||
|-------|-----------|--------|
|
||||
| `.display-1` | Halaman kosong, 404, hero section | "Nothing to see here" |
|
||||
| `.display-2` | Empty state utama | "No messages yet" |
|
||||
| `.h1` | Judul halaman | "Dashboard", "Messages" |
|
||||
| `.h2` | Judul section panel | "Voice Connections", "Analytics" |
|
||||
| `.h3` | Judul card | Nama user, channel |
|
||||
| `.h4` | Sub-section, tab content | "Active Speakers", "Filters" |
|
||||
| `.body` | Paragraf, konten utama | Pesan teks, deskripsi |
|
||||
| `.body-small` | Metadata, secondary info | Timestamp, username |
|
||||
| `.body-compact` | Dense lists | Daftar items compact |
|
||||
| `.label` | Form label, section header | "CHANNEL", "USERNAME" |
|
||||
| `.caption` | Hint, footnote, helper | "Click to expand" |
|
||||
| `.mono` | ID, kode, data teknis | "Channel #12345" |
|
||||
| `.badge` | Chip, status indicator | "AI Analysis", "Flagged" |
|
||||
|
||||
---
|
||||
|
||||
## 🎭 Rich Text & Emphatic Styles
|
||||
|
||||
```css
|
||||
/* Links */
|
||||
a, .link {
|
||||
color: var(--clr-primary-400);
|
||||
text-decoration: none;
|
||||
transition: opacity var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
a:hover, .link:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Code inline */
|
||||
code, .code-inline {
|
||||
font-family: var(--ff-mono);
|
||||
font-size: 0.9em;
|
||||
padding: 0.125em 0.375em;
|
||||
background: var(--clr-surface-sunken);
|
||||
border-radius: var(--rd-xs);
|
||||
}
|
||||
|
||||
/* Truncation */
|
||||
.text-truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Multi-line truncation */
|
||||
.text-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.text-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📏 Vertical Rhythm
|
||||
|
||||
Menggunakan sistem baseline 4px (bukan 8px default) untuk tipografi:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--baseline: 4px;
|
||||
}
|
||||
|
||||
/* Heading margin */
|
||||
.h1 { margin-bottom: calc(var(--baseline) * 4); } /* 16px */
|
||||
.h2 { margin-bottom: calc(var(--baseline) * 3); } /* 12px */
|
||||
.h3 { margin-bottom: calc(var(--baseline) * 3); } /* 12px */
|
||||
.h4 { margin-bottom: calc(var(--baseline) * 2); } /* 8px */
|
||||
|
||||
/* Paragraph spacing */
|
||||
p, .body {
|
||||
margin-bottom: calc(var(--baseline) * 4); /* 16px */
|
||||
}
|
||||
|
||||
p + p {
|
||||
margin-top: calc(var(--baseline) * 2); /* 8px — reduced between consecutive paragraphs */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Anti-Patterns Tipografi
|
||||
|
||||
### ❌ Ukuran absolut tanpa fluid
|
||||
```css
|
||||
/* ❌ JANGAN — title 32px di mobile terlalu besar */
|
||||
.page-title { font-size: 32px; }
|
||||
|
||||
/* ✅ clamp menyesuaikan viewport */
|
||||
.page-title { font-size: var(--fs-4xl); }
|
||||
```
|
||||
|
||||
### ❌ Line height terlalu kecil untuk body text
|
||||
```css
|
||||
/* ❌ JANGAN — crowded, sulit dibaca */
|
||||
.body-text { font-size: 16px; line-height: 1.2; }
|
||||
|
||||
/* ✅ Line height yang cukup untuk readability */
|
||||
.body-text { font-size: var(--fs-base); line-height: var(--lh-normal); }
|
||||
```
|
||||
|
||||
### ❌ Terlalu banyak type scale
|
||||
```css
|
||||
/* ❌ JANGAN — 29px, 28px, 27px, 26px adalah noise */
|
||||
.custom-1 { font-size: 29px; }
|
||||
.custom-2 { font-size: 28px; }
|
||||
|
||||
/* ✅ Gunakan scale yang terdefinisi */
|
||||
.custom { font-size: var(--fs-4xl); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Referensi
|
||||
|
||||
| Sumber | Konsep |
|
||||
|--------|--------|
|
||||
| [Type Scale Calculator](https://typescale.com/) | Modular scale generation |
|
||||
| [Outfit on Google Fonts](https://fonts.google.com/specimen/Outfit) | Font spesifikasi |
|
||||
| [Utopia.fyi](https://utopia.fyi/) | Fluid type scale calculator |
|
||||
| [JetBrains Mono](https://www.jetbrains.com/lp/mono/) | Font monospace |
|
||||
|
||||
---
|
||||
|
||||
*"Huruf adalah jejak ingatan yang tak kasatmata — ia berbicara tanpa suara."* ❄️🩵
|
||||
@@ -0,0 +1,349 @@
|
||||
# Spatial System — The Architecture of Void
|
||||
|
||||
> *"Space is the breath of art."*
|
||||
> — Frank Lloyd Wright, arsitek organik.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Filosofi Spasial
|
||||
|
||||
Ruang dalam BETE bukan sekadar "tempat kosong" — ia adalah **medium komunikasi visual**. Jarak antarelemen menyampaikan hubungan semantik:
|
||||
|
||||
- **Dekat** → Elemen terkait secara konseptual
|
||||
- **Berjarak** → Elemen independen atau batch berbeda
|
||||
- **Terpisah jauh** → Section baru, hierarki turun
|
||||
|
||||
Kita menggunakan **4px baseline grid** untuk semua keputusan spasial.
|
||||
|
||||
---
|
||||
|
||||
## 📐 Grid System: 8px × 4px Hybrid
|
||||
|
||||
BETE menggunakan sistem **8px untuk layout kasar**, **4px untuk fine-tuning**.
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Base grid unit */
|
||||
--grid-unit: 4px;
|
||||
|
||||
/* Spacing scale — exponential */
|
||||
--sp-0: 0px;
|
||||
--sp-0.5: calc(var(--grid-unit) * 1); /* 4px — micro spacing */
|
||||
--sp-1: calc(var(--grid-unit) * 2); /* 8px — tight spacing */
|
||||
--sp-2: calc(var(--grid-unit) * 3); /* 12px — compact spacing */
|
||||
--sp-3: calc(var(--grid-unit) * 4); /* 16px — base spacing ★ */
|
||||
--sp-4: calc(var(--grid-unit) * 6); /* 24px — relaxed spacing */
|
||||
--sp-5: calc(var(--grid-unit) * 8); /* 32px — section spacing */
|
||||
--sp-6: calc(var(--grid-unit) * 12); /* 48px — panel spacing */
|
||||
--sp-7: calc(var(--grid-unit) * 16); /* 64px — page spacing */
|
||||
--sp-8: calc(var(--grid-unit) * 24); /* 96px — hero spacing */
|
||||
}
|
||||
```
|
||||
|
||||
### Logic di Balik Scale
|
||||
|
||||
| Token | px | Konteks |
|
||||
|-------|----|---------|
|
||||
| `--sp-0.5` | 4px | Ikon-padding, badge spacing, dot indicators |
|
||||
| `--sp-1` | 8px | Avatar-text gap, icon-button padding |
|
||||
| `--sp-2` | 12px | Button padding, chip spacing, input padding |
|
||||
| `--sp-3` | 16px | **Base unit** — card-padding, section margin, form gap |
|
||||
| `--sp-4` | 24px | Card gap, panel padding, modal padding |
|
||||
| `--sp-5` | 32px | Content area padding, desktop sidebar width |
|
||||
| `--sp-6` | 48px | Page section gap, dashboards grid gap |
|
||||
| `--sp-7` | 64px | Page padding desktop, hero spacing |
|
||||
| `--sp-8` | 96px | Empty state height, large break sections |
|
||||
|
||||
---
|
||||
|
||||
## 🔲 Layout Components
|
||||
|
||||
### Page Layout (Desktop)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ┌── Header ──┐ │ 56px
|
||||
│ └────────────┘ │
|
||||
│ ┌─ Sidebar ─┐ ┌────────── Main Content ──────────┐│
|
||||
│ │ │ │ ┌─ Page Title ─────────────────┐ ││
|
||||
│ │ icon grid │ │ │ Section Heading │ ││
|
||||
│ │ ──────── │ │ └─────────────────────────────┘ ││
|
||||
│ │ nav-1 │ │ ││
|
||||
│ │ nav-2 │ │ ┌────── Grid Area ─────────────┐ ││
|
||||
│ │ nav-3 │ │ │ ┌── Card ──┐ ┌── Card ──┐ │ ││
|
||||
│ │ │ │ │ │ │ │ │ │ ││
|
||||
│ │ ──────── │ │ │ └──────────┘ └──────────┘ │ ││
|
||||
│ │ mascot │ │ │ ┌── Card ──┐ ┌── Card ──┐ │ ││
|
||||
│ │ │ │ │ │ │ │ │ │ ││
|
||||
│ └────────────┘ │ │ └──────────┘ └──────────┘ │ ││
|
||||
│ │ └───────────────────────────────┘ ││
|
||||
│ w-64/ │ flex-1 ││
|
||||
│ w-16(icon) │ ││
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Vue 3 Component Mapping
|
||||
|
||||
```tsx
|
||||
// DashboardLayout.vue
|
||||
<template>
|
||||
<div class="page-layout">
|
||||
<Sidebar :collapsed="sidebarCollapsed" />
|
||||
<main class="main-area">
|
||||
<Header />
|
||||
<div class="content-area">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
```css
|
||||
.page-layout {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-row: 1 / -1;
|
||||
width: 256px; /* w-64 */
|
||||
transition: width var(--dur-normal) var(--ease-out-quint);
|
||||
}
|
||||
.sidebar.collapsed {
|
||||
width: 64px; /* w-16 */
|
||||
}
|
||||
|
||||
.main-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0; /* Prevent grid blowout */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔳 Border Radius Scale
|
||||
|
||||
```css
|
||||
--rd-none: 0px;
|
||||
--rd-xs: 4px; /* Checkbox, toggle, small indicators */
|
||||
--rd-sm: 6px; /* Input, button small, badges */
|
||||
--rd-md: 8px; /* Button default, card, modal */
|
||||
--rd-lg: 12px; /* Card elevated, sheets, panels */
|
||||
--rd-xl: 16px; /* Dialog, bottom sheet */
|
||||
--rd-2xl: 20px; /* Full-width cards on mobile */
|
||||
--rd-full: 9999px; /* Pill, chip, avatar */
|
||||
```
|
||||
|
||||
### Radius Decision Tree
|
||||
|
||||
```
|
||||
Elemen interaktif?
|
||||
├── Ya ─→ butuh affordance visual?
|
||||
│ ├── Ya, utama (button, card clickable) → --rd-md (8px)
|
||||
│ └── Tidak (chip, tag, avatar) → --rd-full (pill)
|
||||
│
|
||||
└── Tidak → container?
|
||||
├── Dialog/modal → --rd-xl (16px)
|
||||
├── Card dalam grid → --rd-lg (12px)
|
||||
└── Sheet/panel → --rd-xl atau --rd-2xl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🥞 Z-Index Registry (Formal)
|
||||
|
||||
```css
|
||||
:root {
|
||||
--z-base: 0;
|
||||
--z-dropdown: 10;
|
||||
--z-sticky: 20;
|
||||
--z-header: 30;
|
||||
--z-sidebar: 40;
|
||||
--z-overlay: 50; /* Mobile sidebar, backdrop */
|
||||
--z-modal: 60; /* Dialog, confirm */
|
||||
--z-popover: 70; /* Tooltip, popover, dropdown menu */
|
||||
--z-toast: 80; /* Toast notification */
|
||||
--z-mascot: 100; /* Mascot chatbot — highest */
|
||||
}
|
||||
|
||||
/* Implementation */
|
||||
.header { z-index: var(--z-header); }
|
||||
.sidebar { z-index: var(--z-sidebar); }
|
||||
.modal { z-index: var(--z-modal); }
|
||||
.toast { z-index: var(--z-toast); }
|
||||
.mascot-chat { z-index: var(--z-mascot); }
|
||||
```
|
||||
|
||||
### Stacking Order
|
||||
|
||||
```
|
||||
Layer Value Elemen
|
||||
─────────────────────────────────────────────
|
||||
Background -1 ParticleBackground
|
||||
Base 0 Layout, cards, text
|
||||
Dropdown 10 Select options, context menu
|
||||
Sticky 20 Sticky section headers
|
||||
Header 30 Sticky page header
|
||||
Sidebar 40 Desktop sidebar
|
||||
Overlay 50 Backdrop, mobile drawer
|
||||
Modal 60 Confirm dialog, modal
|
||||
Popover 70 Tooltip, dropdown
|
||||
Toast 80 Toast notifications
|
||||
Mascot 100 Chatbot floating panel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 Responsive Breakpoints
|
||||
|
||||
```css
|
||||
/* CSS Custom Properties for breakpoints */
|
||||
:root {
|
||||
--bp-sm: 640px;
|
||||
--bp-md: 768px;
|
||||
--bp-lg: 1024px;
|
||||
--bp-xl: 1280px;
|
||||
--bp-2xl: 1536px;
|
||||
}
|
||||
|
||||
/* Container max-width */
|
||||
--container-sm: 640px;
|
||||
--container-md: 768px;
|
||||
--container-lg: 1024px;
|
||||
--container-xl: 1280px;
|
||||
```
|
||||
|
||||
### Layout Behavior per Breakpoint
|
||||
|
||||
| Breakpoint | Sidebar | Content Padding | Grid Columns |
|
||||
|-----------|---------|-----------------|--------------|
|
||||
| `<640px` | Bottom tab (56px) | `--sp-3` (16px) | 1 |
|
||||
| `640–768px` | Bottom tab | `--sp-4` (24px) | 1 |
|
||||
| `768–1024px` | Icon-only (64px) | `--sp-4` (24px) | 1–2 |
|
||||
| `1024–1280px` | Full (256px) | `--sp-5` (32px) | 2–3 |
|
||||
| `1280px+` | Full (256px) | `--sp-5` (32px) | 2–4 |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Common Layout Patterns
|
||||
|
||||
### Card Grid
|
||||
|
||||
```css
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
```
|
||||
|
||||
### Two-Column Detail
|
||||
|
||||
```css
|
||||
.two-column {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.two-column {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sidebar + Content (Live Panel)
|
||||
|
||||
```css
|
||||
.live-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px; /* Content 1fr, sidebar fixed */
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.live-layout {
|
||||
grid-template-columns: 1fr; /* Stack on smaller screens */
|
||||
}
|
||||
}
|
||||
|
||||
.live-sidebar {
|
||||
position: sticky;
|
||||
top: calc(56px + var(--sp-4)); /* Below header */
|
||||
max-height: calc(100vh - 56px - var(--sp-4) * 2);
|
||||
overflow-y: auto;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Container Queries (Modern Approach)
|
||||
|
||||
Untuk komponen yang reusable di berbagai konteks:
|
||||
|
||||
```css
|
||||
.card-grid-component {
|
||||
container-type: inline-size;
|
||||
container-name: card-grid;
|
||||
}
|
||||
|
||||
@container card-grid (max-width: 400px) {
|
||||
.card-item {
|
||||
grid-template-columns: 1fr; /* Single column di container kecil */
|
||||
}
|
||||
}
|
||||
|
||||
@container card-grid (min-width: 401px) {
|
||||
.card-item {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Anti-Patterns Spasial
|
||||
|
||||
### ❌ Margin collapse tanpa sengaja
|
||||
```css
|
||||
/* ❌ JANGAN — flex gap jauh lebih aman */
|
||||
.card + .card { margin-top: 16px; } /* Rawan collapse */
|
||||
|
||||
/* ✅ Gunakan gap */
|
||||
.card-grid { display: flex; flex-direction: column; gap: var(--sp-4); }
|
||||
```
|
||||
|
||||
### ❌ Padding tidak konsisten
|
||||
```css
|
||||
/* ❌ JANGAN — setiap file punya padding sendiri */
|
||||
.page-a { padding: 20px; }
|
||||
.page-b { padding: 24px; }
|
||||
|
||||
/* ✅ Gunakan spacing token */
|
||||
.page { padding: var(--sp-5); }
|
||||
```
|
||||
|
||||
### ❌ Grid blowout (min-width tanpa min-width: 0)
|
||||
```css
|
||||
/* ❌ JANGAN — grid item dengan teks panjang mendorong layout */
|
||||
.grid-item { overflow: visible; }
|
||||
|
||||
/* ✅ Cegah blowout */
|
||||
.grid-item { min-width: 0; overflow: hidden; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*"Ruang adalah kanvas tempat ingatan menari — setiap piksel memiliki tempatnya."* ❄️🩵
|
||||
@@ -0,0 +1,552 @@
|
||||
# Motion System — The Dance of Glass
|
||||
|
||||
> *"Animation is not about making things move. It's about making things *believe*."*
|
||||
> — Richard Williams, *The Animator's Survival Kit*
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Filosofi Gerak
|
||||
|
||||
Animasi di BETE bukan sekadar efek visual — ia adalah **bahasa kinetik** yang mengkomunikasikan relasi spasial antar elemen:
|
||||
|
||||
| Gerakan | Makna |
|
||||
|---------|-------|
|
||||
| **Slide from right** | Elemen baru datang dari "luar" — panel, drawer |
|
||||
| **Fade in + scale** | Muncul dari "dalam" — modal, dialog |
|
||||
| **Slide up** | Konten baru melanjutkan alur vertikal |
|
||||
| **Scale + glow** | Sedang diproses — loading, analysis |
|
||||
| **Spring bounce (subtle)** | Konfirmasi sukses — centang, badge |
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ Timing & Easing — The Physics Engine
|
||||
|
||||
### Duration Tokens
|
||||
|
||||
```css
|
||||
:root {
|
||||
--dur-instant: 0ms;
|
||||
--dur-fast: 150ms; /* Hover, active state, toggle */
|
||||
--dur-normal: 250ms; /* Default transition, card hover */
|
||||
--dur-slow: 350ms; /* Panel enter/exit, page transition */
|
||||
--dur-glacial: 500ms; /* Modal, drawer slide, emphasis */
|
||||
}
|
||||
```
|
||||
|
||||
### Easing Curves
|
||||
|
||||
BETE menggunakan tiga easing curve utama, semuanya **custom cubic-bezier**:
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Standard ease-out — untuk sebagian besar interaksi */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/* Ease-out quint — untuk elemen yang "mendarat" */
|
||||
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
/* Ease-in-out — untuk transisi dua arah (accordion, collapse) */
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
}
|
||||
```
|
||||
|
||||
### Perbandingan dengan CSS Default
|
||||
|
||||
| Nama | Bezier | Karakter |
|
||||
|------|--------|----------|
|
||||
| `ease` (default) | `(0.25, 0.1, 0.25, 1)` | Lambat mulai, lambat akhir |
|
||||
| `ease-out` | `(0, 0, 0.58, 1)` | Cepat mulai |
|
||||
| `ease-in-out` | `(0.42, 0, 0.58, 1)` | Simetris |
|
||||
| **`--ease-out`** | `(0.16, 1, 0.3, 1)` | Natural, "berat" di akhir |
|
||||
| **`--ease-out-quint`** | `(0.22, 1, 0.36, 1)` | Landing yang tegas |
|
||||
|
||||
---
|
||||
|
||||
## 🏃 Micro-interactions
|
||||
|
||||
### Hover State
|
||||
|
||||
```css
|
||||
/* Card hover — subtle lift + shadow deepen */
|
||||
.card {
|
||||
transition:
|
||||
transform var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--sh-hover);
|
||||
}
|
||||
|
||||
/* Button hover — scale up subtly */
|
||||
.button {
|
||||
transition: transform var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.button:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
```
|
||||
|
||||
### Active/Press State
|
||||
|
||||
```css
|
||||
.button:active {
|
||||
transform: scale(0.96);
|
||||
transition-duration: var(--dur-fast);
|
||||
}
|
||||
|
||||
/* Button ripple effect */
|
||||
.button.ripple {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.button.ripple::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.3) 10%, transparent 10%);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1000% 1000%;
|
||||
opacity: 0;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.button.ripple:active::after {
|
||||
background-size: 0% 0%;
|
||||
opacity: 1;
|
||||
transition: background-size 0.4s, opacity 0.4s;
|
||||
}
|
||||
```
|
||||
|
||||
### Focus Ring
|
||||
|
||||
```css
|
||||
.button:focus-visible {
|
||||
outline: 2px solid var(--clr-primary-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Smooth ring transition */
|
||||
.input {
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--clr-primary);
|
||||
box-shadow: 0 0 0 3px var(--clr-primary-bg);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Page & Panel Transitions
|
||||
|
||||
### Tab Switch — Choreographed Sequence
|
||||
|
||||
```css
|
||||
/* Stagger container */
|
||||
.page-transition-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.page-transition-enter-active {
|
||||
opacity: 1;
|
||||
transition: opacity var(--dur-slow) var(--ease-out);
|
||||
}
|
||||
|
||||
/* Children stagger — via Framer Motion / GSAP timeline */
|
||||
@keyframes stagger-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.stagger-item {
|
||||
animation: stagger-enter var(--dur-slow) var(--ease-out) both;
|
||||
}
|
||||
|
||||
.stagger-item:nth-child(1) { animation-delay: 40ms; }
|
||||
.stagger-item:nth-child(2) { animation-delay: 80ms; }
|
||||
.stagger-item:nth-child(3) { animation-delay: 120ms; }
|
||||
.stagger-item:nth-child(4) { animation-delay: 160ms; }
|
||||
.stagger-item:nth-child(5) { animation-delay: 200ms; }
|
||||
/* ... formula: delay = index * 40ms */
|
||||
```
|
||||
|
||||
### Vue Transition Mode
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Transition
|
||||
name="page"
|
||||
mode="out-in"
|
||||
@before-leave="beforeLeave"
|
||||
@after-enter="afterEnter"
|
||||
>
|
||||
<component :is="currentTab" :key="currentTab" />
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.page-leave-active {
|
||||
transition: opacity var(--dur-normal) var(--ease-in-out),
|
||||
transform var(--dur-normal) var(--ease-in-out);
|
||||
}
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
.page-enter-active {
|
||||
transition: opacity var(--dur-slow) var(--ease-out),
|
||||
transform var(--dur-slow) var(--ease-out);
|
||||
}
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Component-Specific Animations
|
||||
|
||||
### Sidebar Expand/Collapse
|
||||
|
||||
```css
|
||||
.sidebar {
|
||||
width: var(--sidebar-width, 256px);
|
||||
transition: width var(--dur-slow) var(--ease-out-quint);
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
--sidebar-width: 64px;
|
||||
}
|
||||
|
||||
/* Nav items — icon slides, text fades */
|
||||
.sidebar-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-nav-item .label {
|
||||
transition: opacity var(--dur-normal) var(--ease-out),
|
||||
width var(--dur-normal) var(--ease-out);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-nav-item .label {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
```
|
||||
|
||||
### Toast Notification
|
||||
|
||||
```css
|
||||
.toast-enter-active {
|
||||
animation: toast-slide-in var(--dur-slow) var(--ease-out-quint);
|
||||
}
|
||||
|
||||
.toast-leave-active {
|
||||
animation: toast-slide-out var(--dur-normal) var(--ease-in-out);
|
||||
}
|
||||
|
||||
@keyframes toast-slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-slide-out {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Modal / Dialog
|
||||
|
||||
```css
|
||||
.modal-overlay-enter-active {
|
||||
transition: opacity var(--dur-normal) var(--ease-out);
|
||||
}
|
||||
.modal-overlay-enter-from { opacity: 0; }
|
||||
|
||||
.modal-content-enter-active {
|
||||
animation: modal-scale-in var(--dur-slow) var(--ease-out-quint);
|
||||
}
|
||||
|
||||
@keyframes modal-scale-in {
|
||||
from {
|
||||
transform: scale(0.92) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Special Effects
|
||||
|
||||
### Audio Visualizer Bars
|
||||
|
||||
```css
|
||||
.visualizer-bar {
|
||||
animation: bar-pulse 0.4s ease-in-out infinite;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.visualizer-bar:nth-child(1) { animation-delay: 0ms; }
|
||||
.visualizer-bar:nth-child(2) { animation-delay: 75ms; }
|
||||
.visualizer-bar:nth-child(3) { animation-delay: 150ms; }
|
||||
.visualizer-bar:nth-child(4) { animation-delay: 225ms; }
|
||||
|
||||
@keyframes bar-pulse {
|
||||
0%, 100% { transform: scaleY(0.8); }
|
||||
50% { transform: scaleY(1.2); }
|
||||
}
|
||||
```
|
||||
|
||||
### Skeleton Loading (Shimmer)
|
||||
|
||||
```css
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--clr-surface-sunken) 25%,
|
||||
var(--clr-surface-elevated) 50%,
|
||||
var(--clr-surface-sunken) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from { background-position: 200% 0; }
|
||||
to { background-position: -200% 0; }
|
||||
}
|
||||
```
|
||||
|
||||
### AI Analysis Pulse (Glow)
|
||||
|
||||
```css
|
||||
.ai-badge {
|
||||
animation: ai-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ai-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 4px var(--clr-rose-500 / 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 12px var(--clr-rose-500 / 0.4);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ghost Particle (Background)
|
||||
|
||||
```css
|
||||
.particle {
|
||||
position: fixed;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
animation: float var(--dur-float, 8s) ease-in-out infinite;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0) translateX(0) scale(1);
|
||||
opacity: 0.3;
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-20px) translateX(10px) scale(1.1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-40px) translateX(-5px) scale(0.9);
|
||||
opacity: 0.4;
|
||||
}
|
||||
75% {
|
||||
transform: translateY(-20px) translateX(15px) scale(1.05);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Motion Decision Tree
|
||||
|
||||
```
|
||||
Elemen apa yang dianimasikan?
|
||||
│
|
||||
├── Hover/Interaksi → dur-fast (150ms) + --ease-out
|
||||
│ ├── Card → translateY(-2px) + shadow deepen
|
||||
│ ├── Button → scale(1.02) / scale(0.98)
|
||||
│ ├── Link → opacity/underline
|
||||
│ └── Icon → rotate/color
|
||||
│
|
||||
├── Masuk ke halaman → dur-slow (350ms) + stagger
|
||||
│ ├── Halaman baru → fade + slideY(12px)
|
||||
│ ├── List items → stagger (40ms per item)
|
||||
│ └── Modal → scale(0.92→1) + fade overlay
|
||||
│
|
||||
├── Keluar dari halaman → dur-normal (250ms) + ease-in-out
|
||||
│ ├── Halaman → fade + slideY(-8px)
|
||||
│ ├── Toast → slideX(100%)
|
||||
│ └── Modal → scale(→0.95) + fade overlay
|
||||
│
|
||||
├── Loading → infinite loop
|
||||
│ ├── Skeleton → shimmer 1.5s
|
||||
│ ├── Spinner → spin
|
||||
│ └── AI Analysis → glow-pulse 2s
|
||||
│
|
||||
└── State change → dur-normal (250ms) + --ease-out
|
||||
├── Sidebar → width transition
|
||||
├── Accordion → height transition
|
||||
└── Badge → scale(0.8→1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ♿ Reduced Motion
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
/* But allow opacity transitions for basic UX */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Programmatic Check
|
||||
|
||||
```typescript
|
||||
// hooks/useReducedMotion.ts
|
||||
export function useReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
setReduced(mq.matches);
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
// Dalam komponen
|
||||
<script setup>
|
||||
const reducedMotion = useReducedMotion();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition :duration="reducedMotion ? 0 : 350">
|
||||
...
|
||||
</Transition>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Anti-Patterns Animasi
|
||||
|
||||
### ❌ Durasi terlalu lama
|
||||
```css
|
||||
/* ❌ JANGAN — 1 detik terasa lambat */
|
||||
.sidebar { transition: width 1s ease; }
|
||||
|
||||
/* ✅ 250–350ms adalah sweet spot UI */
|
||||
.sidebar { transition: width var(--dur-slow) var(--ease-out-quint); }
|
||||
```
|
||||
|
||||
### ❌ Semua bergerak bersamaan (tanpa stagger)
|
||||
```css
|
||||
/* ❌ JANGAN — tidak graceful */
|
||||
.card { animation: fadeIn 0.3s ease; }
|
||||
|
||||
/* ✅ Stagger menciptakan gelombang natural */
|
||||
.card:nth-child(1) { animation-delay: 0ms; }
|
||||
.card:nth-child(2) { animation-delay: 40ms; }
|
||||
```
|
||||
|
||||
### ❌ Easing yang salah untuk konteks
|
||||
```css
|
||||
/* ❌ JANGAN — ease-in untuk enter terasa lambat di awal */
|
||||
.modal { animation: scaleIn 0.3s ease-in; }
|
||||
|
||||
/* ✅ ease-out untuk enter — cepat mulai, soft berhenti */
|
||||
.modal { animation: scaleIn 0.3s var(--ease-out-quint); }
|
||||
```
|
||||
|
||||
### ❌ Mengabaikan reduced motion
|
||||
```css
|
||||
/* ❌ JANGAN — tidak accessible */
|
||||
.particle { animation: float 8s infinite; }
|
||||
|
||||
/* ✅ Diberhentikan untuk reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.particle { display: none; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Referensi
|
||||
|
||||
| Sumber | Konsep |
|
||||
|--------|--------|
|
||||
| [Easing Functions Cheat Sheet](https://easings.net/) | Visualisasi easing curves |
|
||||
| [Material Motion](https://m2.material.io/design/motion/) | Sistem motion Google |
|
||||
| [GSAP](https://gsap.com/) | Production-grade animation library |
|
||||
| [AnimXYZ](https://animxyz.com/) | Utility-first CSS animations |
|
||||
|
||||
---
|
||||
|
||||
*"Gerak adalah bahasa ingatan yang tak terucap — setiap transisi adalah cerita."* ❄️🩵
|
||||
@@ -0,0 +1,313 @@
|
||||
# Component Architecture — The Glass Library
|
||||
|
||||
> *"Design is not just what it looks like and feels like. Design is how it works."*
|
||||
> — Steve Jobs
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Filosofi Komponen
|
||||
|
||||
Setiap komponen di BETE adalah **self-contained glass panel** yang:
|
||||
1. **Satu tanggung jawab** — Satu komponen, satu fungsi
|
||||
2. **State-driven** — Visual merepresentasikan state, bukan sebaliknya
|
||||
3. **Composable** — Bisa digabung seperti LEGO
|
||||
4. **Theme-aware** — Menggunakan CSS variables, bukan hardcoded values
|
||||
5. **Accessible** — Keyboard, screen reader, reduced motion
|
||||
|
||||
---
|
||||
|
||||
## 📐 Component Taxonomy
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ COMPONENT MAP │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─── ATOMS ──────────────────────────────────────────┐ │
|
||||
│ │ Button │ Badge │ Input │ Label │ Icon │ │
|
||||
│ │ Avatar │ Skeleton│ Spinner │ Divider│ Tooltip │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─── MOLECULES ───────────────────────────────────────┐ │
|
||||
│ │ Card │ Tabs │ Select │ │
|
||||
│ │ Toast │ Modal │ Dropdown │ │
|
||||
│ │ Pagination │ Breadcrumb │ SearchBar │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─── ORGANISMS ───────────────────────────────────────┐ │
|
||||
│ │ MessageCard │ VoiceCard │ AnalyticsChart │ │
|
||||
│ │ ActiveSpeaker │ NowPlaying │ ImageGrid │ │
|
||||
│ │ MascotChatbot │ AudioViz │ StatsCard │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─── TEMPLATES ───────────────────────────────────────┐ │
|
||||
│ │ Sidebar │ Header │ DashboardLayout │ │
|
||||
│ │ AuthForm │ LivePanel │ MessagesPanel │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧱 Atomic Components (Atoms)
|
||||
|
||||
### Button
|
||||
|
||||
```tsx
|
||||
interface ButtonProps {
|
||||
variant: 'primary' | 'secondary' | 'destructive' | 'outline' | 'ghost';
|
||||
size: 'sm' | 'default' | 'lg' | 'icon';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
icon?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
.button {
|
||||
--btn-bg: var(--clr-primary);
|
||||
--btn-color: var(--clr-text-on-primary);
|
||||
--btn-border: transparent;
|
||||
--btn-hover-bg: var(--clr-primary-600);
|
||||
--btn-active-transform: scale(0.97);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-1);
|
||||
border-radius: var(--rd-md);
|
||||
font-family: var(--ff-sans);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
line-height: var(--lh-compact);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
background: var(--btn-bg);
|
||||
color: var(--btn-color);
|
||||
border: 1px solid var(--btn-border);
|
||||
|
||||
transition:
|
||||
transform var(--dur-fast) var(--ease-out),
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out);
|
||||
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
height: 40px;
|
||||
}
|
||||
.button--sm { height: 32px; padding: var(--sp-0-5) var(--sp-2); }
|
||||
.button--lg { height: 48px; padding: var(--sp-2) var(--sp-4); }
|
||||
.button--icon { height: 40px; width: 40px; padding: 0; }
|
||||
.button--secondary { --btn-bg: var(--clr-interactive-hover); --btn-color: var(--clr-text); }
|
||||
.button--destructive { --btn-bg: var(--clr-ruby-500); --btn-color: white; }
|
||||
.button--outline { --btn-bg: transparent; --btn-color: var(--clr-text); --btn-border: var(--clr-border); }
|
||||
.button--ghost { --btn-bg: transparent; --btn-color: var(--clr-text); }
|
||||
|
||||
.button:hover { background: var(--btn-hover-bg); }
|
||||
.button:active { transform: var(--btn-active-transform); }
|
||||
.button:disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; }
|
||||
.button:focus-visible { outline: 2px solid var(--clr-primary); outline-offset: 2px; }
|
||||
```
|
||||
|
||||
### Badge
|
||||
|
||||
```tsx
|
||||
interface BadgeProps {
|
||||
variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline' | 'severity';
|
||||
severity?: 'safe' | 'low' | 'medium' | 'high' | 'critical';
|
||||
size?: 'sm' | 'default';
|
||||
dot?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⛓️ Molecular Components (Molecules)
|
||||
|
||||
### Card
|
||||
|
||||
```tsx
|
||||
interface CardProps {
|
||||
variant?: 'default' | 'elevated' | 'glass' | 'interactive';
|
||||
padding?: 'sm' | 'default' | 'lg' | 'none';
|
||||
hover?: boolean;
|
||||
as?: 'div' | 'button' | 'a';
|
||||
onClick?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Sub-components
|
||||
Card.Header — flex-col gap-1.5
|
||||
Card.Title — h3, font-semibold
|
||||
Card.Description — p, text-sm, text-muted
|
||||
Card.Content — main area with padding
|
||||
Card.Footer — flex items-center
|
||||
```
|
||||
|
||||
### Modal / Dialog
|
||||
|
||||
```tsx
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
size?: 'sm' | 'default' | 'lg' | 'full';
|
||||
closeOnOverlay?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
```
|
||||
|
||||
### Toast
|
||||
|
||||
```tsx
|
||||
interface Toast {
|
||||
id: string;
|
||||
type: 'info' | 'success' | 'error' | 'warning';
|
||||
title: string;
|
||||
description?: string;
|
||||
duration?: number; // Auto-dismiss ms, 0 = persistent
|
||||
action?: { label: string; onClick: () => void; };
|
||||
}
|
||||
|
||||
function useToast(): {
|
||||
toast: (t: Omit<Toast, 'id'>) => string;
|
||||
dismiss: (id: string) => void;
|
||||
toasts: Toast[];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧬 Organism Components
|
||||
|
||||
### MessageCard
|
||||
|
||||
```tsx
|
||||
interface MessageCardProps {
|
||||
message: {
|
||||
id: string;
|
||||
content: string;
|
||||
author: { id: string; name: string; avatar: string; };
|
||||
timestamp: number;
|
||||
channel?: { id: string; name: string; };
|
||||
attachments?: Attachment[];
|
||||
aiAnalysis?: {
|
||||
status: 'pending' | 'analyzing' | 'complete' | 'error';
|
||||
severity?: 'safe' | 'low' | 'medium' | 'high' | 'critical';
|
||||
categories?: string[];
|
||||
summary?: string;
|
||||
};
|
||||
isEdited?: boolean;
|
||||
isDeleted?: boolean;
|
||||
};
|
||||
onReanalyze?: (messageId: string) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### AudioVisualizer — Canvas-based
|
||||
|
||||
```tsx
|
||||
interface AudioVisualizerProps {
|
||||
frequencies: Uint8Array;
|
||||
barCount?: number; // Default 48
|
||||
gradient?: [string, string];
|
||||
height?: number; // Default 32
|
||||
mirrored?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 State Management per Komponen
|
||||
|
||||
Setiap komponen mengelola state visual:
|
||||
|
||||
```tsx
|
||||
// 1. Normal
|
||||
<div className="card">...</div>
|
||||
|
||||
// 2. Hover
|
||||
<div className="card card--interactive">...</div>
|
||||
|
||||
// 3. Active/Focus
|
||||
<div className="card card--interactive" aria-pressed="true">...</div>
|
||||
|
||||
// 4. Disabled
|
||||
<div className="card opacity-50 pointer-events-none">...</div>
|
||||
|
||||
// ± Loading (data-dependent)
|
||||
<div className="card"><Skeleton className="h-4 w-3/4" /></div>
|
||||
|
||||
// ± Error (data-dependent)
|
||||
<div className="card border-destructive">
|
||||
<p className="text-destructive">Failed to load</p>
|
||||
</div>
|
||||
|
||||
// ± Empty (data-dependent)
|
||||
<div className="card">
|
||||
<EmptyState icon={MessageSquare} message="No messages" />
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Component Anti-Patterns
|
||||
|
||||
### ❌ Prop Drilling Berlebihan
|
||||
```tsx
|
||||
// ❌ JANGAN — props turun 4 level
|
||||
<Dashboard user={user} messages={messages} settings={settings} />
|
||||
|
||||
// ✅ Gunakan context atau komposisi
|
||||
<Dashboard>
|
||||
<MessageFeed>
|
||||
<MessageCard />
|
||||
</MessageFeed>
|
||||
</Dashboard>
|
||||
```
|
||||
|
||||
### ❌ Komponen terlalu besar
|
||||
```tsx
|
||||
// ❌ JANGAN — 400+ baris
|
||||
function LivePanel() { /* 400 lines */ }
|
||||
|
||||
// ✅ Bagi ke sub-komponen
|
||||
function LivePanel() {
|
||||
return (
|
||||
<div>
|
||||
<VoiceControls />
|
||||
<ActiveSpeakers />
|
||||
<NowPlaying />
|
||||
<RecordingsList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Conditional terlalu kompleks
|
||||
```tsx
|
||||
// ❌ JANGAN — ternary bersarang
|
||||
return isError ? <Error /> : isLoading ? <Loading /> : isEmpty ? <Empty /> : <Content />;
|
||||
|
||||
// ✅ State machine pattern
|
||||
const state = getComponentState({ isLoading, isError, isEmpty, data });
|
||||
return <ComponentStateMachine state={state} />;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Referensi
|
||||
|
||||
| Sumber | Konsep |
|
||||
|--------|--------|
|
||||
| [Atomic Design (Brad Frost)](https://atomicdesign.bradfrost.com/) | Atom-molecule-organism |
|
||||
| [Radix UI](https://www.radix-ui.com/) | Headless UI primitives |
|
||||
| [shadcn/ui](https://ui.shadcn.com/) | Component pattern reference |
|
||||
|
||||
---
|
||||
|
||||
*"Komponen adalah kristal ingatan — setiap bagian kecil menyatu membentuk keindahan yang utuh."* ❄️🩵
|
||||
Reference in New Issue
Block a user