feat: migrate frontend to Astro + expand AI moderation + backend admin/runtime config
Frontend: - migrate from Vite to Astro (astro.config.mjs, pages/, layouts/) - add admin panel, settings page, command palette, error boundary - refactor App.tsx, MascotChatbot, Sidebar, Header, DashboardLayout - update API client, WebSocket, auth, dashboard features Backend: - add admin module and config routes - refactor middlewares, Redis connection, WebSocket server/bridge - add runtime config loader Discord Gateway: - refactor AI moderation: circuit breaker, concurrency limiter, fallback processor - add media analysis client, Seaxng search, user profile learner - add new drizzle migration Shared: - extend database schema, add new config fields
This commit is contained in:
@@ -0,0 +1,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."* ❄️🩵
|
||||
@@ -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."* ❄️🩵
|
||||
@@ -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."* ❄️🩵
|
||||
Reference in New Issue
Block a user