diff --git a/frontend/src/components/simulator/ComponentCameraToggles.tsx b/frontend/src/components/simulator/ComponentCameraToggles.tsx
new file mode 100644
index 00000000..cba9f32e
--- /dev/null
+++ b/frontend/src/components/simulator/ComponentCameraToggles.tsx
@@ -0,0 +1,67 @@
+/**
+ * Header toggles for component-owned webcams (see componentCameraRegistry).
+ *
+ * One button per registered component camera, drawn like CameraToggle so the
+ * header speaks one language: green while the webcam feeds the component,
+ * red with the reason in the tooltip when it could not, grey when off.
+ * Renders nothing for the common project with no such component.
+ */
+import React from 'react';
+import { useComponentCameras } from '../../lib/componentCameraRegistry';
+import type { ComponentCameraEntry } from '../../lib/componentCameraRegistry';
+
+const Toggle: React.FC<{ cam: ComponentCameraEntry }> = ({ cam }) => {
+ const isOn = cam.status === 'live';
+ const color = isOn ? '#3fb950' : cam.status === 'error' ? '#f85149' : '#ccc';
+ const tooltip =
+ cam.status === 'live'
+ ? `${cam.label}: your webcam is on — click to stop`
+ : cam.status === 'error'
+ ? `${cam.label}: ${cam.reason ?? 'camera failed'} — click to retry`
+ : `${cam.label}: click to use your webcam`;
+ return (
+
+ );
+};
+
+export const ComponentCameraToggles: React.FC = () => {
+ const cams = useComponentCameras();
+ if (cams.length === 0) return null;
+ return (
+ <>
+ {cams.map(([id, cam]) => (
+
+ ))}
+ >
+ );
+};
diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx
index c7fd0a5b..60f0ab0b 100644
--- a/frontend/src/components/simulator/SimulatorCanvas.tsx
+++ b/frontend/src/components/simulator/SimulatorCanvas.tsx
@@ -21,6 +21,7 @@ import { InstrumentComponent } from '../components-instruments/InstrumentCompone
import { ComponentRegistry } from '../../services/ComponentRegistry';
import { getTabSessionId } from '../../simulation/Esp32Bridge';
import { CameraToggle } from './CameraToggle';
+import { ComponentCameraToggles } from './ComponentCameraToggles';
import { MicrophoneToggle } from './MicrophoneToggle';
import { WireLayer } from './WireLayer';
import type { SegmentHandle, WaypointHandle, AlignmentGuide } from './WireLayer';
@@ -2718,6 +2719,11 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
/>
)}
+ {/* Header toggles for COMPONENT-owned webcams (vision sensors
+ that capture the webcam themselves — see
+ componentCameraRegistry). Renders nothing when none. */}
+
+
{/* Microphone stream toggle, for boards whose def declares an
on-board mic (their bridge implements setMicrophoneSource).
Off = the bridge's 440 Hz test tone keeps mic sketches
diff --git a/frontend/src/lib/componentCameraRegistry.ts b/frontend/src/lib/componentCameraRegistry.ts
new file mode 100644
index 00000000..47bd3182
--- /dev/null
+++ b/frontend/src/lib/componentCameraRegistry.ts
@@ -0,0 +1,60 @@
+/**
+ * Header indicator for components that own a webcam.
+ *
+ * Boards with a camera get the header CameraToggle, but that path is
+ * board-plumbing (frames into the guest's DVP). Some COMPONENTS are cameras
+ * too — vision sensors whose element captures the webcam itself. Without a
+ * header presence their camera state lives only in the tiny art on the
+ * canvas, and a user who has learned "the header icon tells me when my
+ * webcam is in use" reads its absence as "the camera is not working".
+ *
+ * A component that runs a webcam registers here on attach and keeps its
+ * entry updated; the header renders one toggle per entry, styled like
+ * CameraToggle (green live, red failed with the reason, grey off). The
+ * registry is a seam: OSS renders whatever registered, overlays register.
+ */
+import { useSyncExternalStore } from 'react';
+
+export interface ComponentCameraEntry {
+ /** Short button label, e.g. "Gesture cam". */
+ label: string;
+ status: 'off' | 'live' | 'error';
+ /** Why the camera is not running, shown in the tooltip when status=error. */
+ reason?: string;
+ /** Click handler: switch the component's camera on/off (or retry). */
+ toggle: () => void;
+}
+
+const entries = new Map();
+const listeners = new Set<() => void>();
+// useSyncExternalStore wants a STABLE snapshot between changes.
+let snapshot: Array<[string, ComponentCameraEntry]> = [];
+
+function emit(): void {
+ snapshot = Array.from(entries.entries());
+ listeners.forEach((l) => l());
+}
+
+/** Add or update a component's camera entry (keyed by component id). */
+export function setComponentCamera(id: string, entry: ComponentCameraEntry): void {
+ entries.set(id, entry);
+ emit();
+}
+
+export function removeComponentCamera(id: string): void {
+ if (entries.delete(id)) emit();
+}
+
+function subscribe(l: () => void): () => void {
+ listeners.add(l);
+ return () => listeners.delete(l);
+}
+
+function getSnapshot(): Array<[string, ComponentCameraEntry]> {
+ return snapshot;
+}
+
+/** All registered component cameras, newest last. Empty for most projects. */
+export function useComponentCameras(): Array<[string, ComponentCameraEntry]> {
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+}