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 (top-level <Routes>)
│ ├── Dashboard.tsx # Main editor (~3000 lines, currentProject + currentView URL-derived)
│ ├── 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 (Accept-Language stamping), React Query setup
├── config/ # Constants (timing, breakpoints)
├── context/ # ThemeContext, LocaleContext
├── hooks/ # Shared React hooks
├── lib/ # SSE events, logger, storage, i18n (i18next + reconcileServerLocale)
├── 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);
},
},
});
Late-subscribe replay (analysis only). Redis pub/sub has no buffering — any event published before pubsub.subscribe() lands is lost. The backend mitigates this for the analysis pipeline by caching the latest analysis_progress event per project to a TTL'd Redis key and yielding it as the first frame on connect. The user-facing effect: a tab opened mid-pipeline immediately shows the current step instead of sitting blank until the next step boundary. From the hook's perspective the snapshot is indistinguishable from a live event — no client-side handling needed. Mechanism documented in docs/architecture/events.md §Late-subscribe replay.
User-Scoped Events SSE (unified stream)¶
User-scoped events — notifications and asset processing — multiplex over a single SSE connection per user. The backend fans both Redis pub/sub channels (user:{uid}:notifications, user:{uid}:assets) into one stream, tagging each event with its type so the frontend can dispatch to the right cache invalidation. Halves uvicorn worker slots vs the prior pair of streams.
The hook also sets up a polling fallback so events 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 useUserEvents(enabled: boolean) {
const queryClient = useQueryClient();
useEffect(() => {
if (!enabled) return;
let pollingTimer: ReturnType<typeof setInterval> | null = null;
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
const invalidateNotifs = () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.all });
queryClient.invalidateQueries({ queryKey: supportKeys.all });
};
const invalidateAssets = () => {
queryClient.invalidateQueries({ queryKey: assetKeys.all });
queryClient.invalidateQueries({ queryKey: groupKeys.all });
};
const invalidateAll = () => { invalidateNotifs(); invalidateAssets(); };
const startPolling = () => {
if (pollingTimer) return;
pollingTimer = setInterval(invalidateAll, POLLING.USER_SSE_FALLBACK_POLL_MS);
};
const stopPolling = () => {
if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null; }
if (pollingTimer) { clearInterval(pollingTimer); pollingTimer = null; }
};
const es = new EventSource('/api/v1/events/user-stream', { withCredentials: true });
es.addEventListener('notification_created', invalidateNotifs);
es.addEventListener('asset_ready', invalidateAssets);
es.addEventListener('asset_failed', invalidateAssets);
es.onopen = stopPolling;
es.onerror = () => {
if (fallbackTimer || pollingTimer) return;
fallbackTimer = setTimeout(() => {
fallbackTimer = null;
if (es.readyState !== EventSource.OPEN) startPolling();
}, POLLING.USER_SSE_FALLBACK_GRACE_MS);
};
return () => { stopPolling(); es.close(); };
}, [enabled, queryClient]);
}
Key differences from project events:
- Channels: user:{user_id}:notifications + user:{user_id}:assets multiplexed (not project-scoped)
- Lifecycle: Connected once at app startup, not per-project
- Triggers: Analysis complete, export ready, asset_ready, asset_failed (from workers)
- UI: Notifications panel (dropdown) + asset library status updates
- Fallback: Polling kicks in after POLLING.USER_SSE_FALLBACK_GRACE_MS (5s) of disconnected state; stops on reconnect
Adding a third user-scoped event source (presence, billing events, support push, etc.) is a one-line tuple addition to the sources list in subscribe_to_user_events (backend/src/infrastructure/events/subscriber.py) plus an event listener in this hook. The fan-in pattern keeps each channel's read loop independent, so a high-frequency new source doesn't starve the quiet ones.
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 main-clips-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.
VideoWindow enforces this by splitting its content into two sibling layers: a cropped layer (the main clips + hidden audio elements, under the project crop transform) and an uncropped asset layer (intro/outro/INSERT clips + REPLACE broll). ClipPlaybackInfo carries isAssetClip?: boolean which the layer split keys on. The asset layer renders outside the main-video crop wrapper because the renderer's main-video crop applies only to MAIN segments — asset segments get their own optional per-asset crop instead (see below). Letting both compose by inheritance would double-zoom intro/outro previews. The asset layer is stacked above the cropped layer, so while cropAdjusting it must carry pointer-events-none — otherwise it intercepts the crop-drag gesture and the main video can't be repositioned.
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.
Per-Asset Crop (Zoom + Pan, issue #235)¶
The main-video crop logic above is shared with per-asset reframe. When a REPLACE or INSERT video/image asset's source aspect differs from the project's target aspect, the default render letterboxes the asset (black bars). The <AssetCropEditor> component in features/assets/components/ lets the user toggle into cover-crop mode and choose zoom/pan per asset, independent of any main-video crop.
Shared with main-video crop:
- computeFillZoom / computeCropRegion / computeCropTransform (the same shared/lib/cropUtils.ts helpers — same convention, same math).
- useCropDragGesture(elementRef, panX, panY, onPanChange) (shared/hooks/) — the drag-to-pan gesture. Extracted from VideoWindow's previously-inline handler; both consumers now share the same axis-snap, center-snap, and ref-stale-closure protection.
- All CROP.* constants (MIN_ZOOM, MAX_ZOOM, FILL_ZOOM_SNAP, AXIS_SNAP_RATIO, PAN_CENTER_SNAP).
State: stored on the Edit row (assetCropEnabled, assetCropZoom, assetCropPanX, assetCropPanY) — not on UI state. Persistence is per-edit, surviving reload + render.
Mount points:
- Desktop: <AssetCropEditor> renders inside EditPopover when the edit's visualMode is replace or insert and the asset is non-audio.
- Mobile: rendered inside AssetFocusMode as a "Reframe" ControlSection.
Disabled state: when the asset's width / height (joined from AssetFile) are null (legacy upload predating the asset-dim probe), the toggle is disabled with a "re-upload this asset to enable" tooltip. The renderer separately falls back to letterbox so even if a value somehow gets through, the export is safe.
Toggle ON behavior: the editor snaps zoom to computeFillZoom(sourceAspect, targetAspect) and resets pan to 0, 0. Without this, toggle ON at zoom=1.0 falls in computeCropRegion's below-fill branch (returns full source) and the asset still letterboxes — the snap-to-fill default is the load-bearing UX cue.
Swap-mode reset: when the user replaces an asset on an existing edit (Dashboard.handleAssetInserted with swappingEditId), all four crop fields are explicitly reset. The persisted zoom/pan was framed against the old asset's source aspect, so carrying it over would produce nonsense framing.
Caption Position (free-form drag)¶
Captions can be dragged anywhere over the preview, and the chosen position rides through export as caption_x / caption_y (normalized 0-1 against the export frame). Backend translates to centered-anchor ASS \an5\pos(x,y) overrides per dialogue line in workers/render/subtitles.py:_resolve_caption_placement, which also emits symmetric per-event MarginL/MarginR so a positioned caption wraps in-frame instead of overflowing the edge — libass bounds \pos wrap width by the event margins. The preview mirrors this with a matching max-width on the caption <p> (2 × distance-to-nearest-edge), so a wrapped caption looks the same in preview and export. The preview additionally measures the rendered box and clamps its center inward by the measured half-size (MAX_BOX_HALF_FRACTION cap, BOX_MEASURE_EPSILON re-measure threshold) so all four edges stay inside the frame — export crops anything past the frame, but the preview canvas does not, so a near-corner caption (narrow wrap → tall box) would otherwise spill over the surrounding UI. Long words break (overflow-wrap) rather than overflowing the narrow corner box.
useCaptionDrag (features/captions/hooks/) owns the pointer choreography. Mechanics mirror useDragOverlay: stable-listener refs, document-level pointermove/pointerup, edge clamp to ASSET_OVERLAY.EDGE_MARGIN, snap to the 9-point grid when Shift is held (or forceSnapRef.current = true for mobile long-press). On pointer-down it captures a grab offset (the gap between the caption's measured center and the grab point) and applies it through the drag, so grabbing the caption off-center no longer snaps its center under the cursor. A press only becomes a drag once the pointer travels past CAPTIONS.DRAG_ACTIVATE_PX — a click (no travel) leaves the caption untouched and never commits a position, so clicking the caption no longer teleports it to a free-form coord. While snapping, CaptionOverlay renders the shared SnapGrid (shared/ui — the same 3×3 overlay the asset overlay uses) so the grid is visible. All nine snap anchors persist as free-form coords at the grid point, so the visible grid is a true, stable reference and the caption lands on the dot you release on (no jump). The legacy position enum (top / center / bottom, which renders at a vertical margin) is set via the Top/Center/Bottom picker buttons, not by drag-snap. The snap grid math reuses ASSET_OVERLAY.SNAP_GRID_COLS/ROWS/SNAP_THRESHOLD/EDGE_MARGIN — no separate CAPTION_SNAP_* mirror.
CaptionOverlay keeps the wrapper pointer-events-none unless the drag affordance is fully wired (draggable && containerRef && onDragEnd), so clicks fall through to underlying video controls in the inactive case. When wired, the <p> element becomes the interactive target (pointer-events: auto + cursor: move) — the wrapper stays click-through, the text catches the gesture.
Setter contract. useCaptionSettings.setCaptionPosition(p) clears captionX/captionY as a side effect (release-on-snap semantics — picking a named position is the user expressing "use the enum"). setCaptionXY(x, y) does NOT clear captionPosition; the renderer prefers X/Y when both are set, matching backend _resolve_caption_placement priority. Callers restoring saved state must dispatch setCaptionPosition FIRST then setCaptionXY, otherwise X/Y are clobbered — useExportPanel.handleLoadSettings is the canonical example.
Undo/redo. Caption repositioning rides the shared edit undo stack (useUndoRedo), not a separate history, so Ctrl+Z / Ctrl+Y interleave caption moves with edit actions. The caption_move action carries a full before/after CaptionSnapshot (position + captionX + captionY) and is self-contained — it has no editId, so undo/redo return undefined (no timeline navigation) for it. Because the caption setters live in useExportPanel (mounted after useTransformedEdits), application is bridged by captionApplyRef: Dashboard sets .current to an applyCaptionSnapshot closure, and useTransformedEdits calls it in its caption_move undo/redo branch. Both the drag commit (handleCaptionDragEnd) and the position-picker buttons (handleCaptionPositionChange, desktop + mobile) record through this path; a no-op move (same placement) is not recorded.
Persistence: per-project localStorage via usePreviewPersistence (round-tripped through validatePreviewSettings with 0-1 bounds + Number.isFinite guard); on Export, the X/Y pair is included in the captions block only when both axes are non-null. Per-line overrides (caption_overrides[line_id] = {x, y}) are accepted by the backend already (Stage 0.1 schema) but not yet authored from the frontend — that wire arrives with the snapshot-keying work.
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),
};
Multipart Upload¶
Files at or above MULTIPART.CUTOFF_BYTES (25 MiB) take a parallel-parts path with resume support; smaller files keep the single-PUT presign + confirm flow. The dispatcher lives in the existing upload mutations (useUploadClip, useUploadAsset) — callers just pass a File and the hook picks the right path.
Dispatcher¶
// features/clips/hooks.ts
export function useUploadClip() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ projectUuid, file, onProgress, signal }) => {
if (file.size >= MULTIPART.CUTOFF_BYTES) {
return uploadClipMultipart(projectUuid, file, onProgress, signal);
}
return uploadClipSinglePut(projectUuid, file, onProgress, signal);
},
onSuccess: (clip) => {
queryClient.invalidateQueries({ queryKey: clipKeys.byProject(clip.project_id) });
queryClient.invalidateQueries({ queryKey: billingKeys.status() });
},
});
}
uploadClipSinglePut and uploadClipMultipart are plain async functions, not hooks — the mutation wrapper owns queryClient, the path implementations only do work. Asset side mirrors the shape with useUploadAsset.
Orchestrator¶
shared/lib/multipartUpload.ts exposes runMultipartUpload<TResult>({ file, adapter, onProgress, signal }). Per-resource adapters (MultipartUploadAdapter<TResult>) implement initiate / complete / abort / listParts / partUrls against the right backend endpoints. The orchestrator handles:
- Initiate → returns
upload_id,key,parts_count,part_size_bytes, plus the first batch of presigned PUT URLs (default 50 upfront). - URL refill — when the upload position is within
URL_REFILL_LOOKAHEAD = 50parts of running out, kick a background fetch for the nextURL_REFILL_BATCH_SIZE = 50URLs. Concurrent triggers share a single in-flight promise (idempotent). - Parallel parts — capped at
MULTIPART.PARALLEL_PARTS = 4viapLimit(shared/lib/pLimit.ts, no external dep). Each part usesXMLHttpRequest(notfetch—fetchlacks portable upload progress). Per-part retry up to 3 attempts with exponential backoff (500ms / 1s / 2s). - ETag round-trip —
xhr.getResponseHeader('ETag')returns R2's"abc..."with literal surrounding quotes. The orchestrator stores it byte-exact and the API layer ships it byte-exact in thecompletepayload. Stripping quotes will fail withMultipartCompleteError(HTTP 409). A regression guard inmultipartUpload.test.tsasserts the failure mode. - Cancel — pass
signal: AbortSignal; the orchestrator aborts in-flight XHRs, callsadapter.abortserver-side, and rejects withsignal.reason.
Resume via IndexedDB¶
shared/lib/multipartState.ts is the codebase's first IndexedDB usage. Schema: { fingerprint, upload_id, key, parts_uploaded, created_at } keyed by fingerprint.
fingerprint is SHA-256(name::size::lastModified) — imperfect but covers the 95% case (same file dragged in twice). When lastModified === 0 (Safari Files-API quirk on synthesized files), computeFileFingerprint returns null and resume is skipped for that upload — without the fingerprint, two distinct uploads with same name+size would collide on the same key.
On retry, the orchestrator calls adapter.listParts and skips parts already on R2 — R2 is the source of truth, IndexedDB is a hot cache. Resume only works for transient interruptions where the backend row is still MULTIPART_INITIATED: page refresh, network drop, the user closing the tab and coming back. On hard failure (part retries exhausted, complete rejected) and on explicit user cancel, IndexedDB state is cleared because the backend deletes the row in both cases — listParts would 404, so the hot cache has nothing to point at.
multipartState.expire(STUCK_MULTIPART_MINUTES * 60 * 1000) should run on app start to keep IndexedDB from growing unbounded; tied to backend's stuck-multipart cron envelope (180 min default) so we don't keep state R2 has already aborted.
Cancel UI¶
useClipUploader exposes isUploadCancellable: boolean and cancelUpload(). While a batch is in flight the ClipUploader renders a compact status strip at the top of the sequence editor — "UPLOADING N / M" plus a CANCEL ALL button. The label is "ALL" (not "UPLOAD") because the controller is batch-scoped: one click aborts the in-flight transfer AND skips every remaining file. Per-file cancel would need per-file AbortControllers and is a deliberate non-goal.
Cancellation drops the current placeholder and every not-yet-started placeholder so the editor doesn't show ghost rows for the skipped tail. Files that already completed survive. The backend's multipart-abort endpoint deletes the Clip + ClipFile / UserAsset + AssetFile rows (rather than flipping them to FAILED), so the user re-drops the file fresh — no ghost FAILED card with a Retry button that wouldn't actually work, because R2's abort destroys the parts.
Multi-file queue (clips + assets)¶
Both useClipUploader.uploadFilesToProject and useAssetManager.uploadAssetQueue follow the same pattern: pre-register every dropped file as a placeholder upfront, then drain serially. Without pre-registration the upload panel jittered "Uploading 1 file → 0 files → 1 file" as each iteration of the for-loop added then removed its own placeholder. Each placeholder carries a progress value updated by the mutation's onProgress callback; for clips this renders as an in-row orange fill in the sequence editor, with the status badge reading QUEUED (progress=0, waiting) or N% (in flight). The clip path also exposes uploadBatch: { total, completed } | null for the "UPLOADING 3 / 5" count in the status strip.
Placeholder IDs come from a monotonic nextUploadIdRef (not Date.now()-based) so two batches dropped within the same millisecond can't mint colliding IDs — under the old scheme, settling batch 1 would have wiped batch 2's row via the shared prev.filter(f => f.id !== uploadId) cleanup line.
Constants¶
MULTIPART group in shared/config/constants.ts mirrors backend's infrastructure/storage/constants.py:
| Constant | Value | Purpose |
|---|---|---|
CUTOFF_BYTES |
25 MiB | Below this, single-PUT |
PART_SIZE_BYTES |
16 MiB | Per-part chunk |
PARALLEL_PARTS |
4 | Concurrent in-flight parts |
RETRY_ATTEMPTS_PER_PART |
3 | Per-part transient retries |
RETRY_BACKOFF_BASE_MS |
500 | Exponential: 500ms / 1s / 2s |
URL_REFILL_LOOKAHEAD |
50 | Trigger refill when within N of running out |
URL_REFILL_BATCH_SIZE |
50 | URLs minted per refill round-trip |
Tests¶
shared/lib/pLimit.test.ts— concurrency cap, error propagation, validation.shared/lib/multipartUpload.test.ts— load-bearing resume regression (skips already-uploaded parts, merges resumed ETags into complete payload byte-exact) plus ETag-quote preservation, URL refill, cancel-clears-IndexedDB.
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>
Internationalization¶
The SPA wires English, Portuguese, and Spanish through i18next. The architecture is live end-to-end — provider mounted, Accept-Language on every request, server-wins reconcile after auth. The SPA UI string catalogs (frontend/locales/{en,pt,es}/*.json) are populated across 19 namespaces (common, admin, analysis, assets, auth, billing, captions, drafts, exports, mobile, notifications, onboarding, projects, settings, shell, shortcuts, support, upload, video_player); Phase 2 SPA string extraction has substantively landed and ongoing additions are slice-by-slice as new features ship. The backend surface is populated and active — the error-message catalog (backend/src/modules/common/i18n/{en,pt,es}.py) carries the service-layer surfaced raises plus auth + worker-emitted notification strings, and the email-template tree (templates/email/{locale}/...) routes per locale via ChoiceLoader with fallback to en.
Watch for: the resources-object namespace invariant. shared/lib/i18n.ts declares the resource bundles by hand per locale (pt: { common: ptCommon, admin: ptAdmin, … }). Every locale's resources entry MUST list every namespace, or default-namespace consumers (useTranslation() with no arg) silently fall back to English for the missing-locale path. This was the root-cause of a long-lived bug where InstallSapariCard, Tooltip, ErrorModal, InlineError, and ColorPicker shipped English for pt/es users — the common bundle was missing from pt: and es:. The regression test shared/lib/i18n.test.ts parametrizes a key in the common namespace across all supported locales and fails if the bundle is missing; mirror that test pattern (or add the new namespace to its loop) whenever you add a namespace.
How locale is decided¶
boot: localStorage['sapari-locale'] → navigator.language → DEFAULT_LOCALE
(via i18next-browser-languagedetector + reduceToBase 'pt-BR' → 'pt')
after /auth/check-auth or /auth/login (server-wins):
if response.user.locale ∈ SUPPORTED_LOCALES and != current base
→ i18next.changeLanguage(response.user.locale)
→ LocaleProvider observes the change, persists, mirrors to <html lang> and ApiClient
reconcileServerLocale(serverLocale) in shared/lib/i18n.ts is the helper; it no-ops when the server value matches the resolved base language, so the LanguageDetector pick wins on first run for anonymous browsers, and the persisted server pick wins once the user has logged in once with their preference set.
Provider tree wiring¶
LocaleProvider mounts in App.tsx directly under ThemeProvider, above InstallPromptProvider and AuthProvider:
The order matters: LocaleProvider must be outside AuthProvider so the API client gets a locale stamped onto it before AuthProvider's checkAuth() fires its first request — otherwise the very first /auth/check-auth (which seeds the server-wins reconcile) goes out without Accept-Language and a fresh anonymous browser falls back to the backend's DEFAULT_LOCALE for that round-trip.
Request stamping¶
ApiClient.setLocale(locale) records the current locale onto a private field. Every request() reads it and writes Accept-Language: <locale> into the header bag (alongside X-CSRF-Token). LocaleContext's useEffect calls api.setLocale(locale) whenever the active locale changes, so the next request carries the new value with no per-call plumbing required.
Backend round-trip¶
User.locale is returned in the user payload of /auth/check-auth and /auth/login (both response types in shared/api/client.ts carry the optional locale field). The backend's get_request_locale dependency reads User.locale → Accept-Language → DEFAULT_LOCALE for every request, so the SPA's stamping primes signup and unauthenticated routes while the persisted User.locale takes over once the user is logged in. See backend.md §Dependency Injection (Request-scoped locale paragraph) for the server side.
Localizing system-generated DB strings¶
Some user-visible labels are stored as frozen English strings on database rows written by the backend (EntitlementTransaction.description, Edit.reason_tag). The strings cannot be localized at write time without a schema change — the row may render under any locale. The pattern is to maintain a small backend-raw → i18n-key map alongside the rendering surface and translate at render time, falling back to the raw string for anything not mapped:
// features/billing/transactionDescription.ts
const TX_DESCRIPTION_KEY_BY_RAW: Record<string, string> = {
'Beta program - renewable AI minutes': 'credits_tab.tx_description.beta_renewable_minutes',
'7-day free trial': 'credits_tab.tx_description.trial_7_day',
...
};
export function localizeTransactionDescription(t: TFunction, raw: string, options?: { ns?: string }) {
const key = TX_DESCRIPTION_KEY_BY_RAW[raw];
if (!key) return raw;
return options?.ns ? t(key, { ns: options.ns }) : t(key);
}
In-tree instances: features/billing/transactionDescription.ts (consumed by CreditsTab.tsx + MobileSettings.tsx) and features/analysis/hooks/useTransformedEdits.ts (REASON_TAG_KEY_BY_RAW, used at edit-card render time). When the backend writes a new system-generated string, add a row to the map and the catalog entries in all three locales; an unmapped raw string still renders (verbatim) rather than crashing, so the failure mode is silent English fallback — covered by per-tag tests in both files. Don't push localization into the backend schema; the frontend pattern keeps DB writes free of i18n concerns and migrations free of locale data.
Tests¶
shared/api/client.test.ts— verifiessetLocalestampsAccept-Languageon subsequent requests and that the header is absent when no locale has been set.shared/lib/i18n.test.ts— coversreconcileServerLocale's match-skip / unsupported-skip / null-skip / change-language paths, plus the regression test that asserts every supported locale resolves acommon-namespace key (catches missing namespace bundles in the resources object).shared/context/LocaleContext.test.tsx— asserts that a locale change pushes through toapi.setLocale.features/billing/transactionDescription.test.ts— pins the backend-raw → i18n-key map for known system-generated transaction descriptions and the fallback-to-raw path for unrecognized strings.features/analysis/hooks/useTransformedEdits.test.ts— same shape for theEdit.reason_tagmapping (one row per known backend tag, fallback for unknown, locale-flip check).
Key files¶
| Purpose | Location |
|---|---|
i18next init + reconcileServerLocale |
shared/lib/i18n.ts |
| Locale persistence + html-lang sync + ApiClient stamping | shared/context/LocaleContext.tsx |
Accept-Language per request |
shared/api/client.ts (setLocale, currentLocale) |
| Server-wins call sites | features/auth/context.tsx (checkAuth + login + checkAuth-after-login fallback) |
Inline bootstrap (<html lang> before SPA mount) |
frontend/index.html + see §Inline locale-bootstrap script below |
Progressive Web App (PWA)¶
Sapari is installable as a PWA via vite-plugin-pwa. The service worker registers but does not cache the SPA shell — Sapari is online-only by design (render queues, analysis, asset processing all require server). Caching the SPA would create staleness bugs without offline value.
What's installed¶
- Manifest — generated at build time as
manifest.webmanifest, configured infrontend/vite.config.ts. Theme/background#1a1a1a(sapari-dark-secondary) for soft transition to white app body.start_url: '/projects'lands returning users on their project list. - Icons —
frontend/public/icons/holds four PNGs generated from the SVGs infrontend/tools/icons/. All four (standard 192/512/180 + maskable 512) ship with a solid black background — transparent corners read poorly against varied dock/taskbar/launcher backgrounds. The maskable variant additionally has 80% scale-down for Android adaptive cropping. - Service worker —
dist/sw.jsprecaches only the offline fallback page, favicon, manifest, and icons (~3 KB total). The SPA bundle is NOT precached. - Offline fallback —
frontend/public/offline.htmlis shown on navigation when the network is unreachable.navigateFallbackDenylistexcludes/api/*and/media/*so XHR/fetch requests aren't intercepted. - Install hook —
useInstallPromptinshared/hooks/capturesbeforeinstallpromptfor a future custom install button. Currently no UI button is wired; placement TBD.
Inline theme-bootstrap script¶
frontend/index.html contains a synchronous inline <script> at the top of <head> that reads localStorage['theme-preference'] and sets html.dark BEFORE the SPA mounts. Without this, dark-mode users see a flash of white body between the dark PWA splash and their dark Dashboard.
The script duplicates the resolution logic in shared/context/ThemeContext.tsx:11-48. Both code paths must agree on the storage key, valid values, and default. A // NOTE: comment alongside STORAGE_KEY in ThemeContext flags the lockstep dependency. If you change STORAGE_KEY or the resolution rules, update both.
Inline locale-bootstrap script¶
A sibling inline <script> immediately after the theme bootstrap reads localStorage['sapari-locale'] and sets document.documentElement.lang BEFORE the SPA mounts. Without this, screen readers and CSS :lang(...) selectors see the static lang="en" declared on <html> and don't adapt to the user's persisted locale until React hydrates LocaleContext and applies the attribute imperatively — a long enough gap to mis-announce the first frame of content to assistive tech.
The script duplicates the SUPPORTED_LOCALES array and the LOCALE_STORAGE_KEY = 'sapari-locale' literal from shared/lib/i18n.ts. All three (the supported-list array, the storage key, and the default fallback) must stay in lockstep across the inline script, i18n.ts, and LocaleContext.tsx. A comment alongside LOCALE_STORAGE_KEY in i18n.ts flags the duplication, and the bootstrap script carries the corresponding pointer back. If you add a locale or rename the storage key, update all three.
The script reduces region-suffixed values (pt-BR → pt) before matching against the supported list, mirroring reduceToBase in LocaleContext. If the persisted value's base isn't supported, or localStorage is blocked (private browsing, third-party cookies disabled), the default lang="en" declared on <html> stands.
Regenerating icons¶
Source SVGs live at frontend/tools/icons/icon.svg (solid bg, full-bleed iris) and frontend/tools/icons/icon-maskable.svg (solid bg + 80% scale-down for Android adaptive cropping). Regenerate PNGs with rsvg-convert (brew install librsvg):
cd frontend/tools/icons
rsvg-convert -w 192 -h 192 icon.svg > ../../public/icons/icon-192.png
rsvg-convert -w 512 -h 512 icon.svg > ../../public/icons/icon-512.png
rsvg-convert -w 180 -h 180 icon.svg > ../../public/icons/apple-touch-icon-180.png
rsvg-convert -w 512 -h 512 icon-maskable.svg > ../../public/icons/icon-maskable-512.png
Verify the maskable variant at maskable.app/editor — the iris must survive circle/squircle/rounded-square/teardrop crops.
CSP coordination¶
When CSP ships, both inline bootstrap scripts (theme + locale) will each need a separate 'sha256-...' hash entry in script-src — CSP hashes are per-script-body and the two scripts have different bodies. Compute via the browser DevTools console error message in CSP Report-Only mode, or via openssl dgst -sha256 -binary <(echo -n '<script-content>') | openssl base64 per script.
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, formatElapsed } 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)
formatElapsed(65000) // "1m 05s" — long-running counters; zero-pads seconds when minutes shown so width never jitters
formatDurationOrFallback(null) // "--:--" — nullable with custom fallback
Never define a local formatTime, formatDuration, or similar in a component file. Import from shared.
Elapsed Counters¶
For long-running operations (analysis, render) the screen needs a ticking counter so users don't feel frozen between sparse SSE events. Use useElapsedMs(startedAt) from @/shared/hooks rather than rolling a fresh setInterval per surface — the hook handles the 1Hz tick (POLLING.ELAPSED_TICK_MS), reset on startedAt change, and cleanup on unmount. Pair with formatElapsed(ms) for the display string. Used by ProcessingTerminalView (desktop) and MobileProcessing (mobile).
Insert Overlap Modes¶
The 3-way toggle (merge / split / anchor) that decides how a non-insert asset edit interacts with each INSERT it touches lives in @/shared/lib/insertOverlapMode. All five toggle sites — EditPopover (desktop) plus the four mobile components (AssetFocusMode, FocusMode, mobile EditsPanel, MobileAssetPicker) — go through the same helpers so cycle order stays in lockstep.
import {
cycleInsertOverlapMode, // merge → split → anchor → merge
applyInsertOverlapMode, // write/clear an entry; merge omits the key
pruneStaleInsertOverlapModes, // drop entries that can't be true anymore
type InsertOverlapMode,
} from '@/shared/lib';
pruneStaleInsertOverlapModes(modes, asset, allInsertEdits) is called from Dashboard.handleAdjustEdit after every move/resize. Without it, a drag that moves an asset out of an insert's main range leaves the stored 'split'/'merge' entry behind; toggling an asset between two inserts can leave an 'anchor' entry pointing at the wrong host. Rules: drop 'anchor' whose insert ID isn't the asset's current insideInsertEditId; drop 'merge'/'split' whose insert no longer overlaps the asset's main range; drop entries for unknown insert IDs.
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). Asset playback also routes through the Worker (/media/v1/<jwt> with the JWT's bkt claim selecting the assets bucket) and asset thumbnails are minted the same way, but MobileAssetEditor does not yet mount the retry-as-contract layer over its asset video tags — refresh on JWT expiry there relies on a fresh URL fetch on the next interaction rather than the snapshot/restore swap used for clips.
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. AssetFocusMode.tsx was rewired to use <ClipVideo> directly — its tap-to-seek paints the sprite tile during the buffering window. CutFocusMode.tsx still renders a bare <video> element — a post-launch follow-up to wire it 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 |
| Locale context | shared/context/LocaleContext.tsx |
| i18next init + server-wins reconcile | shared/lib/i18n.ts |
| Test harness | vite.config.ts (test block), vitest.setup.ts |