diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index e3f8de6..222cfb1 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -148,7 +148,7 @@ model User {
feedbackAssigned FeedbackItem[] @relation("FeedbackAssignee")
feedbackResolved FeedbackItem[] @relation("FeedbackResolver")
feedbackVerified FeedbackItem[] @relation("FeedbackVerifier")
- colorProbes ColorProbe[]
+ colorProbes ColorProbe[] @relation("ColorProbeCreator")
reviewSessionsCreated ReviewSession[] @relation("ReviewSessionCreator")
reviewSessionDecisions ReviewSessionItem[] @relation("ReviewSessionDecider")
@@ -744,27 +744,6 @@ model Annotation {
@@map("annotations")
}
-// ─── Color Probe ────────────────────────────────────────
-
-model ColorProbe {
- id String @id @default(cuid())
- revisionId String
- revision Revision @relation(fields: [revisionId], references: [id], onDelete: Cascade)
- index Int
- workingX Float
- workingY Float
- referenceX Float
- referenceY Float
- createdById String
- createdBy User @relation(fields: [createdById], references: [id])
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- @@unique([revisionId, index])
- @@index([revisionId])
- @@map("color_probes")
-}
-
// ─── Feedback Item ──────────────────────────────────────
model FeedbackItem {
@@ -855,3 +834,24 @@ model ReviewSessionItem {
@@index([sessionId])
@@map("review_session_items")
}
+
+// ─── Color Probes (CMF Eyedropper) ─────────────────────
+
+model ColorProbe {
+ id String @id @default(cuid())
+ revisionId String
+ revision Revision @relation(fields: [revisionId], references: [id], onDelete: Cascade)
+ index Int // 1-12, display order
+ workingX Float // image-space coordinates on working image
+ workingY Float
+ referenceX Float // image-space coordinates on reference (defaults to same as working)
+ referenceY Float
+ createdById String
+ createdBy User @relation("ColorProbeCreator", fields: [createdById], references: [id])
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([revisionId, index])
+ @@index([revisionId])
+ @@map("color_probes")
+}
diff --git a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx
index e6eb2fc..2831fdf 100644
--- a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx
+++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx
@@ -227,6 +227,18 @@ export default function ReviewPage() {
return match?.revisionId ?? null;
}, [galleryImages, activeImageUrl]);
+ // ── Image URLs for CMF probe sampling ──────────────────────────────
+ const { workingImageUrl, referenceImageUrl } = useMemo(() => {
+ if (!activeRevisionId) return { workingImageUrl: null, referenceImageUrl: null };
+ const rev = revisions.find((r: any) => r.id === activeRevisionId);
+ if (!rev) return { workingImageUrl: null, referenceImageUrl: null };
+ const att = rev.attachments as RevisionAttachments | null;
+ return {
+ workingImageUrl: att?.currentImage?.url ?? null,
+ referenceImageUrl: att?.referenceImage?.url ?? null,
+ };
+ }, [activeRevisionId, revisions]);
+
// ── Delete annotation (from feedback sidebar) ─────────────────────
const deleteAnnotationMutation = useDeleteAnnotation(activeRevisionId);
const handleDeleteAnnotation = useCallback(
@@ -549,6 +561,8 @@ export default function ReviewPage() {
containerHeight={vs.containerHeight}
imageDimensions={vs.imageDimensions}
hoveredAnnotationId={hoveredAnnotationId}
+ workingImageUrl={workingImageUrl}
+ referenceImageUrl={referenceImageUrl}
/>
)}
/>
diff --git a/src/app/api/revisions/[revisionId]/color-probes/[probeId]/route.ts b/src/app/api/revisions/[revisionId]/color-probes/[probeId]/route.ts
new file mode 100644
index 0000000..b039f84
--- /dev/null
+++ b/src/app/api/revisions/[revisionId]/color-probes/[probeId]/route.ts
@@ -0,0 +1,43 @@
+import { NextResponse } from "next/server";
+import { getAuthSession, badRequest, serverError } from "@/lib/api-utils";
+import { updateColorProbeSchema } from "@/lib/validators/color-probe";
+import {
+ updateColorProbe,
+ deleteColorProbe,
+} from "@/lib/services/color-probe-service";
+
+interface RouteContext {
+ params: Promise<{ revisionId: string; probeId: string }>;
+}
+
+export async function PATCH(req: Request, ctx: RouteContext) {
+ try {
+ const { session, error } = await getAuthSession();
+ if (error) return error;
+
+ const { probeId } = await ctx.params;
+ const body = await req.json();
+ const parsed = updateColorProbeSchema.safeParse(body);
+ if (!parsed.success) {
+ return badRequest(parsed.error.message);
+ }
+
+ const probe = await updateColorProbe(probeId, parsed.data);
+ return NextResponse.json(probe);
+ } catch (err) {
+ return serverError(err);
+ }
+}
+
+export async function DELETE(_req: Request, ctx: RouteContext) {
+ try {
+ const { session, error } = await getAuthSession();
+ if (error) return error;
+
+ const { probeId } = await ctx.params;
+ await deleteColorProbe(probeId);
+ return NextResponse.json({ ok: true });
+ } catch (err) {
+ return serverError(err);
+ }
+}
diff --git a/src/app/api/revisions/[revisionId]/color-probes/route.ts b/src/app/api/revisions/[revisionId]/color-probes/route.ts
new file mode 100644
index 0000000..829971f
--- /dev/null
+++ b/src/app/api/revisions/[revisionId]/color-probes/route.ts
@@ -0,0 +1,64 @@
+import { NextResponse } from "next/server";
+import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils";
+import { prisma } from "@/lib/prisma";
+import { createColorProbeSchema } from "@/lib/validators/color-probe";
+import {
+ listColorProbes,
+ createColorProbe,
+ clearColorProbes,
+} from "@/lib/services/color-probe-service";
+
+interface RouteContext {
+ params: Promise<{ revisionId: string }>;
+}
+
+export async function GET(_req: Request, ctx: RouteContext) {
+ try {
+ const { session, error } = await getAuthSession();
+ if (error) return error;
+
+ const { revisionId } = await ctx.params;
+ const revision = await prisma.revision.findUnique({ where: { id: revisionId } });
+ if (!revision) return notFound("Revision not found");
+
+ const probes = await listColorProbes(revisionId);
+ return NextResponse.json(probes);
+ } catch (err) {
+ return serverError(err);
+ }
+}
+
+export async function POST(req: Request, ctx: RouteContext) {
+ try {
+ const { session, error } = await getAuthSession();
+ if (error) return error;
+
+ const { revisionId } = await ctx.params;
+ const revision = await prisma.revision.findUnique({ where: { id: revisionId } });
+ if (!revision) return notFound("Revision not found");
+
+ const body = await req.json();
+ const parsed = createColorProbeSchema.safeParse(body);
+ if (!parsed.success) {
+ return badRequest(parsed.error.message);
+ }
+
+ const probe = await createColorProbe(revisionId, session!.user.id, parsed.data);
+ return NextResponse.json(probe, { status: 201 });
+ } catch (err) {
+ return serverError(err);
+ }
+}
+
+export async function DELETE(_req: Request, ctx: RouteContext) {
+ try {
+ const { session, error } = await getAuthSession();
+ if (error) return error;
+
+ const { revisionId } = await ctx.params;
+ await clearColorProbes(revisionId);
+ return NextResponse.json({ ok: true });
+ } catch (err) {
+ return serverError(err);
+ }
+}
diff --git a/src/app/api/stages/[stageId]/revisions/route.ts b/src/app/api/stages/[stageId]/revisions/route.ts
index 7cc57af..d983074 100644
--- a/src/app/api/stages/[stageId]/revisions/route.ts
+++ b/src/app/api/stages/[stageId]/revisions/route.ts
@@ -37,7 +37,7 @@ export async function POST(request: Request, { params }: Params) {
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
}
- const revision = await createRevision(stageId, parsed.data);
+ const revision = await createRevision(stageId, parsed.data, session!.user.id);
return NextResponse.json(revision, { status: 201 });
} catch (e) {
return serverError(e);
diff --git a/src/components/review/annotation-layer.tsx b/src/components/review/annotation-layer.tsx
index cad42d7..51f232d 100644
--- a/src/components/review/annotation-layer.tsx
+++ b/src/components/review/annotation-layer.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useMemo, useCallback, useRef } from "react";
+import { useEffect, useMemo, useCallback, useRef, useState } from "react";
import { Send, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -9,6 +9,7 @@ import {
type AnnotationShape,
} from "@/components/review/annotation-renderer";
import { AnnotationTools } from "@/components/review/annotation-tools";
+import { ColorProbeLayer } from "@/components/review/color-probe-layer";
import { useAnnotationState } from "@/hooks/use-annotation-state";
// ── Types ──────────────────────────────────────────────
@@ -25,6 +26,10 @@ export interface AnnotationLayerProps {
readOnly?: boolean;
/** ID of annotation to highlight (when hovering feedback item) */
hoveredAnnotationId?: string | null;
+ /** Working image URL for CMF probe sampling */
+ workingImageUrl?: string | null;
+ /** Reference image URL for CMF probe sampling */
+ referenceImageUrl?: string | null;
}
// ── Component ──────────────────────────────────────────
@@ -40,8 +45,11 @@ export function AnnotationLayer({
imageDimensions,
readOnly = false,
hoveredAnnotationId,
+ workingImageUrl,
+ referenceImageUrl,
}: AnnotationLayerProps) {
const ann = useAnnotationState(revisionId, stageId);
+ const [probesVisible, setProbesVisible] = useState(true);
// Drag-to-move annotations — works in select mode, dragging any annotation
const handleAnnotationDragStart = useCallback(
@@ -141,6 +149,8 @@ export function AnnotationLayer({
onToggleVisibility={() => ann.setVisible((v: boolean) => !v)}
hasSelection={!!ann.selectedId}
onDeleteSelection={ann.handleDeleteSelection}
+ probesVisible={probesVisible}
+ onToggleProbes={() => setProbesVisible((v) => !v)}
/>
@@ -362,6 +372,22 @@ export function AnnotationLayer({
)}
+ {/* ── CMF Color Probe Layer ────────────────────── */}
+ {!readOnly && (
+
+ )}
+
{/* ── Annotation comment popover ────────────────── */}
{!readOnly && ann.pendingAnnotation && (
void;
hasSelection: boolean;
onDeleteSelection: () => void;
+ /** CMF eyedropper probe visibility toggle */
+ probesVisible?: boolean;
+ onToggleProbes?: () => void;
}
const tools: { id: AnnotationTool; icon: typeof Square; label: string; shortcut?: string }[] = [
@@ -69,6 +74,7 @@ const tools: { id: AnnotationTool; icon: typeof Square; label: string; shortcut?
{ id: "freehand", icon: Pencil, label: "Freehand", shortcut: "F" },
{ id: "text", icon: Type, label: "Text label", shortcut: "T" },
{ id: "pin", icon: MapPin, label: "Pin", shortcut: "P" },
+ { id: "eyedropper", icon: Pipette, label: "CMF Eyedropper", shortcut: "D" },
];
export function AnnotationTools({
@@ -84,6 +90,8 @@ export function AnnotationTools({
onToggleVisibility,
hasSelection,
onDeleteSelection,
+ probesVisible,
+ onToggleProbes,
}: AnnotationToolsProps) {
return (
@@ -217,6 +225,31 @@ export function AnnotationTools({
+ {/* CMF probes visibility toggle */}
+ {onToggleProbes && (
+
+
+
+
+
+ {probesVisible ? "Hide CMF probes" : "Show CMF probes"}
+
+
+ )}
+
{/* Delete selection */}
{hasSelection && (
diff --git a/src/components/review/color-probe-layer.tsx b/src/components/review/color-probe-layer.tsx
new file mode 100644
index 0000000..fc395bb
--- /dev/null
+++ b/src/components/review/color-probe-layer.tsx
@@ -0,0 +1,341 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { ColorProbeMarker, ColorProbeGhostMarker } from "@/components/review/color-probe-marker";
+import { ColorProbePanel } from "@/components/review/color-probe-panel";
+import {
+ useColorProbes,
+ useCreateColorProbe,
+ useUpdateColorProbe,
+ useDeleteColorProbe,
+ useClearColorProbes,
+} from "@/hooks/use-color-probes";
+import {
+ rgbToHsb,
+ compareHsb,
+ sampleFromImage,
+ type ProbeResult,
+} from "@/lib/utils/color";
+
+export interface ColorProbeLayerProps {
+ revisionId: string | null;
+ /** Whether the eyedropper tool is active */
+ active: boolean;
+ /** Whether probes are visible */
+ visible: boolean;
+ /** Viewport state */
+ zoom: number;
+ panX: number;
+ panY: number;
+ containerWidth: number;
+ containerHeight: number;
+ /** Working image URL (the current render) */
+ workingImageUrl: string | null;
+ /** Reference image URL */
+ referenceImageUrl: string | null;
+}
+
+const MAX_PROBES = 12;
+
+export function ColorProbeLayer({
+ revisionId,
+ active,
+ visible,
+ zoom,
+ panX,
+ panY,
+ containerWidth,
+ containerHeight,
+ workingImageUrl,
+ referenceImageUrl,
+}: ColorProbeLayerProps) {
+ const { data: probes = [] } = useColorProbes(revisionId);
+ const createProbe = useCreateColorProbe(revisionId);
+ const updateProbe = useUpdateColorProbe(revisionId);
+ const deleteProbe = useDeleteColorProbe(revisionId);
+ const clearProbes = useClearColorProbes(revisionId);
+
+ const [selectedProbeId, setSelectedProbeId] = useState(null);
+
+ // Loaded image refs for sampling
+ const workingImgRef = useRef(null);
+ const referenceImgRef = useRef(null);
+
+ // Load working image
+ useEffect(() => {
+ if (!workingImageUrl) {
+ workingImgRef.current = null;
+ return;
+ }
+ const img = new Image();
+ img.crossOrigin = "anonymous";
+ img.onload = () => { workingImgRef.current = img; };
+ img.src = workingImageUrl;
+ }, [workingImageUrl]);
+
+ // Load reference image
+ useEffect(() => {
+ if (!referenceImageUrl) {
+ referenceImgRef.current = null;
+ return;
+ }
+ const img = new Image();
+ img.crossOrigin = "anonymous";
+ img.onload = () => { referenceImgRef.current = img; };
+ img.src = referenceImageUrl;
+ }, [referenceImageUrl]);
+
+ // Compute probe results by sampling both images
+ const probeResults = useMemo(() => {
+ const results = new Map();
+ const workingImg = workingImgRef.current;
+ const refImg = referenceImgRef.current;
+
+ if (!workingImg || !refImg) return results;
+
+ for (const probe of probes) {
+ const workingSample = sampleFromImage(workingImg, probe.workingX, probe.workingY);
+ const refSample = sampleFromImage(refImg, probe.referenceX, probe.referenceY);
+
+ if (workingSample && refSample) {
+ const workingHsb = rgbToHsb(workingSample);
+ const refHsb = rgbToHsb(refSample);
+ results.set(probe.index, compareHsb(workingHsb, refHsb));
+ }
+ }
+
+ return results;
+ // Re-sample whenever probes or images change
+ }, [probes, workingImageUrl, referenceImageUrl]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Handle click to place a new probe
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ if (!active || !revisionId) return;
+ if (probes.length >= MAX_PROBES) return;
+
+ const svg = e.currentTarget;
+ const rect = svg.getBoundingClientRect();
+ const imgX = (e.clientX - rect.left - panX) / zoom;
+ const imgY = (e.clientY - rect.top - panY) / zoom;
+
+ // Find next available index
+ const usedIndices = new Set(probes.map((p) => p.index));
+ let nextIndex = 1;
+ while (usedIndices.has(nextIndex) && nextIndex <= MAX_PROBES) {
+ nextIndex++;
+ }
+ if (nextIndex > MAX_PROBES) return;
+
+ createProbe.mutate({
+ index: nextIndex,
+ workingX: Math.round(imgX),
+ workingY: Math.round(imgY),
+ referenceX: Math.round(imgX),
+ referenceY: Math.round(imgY),
+ });
+ },
+ [active, revisionId, probes, panX, panY, zoom, createProbe]
+ );
+
+ // Drag handler for working-side probe
+ const handleProbeDrag = useCallback(
+ (probeId: string, e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setSelectedProbeId(probeId);
+
+ const probe = probes.find((p) => p.id === probeId);
+ if (!probe) return;
+
+ let lastX = e.clientX;
+ let lastY = e.clientY;
+
+ const handleMove = (me: MouseEvent) => {
+ const dx = (me.clientX - lastX) / zoom;
+ const dy = (me.clientY - lastY) / zoom;
+ lastX = me.clientX;
+ lastY = me.clientY;
+
+ // We update the offset relative to original — accumulate in a ref is better
+ // but for simplicity, we do a debounced update on mouseup
+ probe.workingX += dx;
+ probe.workingY += dy;
+ // Also move reference if not Alt-dragged
+ if (!me.altKey) {
+ probe.referenceX += dx;
+ probe.referenceY += dy;
+ }
+ };
+
+ const handleUp = () => {
+ window.removeEventListener("mousemove", handleMove);
+ window.removeEventListener("mouseup", handleUp);
+ updateProbe.mutate({
+ probeId,
+ data: {
+ workingX: Math.round(probe.workingX),
+ workingY: Math.round(probe.workingY),
+ referenceX: Math.round(probe.referenceX),
+ referenceY: Math.round(probe.referenceY),
+ },
+ });
+ };
+
+ window.addEventListener("mousemove", handleMove);
+ window.addEventListener("mouseup", handleUp);
+ },
+ [probes, zoom, updateProbe]
+ );
+
+ // Drag handler for reference-side ghost marker (Alt+drag to offset)
+ const handleRefDrag = useCallback(
+ (probeId: string, e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const probe = probes.find((p) => p.id === probeId);
+ if (!probe) return;
+
+ let lastX = e.clientX;
+ let lastY = e.clientY;
+
+ const handleMove = (me: MouseEvent) => {
+ const dx = (me.clientX - lastX) / zoom;
+ const dy = (me.clientY - lastY) / zoom;
+ lastX = me.clientX;
+ lastY = me.clientY;
+ probe.referenceX += dx;
+ probe.referenceY += dy;
+ };
+
+ const handleUp = () => {
+ window.removeEventListener("mousemove", handleMove);
+ window.removeEventListener("mouseup", handleUp);
+ updateProbe.mutate({
+ probeId,
+ data: {
+ referenceX: Math.round(probe.referenceX),
+ referenceY: Math.round(probe.referenceY),
+ },
+ });
+ };
+
+ window.addEventListener("mousemove", handleMove);
+ window.addEventListener("mouseup", handleUp);
+ },
+ [probes, zoom, updateProbe]
+ );
+
+ // Delete probe on right-click
+ const handleContextMenu = useCallback(
+ (probeId: string, e: React.MouseEvent) => {
+ e.preventDefault();
+ deleteProbe.mutate(probeId);
+ },
+ [deleteProbe]
+ );
+
+ // Delete key handler
+ useEffect(() => {
+ if (!selectedProbeId) return;
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Delete" || e.key === "Backspace") {
+ if (
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLTextAreaElement
+ )
+ return;
+ deleteProbe.mutate(selectedProbeId);
+ setSelectedProbeId(null);
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [selectedProbeId, deleteProbe]);
+
+ if (!visible || !revisionId) return null;
+
+ // Check if any reference point is offset from working point
+ const hasOffsets = probes.some(
+ (p) =>
+ Math.abs(p.referenceX - p.workingX) > 1 ||
+ Math.abs(p.referenceY - p.workingY) > 1
+ );
+
+ return (
+ <>
+ {/* SVG overlay for probe markers */}
+
+
+ {/* Summary panel — bottom-left */}
+ {probes.length > 0 && (
+
+ clearProbes.mutate()}
+ isClearing={clearProbes.isPending}
+ />
+
+ )}
+ >
+ );
+}
diff --git a/src/components/review/color-probe-marker.tsx b/src/components/review/color-probe-marker.tsx
new file mode 100644
index 0000000..170a232
--- /dev/null
+++ b/src/components/review/color-probe-marker.tsx
@@ -0,0 +1,284 @@
+"use client";
+
+import { useState } from "react";
+import type { ProbeResult } from "@/lib/utils/color";
+
+interface ColorProbeMarkerProps {
+ index: number;
+ x: number;
+ y: number;
+ result: ProbeResult | null;
+ isSelected?: boolean;
+ onDragStart?: (e: React.MouseEvent) => void;
+}
+
+const STATUS_COLORS = {
+ pass: "#22C55E",
+ warn: "#F97316",
+ fail: "#EF4444",
+ unknown: "#6B7280",
+} as const;
+
+const MARKER_RADIUS = 8;
+
+export function ColorProbeMarker({
+ index,
+ x,
+ y,
+ result,
+ isSelected,
+ onDragStart,
+}: ColorProbeMarkerProps) {
+ const [hovered, setHovered] = useState(false);
+ const status = result?.status ?? "unknown";
+ const color = STATUS_COLORS[status];
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ onMouseDown={onDragStart}
+ >
+ {/* Outer ring */}
+
+ {/* Background circle */}
+
+ {/* Index number */}
+
+ {index}
+
+
+ {/* Status pill — positioned to the right */}
+
+ {/* Compact pill (always visible) */}
+
+
+ {/* Expanded detail panel on hover */}
+ {hovered && result && (
+
+ {/* Panel background */}
+
+ {/* Header */}
+
+ PROBE {index}
+
+
+
+ {status.toUpperCase()}
+
+
+ {/* H row */}
+
+ {/* S row */}
+
+ {/* B row */}
+
+
+ )}
+
+
+ );
+}
+
+function HsbRow({
+ label,
+ working,
+ reference,
+ delta,
+ pass,
+ y,
+}: {
+ label: string;
+ working: string;
+ reference: string;
+ delta: string;
+ pass: boolean;
+ y: number;
+}) {
+ return (
+
+
+ {label}:
+
+
+ {working}
+
+
+ →
+
+
+ {reference}
+
+
+ {delta}
+
+
+ {pass ? "✓" : "✗"}
+
+
+ );
+}
+
+/**
+ * Ghost marker shown on the reference image for an offset probe.
+ */
+export function ColorProbeGhostMarker({
+ index,
+ x,
+ y,
+ onDragStart,
+}: {
+ index: number;
+ x: number;
+ y: number;
+ onDragStart?: (e: React.MouseEvent) => void;
+}) {
+ return (
+
+
+
+ {index}
+
+
+ );
+}
diff --git a/src/components/review/color-probe-panel.tsx b/src/components/review/color-probe-panel.tsx
new file mode 100644
index 0000000..58c553e
--- /dev/null
+++ b/src/components/review/color-probe-panel.tsx
@@ -0,0 +1,175 @@
+"use client";
+
+import { Trash2, Pipette } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import type { ProbeResult } from "@/lib/utils/color";
+
+interface ColorProbePanelProps {
+ probeResults: Map;
+ probeCount: number;
+ onClearAll: () => void;
+ isClearing?: boolean;
+}
+
+const STATUS_COLORS = {
+ pass: "#22C55E",
+ warn: "#F97316",
+ fail: "#EF4444",
+} as const;
+
+export function ColorProbePanel({
+ probeResults,
+ probeCount,
+ onClearAll,
+ isClearing,
+}: ColorProbePanelProps) {
+ if (probeCount === 0) return null;
+
+ const passCount = Array.from(probeResults.values()).filter(
+ (r) => r.status === "pass"
+ ).length;
+ const failCount = Array.from(probeResults.values()).filter(
+ (r) => r.status === "fail"
+ ).length;
+ const warnCount = Array.from(probeResults.values()).filter(
+ (r) => r.status === "warn"
+ ).length;
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+ Clear all probes
+
+
+
+
+ {/* Summary stats */}
+
+
+ {probeCount} of 12
+
+
+ {passCount > 0 && (
+
+ {passCount}
+
+ )}
+ {warnCount > 0 && (
+
+ {warnCount}
+
+ )}
+ {failCount > 0 && (
+
+ {failCount}
+
+ )}
+
+
+
+ {/* Probe rows */}
+
+ {Array.from({ length: probeCount }, (_, i) => i + 1).map((idx) => {
+ const result = probeResults.get(idx);
+ if (!result) return null;
+ const status = result.status;
+ const color = STATUS_COLORS[status];
+
+ return (
+
+ {/* Index */}
+
+ {idx}
+
+
+ {/* Status dot */}
+
+
+ {/* Deltas */}
+
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+function DeltaValue({
+ label,
+ value,
+ unit,
+ pass,
+}: {
+ label: string;
+ value: number;
+ unit: string;
+ pass: boolean;
+}) {
+ return (
+
+ {label}
+ {value.toFixed(1)}
+ {unit}
+
+ );
+}
diff --git a/src/hooks/use-annotation-state.ts b/src/hooks/use-annotation-state.ts
index 869d6f4..52f95ee 100644
--- a/src/hooks/use-annotation-state.ts
+++ b/src/hooks/use-annotation-state.ts
@@ -40,7 +40,7 @@ interface UndoEntry {
input?: CreateAnnotationInput;
}
-const TOOL_TO_TYPE: Record, AnnotationTypeValue> = {
+const TOOL_TO_TYPE: Record, AnnotationTypeValue> = {
rectangle: "RECTANGLE",
ellipse: "ELLIPSE",
arrow: "ARROW",
@@ -230,7 +230,7 @@ export function useAnnotationState(
// Mouse handlers (need zoom/pan passed in)
const handleMouseDown = useCallback(
(e: React.MouseEvent, panX: number, panY: number, zoom: number) => {
- if (activeTool === "select") {
+ if (activeTool === "select" || activeTool === "eyedropper") {
setSelectedId(null);
return;
}
@@ -553,6 +553,7 @@ export function useAnnotationState(
case "f": setActiveTool("freehand"); return;
case "t": setActiveTool("text"); return;
case "p": setActiveTool("pin"); return;
+ case "d": setActiveTool("eyedropper"); return;
case "delete":
case "backspace":
if (selectedId) { e.preventDefault(); handleDeleteSelection(); }
diff --git a/src/hooks/use-color-probes.ts b/src/hooks/use-color-probes.ts
new file mode 100644
index 0000000..ab48c4c
--- /dev/null
+++ b/src/hooks/use-color-probes.ts
@@ -0,0 +1,100 @@
+"use client";
+
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import type { CreateColorProbeInput, UpdateColorProbeInput } from "@/lib/validators/color-probe";
+
+async function fetchJson(url: string, init?: RequestInit): Promise {
+ const res = await fetch(url, init);
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error(body.error ?? `Request failed: ${res.status}`);
+ }
+ return res.json();
+}
+
+export interface ColorProbe {
+ id: string;
+ revisionId: string;
+ index: number;
+ workingX: number;
+ workingY: number;
+ referenceX: number;
+ referenceY: number;
+ createdById: string;
+ createdBy: { id: string; name: string | null };
+ createdAt: string;
+ updatedAt: string;
+}
+
+export function useColorProbes(revisionId: string | null) {
+ return useQuery({
+ queryKey: ["color-probes", revisionId],
+ queryFn: () => fetchJson(`/api/revisions/${revisionId}/color-probes`),
+ enabled: !!revisionId,
+ });
+}
+
+export function useCreateColorProbe(revisionId: string | null) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (input: CreateColorProbeInput) =>
+ fetchJson(`/api/revisions/${revisionId}/color-probes`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
+ },
+ });
+}
+
+export function useUpdateColorProbe(revisionId: string | null) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({
+ probeId,
+ data,
+ }: {
+ probeId: string;
+ data: UpdateColorProbeInput;
+ }) =>
+ fetchJson(
+ `/api/revisions/${revisionId}/color-probes/${probeId}`,
+ {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ }
+ ),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
+ },
+ });
+}
+
+export function useDeleteColorProbe(revisionId: string | null) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (probeId: string) =>
+ fetchJson(`/api/revisions/${revisionId}/color-probes/${probeId}`, {
+ method: "DELETE",
+ }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
+ },
+ });
+}
+
+export function useClearColorProbes(revisionId: string | null) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () =>
+ fetchJson(`/api/revisions/${revisionId}/color-probes`, {
+ method: "DELETE",
+ }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
+ },
+ });
+}
diff --git a/src/lib/services/color-probe-service.ts b/src/lib/services/color-probe-service.ts
new file mode 100644
index 0000000..317efdc
--- /dev/null
+++ b/src/lib/services/color-probe-service.ts
@@ -0,0 +1,105 @@
+import { prisma } from "@/lib/prisma";
+import type { CreateColorProbeInput, UpdateColorProbeInput } from "@/lib/validators/color-probe";
+
+/**
+ * List all color probes for a revision, ordered by index.
+ */
+export async function listColorProbes(revisionId: string) {
+ return prisma.colorProbe.findMany({
+ where: { revisionId },
+ orderBy: { index: "asc" },
+ include: { createdBy: { select: { id: true, name: true } } },
+ });
+}
+
+/**
+ * Create a new color probe on a revision.
+ */
+export async function createColorProbe(
+ revisionId: string,
+ userId: string,
+ input: CreateColorProbeInput
+) {
+ return prisma.colorProbe.create({
+ data: {
+ revisionId,
+ createdById: userId,
+ index: input.index,
+ workingX: input.workingX,
+ workingY: input.workingY,
+ referenceX: input.referenceX,
+ referenceY: input.referenceY,
+ },
+ include: { createdBy: { select: { id: true, name: true } } },
+ });
+}
+
+/**
+ * Update a color probe's position.
+ */
+export async function updateColorProbe(
+ probeId: string,
+ input: UpdateColorProbeInput
+) {
+ return prisma.colorProbe.update({
+ where: { id: probeId },
+ data: {
+ ...(input.workingX !== undefined && { workingX: input.workingX }),
+ ...(input.workingY !== undefined && { workingY: input.workingY }),
+ ...(input.referenceX !== undefined && { referenceX: input.referenceX }),
+ ...(input.referenceY !== undefined && { referenceY: input.referenceY }),
+ },
+ include: { createdBy: { select: { id: true, name: true } } },
+ });
+}
+
+/**
+ * Delete a single color probe.
+ */
+export async function deleteColorProbe(probeId: string) {
+ await prisma.colorProbe.delete({ where: { id: probeId } });
+ return { ok: true };
+}
+
+/**
+ * Delete all color probes for a revision (wipe).
+ */
+export async function clearColorProbes(revisionId: string) {
+ await prisma.colorProbe.deleteMany({ where: { revisionId } });
+ return { ok: true };
+}
+
+/**
+ * Copy probes from one revision to another (for R1 → R2 carry-over).
+ * Keeps the same coordinates and indices.
+ */
+export async function copyProbes(
+ fromRevisionId: string,
+ toRevisionId: string,
+ userId: string
+) {
+ const existing = await prisma.colorProbe.findMany({
+ where: { revisionId: fromRevisionId },
+ orderBy: { index: "asc" },
+ });
+
+ if (existing.length === 0) return [];
+
+ const created = await prisma.$transaction(
+ existing.map((probe) =>
+ prisma.colorProbe.create({
+ data: {
+ revisionId: toRevisionId,
+ createdById: userId,
+ index: probe.index,
+ workingX: probe.workingX,
+ workingY: probe.workingY,
+ referenceX: probe.referenceX,
+ referenceY: probe.referenceY,
+ },
+ })
+ )
+ );
+
+ return created;
+}
diff --git a/src/lib/services/revision-service.ts b/src/lib/services/revision-service.ts
index 3b0303b..9d42d80 100644
--- a/src/lib/services/revision-service.ts
+++ b/src/lib/services/revision-service.ts
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma";
import type { CreateRevisionInput, UpdateRevisionInput } from "@/lib/validators/revision";
import type { RevisionStatus } from "@/generated/prisma/client";
+import { copyProbes } from "@/lib/services/color-probe-service";
/**
* Create a new revision round for a stage.
@@ -8,7 +9,8 @@ import type { RevisionStatus } from "@/generated/prisma/client";
*/
export async function createRevision(
stageId: string,
- data: CreateRevisionInput
+ data: CreateRevisionInput,
+ userId?: string
) {
const lastRevision = await prisma.revision.findFirst({
where: { deliverableStageId: stageId },
@@ -17,7 +19,7 @@ export async function createRevision(
const roundNumber = (lastRevision?.roundNumber ?? 0) + 1;
- return prisma.revision.create({
+ const revision = await prisma.revision.create({
data: {
deliverableStageId: stageId,
roundNumber,
@@ -27,6 +29,17 @@ export async function createRevision(
attachments: data.attachments ?? undefined,
},
});
+
+ // Copy color probes from previous revision (non-blocking)
+ if (lastRevision && userId) {
+ try {
+ await copyProbes(lastRevision.id, revision.id, userId);
+ } catch {
+ // Non-critical: probes are a convenience, not a requirement
+ }
+ }
+
+ return revision;
}
/**
diff --git a/src/lib/utils/color.ts b/src/lib/utils/color.ts
new file mode 100644
index 0000000..bf27fa3
--- /dev/null
+++ b/src/lib/utils/color.ts
@@ -0,0 +1,209 @@
+/**
+ * Color utilities for CMF eyedropper probes.
+ * RGB ↔ HSB conversion, 5×5 pixel sampling, and delta calculation.
+ */
+
+export interface HSB {
+ h: number; // 0-360 degrees
+ s: number; // 0-100 percent
+ b: number; // 0-100 percent
+}
+
+export interface RGB {
+ r: number; // 0-255
+ g: number; // 0-255
+ b: number; // 0-255
+}
+
+export interface ProbeResult {
+ working: HSB;
+ reference: HSB;
+ delta: { h: number; s: number; b: number };
+ status: "pass" | "warn" | "fail";
+}
+
+// CMF tolerances
+const TOLERANCE = { h: 4, s: 3, b: 3 };
+// Warn when within 1.5× tolerance
+const WARN_MULTIPLIER = 1.5;
+
+/**
+ * Convert RGB to HSB (same as Photoshop's HSB).
+ */
+export function rgbToHsb(rgb: RGB): HSB {
+ const r = rgb.r / 255;
+ const g = rgb.g / 255;
+ const b = rgb.b / 255;
+
+ const max = Math.max(r, g, b);
+ const min = Math.min(r, g, b);
+ const delta = max - min;
+
+ // Brightness
+ const brightness = max * 100;
+
+ // Saturation
+ const saturation = max === 0 ? 0 : (delta / max) * 100;
+
+ // Hue
+ let hue = 0;
+ if (delta !== 0) {
+ if (max === r) {
+ hue = ((g - b) / delta) % 6;
+ } else if (max === g) {
+ hue = (b - r) / delta + 2;
+ } else {
+ hue = (r - g) / delta + 4;
+ }
+ hue *= 60;
+ if (hue < 0) hue += 360;
+ }
+
+ return {
+ h: Math.round(hue * 10) / 10,
+ s: Math.round(saturation * 10) / 10,
+ b: Math.round(brightness * 10) / 10,
+ };
+}
+
+/**
+ * Calculate hue delta accounting for circular wraparound.
+ * e.g., 358° vs 2° = Δ4°, not Δ356°.
+ */
+export function hueDelta(h1: number, h2: number): number {
+ const diff = Math.abs(h1 - h2);
+ return Math.min(diff, 360 - diff);
+}
+
+/**
+ * Compare two HSB values and return status.
+ */
+export function compareHsb(working: HSB, reference: HSB): ProbeResult {
+ const delta = {
+ h: hueDelta(working.h, reference.h),
+ s: Math.abs(working.s - reference.s),
+ b: Math.abs(working.b - reference.b),
+ };
+
+ const hFail = delta.h > TOLERANCE.h;
+ const sFail = delta.s > TOLERANCE.s;
+ const bFail = delta.b > TOLERANCE.b;
+
+ const hWarn = delta.h > TOLERANCE.h / WARN_MULTIPLIER;
+ const sWarn = delta.s > TOLERANCE.s / WARN_MULTIPLIER;
+ const bWarn = delta.b > TOLERANCE.b / WARN_MULTIPLIER;
+
+ let status: ProbeResult["status"] = "pass";
+ if (hFail || sFail || bFail) {
+ status = "fail";
+ } else if (hWarn || sWarn || bWarn) {
+ status = "warn";
+ }
+
+ return { working, reference, delta, status };
+}
+
+/**
+ * Sample a 5×5 pixel area from a canvas, averaging RGB values.
+ * Coordinates are in image space.
+ */
+export function samplePixels(
+ canvas: HTMLCanvasElement,
+ imageX: number,
+ imageY: number,
+ zoom: number,
+ panX: number,
+ panY: number
+): RGB | null {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return null;
+
+ const dpr = window.devicePixelRatio || 1;
+
+ // Sample 5×5 grid centered on the point
+ let totalR = 0;
+ let totalG = 0;
+ let totalB = 0;
+ let count = 0;
+
+ for (let dy = -2; dy <= 2; dy++) {
+ for (let dx = -2; dx <= 2; dx++) {
+ const imgPx = imageX + dx;
+ const imgPy = imageY + dy;
+
+ // Convert image coords to canvas pixel coords
+ const screenX = (panX + imgPx * zoom) * dpr;
+ const screenY = (panY + imgPy * zoom) * dpr;
+
+ // Bounds check
+ if (screenX < 0 || screenY < 0 || screenX >= canvas.width || screenY >= canvas.height) {
+ continue;
+ }
+
+ const pixel = ctx.getImageData(Math.round(screenX), Math.round(screenY), 1, 1).data;
+ totalR += pixel[0];
+ totalG += pixel[1];
+ totalB += pixel[2];
+ count++;
+ }
+ }
+
+ if (count === 0) return null;
+
+ return {
+ r: Math.round(totalR / count),
+ g: Math.round(totalG / count),
+ b: Math.round(totalB / count),
+ };
+}
+
+/**
+ * Sample pixels from an image URL by drawing to an offscreen canvas.
+ * Used when we don't have a visible canvas (e.g., reference image in comparison modes).
+ */
+export function sampleFromImage(
+ image: HTMLImageElement,
+ imageX: number,
+ imageY: number
+): RGB | null {
+ const canvas = document.createElement("canvas");
+ canvas.width = image.naturalWidth;
+ canvas.height = image.naturalHeight;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return null;
+
+ // Draw white background then image (matches main viewer behavior)
+ ctx.fillStyle = "#ffffff";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(image, 0, 0);
+
+ let totalR = 0;
+ let totalG = 0;
+ let totalB = 0;
+ let count = 0;
+
+ for (let dy = -2; dy <= 2; dy++) {
+ for (let dx = -2; dx <= 2; dx++) {
+ const px = Math.round(imageX + dx);
+ const py = Math.round(imageY + dy);
+
+ if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) {
+ continue;
+ }
+
+ const pixel = ctx.getImageData(px, py, 1, 1).data;
+ totalR += pixel[0];
+ totalG += pixel[1];
+ totalB += pixel[2];
+ count++;
+ }
+ }
+
+ if (count === 0) return null;
+
+ return {
+ r: Math.round(totalR / count),
+ g: Math.round(totalG / count),
+ b: Math.round(totalB / count),
+ };
+}
diff --git a/src/lib/validators/color-probe.ts b/src/lib/validators/color-probe.ts
new file mode 100644
index 0000000..a17be76
--- /dev/null
+++ b/src/lib/validators/color-probe.ts
@@ -0,0 +1,20 @@
+import { z } from "zod/v4";
+
+export const createColorProbeSchema = z.object({
+ index: z.number().int().min(1).max(12),
+ workingX: z.number(),
+ workingY: z.number(),
+ referenceX: z.number(),
+ referenceY: z.number(),
+});
+
+export type CreateColorProbeInput = z.infer;
+
+export const updateColorProbeSchema = z.object({
+ workingX: z.number().optional(),
+ workingY: z.number().optional(),
+ referenceX: z.number().optional(),
+ referenceY: z.number().optional(),
+});
+
+export type UpdateColorProbeInput = z.infer;