diff --git a/docs/superpowers/plans/2026-07-02-astro-migration-plan.md b/docs/superpowers/plans/2026-07-02-astro-migration-plan.md new file mode 100644 index 0000000..7073a77 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-astro-migration-plan.md @@ -0,0 +1,2310 @@ +# BETE Astro Migration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Refactor existing Astro + React SPA frontend into Astro SSG with real file-based routing, per-feature React islands, and full design system implementation from `design/`. + +**Architecture:** Replace the current pattern (single `client:only="react"` island rendering a monolithic `App.tsx`) with file-based Astro pages. Non-interactive UI rendered as `.astro` components (zero JS). Interactive parts (WebSocket, canvas, real-time state) remain as React islands in `src/islands/`, each hydrated via appropriate `client:*` directive. CSS architecture uses Tailwind 4 `@theme` block + OKLCH CSS custom properties from `design/`. + +**Tech Stack:** Astro 7, React 19, Tailwind CSS 4, Zustand 5, TanStack Query 5, TypeScript 5 + +## Global Constraints + +- All colors must use OKLCH color space via CSS custom properties (no hex/RGB hardcoding) +- All spacing must use 4px baseline grid (`--sp-*` tokens via Tailwind's 4px `--spacing` unit) +- All typography uses fluid `clamp()` scale via `--fs-*` tokens +- Glassmorphism utility classes must use `backdrop-filter: blur()` + OKLCH rgba +- React islands must use `client:load` only for WebSocket-reliant components; `client:idle` for decorative/non-critical ones +- All API calls go through `src/shared/api/client.ts` with `X-Admin-Password` header +- WebSocket lifecycle managed by `SocketManager` singleton +- Every data-driven component must handle loading/error/empty/success states per `design/patterns/09-state-machines.md` + +--- + +## File Structure Map + +Before tasks, here's every file that will be created or modified: + +``` +services/frontend/ +├── src/ +│ ├── styles/ +│ │ └── base.css ← CREATE (replace styles.css) +│ │ +│ ├── layouts/ +│ │ ├── BaseLayout.astro ← MODIFY (update meta, view-transitions) +│ │ └── AuthLayout.astro ← CREATE +│ │ +│ ├── pages/ +│ │ ├── index.astro ← MODIFY (redirect to /live) +│ │ ├── live.astro ← CREATE +│ │ ├── messages.astro ← CREATE +│ │ ├── settings.astro ← CREATE +│ │ ├── recordings.astro ← CREATE +│ │ ├── login.astro ← CREATE +│ │ └── 404.astro ← CREATE +│ │ +│ ├── components/ ← Astro components (zero JS) +│ │ ├── ui/ +│ │ │ ├── Button.astro ← CREATE +│ │ │ ├── Badge.astro ← CREATE +│ │ │ ├── SeverityBadge.astro ← CREATE +│ │ │ ├── Card.astro ← CREATE +│ │ │ ├── Skeleton.astro ← CREATE +│ │ │ └── Spinner.astro ← CREATE +│ │ ├── sidebar/ +│ │ │ ├── Sidebar.astro ← CREATE +│ │ │ └── NavItem.astro ← CREATE +│ │ ├── header/ +│ │ │ └── Header.astro ← CREATE +│ │ └── states/ +│ │ ├── EmptyState.astro ← CREATE +│ │ ├── ErrorState.astro ← CREATE +│ │ └── LoadingSkeleton.astro ← CREATE +│ │ +│ ├── islands/ ← React components (interactive) +│ │ ├── AuthGuard.tsx ← CREATE (from features/auth) +│ │ ├── VoiceControls.tsx ← CREATE (from features/live) +│ │ ├── AudioVisualizer.tsx ← PORT (from features/live/components/) +│ │ ├── MessageFeed.tsx ← CREATE (composite of features/messages) +│ │ ├── MascotChat.tsx ← CREATE (from MascotChat feature) +│ │ ├── ThemeToggle.tsx ← CREATE (from hooks/useTheme) +│ │ ├── Particles.tsx ← PORT (from widgets/particles/) +│ │ ├── ActiveSpeakers.tsx ← CREATE (from features/live/components/) +│ │ ├── NowPlaying.tsx ← CREATE (from features/live/components/) +│ │ ├── RecordingsList.tsx ← CREATE (from RecordingsSubPanel) +│ │ └── SettingsForm.tsx ← CREATE (from features/settings) +│ │ +│ ├── stores/ ← Zustand +│ │ ├── ui-store.ts ← CREATE +│ │ ├── voice-store.ts ← CREATE +│ │ └── message-store.ts ← CREATE +│ │ +│ ├── shared/ +│ │ ├── api/client.ts ← PORT (existing) +│ │ ├── ws/socket.ts ← PORT (existing) +│ │ ├── hooks/ +│ │ │ ├── useMessages.ts ← PORT (from features/messages/hooks/) +│ │ │ ├── useMediaControl.ts ← PORT (from features/live/hooks/) +│ │ │ └── useVoiceControl.ts ← PORT (from features/live/hooks/) +│ │ └── lib/utils.ts ← PORT (existing) +│ │ +│ └── layouts/ +│ └── DashboardLayout.tsx ← DELETE (replaced by Astro layout) +│ +├── src/App.tsx ← DELETE (logic distributed to islands) +├── src/App.client.tsx ← DELETE (no longer needed) +├── src/entities/ ← DELETE (types move to @bete/shared or inline) +├── src/features/ ← DELETE (features become pages + islands) +├── src/hooks/ ← DELETE (distributed to stores + islands) +├── src/widgets/ ← DELETE (replaced by Astro layout + components) +├── src/styles.css ← DELETE (replaced by base.css) +└── src/shared/ui/ ← DELETE (replaced by Astro components) +``` + +--- + +## Phase 1: Foundation 🔧 + +### Task 1.1: Rewrite CSS Architecture + +**Files:** +- Create: `services/frontend/src/styles/base.css` +- Delete: `services/frontend/src/styles.css` + +**Interfaces:** +- Consumes: Design tokens from `design/core/01-color-system.md`, `design/core/02-typography.md`, `design/core/03-spatial-system.md`, `design/core/04-motion-system.md`, `design/system/15-theme-architecture.md` +- Produces: `base.css` with all design tokens, Tailwind `@theme`, glass utilities, animations + +**CSS Variables Context:** + +``` +Color tokens: --clr-surface-base, --clr-primary, --clr-text, etc. (all OKLCH) +Spacing tokens: --sp-3 = 16px (via 4px grid unit) +Radius tokens: --rd-md = 8px, --rd-lg = 12px, --rd-xl = 16px +Shadow tokens: --sh-card, --sh-hover, --sh-modal +Z-index tokens: --z-header = 30, --z-sidebar = 40, --z-modal = 60, --z-mascot = 100 +Timing tokens: --dur-fast = 150ms, --dur-normal = 250ms, --dur-slow = 350ms +Easing tokens: --ease-out = cubic-bezier(0.16, 1, 0.3, 1) +Font tokens: --fs-base = clamp(0.94rem, 0.94rem + 0.03vw, 1.00rem) +``` + +- [ ] **Step 1: Create the complete `base.css` with design tokens** + +```css +@import "tailwindcss"; + +/* ── Theme: Dark (default) ─────────────────────────────────── */ +:root { + /* 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-on-primary: oklch(0.97 0.005 286); + --clr-text-inverse: oklch(0.11 0.010 286); + + /* Brand (Hue 255 — Aetherial Blue) */ + --clr-primary: oklch(0.62 0.150 255); + --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); + --clr-primary-bg: oklch(0.25 0.060 255 / 0.20); + + /* 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); + + /* Severity */ + --clr-severity-safe: oklch(0.60 0.130 145); + --clr-severity-low: oklch(0.70 0.120 75); + --clr-severity-medium: oklch(0.65 0.150 50); + --clr-severity-high: oklch(0.60 0.150 30); + --clr-severity-critical: oklch(0.55 0.165 25); + + /* Glass */ + --glass-bg: oklch(0.15 0.015 286 / 0.60); + --glass-border: oklch(0.25 0.030 286 / 0.20); + + /* Spacing (4px grid) */ + --sp-0: 0px; + --sp-0-5: 4px; + --sp-1: 8px; + --sp-2: 12px; + --sp-3: 16px; + --sp-4: 24px; + --sp-5: 32px; + --sp-6: 48px; + --sp-7: 64px; + + /* Radius */ + --rd-xs: 4px; + --rd-sm: 6px; + --rd-md: 8px; + --rd-lg: 12px; + --rd-xl: 16px; + + /* Shadow */ + --sh-card: 0 2px 8px rgba(0, 0, 0, 0.3); + --sh-hover: 0 4px 16px rgba(0, 0, 0, 0.4); + --sh-modal: 0 16px 48px rgba(0, 0, 0, 0.6); + + /* Z-index */ + --z-header: 30; + --z-sidebar: 40; + --z-overlay: 50; + --z-modal: 60; + --z-toast: 80; + --z-mascot: 100; + + /* Duration */ + --dur-fast: 150ms; + --dur-normal: 250ms; + --dur-slow: 350ms; + + /* Easing */ + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); + --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); +} + +/* ── Theme: Light ──────────────────────────────────────── */ +[data-theme="light"] { + --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); + --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-primary: oklch(0.55 0.175 255); + --clr-interactive-hover: oklch(0.90 0.005 286); + --clr-interactive-active: oklch(0.85 0.008 286); + --glass-bg: oklch(0.97 0.002 286 / 0.50); + --glass-border: oklch(0.87 0.005 286 / 0.30); + --sh-card: 0 2px 8px rgba(0, 0, 0, 0.08); + --sh-hover: 0 4px 16px rgba(0, 0, 0, 0.12); + --sh-modal: 0 16px 48px rgba(0, 0, 0, 0.12); +} + +/* ── Tailwind 4 @theme ──────────────────────────────────── */ +@theme { + --font-sans: 'Outfit', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + + --color-background: var(--clr-surface-base); + --color-foreground: var(--clr-text); + --color-card: var(--clr-surface-elevated); + --color-card-foreground: var(--clr-text); + --color-border: var(--clr-border); + --color-primary: var(--clr-primary); + --color-primary-foreground: var(--clr-text-on-primary); + --color-muted: var(--clr-surface-elevated); + --color-muted-foreground: var(--clr-text-secondary); + --color-destructive: var(--clr-severity-critical); + --color-destructive-foreground: white; + --color-severity-safe: var(--clr-severity-safe); + --color-severity-low: var(--clr-severity-low); + --color-severity-medium: var(--clr-severity-medium); + --color-severity-high: var(--clr-severity-high); + --color-severity-critical: var(--clr-severity-critical); + + --radius-xs: var(--rd-xs); + --radius-sm: var(--rd-sm); + --radius-md: var(--rd-md); + --radius-lg: var(--rd-lg); + --radius-xl: var(--rd-xl); + + --spacing: 4px; + + --animate-fade-in: fadeIn var(--dur-normal) var(--ease-out); + --animate-fade-in-up: fadeInUp var(--dur-slow) var(--ease-out); + --animate-shimmer: shimmer 1.5s ease-in-out infinite; + --animate-scale-in: scaleIn var(--dur-slow) var(--ease-out-quint); + --animate-glow-pulse: glowPulse 2s ease-in-out infinite; +} + +/* ── Keyframes ───────────────────────────────────────────── */ +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes fadeInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } +@keyframes shimmer { 0% { background-position: 200% 0; } to { background-position: -200% 0; } } +@keyframes scaleIn { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } } +@keyframes glowPulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } } +@keyframes slideUp { from { transform: translateY(10px); opacity: 0; } to { opacity: 1; } } +@keyframes slideDown { from { transform: translateY(-10px); opacity: 0; } to { opacity: 1; } } + +/* ── Base layer ──────────────────────────────────────────── */ +@layer base { + body { + background-color: var(--clr-surface-base); + color: var(--clr-text); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + } + + * { + border-color: var(--clr-border); + transition: background-color var(--dur-normal) var(--ease-out), + color var(--dur-normal) var(--ease-out), + border-color var(--dur-normal) var(--ease-out); + } + + :focus-visible { + outline: 2px solid var(--clr-primary); + outline-offset: 2px; + } + + ::-webkit-scrollbar { width: 6px; height: 6px; } + ::-webkit-scrollbar-track { background: var(--clr-surface-sunken); } + ::-webkit-scrollbar-thumb { background: var(--clr-border); border-radius: 999px; } +} + +/* ── Components layer ─────────────────────────────────────── */ +@layer components { + .glass { + background: var(--glass-bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--glass-border); + } + + .glass-strong { + background: oklch(from var(--clr-surface-overlay) l c h / 0.85); + backdrop-filter: blur(24px); + } + + .gradient-text { + background: linear-gradient(135deg, oklch(from var(--clr-primary) l c h), oklch(from var(--clr-primary-400) l c h)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} +``` + +- [ ] **Step 2: Delete old `styles.css` and verify build** + +Run: +```bash +rm services/frontend/src/styles.css +cd services/frontend +pnpm build 2>&1 | tail -20 +``` +Expected: Build succeeds, new CSS is compiled. + +- [ ] **Step 3: Commit** + +```bash +git add services/frontend/src/styles/base.css services/frontend/src/styles.css +git rm services/frontend/src/styles.css +git commit -m "feat(frontend): implement design system CSS architecture with OKLCH tokens" +``` + +--- + +### Task 1.2: Create Astro UI Components + +**Files:** +- Create: `services/frontend/src/components/ui/Button.astro` +- Create: `services/frontend/src/components/ui/Badge.astro` +- Create: `services/frontend/src/components/ui/SeverityBadge.astro` +- Create: `services/frontend/src/components/ui/Card.astro` +- Create: `services/frontend/src/components/ui/Skeleton.astro` +- Create: `services/frontend/src/components/ui/Spinner.astro` + +**Interfaces:** +- Consumes: CSS variables from Task 1.1 +- Produces: Reusable Astro components usable in all page layouts + +- [ ] **Step 1: Create `Button.astro`** + +```astro +--- +export interface Props { + variant?: 'primary' | 'secondary' | 'destructive' | 'outline' | 'ghost'; + size?: 'sm' | 'default' | 'lg' | 'icon'; + disabled?: boolean; + href?: string; + class?: string; +} + +const { variant = 'primary', size = 'default', disabled = false, href, class: className = '' } = Astro.props; + +const baseClass = `btn btn--${variant} btn--${size}${className ? ' ' + className : ''}`; +--- + +{ + href ? ( + + + + ) : ( + + ) +} + + +``` + +- [ ] **Step 2: Create `Badge.astro`** + +```astro +--- +export interface Props { + variant?: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; + size?: 'sm' | 'default'; + dot?: boolean; + class?: string; +} + +const { variant = 'default', size = 'default', dot = false, class: className = '' } = Astro.props; +--- + + + {dot && + + +``` + +- [ ] **Step 3: Create `SeverityBadge.astro`** + +```astro +--- +export interface Props { + severity: 'safe' | 'low' | 'medium' | 'high' | 'critical'; + class?: string; +} + +const { severity, class: className = '' } = Astro.props; + +const ICONS: Record = { + safe: '✅', + low: '⚠️', + medium: '🔶', + high: '🚫', + critical: '🔴', +}; +--- + + + + {severity.toUpperCase()} + + + +``` + +- [ ] **Step 4: Create `Card.astro`** + +```astro +--- +export interface Props { + variant?: 'default' | 'elevated' | 'glass' | 'interactive'; + padding?: 'sm' | 'default' | 'lg' | 'none'; + class?: string; +} + +const { variant = 'default', padding = 'default', class: className = '' } = Astro.props; +--- + +
+ +
+ + +``` + +- [ ] **Step 5: Create `Skeleton.astro`** and `Spinner.astro` + +```astro +--- +// Skeleton.astro +export interface Props { + variant?: 'text' | 'card' | 'circle' | 'rect'; + width?: string; + height?: string; + class?: string; +} +const { variant = 'text', width, height, class: className = '' } = Astro.props; +--- +
+ +``` + +```astro +--- +// Spinner.astro +export interface Props { size?: 'sm' | 'default' | 'lg'; class?: string; } +const { size = 'default', class: className = '' } = Astro.props; +--- +
+ Loading... +
+ +``` + +- [ ] **Step 6: Verify components compile** + +Run: +```bash +cd services/frontend && pnpm build 2>&1 | tail -10 +``` +Expected: Build succeeds, zero errors. + +- [ ] **Step 7: Commit** + +```bash +git add services/frontend/src/components/ui/ +git commit -m "feat(frontend): add Astro UI components (Button, Badge, Card, Skeleton, Spinner)" +``` + +--- + +### Task 1.3: Create Layout Components (Sidebar, Header, States) + +**Files:** +- Create: `services/frontend/src/components/sidebar/NavItem.astro` +- Create: `services/frontend/src/components/sidebar/Sidebar.astro` +- Create: `services/frontend/src/components/header/Header.astro` +- Create: `services/frontend/src/components/states/EmptyState.astro` +- Create: `services/frontend/src/components/states/ErrorState.astro` +- Create: `services/frontend/src/components/states/LoadingSkeleton.astro` + +**Interfaces:** +- Consumes: UI components from Task 1.2 +- Produces: Layout shell components used by DashboardLayout + +- [ ] **Step 1: Create `NavItem.astro`** + +```astro +--- +export interface Props { + href: string; + icon: string; // Lucide icon name + label: string; + active?: boolean; + collapsed?: boolean; + notificationCount?: number; +} +const { href, icon, label, active = false, collapsed = false, notificationCount = 0 } = Astro.props; +--- + + + + {!collapsed && ( + <> + {label} + {notificationCount > 0 && ( + {notificationCount > 99 ? '99+' : notificationCount} + )} + + )} + + + +``` + +- [ ] **Step 2: Create `Sidebar.astro`** + +```astro +--- +import NavItem from './NavItem.astro'; + +export interface Props { + collapsed?: boolean; + activeTab?: string; + notificationCount?: number; + class?: string; +} + +const { collapsed = false, activeTab = 'live', notificationCount = 0, class: className = '' } = Astro.props; + +const NAV_ITEMS = [ + { id: 'live', icon: 'radio', href: '/live', label: 'Live' }, + { id: 'messages', icon: 'message-square', href: '/messages', label: 'Messages' }, + { id: 'recordings', icon: 'mic', href: '/recordings', label: 'Recordings' }, + { id: 'settings', icon: 'settings', href: '/settings', label: 'Settings' }, +]; +--- + + + + +``` + +- [ ] **Step 3: Create `Header.astro`** + +```astro +--- +export interface Props { + title?: string; + class?: string; +} +const { title = 'Dashboard', class: className = '' } = Astro.props; +--- + +
+
+

{title}

+
+
+ +
+
+ + +``` + +- [ ] **Step 4: Create state components** + +`EmptyState.astro`: +```astro +--- +export interface Props { + icon?: string; + title: string; + description?: string; + class?: string; +} +const { icon, title, description, class: className = '' } = Astro.props; +--- +
+ {icon && + +``` + +`ErrorState.astro`: +```astro +--- +export interface Props { + message?: string; + class?: string; +} +const { message = 'Something went wrong', class: className = '' } = Astro.props; +--- +
+
!
+

{message}

+ +
+ +``` + +`LoadingSkeleton.astro`: +```astro +--- +import Skeleton from '../ui/Skeleton.astro'; + +export interface Props { + variant?: 'card' | 'list' | 'detail'; + count?: number; + class?: string; +} +const { variant = 'card', count = 3, class: className = '' } = Astro.props; +--- +
+ {Array.from({ length: count }).map(() => ( + variant === 'list' ? ( +
+ +
+ + +
+
+ ) : ( +
+ + + +
+ ) + ))} +
+``` + +- [ ] **Step 5: Commit** + +```bash +git add services/frontend/src/components/ +git commit -m "feat(frontend): add Astro layout components (Sidebar, Header, states)" +``` + +--- + +### Task 1.4: Update BaseLayout and Create AuthLayout + Pages + +**Files:** +- Modify: `services/frontend/src/layouts/BaseLayout.astro` +- Create: `services/frontend/src/layouts/AuthLayout.astro` +- Create: `services/frontend/src/pages/login.astro` +- Create: `services/frontend/src/pages/404.astro` +- Modify: `services/frontend/src/pages/index.astro` + +**Interfaces:** +- Consumes: No prior tasks (independent) +- Produces: Working routes and layout shell + +- [ ] **Step 1: Update `BaseLayout.astro`** + +```astro +--- +const FONT_HREF = + "https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap"; +const FONT_MONO_HREF = + "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap"; + +export interface Props { + title?: string; + description?: string; +} +const { title = 'IMPHNEN — Discord Moderation', description = 'Real-time Discord AI Moderation & Voice Recording Dashboard' } = Astro.props; +--- + + + + + + + {title} + + + + + + + + + + + + + +``` + +- [ ] **Step 2: Create `AuthLayout.astro`** + +```astro +--- +import BaseLayout from './BaseLayout.astro'; + +interface Props { title?: string; } +const { title } = Astro.props; +--- + + +
+
+ +
+
+
+ + +``` + +- [ ] **Step 3: Create `login.astro`** + +```astro +--- +import AuthLayout from '../layouts/AuthLayout.astro'; +--- + + +
+

BETE

+

Discord Moderation Dashboard

+
+ +
+``` + +- [ ] **Step 4: Create `404.astro`** + +```astro +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +--- + + +
+
+

404

+

The page you're looking for doesn't exist.

+ Go Home +
+
+
+``` + +- [ ] **Step 5: Update `index.astro`** — 301 redirect to /live + +```astro +--- +// Redirect to live view (default tab) +return Astro.redirect('/live', 301); +--- +``` + +- [ ] **Step 6: Commit** + +```bash +git add services/frontend/src/layouts/ services/frontend/src/pages/ +git commit -m "feat(frontend): update BaseLayout, add AuthLayout, login, 404 pages" +``` + +--- + +## Phase 2: Zustand Stores + Shared Utilities + +### Task 2.1: Create Zustand Stores + +**Files:** +- Create: `services/frontend/src/stores/ui-store.ts` +- Create: `services/frontend/src/stores/voice-store.ts` +- Create: `services/frontend/src/stores/message-store.ts` + +**Interfaces:** +- Consumes: Types from `@bete/shared` +- Produces: `useUIStore`, `useVoiceStore`, `useMessageStore` for islands + +- [ ] **Step 1: Create `ui-store.ts`** + +```typescript +import { create } from 'zustand'; + +export type DashboardTab = 'live' | 'messages' | 'recordings' | 'settings' | 'dashboard'; + +interface UIState { + sidebarCollapsed: boolean; + activeTab: DashboardTab; + theme: 'dark' | 'light' | 'system'; + selectedVoiceGuild: string; + selectedVoiceChannel: string; + toggleSidebar: () => void; + setActiveTab: (tab: DashboardTab) => void; + setTheme: (theme: 'dark' | 'light' | 'system') => void; +} + +export const useUIStore = create((set) => ({ + sidebarCollapsed: false, + activeTab: 'live', + theme: 'dark', + selectedVoiceGuild: '', + selectedVoiceChannel: '', + toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })), + setActiveTab: (tab) => set({ activeTab: tab }), + setTheme: (theme) => set({ theme }), +})); +``` + +- [ ] **Step 2: Create `voice-store.ts`** + +```typescript +import { create } from 'zustand'; +import type { ActiveSpeaker, VoiceStatus } from '@bete/shared/types'; + +interface VoiceState { + connected: boolean; + status: VoiceStatus | null; + activeSpeakers: ActiveSpeaker[]; + guildId: string; + channelId: string; + setConnected: (connected: boolean) => void; + setStatus: (status: VoiceStatus) => void; + setActiveSpeakers: (speakers: ActiveSpeaker[]) => void; + updateSpeaker: (speaker: Partial & { userId: string }) => void; + setGuildChannel: (guildId: string, channelId: string) => void; +} + +export const useVoiceStore = create((set) => ({ + connected: false, + status: null, + activeSpeakers: [], + guildId: '', + channelId: '', + setConnected: (connected) => set({ connected }), + setStatus: (status) => set({ status }), + setActiveSpeakers: (activeSpeakers) => set({ activeSpeakers }), + updateSpeaker: (speaker) => + set((s) => { + const idx = s.activeSpeakers.findIndex((sp) => sp.userId === speaker.userId); + if (idx >= 0) { + const next = [...s.activeSpeakers]; + next[idx] = { ...next[idx], ...speaker, heardAt: Date.now() }; + return { activeSpeakers: next }; + } + return { activeSpeakers: [...s.activeSpeakers, { ...speaker, heardAt: Date.now() } as ActiveSpeaker] }; + }), + setGuildChannel: (guildId, channelId) => set({ guildId, channelId }), +})); +``` + +- [ ] **Step 3: Create `message-store.ts`** + +```typescript +import { create } from 'zustand'; +import type { MessageRecord } from '@bete/shared/types'; + +interface MessageState { + messages: MessageRecord[]; + setMessages: (msgs: MessageRecord[] | ((prev: MessageRecord[]) => MessageRecord[])) => void; + prependMessage: (msg: MessageRecord) => void; + updateMessage: (id: string, updates: Partial) => void; + removeMessage: (id: string) => void; +} + +export const useMessageStore = create((set) => ({ + messages: [], + setMessages: (msgs) => + set((s) => ({ + messages: typeof msgs === 'function' ? msgs(s.messages) : msgs, + })), + prependMessage: (msg) => + set((s) => ({ + messages: s.messages.some((m) => m.id === msg.id) ? s.messages : [msg, ...s.messages], + })), + updateMessage: (id, updates) => + set((s) => ({ + messages: s.messages.map((m) => (m.id === id ? { ...m, ...updates } : m)), + })), + removeMessage: (id) => + set((s) => ({ + messages: s.messages.map((m) => (m.id === id ? { ...m, type: 'deleted' as const } : m)), + })), +})); +``` + +- [ ] **Step 4: Commit** + +```bash +git add services/frontend/src/stores/ +git commit -m "feat(frontend): add Zustand stores (ui, voice, message)" +``` + +--- + +### Task 2.2: Create WebSocket Bridge + AuthGuard Island + +**Files:** +- Port: `services/frontend/src/shared/ws/socket.ts` (exists, port as-is with minor cleanup) +- Create: `services/frontend/src/islands/AuthGuard.tsx` +- Create: `services/frontend/src/islands/ThemeToggle.tsx` + +**Interfaces:** +- Consumes: `useUIStore` from Task 2.1, existing `socket.ts` +- Produces: Islands mounted on `/login` and layout + +- [ ] **Step 1: Verify `socket.ts` is clean and working** +Read existing file and confirm SocketManager class is exported properly. + +Run: +```bash +head -5 services/frontend/src/shared/ws/socket.ts +``` +Expected: Contains `export class SocketManager` or similar. + +- [ ] **Step 2: Create `AuthGuard.tsx` island** + +```tsx +import { useEffect, useState } from 'react'; +import { getSessionToken, getAdminPassword, clearSessionToken } from '../shared/api/client'; + +interface AuthGuardProps { + children?: React.ReactNode; +} + +export default function AuthGuard({ children }: AuthGuardProps) { + const [authenticated, setAuthenticated] = useState(null); + + useEffect(() => { + const token = getSessionToken(); + const password = getAdminPassword(); + if (token || password) { + setAuthenticated(true); + } else { + setAuthenticated(false); + } + }, []); + + if (authenticated === null) { + return ( +
+
+
+ ); + } + + if (!authenticated) { + return ( +
+

Please enter admin password to continue.

+ {/* Login form with password input */} +
{ + e.preventDefault(); + const form = e.target as HTMLFormElement; + const input = form.elements.namedItem('password') as HTMLInputElement; + localStorage.setItem('admin-password', input.value); + setAuthenticated(true); + }} className="space-y-3"> + + +
+
+ ); + } + + return <>{children}; +} +``` + +- [ ] **Step 3: Create `ThemeToggle.tsx` island** + +```tsx +import { useEffect, useState } from 'react'; + +export default function ThemeToggle() { + const [theme, setTheme] = useState<'dark' | 'light'>('dark'); + + useEffect(() => { + const stored = localStorage.getItem('bete-dashboard-theme'); + if (stored === 'light' || stored === 'dark') setTheme(stored); + }, []); + + const toggle = () => { + const next = theme === 'dark' ? 'light' : 'dark'; + setTheme(next); + localStorage.setItem('bete-dashboard-theme', next); + document.documentElement.setAttribute('data-theme', next); + document.documentElement.classList.toggle('dark', next === 'dark'); + }; + + return ( + + ); +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add services/frontend/src/islands/AuthGuard.tsx services/frontend/src/islands/ThemeToggle.tsx +git commit -m "feat(frontend): add AuthGuard and ThemeToggle islands" +``` + +--- + +## Phase 3: Messages Feature + +### Task 3.1: MessageFeed Island + +**Files:** +- Create: `services/frontend/src/islands/MessageFeed.tsx` +- Port: `services/frontend/src/shared/hooks/useMessages.ts` from `features/messages/hooks/useMessages.ts` +- Port: `services/frontend/src/shared/api/client.ts` (exists) + +**Interfaces:** +- Consumes: `useMessageStore` from Task 2.1, `socket.ts` from Task 2.2 +- Produces: `MessageFeed` React island with loading/error/empty states + +- [ ] **Step 1: Port `useMessages` hook to `src/shared/hooks/useMessages.ts`** + +```typescript +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { MessageRecord } from '@bete/shared/types'; +import { getMessages, reanalyzeMessage } from '../api/client'; +import { useMessageStore } from '../../stores/message-store'; + +export function mergeMessages( + existing: MessageRecord[], + incoming: MessageRecord[], +): MessageRecord[] { + const map = new Map(existing.map((m) => [m.id, m])); + for (const msg of incoming) { + map.set(msg.id, msg); + } + return Array.from(map.values()).sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ); +} + +export function useMessages() { + const { messages, setMessages, prependMessage } = useMessageStore(); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const cursorRef = useRef(undefined); + const guildRef = useRef(''); + + const fetchMessages = useCallback(async (guildId?: string) => { + if (!guildId) return; + guildRef.current = guildId; + setLoading(true); + try { + const res = await getMessages({ guildId, limit: 50 }); + setMessages(res.data ?? []); + cursorRef.current = res.nextCursor; + setHasMore(!!res.nextCursor); + } catch (err) { + console.error('Failed to fetch messages', err); + } finally { + setLoading(false); + } + }, [setMessages]); + + const loadMore = useCallback(async () => { + if (!hasMore || loadingMore || !guildRef.current) return; + setLoadingMore(true); + try { + const res = await getMessages({ guildId: guildRef.current, cursor: cursorRef.current, limit: 50 }); + setMessages((prev) => mergeMessages(prev, res.data ?? [])); + cursorRef.current = res.nextCursor; + setHasMore(!!res.nextCursor); + } catch (err) { + console.error('Failed to load more', err); + } finally { + setLoadingMore(false); + } + }, [hasMore, loadingMore, setMessages]); + + const reanalyze = useCallback(async (messageId: string) => { + try { + await reanalyzeMessage(messageId); + } catch (err) { + console.error('Reanalyze failed', err); + } + }, []); + + return { messages, loading, loadingMore, hasMore, fetchMessages, loadMore, reanalyze }; +} +``` + +- [ ] **Step 2: Create `MessageFeed.tsx` island** + +```tsx +import { useEffect, useRef, useCallback } from 'react'; +import { useMessages } from '../shared/hooks/useMessages'; +import { useDashboardSocket } from '../shared/ws/socket'; +import { useMessageStore } from '../stores/message-store'; + +interface MessageFeedProps { + guildId?: string; +} + +export default function MessageFeed({ guildId }: MessageFeedProps) { + const { messages, loading, loadingMore, hasMore, fetchMessages, loadMore, reanalyze } = useMessages(); + const { setMessages, updateMessage } = useMessageStore(); + const containerRef = useRef(null); + const scrollRef = useRef(null); + + // WebSocket bridge + useDashboardSocket({ + onMessageCreated: (msg) => { + useMessageStore.getState().prependMessage(msg); + }, + onMessageUpdated: (msg) => { + useMessageStore.getState().updateMessage(msg.id, msg); + }, + onMessageDeleted: (msg) => { + useMessageStore.getState().removeMessage(msg.id); + }, + onMessageAnalyzed: (msg) => { + useMessageStore.getState().updateMessage(msg.id, msg); + }, + }); + + useEffect(() => { + if (guildId) fetchMessages(guildId); + }, [guildId, fetchMessages]); + + // Infinite scroll + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 200 && hasMore && !loadingMore) { + loadMore(); + } + }, [hasMore, loadingMore, loadMore]); + + // Loading state + if (loading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ); + } + + // Empty state + if (!loading && messages.length === 0) { + return ( +
+
💬
+

No messages yet

+

Messages will appear here once captured.

+
+ ); + } + + // Error state + if (messages.length === 0) { + return ( +
+
!
+

