Frontend Development Guide¶
This guide covers patterns and conventions for developing in the Sapari frontend.
Project Structure¶
Organized by feature: each domain has its own module containing API, hooks, components, and types.
flowchart TB
subgraph frontend["frontend/"]
A[App.tsx] --> SH[shell/]
A --> F[features/]
A --> S[shared/]
A --> T[types.ts]
end
frontend/
├── App.tsx # Root component with providers
├── types.ts # Frontend UI types
├── shell/ # App orchestration layer
│ ├── Dashboard.tsx # Main editor (~1400 lines)
│ ├── DashboardContext.tsx # Dashboard state context
│ ├── Sidebar.tsx # Navigation sidebar
│ └── index.ts
├── features/ # Self-contained feature modules
│ ├── admin/ # Admin panel (lazy-loaded, code-split from main bundle)
│ ├── analysis/ # Timeline, edit navigation, undo/redo
│ ├── analysis-runs/ # Analysis run history and management
│ ├── assets/ # Asset library, groups, editor
│ ├── auth/ # Login, signup, verify, password reset
│ ├── billing/ # Credit balance, plan picker, checkout, 402 handling
│ ├── captions/ # Caption editor, overlay, cuts
│ ├── clips/ # Clip CRUD, playback info
│ ├── drafts/ # Draft save/load, auto-save
│ ├── edits/ # Edit CRUD, optimistic updates
│ ├── exports/ # Export panel, caption settings
│ ├── feature-flags/ # Feature flag evaluation (server-driven)
│ ├── mobile/ # Mobile UI: SwipeReview, FocusMode
│ ├── notifications/ # In-app notifications, SSE push
│ ├── onboarding/ # Onboarding flow, welcome modal
│ ├── projects/ # Project CRUD, SSE events
│ ├── settings/ # Settings panel, presets
│ ├── shortcuts/ # Keyboard shortcuts
│ ├── support/ # Support conversation panel
│ ├── upload/ # File upload, YouTube import
│ └── video-player/ # Video preview, timeline hooks
└── shared/ # Cross-cutting concerns
├── api/ # API client, React Query setup
├── config/ # Constants (timing, breakpoints)
├── context/ # ThemeContext
├── hooks/ # Shared React hooks
├── lib/ # SSE events, logger, storage
├── types/ # Shared API response types
└── ui/ # UI components, icons, buttons
Feature Module Pattern¶
Each feature is self-contained with its own API, hooks, and components:
features/projects/
├── api.ts # API functions + query keys
├── hooks.ts # React Query hooks
├── components/ # Feature-specific components
├── context/ # Feature context (if needed)
├── utils/ # Feature utilities
└── index.ts # Barrel exports
All exports go through index.ts:
// features/projects/index.ts
export { projectApi, projectKeys } from './api';
export { useProjects, useProject, useCreateProject, useProjectEvents } from './hooks';
export type { Project, ProjectCreate } from './api';
React Query Patterns¶
All server state goes through React Query (not useState).
Query Key Organization¶
Each feature defines keys in its api.ts:
// features/projects/api.ts
export const projectKeys = {
all: ['projects'] as const,
lists: () => [...projectKeys.all, 'list'] as const,
list: (filters?: Record<string, unknown>) => [...projectKeys.lists(), filters] as const,
details: () => [...projectKeys.all, 'detail'] as const,
detail: (uuid: string) => [...projectKeys.details(), uuid] as const,
};
// features/edits/api.ts
export const editKeys = {
all: ['edits'] as const,
byProject: (projectUuid: string) => [...editKeys.all, 'project', projectUuid] as const,
};
Basic Query¶
// features/projects/hooks.ts
export function useProjects() {
return useQuery({
queryKey: projectKeys.lists(),
queryFn: async () => {
const response = await projectApi.list();
return response.data.map(toFrontendProject);
},
staleTime: CACHE.PROJECTS_STALE_TIME_MS,
});
}
// Conditional query
export function useProject(uuid: string | undefined) {
return useQuery({
queryKey: projectKeys.detail(uuid ?? ''),
queryFn: async () => {
if (!uuid) throw new Error('UUID required');
return toFrontendProject(await projectApi.get(uuid));
},
enabled: !!uuid, // Only run when uuid exists
});
}
Optimistic Updates¶
Update cache immediately, roll back on error:
// features/projects/hooks.ts
export function useDeleteProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (uuid: string) => {
await projectApi.delete(uuid);
return uuid;
},
onMutate: async (uuid) => {
await queryClient.cancelQueries({ queryKey: projectKeys.lists() });
const previousProjects = queryClient.getQueryData<Project[]>(
projectKeys.lists()
);
queryClient.setQueryData<Project[]>(projectKeys.lists(), (old) => {
if (!old) return [];
return old.filter((p) => p.id !== uuid);
});
return { previousProjects };
},
onError: (err, uuid, context) => {
if (context?.previousProjects) {
queryClient.setQueryData(projectKeys.lists(), context.previousProjects);
}
},
// No onSettled invalidation -- the optimistic setQueryData already
// reflects the post-mutation state. Invalidating on settle triggers a
// refetch that blows the cache we just wrote, causing visible flicker and
// defeating the optimistic update. Only invalidate on error (rollback).
// Caches refresh on their natural stale times if server state diverges.
});
}
If the mutation also affects a sibling cache entry (e.g. deleting a project changes project_count in the billing status cache), extend onMutate and onError to snapshot and roll back both caches in one transaction — don't reintroduce onSettled to "sync" them.
Polling¶
For long-running operations, use conditional polling:
// features/exports/hooks.ts
export function useExport(projectUuid: string, exportUuid: string) {
return useQuery({
queryKey: exportKeys.detail(projectUuid, exportUuid),
queryFn: () => exportApi.get(projectUuid, exportUuid),
enabled: !!projectUuid && !!exportUuid,
// Poll while processing
refetchInterval: (query) => {
const data = query.state.data;
if (data?.status === 'pending' || data?.status === 'rendering') {
return 2000; // Poll every 2 seconds
}
return false; // Stop polling when done
},
});
}
SSE Integration¶
Workers push real-time updates via Server-Sent Events. The frontend subscribes to project-specific channels and updates the UI as events arrive.
Event Types¶
All SSE events are strongly typed in shared/lib/events.ts:
export type ProjectEventType =
| 'clip_processing'
| 'clip_ready'
| 'clip_failed'
| 'analysis_started'
| 'analysis_progress'
| 'analysis_complete'
| 'analysis_failed'
| 'export_started'
| 'export_progress'
| 'export_complete'
| 'export_failed';
Subscribing to Events¶
import { subscribeToProject } from '@/shared/lib';
const unsubscribe = subscribeToProject(projectUuid, {
onAnalysisStarted: (event) => {
console.log('Analysis started');
},
onAnalysisProgress: (event) => {
setProgress(event.progress);
},
onAnalysisComplete: (event) => {
console.log(`Created ${event.edit_count} edits`);
queryClient.invalidateQueries({ queryKey: editKeys.byProject(projectUuid) });
},
});
// Cleanup when done
return () => unsubscribe();
useProjectEvents Hook¶
Combines SSE with React Query cache invalidation:
// In your component
useProjectEvents(currentProject?.id, {
autoInvalidate: true,
handlers: {
onClipReady: (event) => {
toast.success(`${event.clip_name} ready`);
},
onExportComplete: (event) => {
setExportReady(true);
},
},
});
User-Scoped Notification SSE¶
Notifications use a separate user-scoped channel (vs project-scoped events above). The hook also sets up a polling fallback so notifications still flow if SSE is unreachable (server restart, CF edge issue, auth cookie expired mid-session). Polling stops automatically when SSE reconnects.
// features/notifications/hooks.ts
export function useNotificationSSE(enabled: boolean) {
const queryClient = useQueryClient();
useEffect(() => {
if (!enabled) return;
let pollingTimer: ReturnType<typeof setInterval> | null = null;
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.all });
};
const startPolling = () => {
if (pollingTimer) return;
pollingTimer = setInterval(invalidate, POLLING.NOTIFICATION_SSE_FALLBACK_POLL_MS);
};
const stopPolling = () => {
if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null; }
if (pollingTimer) { clearInterval(pollingTimer); pollingTimer = null; }
};
const es = new EventSource('/api/v1/notifications/events', { withCredentials: true });
es.addEventListener('notification_created', invalidate);
es.onopen = stopPolling; // Reconnect = cancel fallback
es.onerror = () => {
if (fallbackTimer || pollingTimer) return;
fallbackTimer = setTimeout(() => {
fallbackTimer = null;
if (es.readyState !== EventSource.OPEN) startPolling();
}, POLLING.NOTIFICATION_SSE_FALLBACK_GRACE_MS);
};
return () => { stopPolling(); es.close(); };
}, [enabled, queryClient]);
}
Key differences from project events:
- Channel: user:{user_id}:notifications (not project-scoped)
- Lifecycle: Connected once at app startup, not per-project
- Triggers: Analysis complete, export ready (from workers)
- UI: Compact dropdown panel with swipe gestures on mobile
- Fallback: Polling kicks in after POLLING.NOTIFICATION_SSE_FALLBACK_GRACE_MS (5s) of disconnected state; stops on reconnect
Derived Analysis Mode¶
The credit tier (AI Edit / Captions Only / Manual) is derived from settings, not selected explicitly. computeAnalysisMode(settings) in features/settings/types.ts:
import { computeAnalysisMode } from '@/features/settings';
const analysisMode = computeAnalysisMode(settings);
// 'ai_edit' — any cut/censorship/director feature enabled (1.0 credits/min)
// 'captions_only' — only language set (0.5 credits/min)
// 'manual' — nothing toggled (free, no analysis)
Called in Dashboard.tsx, passed to useAnalysisPipeline (API payload) and CostEstimate (display). Display constants in ANALYSIS_MODE (shared/config/constants.ts).
Vertical Crop (Zoom + Pan)¶
When a non-native aspect ratio is selected, users can crop instead of letterbox. The preview uses CSS scale() + translate() on a video-only group wrapper div — overlays (captions, asset inserts, watermark) render as siblings of the crop group, NOT inside it. This matches the export-time FFmpeg filter chain {crop}{scale}{transform}{subtitles} in workers/render/ffmpeg/command.py: subtitles apply AFTER the crop, positioned relative to the output frame. If overlays were inside the crop group, cropping with caption_position=top would push captions off-screen in preview even though the export still renders them correctly. The outer container (videoContainerRef) clips with overflow: hidden.
State (top-level in Dashboard.tsx): cropEnabled, cropAdjusting, cropZoom, cropPanX, cropPanY.
Utilities in shared/lib/cropUtils.ts:
- computeFillZoom(sourceAspect, targetAspect) — minimum zoom to eliminate bars
- computeCropTransform(zoom, panX, panY) — CSS transform for preview
- computeCropRegion(sourceAspect, targetAspect, zoom, panX, panY) — normalized 0-1 rect for backend FFmpeg
UI controls: CROP/BARS toggle + zoom slider in SettingsSidebar (desktop) and FormatPanel (mobile). DONE/ADJUST buttons gate cropAdjusting. Constants in CROP (shared/config/constants.ts).
Key implementation details:
- Drag handlers use refs (not state) to avoid stale closures breaking pointer capture
- Wheel zoom uses native addEventListener({ passive: false }) — React onWheel is passive
- Play button hidden during crop adjusting so touch drag works on mobile
- touch-action: none on crop group during adjusting prevents browser scroll interference
- Overlays (captions, asset inserts, watermark) are siblings of the crop group — they must stay outside the transform so they keep their output-frame positions under any zoom/pan. Same reason the speed indicator and play/pause button sit outside.
API Client Pattern¶
Core Client¶
Located in shared/api/client.ts:
class ApiClient {
async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`/api/v1${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
credentials: 'include',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw { detail: errorData.detail || 'Error', status: response.status };
}
return response.json();
}
get<T>(path: string) { return this.request<T>(path); }
post<T>(path: string, body?: unknown) { return this.request<T>(path, { method: 'POST', body: JSON.stringify(body) }); }
patch<T>(path: string, body?: unknown) { return this.request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }); }
delete(path: string) { return this.request<void>(path, { method: 'DELETE' }); }
}
export const api = new ApiClient();
Feature APIs¶
Each feature has its own API in api.ts:
// features/projects/api.ts
export const projectApi = {
list: (page = 1) =>
api.get<PaginatedResponse<ProjectRead>>(`/projects/?page=${page}`),
get: (uuid: string) =>
api.get<ProjectRead>(`/projects/${uuid}`),
create: (data: ProjectCreate) =>
api.post<ProjectRead>('/projects/', data),
analyze: (uuid: string, settings: AnalysisSettings) =>
api.post(`/projects/${uuid}/analyze`, settings),
};
Type System¶
Central Types¶
UI types live in types.ts at the root:
// Status enums (match backend)
export type ProjectStatus = 'created' | 'analyzing' | 'analyzed' | 'rendering' | 'complete' | 'failed';
export type EditType = 'silence' | 'false_start' | 'clean' | 'asset' | 'manual' | 'keep';
// Domain interfaces
export interface Edit {
id: string;
type: EditType;
start_ms: number;
end_ms: number;
active: boolean;
confidence?: number;
reason?: string;
}
export interface Project {
id: string;
name: string;
status: ProjectStatus;
createdAt: string;
updatedAt: string;
transcript?: string;
}
API Response Types¶
Shared API response types in shared/types/:
// shared/types/api.ts
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
items_per_page: number;
}
Configuration¶
Constants¶
Keep magic numbers in shared/config/constants.ts:
export const POLLING = {
EXPORT_INTERVAL_MS: 2000,
ANALYSIS_INTERVAL_MS: 2000,
PENDING_ASSET_INTERVAL_MS: 3000,
ANALYSIS_TIMEOUT_MS: 10 * 60 * 1000,
} as const;
export const CACHE = {
PROJECTS_STALE_TIME_MS: 5 * 1000,
EDITS_STALE_TIME_MS: 10 * 1000,
CAPTIONS_STALE_TIME_MS: 30 * 1000,
WAVEFORM_STALE_TIME_MS: Infinity,
} as const;
export const UI = {
DEBOUNCE_MS: 100,
MOBILE_BREAKPOINT: 768,
MIN_TOUCH_TARGET_PX: 44,
} as const;
export const MAIN_AUDIO = {
MIN_VOLUME_PERCENT: 0,
MAX_VOLUME_PERCENT: 100,
DEFAULT_VOLUME_PERCENT: 100,
VOLUME_STEP: 5,
} as const;
export const ASSET_OVERLAY = {
DEFAULT_SIZE_PERCENT: 20,
MIN_SIZE_PERCENT: 10,
MAX_SIZE_PERCENT: 100,
ROTATION_STEP: 90, // Degrees per rotation click
// ... position coords, snap grid, etc.
} as const;
Theming & Dark Mode¶
Sapari uses an orange-accented dark mode.
Theme Context¶
Located in shared/context/ThemeContext.tsx:
import { useTheme } from '@/shared/context';
function MyComponent() {
const { mode, setMode, resolvedTheme } = useTheme();
// mode: 'light' | 'dark' | 'auto'
// resolvedTheme: 'light' | 'dark' (actual applied theme)
}
Color Palette¶
| Element | Light Mode | Dark Mode Class |
|---|---|---|
| Background (primary) | bg-white |
dark:bg-sapari-dark (#0d0d0d) |
| Background (secondary) | bg-sapari-gray |
dark:bg-sapari-dark-secondary (#1a1a1a) |
| Text | text-black |
dark:text-white |
| Borders | border-black |
dark:border-orange-500/50 |
| Accent | bg-sapari-orange |
dark:bg-sapari-orange-light |
| Shadows | shadow-hard |
dark:shadow-none |
Common Patterns¶
Containers:
<div className="bg-white dark:bg-sapari-dark-secondary border-2 border-black dark:border-orange-500/50 shadow-hard dark:shadow-none">
Text:
<span className="text-black dark:text-white">Primary text</span>
<span className="text-gray-600 dark:text-gray-400">Secondary text</span>
Path Aliases¶
TypeScript path aliases in tsconfig.json:
{
"compilerOptions": {
"paths": {
"@/*": ["./*"],
"@/features/*": ["./features/*"],
"@/shared/*": ["./shared/*"],
"@/shell/*": ["./shell/*"],
"@/types": ["./types.ts"]
}
}
}
Usage:
import { useProjects } from '@/features/projects';
import { Button } from '@/shared/ui';
import { Dashboard } from '@/shell';
import type { Project } from '@/types';
Shared Utilities¶
Don't repeat behavior — extract and import.
When you find yourself writing logic that already exists (or could exist) elsewhere, put it in shared/ and import it. This applies to:
- Formatting functions —
shared/lib/formatTime.tshas all time/duration formatters - Constants —
shared/config/constants.tsfor magic numbers - Hooks —
shared/hooks/for cross-feature React hooks - UI components —
shared/ui/for buttons, icons, modals
Time Formatting¶
All time formatting goes through shared/lib/formatTime.ts:
import { formatTime, formatTimePrecise, formatDuration } from '@/shared/lib/formatTime';
formatTime(125000) // "2:05" — general timestamps
formatTimePrecise(125300) // "2:05.3" — trim handles, precise editors
formatDuration(1500) // "1.5s" — compact durations (also "250ms", "2m 30s")
formatDurationShort(1500) // "1.5s" — inline labels (never shows minutes)
formatDurationOrFallback(null) // "--:--" — nullable with custom fallback
Never define a local formatTime, formatDuration, or similar in a component file. Import from shared.
Before Adding Code¶
Ask yourself:
1. Does this behavior already exist in shared/?
2. Is this the same pattern I've seen in another feature?
3. Would another feature benefit from this logic?
If yes to any: extract to shared/, import everywhere.
Usage Limits Hook¶
The useUsageLimits() hook derives tier limits and storage usage from useBillingStatus():
import { useUsageLimits } from '@/features/billing';
const {
projectCount, maxProjects, isAtProjectLimit, projectLabel, // "2/3"
storagePercent, storageLabel, isStorageFull, isStorageWarning,
canUseAiDirector, isLoading,
} = useUsageLimits();
Use this hook instead of computing limits in components. Constants: BILLING.STORAGE_WARNING_PERCENT (80%), BILLING.STORAGE_CRITICAL_PERCENT (95%).
Clip Playback URL Refresh¶
Clip playback URLs are short-lived JWTs minted by the backend and verified by a Cloudflare Worker at /media/v1/<jwt>. The JWT TTL is 300 seconds, which means any editing session longer than five minutes (i.e. every real session) would hit a 401 mid-playback without a refresh layer. The frontend treats refresh as part of the playback contract — not as error handling — via four cooperating pieces:
useClipProxyUrls(projectUuid, uploadedFiles) (features/clips/hooks.ts) — the data layer. Uses React Query's useQueries to run one query per clip. Each query refreshes at (expires_in - PROXY_URL.REFRESH_BUFFER_SECONDS) ms via refetchInterval, retries once on 5xx/network errors, and skips retry on 401/403/429 (those propagate so the API client's onUnauthorized can fire). Returns { infos: ClipPlaybackInfo[], refetchClip } — infos replaces the previously-imperative mainClipPlaybackInfos state in Dashboard.tsx; refetchClip(uuid) invalidates a single clip's query for manual refresh.
useProxyUrlSwap(videoRef, proxyUrl) (features/clips/hooks.ts) — the media-element layer. When the <video>'s src attribute changes, the HTML spec's media resource selection algorithm restarts the load from byte 0 (abort → emptied → loadstart → loadedmetadata). Without intervention, playback pauses and position is lost. The hook snapshots (currentTime, paused) in a useLayoutEffect before the DOM mutation, then restores on loadedmetadata via useEffect. A video.seeking guard skips the snapshot mid-scrubber-drag so transient positions don't get captured.
<ClipVideo> (features/video-player/components/ClipVideo.tsx) — the per-element component. Each one owns a single videoRef, calls useProxyUrlSwap internally, and wires onError to call the parent's onRequestRefresh(clipUuid) when MediaError.code is 2 (NETWORK) or 4 (SRC_NOT_SUPPORTED) — the codes browsers surface for an expired JWT at the video layer. Extracted from VideoWindow's inline clips.map(...) because hooks-in-a-loop is illegal and the per-video lifecycle belongs with the per-video component.
<ClipPlaybackErrorBanner> (features/video-player/components/ClipPlaybackErrorBanner.tsx) — the user-visible safety net. Surfaces when any ClipPlaybackInfo.refreshError !== undefined (populated by useClipProxyUrls after its own retry has hard-failed). Offers Retry (iterates errored clips and calls onRetryClip), Dismiss (X — session-scoped, auto-resets when all errors clear), and copy differentiation (server vs network). Mounted by both Dashboard (desktop) and MobileDashboard (mobile) above their respective video surfaces.
The full chain: backend mints short-TTL JWT → useClipProxyUrls fetches + sets refresh timer → timer fires, refetch gets new JWT → ClipPlaybackInfo.proxyUrl updates → React re-renders <ClipVideo> with new src → useProxyUrlSwap snapshots state pre-commit → browser reloads via new src → loadedmetadata fires → state restored → playback continues. If any step fails twice, refreshError populates and <ClipPlaybackErrorBanner> surfaces with the Retry/Dismiss/reload affordances.
Refresh semantics for mobile match desktop: onRequestRefresh plumbs through MobileDashboard → SwipeReview → FocusMode → VideoWindow (both portrait/landscape SwipeReview and both timeline/detail FocusMode instances). MobileAssetEditor is deliberately not wired — it consumes presigned R2 asset URLs with 1h TTL, not Worker-fronted clip URLs. Asset playback picks up retry semantics when task #24 migrates it.
Tunable constants live in PROXY_URL (shared/config/constants.ts):
- REFRESH_BUFFER_SECONDS (30) — refresh this many seconds before server-reported expiry. Sized to cover network RTT + one retry budget + timer-throttling slack. Revisit when Stage 5 observability lands real p99 numbers.
- RETRY_DELAY_MS (2000) — delay between the first failed refresh and its one retry.
Test coverage: hook-level tests in features/clips/useProxyUrlSwap.test.tsx (6 tests covering the snapshot/restore lifecycle + mid-seek guard + rapid-swap) and features/clips/useClipProxyUrls.test.tsx (11 tests covering initial fetch, retry policy matrix, refreshError state transitions, refetchClip, and the clipKey memoization stability guarantee). Component tests in ClipVideo.test.tsx and ClipPlaybackErrorBanner.test.tsx cover the ref contract, render fork, copy differentiation, and dismiss/retry behavior. The real <video> element behavior under expired tokens and Worker 401s is manual-QA — the procedure lives in docs/qa/desktop/editor.md §5c (desktop) and docs/qa/mobile/editor.md §9 (mobile portrait + landscape).
Clip Scrub Preview¶
Timeline scrubbing and buffering gaps both get a sprite-tile preview so the editor never shows a gray frame during a seek. The sprite is a 10×20 grid of 160×90 thumbnails generated alongside the proxy during download (sprite_key + sprite_seconds_per_tile on ClipFile; see docs/backend/download.md). The JPEG URL is content-addressable, so the Worker serves it with Cache-Control: public, max-age=31536000, immutable and every subsequent tile lookup is a browser cache hit. Four cooperating pieces:
useClipProxyUrls surfaces the sprite alongside the proxy URL. The API returns a nested sprite: SpriteInfo | null object (null until proxy generation completes); the hook maps snake_case → camelCase into ClipPlaybackInfo.spriteUrl + ClipPlaybackInfo.spriteMeta. No second query — the sprite piggy-backs on the existing proxy refresh cycle.
useSpritePreload() (features/video-player/hooks/useSpritePreload.ts) — a lazy per-clip preload helper. First call per clipUuid kicks off new Image().src = spriteUrl; subsequent calls are no-ops (idempotent via a ref-held Set<clipUuid>). Keyed on clipUuid, not spriteUrl, because the URL is a short-lived signed token that rotates every ~5 minutes while the underlying bytes are immutable. Called from <VideoWindow> on drag-start and during scrub.
usePlayheadDrag (extended) gained two optional callbacks: onScrubPreview(timeMs) and onDragStart(). When onScrubPreview is set, mousemove/touchmove fires the preview callback instead of seekTo, and seekTo is committed exactly once on mouseup/touchend using the last known clientX. Consumers that don't pass onScrubPreview get the pre-Tier-3 per-tick seek behavior unchanged (backward-compatible).
<TimelineScrubPreview> (features/video-player/components/TimelineScrubPreview.tsx) renders a single CSS-background-positioned tile floated above the timeline at the drag cursor's X, clamped inside the container's horizontal bounds. Tile math lives in shared/lib/spriteTile.ts:computeSpriteTile(meta, timeMs), which <ClipVideo> also consumes — the same tile appears as the buffering overlay when <ClipVideo> is in a waiting state between seeking and canplay. If no sprite is available, <ClipVideo> falls back to the existing "Buffering..." text overlay.
The full chain on a desktop scrub: user mouses down on the playhead → onDragStart preloads the sprite for the clip containing the current playhead position → mousemove fires onScrubPreview(ms) → <VideoWindow> resolves which clip contains that ms (features/video-player/utils/resolveClipAtMs.ts) and renders <TimelineScrubPreview> at the cursor with the computed tile → mouseup commits a single seekTo(ms). Result: zero range requests fire during the drag, exactly one fires on release.
Mobile SwipeReview and FocusMode.tsx render <VideoWindow> so inherit the scrub preview + sprite-as-buffering-poster automatically. CutFocusMode.tsx and AssetFocusMode.tsx still render bare <video> elements — a post-launch follow-up to wire them through <ClipVideo> so the sprite poster covers all mobile surfaces.
Test coverage: shared/lib/spriteTile.test.ts (7 tests on the tile-index math including density scaling and partial bottom rows), features/video-player/utils/resolveClipAtMs.test.ts (6 tests on the clip-containing-ms resolver including boundary + sourceOffsetMs), useSpritePreload.test.ts (3 tests — happy path, idempotency, null-safe), usePlayheadDrag.test.ts (5 tests — legacy compat, scrub+commit, drag-start, touchend-without-touchmove, release-time clamping), TimelineScrubPreview.test.tsx (5 tests — tile rendering + viewport-edge clamp), extended ClipVideo.test.tsx (3 new tests — sprite branch, text fallback, state reset on canplay), extended useClipProxyUrls.test.tsx (2 new tests — snake→camel mapping + null sprite). Real <video> scrub UX under drag is manual-QA; procedure in docs/qa/desktop/editor.md §Timeline scrub preview.
Testing¶
The frontend uses Vitest + @testing-library/react + jsdom for unit-testing hooks and pure functions. The harness was bootstrapped as part of R2 Stage 4 — for years the frontend had no tests, so conventions here are new and worth calling out.
Location. Tests are colocated next to the source, not in a separate test tree:
features/clips/
├── hooks.ts
├── hooks.test.ts # non-JSX helper tests
└── useProxyUrlSwap.test.tsx # JSX or anything that renders
Extension. Use .test.tsx for any test that uses JSX (including render(<Component />) calls). .test.ts works for tests of pure functions and non-JSX hooks. The .ts extension will fail esbuild's parse with Expected ">" but found "data" the moment a JSX expression appears.
Setup. vitest.setup.ts at the frontend root registers @testing-library/jest-dom/vitest matchers (e.g. toBeInTheDocument) and calls cleanup() after each test via afterEach. The test block in vite.config.ts wires the setup file and selects the jsdom environment.
DOM mocks for media elements. jsdom does not meaningfully implement HTMLVideoElement / HTMLAudioElement — play() / pause() are stubs and loadedmetadata never fires on its own. For hooks that interact with media elements, hand-roll a fake exposing only the surface the hook touches, and fire the spec'd events manually. See features/clips/useProxyUrlSwap.test.tsx for the pattern.
What not to test this way. Component-level tests for <video> / <audio> code are out of scope — the fake-media-element investment isn't worth the reward, and the feature ships with manual QA docs in docs/qa/*.md for those surfaces. Unit tests cover the hook / data-layer; manual QA covers the DOM-rendering layer.
Running. npm test for a single run, npm run test:watch for watch mode.
Key Conventions¶
- Feature-based organization - Code lives in feature modules, not by type
- React Query for server state - Don't use useState for API data
- Typed SSE events - All events have TypeScript interfaces
- Optimistic updates - Update UI immediately, rollback on error
- Barrel exports - All features export through
index.ts - Constants - No magic numbers in component code
- Extract shared behavior - Don't duplicate logic across features; put it in
shared/ - Tests colocated -
*.test.tsxnext to the source file, not a separate test tree
Key Files¶
| Purpose | Location |
|---|---|
| Types | types.ts |
| Main dashboard | shell/Dashboard.tsx |
| React Query hooks | features/*/hooks.ts |
| API clients | features/*/api.ts |
| SSE client | shared/lib/events.ts |
| API client | shared/api/client.ts |
| Constants | shared/config/constants.ts |
| Time formatting | shared/lib/formatTime.ts |
| Theme context | shared/context/ThemeContext.tsx |
| Test harness | vite.config.ts (test block), vitest.setup.ts |