diff --git a/apps/web/src/components/GamePreview.tsx b/apps/web/src/components/GamePreview.tsx
new file mode 100644
index 00000000..0ff36052
--- /dev/null
+++ b/apps/web/src/components/GamePreview.tsx
@@ -0,0 +1,125 @@
+'use client'
+
+import { Component, createContext, useEffect, useMemo, useState } from 'react'
+import type { ReactNode } from 'react'
+import type { GameComponent, GameProviderComponent } from '@/lib/arcade/game-sdk/types'
+import { MockArcadeEnvironment } from './MockArcadeEnvironment'
+import { GameModeProvider } from '@/contexts/GameModeContext'
+import { ViewportProvider } from '@/contexts/ViewportContext'
+import { getMockGameState } from './MockGameStates'
+
+// Export context so useArcadeSession can check for preview mode
+export const PreviewModeContext = createContext<{
+ isPreview: boolean
+ mockState: any
+} | null>(null)
+
+interface GamePreviewProps {
+ GameComponent: GameComponent
+ Provider: GameProviderComponent
+ gameName: string
+}
+
+/**
+ * Error boundary to prevent game errors from crashing the page
+ */
+class GameErrorBoundary extends Component<
+ { children: ReactNode; fallback: ReactNode },
+ { hasError: boolean }
+> {
+ constructor(props: { children: ReactNode; fallback: ReactNode }) {
+ super(props)
+ this.state = { hasError: false }
+ }
+
+ static getDerivedStateFromError() {
+ return { hasError: true }
+ }
+
+ componentDidCatch(error: Error) {
+ console.error(`Game preview error (${error.message}):`, error)
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return this.props.fallback
+ }
+ return this.props.children
+ }
+}
+
+/**
+ * Wrapper for displaying games in demo/preview mode
+ * Provides mock arcade contexts so games can render
+ */
+export function GamePreview({ GameComponent, Provider, gameName }: GamePreviewProps) {
+ // Don't render on first mount to avoid hydration issues
+ const [mounted, setMounted] = useState(false)
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ // Get mock state for this game
+ const mockState = useMemo(() => getMockGameState(gameName), [gameName])
+
+ // Preview mode context value
+ const previewModeValue = useMemo(
+ () => ({
+ isPreview: true,
+ mockState,
+ }),
+ [mockState]
+ )
+
+ if (!mounted) {
+ return null
+ }
+
+ return (
+
+ 🎮
+ Game Demo
+
+ }
+ >
+
+
+
+ {/*
+ Mock viewport: Provide 1440x900 dimensions to games via ViewportContext
+ This prevents layout issues when games check viewport size
+ */}
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/MockArcadeEnvironment.tsx b/apps/web/src/components/MockArcadeEnvironment.tsx
new file mode 100644
index 00000000..4669629a
--- /dev/null
+++ b/apps/web/src/components/MockArcadeEnvironment.tsx
@@ -0,0 +1,211 @@
+'use client'
+
+import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
+import type { Player } from '@/contexts/GameModeContext'
+import type { GameMove } from '@/lib/arcade/validation'
+import type { RetryState } from '@/lib/arcade/error-handling'
+
+// ============================================================================
+// Mock ViewerId Context
+// ============================================================================
+
+const MockViewerIdContext = createContext('demo-viewer-id')
+
+export function useMockViewerId() {
+ return useContext(MockViewerIdContext)
+}
+
+// ============================================================================
+// Mock Room Data Context
+// ============================================================================
+
+interface MockRoomData {
+ id: string
+ name: string
+ code: string
+ gameName: string
+ gameConfig: Record
+}
+
+const MockRoomDataContext = createContext(null)
+
+export function useMockRoomData() {
+ const room = useContext(MockRoomDataContext)
+ if (!room) throw new Error('useMockRoomData must be used within MockRoomDataProvider')
+ return room
+}
+
+export function useMockUpdateGameConfig() {
+ return useCallback((config: Record) => {
+ // Mock: do nothing in preview mode
+ console.log('Mock updateGameConfig:', config)
+ }, [])
+}
+
+// ============================================================================
+// Mock Game Mode Context
+// ============================================================================
+
+type GameMode = 'single' | 'battle' | 'tournament'
+
+interface GameModeContextType {
+ gameMode: GameMode
+ players: Map
+ activePlayers: Set
+ activePlayerCount: number
+ addPlayer: (player?: Partial) => void
+ updatePlayer: (id: string, updates: Partial) => void
+ removePlayer: (id: string) => void
+ setActive: (id: string, active: boolean) => void
+ getActivePlayers: () => Player[]
+ getPlayer: (id: string) => Player | undefined
+ getAllPlayers: () => Player[]
+ resetPlayers: () => void
+ isLoading: boolean
+}
+
+const MockGameModeContextValue = createContext(null)
+
+export function useMockGameMode() {
+ const ctx = useContext(MockGameModeContextValue)
+ if (!ctx) throw new Error('useMockGameMode must be used within MockGameModeProvider')
+ return ctx
+}
+
+// ============================================================================
+// Mock Arcade Session
+// ============================================================================
+
+interface MockArcadeSessionReturn {
+ state: TState
+ version: number
+ connected: boolean
+ hasPendingMoves: boolean
+ lastError: string | null
+ retryState: RetryState
+ sendMove: (move: Omit) => void
+ exitSession: () => void
+ clearError: () => void
+ refresh: () => void
+}
+
+export function createMockArcadeSession(
+ initialState: TState
+): MockArcadeSessionReturn {
+ const mockRetryState: RetryState = {
+ isRetrying: false,
+ retryCount: 0,
+ move: null,
+ timestamp: null,
+ }
+
+ return {
+ state: initialState,
+ version: 1,
+ connected: true,
+ hasPendingMoves: false,
+ lastError: null,
+ retryState: mockRetryState,
+ sendMove: () => {
+ // Mock: do nothing in preview
+ },
+ exitSession: () => {
+ // Mock: do nothing in preview
+ },
+ clearError: () => {
+ // Mock: do nothing in preview
+ },
+ refresh: () => {
+ // Mock: do nothing in preview
+ },
+ }
+}
+
+// ============================================================================
+// Mock Environment Provider
+// ============================================================================
+
+interface MockArcadeEnvironmentProps {
+ children: ReactNode
+ gameName: string
+ gameConfig?: Record
+}
+
+export function MockArcadeEnvironment({
+ children,
+ gameName,
+ gameConfig = {},
+}: MockArcadeEnvironmentProps) {
+ const mockPlayers = useMemo(
+ (): Player[] => [
+ {
+ id: 'demo-player-1',
+ name: 'Demo Player',
+ emoji: '🎮',
+ color: '#3b82f6',
+ createdAt: Date.now(),
+ },
+ ],
+ []
+ )
+
+ const playersMap = useMemo(() => {
+ const map = new Map()
+ for (const p of mockPlayers) {
+ map.set(p.id, p)
+ }
+ return map
+ }, [mockPlayers])
+
+ const activePlayers = useMemo(() => new Set(mockPlayers.map((p) => p.id)), [mockPlayers])
+
+ const mockGameModeCtx: GameModeContextType = useMemo(
+ () => ({
+ gameMode: 'single',
+ players: playersMap,
+ activePlayers,
+ activePlayerCount: activePlayers.size,
+ addPlayer: () => {
+ // Mock: do nothing
+ },
+ updatePlayer: () => {
+ // Mock: do nothing
+ },
+ removePlayer: () => {
+ // Mock: do nothing
+ },
+ setActive: () => {
+ // Mock: do nothing
+ },
+ getActivePlayers: () => mockPlayers,
+ getPlayer: (id: string) => playersMap.get(id),
+ getAllPlayers: () => mockPlayers,
+ resetPlayers: () => {
+ // Mock: do nothing
+ },
+ isLoading: false,
+ }),
+ [mockPlayers, playersMap, activePlayers]
+ )
+
+ const mockRoomData: MockRoomData = useMemo(
+ () => ({
+ id: `demo-room-${gameName}`,
+ name: 'Demo Room',
+ code: 'DEMO',
+ gameName,
+ gameConfig,
+ }),
+ [gameName, gameConfig]
+ )
+
+ return (
+
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/MockArcadeHooks.tsx b/apps/web/src/components/MockArcadeHooks.tsx
new file mode 100644
index 00000000..a2f6af83
--- /dev/null
+++ b/apps/web/src/components/MockArcadeHooks.tsx
@@ -0,0 +1,21 @@
+'use client'
+
+/**
+ * Mock implementations of arcade SDK hooks for game previews
+ * These are exported with the same names so games can use them transparently
+ */
+
+import {
+ useMockViewerId,
+ useMockRoomData,
+ useMockUpdateGameConfig,
+ useMockGameMode,
+} from './MockArcadeEnvironment'
+
+// Re-export with SDK names
+export const useViewerId = useMockViewerId
+export const useRoomData = useMockRoomData
+export const useUpdateGameConfig = useMockUpdateGameConfig
+export const useGameMode = useMockGameMode
+
+// Note: useArcadeSession must be handled per-game since it needs type parameters
diff --git a/apps/web/src/components/MockGameStates.ts b/apps/web/src/components/MockGameStates.ts
new file mode 100644
index 00000000..d6d1354c
--- /dev/null
+++ b/apps/web/src/components/MockGameStates.ts
@@ -0,0 +1,437 @@
+/**
+ * Mock game states for game previews
+ * Creates proper initial states in "playing" phase for each game type
+ */
+
+import { complementRaceValidator } from '@/arcade-games/complement-race/Validator'
+import { matchingGameValidator } from '@/arcade-games/matching/Validator'
+import { memoryQuizGameValidator } from '@/arcade-games/memory-quiz/Validator'
+import { cardSortingValidator } from '@/arcade-games/card-sorting/Validator'
+import { rithmomachiaValidator } from '@/arcade-games/rithmomachia/Validator'
+import {
+ DEFAULT_COMPLEMENT_RACE_CONFIG,
+ DEFAULT_MATCHING_CONFIG,
+ DEFAULT_MEMORY_QUIZ_CONFIG,
+ DEFAULT_CARD_SORTING_CONFIG,
+ DEFAULT_RITHMOMACHIA_CONFIG,
+} from '@/lib/arcade/game-configs'
+import type { ComplementRaceState } from '@/arcade-games/complement-race/types'
+import type { MatchingState } from '@/arcade-games/matching/types'
+import type { MemoryQuizState } from '@/arcade-games/memory-quiz/types'
+import type { CardSortingState } from '@/arcade-games/card-sorting/types'
+import type { RithmomachiaState } from '@/arcade-games/rithmomachia/types'
+
+/**
+ * Create a mock state for Complement Race in playing phase
+ * Shows mid-game state with progress and activity
+ */
+export function createMockComplementRaceState(): ComplementRaceState {
+ const baseState = complementRaceValidator.getInitialState(DEFAULT_COMPLEMENT_RACE_CONFIG)
+
+ // Create some passengers for visual interest
+ const mockPassengers = [
+ {
+ id: 'p1',
+ name: 'Alice',
+ avatar: '👩💼',
+ originStationId: 'depot',
+ destinationStationId: 'canyon',
+ isUrgent: false,
+ claimedBy: 'demo-player-1',
+ deliveredBy: null,
+ carIndex: 0,
+ timestamp: Date.now() - 10000,
+ },
+ {
+ id: 'p2',
+ name: 'Bob',
+ avatar: '👨🎓',
+ originStationId: 'riverside',
+ destinationStationId: 'grand-central',
+ isUrgent: true,
+ claimedBy: null,
+ deliveredBy: null,
+ carIndex: null,
+ timestamp: Date.now() - 5000,
+ },
+ ]
+
+ // Create stations for sprint mode
+ const mockStations = [
+ { id: 'station-0', name: 'Depot', position: 0, icon: '🏭', emoji: '🏭' },
+ { id: 'station-1', name: 'Riverside', position: 20, icon: '🌊', emoji: '🌊' },
+ { id: 'station-2', name: 'Hillside', position: 40, icon: '⛰️', emoji: '⛰️' },
+ { id: 'station-3', name: 'Canyon View', position: 60, icon: '🏜️', emoji: '🏜️' },
+ { id: 'station-4', name: 'Meadows', position: 80, icon: '🌾', emoji: '🌾' },
+ { id: 'station-5', name: 'Grand Central', position: 100, icon: '🏛️', emoji: '🏛️' },
+ ]
+
+ // Override to playing phase with mid-game action
+ // IMPORTANT: Set style to 'sprint' for Steam Sprint mode with train visualization
+ return {
+ ...baseState,
+ config: {
+ ...baseState.config,
+ style: 'sprint', // Steam Sprint mode with train and passengers
+ },
+ style: 'sprint', // Also set at top level for local context
+ gamePhase: 'playing',
+ isGameActive: true,
+ activePlayers: ['demo-player-1'],
+ playerMetadata: {
+ 'demo-player-1': {
+ name: 'Demo Player',
+ color: '#3b82f6',
+ },
+ },
+ players: {
+ 'demo-player-1': {
+ id: 'demo-player-1',
+ name: 'Demo Player',
+ color: '#3b82f6',
+ score: 420,
+ streak: 5,
+ bestStreak: 8,
+ correctAnswers: 18,
+ totalQuestions: 21,
+ position: 65, // Well into the race
+ isReady: true,
+ isActive: true,
+ currentAnswer: null,
+ lastAnswerTime: Date.now() - 2000,
+ passengers: ['p1'],
+ deliveredPassengers: 5,
+ },
+ },
+ currentQuestions: {
+ 'demo-player-1': {
+ id: 'demo-q-current',
+ number: 6,
+ targetSum: 10,
+ correctAnswer: 4,
+ showAsAbacus: true,
+ timestamp: Date.now() - 1500,
+ },
+ },
+ currentQuestion: {
+ id: 'demo-q-current',
+ number: 6,
+ targetSum: 10,
+ correctAnswer: 4,
+ showAsAbacus: true,
+ timestamp: Date.now() - 1500,
+ },
+ // Sprint mode specific fields
+ momentum: 45, // Mid-level momentum
+ trainPosition: 65, // 65% along the track
+ pressure: 30, // Some pressure building up
+ elapsedTime: 45, // 45 seconds into the game
+ lastCorrectAnswerTime: Date.now() - 2000,
+ currentRoute: 1,
+ stations: mockStations,
+ passengers: mockPassengers,
+ deliveredPassengers: 5,
+ cumulativeDistance: 65,
+ showRouteCelebration: false,
+ questionStartTime: Date.now() - 1500,
+ gameStartTime: Date.now() - 45000, // Game has been running for 45 seconds
+ raceStartTime: Date.now() - 45000,
+ // Additional fields for compatibility
+ score: 420,
+ streak: 5,
+ bestStreak: 8,
+ correctAnswers: 18,
+ totalQuestions: 21,
+ currentInput: '',
+ }
+}
+
+/**
+ * Create a mock state for Matching game in playing phase
+ * Shows mid-game with some cards matched and one card flipped
+ */
+export function createMockMatchingState(): MatchingState {
+ const baseState = matchingGameValidator.getInitialState(DEFAULT_MATCHING_CONFIG)
+
+ // Create mock cards showing mid-game progress
+ // 2 pairs matched, 1 card currently flipped (looking for its match)
+ const mockGameCards = [
+ // Matched pair 1
+ {
+ id: 'c1',
+ type: 'number' as const,
+ number: 5,
+ matched: true,
+ matchedBy: 'demo-player-1',
+ },
+ {
+ id: 'c2',
+ type: 'number' as const,
+ number: 5,
+ matched: true,
+ matchedBy: 'demo-player-1',
+ },
+ // Matched pair 2
+ {
+ id: 'c3',
+ type: 'number' as const,
+ number: 8,
+ matched: true,
+ matchedBy: 'demo-player-1',
+ },
+ {
+ id: 'c4',
+ type: 'number' as const,
+ number: 8,
+ matched: true,
+ matchedBy: 'demo-player-1',
+ },
+ // Unmatched cards - player is looking for matches
+ { id: 'c5', type: 'number' as const, number: 3, matched: false },
+ { id: 'c6', type: 'number' as const, number: 7, matched: false },
+ { id: 'c7', type: 'number' as const, number: 3, matched: false },
+ { id: 'c8', type: 'number' as const, number: 7, matched: false },
+ { id: 'c9', type: 'number' as const, number: 2, matched: false },
+ { id: 'c10', type: 'number' as const, number: 2, matched: false },
+ { id: 'c11', type: 'number' as const, number: 9, matched: false },
+ { id: 'c12', type: 'number' as const, number: 9, matched: false },
+ ]
+
+ // One card is currently flipped
+ const flippedCard = mockGameCards[4] // The first "3"
+
+ // Override to playing phase
+ return {
+ ...baseState,
+ gamePhase: 'playing',
+ activePlayers: ['demo-player-1'],
+ playerMetadata: {
+ 'demo-player-1': {
+ id: 'demo-player-1',
+ name: 'Demo Player',
+ emoji: '🎮',
+ userId: 'demo-viewer-id',
+ color: '#3b82f6',
+ },
+ },
+ currentPlayer: 'demo-player-1',
+ gameCards: mockGameCards,
+ cards: mockGameCards,
+ flippedCards: [flippedCard],
+ scores: {
+ 'demo-player-1': 2,
+ },
+ consecutiveMatches: {
+ 'demo-player-1': 2,
+ },
+ matchedPairs: 2,
+ totalPairs: 6,
+ moves: 12,
+ gameStartTime: Date.now() - 25000, // Game has been running for 25 seconds
+ currentMoveStartTime: Date.now() - 500,
+ isProcessingMove: false,
+ showMismatchFeedback: false,
+ }
+}
+
+/**
+ * Create a mock state for Memory Quiz in input phase
+ * Shows mid-game with some numbers already found
+ */
+export function createMockMemoryQuizState(): MemoryQuizState {
+ const baseState = memoryQuizGameValidator.getInitialState(DEFAULT_MEMORY_QUIZ_CONFIG)
+
+ // Create mock quiz cards
+ const mockQuizCards = [
+ { number: 123, svgComponent: null, element: null },
+ { number: 456, svgComponent: null, element: null },
+ { number: 789, svgComponent: null, element: null },
+ { number: 234, svgComponent: null, element: null },
+ { number: 567, svgComponent: null, element: null },
+ ]
+
+ // Override to input phase with some numbers found
+ return {
+ ...baseState,
+ gamePhase: 'input',
+ quizCards: mockQuizCards,
+ correctAnswers: mockQuizCards.map((c) => c.number),
+ cards: mockQuizCards,
+ currentCardIndex: mockQuizCards.length, // Display phase complete
+ foundNumbers: [123, 456], // 2 out of 5 found
+ guessesRemaining: 3,
+ currentInput: '',
+ incorrectGuesses: 1,
+ activePlayers: ['demo-player-1'],
+ playerMetadata: {
+ 'demo-player-1': {
+ id: 'demo-player-1',
+ name: 'Demo Player',
+ emoji: '🎮',
+ userId: 'demo-viewer-id',
+ color: '#3b82f6',
+ },
+ },
+ playerScores: {
+ 'demo-viewer-id': {
+ correct: 2,
+ incorrect: 1,
+ },
+ },
+ numberFoundBy: {
+ 123: 'demo-viewer-id',
+ 456: 'demo-viewer-id',
+ },
+ playMode: 'cooperative',
+ selectedCount: 5,
+ selectedDifficulty: 'medium',
+ displayTime: 3000,
+ hasPhysicalKeyboard: true,
+ testingMode: false,
+ showOnScreenKeyboard: false,
+ prefixAcceptanceTimeout: null,
+ finishButtonsBound: false,
+ wrongGuessAnimations: [],
+ }
+}
+
+/**
+ * Create a mock state for Card Sorting in playing phase
+ * Shows mid-game with some cards placed in sorting area
+ */
+export function createMockCardSortingState(): CardSortingState {
+ const baseState = cardSortingValidator.getInitialState(DEFAULT_CARD_SORTING_CONFIG)
+
+ // Create mock cards with AbacusReact SVG placeholders
+ const mockCards = [
+ { id: 'c1', number: 23, svgContent: '' },
+ { id: 'c2', number: 45, svgContent: '' },
+ { id: 'c3', number: 12, svgContent: '' },
+ { id: 'c4', number: 78, svgContent: '' },
+ { id: 'c5', number: 56, svgContent: '' },
+ ]
+
+ // Correct order (sorted)
+ const correctOrder = [...mockCards].sort((a, b) => a.number - b.number)
+
+ // Show 3 cards placed, 2 still available
+ return {
+ ...baseState,
+ gamePhase: 'playing',
+ playerId: 'demo-player-1',
+ playerMetadata: {
+ id: 'demo-player-1',
+ name: 'Demo Player',
+ emoji: '🎮',
+ userId: 'demo-viewer-id',
+ },
+ activePlayers: ['demo-player-1'],
+ allPlayerMetadata: new Map([
+ [
+ 'demo-player-1',
+ {
+ id: 'demo-player-1',
+ name: 'Demo Player',
+ emoji: '🎮',
+ userId: 'demo-viewer-id',
+ },
+ ],
+ ]),
+ gameStartTime: Date.now() - 30000, // 30 seconds ago
+ selectedCards: mockCards,
+ correctOrder,
+ availableCards: [mockCards[3], mockCards[4]], // 78 and 56 still available
+ placedCards: [mockCards[2], mockCards[0], mockCards[1], null, null], // 12, 23, 45, empty, empty
+ cardPositions: [],
+ cursorPositions: new Map(),
+ }
+}
+
+/**
+ * Create a mock state for Rithmomachia in playing phase
+ * Shows mid-game with some pieces captured
+ */
+export function createMockRithmomachiaState(): RithmomachiaState {
+ const baseState = rithmomachiaValidator.getInitialState(DEFAULT_RITHMOMACHIA_CONFIG)
+
+ // Start the game (transitions to playing phase)
+ return {
+ ...baseState,
+ gamePhase: 'playing',
+ turn: 'W', // White's turn
+ // Captured pieces show some progress
+ capturedPieces: {
+ W: [
+ // White has captured 2 black pieces
+ { id: 'B_C_01', color: 'B', type: 'C', value: 4, square: 'CAPTURED', captured: true },
+ { id: 'B_T_01', color: 'B', type: 'T', value: 9, square: 'CAPTURED', captured: true },
+ ],
+ B: [
+ // Black has captured 1 white piece
+ { id: 'W_C_02', color: 'W', type: 'C', value: 6, square: 'CAPTURED', captured: true },
+ ],
+ },
+ history: [
+ // Add a few moves to show activity
+ {
+ ply: 1,
+ color: 'W',
+ from: 'C2',
+ to: 'C4',
+ pieceId: 'W_C_01',
+ capture: null,
+ ambush: null,
+ fenLikeHash: 'mock-hash-1',
+ noProgressCount: 1,
+ resultAfter: 'ONGOING',
+ },
+ {
+ ply: 2,
+ color: 'B',
+ from: 'N7',
+ to: 'N5',
+ pieceId: 'B_T_02',
+ capture: null,
+ ambush: null,
+ fenLikeHash: 'mock-hash-2',
+ noProgressCount: 2,
+ resultAfter: 'ONGOING',
+ },
+ ],
+ noProgressCount: 2,
+ stateHashes: ['initial-hash', 'mock-hash-1', 'mock-hash-2'],
+ }
+}
+
+/**
+ * Get mock state for any game by name
+ */
+export function getMockGameState(gameName: string): any {
+ switch (gameName) {
+ case 'complement-race':
+ return createMockComplementRaceState()
+ case 'matching':
+ return createMockMatchingState()
+ case 'memory-quiz':
+ return createMockMemoryQuizState()
+ case 'card-sorting':
+ return createMockCardSortingState()
+ case 'rithmomachia':
+ return createMockRithmomachiaState()
+ // For games we haven't implemented yet, return a basic "playing" state
+ default:
+ return {
+ gamePhase: 'playing',
+ activePlayers: ['demo-player-1'],
+ playerMetadata: {
+ 'demo-player-1': {
+ id: 'demo-player-1',
+ name: 'Demo Player',
+ emoji: '🎮',
+ color: '#3b82f6',
+ userId: 'demo-viewer-id',
+ },
+ },
+ }
+ }
+}
diff --git a/apps/web/src/contexts/ViewportContext.tsx b/apps/web/src/contexts/ViewportContext.tsx
new file mode 100644
index 00000000..b137d459
--- /dev/null
+++ b/apps/web/src/contexts/ViewportContext.tsx
@@ -0,0 +1,72 @@
+'use client'
+
+import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
+
+/**
+ * Viewport dimensions
+ */
+export interface ViewportDimensions {
+ width: number
+ height: number
+}
+
+/**
+ * Viewport context value
+ */
+interface ViewportContextValue {
+ width: number
+ height: number
+}
+
+const ViewportContext = createContext(null)
+
+/**
+ * Hook to get viewport dimensions
+ * Returns mock dimensions in preview mode, actual window dimensions otherwise
+ */
+export function useViewport(): ViewportDimensions {
+ const context = useContext(ViewportContext)
+
+ // If context is provided (preview mode or custom viewport), use it
+ if (context) {
+ return context
+ }
+
+ // Otherwise, use actual window dimensions (hook will update on resize)
+ const [dimensions, setDimensions] = useState({
+ width: typeof window !== 'undefined' ? window.innerWidth : 1440,
+ height: typeof window !== 'undefined' ? window.innerHeight : 900,
+ })
+
+ useEffect(() => {
+ const handleResize = () => {
+ setDimensions({
+ width: window.innerWidth,
+ height: window.innerHeight,
+ })
+ }
+
+ window.addEventListener('resize', handleResize)
+ handleResize() // Set initial value
+
+ return () => window.removeEventListener('resize', handleResize)
+ }, [])
+
+ return dimensions
+}
+
+/**
+ * Provider that supplies custom viewport dimensions
+ * Used in preview mode to provide mock 1440×900 viewport
+ */
+export function ViewportProvider({
+ children,
+ width,
+ height,
+}: {
+ children: ReactNode
+ width: number
+ height: number
+}) {
+ return {children}
+}