Failed to load messages

+ +
+ ); + } + + // Success state + return ( +
+ {messages.map((msg) => ( +
+
+ +
+
+ {msg.username} + {msg.ai_severity && ( + + {msg.ai_severity.toUpperCase()} + + )} +
+

{msg.content || msg.edited_content}

+
+ +
+
+
+
+ ))} + {loadingMore && ( +
+
+
+ )} +
+ ); +} +``` + +- [ ] **Step 3: Create `messages.astro` page** + +```astro +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import Sidebar from '../components/sidebar/Sidebar.astro'; +import Header from '../components/header/Header.astro'; +import ThemeToggle from '../islands/ThemeToggle.tsx'; +import MessageFeed from '../islands/MessageFeed.tsx'; +--- + + +
+ +
+
+ +
+
+ +
+
+
+
+ + +``` + +- [ ] **Step 4: Commit** + +```bash +git add services/frontend/src/islands/MessageFeed.tsx services/frontend/src/pages/messages.astro services/frontend/src/shared/hooks/useMessages.ts +git commit -m "feat(frontend): add MessageFeed island and /messages page" +``` + +--- + +## Phase 4: Live / Voice Feature + +### Task 4.1: Voice Islands (Controls, Speakers, Visualizer) + +**Files:** +- Create: `services/frontend/src/islands/VoiceControls.tsx` +- Create: `services/frontend/src/islands/ActiveSpeakers.tsx` +- Create: `services/frontend/src/islands/AudioVisualizer.tsx` +- Create: `services/frontend/src/islands/NowPlaying.tsx` +- Port: `services/frontend/src/shared/hooks/useVoiceControl.ts` from `features/live/hooks/useVoiceControl.ts` +- Port: `services/frontend/src/shared/hooks/useMediaControl.ts` from `features/live/hooks/useMediaControl.ts` + +**Interfaces:** +- Consumes: `useVoiceStore` from Task 2.1, `useDashboardSocket` from `socket.ts` +- Produces: Voice islands mounted on `/live` page + +- [ ] **Step 1: Create `VoiceControls.tsx`** + +```tsx +import { useEffect, useState } from 'react'; +import { useVoiceStore } from '../stores/voice-store'; +import { voiceConnect, voiceDisconnect, getStatus } from '../shared/api/client'; + +interface VoiceControlsProps { + guilds: Array<{ id: string; name: string }>; + voiceChannels: Array<{ id: string; name: string }>; +} + +export default function VoiceControls({ guilds, voiceChannels }: VoiceControlsProps) { + const { guildId, channelId, connected, setConnected, setGuildChannel } = useVoiceStore(); + const [loading, setLoading] = useState(false); + + const handleConnect = async () => { + if (!guildId || !channelId) return; + setLoading(true); + try { + await voiceConnect(guildId, channelId); + setConnected(true); + } catch (err) { + console.error('Failed to connect', err); + } finally { + setLoading(false); + } + }; + + const handleDisconnect = async () => { + setLoading(true); + try { + await voiceDisconnect(); + setConnected(false); + } catch (err) { + console.error('Failed to disconnect', err); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Guild select */} + + + {/* Channel select */} + + + {/* Connect/Disconnect */} + {!connected ? ( + + ) : ( + + )} +
+ ); +} +``` + +- [ ] **Step 2: Create `ActiveSpeakers.tsx`** + +```tsx +import { useVoiceStore } from '../stores/voice-store'; + +export default function ActiveSpeakers() { + const activeSpeakers = useVoiceStore((s) => s.activeSpeakers); + + if (activeSpeakers.length === 0) { + return ( +
+

No active speakers

+
+ ); + } + + return ( +
    + {activeSpeakers.map((speaker) => ( +
  • + + {speaker.username} + {speaker.speaking && ( +
    + {Array.from({ length: 4 }).map((_, i) => ( +
    + ))} +
    + )} +
  • + ))} +
+ ); +} +``` + +- [ ] **Step 3: Create `AudioVisualizer.tsx`** (canvas-based) + +```tsx +import { useEffect, useRef } from 'react'; + +interface AudioVisualizerProps { + barCount?: number; + height?: number; +} + +export default function AudioVisualizer({ barCount = 48, height = 32 }: AudioVisualizerProps) { + const canvasRef = useRef(null); + const animRef = useRef(0); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Dummy frequency data — in real app, this comes from WebSocket + const draw = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + const barWidth = canvas.width / barCount; + for (let i = 0; i < barCount; i++) { + const h = Math.random() * height * 0.8; + ctx.fillStyle = `oklch(0.62 0.15 255 / ${0.3 + Math.random() * 0.7})`; + ctx.fillRect(i * barWidth, height - h, barWidth - 1, h); + } + animRef.current = requestAnimationFrame(draw); + }; + draw(); + + return () => cancelAnimationFrame(animRef.current); + }, [barCount, height]); + + return ( + + ); +} +``` + +- [ ] **Step 4: Create `live.astro` page** + +```astro +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import Sidebar from '../components/sidebar/Sidebar.astro'; +import Header from '../components/header/Header.astro'; +import Card from '../components/ui/Card.astro'; +import ThemeToggle from '../islands/ThemeToggle.tsx'; +import VoiceControls from '../islands/VoiceControls.tsx'; +import ActiveSpeakers from '../islands/ActiveSpeakers.tsx'; +import AudioVisualizer from '../islands/AudioVisualizer.tsx'; +import NowPlaying from '../islands/NowPlaying.tsx'; +import Particles from '../islands/Particles.tsx'; +import MascotChat from '../islands/MascotChat.tsx'; +--- + + +
+ +
+
+ +
+
+
+
+ +

