Add Lucide icons: camera status badge, actions and warnings

Adds lucide-react (vrek dec-1vnz5fw, chosen over Heroicons and React
Icons) for the icons goal gol-m8je1wc.

- Camera status badge on each card (iss-scj42jq): Live, Connecting,
  Needs login, Not activated, Offline, Session ended. Each is an icon
  plus a visible label, derived from existing query state with no
  extra device calls. Logic in src/app/camera-status.tsx.
- Action icons (iss-chrdd0v): scan (radar, then spinner), refresh
  rate, change or forget login, retry, the camera web page link
  (replaces ↗), save, sign in and sign out.
- Warning and error icons (iss-egy082x): unsecured banner, setup
  panel, inline errors, session-ended notice, login and setup
  headings.

Icons are decorative (aria-hidden), spinners respect reduced motion,
and every accessible name is unchanged (all 403 prior tests pass
untouched). 416 tests, 99.9% line coverage. The vitest threshold
comment now points to principle pri-be2smzk. Refreshes the vrek
export.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-19 12:33:16 -05:00
parent 18a19fdf81
commit 190191df92
16 changed files with 554 additions and 27 deletions

View File

@ -360,3 +360,60 @@
{"id":"evt-3j6gy3qctp2f","type":"edge.added","subject":"ver-86zabcp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-86zabcp","to":"iss-r5vrjx7"},"at":"2026-09-19T16:56:11.242Z","parents":["evt-z4k3aqs5whz1"],"hash":"c292ee79097215281246b9eb8f6e111ffdfadb0ed2e30764244f9ba1da799923"}
{"id":"evt-qv7r0x32bhrq","type":"verification.recorded","subject":"ver-86zabcp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Manual test by the user (Michael Mainguy) on 2026-09-19, who went through the full flow and reported that everything works correctly. This resolves the earlier pending check ver-qz5sev9."},"at":"2026-09-19T16:56:11.243Z","parents":["evt-3j6gy3qctp2f"],"hash":"bcceb6a19d3adc70e59f72bb44ca0ee76d8f0d79f197018ce0f53ed841c5a122"}
{"id":"evt-h5c9jc9hak9q","type":"node.status_changed","subject":"iss-r5vrjx7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T16:56:12.460Z","parents":["evt-qv7r0x32bhrq"],"hash":"8c7c6c6e0a55282116ad4421d2f7491da248227185abea89a815e4c147cbfea9"}
{"id":"evt-cbmv07cqwtwp","type":"node.created","subject":"pri-be2smzk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"principle","title":"Keep automated line coverage at or above 95%, and ship every change with its tests","body":"`npm run coverage` must pass: vitest.config.mts sets coverage.thresholds.lines = 95 over src/**. Excluded: tests, *.d.ts, and the async Server Component src/app/page.tsx, which unit runners can't render (Next docs, 02-guides/testing/vitest.md). New or changed code comes with tests in the same change, so coverage never dips and gets repaid later. Security properties are pinned by tests, not by manual checks: no secrets or upstream error text in responses, input validation, private-IP-only camera targets, and auth on every route. Tests never touch the real network or cameras (pri-e14bahk). Where something genuinely can't be unit-tested, such as a real browser Server Action round trip, record a pending verification in vrek and get it checked by hand rather than skipping it. Why: the app holds admin logins for every camera on the network and is headed toward a third-party API (gol-sjabnh3), so regressions must be caught automatically. Origin: formerly goal gol-9zxah3p, met on 2026-09-19 at 99.8% (retired); the user asked to keep it as a standing rule. Rules out: merging untested code, lowering the threshold to get a change through, and tests that pass only because of what's on the developer's machine.","status":"active","owner":"prn-q80g8mz","attrs":{},"weight":null},"at":"2026-09-19T17:12:32.802Z","parents":["evt-h5c9jc9hak9q"],"hash":"94146a47643f63ba092c212e5500915638169745f126befbab4944c1413ca8df"}
{"id":"evt-5r63syak8qcb","type":"edge.added","subject":"pri-be2smzk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-be2smzk","to":"gol-6q1q5mr"},"at":"2026-09-19T17:12:32.806Z","parents":["evt-cbmv07cqwtwp"],"hash":"991197bd763ab9f0698bac15915ed1667d3319836529e96e275afedb8ef6167a"}
{"id":"evt-qbrfjfjn7zj3","type":"edge.added","subject":"pri-be2smzk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-be2smzk","to":"gol-146a3cb"},"at":"2026-09-19T17:12:32.807Z","parents":["evt-5r63syak8qcb"],"hash":"c05d7ae3fd03f67bc7ebc1f79678212a5b368663ced53ffaf819e08be306bd70"}
{"id":"evt-h30p9pk188sa","type":"edge.added","subject":"pri-be2smzk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-be2smzk","to":"gol-sjabnh3"},"at":"2026-09-19T17:12:32.808Z","parents":["evt-qbrfjfjn7zj3"],"hash":"10653662eef7815940253e652978d7730cd07085a2aa22c926ad2db354fbc1df"}
{"id":"evt-kcr6skmhvt7z","type":"edge.added","subject":"pri-be2smzk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-be2smzk","to":"gol-wqf95dq"},"at":"2026-09-19T17:12:32.809Z","parents":["evt-h30p9pk188sa"],"hash":"78723618b64324e5669712afe7684d8bdcaff4b34ababa5793154085e0e42082"}
{"id":"evt-wjm17pkv4g76","type":"node.status_changed","subject":"gol-9zxah3p","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"active","to":"retired"},"at":"2026-09-19T17:12:33.983Z","parents":["evt-kcr6skmhvt7z"],"hash":"679a458798391a3d160472493901cb255c922bc46ce9fdc7b64936dd899357bf"}
{"id":"evt-g90ppyyh82e9","type":"node.created","subject":"gol-m8je1wc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"goal","title":"Informative icons throughout the dashboard","body":"Use icons to make state and actions recognizable at a glance: camera online/offline/needs-login/not-activated status, actions (scan, refresh, change login, sign out, retry), and warnings (unsecured banner, errors). Icons supplement text labels rather than replace them, stay accessible (decorative icons aria-hidden; icon-only buttons get an aria-label), and match Tailwind sizing and colors in light and dark mode. Use an established icon library rather than hand-drawn SVGs (user preference, 2026-09-19); which library is decided through a dependency question per pri-mz2jxpb.","status":"active","owner":null,"attrs":{},"weight":null,"target":null,"direction":"up","unit":null},"at":"2026-09-19T17:20:55.493Z","parents":["evt-wjm17pkv4g76"],"hash":"578f03fecd8025f62b1e62d619d777dc79166ebe2994a3dc1845435bda2487a9"}
{"id":"evt-yv7jfyphnqjg","type":"edge.added","subject":"gol-m8je1wc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"gol-m8je1wc","to":"gol-6q1q5mr"},"at":"2026-09-19T17:20:55.495Z","parents":["evt-g90ppyyh82e9"],"hash":"e09e14f4e7cbe51e9edf4043cf731b7712316a9fb0fd398f75511154a166e55c"}
{"id":"evt-pzjjv5eczc9q","type":"edge.added","subject":"gol-m8je1wc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"gol-m8je1wc","to":"gol-146a3cb"},"at":"2026-09-19T17:20:55.496Z","parents":["evt-yv7jfyphnqjg"],"hash":"6d4d4c4df9385316ccba3ba62ea7c01f7d9a25670cd76ed45ff34762dbb4230c"}
{"id":"evt-a6c5zqdn04kk","type":"edge.added","subject":"gol-m8je1wc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-m8je1wc","to":"pri-mz2jxpb"},"at":"2026-09-19T17:20:55.497Z","parents":["evt-pzjjv5eczc9q"],"hash":"cd38ae5da658b943a330a45101c5b2779974d35f395ef11bd0cfadcdb737f810"}
{"id":"evt-vtstx24gawxm","type":"node.created","subject":"que-nm0xbg4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"question","title":"Which icon library should the dashboard use: lucide-react, @heroicons/react, or react-icons?","body":"Dependency justification per pri-mz2jxpb, for gol-m8je1wc. The user prefers a library over hand-drawn SVGs. Facts checked on 2026-09-19 against the npm registry and package contents: all three have zero runtime dependencies, sideEffects:false, React 19 peer support and permissive licenses, and all are in Next 16's default optimizePackageImports list (03-api-reference/05-config/01-next-config-js/optimizePackageImports.md), so only the icons used ship to the browser, the same code Next's own import optimization produces. (1) lucide-react 1.47.0, ISC: about 1,500 icons in one consistent 24px outline style with adjustable stroke width; 85 releases in the last 12 months, the latest on 2026-09-17; 35 MB unpacked. It has camera-domain icons (Cctv, Camera/CameraOff, Video/VideoOff, Wifi/WifiOff, ShieldAlert, KeyRound). (2) @heroicons/react 2.2.0, MIT, by the Tailwind team: 324 icons in 24 outline, 24 solid, 20 solid and 16 solid; 1 release in 12 months, the latest on 2024-11-18, so it's in maintenance mode; 3.7 MB. It covers the basics (VideoCamera/VideoCameraSlash, Wifi, ShieldExclamation, ArrowPath, Key) but has no CCTV icon. (3) react-icons 5.7.0, MIT: about 51,000 icons from 31 sets, including copies of Lucide (1,541) and Heroicons, plus Font Awesome, Material and Tabler; 3 releases in 12 months, the latest on 2026-06-30; 88 MB unpacked. Its bundled copies lag upstream, and mixing sets undermines visual consistency. Removal: icons are leaf imports in components, so swapping libraries later is a find-and-replace.","status":"open","owner":null,"attrs":{"options":[{"option":"lucide-react","consequence":"One consistent set of about 1,500 icons, including camera-specific ones; frequent releases mean occasional renames to handle."},{"option":"@heroicons/react","consequence":"Smallest install and a Tailwind-native look, but only 324 icons and near-dormant maintenance; some camera concepts would need workarounds."},{"option":"react-icons","consequence":"Access to about 51,000 icons from many sets; the largest install, copies of upstream sets that lag behind, and a style drift risk unless the project sticks to one set."}],"revisit":null}},"at":"2026-09-19T17:21:58.438Z","parents":["evt-a6c5zqdn04kk"],"hash":"2699fabf05b65194c6b8293ee07f9f608786adfac81c6ed037a573239b178228"}
{"id":"evt-6ae5qyh48d59","type":"edge.added","subject":"que-nm0xbg4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"que-nm0xbg4","to":"gol-m8je1wc"},"at":"2026-09-19T17:21:58.440Z","parents":["evt-vtstx24gawxm"],"hash":"eae8ecb88e0679d4d7d6049ddd51533082251f5efa81956d998c2cc7fed6c4fe"}
{"id":"evt-xcsfxy4913d3","type":"edge.added","subject":"pri-mz2jxpb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-mz2jxpb","to":"que-nm0xbg4"},"at":"2026-09-19T17:21:58.441Z","parents":["evt-6ae5qyh48d59"],"hash":"6da97fed371a923ad20b0bcea75c47d73dc93cf12f93e924a83ccc25fec51f21"}
{"id":"evt-xzzpcyr12c4n","type":"node.created","subject":"dec-1vnz5fw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Use lucide-react for dashboard icons","body":"Chosen by the user on 2026-09-19. Why: about 1,500 icons in one consistent outline style, including camera-domain icons (Cctv, CameraOff, VideoOff, WifiOff, ShieldAlert, KeyRound); actively maintained; ISC; no dependencies; optimized by Next 16 by default. Rejected: @heroicons/react (324 icons, no release since 2024-11) and react-icons (88 MB, lagging copies, style-mixing risk). Conventions: import named icons directly from 'lucide-react'; size with Tailwind (className=\"size-4\"); decorative icons get aria-hidden; icon-only buttons get an aria-label; icons accompany text, never replace it for state.","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:24:55.568Z","parents":["evt-xcsfxy4913d3"],"hash":"777497d2f7871963f174326847256cf0664a6f69ba8e72a8ff09996bde9de3f2"}
{"id":"evt-gr4kzj719v0z","type":"edge.added","subject":"dec-1vnz5fw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"answers","from":"dec-1vnz5fw","to":"que-nm0xbg4"},"at":"2026-09-19T17:24:55.570Z","parents":["evt-xzzpcyr12c4n"],"hash":"ab2049fcb5ddf40a335875c92b0d7171f11f8324cd127fabada337e3bfc8a305"}
{"id":"evt-7gxfmdx2mxdz","type":"question.answered","subject":"que-nm0xbg4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"answer":"dec-1vnz5fw"},"at":"2026-09-19T17:24:55.571Z","parents":["evt-gr4kzj719v0z"],"hash":"d86136a10f3124a03be2ea54367dcdd7bfacfab1bd9bb7752aa84c58701038db"}
{"id":"evt-5ks734vysg8z","type":"edge.added","subject":"dec-1vnz5fw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-1vnz5fw","to":"gol-m8je1wc"},"at":"2026-09-19T17:24:55.572Z","parents":["evt-7gxfmdx2mxdz"],"hash":"87d1b4bb262a92c01e090cadc458c53f716183de381dd9a6e48db9ce5636a6bf"}
{"id":"evt-6zvrt762e8fa","type":"node.created","subject":"iss-scj42jq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Camera status badge with icons on each dashboard card","body":"Each camera card gets a status badge, an icon plus a text label, derived from the existing query state with no new device calls: Live (Cctv, green) when the snapshot is polling; Connecting (LoaderCircle, spinning) while info loads; Needs login (KeyRound, amber) for the 'auth' problem; Not activated (ShieldAlert, amber) for 'inactive'; Offline or error (WifiOff, red) for 'error'; Session ended (LogIn) for 'signed-out'. Colors work in light and dark mode, and the label is always visible text, never color or icon alone. This also partly fills the 'live status' gap noted on gol-146a3cb. Tests: each state shows the right label and icon.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T17:25:14.428Z","parents":["evt-5ks734vysg8z"],"hash":"46507c2c5cc26321e5d73d36f40049d2f8532346dea16f3c71a2268a414a7315"}
{"id":"evt-7yvhajjxnk65","type":"edge.added","subject":"iss-scj42jq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-scj42jq","to":"gol-m8je1wc"},"at":"2026-09-19T17:25:14.429Z","parents":["evt-6zvrt762e8fa"],"hash":"431fd3e35f5f5c79005633edd272ef044e94ae1a1fd3578ee1e23f0ed467133a"}
{"id":"evt-cemey6n87hbb","type":"edge.added","subject":"iss-scj42jq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-scj42jq","to":"gol-146a3cb"},"at":"2026-09-19T17:25:14.430Z","parents":["evt-7yvhajjxnk65"],"hash":"96759ba2f174eb984bf1de213c402d542ac6e179c1a35e9b8011f65c68ca5cc6"}
{"id":"evt-1h5qv8r2k2g8","type":"edge.added","subject":"iss-scj42jq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-scj42jq","to":"src/app/camera-card.tsx"},"at":"2026-09-19T17:25:14.431Z","parents":["evt-cemey6n87hbb"],"hash":"3e8431ac5007e9f0974f09b4d11df7cc07c62f45484809c0ac5c77487fb5a89b"}
{"id":"evt-h06gyawk0r36","type":"edge.added","subject":"iss-scj42jq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-scj42jq","to":"area:ui"},"at":"2026-09-19T17:25:14.432Z","parents":["evt-1h5qv8r2k2g8"],"hash":"28d65c77e75835350fdc2b8c7429db2255f1609f359fb1d094afe6727832728b"}
{"id":"evt-zbc6epbtz66f","type":"edge.added","subject":"iss-scj42jq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-scj42jq","to":"area:icons"},"at":"2026-09-19T17:25:14.433Z","parents":["evt-h06gyawk0r36"],"hash":"79d4d06eb0641509ebfd3228661415421ac612c094b0269280f7694b5ebb0a74"}
{"id":"evt-xjpveszhvt1k","type":"node.created","subject":"iss-chrdd0v","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Icons on dashboard actions and controls","body":"Add leading icons to actions, keeping their text labels: Scan network (Radar; LoaderCircle spinning while scanning); Snapshot refresh selector (Timer); Change login (KeyRound); Forget saved login (Trash2); Retry and Check again (RefreshCw); camera Web page link (ExternalLink, which also signals a new tab); Sign out (LogOut); Save (Lock). Icons are decorative (aria-hidden), since each control already has a text label, and are sized with Tailwind (size-4 in buttons, size-3.5 in small text). Tests check the icons don't change the accessible names existing tests rely on.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T17:25:17.898Z","parents":["evt-zbc6epbtz66f"],"hash":"6f35203c9d59e5a76c7afa07c5b6ef371c2d14bc1e02c93a152dae7d33075410"}
{"id":"evt-mkmvsqezshj4","type":"edge.added","subject":"iss-chrdd0v","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-chrdd0v","to":"gol-m8je1wc"},"at":"2026-09-19T17:25:17.899Z","parents":["evt-xjpveszhvt1k"],"hash":"4b5c61fdeb3b22fc7905a8ae2f317b8f73a4ea818a042ae1def09452c4a1074b"}
{"id":"evt-hzk5w34zbmjf","type":"edge.added","subject":"iss-chrdd0v","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-chrdd0v","to":"src/app/camera-card.tsx"},"at":"2026-09-19T17:25:17.900Z","parents":["evt-mkmvsqezshj4"],"hash":"136c3a8f219d78465b161fce1da0865905f9876c298bed382c31abb1362cd19e"}
{"id":"evt-w90nhvbcvtka","type":"edge.added","subject":"iss-chrdd0v","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-chrdd0v","to":"src/app/camera-scanner.tsx"},"at":"2026-09-19T17:25:17.901Z","parents":["evt-hzk5w34zbmjf"],"hash":"565a6aca5b4a7853da5e012fba9c127e3bb8b3d175d999cda5332239c687ff05"}
{"id":"evt-y4fe0srp615m","type":"edge.added","subject":"iss-chrdd0v","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-chrdd0v","to":"src/app/refresh-rate-select.tsx"},"at":"2026-09-19T17:25:17.902Z","parents":["evt-w90nhvbcvtka"],"hash":"18de00519ef81831ba82ba79020e90eb09fd316bbbcf96c03cba1b0d64880834"}
{"id":"evt-c34h3hbdqyzr","type":"edge.added","subject":"iss-chrdd0v","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-chrdd0v","to":"src/app/security-bar.tsx"},"at":"2026-09-19T17:25:17.903Z","parents":["evt-y4fe0srp615m"],"hash":"2e78c43b8c1f37fa772d48ccf92b3b765ea351b87b986cb0fb513c21e27fc6eb"}
{"id":"evt-tp7htnkxh47f","type":"edge.added","subject":"iss-chrdd0v","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-chrdd0v","to":"area:ui"},"at":"2026-09-19T17:25:17.904Z","parents":["evt-c34h3hbdqyzr"],"hash":"f6a807ec9f5d7644ed64e4bd7705d9c56c108f9825518347eadf45efc5acf5e5"}
{"id":"evt-vh7ppk61cbqh","type":"edge.added","subject":"iss-chrdd0v","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-chrdd0v","to":"area:icons"},"at":"2026-09-19T17:25:17.905Z","parents":["evt-tp7htnkxh47f"],"hash":"89462173bfd30f97081825fda19a2b60dc8bd24e839e60f156f2222a0be233e7"}
{"id":"evt-ze812dw52pr4","type":"node.created","subject":"iss-egy082x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Icons on warnings, errors and the login and setup screens","body":"Unsecured banner and the setup page's skip box: ShieldAlert. Camera setup panel ('not activated'): TriangleAlert. Inline errors (form errors, 'Stopped: …', scan errors): CircleAlert. Session-ended notice: LogIn. The signed-in bar: ShieldCheck next to 'Signed in as'. /login heading: Lock. /setup heading: ShieldCheck. Warnings keep role=\"alert\" and their text; icons are aria-hidden.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T17:25:20.490Z","parents":["evt-vh7ppk61cbqh"],"hash":"aa0aca07465145074b15207ecc9993590bafd0571ac0dc2f48e9b79ef0a08ed6"}
{"id":"evt-d9kz9rhbbqw9","type":"edge.added","subject":"iss-egy082x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-egy082x","to":"gol-m8je1wc"},"at":"2026-09-19T17:25:20.491Z","parents":["evt-ze812dw52pr4"],"hash":"0c18c688c7a1bf1532c0fd0bd9592fcd7adb5fcf17c0c5e130ef762f34621da3"}
{"id":"evt-85mdv4ynn7th","type":"edge.added","subject":"iss-egy082x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-egy082x","to":"gol-wqf95dq"},"at":"2026-09-19T17:25:20.492Z","parents":["evt-d9kz9rhbbqw9"],"hash":"7404dcdcd35bc2091543e6b248c8f0461ef7559b9db25af80ba41a67da89aee4"}
{"id":"evt-whmkdf58tawj","type":"edge.added","subject":"iss-egy082x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-egy082x","to":"src/app/security-bar.tsx"},"at":"2026-09-19T17:25:20.493Z","parents":["evt-85mdv4ynn7th"],"hash":"70b84ce6063b4829ce29cdb1a6a53cc1de32adb2cb045045cad65a34e74c9a29"}
{"id":"evt-rwx2q6mvd5qh","type":"edge.added","subject":"iss-egy082x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-egy082x","to":"src/app/setup/"},"at":"2026-09-19T17:25:20.494Z","parents":["evt-whmkdf58tawj"],"hash":"6ca157f483df0146e21075232066d93b797270c6888132b4eb46f1225891d40b"}
{"id":"evt-rym686zqdrr4","type":"edge.added","subject":"iss-egy082x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-egy082x","to":"src/app/login/"},"at":"2026-09-19T17:25:20.495Z","parents":["evt-rwx2q6mvd5qh"],"hash":"2b1a71bd44cb7b12cf83e606a39c37d7b952b2032f969b7a9f8e8538286e19fb"}
{"id":"evt-690ksyrzr514","type":"edge.added","subject":"iss-egy082x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-egy082x","to":"src/app/camera-card.tsx"},"at":"2026-09-19T17:25:20.496Z","parents":["evt-rym686zqdrr4"],"hash":"52b3d1c3818c8b2457aa2601e94a9c2667fe32ad2bef5b681fec4e5c22571be5"}
{"id":"evt-vt5aasrmse8a","type":"edge.added","subject":"iss-egy082x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-egy082x","to":"src/app/camera-scanner.tsx"},"at":"2026-09-19T17:25:20.497Z","parents":["evt-690ksyrzr514"],"hash":"b63cc4033152e29a99633dd1745c90588b7cfefc8ceeb3d684535a15e39ff43f"}
{"id":"evt-dcf01c8jjq40","type":"edge.added","subject":"iss-egy082x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-egy082x","to":"area:ui"},"at":"2026-09-19T17:25:20.498Z","parents":["evt-vt5aasrmse8a"],"hash":"c8ee2b55a1e527b3f54740326f1258cb71b00856a99eb417c3a74a7e95bf8340"}
{"id":"evt-1f1j4caqqfqg","type":"edge.added","subject":"iss-egy082x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-egy082x","to":"area:icons"},"at":"2026-09-19T17:25:20.499Z","parents":["evt-dcf01c8jjq40"],"hash":"b4fe0ed0451b794a630e68d534e0ea84036eb803721d9bf14bf8c49e8b78c35a"}
{"id":"evt-e463qfq0k6n1","type":"node.updated","subject":"gol-m8je1wc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"weight":1}},"at":"2026-09-19T17:25:48.717Z","parents":["evt-1f1j4caqqfqg"],"hash":"fa13ede027745fbd1ab0c75a78f7c58731e8c47a15588d0d6ae21abccac67c81"}
{"id":"evt-at6ht9pamwwf","type":"node.created","subject":"ver-aqgngd8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Each camera card shows a status badge (icon plus visible text label) derived from existing query state: Live (Cctv), Connecting (spinning LoaderCircle, respecting reduced motion), Needs login (KeyRound), Not activated (ShieldAlert), Offline (WifiOff), Session ended (LogIn); icons are aria-hidden; light and dark tones; problems take precedence over a stale frame.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:27:30.170Z","parents":["evt-e463qfq0k6n1"],"hash":"20d8509e94afbe24dae9604c1cecb46eaf762a333a8981642e51bfe42eee0f53"}
{"id":"evt-p2bahjnbk0yz","type":"edge.added","subject":"ver-aqgngd8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-aqgngd8","to":"iss-scj42jq"},"at":"2026-09-19T17:27:30.171Z","parents":["evt-at6ht9pamwwf"],"hash":"6ec36bc805ecaaf5248e12faa594ab3d2adf151ba714b71fbdd721c309cc1e5a"}
{"id":"evt-f5628h5wpygb","type":"verification.recorded","subject":"ver-aqgngd8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/camera-status.tsx (pure cameraStatus() plus the StatusBadge component), wired into the camera-card.tsx header. Tests: src/app/camera-status.test.tsx (each state's label, lucide-<icon> class and aria-hidden; spin only while connecting, with motion-reduce; dark tones) and the camera-card.test.tsx 'status badge' block (Connecting→Live on the first frame; auth, inactive, error and signed-out from real 401/409/502 responses; Live→Offline when polling fails). Full suite 403/403, coverage 99.86%, tsc, eslint and next build clean, 2026-09-19. Not checked: how it looks in a real browser."},"at":"2026-09-19T17:27:30.172Z","parents":["evt-p2bahjnbk0yz"],"hash":"fbb1d2d79453a16bc11df354f57165f1f0d862bf38d0b9b30c3e8cadc2a07717"}
{"id":"evt-ffbztk22ttbh","type":"node.status_changed","subject":"iss-scj42jq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T17:27:31.300Z","parents":["evt-f5628h5wpygb"],"hash":"3c08b99276d86bae8115e97ebd63de011db3a730e4863b1c38f5bc22a152a69d"}
{"id":"evt-j4xqd53rstf9","type":"node.created","subject":"ver-k6w31xe","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"The status badge looks right in a real browser: placement, colors and states.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:29:37.139Z","parents":["evt-ffbztk22ttbh"],"hash":"3524ef9c0470967b15d8f6728108e646d471d495aca4da87173d2c2db77f40ec"}
{"id":"evt-6w7f3v6b7nwe","type":"edge.added","subject":"ver-k6w31xe","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-k6w31xe","to":"iss-scj42jq"},"at":"2026-09-19T17:29:37.140Z","parents":["evt-j4xqd53rstf9"],"hash":"892875d77ff0f97113eee3bc978059c38b0643f1a2e49748a74b70705ff3fd85"}
{"id":"evt-9qdgf9nzdtqw","type":"verification.recorded","subject":"ver-k6w31xe","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Manual check by the user (Michael Mainguy) on 2026-09-19: \"Looks good\"."},"at":"2026-09-19T17:29:37.141Z","parents":["evt-6w7f3v6b7nwe"],"hash":"02f1539222835a74e530e16271f1af785037430392d024675d8ba928f1332780"}
{"id":"evt-sj24gaby174p","type":"node.created","subject":"ver-qg053tc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Actions carry the agreed Lucide icons while keeping their text: Scan network (Radar, spinning LoaderCircle while scanning), refresh selector (Timer), Change login (KeyRound), Forget saved login (Trash2), Retry and Check again (RefreshCw), Web page link (ExternalLink, replacing the ↗ glyph), Sign out (LogOut), camera-login Save (Lock, spinner while checking), login submit (LogIn), setup submit (ShieldCheck); icons are aria-hidden and accessible names are unchanged.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:32:33.331Z","parents":["evt-9qdgf9nzdtqw"],"hash":"8a12849b543c9a888201cccd42ed1b77adbe04284f990394d8cfe828c1d92778"}
{"id":"evt-c3qc77nankwd","type":"edge.added","subject":"ver-qg053tc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-qg053tc","to":"iss-chrdd0v"},"at":"2026-09-19T17:32:33.332Z","parents":["evt-sj24gaby174p"],"hash":"37f36e688bd004b1ce8239c789e1f1a0a048f9d21547e428489e57b1e2e21bdc"}
{"id":"evt-adact6kfkb6g","type":"verification.recorded","subject":"ver-qg053tc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/icons.test.tsx (13 tests: each control's icon, looked up by its unchanged accessible name, plus every svg.lucide rendered is aria-hidden); spinners carry motion-reduce:animate-none. All 403 pre-existing tests pass unchanged, so names and text are intact. Full suite 416/416, coverage 99.86%, tsc, eslint and next build clean, 2026-09-19. Not yet checked in a real browser."},"at":"2026-09-19T17:32:33.333Z","parents":["evt-c3qc77nankwd"],"hash":"90f0932a8bb6d61ac078f2e0dfa63165d3df4f5a19fe7a26dddf52c3cf57143f"}
{"id":"evt-x3d2tg336gr8","type":"node.created","subject":"ver-7ctbkz0","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Warnings and errors carry icons while keeping role=\"alert\" and their text: unsecured banner and setup skip warning (ShieldAlert), camera setup panel (TriangleAlert), inline errors in the camera card, scanner and login/setup forms (CircleAlert), session-ended notice (CircleAlert) with a Sign in again link (LogIn), signed-in bar (ShieldCheck), /login heading (Lock), /setup heading (ShieldCheck).","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:32:35.909Z","parents":["evt-adact6kfkb6g"],"hash":"0006e18df20cff77506e325da0dee2b307e2ccc3bc90c1c14df9f2ee545568d5"}
{"id":"evt-6qf34pxqayr8","type":"edge.added","subject":"ver-7ctbkz0","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-7ctbkz0","to":"iss-egy082x"},"at":"2026-09-19T17:32:35.910Z","parents":["evt-x3d2tg336gr8"],"hash":"a3d24d83a2b5f75a4af1edc393bfd85c22db9837328ca6ca8d0776c7d042cfd3"}
{"id":"evt-2m9f36a8ak2r","type":"verification.recorded","subject":"ver-7ctbkz0","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/icons.test.tsx covers each placement (server components rendered via renderToStaticMarkup, client ones in jsdom); existing security-bar, auth-pages and auth-forms tests still pass unchanged. Full suite 416/416, 2026-09-19. Not yet checked in a real browser."},"at":"2026-09-19T17:32:35.911Z","parents":["evt-6qf34pxqayr8"],"hash":"839cd33945cb92668b475bce329d7688ed9b5aed8c31a72725606ee84643a366"}
{"id":"evt-pj9asf1mqfa2","type":"node.status_changed","subject":"iss-chrdd0v","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T17:32:37.022Z","parents":["evt-2m9f36a8ak2r"],"hash":"4cedeee71ddeff681f58d31259537489eee4a9ca314c172b61a2915365e040e3"}
{"id":"evt-k12ns9yp4phn","type":"node.status_changed","subject":"iss-egy082x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T17:32:38.169Z","parents":["evt-pj9asf1mqfa2"],"hash":"1a722c19e825f011d1514d9e760af22eb456a129ea8799e1565badfb51d1e88f"}

10
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@tanstack/react-query": "^5.103.1",
"lucide-react": "^1.47.0",
"next": "16.3.5",
"onvif": "^0.8.3",
"react": "19.2.8",
@ -6191,6 +6192,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz",
"integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",

View File

@ -14,6 +14,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.103.1",
"lucide-react": "^1.47.0",
"next": "16.3.5",
"onvif": "^0.8.3",
"react": "19.2.8",

View File

@ -248,6 +248,55 @@ describe("CameraCard", () => {
});
});
describe("status badge", () => {
const badge = () => document.querySelector("[data-status]")!;
it("goes from Connecting to Live when the first frame arrives", async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
stubFetch({
[`GET ${base}/info`]: () => json(info),
[`GET ${base}/snapshot`]: async () => {
await gate;
return frame();
},
});
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
expect(badge().textContent).toBe("Connecting");
release();
await waitFor(() => expect(badge().getAttribute("data-status")).toBe("live"));
expect(badge().textContent).toBe("Live");
});
it.each([
["auth", json({ error: "Wrong login", code: "auth" }, 401), "Needs login"],
["inactive", json({ error: "not activated", code: "inactive" }, 409), "Not activated"],
["error", json({ error: "Camera request failed" }, 502), "Offline"],
["signed-out", json({ error: "Sign in required" }, 401), "Session ended"],
] as const)("shows %s when the camera reports it", async (status, response, label) => {
stubFetch({
[`GET ${base}/info`]: () => response.clone(),
[`GET ${base}/credentials`]: () => json(noLogin),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
await waitFor(() => expect(badge().getAttribute("data-status")).toBe(status));
expect(badge().textContent).toBe(label);
});
it("switches to Offline when a live camera stops answering", async () => {
let fail = false;
stubFetch({
[`GET ${base}/info`]: () => json(info),
[`GET ${base}/snapshot`]: () => (fail ? json({ error: "x" }, 502) : frame()),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={20} />);
await waitFor(() => expect(badge().getAttribute("data-status")).toBe("live"));
fail = true;
await waitFor(() => expect(badge().getAttribute("data-status")).toBe("error"));
});
});
describe("two cameras", () => {
it("saving one camera's login refetches only that camera", async () => {
const other = { ...cam, id: "99999999-2222-3333-4444-555555555555", name: "Yard" };

View File

@ -1,5 +1,16 @@
"use client";
import {
CircleAlert,
ExternalLink,
KeyRound,
LoaderCircle,
Lock,
LogIn,
RefreshCw,
Trash2,
TriangleAlert,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { CameraSummary } from "@/lib/camera-registry";
import {
@ -11,6 +22,7 @@ import {
useSaveCredentials,
type CameraProblem,
} from "./camera-queries";
import { cameraStatus, StatusBadge } from "./camera-status";
const DEFAULT_USERNAME = "camera";
@ -22,7 +34,8 @@ function webUiUrl(cam: CameraSummary) {
function WebUiLink({ cam, children }: { cam: CameraSummary; children: React.ReactNode }) {
return (
<a href={webUiUrl(cam)} target="_blank" rel="noopener noreferrer" className="underline">
{children} ↗
{children}
<ExternalLink aria-hidden className="ml-0.5 inline size-3 align-[-0.1em]" />
</a>
);
}
@ -30,7 +43,10 @@ function WebUiLink({ cam, children }: { cam: CameraSummary; children: React.Reac
function SetupPanel({ cam, onCheckAgain }: { cam: CameraSummary; onCheckAgain: () => void }) {
return (
<div className="flex flex-col gap-2 rounded border border-amber-400/60 p-3 text-sm">
<div className="font-medium">Setup required: camera not activated</div>
<div className="flex items-center gap-1.5 font-medium">
<TriangleAlert aria-hidden className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
Setup required: camera not activated
</div>
<ol className="list-decimal space-y-1 pl-5 text-zinc-700 dark:text-zinc-300">
<li>
<WebUiLink cam={cam}>Open the camera&apos;s web page</WebUiLink> and activate it by
@ -50,8 +66,9 @@ function SetupPanel({ cam, onCheckAgain }: { cam: CameraSummary; onCheckAgain: (
<div>
<button
onClick={onCheckAgain}
className="rounded bg-black px-3 py-1 text-white dark:bg-white dark:text-black"
className="inline-flex items-center gap-1.5 rounded bg-black px-3 py-1 text-white dark:bg-white dark:text-black"
>
<RefreshCw aria-hidden className="size-4" />
Check again
</button>
</div>
@ -130,7 +147,10 @@ function CredentialsForm({
return (
<form onSubmit={submit} className="flex flex-col gap-2 rounded border border-amber-400/60 p-3 text-sm">
<div className="font-medium">Camera login</div>
<div className="flex items-center gap-1.5 font-medium">
<KeyRound aria-hidden className="size-4 shrink-0" />
Camera login
</div>
{reason && <div className="text-xs text-zinc-500">{reason}</div>}
<div className="text-xs text-zinc-500">
Use the ONVIF user you created on the camera (
@ -153,7 +173,8 @@ function CredentialsForm({
className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
/>
{error && (
<div className="text-red-600">
<div className="flex items-center gap-1.5 text-red-600">
<CircleAlert aria-hidden className="size-4 shrink-0" />
{error === save.error && error.kind === "auth" ? "Camera rejected that login." : error.message}
</div>
)}
@ -161,8 +182,13 @@ function CredentialsForm({
<button
type="submit"
disabled={busy}
className="rounded bg-black px-3 py-1 text-white disabled:opacity-50 dark:bg-white dark:text-black"
className="inline-flex items-center gap-1.5 rounded bg-black px-3 py-1 text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{save.isPending ? (
<LoaderCircle aria-hidden className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock aria-hidden className="size-4" />
)}
{save.isPending ? "Checking…" : "Save"}
</button>
{status.data?.source === "stored" && (
@ -170,8 +196,9 @@ function CredentialsForm({
type="button"
disabled={busy}
onClick={() => clear.mutate(undefined, { onSuccess: onSaved })}
className="rounded px-3 py-1 underline disabled:opacity-50"
className="inline-flex items-center gap-1.5 rounded px-3 py-1 underline disabled:opacity-50"
>
<Trash2 aria-hidden className="size-4" />
Forget saved login
</button>
)}
@ -211,7 +238,12 @@ export default function CameraCard({
<Snapshot cam={cam} frame={snapshot.data} paused={problem !== null || !info.isSuccess} />
<div>
<div className="font-medium">{cam.name ?? info.data?.model ?? "Unnamed camera"}</div>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{cam.name ?? info.data?.model ?? "Unnamed camera"}</span>
<StatusBadge
status={cameraStatus({ problem: problem?.kind ?? null, hasFrame: snapshot.data !== undefined })}
/>
</div>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-sm">
{cam.host}:{cam.port}
@ -219,7 +251,11 @@ export default function CameraCard({
<span className="flex gap-3 text-xs">
<WebUiLink cam={cam}>Web page</WebUiLink>
{!showLogin && !showSetup && (
<button onClick={() => setEditingLogin(true)} className="underline">
<button
onClick={() => setEditingLogin(true)}
className="inline-flex items-center gap-1 underline"
>
<KeyRound aria-hidden className="size-3.5" />
Change login
</button>
)}
@ -248,8 +284,12 @@ export default function CameraCard({
{problem?.kind === "signed-out" && (
<div className="flex items-center justify-between gap-2 text-sm text-red-600">
<span>Your session has ended.</span>
<a href="/login" className="underline">
<span className="inline-flex items-center gap-1.5">
<CircleAlert aria-hidden className="size-4 shrink-0" />
Your session has ended.
</span>
<a href="/login" className="inline-flex items-center gap-1 underline">
<LogIn aria-hidden className="size-4" />
Sign in again
</a>
</div>
@ -257,8 +297,12 @@ export default function CameraCard({
{problem?.kind === "error" && (
<div className="flex items-center justify-between gap-2 text-sm text-red-600">
<span>Stopped: {problem.message}</span>
<button onClick={retry} className="underline">
<span className="inline-flex items-center gap-1.5">
<CircleAlert aria-hidden className="size-4 shrink-0" />
Stopped: {problem.message}
</span>
<button onClick={retry} className="inline-flex items-center gap-1 underline">
<RefreshCw aria-hidden className="size-4" />
Retry
</button>
</div>

View File

@ -1,5 +1,6 @@
"use client";
import { CircleAlert, LoaderCircle, Radar } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useScanNetwork } from "./camera-queries";
@ -23,8 +24,13 @@ export default function CameraScanner() {
scan.mutate({ timeoutSecs, unicastSweep }, { onSuccess: () => router.refresh() })
}
disabled={scan.isPending}
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
className="inline-flex items-center gap-2 rounded-md bg-black px-4 py-2 text-sm font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{scan.isPending ? (
<LoaderCircle aria-hidden className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<Radar aria-hidden className="size-4" />
)}
{scan.isPending ? "Scanning…" : "Scan network"}
</button>
<label className="flex items-center gap-2 text-sm">
@ -52,7 +58,12 @@ export default function CameraScanner() {
Scan found {found} camera{found === 1 ? "" : "s"}
</p>
)}
{scan.isError && <p className="w-full text-sm text-red-600">Error: {scan.error.message}</p>}
{scan.isError && (
<p className="flex w-full items-center gap-1.5 text-sm text-red-600">
<CircleAlert aria-hidden className="size-4 shrink-0" />
Error: {scan.error.message}
</p>
)}
</>
);
}

View File

@ -0,0 +1,49 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { cameraStatus, StatusBadge, type CameraStatus } from "./camera-status";
describe("cameraStatus", () => {
it("is live once a frame has arrived", () => {
expect(cameraStatus({ problem: null, hasFrame: true })).toBe("live");
});
it("is connecting until then", () => {
expect(cameraStatus({ problem: null, hasFrame: false })).toBe("connecting");
});
it.each(["auth", "inactive", "error", "signed-out"] as const)(
"reports the %s problem even if an old frame is still shown",
(problem) => {
expect(cameraStatus({ problem, hasFrame: true })).toBe(problem);
},
);
});
describe("StatusBadge", () => {
it.each<[CameraStatus, string, string]>([
["live", "Live", "lucide-cctv"],
["connecting", "Connecting", "lucide-loader-circle"],
["auth", "Needs login", "lucide-key-round"],
["inactive", "Not activated", "lucide-shield-alert"],
["error", "Offline", "lucide-wifi-off"],
["signed-out", "Session ended", "lucide-log-in"],
])("%s shows the text label %j with a decorative %s icon", (status, label, iconClass) => {
const html = renderToStaticMarkup(<StatusBadge status={status} />);
expect(html).toContain(`data-status="${status}"`);
expect(html).toContain(`>${label}</span>`);
expect(html).toMatch(new RegExp(`<svg[^>]*class="lucide ${iconClass}[^"]*"[^>]*aria-hidden="true"`));
});
it("spins only the connecting icon, and not for users who prefer reduced motion", () => {
expect(renderToStaticMarkup(<StatusBadge status="connecting" />)).toContain(
"animate-spin motion-reduce:animate-none",
);
expect(renderToStaticMarkup(<StatusBadge status="live" />)).not.toContain("animate-spin");
});
it("styles each status for dark mode too", () => {
for (const status of ["live", "auth", "error"] as const) {
expect(renderToStaticMarkup(<StatusBadge status={status} />)).toMatch(/dark:bg-\w+-950/);
}
});
});

67
src/app/camera-status.tsx Normal file
View File

@ -0,0 +1,67 @@
import { Cctv, KeyRound, LoaderCircle, LogIn, ShieldAlert, WifiOff, type LucideIcon } from "lucide-react";
import type { ProblemKind } from "./camera-queries";
/**
* A camera's status at a glance, derived from state the card already has (no extra device
* calls). Always an icon *and* a text label: color and icon alone don't carry meaning.
*/
export type CameraStatus = "live" | "connecting" | ProblemKind;
export function cameraStatus({
problem,
hasFrame,
}: {
problem: ProblemKind | null;
hasFrame: boolean;
}): CameraStatus {
if (problem) return problem;
return hasFrame ? "live" : "connecting";
}
const STATUS: Record<CameraStatus, { label: string; Icon: LucideIcon; tone: string; spin?: boolean }> = {
live: {
label: "Live",
Icon: Cctv,
tone: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
},
connecting: {
label: "Connecting",
Icon: LoaderCircle,
tone: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
spin: true,
},
auth: {
label: "Needs login",
Icon: KeyRound,
tone: "bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-300",
},
inactive: {
label: "Not activated",
Icon: ShieldAlert,
tone: "bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-300",
},
error: {
label: "Offline",
Icon: WifiOff,
tone: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
},
"signed-out": {
label: "Session ended",
Icon: LogIn,
tone: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
},
};
export function StatusBadge({ status }: { status: CameraStatus }) {
const { label, Icon, tone, spin } = STATUS[status];
return (
<span
data-status={status}
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${tone}`}
>
<Icon aria-hidden className={`size-3.5 ${spin ? "animate-spin motion-reduce:animate-none" : ""}`} />
{label}
</span>
);
}

206
src/app/icons.test.tsx Normal file
View File

@ -0,0 +1,206 @@
// @vitest-environment jsdom
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CameraSummary } from "@/lib/camera-registry";
import { json, renderWithQuery, stubFetch } from "../../test/dom";
/**
* Icons (vrek gol-m8je1wc, dec-1vnz5fw): each control and warning carries the agreed
* Lucide icon, icons are always decorative (aria-hidden), and accessible names stay the
* visible text.
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn(), replace: vi.fn() }),
usePathname: () => "/",
redirect: (to: string) => {
throw new Error(`redirect ${to}`);
},
}));
const getAdmin = vi.fn();
const verifySession = vi.fn();
vi.mock("@/lib/admin-auth", () => ({ getAdmin, hasAdmin: async () => false }));
vi.mock("@/lib/session", () => ({ verifySession, authState: async () => "signed-out" }));
vi.mock("@/lib/setup-code", () => ({ ensureSetupCode: () => "CODE" }));
const actions = vi.hoisted(() => ({ login: vi.fn(), setupAdmin: vi.fn() }));
vi.mock("./auth-actions", () => ({
login: actions.login,
setupAdmin: actions.setupAdmin,
logout: async () => {},
skipSetup: async () => {},
}));
const { default: CameraCard } = await import("./camera-card");
const { default: CameraScanner } = await import("./camera-scanner");
const { default: RefreshRateSelect } = await import("./refresh-rate-select");
const { default: SecurityBar } = await import("./security-bar");
const { default: LoginPage } = await import("./login/page");
const { default: SetupPage } = await import("./setup/page");
const { default: LoginForm } = await import("./login/login-form");
const { default: SetupForm } = await import("./setup/setup-form");
/**
* The Lucide icon names on the first icon inside an element, e.g. ["key-round"]. Lucide also
* adds classes for older aliases (Trash2 → "trash", "trash-2"), so compare with toContain.
*/
const iconIn = (el: Element) =>
[...(el.querySelector("svg")?.getAttribute("class") ?? "").matchAll(/lucide-([a-z0-9-]+)/g)].map(
(m) => m[1],
);
const allIconsDecorative = (root: ParentNode = document) => {
const svgs = [...root.querySelectorAll("svg.lucide")];
expect(svgs.length).toBeGreaterThan(0);
for (const svg of svgs) expect(svg.getAttribute("aria-hidden")).toBe("true");
};
const cam: CameraSummary = { id: "cam1", host: "192.168.1.10", port: 80, name: "Porch", lastSeen: "" };
const base = "/api/cameras/cam1";
const info = { manufacturer: "Hikvision", model: "DS", firmwareVersion: "1", profiles: [] };
beforeEach(() => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
});
describe("camera card", () => {
it("marks Change login, the web page link, and the status badge", async () => {
stubFetch({
[`GET ${base}/info`]: () => json(info),
[`GET ${base}/snapshot`]: () => new Response(new Blob(["x"])),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
const change = await screen.findByRole("button", { name: "Change login" });
expect(iconIn(change)).toContain("key-round");
const web = screen.getByRole("link", { name: "Web page" });
expect(iconIn(web)).toContain("external-link");
expect(web.textContent).not.toContain("↗");
allIconsDecorative();
});
it("marks the login form: title, Save, Forget, and errors", async () => {
stubFetch({
[`GET ${base}/info`]: () => json({ error: "Wrong login", code: "auth" }, 401),
[`GET ${base}/credentials`]: () => json({ source: "stored", username: "admin", hasPassword: true }),
[`PUT ${base}/credentials`]: () => json({ error: "nope", code: "auth" }, 401),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
expect(iconIn((await screen.findByText("Camera login")).closest("div")!)).toContain("key-round");
expect(iconIn(screen.getByRole("button", { name: "Save" }))).toContain("lock");
expect(iconIn(await screen.findByRole("button", { name: "Forget saved login" }))).toContain("trash-2");
fireEvent.click(screen.getByRole("button", { name: "Save" }));
const error = await screen.findByText("Camera rejected that login.");
expect(iconIn(error)).toContain("circle-alert");
allIconsDecorative();
});
it("marks the setup panel and its Check again button", async () => {
stubFetch({ [`GET ${base}/info`]: () => json({ error: "x", code: "inactive" }, 409) });
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
const title = await screen.findByText("Setup required: camera not activated");
expect(iconIn(title)).toContain("triangle-alert");
expect(iconIn(screen.getByRole("button", { name: "Check again" }))).toContain("refresh-cw");
allIconsDecorative();
});
it("marks the stopped error and Retry", async () => {
stubFetch({ [`GET ${base}/info`]: () => json({ error: "Camera request failed" }, 502) });
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
expect(iconIn(await screen.findByText(/^Stopped:/))).toContain("circle-alert");
expect(iconIn(screen.getByRole("button", { name: "Retry" }))).toContain("refresh-cw");
});
it("marks the session-ended notice and its sign-in link", async () => {
stubFetch({ [`GET ${base}/info`]: () => json({ error: "Sign in required" }, 401) });
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
expect(iconIn(await screen.findByText("Your session has ended."))).toContain("circle-alert");
expect(iconIn(screen.getByRole("link", { name: "Sign in again" }))).toContain("log-in");
});
});
describe("dashboard controls", () => {
it("Scan network shows a radar, then a spinner while scanning", async () => {
let finish!: (r: Response) => void;
stubFetch({ "POST /api/discover": () => new Promise((r) => (finish = r)) });
renderWithQuery(<CameraScanner />);
const scan = screen.getByRole("button", { name: "Scan network" });
expect(iconIn(scan)).toContain("radar");
fireEvent.click(scan);
const busy = await screen.findByRole("button", { name: "Scanning…" });
expect(iconIn(busy)).toContain("loader-circle");
expect(busy.querySelector("svg")!.getAttribute("class")).toContain("motion-reduce:animate-none");
finish(json({ error: "Network scan failed" }, 500));
expect(iconIn(await screen.findByText(/^Error:/))).toContain("circle-alert");
allIconsDecorative();
});
it("the refresh selector has a timer and keeps its label", () => {
renderWithQuery(<RefreshRateSelect value={1000} />);
const select = screen.getByRole("combobox", { name: /Snapshot refresh/ });
expect(iconIn(select.closest("label")!)).toContain("timer");
});
});
describe("security bar and auth pages", () => {
const markup = async (node: Promise<React.ReactNode> | React.ReactNode) => {
const div = document.createElement("div");
div.innerHTML = renderToStaticMarkup(<>{await node}</>);
return div;
};
it("the unsecured banner carries a shield alert", async () => {
getAdmin.mockResolvedValue(null);
const bar = await markup(SecurityBar());
expect(iconIn(bar.querySelector('[role="alert"] p')!)).toContain("shield-alert");
allIconsDecorative(bar);
});
it("the signed-in bar has a shield check and Sign out has a log-out icon", async () => {
getAdmin.mockResolvedValue({ username: "admin" });
verifySession.mockResolvedValue({ username: "admin", expiresAt: 0 });
const bar = await markup(SecurityBar());
expect(iconIn(bar)).toContain("shield-check");
expect(iconIn(bar.querySelector("button")!)).toContain("log-out");
allIconsDecorative(bar);
});
it("/login has a lock heading and a sign-in button icon", async () => {
const page = await markup(
LoginPage({ params: Promise.resolve({}), searchParams: Promise.resolve({}) }),
);
expect(iconIn(page.querySelector("h1")!)).toContain("lock");
expect(iconIn(page.querySelector('button[type="submit"]')!)).toContain("log-in");
allIconsDecorative(page);
});
it("/setup has a shield-check heading and a shield alert on the skip warning", async () => {
const page = await markup(SetupPage());
expect(iconIn(page.querySelector("h1")!)).toContain("shield-check");
const warning = [...page.querySelectorAll("p")].find((p) => p.textContent === "Skip at your own risk")!;
expect(iconIn(warning)).toContain("shield-alert");
allIconsDecorative(page);
});
it.each([
["login", () => <LoginForm next="/" />, actions.login, "Sign in"],
["setup", () => <SetupForm />, actions.setupAdmin, "Create admin and sign in"],
] as const)("the %s form marks its error with a circle alert", async (_n, Form, action, submit) => {
action.mockResolvedValue({ error: "Something went wrong." });
renderWithQuery(<Form />);
for (const input of document.querySelectorAll<HTMLInputElement>("input[required]")) {
fireEvent.change(input, { target: { value: "correct horse battery" } });
}
fireEvent.click(screen.getByRole("button", { name: submit }));
await waitFor(() => expect(iconIn(screen.getByRole("alert"))).toContain("circle-alert"));
allIconsDecorative();
});
});

View File

@ -1,5 +1,6 @@
"use client";
import { CircleAlert, LoaderCircle, LogIn } from "lucide-react";
import { useActionState } from "react";
import { login, type FormState } from "../auth-actions";
@ -27,15 +28,21 @@ export default function LoginForm({ next }: { next: string }) {
/>
</label>
{state.error && (
<p role="alert" className="text-red-600">
<p role="alert" className="flex items-center gap-1.5 text-red-600">
<CircleAlert aria-hidden className="size-4 shrink-0" />
{state.error}
</p>
)}
<button
type="submit"
disabled={pending}
className="rounded-md bg-black px-4 py-2 font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{pending ? (
<LoaderCircle aria-hidden className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<LogIn aria-hidden className="size-4" />
)}
{pending ? "Signing in…" : "Sign in"}
</button>
</form>

View File

@ -1,3 +1,4 @@
import { Lock } from "lucide-react";
import { redirect } from "next/navigation";
import { safeNextPath } from "@/lib/auth-shared";
import { authState } from "@/lib/session";
@ -11,7 +12,10 @@ export default async function LoginPage({ searchParams }: PageProps<"/login">) {
return (
<main className="mx-auto w-full max-w-sm flex-1 px-4 py-16 font-sans">
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Lock aria-hidden className="size-6" />
Sign in
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Sign in with the camera dashboard&apos;s admin login.
</p>

View File

@ -1,5 +1,6 @@
"use client";
import { Timer } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { REFRESH_OPTIONS_MS } from "./refresh-rate";
@ -10,6 +11,7 @@ export default function RefreshRateSelect({ value }: { value: number }) {
return (
<label className="flex items-center gap-2 text-sm">
<Timer aria-hidden className="size-4" />
Snapshot refresh
<select
value={value}

View File

@ -1,3 +1,4 @@
import { LogOut, ShieldAlert, ShieldCheck } from "lucide-react";
import Link from "next/link";
import { getAdmin } from "@/lib/admin-auth";
import { verifySession } from "@/lib/session";
@ -13,9 +14,12 @@ export default async function SecurityBar() {
if (!admin) {
return (
<div role="alert" className="flex flex-wrap items-center justify-between gap-3 bg-red-700 px-4 py-3 text-sm text-white">
<p>
<strong>Not secured:</strong> anyone on your network can view your cameras and change
their logins.
<p className="flex items-center gap-2">
<ShieldAlert aria-hidden className="size-5 shrink-0" />
<span>
<strong>Not secured:</strong> anyone on your network can view your cameras and change
their logins.
</span>
</p>
<Link href="/setup" className="rounded bg-white px-3 py-1 font-medium text-red-700">
Set up admin login
@ -27,11 +31,13 @@ export default async function SecurityBar() {
if (!session) return null;
return (
<div className="flex items-center justify-end gap-3 border-b border-zinc-200 px-4 py-2 text-xs text-zinc-600 dark:border-zinc-800 dark:text-zinc-400">
<ShieldCheck aria-hidden className="size-4 text-emerald-600 dark:text-emerald-400" />
<span>
Signed in as <strong>{session.username}</strong>
</span>
<form action={logout}>
<button type="submit" className="underline">
<button type="submit" className="inline-flex items-center gap-1 underline">
<LogOut aria-hidden className="size-3.5" />
Sign out
</button>
</form>

View File

@ -1,3 +1,4 @@
import { ShieldAlert, ShieldCheck } from "lucide-react";
import { redirect } from "next/navigation";
import { hasAdmin } from "@/lib/admin-auth";
import { ensureSetupCode } from "@/lib/setup-code";
@ -11,7 +12,10 @@ export default async function SetupPage() {
return (
<main className="mx-auto w-full max-w-md flex-1 px-4 py-12 font-sans">
<h1 className="text-2xl font-semibold tracking-tight">Secure the camera dashboard</h1>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<ShieldCheck aria-hidden className="size-6" />
Secure the camera dashboard
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Create the admin login. Everyone will need it to view cameras or change their settings.
</p>
@ -24,7 +28,10 @@ export default async function SetupPage() {
<SetupForm />
<div className="mt-10 rounded-md border-2 border-red-600 p-4 text-sm">
<p className="font-semibold text-red-700 dark:text-red-400">Skip at your own risk</p>
<p className="flex items-center gap-1.5 font-semibold text-red-700 dark:text-red-400">
<ShieldAlert aria-hidden className="size-4 shrink-0" />
Skip at your own risk
</p>
<p className="mt-1">
Without an admin login, <strong>anyone on your network</strong> can view your cameras,
change their logins, and use the API.

View File

@ -1,5 +1,6 @@
"use client";
import { CircleAlert, LoaderCircle, ShieldCheck } from "lucide-react";
import { useActionState } from "react";
import { setupAdmin, type FormState } from "../auth-actions";
@ -48,15 +49,21 @@ export default function SetupForm() {
/>
</label>
{state.error && (
<p role="alert" className="text-red-600">
<p role="alert" className="flex items-center gap-1.5 text-red-600">
<CircleAlert aria-hidden className="size-4 shrink-0" />
{state.error}
</p>
)}
<button
type="submit"
disabled={pending}
className="rounded-md bg-black px-4 py-2 font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{pending ? (
<LoaderCircle aria-hidden className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<ShieldCheck aria-hidden className="size-4" />
)}
{pending ? "Securing…" : "Create admin and sign in"}
</button>
</form>

View File

@ -28,7 +28,7 @@ export default defineConfig({
"src/app/page.tsx",
],
reporter: ["text", "json-summary"],
// Goal gol-9zxah3p in vrek: `npm run coverage` fails if line coverage drops below 95%.
// vrek principle pri-be2smzk: `npm run coverage` fails if line coverage drops below 95%.
thresholds: { lines: 95 },
},
},