Non-negotiable conventions. Follow strictly.
- 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.mdin full before acting. - Treat the skill and this
AGENTS.mdas cumulative, mandatory guidance. If additional applicable skills are added under.agents/, read those in full as well.
- No
anytype - use explicit types, compose when needed - Limit Return Values - NEVER return more than 2 values. 3 or more is strictly illegal.
- No
@ts-ignoreor@ts-nocheck- fix the type - No
try/catch- usesafeWrap/safeWrapAsyncfrom@vibes/shared - Use Biome for ALL linting and formatting. No ESLint.
- Run
pnpm lintto check both format and lint rules before committing. - Run
pnpm typecheckto verify TypeScript compilation before committing. - Use
@vibes/apifor 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()ornew EventSource()in app code. Only@vibes/apiclients; native API construction may injectexpo/fetch. - React Router REST boundary - In every DOM app, including Cast and Samsung Tizen, REST calls belong only in
loader,clientLoader,action, orclientActionmodules. 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
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
@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
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 dataImportant: 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.
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
});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__ || {};Both apps use the pnpm workspace:
# Development (with watch mode and SSR)
pnpm dev
# Production build (with content hashing)
pnpm buildBuild features:
- Content-based hashing for cache busting (
client-abc123.js) - Manifest generation for asset resolution
- CSS compilation with Tailwind v4
- SSR server bundling
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>;
}const { room, users, userId, isAdmin, setRoom, setSession } = useRoomStore();const { songs, setSongs, addSong, removeSong, reorderSongs } = useQueueStore();const { currentSong, isPlaying, positionMs, actualPositionMs, setPlaybackState } = usePlaybackStore();const { isDarkMode, toggleDarkMode } = useThemeStore();const { isConnected, availableDevices, connectToDevice, castCurrentSong } = useCastStore();const { room, users, fetchRoom, joinRoom, updateRoomSettings } = useRoom(roomId);const { songs, fetchQueue, addToQueue, removeFromQueue, moveInQueue } = useQueue(roomId);const { currentSong, isPlaying, play, pause, seek, skip, vote } = usePlayback(roomId);// Automatically manages SSE connection lifecycle
// Reference counting for multiple subscribers
// Grace period before cleanup (2 seconds)
useSSE(roomId);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 changedThe 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' });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' }}>- 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
undefinedto 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>
);
}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)
}
}// 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);
}
};- Type Safety: Run
pnpm typecheckbefore committing - Code Quality: Run
pnpm lintfor formatting and linting - API Integration: Use
@vibes/apifor all API calls - Error Handling: Use
safeWrap/safeWrapAsyncutilities - State Management: Use Zustand stores for global state
- Styling: Use Tailwind CSS v4 with dark mode support
- SSR: Test both server-side rendering and client hydration