diff --git a/dashboard/AGENTS.md b/dashboard/AGENTS.md
index 58df3c2..a330c68 100644
--- a/dashboard/AGENTS.md
+++ b/dashboard/AGENTS.md
@@ -24,3 +24,4 @@ Area gotchas that are still true. Claude Code loads this file (through `CLAUDE.m
- **The coast Water ETA is built on the device** (`coastWaterEta.ts`, `useCoastWaterEta.ts`): the home's ground comes from the pack's elevation tile (`groundHeight.ts`), the rate from the nearest Registered Node's levels kept by Event time, and the Region's `storm_surge` summary (`rising_window_s`, `flood_prone_below_cm`) from `/api/state`. A flow test stubs `createImageBitmap` and `OffscreenCanvas` to give a tile pixel; ground above `flood_prone_below_cm` (9 ft at Fort Myers) has no ETA, so pick test ground below it.
- **Vitest runs under Node 22 in CI; test locally with it** (`npx --yes node@22 node_modules/vitest/vitest.mjs run`). A fetch stub must not build a `Response` from a jsdom `Blob` (`object.stream is not a function` on Node 22); give it a string body. A flow test mutes `console.error`, so print it when a test fails without a clue.
- **A Measurement kind is data, not a property name.** `kindLabel` and `measurementInDisplayUnit` look kinds up with `Object.hasOwn`, so `constructor` and `__proto__` read as their own names; only a known kind in its wire unit is converted (`displayUnits.ts`, `CONVERSIONS`), anything else is shown as sent. `camera` has no trend arrow (`readingTrends.ts`). A test needs `JSON.parse` to make an own `__proto__` key.
+- **A Camera line opens its picture through an `` whose URL carries the picture number** (`CameraLine.tsx`), so the browser refetches when it changes; the Sandbox serves any Sensor's, anywhere else only this Node's own `/api/camera/picture`.
diff --git a/dashboard/src/App.css b/dashboard/src/App.css
index c39e42e..05d3463 100644
--- a/dashboard/src/App.css
+++ b/dashboard/src/App.css
@@ -1741,6 +1741,40 @@ section p.flood-prone-caption {
overflow-wrap: anywhere;
}
+.camera-toggle {
+ min-height: 2.5rem;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--accent);
+ font: inherit;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.camera-picture {
+ margin: 0.3rem 0 0.2rem;
+}
+
+.camera-picture img {
+ display: block;
+ width: 100%;
+ height: auto;
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+}
+
+.camera-picture figcaption {
+ margin-top: 0.3rem;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+.camera-picture .tag {
+ margin-left: 0;
+ margin-right: 0.3rem;
+}
+
.slot-value.tick {
animation: slot-flash 600ms ease-out 1;
}
diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx
index 2bd8b53..2f424c2 100644
--- a/dashboard/src/App.tsx
+++ b/dashboard/src/App.tsx
@@ -265,6 +265,7 @@ export function App() {
rises={rises}
trends={trends}
panel={panel("readings")}
+ inSandbox={sandbox !== null}
/>
);
const sandboxPanel = sandbox && (
diff --git a/dashboard/src/CameraLine.tsx b/dashboard/src/CameraLine.tsx
new file mode 100644
index 0000000..b257552
--- /dev/null
+++ b/dashboard/src/CameraLine.tsx
@@ -0,0 +1,51 @@
+import { useState } from "react";
+
+import { kindLabel } from "./ReadingsPanel";
+import { ReadingSlot } from "./ReadingSlot";
+
+type Props = {
+ // The Camera's picture number; the picture is fetched again whenever it changes.
+ pictureNumber: number;
+ shown: string;
+ // Where the newest picture is, or null when this page may not show it.
+ pictureUrl: string | null;
+};
+
+// A Camera's row. Where the page may show its picture, the line opens it: the Sandbox's Hub passes
+// on any Sensor's picture, and outside the Sandbox only this Node's own Camera is reachable.
+export function CameraLine({ pictureNumber, shown, pictureUrl }: Props) {
+ const [open, setOpen] = useState(false);
+ const label = `${kindLabel("camera")}: ${shown}`;
+ return (
+ <>
+
+ {pictureUrl ? (
+
+ ) : (
+ label
+ )}
+
+ {pictureUrl && open && (
+
+ {/* An , never inline markup: a script inside the SVG cannot run. */}
+
+
+ SIMULATED Stays on this Camera; it does not
+ cross the Mesh
+
+
+ )}
+ >
+ );
+}
diff --git a/dashboard/src/ReadingsPanel.tsx b/dashboard/src/ReadingsPanel.tsx
index c7632c0..dc181a3 100644
--- a/dashboard/src/ReadingsPanel.tsx
+++ b/dashboard/src/ReadingsPanel.tsx
@@ -1,5 +1,6 @@
import { Fragment } from "react";
+import { CameraLine } from "./CameraLine";
import { FloodRiskCard } from "./FloodRiskPanel";
import { NodeTags } from "./NodeTags";
import { Panel, type PanelControl } from "./Panel";
@@ -49,6 +50,8 @@ type Props = {
trends: Record;
onSelect: (id: string) => void;
panel: PanelControl;
+ // True in the Sandbox, whose Hub passes on every Sensor's Camera picture.
+ inSandbox: boolean;
};
export function ReadingsPanel({
@@ -63,6 +66,7 @@ export function ReadingsPanel({
trends,
onSelect,
panel,
+ inSandbox,
}: Props) {
const reducedMotion = usePrefersReducedMotion();
@@ -70,6 +74,15 @@ export function ReadingsPanel({
const firstNotCountedId = offRiver.find((node) => !node.registered && !node.isThisNode)?.id;
const waveAfter = rungBeforeWave(rungs, waveKm);
+ // A picture never crosses the Mesh: the Sandbox's Hub has them all, anywhere else this page can
+ // reach only the Camera of the Node it is connected to.
+ const pictureUrlOf = (node: NodeOnMap) =>
+ inSandbox
+ ? `/api/sandbox/sensors/${encodeURIComponent(node.id)}/picture`
+ : node.isThisNode
+ ? "/api/camera/picture"
+ : null;
+
const card = (
node: NodeOnMap,
{ hero, liveSensors }: { hero: boolean; liveSensors?: boolean },
@@ -112,12 +125,20 @@ export function ReadingsPanel({
.filter((measurement) => !(hero && measurement.kind === "water_level"))
.map((measurement) => (