Voice Connection

+ +
+ + +

Now Playing

+ +
+
+ +
+
+
+ + +
+
+ + +``` + +- [ ] **Step 5: Commit** + +```bash +git add services/frontend/src/islands/VoiceControls.tsx services/frontend/src/islands/ActiveSpeakers.tsx services/frontend/src/islands/AudioVisualizer.tsx services/frontend/src/islands/NowPlaying.tsx services/frontend/src/pages/live.astro +git commit -m "feat(frontend): add voice islands and /live page" +``` + +--- + +### Task 4.2: Recordings Page and Remaining Pages + +**Files:** +- Create: `services/frontend/src/pages/recordings.astro` +- Create: `services/frontend/src/islands/RecordingsList.tsx` +- Create: `services/frontend/src/pages/settings.astro` +- Create: `services/frontend/src/islands/SettingsForm.tsx` + +- [ ] **Step 1: Create `RecordingsList.tsx` island** + +```tsx +import { useEffect, useState } from 'react'; +import { getRecordings } from '../shared/api/client'; + +interface Recording { + id: string; + username: string; + channel_name: string; + created_at: string; + download_url?: string; +} + +export default function RecordingsList() { + const [recordings, setRecordings] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getRecordings() + .then((res) => { + setRecordings(res.data ?? []); + setLoading(false); + }) + .catch((err) => { + setError(err.message ?? 'Failed to load recordings'); + setLoading(false); + }); + }, []); + + if (loading) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (error) { + return ( +
+
!
+

{error}

+
+ ); + } + + if (recordings.length === 0) { + return ( +
+

No recordings

+

Join a voice channel to start recording.

+
+ ); + } + + return ( +
+ {recordings.map((rec) => ( +
+
+
+

{rec.username}

+

{rec.channel_name} · {new Date(rec.created_at).toLocaleString()}

+
+ {rec.download_url && ( + + Download + + )} +
+
+ ))} +
+ ); +} +``` + +- [ ] **Step 2: Create `recordings.astro` page** + +```astro +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import Sidebar from '../components/sidebar/Sidebar.astro'; +import Header from '../components/header/Header.astro'; +import Card from '../components/ui/Card.astro'; +import RecordingsList from '../islands/RecordingsList.tsx'; +import ThemeToggle from '../islands/ThemeToggle.tsx'; +--- + + +
+ +
+
+ +
+
+ + + +
+
+
+
+``` + +- [ ] **Step 3: Create `SettingsForm.tsx` island and `settings.astro` page** + +```tsx +// settings.astro +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import Sidebar from '../components/sidebar/Sidebar.astro'; +import Header from '../components/header/Header.astro'; +import Card from '../components/ui/Card.astro'; +import ThemeToggle from '../islands/ThemeToggle.tsx'; +import SettingsForm from '../islands/SettingsForm.tsx'; +--- + + +
+ +
+
+ +
+
+ + + +
+
+
+
+``` + +```tsx +// SettingsForm.tsx +import { useState, useEffect } from 'react'; + +export default function SettingsForm() { + const [theme, setThemeState] = useState<'dark' | 'light' | 'system'>('dark'); + + useEffect(() => { + const stored = localStorage.getItem('bete-dashboard-theme') as 'dark' | 'light' | 'system' | null; + if (stored) setThemeState(stored); + }, []); + + const handleThemeChange = (mode: 'dark' | 'light' | 'system') => { + setThemeState(mode); + localStorage.setItem('bete-dashboard-theme', mode); + let resolved: string; + if (mode === 'system') { + resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } else { + resolved = mode; + } + document.documentElement.setAttribute('data-theme', resolved); + document.documentElement.classList.toggle('dark', resolved === 'dark'); + }; + + return ( +
+

