void>>()
+
+ constructor(id: string) {
+ this.id = id
+ }
+
+ abstract connect(): void
+ abstract disconnect(): void
+
+ /** Subscribe to a specific event type */
+ on(eventType: string, handler: (payload: P) => void): () => void {
+ if (!this.listeners.has(eventType)) {
+ this.listeners.set(eventType, new Set())
+ }
+ this.listeners.get(eventType)!.add(handler)
+ return () => this.listeners.get(eventType)?.delete(handler)
+ }
+
+ /** Emit an event to subscribers (called by subclasses) */
+ protected emit(eventType: string, payload: unknown): void {
+ this.listeners.get(eventType)?.forEach((fn) => fn(payload))
+ }
+}
diff --git a/ui/framework/src/datasources/SSEDataSource.ts b/ui/framework/src/datasources/SSEDataSource.ts
new file mode 100644
index 0000000..ed52527
--- /dev/null
+++ b/ui/framework/src/datasources/SSEDataSource.ts
@@ -0,0 +1,93 @@
+import { DataSource } from './DataSource'
+
+export interface SSEDataSourceOptions {
+ /** Unique identifier for this source */
+ id: string
+ /** SSE endpoint URL (e.g. '/api/detect/stream/job-123') */
+ url: string
+ /** Event types to listen for. Each is dispatched to subscribers via on(). */
+ eventTypes: string[]
+ /** Max reconnection attempts before giving up. Default: 10 */
+ maxRetries?: number
+}
+
+/**
+ * DataSource backed by native EventSource (Server-Sent Events).
+ *
+ * Connects to a single SSE endpoint and demultiplexes events by type.
+ * Multiple panels can subscribe to different event types from the same source.
+ */
+export class SSEDataSource extends DataSource {
+ private es: EventSource | null = null
+ private url: string
+ private eventTypes: string[]
+ private maxRetries: number
+ private retryCount = 0
+
+ constructor(opts: SSEDataSourceOptions) {
+ super(opts.id)
+ this.url = opts.url
+ this.eventTypes = opts.eventTypes
+ this.maxRetries = opts.maxRetries ?? 10
+ }
+
+ connect(): void {
+ if (this.es) return
+ this.status.value = 'connecting'
+ this.error.value = null
+
+ this.es = new EventSource(this.url)
+
+ this.es.onopen = () => {
+ this.status.value = 'live'
+ this.retryCount = 0
+ }
+
+ this.es.onerror = () => {
+ if (this.es?.readyState === EventSource.CLOSED) {
+ this.retryCount++
+ if (this.retryCount >= this.maxRetries) {
+ this.status.value = 'error'
+ this.error.value = `Connection lost after ${this.maxRetries} retries`
+ this.disconnect()
+ } else {
+ this.status.value = 'connecting'
+ }
+ }
+ }
+
+ // Register a listener for each event type
+ for (const eventType of this.eventTypes) {
+ this.es.addEventListener(eventType, (e: MessageEvent) => {
+ try {
+ const parsed = JSON.parse(e.data)
+ this.data.value = parsed
+ this.emit(eventType, parsed)
+ } catch {
+ // ignore malformed events
+ }
+ })
+ }
+
+ // Terminal event — pipeline finished (success, failure, or cancel)
+ this.es.addEventListener('done', () => {
+ this.status.value = 'idle'
+ })
+ }
+
+ disconnect(): void {
+ if (this.es) {
+ this.es.close()
+ this.es = null
+ }
+ }
+
+ /** Update the URL (e.g. when job ID changes) and reconnect */
+ setUrl(url: string): void {
+ this.url = url
+ if (this.status.value === 'live' || this.status.value === 'connecting') {
+ this.disconnect()
+ this.connect()
+ }
+ }
+}
diff --git a/ui/framework/src/datasources/StaticDataSource.ts b/ui/framework/src/datasources/StaticDataSource.ts
new file mode 100644
index 0000000..a09dfa6
--- /dev/null
+++ b/ui/framework/src/datasources/StaticDataSource.ts
@@ -0,0 +1,45 @@
+import { DataSource } from './DataSource'
+
+export interface StaticEvent {
+ type: string
+ data: unknown
+ /** Delay in ms before emitting this event (relative to previous). Default: 0 */
+ delay?: number
+}
+
+/**
+ * DataSource that replays a fixture array of events.
+ *
+ * Used for development and testing without a running backend.
+ * Events are emitted in sequence with optional delays.
+ */
+export class StaticDataSource extends DataSource {
+ private events: StaticEvent[]
+ private timeouts: ReturnType[] = []
+
+ constructor(id: string, events: StaticEvent[]) {
+ super(id)
+ this.events = events
+ }
+
+ connect(): void {
+ this.status.value = 'live'
+ this.error.value = null
+
+ let cumDelay = 0
+ for (const event of this.events) {
+ cumDelay += event.delay ?? 0
+ const timeout = setTimeout(() => {
+ this.data.value = event.data
+ this.emit(event.type, event.data)
+ }, cumDelay)
+ this.timeouts.push(timeout)
+ }
+ }
+
+ disconnect(): void {
+ for (const t of this.timeouts) clearTimeout(t)
+ this.timeouts = []
+ this.status.value = 'idle'
+ }
+}
diff --git a/ui/framework/src/datasources/__tests__/StaticDataSource.test.ts b/ui/framework/src/datasources/__tests__/StaticDataSource.test.ts
new file mode 100644
index 0000000..9c4cb30
--- /dev/null
+++ b/ui/framework/src/datasources/__tests__/StaticDataSource.test.ts
@@ -0,0 +1,103 @@
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { StaticDataSource } from '../StaticDataSource'
+
+describe('StaticDataSource', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('emits events in order', async () => {
+ const source = new StaticDataSource('test', [
+ { type: 'log', data: { msg: 'first' } },
+ { type: 'log', data: { msg: 'second' } },
+ { type: 'stats', data: { count: 42 } },
+ ])
+
+ const received: { type: string; data: unknown }[] = []
+ source.on('log', (d) => received.push({ type: 'log', data: d }))
+ source.on('stats', (d) => received.push({ type: 'stats', data: d }))
+
+ source.connect()
+
+ // Events with delay=0 fire on next microtask via setTimeout(0)
+ await new Promise((r) => setTimeout(r, 10))
+
+ expect(source.status.value).toBe('live')
+ expect(received).toHaveLength(3)
+ expect(received[0]).toEqual({ type: 'log', data: { msg: 'first' } })
+ expect(received[1]).toEqual({ type: 'log', data: { msg: 'second' } })
+ expect(received[2]).toEqual({ type: 'stats', data: { count: 42 } })
+
+ source.disconnect()
+ expect(source.status.value).toBe('idle')
+ })
+
+ it('respects delays between events', async () => {
+ const source = new StaticDataSource('test-delay', [
+ { type: 'a', data: 1 },
+ { type: 'b', data: 2, delay: 50 },
+ ])
+
+ const received: unknown[] = []
+ source.on('a', (d) => received.push(d))
+ source.on('b', (d) => received.push(d))
+
+ source.connect()
+
+ await new Promise((r) => setTimeout(r, 10))
+ expect(received).toHaveLength(1) // only 'a' so far
+
+ await new Promise((r) => setTimeout(r, 60))
+ expect(received).toHaveLength(2) // 'b' arrived after delay
+
+ source.disconnect()
+ })
+
+ it('updates data ref with latest event payload', async () => {
+ const source = new StaticDataSource('test-data', [
+ { type: 'x', data: { v: 1 } },
+ { type: 'x', data: { v: 2 } },
+ ])
+
+ source.connect()
+ await new Promise((r) => setTimeout(r, 10))
+
+ expect(source.data.value).toEqual({ v: 2 })
+
+ source.disconnect()
+ })
+
+ it('cleans up on disconnect', async () => {
+ const source = new StaticDataSource('test-cleanup', [
+ { type: 'a', data: 1 },
+ { type: 'b', data: 2, delay: 100 },
+ ])
+
+ const received: unknown[] = []
+ source.on('b', (d) => received.push(d))
+
+ source.connect()
+ await new Promise((r) => setTimeout(r, 10))
+ source.disconnect()
+
+ // 'b' should never fire since we disconnected before its delay
+ await new Promise((r) => setTimeout(r, 150))
+ expect(received).toHaveLength(0)
+ })
+
+ it('unsubscribe removes listener', async () => {
+ const source = new StaticDataSource('test-unsub', [
+ { type: 'x', data: 1 },
+ ])
+
+ const received: unknown[] = []
+ const unsub = source.on('x', (d) => received.push(d))
+ unsub()
+
+ source.connect()
+ await new Promise((r) => setTimeout(r, 10))
+
+ expect(received).toHaveLength(0)
+ source.disconnect()
+ })
+})
diff --git a/ui/framework/src/index.ts b/ui/framework/src/index.ts
new file mode 100644
index 0000000..32e2edb
--- /dev/null
+++ b/ui/framework/src/index.ts
@@ -0,0 +1,39 @@
+// Framework public API
+export { DataSource, type DataSourceStatus } from './datasources/DataSource'
+export { SSEDataSource } from './datasources/SSEDataSource'
+export { StaticDataSource } from './datasources/StaticDataSource'
+export { useDataSource } from './composables/useDataSource'
+export { useRegistry } from './composables/useRegistry'
+export { useEditorExecution } from './composables/useEditorExecution'
+export type { EditorExecutionOptions } from './composables/useEditorExecution'
+
+// Components
+export { default as Panel } from './components/Panel.vue'
+export { default as LayoutGrid } from './components/LayoutGrid.vue'
+export { default as ResizeHandle } from './components/ResizeHandle.vue'
+export { default as SplitPane } from './components/SplitPane.vue'
+export { default as ParameterEditor } from './components/ParameterEditor.vue'
+export type { ConfigField } from './components/ParameterEditor.vue'
+export { default as VideoPlayer } from './components/VideoPlayer.vue'
+
+// Renderers
+export { default as LogRenderer } from './renderers/LogRenderer.vue'
+export { default as TimeSeriesRenderer } from './renderers/TimeSeriesRenderer.vue'
+export { default as GraphRenderer } from './renderers/GraphRenderer.vue'
+export { default as FrameRenderer } from './renderers/FrameRenderer.vue'
+export { default as TableRenderer } from './renderers/TableRenderer.vue'
+
+// Renderer types
+export type { FrameBBox, FrameOverlay } from './renderers/FrameRenderer.vue'
+export type { LogEntry } from './renderers/LogRenderer.vue'
+export type { GraphNode, GraphMode } from './renderers/GraphRenderer.vue'
+export type { TableColumn } from './renderers/TableRenderer.vue'
+export type { TimeSeriesSeries } from './renderers/TimeSeriesRenderer.vue'
+
+// Interaction plugins
+export type { InteractionPlugin, PluginContext } from './plugins/InteractionPlugin'
+export { BBoxDrawPlugin } from './plugins/BBoxDrawPlugin'
+export type { BBoxResult, BBoxCallback } from './plugins/BBoxDrawPlugin'
+export { CrosshairPlugin } from './plugins/CrosshairPlugin'
+export type { CrosshairCallback } from './plugins/CrosshairPlugin'
+
diff --git a/ui/framework/src/plugins/BBoxDrawPlugin.ts b/ui/framework/src/plugins/BBoxDrawPlugin.ts
new file mode 100644
index 0000000..064ef6d
--- /dev/null
+++ b/ui/framework/src/plugins/BBoxDrawPlugin.ts
@@ -0,0 +1,88 @@
+/**
+ * BBoxDrawPlugin — draw bounding boxes on the frame viewer.
+ *
+ * User drags on the canvas to draw a rectangle.
+ * On pointer up, emits the bbox coordinates via the callback.
+ * The frame viewer panel feeds this into the selection store.
+ */
+
+import type { InteractionPlugin, PluginContext } from './InteractionPlugin'
+
+export interface BBoxResult {
+ x: number
+ y: number
+ w: number
+ h: number
+}
+
+export type BBoxCallback = (bbox: BBoxResult) => void
+
+export class BBoxDrawPlugin implements InteractionPlugin {
+ name = 'bbox-draw'
+
+ private ctx: CanvasRenderingContext2D | null = null
+ private drawing = false
+ private startX = 0
+ private startY = 0
+ private currentBox: BBoxResult | null = null
+ private callback: BBoxCallback
+
+ constructor(callback: BBoxCallback) {
+ this.callback = callback
+ }
+
+ onMount(context: PluginContext): void {
+ this.ctx = context.ctx
+ }
+
+ onUnmount(): void {
+ this.ctx = null
+ this.drawing = false
+ this.currentBox = null
+ }
+
+ onPointerDown(e: PointerEvent): void {
+ this.drawing = true
+ this.startX = e.offsetX
+ this.startY = e.offsetY
+ this.currentBox = null
+ }
+
+ onPointerMove(e: PointerEvent): void {
+ if (!this.drawing) return
+
+ const x = Math.min(this.startX, e.offsetX)
+ const y = Math.min(this.startY, e.offsetY)
+ const w = Math.abs(e.offsetX - this.startX)
+ const h = Math.abs(e.offsetY - this.startY)
+
+ this.currentBox = { x, y, w, h }
+ }
+
+ onPointerUp(_e: PointerEvent): void {
+ if (!this.drawing) return
+ this.drawing = false
+
+ if (this.currentBox && this.currentBox.w > 5 && this.currentBox.h > 5) {
+ this.callback(this.currentBox)
+ }
+
+ this.currentBox = null
+ }
+
+ render(ctx: CanvasRenderingContext2D): void {
+ if (!this.currentBox) return
+
+ const box = this.currentBox
+
+ ctx.strokeStyle = '#4f9cf9'
+ ctx.lineWidth = 2
+ ctx.setLineDash([6, 3])
+ ctx.strokeRect(box.x, box.y, box.w, box.h)
+ ctx.setLineDash([])
+
+ // Semi-transparent fill
+ ctx.fillStyle = 'rgba(79, 156, 249, 0.1)'
+ ctx.fillRect(box.x, box.y, box.w, box.h)
+ }
+}
diff --git a/ui/framework/src/plugins/CrosshairPlugin.ts b/ui/framework/src/plugins/CrosshairPlugin.ts
new file mode 100644
index 0000000..0011b5d
--- /dev/null
+++ b/ui/framework/src/plugins/CrosshairPlugin.ts
@@ -0,0 +1,60 @@
+/**
+ * CrosshairPlugin — synchronized vertical crosshair across time-series panels.
+ *
+ * When the user hovers on any panel with this plugin, the crosshair
+ * position (as a timestamp) is written to the selection store.
+ * All panels with this plugin render a vertical line at that timestamp.
+ */
+
+import type { InteractionPlugin, PluginContext } from './InteractionPlugin'
+
+export type CrosshairCallback = (timestamp: number | null) => void
+
+export class CrosshairPlugin implements InteractionPlugin {
+ name = 'crosshair'
+
+ private width = 0
+ private callback: CrosshairCallback
+
+ /** Current crosshair X position (pixels), set externally from store */
+ public crosshairX: number | null = null
+
+ constructor(callback: CrosshairCallback) {
+ this.callback = callback
+ }
+
+ onMount(context: PluginContext): void {
+ this.width = context.width
+ }
+
+ onUnmount(): void {
+ this.crosshairX = null
+ }
+
+ onPointerMove(e: PointerEvent): void {
+ // Convert pixel X to normalized position (0-1)
+ const normalized = e.offsetX / this.width
+ this.callback(normalized)
+ }
+
+ onPointerDown(_e: PointerEvent): void {
+ // no-op for crosshair
+ }
+
+ onPointerUp(_e: PointerEvent): void {
+ this.callback(null)
+ }
+
+ render(ctx: CanvasRenderingContext2D): void {
+ if (this.crosshairX === null) return
+
+ ctx.strokeStyle = '#a78bfa'
+ ctx.lineWidth = 1
+ ctx.setLineDash([4, 4])
+ ctx.beginPath()
+ ctx.moveTo(this.crosshairX, 0)
+ ctx.lineTo(this.crosshairX, ctx.canvas.height)
+ ctx.stroke()
+ ctx.setLineDash([])
+ }
+}
diff --git a/ui/framework/src/plugins/InteractionPlugin.ts b/ui/framework/src/plugins/InteractionPlugin.ts
new file mode 100644
index 0000000..82fd944
--- /dev/null
+++ b/ui/framework/src/plugins/InteractionPlugin.ts
@@ -0,0 +1,36 @@
+/**
+ * Interaction plugin interface.
+ *
+ * Plugins attach to a Panel's overlay canvas. They receive pointer events
+ * and emit typed results via the callback. The panel handles rendering
+ * the overlay and routing events to the active plugin.
+ */
+
+export interface PluginContext {
+ /** Canvas element for drawing overlays */
+ canvas: HTMLCanvasElement
+ /** 2D rendering context */
+ ctx: CanvasRenderingContext2D
+ /** Canvas dimensions (may differ from display size) */
+ width: number
+ height: number
+}
+
+export interface InteractionPlugin {
+ /** Unique plugin name */
+ name: string
+
+ /** Called when the plugin is mounted on a panel */
+ onMount(context: PluginContext): void
+
+ /** Called when the plugin is unmounted */
+ onUnmount(): void
+
+ /** Pointer event handlers (optional) */
+ onPointerDown?(e: PointerEvent): void
+ onPointerMove?(e: PointerEvent): void
+ onPointerUp?(e: PointerEvent): void
+
+ /** Called each animation frame to render the overlay */
+ render(ctx: CanvasRenderingContext2D): void
+}
diff --git a/ui/framework/src/renderers/FrameRenderer.vue b/ui/framework/src/renderers/FrameRenderer.vue
new file mode 100644
index 0000000..50118c0
--- /dev/null
+++ b/ui/framework/src/renderers/FrameRenderer.vue
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
diff --git a/ui/framework/src/renderers/GraphRenderer.vue b/ui/framework/src/renderers/GraphRenderer.vue
new file mode 100644
index 0000000..53017cb
--- /dev/null
+++ b/ui/framework/src/renderers/GraphRenderer.vue
@@ -0,0 +1,317 @@
+
+
+
+
+
+
+
+
{{ data.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/framework/src/renderers/LogRenderer.vue b/ui/framework/src/renderers/LogRenderer.vue
new file mode 100644
index 0000000..2974909
--- /dev/null
+++ b/ui/framework/src/renderers/LogRenderer.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+ {{ entry.ts }}
+ {{ entry.level }}
+ {{ entry.stage }}
+ {{ entry.msg }}
+
+
+
+
+ Waiting for log events...
+
+
+
+
+
diff --git a/ui/framework/src/renderers/TableRenderer.vue b/ui/framework/src/renderers/TableRenderer.vue
new file mode 100644
index 0000000..d4c3d69
--- /dev/null
+++ b/ui/framework/src/renderers/TableRenderer.vue
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+ |
+ {{ col.label }}
+
+ {{ sortDir === 'desc' ? '▼' : '▲' }}
+
+ |
+
+
+
+
+ |
+ {{ row[col.key] }}
+ |
+
+
+ | No detections yet |
+
+
+
+
+
+
+
diff --git a/ui/framework/src/renderers/TimeSeriesRenderer.vue b/ui/framework/src/renderers/TimeSeriesRenderer.vue
new file mode 100644
index 0000000..c664ffb
--- /dev/null
+++ b/ui/framework/src/renderers/TimeSeriesRenderer.vue
@@ -0,0 +1,198 @@
+
+
+
+
+
+
+
diff --git a/ui/framework/src/tokens.css b/ui/framework/src/tokens.css
new file mode 100644
index 0000000..4858ec3
--- /dev/null
+++ b/ui/framework/src/tokens.css
@@ -0,0 +1,59 @@
+/* Framework design tokens — retheme by replacing this file */
+:root {
+ /* spacing scale (4px base) */
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-6: 24px;
+ --space-8: 32px;
+
+ /* color — dark theme (observability UIs are always dark) */
+ --surface-0: #0d0d0f;
+ --surface-1: #16161a;
+ --surface-2: #1e1e24;
+ --surface-3: #26262f;
+ --border: #2e2e38;
+
+ --text-primary: #e8e8f0;
+ --text-secondary: #8888a0;
+ --text-dim: #555568;
+
+ /* status colors */
+ --status-idle: #555568;
+ --status-live: #3ecf8e;
+ --status-processing: #4f9cf9;
+ --status-escalating: #f5a623;
+ --status-error: #f06565;
+
+ /* confidence color scale (low → high) */
+ --conf-low: #f06565;
+ --conf-mid: #f5a623;
+ --conf-high: #3ecf8e;
+
+ /* typography */
+ --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
+ --font-ui: 'Inter', system-ui, sans-serif;
+ --font-size-sm: 11px;
+ --font-size-base: 13px;
+ --font-size-lg: 15px;
+
+ /* panel chrome */
+ --panel-radius: 6px;
+ --panel-border: 1px solid var(--border);
+ --panel-header-height: 36px;
+}
+
+/* Animated gradient outline for buttons in a waiting state.
+ Usage: add class="waiting" to any button/element. */
+@keyframes waiting-glow {
+ 0% { box-shadow: 0 0 3px 1px var(--status-processing); }
+ 33% { box-shadow: 0 0 3px 1px var(--status-live); }
+ 66% { box-shadow: 0 0 3px 1px var(--status-escalating); }
+ 100% { box-shadow: 0 0 3px 1px var(--status-processing); }
+}
+
+.waiting {
+ animation: waiting-glow 2s linear infinite;
+ outline: 1px solid transparent;
+}
diff --git a/ui/framework/tsconfig.json b/ui/framework/tsconfig.json
new file mode 100644
index 0000000..cee8317
--- /dev/null
+++ b/ui/framework/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "jsx": "preserve",
+ "noEmit": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src/**/*.ts", "src/**/*.vue"]
+}
diff --git a/ui/framework/vitest.config.ts b/ui/framework/vitest.config.ts
new file mode 100644
index 0000000..2b1c323
--- /dev/null
+++ b/ui/framework/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ environment: 'node',
+ },
+})