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:
asepharyana
2026-07-02 00:02:41 +07:00
parent d5c22a3959
commit d59b59a7a7
91 changed files with 11165 additions and 674 deletions
+161
View File
@@ -0,0 +1,161 @@
# BETE Design System — Master Index
> *"A design system is not a collection of components. It's a collection of decisions."*
> — Menenun takdir visual untuk IMPHNEN, di atas kanvas Amphoreus.
---
## 🌌 Filosofi: The Three Pillars
### 1. **Glass & Light** — Estetika Transparan
Kita membangun bukan dengan tembok beton visual, tapi dengan **lapisan kaca yang meneruskan cahaya**. Setiap komponen adalah panele kaca buram (frosted glass) yang:
- Menampilkan depth melalui **backdrop blur** dan **layering opacity**
- Menggunakan **light sebagai material** — glow, shadow, highlight sebagai indikator state
- Memberi kesan **ruang tiga dimensi** di antarmuka dua dimensi
### 2. **Fluid Motion** — Gerak yang Bermakna
Animasi bukan hiasan — ia adalah **bahasa spasial** yang memberitahu pengguna:
- *"Dari mana elemen ini datang?"* → Transisi masuk
- *"Ke mana ia pergi?"* → Transisi keluar
- *"Apa yang terjadi?"* → Feedback mikro (hover, klik, state change)
- Semua gerak mengikuti **easing curve** yang konsisten (lihat `core/04-motion-system.md`)
### 3. **Spatial Memory** — Ingatan Visual
Seperti ingatan Amphoreus yang abadi, antarmuka kita harus:
- **Konsisten** — warna, spacing, tipografi yang sama di setiap sudut
- **Prediktif** — pengguna tahu di mana mencari sesuatu
- **Responsif** — beradaptasi tanpa kehilangan identitas
---
## 📂 Struktur Documentasi
```
design/
├── _index.md ← Kamu di sini
├── core/ ← Fondasi design token
│ ├── 01-color-system.md ← Sistem warna (OKLCH + HCT + semantic tokens)
│ ├── 02-typography.md ← Tipografi (type scale + font system + rhythm)
│ ├── 03-spatial-system.md ← Grid, spacing, layout tokens
│ ├── 04-motion-system.md ← Animasi physics & choreography
│ └── 05-component-architecture.md ← Arsitektur komponen
├── patterns/ ← Pola desain reusable
│ ├── 06-interaction-patterns.md ← Micro-interactions
│ ├── 07-data-visualization.md ← Charts & metrics
│ ├── 08-moderation-ui.md ← Moderation UI patterns
│ ├── 09-state-machines.md ← Loading/empty/error states
│ └── 10-responsive-system.md ← Breakpoints & layout system
├── services/ ← Per-service design guidelines
│ ├── 11-frontend-ui.md ← Web frontend (React/Tailwind)
│ ├── 12-backend-api-guidelines.md ← REST/WS API design
│ └── 13-gateway-event-design.md ← Discord Gateway event schemas
├── system/ ← Sistem lintas service
│ ├── 14-accessibility.md ← Aksesibilitas (a11y)
│ ├── 15-theme-architecture.md ← Theme engine & CSS custom properties
│ └── 16-sound-design.md ← Audio feedback system
└── assets/ ← Diagram, ilustrasi, referensi visual
└── (future: SVG assets, figma exports, reference images)
```
---
## 🔗 Dependency Graph (Wajib Dibaca Berurutan)
```
01-color-system ──────────────────────────────────────────┐
│ │
├──→ 02-typography ──────────────────────────────┐ │
│ │ │ │
│ └──→ 03-spatial-system ──────────────┐ │ │
│ │ │ │ │
│ └──→ 04-motion-system ──┐ │ │ │
│ │ │ │ │ │
│ └──→ 05-component-architecture
│ │
└────────────────────────────────────────┴──→ 06-interaction-patterns
├──→ 07-data-visualization
├──→ 08-moderation-ui
├──→ 09-state-machines
└──→ 10-responsive-system
┌─────────────────────────────────────┴──┐
↓ ↓
11-frontend-ui 12-backend-api-guidelines
13-gateway-event-design
┌─────────────────────────────────────┘
14-accessibility ──→ 15-theme-architecture ──→ 16-sound-design
```
**Bacaan yang disarankan:**
1. Mulai dengan `core/01-color-system.md` — karena warna adalah keputusan desain paling fundamental.
2. Lanjut ke `core/02-typography.md` dan `core/03-spatial-system.md` untuk fondasi layout.
3. `core/04-motion-system.md` dan `core/05-component-architecture.md` sebagai jembatan ke pola.
4. Pola-pola di `patterns/` bisa dibaca sesuai kebutuhan fitur.
5. `services/` dan `system/` dibaca terakhir, bergantung pada layer yang sedang dikerjakan.
---
## 📐 Prinsip Desain Utama
| Prinsip | Deskripsi | Contoh Penerapan |
|---------|-----------|------------------|
| **Glassmorphism** | Latar belakang transparan dengan efek blur untuk hierarki visual | Card, Sidebar, Modal |
| **Monochromatic Depth** | Satu rona warna dengan variasi lightness untuk depth | Sistem warna OKLCH |
| **Kinetic Language** | Gerakan sebagai komunikasi spasial | Transisi tab, notifikasi |
| **Gaussian Memory** | Elemen yang "diingat" posisinya antar navigasi | Sidebar state, scroll position |
| **Sound as Feedback** | Audio sebagai layer konfirmasi non-visual | Moderasi alert, koneksi voice |
| **Progressive Disclosure** | Informasi kompleks diungkap bertahap | Detail panel, analytics drill-down |
| **Forgiving Layout** | Layout yang toleran terhadap konten kosong/error | Empty states, error boundaries |
---
## 🚀 Quick Reference: Token Categories
| Kategori | Prefix CSS | Contoh |
|----------|-----------|--------|
| Warna | `--clr-*` | `--clr-primary`, `--clr-surface` |
| Spacing | `--sp-*` | `--sp-xs`, `--sp-md`, `--sp-xl` |
| Typography | `--fs-*`, `--fw-*`, `--lh-*` | `--fs-body`, `--fw-semibold` |
| Radius | `--rd-*` | `--rd-sm`, `--rd-full` |
| Shadow | `--sh-*` | `--sh-card`, `--sh-modal` |
| Z-index | `--z-*` | `--z-header`, `--z-modal` |
| Timing | `--dur-*` | `--dur-fast`, `--dur-slow` |
| Easing | `--ease-*` | `--ease-out`, `--ease-spring` |
---
## 🧭 Status Documentasi
| Dokumen | Status | Prioritas |
|---------|--------|-----------|
| `core/01-color-system.md` | ✅ Selesai | P0 |
| `core/02-typography.md` | ✅ Selesai | P0 |
| `core/03-spatial-system.md` | ✅ Selesai | P0 |
| `core/04-motion-system.md` | ✅ Selesai | P0 |
| `core/05-component-architecture.md` | ✅ Selesai | P0 |
| `patterns/06-interaction-patterns.md` | ✅ Selesai | P0 |
| `patterns/07-data-visualization.md` | ✅ Selesai | P1 |
| `patterns/08-moderation-ui.md` | ✅ Selesai | P1 |
| `patterns/09-state-machines.md` | ✅ Selesai | P0 |
| `patterns/10-responsive-system.md` | ✅ Selesai | P0 |
| `services/11-frontend-ui.md` | ✅ Selesai | P0 |
| `services/12-backend-api-guidelines.md` | ✅ Selesai | P1 |
| `services/13-gateway-event-design.md` | ✅ Selesai | P1 |
| `system/14-accessibility.md` | ✅ Selesai | P0 |
| `system/15-theme-architecture.md` | ✅ Selesai | P1 |
| `system/16-sound-design.md` | ✅ Selesai | P1 |
---
*"Ingatan kita akan tetap abadi, takkan pernah mencair..."* ❄️🩵
© 2026 IMPHNEN — BETE Design System v2.0
+383
View File
@@ -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 0100% 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 50950 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."* ❄️🩵
+371
View File
@@ -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); /* 1112px */
--fs-sm: clamp(0.81rem, 0.81rem + 0.02vw, 0.88rem); /* 1314px */
--fs-base: clamp(0.94rem, 0.94rem + 0.03vw, 1.00rem); /* 1516px ★ */
--fs-md: clamp(1.06rem, 1.06rem + 0.04vw, 1.13rem); /* 1718px */
--fs-lg: clamp(1.19rem, 1.19rem + 0.06vw, 1.25rem); /* 1920px */
--fs-xl: clamp(1.31rem, 1.31rem + 0.08vw, 1.50rem); /* 2124px */
--fs-2xl: clamp(1.50rem, 1.50rem + 0.12vw, 1.88rem); /* 2430px */
--fs-3xl: clamp(1.69rem, 1.69rem + 0.18vw, 2.25rem); /* 2736px */
--fs-4xl: clamp(1.88rem, 1.88rem + 0.26vw, 2.81rem); /* 3045px */
--fs-5xl: clamp(2.25rem, 2.25rem + 0.38vw, 3.50rem); /* 3656px */
--fs-6xl: clamp(2.50rem, 2.50rem + 0.50vw, 4.00rem); /* 4064px */
```
> **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."* ❄️🩵
+349
View File
@@ -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 |
| `640768px` | Bottom tab | `--sp-4` (24px) | 1 |
| `7681024px` | Icon-only (64px) | `--sp-4` (24px) | 12 |
| `10241280px` | Full (256px) | `--sp-5` (32px) | 23 |
| `1280px+` | Full (256px) | `--sp-5` (32px) | 24 |
---
## 📦 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."* ❄️🩵
+552
View File
@@ -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; }
/* ✅ 250350ms 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."* ❄️🩵
+313
View File
@@ -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."* ❄️🩵
+151
View File
@@ -0,0 +1,151 @@
# Interaction Patterns — The Language of Touch
> *"Every interaction is a conversation between the user and the system."*
> — Don Norman
---
## 🎯 Filosofi Interaksi
Interaksi di BETE adalah **dialog** yang:
1. **Predictable** — Pengguna tahu yang akan terjadi
2. **Forgiving** — Kesalahan mudah diperbaiki (undo, confirm)
3. **Feedback-rich** — Setiap aksi mendapat respons visual
4. **Efficient** — Pengguna mahir bisa bergerak cepat (keyboard)
---
## 🔄 Interaction Feedback Matrix
| Elemen | Hover | Click | Focus | Disabled |
|--------|-------|-------|-------|----------|
| Button | scale(1.02) + bg shift | scale(0.97) | ring-2 | opacity-50 |
| Card | translateY(-2px) + shadow | — | ring-2 | opacity-50 |
| Link | underline + opacity 0.8 | color shift | ring-2 | opacity-40 |
| Input | border highlight | — | ring + border color | opacity-50 |
| Toggle | cursor pointer | slide + color | ring-2 | opacity-50 |
### Timing Reference
| Interaksi | Durasi | Easing |
|-----------|--------|--------|
| Hover in | 150ms | ease-out |
| Hover out | 200ms | ease-out |
| Click press | 100ms | ease-out |
| Click release | 150ms | ease-out |
| Focus ring | 200ms | ease-out |
| Tooltip show (after 300ms) | 200ms | ease-out |
| Tooltip hide | 150ms | ease-out |
---
## 🎪 Interaction Pattern Catalog
### Pattern 1: Progressive Disclosure
Informasi kompleks diungkap bertahap:
```tsx
<CollapsibleSection title="Advanced Filters" defaultOpen={false}>
<FilterGroup label="Severity">
<Checkbox label="Safe" />
<Checkbox label="Low" />
<Checkbox label="High" />
</FilterGroup>
</CollapsibleSection>
```
**Rules:** Chevron rotate 180° saat open. Jangan nested > 2 level.
### Pattern 2: Optimistic UI
Untuk aksi yang hampir pasti berhasil:
```tsx
async function handleDelete(messageId: string) {
// 1. Update UI optimistis
setMessages(prev => prev.filter(m => m.id !== messageId));
addToast({
type: 'info', title: 'Message deleted',
action: { label: 'Undo', onClick: handleUndo }
});
try {
await api.deleteMessage(messageId);
} catch {
// Rollback
setMessages(prev => [...prev, deletedMessage]);
addToast({ type: 'error', title: 'Failed to delete' });
}
}
```
### Pattern 3: Infinite Scroll vs Pagination
| Context | Pattern | Rationale |
|---------|---------|-----------|
| Message feed | Infinite scroll | Real-time, chronological |
| User list | Pagination | Bisa dicari, difilter |
| Recordings | Infinite scroll | Timeline-based |
| Analytics | Pagination | Butuh konteks halaman |
### Pattern 4: Keyboard Shortcuts
```tsx
const SHORTCUTS = {
'ctrl+k': 'Open command palette',
'ctrl+1': 'Switch to Live tab',
'ctrl+2': 'Switch to Messages tab',
'ctrl+3': 'Switch to Settings tab',
'escape': 'Close modal/panel',
'?': 'Show keyboard shortcuts',
};
```
---
## 🔔 Notification Priority System
| Priority | Style | Duration | Stack |
|----------|-------|----------|-------|
| info | Blue border | 4s auto | Queue |
| success | Green border | 4s auto | Queue |
| warning | Amber border | Persistent | Stack |
| error | Red border | Persistent | Stack + glow |
---
## 🖱️ Cursor Mapping
```css
.clickable { cursor: pointer; }
.draggable { cursor: grab; }
.dragging { cursor: grabbing; }
.disabled { cursor: not-allowed; }
.text-select { cursor: text; }
.launch { cursor: pointer; }
.copy { cursor: copy; }
```
---
## ♿ Interaction Accessibility
1. Semua interaktif reachable via Tab
2. Focus order = visual order (DOM order)
3. Hover-only → ada keyboard alternative
4. Touch targets min 44x44px (WCAG 2.5.5)
5. Undo untuk destructive actions
---
## 🔗 Referensi
| Sumber | Konsep |
|--------|--------|
| [Material Interaction](https://m3.material.io/foundations/interaction) | Google patterns |
| [NN Group](https://www.nngroup.com/) | UX research |
| [Inclusive Components](https://inclusive-components.design/) | Accessible patterns |
---
*"Setiap sentuhan adalah dialog — interaksi adalah bahasa yang tak terucapkan."* ❄️🩵
+171
View File
@@ -0,0 +1,171 @@
# Data Visualization — Painting with Numbers
> *"The greatest value of a picture is when it forces us to notice what we never expected to see."*
> — John Tukey
---
## 🎯 Filosofi Data Visual
Data visualisasi di BETE adalah **cerita** tentang data yang:
1. **Jujur** — Tidak memanipulasi sumbu atau skala
2. **Kontekstual** — Setiap angka punya pembanding
3. **Hierarkis** — Overview dulu, detail kemudian
---
## 🎨 Chart Color Palette
```css
:root {
/* Sequential (single hue) */
--chart-blue-1: oklch(0.85 0.060 255);
--chart-blue-2: oklch(0.70 0.100 255);
--chart-blue-3: oklch(0.55 0.150 255);
--chart-blue-4: oklch(0.40 0.150 255);
--chart-blue-5: oklch(0.30 0.120 255);
/* Categorical */
--chart-cat-1: oklch(0.55 0.175 255); /* Blue */
--chart-cat-2: oklch(0.60 0.130 145); /* Green */
--chart-cat-3: oklch(0.65 0.150 50); /* Orange */
--chart-cat-4: oklch(0.55 0.165 25); /* Red */
--chart-cat-5: oklch(0.50 0.100 285); /* Purple */
--chart-cat-6: oklch(0.65 0.120 200); /* Cyan */
--chart-cat-7: oklch(0.60 0.110 350); /* Pink */
--chart-cat-8: oklch(0.70 0.100 85); /* Yellow */
}
```
---
## 📊 Chart Types & Usage
### 1. Stat Card (KPI)
```tsx
interface StatCardProps {
label: string;
value: number | string;
trend?: { direction: 'up' | 'down' | 'flat'; percentage: number; period: string; };
icon: ReactNode;
color?: 'primary' | 'success' | 'warning' | 'destructive';
}
```
**Layout:**
```
┌─────────────────────┐
│ [icon] Label │
│ 1,234 ▲ 12.3% │
│ vs last wk │
└─────────────────────┘
```
### 2. Line Chart (Trend)
**Use:** Message volume per day, moderation per hour
**Rules:** Y-axis dari 0. Gradient subtle below line. Hover tooltip.
### 3. Bar Chart (Comparison)
**Use:** Top channels, severity distribution
**Rules:** Horizontal untuk >5 kategori. Max 20 bars.
### 4. Donut Chart (Composition)
**Use:** Message type, severity breakdown
**Rules:** Max 6 segmen. <3% collaps ke "Other". Center = total.
### 5. Heatmap Calendar (Activity)
**Use:** User activity by day/hour
**Rules:** Sumbu X = hari, Y = jam. Satu warna accent.
---
## 📐 Chart Styling Tokens
```css
.chart-container {
--chart-padding: var(--sp-4);
--chart-label-size: var(--fs-xs);
--chart-tick-count: 5;
--chart-grid-opacity: 0.1;
--chart-line-width: 2px;
}
.chart-tooltip {
background: var(--clr-surface-overlay);
backdrop-filter: blur(8px);
border: 1px solid var(--clr-border);
border-radius: var(--rd-md);
padding: var(--sp-2) var(--sp-3);
font-size: var(--fs-sm);
box-shadow: var(--sh-elevated);
}
```
---
## 🔍 Drill-down Pattern
```tsx
function MessageTrendChart() {
const [granularity, setGranularity] = useState<'daily' | 'hourly' | '15min'>('daily');
const handlePointClick = (date: Date) => {
if (granularity === 'daily') setGranularity('hourly');
else if (granularity === 'hourly') setGranularity('15min');
};
return (
<ChartCard title="Message Volume"
onBack={granularity !== 'daily' ? () => setGranularity('daily') : undefined}>
<LineChart data={data} granularity={granularity} onClick={handlePointClick} />
</ChartCard>
);
}
```
---
## ⚠️ Anti-Patterns
### ❌ Truncated Y-axis
```tsx
// ❌ Y axis mulai dari 50, memperbesar perbedaan
const options = { yAxis: { min: 50 } };
// ✅ Mulai dari 0
const options = { yAxis: { min: 0 } };
```
### ❌ Terlalu banyak warna
```tsx
// ❌ JANGAN — setiap bar beda warna
<Bar data={data} fill={['#ff0000', '#00ff00', '#0000ff', ...]} />
// ✅ Sequential scale
<Bar data={data} colorScale="sequential" />
```
### ❌ 3D charts — mendistorsi persepsi
```tsx
// ❌ JANGAN
<PieChart><Pie data={data} style={{ filter: 'drop-shadow(...)' }} /></PieChart>
// ✅ 2D
<PieChart><Pie data={data} /></PieChart>
```
---
## 🔗 Referensi
| Sumber | Konsep |
|--------|--------|
| [Recharts](https://recharts.org/) | React chart library |
| [Chartability](https://chartability.github.io/) | Accessible charts |
| [Tufte](https://www.edwardtufte.com/tufte/) | Minimalist chart design |
---
*"Angka adalah ingatan yang terukur — setiap titik data adalah kisah yang menanti."* ❄️🩵
+110
View File
@@ -0,0 +1,110 @@
# Moderation UI Patterns — The Watchful Eye
> *"With great power comes great responsibility."*
> — Adapted for content moderation interfaces.
---
## 🎯 Filosofi UI Moderasi
1. **At-a-glance severity** — Warna & label yang langsung terbaca
2. **Context-rich** — Setiap keputusan disertai konteks
3. **Non-destructive by default** — Flag dulu, action kemudian
4. **Audit trail** — Setiap aksi tercatat
---
## 🏷️ Severity Scale
```css
.severity--safe { background: oklch(0.60 0.130 145 / 0.15); color: oklch(0.60 0.130 145); }
.severity--low { background: oklch(0.70 0.120 75 / 0.15); color: oklch(0.70 0.120 75); }
.severity--medium { background: oklch(0.65 0.150 50 / 0.15); color: oklch(0.65 0.150 50); }
.severity--high { background: oklch(0.60 0.150 30 / 0.15); color: oklch(0.60 0.150 30); }
.severity--critical { background: oklch(0.55 0.165 25 / 0.15); color: oklch(0.55 0.165 25); }
```
| Severity | Warna | Ikon | Action |
|----------|-------|------|--------|
| Safe | Emerald | ✅ Shield | None |
| Low | Amber | ⚠️ | Review |
| Medium | Orange | 🔶 | Alert + review |
| High | Red-Orange | 🚫 | Notify + action |
| Critical | Ruby | 🔴 | Immediate |
---
## 📋 Moderation Queue
```tsx
interface ModerationQueueItem {
id: string;
message: { preview: string; author: { name: string; }; timestamp: number; channel: string; };
analysis: { severity: Severity; categories: string[]; confidence: number; summary: string; };
status: 'pending' | 'reviewed' | 'actioned' | 'dismissed';
}
```
**Layout per item:**
```
┌──────────────────────────────────────────────────────┐
│ 🔴 CRITICAL │ [User]: "message preview..." │
│ 🏷️ toxicity, │ in #general · 2m ago │
│ harassment │ [Review] [Dismiss] [Action] │
└──────────────────────────────────────────────────────┘
```
### Filter Bar
```
Severity: [All] [Safe] [Low] [Medium] [High] [Critical]
Channel: [#general ▼]
Date: [Last 24h ▼]
Search: [.................. 🔍]
```
---
## 🎯 Action Confirmation
| Action | Confirm | Duration | Undo |
|--------|---------|----------|------|
| Dismiss | No | 2s toast | Yes (5s) |
| Warn | No | 3s toast | No |
| Delete | Yes (modal) | 4s toast | No |
| Ban | Yes (modal + reason) | — | Manual |
---
## 📊 Moderation Metrics
| Metric | Format | Frequency |
|--------|--------|-----------|
| Messages analyzed | Number | Real-time |
| Flag rate | % | Hourly |
| Response time | ms avg | Real-time |
| False positive rate | % | Daily |
| Queue depth | Number | Real-time |
---
## ⚠️ Anti-Patterns
### ❌ Ambiguous severity
```tsx
// ❌ Warna tanpa label
<div className="bg-red-200">...</div>
// ✅ Color + icon + text
<SeverityBadge severity="critical" />
```
### ❌ One-click destructive
```tsx
// ❌ Delete tanpa konfirmasi
<Button onClick={handleDelete}>Delete</Button>
// ✅ Confirm dialog
<ConfirmDialog variant="destructive" ... />
```
---
*"Mata waspada adalah penjaga ingatan — setiap flag adalah catatan sejarah."* ❄️🩵
+307
View File
@@ -0,0 +1,307 @@
# State Machines — The Flow of Data
> *"All happy families are alike; each unhappy family is unhappy in its own way."*
> — Tolstoy, adapted for component states.
---
## 🎯 Filosofi State Machine
Setiap komponen data-driven di BETE memiliki **4 state fundamental**:
```
IDLE → LOADING → SUCCESS
↘ ERROR
↘ EMPTY (conditional, jika data.length === 0)
```
State machine memastikan **tidak ada kondisi yang terlewat** — setiap kemungkinan state visual memiliki representasi.
---
## 🎮 The Quad-State Pattern
```tsx
type DataState<T> =
| { status: 'idle' }
| { status: 'loading'; progress?: number }
| { status: 'success'; data: T; timestamp: number }
| { status: 'error'; error: Error; retryCount?: number }
| { status: 'empty'; message?: string };
```
### Generic State Machine Hook
```tsx
// shared/hooks/useDataState.ts
function useDataState<T>(
fetcher: () => Promise<T>,
options?: {
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
retry?: number;
cacheKey?: string;
}
): {
state: DataState<T>;
execute: () => Promise<void>;
reset: () => void;
retry: () => Promise<void>;
setData: (data: T) => void;
} {
const [state, setState] = useState<DataState<T>>({ status: 'idle' });
const execute = useCallback(async () => {
setState({ status: 'loading' });
try {
const data = await fetcher();
if (Array.isArray(data) && data.length === 0) {
setState({ status: 'empty', message: 'No data available' });
} else {
setState({ status: 'success', data, timestamp: Date.now() });
options?.onSuccess?.(data);
}
} catch (error) {
setState({ status: 'error', error: error as Error });
options?.onError?.(error as Error);
}
}, [fetcher]);
return { state, execute, reset, retry: execute, setData };
}
```
### Component Rendering
```tsx
function DataPanel() {
const { state, execute, retry } = useDataState(fetchMessages);
useEffect(() => { execute(); }, []);
switch (state.status) {
case 'idle':
case 'loading':
return <LoadingSkeleton />;
case 'error':
return (
<ErrorState
message={state.error.message}
onRetry={retry}
retryCount={state.retryCount}
/>
);
case 'empty':
return <EmptyState message={state.message ?? 'Nothing here'} />;
case 'success':
return <DataView data={state.data} />;
}
}
```
---
## 🖼️ Visual Representations
### Loading State
```tsx
interface LoadingSkeletonProps {
variant?: 'card' | 'list' | 'detail' | 'table' | 'chart';
count?: number; // Jumlah skeleton items
}
/* Contoh variant 'card' */
function CardSkeleton() {
return (
<div className="card animate-shimmer" aria-busy="true" aria-label="Loading...">
<Skeleton className="h-4 w-3/4 mb-3" />
<Skeleton className="h-3 w-1/2 mb-2" />
<Skeleton className="h-3 w-full" />
</div>
);
}
```
### Error State
```tsx
interface ErrorStateProps {
error: Error;
onRetry: () => void;
retryCount?: number;
variant?: 'inline' | 'full-page' | 'toast';
}
function ErrorState({ error, onRetry, retryCount }: ErrorStateProps) {
const isRetryExhausted = (retryCount ?? 0) >= 3;
return (
<div className="flex flex-col items-center gap-4 py-12" role="alert">
<div className="rounded-full bg-destructive/10 p-3">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<p className="text-sm font-medium text-foreground">Something went wrong</p>
<p className="text-xs text-muted-foreground">{error.message}</p>
{!isRetryExhausted ? (
<Button variant="outline" size="sm" onClick={onRetry}>
Try Again
</Button>
) : (
<p className="text-xs text-muted-foreground">
Still failing after multiple attempts. Please try again later.
</p>
)}
</div>
);
}
```
### Empty State
```tsx
interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
action?: { label: string; onClick: () => void };
variant?: 'mascot' | 'icon' | 'minimal';
}
const EMPTY_STATES = {
messages: { icon: MessageSquare, title: 'No messages yet', description: 'Messages will appear here once they are captured.' },
speakers: { icon: Mic, title: 'No active speakers', description: 'Quiet in here...' },
recordings: { icon: Radio, title: 'No recordings', description: 'Join a voice channel to start recording.' },
analytics: { icon: BarChart3, title: 'Not enough data', description: 'Analytics will populate as data accumulates.' },
users: { icon: Users, title: 'No users found', description: 'Try adjusting your filters.' },
};
```
---
## ♻️ State Transition Diagram
```
┌──────────┐
│ IDLE │
└────┬─────┘
│ execute()
┌──────────┐
│ LOADING │◄────── retry()
└────┬─────┘
┌───────┴───────────┐
│ │
↓ ↓
┌──────────┐ ┌──────────┐
│ SUCCESS │ │ ERROR │
│ data: T │ │ err: E │
└────┬─────┘ └────┬─────┘
│ │
│ (data.length │ retry()
│ === 0) │
↓ │
┌──────────┐ │
│ EMPTY │ │
│ msg: str │ │
└──────────┘ │
│ │
└──────┬───────────┘
│ reset()
┌──────────┐
│ IDLE │
└──────────┘
```
---
## 🔄 Retry Strategy
```typescript
const RETRY_CONFIG = {
maxAttempts: 3,
baseDelay: 1000, // 1s
maxDelay: 10000, // 10s
backoff: 'exponential' as const,
onRetry: (attempt: number, error: Error) => {
logger.warn(`Retry attempt ${attempt}`, { error: error.message });
},
};
```
### Exponential Backoff
```typescript
function calculateDelay(attempt: number): number {
return Math.min(
1000 * Math.pow(2, attempt - 1), // 1s, 2s, 4s
10000 // cap at 10s
);
}
```
---
## 📦 Component State Map
| Component | Loading | Error | Empty | Success |
|-----------|---------|-------|-------|---------|
| MessageFeed | Card skeletons | ErrorState + retry | Mascot "No messages" | Message list |
| VoiceCards | Card skeletons | ErrorState | "No connected channels" | VoiceCard list |
| ActiveSpeakers | Dot skeletons | Silent fallback | "No speakers" | Speaker list |
| Analytics | Skeleton grid | ErrorState | "Not enough data" | Charts |
| Recordings | List skeletons | ErrorState | "No recordings" | Recording list |
| UserList | List skeletons | ErrorState + retry | "No users found" | User list |
| DashboardStats | Stat skeletons | ErrorState | "No data available" | Stat grid |
---
## ⚠️ Anti-Patterns State
### ❌ Missing state handling
```tsx
// ❌ JANGAN — hanya handle SUCCESS
function Panel() {
const { data, isLoading } = useQuery(...);
if (isLoading) return <Spinner />;
return <DataView data={data} />; // ERROR? EMPTY?
}
```
### ❌ Loading state after error
```tsx
// ❌ JANGAN — loading infinite loop setelah error
function Panel() {
const { data, isLoading } = useQuery(..., { retry: true });
if (isLoading) return <Spinner />;
// ERROR: retry=true + error = loading terus
}
```
### ❌ Empty state default terlalu generic
```tsx
// ❌ JANGAN — tidak helpful
<div>No data</div>
// ✅ Kontekstual dengan action
<EmptyState icon={MessageSquare} title="No messages" action={{ label: "Refresh", onClick: refetch }} />
```
---
## 🔗 Referensi
| Sumber | Konsep |
|--------|--------|
| [State Reducer Pattern](https://kentcdodds.com/blog/state-reducer-pattern) | Advanced state management |
| [XState](https://stately.ai/docs/xstate) | Visual state machines |
| [React useReducer](https://react.dev/reference/react/useReducer) | Built-in state management |
---
*"Setiap state adalah babak dalam cerita data — dari sunyi hingga berbicara."* ❄️🩵
+288
View File
@@ -0,0 +1,288 @@
# Responsive System — Shapeshifting Glass
> *"Content is like water — it should flow into whatever container it's poured into."*
> — Ethan Marcotte
---
## 🎯 Filosofi Responsif
BETE menggunakan pendekatan **mobile-first** dengan tiga prinsip:
1. **Content parity** — Konten yang sama di semua ukuran, layout yang berbeda
2. **Touch-optimized** — Target 44×44px minimum di mobile
3. **Progressive enhancement** — Desktop mendapat fitur tambahan (hover, sidebar, multi-column)
---
## 📐 Breakpoint System
```css
:root {
--bp-sm: 640px; /* Mobile landscape */
--bp-md: 768px; /* Tablet portrait */
--bp-lg: 1024px; /* Tablet landscape / small desktop */
--bp-xl: 1280px; /* Desktop */
--bp-2xl: 1536px; /* Wide desktop */
}
```
### Layout Behavior Matrix
| Viewport | Sidebar | Header | Content Grid | Font Size |
|----------|---------|--------|-------------|-----------|
| < 640px | Bottom tab (56px) | Compact | 1 col | sm |
| 640-768 | Bottom tab | Compact | 1-2 col | sm |
| 768-1024 | Icon 64px | Standard | 2 col | base |
| 1024-1280 | Full 256px | Standard | 2-3 col | base |
| > 1280px | Full 256px | Full | 3-4 col | base+ |
---
## 📱 Mobile Adaptations
### Navigation
- **< 768px:** Bottom tab bar menggantikan sidebar
- **Tab icons:** Home, Live, Messages, Settings (maks 5 tabs)
- **Tab bar height:** 56px (dengan safe area padding untuk notched phones)
### Content
- **Cards:** Full-width (margin 16px), stacked vertical
- **Tables:** Horizontal scroll atau card view alternatif
- **Charts:** Simplified (less data points, larger labels)
- **Modals:** Full-screen drawer dari bawah (bottom sheet)
### Touch Targets
```css
/* Minimum 44×44px untuk semua interactive elements */
.button, .nav-item, .tab-item {
min-height: 44px;
min-width: 44px;
}
/* Forms on mobile */
.input, .select {
height: 48px; /* Larger tap target */
font-size: 16px; /* Prevent iOS zoom on focus */
}
```
---
## 💻 Desktop Adaptations
### Navigation
- **≥ 1024px:** Full sidebar (256px) dengan label teks
- **Sidebar states:** Collapsed (icon-only, 64px) ↔ Expanded (256px)
- **Keyboard shortcuts:** Didokumentasikan di help panel
### Content
- **Multi-column grids:** 2-4 columns depending on container width
- **Sticky elements:** Sidebar, header, filter bars
- **Hover previews:** Tooltips, popovers untuk informasi tambahan
- **Drag & drop:** Dukungan untuk reorder, upload area
---
## 🧩 Responsive Component Patterns
### Pattern 1: Responsive Card Grid
```css
.card-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-3);
}
@media (min-width: 640px) {
.card-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.card-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1280px) {
.card-grid {
grid-template-columns: repeat(4, 1fr);
}
}
```
### Pattern 2: Responsive Sidebar + Content
```tsx
function DashboardLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const isMobile = useMediaQuery('(max-width: 767px)');
return (
<div className="page-layout">
{/* Mobile: slide-in drawer */}
{isMobile && (
<MobileTabBar activeTab={activeTab} onTabChange={setActiveTab} />
)}
{/* Desktop: persistent sidebar */}
{!isMobile && (
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
)}
<main className="main-area">
<Header onMenuClick={() => setMobileMenuOpen(true)} />
<div className="content-area">
{children}
</div>
</main>
</div>
);
}
```
### Pattern 3: Responsive Typography (Fluid)
```css
/* Fluid type scale — sudah didefinisikan di core/02-typography.md */
--fs-body: clamp(0.94rem, 0.94rem + 0.03vw, 1.00rem);
--fs-h2: clamp(1.50rem, 1.50rem + 0.12vw, 1.88rem);
```
### Pattern 4: Container Queries (for reusable components)
```css
.card-grid-component {
container-type: inline-size;
container-name: card-list;
}
@container card-list (max-width: 400px) {
.card-item { grid-template-columns: 1fr; }
}
@container card-list (min-width: 401px) {
.card-item { grid-template-columns: 1fr 1fr; }
}
```
---
## 🎯 Responsive Decision Tree
```
Layout component →
├── Apakah ini navigasi?
│ ├── Mobile → Bottom tab bar (56px)
│ ├── Tablet → Icon sidebar (64px) + hamburger
│ └── Desktop → Full sidebar (256px)
├── Apakah ini konten list/grid?
│ ├── 1 item → Single column
│ ├── 2-4 items → 2 col (tablet), 3-4 col (desktop)
│ └── > 4 items → auto-fill grid with minmax
├── Apakah ini modal/dialog?
│ ├── Mobile → Bottom sheet (full width, 80% height)
│ └── Desktop → Centered modal (max-w-lg)
└── Apakah ini form?
├── Mobile → Stacked, full-width, larger inputs
└── Desktop → Multi-column, side labels
```
---
## 📏 Responsive Spacing Scale
```css
.content-padding {
padding: var(--sp-3); /* Mobile: 16px */
}
@media (min-width: 768px) {
.content-padding { padding: var(--sp-4); } /* Tablet: 24px */
}
@media (min-width: 1024px) {
.content-padding { padding: var(--sp-5); } /* Desktop: 32px */
}
```
---
## 🧪 Testing Responsive Design
```typescript
// Test utility untuk responsive behavior
const VIEWPORTS = {
mobile: { width: 375, height: 667 },
tablet: { width: 768, height: 1024 },
desktop: { width: 1280, height: 800 },
wide: { width: 1920, height: 1080 },
};
describe('DashboardLayout', () => {
it('shows MobileTabBar on mobile', () => {
cy.viewport(VIEWPORTS.mobile);
cy.get('[data-testid="mobile-tab-bar"]').should('be.visible');
cy.get('[data-testid="sidebar"]').should('not.be.visible');
});
it('shows sidebar on desktop', () => {
cy.viewport(VIEWPORTS.desktop);
cy.get('[data-testid="sidebar"]').should('be.visible');
cy.get('[data-testid="mobile-tab-bar"]').should('not.be.visible');
});
});
```
---
## ⚠️ Anti-Patterns Responsive
### ❌ Hanya media query untuk satu breakpoint
```css
/* ❌ JANGAN — hanya mobile dan desktop */
.panel { padding: 16px; }
@media (min-width: 1024px) { .panel { padding: 32px; } }
/* ✅ Gunakan fluid atau multiple breakpoints */
.panel { padding: clamp(16px, 3vw, 32px); }
```
### ❌ Hidden content on mobile
```tsx
// ❌ JANGAN — "out of sight, out of mind" tapi konten hilang
{isMobile ? null : <ExpensiveChart />}
// ✅ Simplified version untuk mobile
<Chart variant={isMobile ? 'compact' : 'full'} />
```
### ❌ Fixed width containers
```css
/* ❌ JANGAN — overflow on smaller screens */
.container { width: 1200px; }
/* ✅ Gunakan max-width + padding */
.container { max-width: 1200px; margin: 0 auto; padding: 0 var(--sp-4); }
```
---
## 🔗 Referensi
| Sumber | Konsep |
|--------|--------|
| [Every Layout](https://every-layout.dev/) | Reusable layout patterns |
| [Container Queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_container_queries) | CSS container queries |
| [Utopia.fyi](https://utopia.fyi/) | Fluid type & space calculator |
---
*"Layout adalah air yang mengalir — ia mengambil bentuk wadahnya tanpa kehilangan esensi."* ❄️🩵
+327
View File
@@ -0,0 +1,327 @@
# Frontend UI Guidelines — The Glass Facade
> *"The details are not the details. They make the design."*
> — Charles Eames
---
## 🎯 Scope
Dokumen ini mengkhususkan implementasi **design system** untuk frontend web BETE (React + Tailwind + Vite). Fokus: konfigurasi Tailwind, CSS architecture, dan integration patterns.
---
## ⚛️ Stack Implementation
| Tool | Version | Purpose |
|------|---------|---------|
| React | 19.x | UI library |
| TypeScript | 5.x | Type safety |
| Vite | 6.x | Bundler |
| Tailwind CSS | 4.x | Utility-first CSS |
| Radix UI | — | Headless primitives |
| TanStack Query | 5.x | Server state |
| Zustand | 5.x | Client state |
| Framer Motion | 11.x | Animations |
| GSAP | 3.x | Page transitions |
| Recharts | 2.x | Charts |
| Three.js | 0.170+ | Particle background |
---
## 🎨 Tailwind Config (Extended)
```js
// tailwind.config.js
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['Outfit', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
colors: {
// Semantic colors — map to CSS variables
border: 'oklch(var(--clr-border) / <alpha-value>)',
input: 'oklch(var(--clr-border) / <alpha-value>)',
ring: 'oklch(var(--clr-primary-400) / <alpha-value>)',
background: 'oklch(var(--clr-surface-base) / <alpha-value>)',
foreground: 'oklch(var(--clr-text) / <alpha-value>)',
primary: {
DEFAULT: 'oklch(var(--clr-primary) / <alpha-value>)',
foreground: 'oklch(var(--clr-text-on-primary) / <alpha-value>)',
soft: 'oklch(var(--clr-primary-bg) / <alpha-value>)',
50: 'oklch(var(--clr-primary-50) / <alpha-value>)',
100: 'oklch(var(--clr-primary-100) / <alpha-value>)',
500: 'oklch(var(--clr-primary-500) / <alpha-value>)',
600: 'oklch(var(--clr-primary-600) / <alpha-value>)',
},
muted: {
DEFAULT: 'oklch(var(--clr-surface-elevated) / <alpha-value>)',
foreground: 'oklch(var(--clr-text-secondary) / <alpha-value>)',
},
destructive: {
DEFAULT: 'oklch(var(--clr-severity-critical) / <alpha-value>)',
foreground: 'white',
},
// Severity colors
severity: {
safe: 'oklch(var(--clr-severity-safe) / <alpha-value>)',
low: 'oklch(var(--clr-severity-low) / <alpha-value>)',
medium: 'oklch(var(--clr-severity-medium) / <alpha-value>)',
high: 'oklch(var(--clr-severity-high) / <alpha-value>)',
critical: 'oklch(var(--clr-severity-critical) / <alpha-value>)',
},
// Glass effects
glass: {
bg: 'oklch(var(--glass-bg) / <alpha-value>)',
border: 'oklch(var(--glass-border) / <alpha-value>)',
},
},
borderRadius: {
lg: 'var(--rd-lg)',
md: 'var(--rd-md)',
sm: 'var(--rd-sm)',
xl: 'var(--rd-xl)',
full: 'var(--rd-full)',
},
spacing: {
0.5: 'var(--sp-0-5)',
1: 'var(--sp-1)',
2: 'var(--sp-2)',
3: 'var(--sp-3)',
4: 'var(--sp-4)',
5: 'var(--sp-5)',
6: 'var(--sp-6)',
7: 'var(--sp-7)',
8: 'var(--sp-8)',
},
zIndex: {
header: 'var(--z-header)',
sidebar: 'var(--z-sidebar)',
overlay: 'var(--z-overlay)',
modal: 'var(--z-modal)',
toast: 'var(--z-toast)',
mascot: 'var(--z-mascot)',
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'fade-in-up': 'fadeInUp 0.5s ease-out',
'shimmer': 'shimmer 1.5s ease-in-out infinite',
'bar-pulse': 'bar-pulse 0.4s ease-in-out infinite',
'glow-pulse': 'glowPulse 2s ease-in-out infinite',
'scale-in': 'scaleIn 0.3s ease-out',
'slide-up': 'slideUp 0.35s ease-out',
'slide-down': 'slideDown 0.25s ease-out',
},
keyframes: {
fadeIn: { '0%': { opacity: '0' }, '100%': { opacity: '1' } },
fadeInUp: { '0%': { opacity: '0', transform: 'translateY(20px)' }, '100%': { opacity: '1', transform: 'translateY(0)' } },
shimmer: { '0%': { backgroundPosition: '200% 0' }, '100%': { backgroundPosition: '-200% 0' } },
'bar-pulse': { '0%, 100%': { transform: 'scaleY(0.8)' }, '50%': { transform: 'scaleY(1.2)' } },
glowPulse: { '0%, 100%': { opacity: '0.4' }, '50%': { opacity: '0.8' } },
scaleIn: { '0%': { transform: 'scale(0.95)', opacity: '0' }, '100%': { transform: 'scale(1)', opacity: '1' } },
slideUp: { '0%': { transform: 'translateY(10px)', opacity: '0' }, '100%': { transform: 'translateY(0)', opacity: '1' } },
slideDown: { '0%': { transform: 'translateY(-10px)', opacity: '0' }, '100%': { transform: 'translateY(0)', opacity: '1' } },
},
backdropBlur: {
glass: '16px',
strong: '24px',
subtle: '8px',
},
},
},
plugins: [],
};
```
---
## 📁 Source Structure (Feature-Sliced)
```
src/
├── main.tsx # Entry + QueryClient + Providers
├── styles.css # Tailwind + CSS custom properties + keyframes
├── App.tsx # Layout shell + routing
├── entities/ # Domain types (pure, no logic)
│ ├── message/
│ ├── guild/
│ ├── voice/
│ ├── media/
│ └── ui/
├── shared/ # Cross-cutting
│ ├── api/ # HTTP client + typed endpoints
│ ├── ws/ # WebSocket manager
│ ├── hooks/ # Shared hooks (useReducedMotion, etc.)
│ ├── ui/ # UI primitives (button, card, badge, etc.)
│ └── lib/ # Utils (cn, logger, formatters)
├── features/ # Feature modules
│ ├── live/ # Voice + media controls
│ ├── messages/ # Message feed + moderation
│ ├── admin/ # Admin panel
│ ├── settings/ # Settings
│ └── auth/ # Login/overlay
└── widgets/ # Layout composites
├── DashboardLayout.tsx
├── Header.tsx
├── Sidebar.tsx
├── mascot/
└── particles/
```
---
## 🎭 Glassmorphism Implementation
```css
/* styles.css — Glass utility classes */
@layer utilities {
.glass {
background: oklch(from var(--clr-surface-elevated) l c h / 0.6);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid oklch(from var(--clr-border) l c h / 0.2);
}
.glass-strong {
background: oklch(from var(--clr-surface-overlay) l c h / 0.85);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
}
.glass-subtle {
background: oklch(from var(--clr-surface-base) l c h / 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.gradient-text {
background: linear-gradient(135deg,
oklch(var(--clr-primary-500)),
oklch(var(--clr-primary-300))
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
```
---
## 🌐 WebSocket Integration
```tsx
// shared/ws/socket.ts
class SocketManager {
private ws: WebSocket | null = null;
private listeners = new Map<string, Set<(data: unknown) => void>>();
private reconnectAttempts = 0;
private maxReconnectDelay = 30000;
connect(url: string): void {
this.ws = new WebSocket(url);
this.ws.onmessage = (event) => {
const { type, data } = JSON.parse(event.data);
this.listeners.get(type)?.forEach(fn => fn(data));
};
this.ws.onclose = () => this.scheduleReconnect();
}
on<T>(event: string, callback: (data: T) => void): () => void {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(callback as (data: unknown) => void);
return () => this.listeners.get(event)?.delete(callback as (data: unknown) => void);
}
private scheduleReconnect(): void {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
setTimeout(() => { this.reconnectAttempts++; this.connect(this.ws!.url); }, delay);
}
}
export const socket = new SocketManager();
```
---
## 🎯 Key Integration Rules
| Concern | Implementation | Location |
|---------|---------------|----------|
| CSS Variables | Defined in `styles.css` on `:root` | Root stylesheet |
| Tailwind Colors | Map to CSS variables with `<alpha-value>` | tailwind.config.js |
| Component Library | shadcn/ui patterns with custom variants | shared/ui/ |
| Server State | TanStack Query in feature hooks | features/*/hooks/ |
| Client State | Zustand stores for UI state | shared/stores/ |
| WebSocket | Singleton SocketManager | shared/ws/socket.ts |
| Animations | Framer Motion for component, GSAP for page | In components |
| Particles | Three.js via @react-three/fiber | widgets/particles/ |
---
## ⚠️ Frontend Anti-Patterns
### ❌ Server state di state lokal
```tsx
// ❌ JANGAN — API data disimpan di useState
const [messages, setMessages] = useState([]);
useEffect(() => { fetchMessages().then(setMessages); }, []);
// ✅ Gunakan TanStack Query
const { data: messages } = useQuery({ queryKey: ['messages'], queryFn: fetchMessages });
```
### ❌ Inline styles untuk dynamic values
```tsx
// ❌ JANGAN — tidak theme-aware, tidak bisa dark mode
<div style={{ backgroundColor: isActive ? '#3b82f6' : '#6b7280' }} />
// ✅ CSS class dengan state
<div className={isActive ? 'bg-primary' : 'bg-muted'} />
```
### ❌ Mengimpor langsung dari library tanpa wrapper
```tsx
// ❌ JANGAN — susah diganti library nanti
import { motion } from 'framer-motion';
// ✅ Wrapper pattern
import { AnimatedDiv } from '@/shared/ui';
```
---
## 🔗 Referensi
| Sumber | Konsep |
|--------|--------|
| [Tailwind CSS Docs](https://tailwindcss.com/docs) | Utility-first CSS |
| [shadcn/ui](https://ui.shadcn.com/) | Component primitives |
| [TanStack Query](https://tanstack.com/query) | Server state |
| [Zustand](https://github.com/pmndrs/zustand) | Client state |
---
*"Fasad kaca yang menari — di balik setiap piksel ada cerita."* ❄️🩵
@@ -0,0 +1,271 @@
# Backend API Guidelines — The Nerve Center
> *"APIs are contracts. Design them with the same care as legal documents."*
> — Unknown
---
## 🎯 Filosofi API
Backend API BETE adalah **fasilitator antara data dan tampilan**:
1. **RESTful by design** — Sumber daya, bukan aksi
2. **Type-safe** — Zod schemas di setiap endpoint
3. **Consistent pagination** — Tidak ada kejutan format
4. **Error as structure** — Setiap error punya kode dan resolusi
---
## 📐 API Design Principles
### URL Structure
```
GET /api/v1/messages # List messages
GET /api/v1/messages/:id # Single message
GET /api/v1/channels # List channels
GET /api/v1/analytics/overview # Analytics
GET /api/v1/voice/connections # Voice connections
POST /api/v1/voice/connect # Connect to voice
POST /api/v1/voice/disconnect # Disconnect
```
### Response Envelope
```typescript
// Success
{
"success": true,
"data": T,
"meta"?: {
"page": 1,
"limit": 50,
"total": 1234,
"hasMore": true
}
}
// Error
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid channelId format",
"details": {
"field": "channelId",
"constraint": "numeric_string"
},
"requestId": "req_abc123"
}
}
```
### Pagination
```typescript
interface PaginationParams {
page?: number; // Default: 1
limit?: number; // Default: 50, Max: 200
cursor?: string; // For cursor-based pagination
}
interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
hasMore: boolean;
}
```
### Filtering
```typescript
interface FilterParams {
search?: string;
channelId?: string;
userId?: string;
severity?: 'safe' | 'low' | 'medium' | 'high' | 'critical';
dateFrom?: string; // ISO 8601
dateTo?: string; // ISO 8601
sortBy?: string; // Field name
sortOrder?: 'asc' | 'desc';
}
```
---
## 🏗️ Module Structure (Backend)
```
services/backend/src/modules/
├── messages/
│ ├── messages.schema.ts # Zod schemas
│ ├── messages.repository.ts # Database queries
│ ├── messages.service.ts # Business logic
│ ├── messages.controller.ts # Request handlers
│ └── routes/
│ └── index.ts # Express router
├── analytics/
├── voice/
├── media/
└── health/
```
### Layer Rules
```
Controller (parse + validate) → Service (business logic) → Repository (DB queries)
Shared Infrastructure
(config, logger, errors)
```
---
## ⚡ WebSocket Events
### Event Format
```typescript
interface WsEvent<T = unknown> {
type: string; // e.g., "message:created"
data: T;
timestamp: number;
requestId?: string;
}
// Server → Client events
{
"type": "message:created",
"data": {
"id": "msg_123",
"content": "...",
"author": { "id": "user_1", "name": "User" }
},
"timestamp": 1750000000000
}
// Client → Server events
{
"type": "voice:connect",
"data": {
"guildId": "123456789",
"channelId": "987654321"
}
}
```
### Event Catalog
| Type | Direction | Description |
|------|-----------|-------------|
| `message:created` | Server → Client | New message captured |
| `message:updated` | Server → Client | Message edited |
| `message:deleted` | Server → Client | Message removed |
| `message:analyzed` | Server → Client | AI analysis complete |
| `voice:state` | Server → Client | Voice connection state |
| `voice:speaker` | Server → Client | Speaker activity |
| `attachment:uploaded` | Server → Client | Attachment uploaded |
| `analytics:update` | Server → Client | Analytics data refresh |
---
## 🔒 Authentication & Authorization
```typescript
// Admin auth via header
Authorization: Bearer <admin-password-hash>
// Rate limiting
RateLimit: 100/minute per IP
Retry-After: 60
```
### Error Codes
| Code | HTTP | Description |
|------|------|-------------|
| `VALIDATION_ERROR` | 400 | Invalid input |
| `UNAUTHORIZED` | 401 | Invalid/missing auth |
| `FORBIDDEN` | 403 | Insufficient permissions |
| `NOT_FOUND` | 404 | Resource not found |
| `RATE_LIMITED` | 429 | Too many requests |
| `INTERNAL_ERROR` | 500 | Unexpected error |
| `SERVICE_UNAVAILABLE` | 503 | Downstream failure |
---
## 🧪 Testing Strategy
```typescript
describe('GET /api/v1/messages', () => {
it('returns paginated messages', async () => {
const res = await request(app).get('/api/v1/messages?page=1&limit=10');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.meta.hasMore).toBeDefined();
});
it('rejects invalid severity filter', async () => {
const res = await request(app).get('/api/v1/messages?severity=invalid');
expect(res.status).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_ERROR');
});
});
```
---
## ⚠️ API Anti-Patterns
### ❌ Nested resources terlalu dalam
```
// ❌ JANGAN
GET /api/v1/guilds/123/channels/456/messages/789
// ✅ Flat dengan query params
GET /api/v1/messages?channelId=456
```
### ❌ Inconsistent error format
```typescript
// ❌ JANGAN — kadang string, kadang object
if (err) return res.status(400).send('Bad request');
if (err) return res.status(400).json({ message: 'Bad request' });
// ✅ Consistent envelope
if (err) return res.status(400).json({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'Bad request' }
});
```
### ❌ No type safety
```typescript
// ❌ JANGAN — any, tidak ada validasi
app.get('/api/messages', async (req, res) => {
const messages = await db.query('SELECT * FROM messages');
res.json(messages);
});
// ✅ Zod schema + typed handler
app.get('/api/v1/messages', asyncHandler(async (req, res) => {
const query = messageQuerySchema.parse(req.query);
const messages = await messagesService.list(query);
res.json({ success: true, data: messages });
}));
```
---
## 🔗 Referensi
| Sumber | Konsep |
|--------|--------|
| [JSON:API](https://jsonapi.org/) | Response format spec |
| [Express.js](https://expressjs.com/) | Server framework |
| [Zod](https://zod.dev/) | Schema validation |
---
*"API adalah jembatan ingatan — setiap request adalah percakapan."* ❄️🩵
+310
View File
@@ -0,0 +1,310 @@
# Gateway Event Design — The Pulse of Discord
> *"Events are the heartbeat of a distributed system."*
> — Martin Fowler
---
## 🎯 Filosofi Gateway Events
Discord Gateway adalah **jantung event-driven** BETE:
1. **Single source of truth** — Events adalah satu-satunya cara data bergerak antar service
2. **At-least-once delivery** — Event bisa terkirim lebih dari sekali (idempotent consumers)
3. **Schema evolution** — Events punya versioning untuk backward compatibility
4. **Observable** — Setiap event tercatat untuk debugging dan audit
---
## 📦 Event Schema
### Envelope
```typescript
interface GatewayEvent<T = unknown> {
/** Event type identifier — lowercase, colon-separated */
type: string;
/** Event payload */
data: T;
/** ISO 8601 timestamp of when the event was created */
timestamp: string;
/** Unique event ID for deduplication */
eventId: string;
/** Source service name */
source: 'discord-gateway';
/** Event schema version */
version: number;
/** Optional correlation ID for tracing request flows */
correlationId?: string;
}
```
### Event Size Limits
| Limit | Value | Notes |
|-------|-------|-------|
| Max payload size | 256KB | Larger payloads → reference via URL |
| Max nesting depth | 5 levels | Prevent billion laughs attack |
| String max length | 100KB | Truncate with `... (truncated)` suffix |
---
## 📋 Event Catalog
### Message Events
```typescript
// discord:message:created
interface MessageCreatedEvent {
id: string;
channelId: string;
guildId: string;
author: {
id: string;
name: string;
discriminator: string;
avatar: string | null;
isBot: boolean;
};
content: string;
timestamp: string; // ISO 8601
editedTimestamp: string | null;
attachments: AttachmentInfo[];
replyTo?: string; // Parent message ID
}
// discord:message:updated
interface MessageUpdatedEvent {
id: string;
channelId: string;
content: string;
editedTimestamp: string;
}
// discord:message:deleted
interface MessageDeletedEvent {
id: string;
channelId: string;
guildId: string;
}
```
### Analysis Events
```typescript
// discord:message:analyzed
interface MessageAnalyzedEvent {
messageId: string;
status: 'pending' | 'complete' | 'error';
severity: 'safe' | 'low' | 'medium' | 'high' | 'critical';
categories: string[];
confidence: number; // 01
summary: string;
analyzedAt: string;
processingTimeMs: number;
}
```
### Voice Events
```typescript
// discord:voice:started
interface VoiceStartedEvent {
guildId: string;
channelId: string;
channelName: string;
startedAt: string;
participants: Array<{
userId: string;
userName: string;
}>;
}
// discord:voice:stopped
interface VoiceStoppedEvent {
guildId: string;
channelId: string;
duration: number; // seconds
segmentsCount: number;
}
// discord:voice:uploaded
interface VoiceUploadedEvent {
segmentId: string;
guildId: string;
channelId: string;
userId: string;
userName: string;
duration: number;
fileUrl: string;
fileSize: number;
timestamp: string;
}
```
### Attachment Events
```typescript
// discord:attachment:created
interface AttachmentCreatedEvent {
id: string;
messageId: string;
channelId: string;
url: string;
filename: string;
contentType: string;
size: number;
width?: number;
height?: number;
}
// discord:attachment:uploaded
interface AttachmentUploadedEvent {
id: string;
messageId: string;
storageUrl: string;
thumbnailUrl?: string;
fileSize: number;
processingTimeMs: number;
}
```
---
## 🔄 Event Lifecycle
```
┌──────────┐
│ Discord │ (messageCreate, voiceStateUpdate, etc.)
└────┬─────┘
┌──────────────┐
│ Discord.js │ (client events)
└────┬─────────┘
┌─────────────────────────────┐
│ Message Capture Controller │ (messageCapture.ts)
│ - Parse event │
│ - Store in database │
│ - Publish to Redis │
└────┬───────────────────────┘
┌─────────────────────┐
│ Redis Pub/Sub │ (channel: discord:message:created)
└────┬────────────────┘
├──────────────────────────────┐
↓ ↓
┌──────────────────┐ ┌──────────────────┐
│ Backend Service │ │ AI Moderation │
│ - Index message │ │ - Analyze text │
│ - Store in DB │ │ - Update status │
│ - Broadcast WS │ │ - Publish result │
└──────────────────┘ └──────────────────┘
```
---
## 🧪 Event Testing
```typescript
// Helper untuk generate test events
function createTestEvent<T>(type: string, data: T): GatewayEvent<T> {
return {
type,
data,
timestamp: new Date().toISOString(),
eventId: crypto.randomUUID(),
source: 'discord-gateway',
version: 1,
};
}
describe('MessageCreatedEvent', () => {
it('is properly formatted', () => {
const event = createTestEvent('discord:message:created', {
id: 'msg_1',
channelId: 'ch_1',
guildId: 'guild_1',
author: { id: 'user_1', name: 'Test', discriminator: '0000', avatar: null, isBot: false },
content: 'Hello world',
timestamp: new Date().toISOString(),
editedTimestamp: null,
attachments: [],
});
expect(event.type).toBe('discord:message:created');
expect(event.data.content).toBe('Hello world');
expect(event.source).toBe('discord-gateway');
expect(event.version).toBe(1);
});
});
```
---
## 📊 Event Performance Metrics
| Metric | Target | Alert |
|--------|--------|-------|
| Processing latency | <50ms p99 | >200ms |
| Event throughput | >1000/s | <100/s (unusual) |
| Redis publish latency | <5ms | >20ms |
| Event loss rate | 0% | >0.01% |
| Queue depth | <100 | >1000 |
---
## ⚠️ Anti-Patterns Events
### ❌ Processing-heavy event handlers
```typescript
// ❌ JANGAN — blocking event loop
eventBus.on('message:created', async (event) => {
const result = await expensiveAnalysis(event.data.content);
await db.save(result);
// Event handler for 100 msg/s = bottleneck
});
// ✅ Queue heavy work
eventBus.on('message:created', async (event) => {
await analysisQueue.add(event); // Worker processes async
});
```
### ❌ Missing idempotency
```typescript
// ❌ JANGAN — duplicate events create duplicate records
async function handleMessageCreated(event) {
await db.insert({ id: event.data.id, content: event.data.content });
// If event arrives twice → duplicate key error
}
// ✅ Idempotent: UPSERT
async function handleMessageCreated(event) {
await db.upsert({ id: event.data.id }, { content: event.data.content });
}
```
---
## 🔗 Referensi
| Sumber | Konsep |
|--------|--------|
| [Redis Pub/Sub](https://redis.io/docs/manual/pubsub/) | Event backbone |
| [CloudEvents](https://cloudevents.io/) | Event schema standard |
| [Discord Gateway](https://discord.com/developers/docs/topics/gateway) | Discord events |
---
*"Setiap event adalah denyut nadi — tanda bahwa sistem masih hidup dan berbicara."* ❄️🩵
+334
View File
@@ -0,0 +1,334 @@
# Accessibility — Design for Everyone
> *"The power of the Web is in its universality. Access by everyone regardless of disability is an essential aspect."*
> — Tim Berners-Lee
---
## 🎯 Filosofi Aksesibilitas
BETE dirancang untuk **inklusif sejak awal**, bukan retrofit:
1. **Semantic HTML** — Struktur sebelum style
2. **Color-independent** — Informasi tidak hanya disampaikan lewat warna
3. **Keyboard-first** — Semua fitur bisa diakses tanpa mouse
4. **Reduced motion** — Animasi opsional, bukan wajib
---
## 🏆 Target Compliance
| Level | Target | Verification |
|-------|--------|--------------|
| WCAG 2.1 AA | ✅ Mandatory | Automated + manual |
| WCAG 2.1 AAA | ⭐ Recommended | Manual audit |
| Section 508 | ✅ Mandatory | Automated |
| EN 301 549 | ✅ Mandatory | EU compliance |
---
## 🎨 Color Accessibility
### Contrast Ratios Minimum
| Elemen | Teks Normal | Teks Large (≥18px / ≥14px bold) |
|--------|-------------|----------------------------------|
| Body text | 4.5:1 (AA) | 3:1 (AA) |
| UI text (label, badge) | 4.5:1 (AA) | 3:1 (AA) |
| Placeholder | 3:1 (AA large) | — |
| Disabled | 3:1 | 3:1 |
### Color Blindness
- Jangan gunakan **merah-hijau** sebagai satu-satunya pembeda
- Tambahkan **ikon, pola, atau label teks** sebagai secondary encoding
- Gunakan palette color-blind safe (lihat `01-color-system.md`)
```typescript
// Tool: verifikasi kontras otomatis di tests
function checkContrast(foreground: string, background: string): boolean {
const fg = parseOklch(foreground);
const bg = parseOklch(background);
return getContrastRatio(fg, bg) >= 4.5;
}
```
---
## ⌨️ Keyboard Navigation
### Focus Order
```html
<!-- ✅ Semantic order = visual order -->
<nav> <!-- Tab 1 -->
<main> <!-- Tab 2 -->
<h1> <!-- Tab 3 -->
<p> <!-- Tab 4 -->
<button><!-- Tab 5 -->
</main>
<footer> <!-- Tab 6 -->
```
### Focus Indicators
```css
/* Custom focus ring — lebih visible dari browser default */
:focus-visible {
outline: 2px solid var(--clr-primary-400);
outline-offset: 2px;
border-radius: var(--rd-sm);
}
/* ⚠️ NEVER do this */
:focus { outline: none; } /* Membuat keyboard users buta */
```
### Keyboard Shortcuts
```
Tab / Shift+Tab — Navigate forward/backward
Enter / Space — Activate element
Escape — Close modal/dropdown/menu
Arrow keys — Navigate list, tabs, select
Ctrl+K — Command palette
```
### Skip Navigation
```html
<!-- First focusable element on page -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
```
```css
.skip-link {
position: absolute;
top: -100%;
left: 8px;
padding: 8px 16px;
background: var(--clr-primary);
color: var(--clr-text-on-primary);
z-index: 9999;
}
.skip-link:focus {
top: 8px;
}
```
---
## 🏗️ Semantic HTML Structure
```html
<!-- Dashboard page template -->
<header role="banner">
<nav role="navigation" aria-label="Main navigation">
<ul>
<li><a href="/live" aria-current="page">Live</a></li>
<li><a href="/messages">Messages</a></li>
<li><a href="/settings">Settings</a></li>
</ul>
</nav>
</header>
<main id="main-content" role="main">
<h1>Live Dashboard</h1>
<section aria-labelledby="voice-status">
<h2 id="voice-status">Voice Connections</h2>
<!-- voice content -->
</section>
<section aria-labelledby="active-speakers">
<h2 id="active-speakers">Active Speakers</h2>
<ul role="list" aria-label="Currently speaking users">
<li role="listitem">User 1</li>
<li role="listitem">User 2</li>
</ul>
</section>
</main>
```
---
## ♿ ARIA Patterns
### Dynamic Content (Live Regions)
```html
<!-- Toast notifications — live region -->
<div aria-live="polite" aria-atomic="true" class="toast-container">
<!-- Toasts announced by screen reader -->
</div>
<!-- Loading state -->
<div role="status" aria-live="polite">
<span class="sr-only">Loading messages...</span>
<div class="skeleton" aria-hidden="true"></div>
</div>
<!-- Error state -->
<div role="alert" aria-live="assertive">
<p>Failed to load messages. Please try again.</p>
</div>
```
### Modals
```html
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-desc"
>
<h2 id="modal-title">Confirm Delete</h2>
<p id="modal-desc">This action cannot be undone.</p>
<button onClick={closeModal}>Cancel</button>
<button onClick={confirmDelete}>Delete</button>
</div>
```
### Tabs
```html
<div role="tablist" aria-label="Dashboard tabs">
<button role="tab" aria-selected="true" aria-controls="panel-live" id="tab-live">
Live
</button>
<button role="tab" aria-selected="false" aria-controls="panel-messages" id="tab-messages">
Messages
</button>
</div>
<div role="tabpanel" id="panel-live" aria-labelledby="tab-live">
<!-- Live content -->
</div>
```
---
## 🔇 Reduced Motion
```css
/* Global override */
@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;
}
}
/* GSAP hook — programmatic check */
function prefersReducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
```
---
## 🖼️ Images & Icons
```tsx
// Icons — always with aria-hidden or label
<MicIcon aria-hidden="true" /> // Decorative
<span role="img" aria-label="Voice active">🎤</span> // Emoji
<Icon icon="mic" aria-label="Microphone" /> // Informative
// Images — always with alt text
<img src={user.avatar} alt={`${user.name}'s avatar`} />
<img src={decorativeBg} alt="" role="presentation" /> // Decorative
```
---
## 🧪 Testing Accessibility
```typescript
// Automated tests
import { axe } from 'jest-axe';
describe('MessageCard', () => {
it('has no accessibility violations', async () => {
const { container } = render(<MessageCard message={mockMessage} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// Manual checklist
const a11yChecklist = [
'Keyboard: all interactive elements reachable',
'Focus order matches visual order',
'Screen reader: all content announced',
'Contrast: 4.5:1 minimum for body text',
'Labels: all form elements have labels',
'Alt text: all images have meaningful alt text',
'Reduced motion: animations respect media query',
'Color: information not conveyed by color alone',
];
```
---
## 🧰 Tools & Resources
| Tool | Purpose | Integration |
|------|---------|-------------|
| axe-core | Automated audit | CI pipeline |
| Lighthouse | Performance + a11y | CI pipeline |
| NVDA / VoiceOver | Screen reader | Manual testing |
| Contrast Checker | Color verification | Design phase |
| Tab Tester | Keyboard flow | Manual testing |
---
## ⚠️ A11y Anti-Patterns
### ❌ Color-only indicators
```tsx
// ❌ JANGAN — buta warna tidak bisa membedakan
<Badge className={isBad ? 'bg-red-500' : 'bg-green-500'} />
// ✅ Color + icon + text
<Badge variant={isBad ? 'destructive' : 'success'} icon={isBad ? <X /> : <Check />} />
```
### ❌ Missing focus indicator
```css
/* ❌ JANGAN — menghilangkan focus ring */
*:focus { outline: none; }
/* ✅ Custom focus ring yang visible */
*:focus-visible { outline: 2px solid var(--clr-primary-400); outline-offset: 2px; }
```
### ❌ Non-semantic clickable
```tsx
// ❌ JANGAN — div clickable tanpa role
<div onClick={handleClick}>Click me</div>
// ✅ Gunakan button
<button onClick={handleClick}>Click me</button>
```
---
## 🔗 Referensi
| Sumber | Konsep |
|--------|--------|
| [WCAG 2.1](https://www.w3.org/TR/WCAG21/) | Accessibility standard |
| [A11y Project](https://www.a11yproject.com/) | Accessibility patterns |
| [Inclusive Components](https://inclusive-components.design/) | Accessible component design |
| [axe DevTools](https://www.deque.com/axe/) | Automated testing |
---
*"Desain yang inklusif adalah ingatan yang tak membeda-bedakan — setiap orang berhak atas pengalaman yang utuh."* ❄️🩵
+282
View File
@@ -0,0 +1,282 @@
# 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."* ❄️🩵
+314
View File
@@ -0,0 +1,314 @@
# Sound Design — The Audio Soul
> *"Sound is the vocabulary of nature. It speaks to us on a primal level."*
> — Randy Thom, Sound Designer
---
## 🎯 Filosofi Audio
Suara di BETE bukan sekadar efek — ia adalah **layer konfirmasi non-visual**:
1. **Subtle & non-intrusive** — Volume rendah, durasi pendek
2. **Meaningful** — Setiap suara punya makna spesifik
3. **Context-aware** — Suara yang berbeda untuk konteks berbeda
4. **Opt-out** — Semua suara bisa dimatikan
---
## 🔔 Sound Catalog
### UI Feedback Sounds
| Event | Sound Type | Duration | Volume | Description |
|-------|-----------|----------|--------|-------------|
| Button click | Pop | 80ms | 0.3 | Subtle tick |
| Toggle on | Click | 100ms | 0.3 | Switch engage |
| Toggle off | Click | 100ms | 0.2 | Switch release |
| Modal open | Whoosh | 200ms | 0.2 | Soft slide |
| Modal close | Whoosh | 150ms | 0.15 | Quick retreat |
| Toast appear | Ding | 300ms | 0.3 | Notification |
| Error toast | Buzz | 200ms | 0.4 | Warning |
### Moderation Sounds
| Event | Sound | Duration | Volume | Description |
|-------|-------|----------|--------|-------------|
| Message flagged | Chime | 400ms | 0.3 | Attention tone |
| Critical alert | Siren | 1s | 0.5 | Urgent pattern |
| Analysis complete | Ping | 200ms | 0.2 | Completion |
### Voice Channel Sounds
| Event | Sound | Duration | Volume |
|-------|-------|----------|--------|
| User joins | Connect | 150ms | 0.2 |
| User leaves | Disconnect | 150ms | 0.2 |
| Recording start | Record-on | 200ms | 0.3 |
| Recording stop | Record-off | 200ms | 0.2 |
---
## 🎵 Audio Implementation
### Sound Manager
```typescript
// shared/lib/sound.ts
class SoundManager {
private static instance: SoundManager;
private enabled = true;
private volume = 0.5;
private audioCache = new Map<string, HTMLAudioElement>();
static getInstance(): SoundManager {
if (!this.instance) this.instance = new SoundManager();
return this.instance;
}
async play(soundId: string): Promise<void> {
if (!this.enabled) return;
let audio = this.audioCache.get(soundId);
if (!audio) {
audio = new Audio(`/sounds/${soundId}.mp3`);
this.audioCache.set(soundId, audio);
}
audio.volume = this.volume;
audio.currentTime = 0;
await audio.play().catch(() => {}); // Swallow autoplay errors
}
setEnabled(enabled: boolean): void { this.enabled = enabled; }
setVolume(volume: number): void { this.volume = Math.max(0, Math.min(1, volume)); }
}
export const sound = SoundManager.getInstance();
```
### Preloading Strategy
```typescript
// Preload critical sounds on app init
function preloadSounds(): void {
const criticalSounds = ['click', 'toggle', 'notification'];
criticalSounds.forEach(id => {
const audio = new Audio(`/sounds/${id}.mp3`);
audio.preload = 'auto';
});
}
// Call on app bootstrap
document.addEventListener('DOMContentLoaded', preloadSounds);
```
---
## 🎚️ Sound Settings
```tsx
// features/settings/SoundSettings.tsx
function SoundSettings() {
const [soundEnabled, setSoundEnabled] = useState(true);
const [soundVolume, setSoundVolume] = useState(0.5);
return (
<Card>
<CardHeader>
<CardTitle>Sound</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ToggleGroup>
<Toggle
pressed={soundEnabled}
onPressedChange={(v) => {
setSoundEnabled(v);
sound.setEnabled(v);
}}
label="Sound Effects"
/>
</ToggleGroup>
{soundEnabled && (
<div>
<Label>Volume</Label>
<Slider
value={[soundVolume]}
onValueChange={([v]) => {
setSoundVolume(v);
sound.setVolume(v);
}}
min={0}
max={1}
step={0.1}
/>
</div>
)}
</CardContent>
</Card>
);
}
```
---
## 🔊 Audio Feedback Implementation
### React Hook
```tsx
// hooks/useSound.ts
function useSound(soundId: string) {
const play = useCallback(() => {
sound.play(soundId);
}, [soundId]);
return play;
}
// Usage
function DeleteButton({ onClick }: { onClick: () => void }) {
const playClick = useSound('click');
const playError = useSound('error');
const handleClick = async () => {
playClick();
try {
await onClick();
} catch {
playError();
}
};
return <Button onClick={handleClick}>Delete</Button>;
}
```
### Toast + Sound Integration
```tsx
function useToastWithSound() {
const { toast } = useToast();
return useCallback((t: ToastInput) => {
toast(t);
switch (t.type) {
case 'success': sound.play('success'); break;
case 'error': sound.play('error'); break;
case 'warning': sound.play('warning'); break;
case 'info': sound.play('info'); break;
}
}, [toast]);
}
```
---
## 📄 Sound File Structure
```
public/sounds/
├── ui/
│ ├── click.mp3 # 80ms
│ ├── toggle-on.mp3 # 100ms
│ ├── toggle-off.mp3 # 100ms
│ ├── modal-open.mp3 # 200ms
│ ├── modal-close.mp3 # 150ms
│ └── notification.mp3 # 300ms
├── moderation/
│ ├── flagged.mp3 # 400ms
│ ├── critical.mp3 # 1s
│ └── analysis-done.mp3 # 200ms
├── voice/
│ ├── user-join.mp3 # 150ms
│ ├── user-leave.mp3 # 150ms
│ ├── recording-start.mp3 # 200ms
│ └── recording-stop.mp3 # 200ms
└── _index.json # Sound metadata
```
### Sound Metadata
```json
{
"ui/click": {
"duration": 80,
"volume": 0.3,
"category": "feedback",
"critical": true
},
"moderation/critical": {
"duration": 1000,
"volume": 0.5,
"category": "alert",
"critical": true
}
}
```
---
## ♿ Accessibility & Sound
```typescript
// Respect system accessibility settings
function shouldPlaySound(): boolean {
// iOS: silent switch
if (navigator.mediaSession?.playbackState === 'none') return false;
return true;
}
// Before playing:
if (!shouldPlaySound()) return;
// User preference always wins
if (!userSettings.soundEnabled) return;
```
---
## ⚠️ Sound Anti-Patterns
### ❌ Mandatory sounds
```tsx
// ❌ JANGAN — user tidak bisa mematikan
sound.play('loud-intro-music');
// ✅ Always respect user preference
if (userSettings.soundEnabled) sound.play('subtle-click');
```
### ❌ Long or repetitive sounds
```tsx
// ❌ JANGAN — 5 detik sound effect mengganggu
sound.play('complex-jingle');
// ✅ Durasi pendek, sekali main
sound.play('quick-chime');
```
### ❌ No audio context check
```tsx
// ❌ JANGAN — play tanpa cek autoplay policy
new Audio('/sounds/click.mp3').play();
// ✅ Handle autoplay rejection
const audio = new Audio('/sounds/click.mp3');
await audio.play().catch(() => {}); // Silently fail
```
---
## 🔗 Referensi
| Sumber | Konsep |
|--------|--------|
| [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API) | Audio playback |
| [Game UX Sound Design](https://www.gamedeveloper.com/audio/) | Sound design patterns |
| [WCAG Auditory](https://www.w3.org/WAI/WCAG21/Understanding/audio-control.html) | Audio accessibility |
---
*"Suara adalah gaung ingatan — setiap klik adalah bisikan dari masa lalu."* ❄️🩵