Appearance

+
+ +
+ {(['dark', 'light', 'system'] as const).map((mode) => ( + + ))} +
+
+
+ ); +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add services/frontend/src/pages/recordings.astro services/frontend/src/islands/RecordingsList.tsx services/frontend/src/pages/settings.astro services/frontend/src/islands/SettingsForm.tsx +git commit -m "feat(frontend): add Recordings and Settings pages with islands" +``` + +--- + +## Phase 5: Polish (Deferred Islands + Cleanup) + +### Task 5.1: Deferred Islands (Particles, Mascot, Recordings) + +**Files:** +- Port: `services/frontend/src/islands/Particles.tsx` from `widgets/particles/` +- Create: `services/frontend/src/islands/MascotChat.tsx` + +- [ ] **Step 1: Create `Particles.tsx`** (Three.js background, deferred) + +```tsx +import { useEffect, useRef } from 'react'; + +export default function Particles() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + + const particles: Array<{ x: number; y: number; vx: number; vy: number; r: number }> = []; + for (let i = 0; i < 30; i++) { + particles.push({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + vx: (Math.random() - 0.5) * 0.5, + vy: (Math.random() - 0.5) * 0.5, + r: Math.random() * 3 + 1, + }); + } + + let animId: number; + const animate = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + for (const p of particles) { + p.x += p.vx; + p.y += p.vy; + if (p.x < 0 || p.x > canvas.width) p.vx *= -1; + if (p.y < 0 || p.y > canvas.height) p.vy *= -1; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fillStyle = 'oklch(0.62 0.15 255 / 0.15)'; + ctx.fill(); + } + animId = requestAnimationFrame(animate); + }; + animate(); + + return () => cancelAnimationFrame(animId); + }, []); + + return ( +