Skip to content

Latest commit

 

History

History
385 lines (304 loc) · 12.5 KB

File metadata and controls

385 lines (304 loc) · 12.5 KB

Frontend Coding Rules

Non-negotiable conventions. Follow strictly.

Required Skill Check

  • Before making changes or reviewing code, check the repository's .agents/ directory for applicable skills.
  • Frontend work in this repository requires reading .agents/skills/vibes-frontend/SKILL.md in full before acting.
  • Treat the skill and this AGENTS.md as cumulative, mandatory guidance. If additional applicable skills are added under .agents/, read those in full as well.

Critical Rules

  • No any type - use explicit types, compose when needed
  • Limit Return Values - NEVER return more than 2 values. 3 or more is strictly illegal.
  • No @ts-ignore or @ts-nocheck - fix the type
  • No try/catch - use safeWrap/safeWrapAsync from @vibes/shared
  • Use Biome for ALL linting and formatting. No ESLint.
  • Run pnpm lint to check both format and lint rules before committing.
  • Run pnpm typecheck to verify TypeScript compilation before committing.
  • Use @vibes/api for ALL API calls / SSE.
  • Only SSE hooks in @vibes/api - reusable SSE subscription hooks are allowed. REST/request hooks, providers, stores, and application state orchestration are forbidden; REST capabilities remain plain React-free functions.
  • NEVER use fetch() or new EventSource() in app code. Only @vibes/api clients; native API construction may inject expo/fetch.
  • React Router REST boundary - In every DOM app, including Cast and Samsung Tizen, REST calls belong only in loader, clientLoader, action, or clientAction modules. Components may submit through fetchers and subscribe to SSE through @vibes/api, but must never execute REST requests inline.
  • Unified Build System - Apps build through the pnpm workspace
  • Content Hashing - All assets use content-based hashing for cache busting

File Layout

apps/platform/src/
├── components/
│   ├── ui/                # Deprecated; shared UI now lives in @vibes/ui
│   ├── player/            # Deprecated; shared player UI now lives in @vibes/ui
│   ├── queue/             # QueueItem, QueueList, AddToQueueModal
│   ├── cast/              # CastButton, DeviceSelector
│   └── room/              # UserCount, room components
├── hooks/                 # Custom React hooks (useRoom, useQueue, usePlayback, useSSE)
├── stores/                # Zustand stores (roomStore, queueStore, castStore, themeStore)
├── pages/                 # Route components (Home, CreateRoom, RoomView, Callback)
├── services/              # castManager, etc.
├── vite.config.ts         # Vite build
└── client.tsx             # Client hydration

apps/cast/src/
├── App.tsx                # Cast receiver entrypoint
├── components/            # Cast-specific components
├── vite.config.ts         # Vite build
└── client.tsx             # Client hydration

packages/
├── api/                   # API client
├── models/                # Shared types and compiled Zod 4 schemas
├── shared/                # Utilities, hooks, stores
│   ├── src/utils/wrap.ts  # safeWrap utilities
│   ├── src/stores/        # Shared Zustand stores (playbackStore)
│   ├── src/hooks/         # Shared hooks (useProviderToken)
│   └── src/constants.ts   # Shared constants
└── ui/                    # Shared UI with web, native, and shared subpath boundaries

Packages

  • @vibes/api: API client (import { api } from '@vibes/api')
  • @vibes/models: Shared types and schemas (import { Room, Song, PlaybackState } from '@vibes/models')
  • @vibes/shared: Shared utilities (import { safeWrap, usePlaybackStore, SourceType } from '@vibes/shared')
  • @vibes/ui/web: DOM UI and player components (import { VideoPlayer, Toast } from '@vibes/ui/web')
  • @vibes/ui/native: React Native controls and official native provider-player wrappers
  • @vibes/ui/shared: Platform-neutral icons, formatting, playback calculations, and presentation helpers

Error Handling

