# 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 (