diff --git a/src/lib/components/Timeline.svelte b/src/lib/components/Timeline.svelte
index d1437b9..05c6f65 100644
--- a/src/lib/components/Timeline.svelte
+++ b/src/lib/components/Timeline.svelte
@@ -1,17 +1,133 @@
-
-
Timeline
+
+
+
+
diff --git a/src/lib/timeline/interactions.ts b/src/lib/timeline/interactions.ts
new file mode 100644
index 0000000..75b7439
--- /dev/null
+++ b/src/lib/timeline/interactions.ts
@@ -0,0 +1,48 @@
+import { type TimelineState, xToTime } from './renderer';
+
+export type { TimelineState };
+
+const MIN_VISIBLE_RANGE = 0.1;
+const MIN_ZOOM = 1;
+
+export function clampZoom(zoom: number, duration: number): number {
+ const maxZoom = duration / MIN_VISIBLE_RANGE;
+ return Math.max(MIN_ZOOM, Math.min(zoom, maxZoom));
+}
+
+export function computeZoom(
+ state: TimelineState,
+ anchorTime: number,
+ factor: number,
+ duration: number
+): { visibleStart: number; visibleEnd: number; zoom: number } {
+ const currentRange = state.visibleEnd - state.visibleStart;
+ const newRange = currentRange / factor;
+ const clampedRange = Math.max(MIN_VISIBLE_RANGE, Math.min(newRange, duration));
+
+ const anchorRatio = (anchorTime - state.visibleStart) / currentRange;
+ let newStart = anchorTime - anchorRatio * clampedRange;
+ let newEnd = newStart + clampedRange;
+
+ if (newStart < 0) {
+ newStart = 0;
+ newEnd = clampedRange;
+ }
+ if (newEnd > duration) {
+ newEnd = duration;
+ newStart = Math.max(0, newEnd - clampedRange);
+ }
+
+ const newZoom = clampZoom(duration / clampedRange, duration);
+
+ return { visibleStart: newStart, visibleEnd: newEnd, zoom: newZoom };
+}
+
+export function handleClick(
+ x: number,
+ state: TimelineState,
+ duration: number
+): number {
+ const time = xToTime(x, state);
+ return Math.max(0, Math.min(time, duration));
+}
diff --git a/src/lib/timeline/renderer.ts b/src/lib/timeline/renderer.ts
new file mode 100644
index 0000000..d4b8f41
--- /dev/null
+++ b/src/lib/timeline/renderer.ts
@@ -0,0 +1,128 @@
+export interface TimelineState {
+ visibleStart: number;
+ visibleEnd: number;
+ zoom: number;
+ width: number;
+ height: number;
+}
+
+export function timeToX(time: number, state: TimelineState): number {
+ const range = state.visibleEnd - state.visibleStart;
+ if (range <= 0) return 0;
+ return ((time - state.visibleStart) / range) * state.width;
+}
+
+export function xToTime(x: number, state: TimelineState): number {
+ const range = state.visibleEnd - state.visibleStart;
+ return state.visibleStart + (x / state.width) * range;
+}
+
+const THUMB_LANE_HEIGHT = 50;
+const WAVEFORM_LANE_HEIGHT = 40;
+const CONTROLS_LANE_HEIGHT = 30;
+const TICK_HEIGHT = 10;
+
+export function drawTimeline(
+ ctx: CanvasRenderingContext2D,
+ state: TimelineState,
+ currentTime: number,
+ duration: number
+): void {
+ const { width, height } = state;
+ ctx.clearRect(0, 0, width, height);
+
+ ctx.fillStyle = '#181825';
+ ctx.fillRect(0, 0, width, height);
+
+ drawTimeTicks(ctx, state, duration);
+
+ drawPlaceholderLane(ctx, state, 0, THUMB_LANE_HEIGHT, 'Thumbnails');
+ drawPlaceholderLane(ctx, state, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT, 'Waveform');
+
+ drawPlayhead(ctx, state, currentTime);
+}
+
+function drawTimeTicks(
+ ctx: CanvasRenderingContext2D,
+ state: TimelineState,
+ duration: number
+): void {
+ const range = state.visibleEnd - state.visibleStart;
+ const tickInterval = getTickInterval(range, state.width);
+ const startTick = Math.floor(state.visibleStart / tickInterval) * tickInterval;
+
+ ctx.fillStyle = '#6c7086';
+ ctx.font = '10px -apple-system, sans-serif';
+ ctx.textAlign = 'center';
+
+ for (let t = startTick; t <= state.visibleEnd; t += tickInterval) {
+ if (t < 0 || t > duration) continue;
+ const x = timeToX(t, state);
+ ctx.strokeStyle = '#45475a';
+ ctx.beginPath();
+ ctx.moveTo(x, state.height - CONTROLS_LANE_HEIGHT);
+ ctx.lineTo(x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT);
+ ctx.stroke();
+
+ const label = formatTickLabel(t);
+ ctx.fillText(label, x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT + 12);
+ }
+}
+
+function getTickInterval(visibleRange: number, width: number): number {
+ const minPixelsPerTick = 80;
+ const maxTicks = width / minPixelsPerTick;
+ const rawInterval = visibleRange / maxTicks;
+
+ const intervals = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
+ for (const interval of intervals) {
+ if (interval >= rawInterval) return interval;
+ }
+ return 600;
+}
+
+function formatTickLabel(seconds: number): string {
+ const m = Math.floor(seconds / 60);
+ const s = Math.floor(seconds % 60);
+ return `${m}:${String(s).padStart(2, '0')}`;
+}
+
+function drawPlaceholderLane(
+ ctx: CanvasRenderingContext2D,
+ state: TimelineState,
+ y: number,
+ height: number,
+ label: string
+): void {
+ ctx.fillStyle = '#2a2a3e';
+ ctx.fillRect(0, y, state.width, height);
+ ctx.strokeStyle = '#353550';
+ ctx.strokeRect(0, y, state.width, height);
+ ctx.fillStyle = '#6c7086';
+ ctx.font = '11px -apple-system, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText(`Generating ${label}…`, state.width / 2, y + height / 2 + 4);
+}
+
+export function drawPlayhead(
+ ctx: CanvasRenderingContext2D,
+ state: TimelineState,
+ currentTime: number
+): void {
+ const x = timeToX(currentTime, state);
+ ctx.strokeStyle = '#ffffff';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, state.height);
+ ctx.stroke();
+ ctx.lineWidth = 1;
+
+ ctx.fillStyle = '#ffffff';
+ ctx.beginPath();
+ ctx.moveTo(x - 5, 0);
+ ctx.lineTo(x + 5, 0);
+ ctx.lineTo(x, 8);
+ ctx.closePath();
+ ctx.fill();
+}
diff --git a/tests/lib/timeline/interactions.test.ts b/tests/lib/timeline/interactions.test.ts
new file mode 100644
index 0000000..e624454
--- /dev/null
+++ b/tests/lib/timeline/interactions.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from 'vitest';
+import { clampZoom, computeZoom, type TimelineState } from '$lib/timeline/interactions';
+
+describe('clampZoom', () => {
+ it('clamps zoom above minimum', () => {
+ expect(clampZoom(0.5, 60)).toBeGreaterThanOrEqual(1);
+ });
+
+ it('clamps zoom below maximum', () => {
+ expect(clampZoom(10000, 60)).toBeLessThanOrEqual(600);
+ });
+});
+
+describe('computeZoom', () => {
+ it('zooming in narrows the visible range', () => {
+ const state: TimelineState = {
+ visibleStart: 0,
+ visibleEnd: 60,
+ zoom: 1,
+ width: 600,
+ height: 160,
+ };
+ const result = computeZoom(state, 30, 1.1, 60);
+ const newRange = result.visibleEnd - result.visibleStart;
+ expect(newRange).toBeLessThan(60);
+ });
+
+ it('zooming out widens the visible range', () => {
+ const state: TimelineState = {
+ visibleStart: 10,
+ visibleEnd: 20,
+ zoom: 6,
+ width: 600,
+ height: 160,
+ };
+ const result = computeZoom(state, 15, 0.9, 60);
+ const newRange = result.visibleEnd - result.visibleStart;
+ expect(newRange).toBeGreaterThan(10);
+ });
+});
diff --git a/tests/lib/timeline/renderer.test.ts b/tests/lib/timeline/renderer.test.ts
new file mode 100644
index 0000000..988ac07
--- /dev/null
+++ b/tests/lib/timeline/renderer.test.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect } from 'vitest';
+import { timeToX, xToTime, type TimelineState } from '$lib/timeline/renderer';
+
+function makeState(overrides: Partial
= {}): TimelineState {
+ return {
+ visibleStart: 0,
+ visibleEnd: 60,
+ zoom: 1,
+ width: 600,
+ height: 160,
+ ...overrides,
+ };
+}
+
+describe('timeToX', () => {
+ it('maps start of visible range to 0', () => {
+ const state = makeState();
+ expect(timeToX(0, state)).toBe(0);
+ });
+
+ it('maps end of visible range to width', () => {
+ const state = makeState();
+ expect(timeToX(60, state)).toBe(600);
+ });
+
+ it('maps midpoint correctly', () => {
+ const state = makeState();
+ expect(timeToX(30, state)).toBe(300);
+ });
+
+ it('handles zoomed-in view', () => {
+ const state = makeState({ visibleStart: 10, visibleEnd: 20 });
+ expect(timeToX(15, state)).toBe(300);
+ });
+});
+
+describe('xToTime', () => {
+ it('maps 0 to visible start', () => {
+ const state = makeState();
+ expect(xToTime(0, state)).toBe(0);
+ });
+
+ it('maps width to visible end', () => {
+ const state = makeState();
+ expect(xToTime(600, state)).toBe(60);
+ });
+
+ it('maps midpoint correctly', () => {
+ const state = makeState();
+ expect(xToTime(300, state)).toBe(30);
+ });
+
+ it('inverse of timeToX', () => {
+ const state = makeState({ visibleStart: 10, visibleEnd: 20 });
+ expect(xToTime(timeToX(15, state), state)).toBeCloseTo(15);
+ });
+});