Never use try/catch. Use the wrap utilities from @vibes/shared:

import { safeWrap, safeWrapAsync } from '@vibes/shared';

// Sync
const [error, result] = safeWrap(() => JSON.parse(data));
if (error) {
  // handle error
  return;
}
// use result

// Async - CRITICAL: destructure as [error, data]
const [error, data] = await safeWrapAsync(loadSomething());
if (error) {
  // handle error
  return;
}
// use data

Important: safeWrapAsync returns [Error | null, T | null] - always destructure as [error, data], not [data, error]. Important: @vibes/api methods already return [error, data]; do not wrap api.get(), api.post(), api.put(), or similar API calls in safeWrapAsync.

API Calls

Always use @vibes/api:

import { api } from '@vibes/api';

// GET request
const [error, room] = await api.get('/rooms/{id}', { id: roomId });

// POST request
const [error, song] = await api.post('/rooms/{id}/songs', { id: roomId }, {
  sourceType: 'youtube',
  sourceId: 'dQw4w9WgXcQ',
  title: 'Never Gonna Give You Up',
  artist: 'Rick Astley',
  thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
  duration: 213
});

// PUT request
const [error, playbackState] = await api.put('/rooms/{id}/states', { id: roomId }, {
  action: 'play',
  positionMs: 45000
});

Server-Side Rendering (SSR)

The platform app uses React Router with Node.js serving:

// Production server
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router';

// Asset resolution via manifest
react-router-serve ./build/server/index.js
const mainJS = manifest['main.js'] ? `/assets/platform/${manifest['main.js']}` : '/assets/platform/client.js';

// Data injection for hydration
const initialData = {
  createRoomName: searchParams.get('name') || '',
  theme: 'dark'
};

// client.tsx - Client hydration
import { hydrateRoot } from 'react-dom/client';

// Extract SSR data
const initialData = (window as any).__INITIAL_DATA__ || {};

Build System

Both apps use the pnpm workspace:

# Development (with watch mode and SSR)
pnpm dev

# Production build (with content hashing)
pnpm build

Build features:

  • Content-based hashing for cache busting (client-abc123.js)
  • Manifest generation for asset resolution
  • CSS compilation with Tailwind v4
  • SSR server bundling

Hydration Pattern

Prevent hydration mismatches:

const [isHydrated, setIsHydrated] = useState(false);

// Initialize with SSR data
const [name, setName] = useState(() => {
  if (initialData?.createRoomName) {
    return initialData.createRoomName;
  }
  return '';
});

useEffect(() => {
  setIsHydrated(true);
  // Fix hydration mismatch: ensure client state matches server state
  if (initialData?.createRoomName) {
    setName(initialData.createRoomName);
  }
}, []);

// Render different content during hydration if needed
if (!isHydrated) {
  return <div>Loading...</div>;
}

State Management (Zustand)

Room Store (useRoomStore)

const { room, users, userId, isAdmin, setRoom, setSession } = useRoomStore();

Queue Store (useQueueStore)

const { songs, setSongs, addSong, removeSong, reorderSongs } = useQueueStore();

Playback Store (usePlaybackStore)

const { currentSong, isPlaying, positionMs, actualPositionMs, setPlaybackState } = usePlaybackStore();

Theme Store (useThemeStore)

const { isDarkMode, toggleDarkMode } = useThemeStore();

Cast Store (useCastStore)

const { isConnected, availableDevices, connectToDevice, castCurrentSong } = useCastStore();

Custom Hooks

useRoom(roomId)

const { room, users, fetchRoom, joinRoom, updateRoomSettings } = useRoom(roomId);

useQueue(roomId)

const { songs, fetchQueue, addToQueue, removeFromQueue, moveInQueue } = useQueue(roomId);

usePlayback(roomId)

const { currentSong, isPlaying, play, pause, seek, skip, vote } = usePlayback(roomId);

useSSE(roomId)

