From 190191df928499743114310314289626f17f3000 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sat, 19 Sep 2026 12:33:16 -0500 Subject: [PATCH] Add Lucide icons: camera status badge, actions and warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .vrek/log.ndjson | 57 +++++++++ package-lock.json | 10 ++ package.json | 1 + src/app/camera-card.test.tsx | 49 ++++++++ src/app/camera-card.tsx | 70 +++++++++-- src/app/camera-scanner.tsx | 15 ++- src/app/camera-status.test.tsx | 49 ++++++++ src/app/camera-status.tsx | 67 +++++++++++ src/app/icons.test.tsx | 206 ++++++++++++++++++++++++++++++++ src/app/login/login-form.tsx | 11 +- src/app/login/page.tsx | 6 +- src/app/refresh-rate-select.tsx | 2 + src/app/security-bar.tsx | 14 ++- src/app/setup/page.tsx | 11 +- src/app/setup/setup-form.tsx | 11 +- vitest.config.mts | 2 +- 16 files changed, 554 insertions(+), 27 deletions(-) create mode 100644 src/app/camera-status.test.tsx create mode 100644 src/app/camera-status.tsx create mode 100644 src/app/icons.test.tsx diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index b304278..c2ac6fc 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -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- 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"} diff --git a/package-lock.json b/package-lock.json index 175a51e..1918550 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 891c0dd..4f3e700 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/camera-card.test.tsx b/src/app/camera-card.test.tsx index 7d39180..93bde45 100644 --- a/src/app/camera-card.test.tsx +++ b/src/app/camera-card.test.tsx @@ -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((r) => (release = r)); + stubFetch({ + [`GET ${base}/info`]: () => json(info), + [`GET ${base}/snapshot`]: async () => { + await gate; + return frame(); + }, + }); + renderWithQuery(); + + 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(); + 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(); + 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" }; diff --git a/src/app/camera-card.tsx b/src/app/camera-card.tsx index 996a5c7..b398a3a 100644 --- a/src/app/camera-card.tsx +++ b/src/app/camera-card.tsx @@ -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 ( - {children} ↗ + {children} + ); } @@ -30,7 +43,10 @@ function WebUiLink({ cam, children }: { cam: CameraSummary; children: React.Reac function SetupPanel({ cam, onCheckAgain }: { cam: CameraSummary; onCheckAgain: () => void }) { return (
-
Setup required: camera not activated
+
+ + Setup required: camera not activated +
  1. Open the camera's web page and activate it by @@ -50,8 +66,9 @@ function SetupPanel({ cam, onCheckAgain }: { cam: CameraSummary; onCheckAgain: (
    @@ -130,7 +147,10 @@ function CredentialsForm({ return (
    -
    Camera login
    +
    + + Camera login +
    {reason &&
    {reason}
    }
    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 && ( -
    +
    + {error === save.error && error.kind === "auth" ? "Camera rejected that login." : error.message}
    )} @@ -161,8 +182,13 @@ function CredentialsForm({ {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" > + Forget saved login )} @@ -211,7 +238,12 @@ export default function CameraCard({
    -
    {cam.name ?? info.data?.model ?? "Unnamed camera"}
    +
    + {cam.name ?? info.data?.model ?? "Unnamed camera"} + +
    {cam.host}:{cam.port} @@ -219,7 +251,11 @@ export default function CameraCard({ Web page {!showLogin && !showSetup && ( - )} @@ -248,8 +284,12 @@ export default function CameraCard({ {problem?.kind === "signed-out" && ( @@ -257,8 +297,12 @@ export default function CameraCard({ {problem?.kind === "error" && (
    - Stopped: {problem.message} -
    diff --git a/src/app/camera-scanner.tsx b/src/app/camera-scanner.tsx index 5c6dcfc..6947261 100644 --- a/src/app/camera-scanner.tsx +++ b/src/app/camera-scanner.tsx @@ -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 ? ( + + ) : ( + + )} {scan.isPending ? "Scanning…" : "Scan network"}
    `); + expect(html).toMatch(new RegExp(`]*class="lucide ${iconClass}[^"]*"[^>]*aria-hidden="true"`)); + }); + + it("spins only the connecting icon, and not for users who prefer reduced motion", () => { + expect(renderToStaticMarkup()).toContain( + "animate-spin motion-reduce:animate-none", + ); + expect(renderToStaticMarkup()).not.toContain("animate-spin"); + }); + + it("styles each status for dark mode too", () => { + for (const status of ["live", "auth", "error"] as const) { + expect(renderToStaticMarkup()).toMatch(/dark:bg-\w+-950/); + } + }); +}); diff --git a/src/app/camera-status.tsx b/src/app/camera-status.tsx new file mode 100644 index 0000000..722c815 --- /dev/null +++ b/src/app/camera-status.tsx @@ -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 = { + 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 ( + + + {label} + + ); +} diff --git a/src/app/icons.test.tsx b/src/app/icons.test.tsx new file mode 100644 index 0000000..d91cc89 --- /dev/null +++ b/src/app/icons.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + 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) => { + 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", () => , actions.login, "Sign in"], + ["setup", () => , 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(); + for (const input of document.querySelectorAll("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(); + }); +}); diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index 26c2d9d..100d1c8 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -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 }) { /> {state.error && ( -

    +

    + {state.error}

    )} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 96a1a96..0b87eba 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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 (
    -

    Sign in

    +

    + + Sign in +

    Sign in with the camera dashboard's admin login.

    diff --git a/src/app/refresh-rate-select.tsx b/src/app/refresh-rate-select.tsx index a58468a..611c76d 100644 --- a/src/app/refresh-rate-select.tsx +++ b/src/app/refresh-rate-select.tsx @@ -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 (