diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index f90bfd7..51233a2 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -1185,3 +1185,7 @@ {"id":"evt-jyahmdef91ar","type":"node.status_changed","subject":"iss-jz3431t","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-20T11:39:22.694Z","parents":["evt-kzzwz3e5jc0h"],"hash":"3d87103db08734a822a9083195041bbca6305e086471b992149f1c11adfb8418"} {"id":"evt-3sec7pkrge2y","type":"node.created","subject":"mea-4c17z3m","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"Interface simplification: 1 of 5 issues done","body":"Counted from vrek on 2026-09-20: the shared disclosure component and its rules are done (iss-jz3431t), which unblocks the four screens. Left: the dashboard, the camera page, the recordings page, and what a new install sees first.","status":"recorded","owner":null,"attrs":{"value":1,"applies_at":"2026-09-20"}},"at":"2026-09-20T11:39:23.964Z","parents":["evt-jyahmdef91ar"],"hash":"b5bff23790e8e894ef886795f96687eb1155dbaa70bccbf1b00c4d8a0cd35dab"} {"id":"evt-k39g60g2v64h","type":"edge.added","subject":"mea-4c17z3m","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-4c17z3m","to":"gol-6x4ctm2"},"at":"2026-09-20T11:39:23.975Z","parents":["evt-3sec7pkrge2y"],"hash":"ebbc6e8b6e4908ffd94cdc58e10ea239ed4c4bdc0c4f8dafef4b017250bc673e"} +{"id":"evt-xejefx0mvvv4","type":"node.created","subject":"ver-c6d8r7n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"A card leads with the picture, name, status and Pop out, with the address, device line, streams, location, links and profile table behind Details; scanning is one button with its options folded away and summarised; anything needing attention is never hidden","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-20T11:41:37.164Z","parents":["evt-k39g60g2v64h"],"hash":"d6db65efbf637e7fee67e7339d9eba9953795de8b7d793068a5a23e4dc1edaf9"} +{"id":"evt-stnszeba14yw","type":"edge.added","subject":"ver-c6d8r7n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-c6d8r7n","to":"iss-t9twsxg"},"at":"2026-09-20T11:41:37.165Z","parents":["evt-xejefx0mvvv4"],"hash":"b16063efec294f51fc6e3b33a272edf8449798ac0335bbad223c055afb60e716"} +{"id":"evt-t0r3gbcnybbv","type":"verification.recorded","subject":"ver-c6d8r7n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"camera-card.tsx and camera-scanner.tsx now use the shared Disclosure (dec-0kchta3). Tests in camera-card.test.tsx (\"showing less by default\": what sits outside the details, what sits inside, and that a camera needing setup is not inside any details) and camera-scanner.test.tsx (options closed, summarised as \"5s, sweep\", both controls inside, the button outside). Closed is not hidden, so the existing assertions about that content still hold, which is the point. Full suite 967 tests pass, 99.66% lines; tsc, eslint and next build clean. Not yet seen in a browser."},"at":"2026-09-20T11:41:37.166Z","parents":["evt-stnszeba14yw"],"hash":"863c4152bfd102b11c1dc638ce574d96263239839122235c558afc5b0cf0d179"} +{"id":"evt-7fjdxzernfba","type":"node.status_changed","subject":"iss-t9twsxg","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-20T11:41:38.231Z","parents":["evt-t0r3gbcnybbv"],"hash":"f22b614fb2b007d7e7391e3ba7d3d66318ad3f8a551d23d388c6cad2a2b0aef9"} diff --git a/src/app/camera-card.test.tsx b/src/app/camera-card.test.tsx index c1ee034..bfcce5a 100644 --- a/src/app/camera-card.test.tsx +++ b/src/app/camera-card.test.tsx @@ -55,6 +55,45 @@ describe("CameraCard", () => { ); }); + describe("showing less by default (iss-t9twsxg)", () => { + /** What a closed
holds: present for search and screen readers, not on show. */ + const detail = () => document.querySelector("details")!; + + it("leads with the picture, the name, the status and Pop out", async () => { + stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame }); + renderWithQuery(); + await screen.findByText("Hikvision DS-2CD · fw V5.7"); + + const outside = detail().parentElement!.textContent!.replace(detail().textContent!, ""); + expect(outside).toContain("Porch"); + expect(outside).toContain("Pop out"); + expect(outside).not.toContain("192.168.1.10:8080"); + }); + + it("keeps the address, streams, location, links and profiles for when they're asked for", async () => { + stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame }); + renderWithQuery(); + await screen.findByText("Hikvision DS-2CD · fw V5.7"); + + expect(detail().open).toBe(false); + const inside = detail().textContent!; + for (const text of ["192.168.1.10:8080", "Front door", "Web page", "Change login", "1920×1080"]) { + expect(inside).toContain(text); + } + // Closed still says something useful. + expect(screen.getByText("Hikvision DS-2CD · fw V5.7")).toBeTruthy(); + }); + + it("never hides a camera that needs attention", async () => { + stubFetch({ + [`GET ${base}/info`]: () => json({ error: "not activated", code: "inactive" }, 409), + }); + renderWithQuery(); + const panel = await screen.findByText("Setup required: camera not activated"); + expect(panel.closest("details")).toBeNull(); + }); + }); + describe("pop-out (iss-mt5zv64)", () => { const settle = () => new Promise((r) => setTimeout(r, 30)); diff --git a/src/app/camera-card.tsx b/src/app/camera-card.tsx index b999fd0..fc9c30c 100644 --- a/src/app/camera-card.tsx +++ b/src/app/camera-card.tsx @@ -25,6 +25,7 @@ import { type CameraProblem, } from "./camera-queries"; import { cameraStatus, StatusBadge } from "./camera-status"; +import Disclosure from "./disclosure"; import { deviceLine, streamSummary } from "./camera-summary"; import FrameImage from "./frame-image"; import { openPopout, popoutUrl, popoutWindowName, watchPopout } from "./popout"; @@ -286,21 +287,27 @@ export default function CameraCard({ poppedOut={poppedOut} /> -
-
- - {cam.name ?? info.data?.model ?? "Unnamed camera"} - +
+ + {cam.name ?? info.data?.model ?? "Unnamed camera"} + + + -
-
- + +
+ + {/* Everything below is detail: useful when asked for, noise on a wall of cameras. */} + +
+ {cam.host}:{cam.port} - - + {summary && {summary}} + {cam.location && {cam.location}} + Web page {!showLogin && !showSetup && (
- {device &&
{device}
} - {summary &&
{summary}
} - {cam.location && ( -
{cam.location}
- )} -
+ {showSetup && } @@ -357,28 +381,6 @@ export default function CameraCard({
)} - {info.data && info.data.profiles.length > 0 && ( - - - - - - - - - - - {info.data.profiles.map((p) => ( - - - - - - - ))} - -
ProfileCodecResolutionFPS
{p.name ?? p.token}{p.encoding ?? "?"}{p.width && p.height ? `${p.width}×${p.height}` : "?"}{p.fps ?? "?"}
- )} ); } diff --git a/src/app/camera-scanner.test.tsx b/src/app/camera-scanner.test.tsx index 4c58baf..84bbddf 100644 --- a/src/app/camera-scanner.test.tsx +++ b/src/app/camera-scanner.test.tsx @@ -35,6 +35,20 @@ describe("CameraScanner", () => { expect(await screen.findByText("Found 1 camera in 1s.")).toBeTruthy(); }); + it("is one button, with the options folded away and summarised", () => { + stubFetch({ "POST /api/discover": () => new Promise(() => {}) }); + renderWithQuery(); + + const options = document.querySelector("details")!; + expect(options.open).toBe(false); + expect(options.textContent).toContain("Options"); + expect(options.textContent).toContain("5s, sweep"); // the defaults, said plainly + expect(screen.getByLabelText(/Timeout/).closest("details")).toBe(options); + expect(screen.getByLabelText("Unicast sweep of local subnet").closest("details")).toBe(options); + // The button itself is not inside the options. + expect(screen.getByRole("button", { name: /Scan network/ }).closest("details")).toBeNull(); + }); + it("says what it is doing while it runs, and while the list updates", async () => { let finish!: (res: Response) => void; stubFetch({ "POST /api/discover": () => new Promise((r) => (finish = r)) }); diff --git a/src/app/camera-scanner.tsx b/src/app/camera-scanner.tsx index 6d582dc..d90645d 100644 --- a/src/app/camera-scanner.tsx +++ b/src/app/camera-scanner.tsx @@ -4,6 +4,7 @@ import { CircleAlert, LoaderCircle, Radar } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState, useTransition } from "react"; import { useScanNetwork, type ScanReport } from "./camera-queries"; +import Disclosure from "./disclosure"; /** * Runs a network scan. The scan writes the server-side registry, so afterwards the @@ -76,28 +77,33 @@ export default function CameraScanner() { )} {scan.isPending ? `Scanning… ${elapsed}s of ${timeoutSecs}s` : refreshing ? "Updating the list…" : "Scan network"} - - + {/* The defaults suit nearly every scan, so the knobs stay out of the way. */} + +
+ + +
+
{scan.isPending && (