// Automatically manages SSE connection lifecycle
// Reference counting for multiple subscribers
// Grace period before cleanup (2 seconds)
useSSE(roomId);

Real-time Updates (SSE)

SSE events are handled automatically by useSSE hook:

// Event types received:
// - playback_update: Playback state changed
// - song_added: Song added to queue
// - song_removed: Song removed from queue
// - songs_update: Queue reordered
// - users_update: User count updated
// - settings_update: Room settings changed

Music Providers

The app supports multiple music providers:

// Source types
type SourceType = 'youtube' | 'soundcloud';

// Provider authentication
const { getToken, isAuthenticated } = useProviderToken('soundcloud');

// Search across providers
const [error, results] = await api.get('/youtube/search', {}, { q: 'never gonna give you up' });
const [error, results] = await api.get('/soundcloud/search', {}, { q: 'never gonna give you up' });

Styling

Use Tailwind CSS v4 or NativeWind with dark mode support. Static presentation must not use inline styles or CSS-in-JS. React Native may use inline styles only for measured/runtime geometry, progress, or required native API style objects. Use classNames from @vibes/shared for conditional or composed class names. Do not interpolate class names with template strings.

// Good - with dark mode support
<button className="bg-white text-gray-900 dark:bg-gray-800 dark:text-white px-4 py-2 rounded-lg transition-colors duration-200">

// Glass morphism pattern (existing design language)
<div className="glass p-4 rounded-xl hover:shadow-retro active:scale-95 transition-all">

// Responsive design
<div className="text-sm md:text-base lg:text-lg">

// Bad
<button style={{ backgroundColor: 'purple' }}>

Component Guidelines

  • Props interfaces defined above component
  • Destructure props in function signature
  • Export named components; framework route and entrypoint files may use required default exports
  • Support dark mode
  • Include proper focus states for accessibility
  • Do not use ternaries to render JSX or DOM element branches. Use explicit && conditions for each branch. Ternaries are allowed for scalar props, labels, and computed values.
  • Do not explicitly pass undefined to JSX props. Use a conditional JSX spread to omit optional attributes.
import { classNames } from '@vibes/shared';

interface ButtonProps {
  variant: 'primary' | 'secondary';
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
}

export function Button({ variant, children, onClick, disabled }: ButtonProps) {
  return (
    <button
      className={classNames(
        'px-4 py-2 rounded-lg font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-800',
        variant === 'primary'
          ? 'bg-primary text-white hover:bg-primary-dark dark:bg-primary-light dark:hover:bg-primary' 
          : 'bg-gray-200 text-gray-900 hover:bg-gray-300 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600',
        disabled && 'opacity-50 cursor-not-allowed',
      )}
      onClick={onClick}
      disabled={disabled}
    >
      {children}
    </button>
  );
}

Room Modes

Handle different room modes in the UI:

// Server mode: Server controls playback automatically
if (room?.mode === 'server') {
  // Show play/pause controls for all users
  // Show skip voting UI
}

// Host mode: Only host can control playback
if (room?.mode === 'host') {
  if (room.hostId === userId || isAdmin) {
    // Show full playback controls
  } else {
    // Show limited UI (add songs, vote)
  }
}

Casting Integration

// Initialize casting
const { initialize, connectToDevice, castCurrentSong } = useCastStore();

useEffect(() => {
  initialize();
}, []);

// Cast current song
const handleCast = async (deviceId: string) => {
  await connectToDevice(deviceId);
  if (currentSong) {
    await castCurrentSong(currentSong);
  }
};

Development Workflow

  1. Type Safety: Run pnpm typecheck before committing
  2. Code Quality: Run pnpm lint for formatting and linting
  3. API Integration: Use @vibes/api for all API calls
  4. Error Handling: Use safeWrap/safeWrapAsync utilities
  5. State Management: Use Zustand stores for global state
  6. Styling: Use Tailwind CSS v4 with dark mode support
  7. SSR: Test both server-side rendering and client hydration