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
+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."* ❄️🩵