@7365admin1/layer-common 3.2.2-staging.108 → 3.2.2-staging.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -630,7 +630,7 @@ async function loadDashboard() {
630
630
  administratorUserIds.value = getAdministratorRoleUserIds(roleResponse);
631
631
  allIdentities.value = mergedIdentities;
632
632
  identities.value = mergedIdentities.filter(
633
- (identity: Record<string, any>) => classifyIdentity(identity) === activeTab.value,
633
+ (identity: Record<string, unknown>) => classifyIdentity(identity) === activeTab.value,
634
634
  );
635
635
  logs.value = logResponse?.items ?? logResponse?.data?.items ?? logResponse?.data?.logs ?? [];
636
636
  liveAccessLogs.value = hidAccessLogs;
@@ -840,7 +840,7 @@ function isVisitorIdentity(identity: Record<string, unknown> | undefined) {
840
840
  ));
841
841
  }
842
842
 
843
- function classifyIdentity(identity?: Record<string, any>, row?: AccessRow): AccessTab {
843
+ function classifyIdentity(identity?: Record<string, unknown>, row?: AccessRow): AccessTab {
844
844
  const hidUserId = toHidNumericId(identity?.hidUserId ?? row?.identityKey.split("|")[0]);
845
845
  if (hidUserId && administratorUserIds.value.has(hidUserId)) return "administrator";
846
846
  if (row?.identityType === "visitor" || row?.visitorId || isVisitorIdentity(identity)) return "visitor";
@@ -1197,15 +1197,15 @@ function getRawAccessLogs(event: Record<string, any>) {
1197
1197
 
1198
1198
  if (Array.isArray(accessLogs)) return accessLogs;
1199
1199
 
1200
- const objectChanges = payload.object_changes || result.object_changes || [];
1200
+ const objectChanges: unknown = payload.object_changes || result.object_changes || [];
1201
1201
  return Array.isArray(objectChanges)
1202
- ? objectChanges
1203
- .filter((change: Record<string, any>) => (
1204
- change?.object === "access_logs"
1205
- && change?.type !== "deleted"
1206
- && change?.values
1207
- ))
1208
- .map((change: Record<string, any>) => change.values)
1202
+ ? objectChanges.flatMap((change: unknown) => {
1203
+ const record = toUnknownRecord(change);
1204
+ const values = toUnknownRecord(record.values);
1205
+ return record.object === "access_logs" && record.type !== "deleted" && Object.keys(values).length
1206
+ ? [values]
1207
+ : [];
1208
+ })
1209
1209
  : [];
1210
1210
  }
1211
1211
 
@@ -239,16 +239,33 @@ async function connectReader() {
239
239
  username: draft.username.trim(),
240
240
  password: draft.password,
241
241
  });
242
- const data = response?.data ?? response;
242
+ const nestedData = response?.data;
243
+ const data = typeof nestedData === "object" && nestedData !== null
244
+ ? nestedData as Record<string, unknown>
245
+ : response;
243
246
  draft.deviceId = String(data?.deviceId || "");
244
- portals.value = Array.isArray(data?.portals) ? data.portals : [];
247
+ portals.value = Array.isArray(data?.portals)
248
+ ? data.portals.flatMap((portal: unknown) => {
249
+ if (typeof portal !== "object" || portal === null) return [];
250
+ const item = portal as Record<string, unknown>;
251
+ const id = Number(item.id);
252
+ const name = String(item.name || "").trim();
253
+ return Number.isSafeInteger(id) && id > 0 && name ? [{ id, name }] : [];
254
+ })
255
+ : [];
245
256
  draft.portalId = portals.value.length === 1 ? portals.value[0].id : null;
246
257
  discoveredConnection.value = [draft.baseUrl.trim(), draft.username.trim(), draft.password].join("\n");
247
- } catch (error: any) {
258
+ } catch (error: unknown) {
248
259
  draft.deviceId = "";
249
260
  draft.portalId = null;
250
261
  portals.value = [];
251
- discoveryError.value = error?.data?.message || error?.message || "Unable to connect to the HID reader.";
262
+ const record = typeof error === "object" && error !== null ? error as Record<string, unknown> : {};
263
+ const errorData = typeof record.data === "object" && record.data !== null
264
+ ? record.data as Record<string, unknown>
265
+ : {};
266
+ discoveryError.value = String(
267
+ errorData.message || record.message || "Unable to connect to the HID reader.",
268
+ );
252
269
  } finally {
253
270
  discovering.value = false;
254
271
  }
@@ -883,7 +883,13 @@ const hidQrValidityMinutes = computed(() => {
883
883
  });
884
884
 
885
885
  const hidQrReaderId = computed(() => String(hidQrCodePassConfig.value?.readerId || ""));
886
- const hidQrReader = ref<Record<string, any> | null>(null);
886
+ type HidQrReaderSummary = {
887
+ _id: string;
888
+ name?: string;
889
+ portalId?: number;
890
+ portalName?: string;
891
+ };
892
+ const hidQrReader = ref<HidQrReaderSummary | null>(null);
887
893
 
888
894
  watch(
889
895
  [hidQrReaderId, () => prop.site],
@@ -892,8 +898,14 @@ watch(
892
898
  if (!readerId || !site) return;
893
899
  try {
894
900
  const response = await getReaders({ site, page: 1, limit: 100 });
895
- const readers = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
896
- hidQrReader.value = readers.find((reader: Record<string, any>) => String(reader._id) === readerId) || null;
901
+ const readers: unknown = response?.items ?? response?.data?.items ?? response?.data?.readers ?? [];
902
+ hidQrReader.value = Array.isArray(readers)
903
+ ? readers.find((reader: unknown): reader is HidQrReaderSummary => (
904
+ typeof reader === "object"
905
+ && reader !== null
906
+ && String((reader as Record<string, unknown>)._id || "") === readerId
907
+ )) || null
908
+ : null;
897
909
  } catch {
898
910
  hidQrReader.value = null;
899
911
  }
@@ -195,7 +195,7 @@ export default function useHidAmico() {
195
195
  }
196
196
 
197
197
  function discoverReader(payload: Pick<HidReaderPayload, "baseUrl" | "username" | "password">) {
198
- return useNuxtApp().$api<Record<string, any>>(`${basePath}/readers/discover`, {
198
+ return useNuxtApp().$api<Record<string, unknown>>(`${basePath}/readers/discover`, {
199
199
  method: "POST",
200
200
  body: payload,
201
201
  });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.2.2-staging.108",
5
+ "version": "3.2.2-staging.110",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -0,0 +1,87 @@
1
+ # Render harness hardening
2
+
3
+ Tooling, not product code. Nothing here ships in `@7365admin1/layer-common` and
4
+ nothing here is imported by any component — it exists to stop the theme-review
5
+ harness reporting a pass it has not earned.
6
+
7
+ The harness itself (vite + Vuetify + a mirror of this package, driven by
8
+ Playwright) lives outside the repo, under
9
+ `_scratch-theme-review/harness`. These four files attach to it.
10
+
11
+ | File | What it does |
12
+ |---|---|
13
+ | `harness-init.ps1` | Makes a private harness for one worktree: fork directory and dev port both derived from the worktree path, own vite cache, mirror stamped with what was synced. |
14
+ | `render-check.mjs` | The run. Refuses to measure an unprovable mirror, then measures each screen at 360/768/1024/1440 × light/dark. |
15
+ | `probe.mjs` | The in-page half — what is actually read off the glass. |
16
+ | `render-check.selftest.mjs` | Proves every detector fails the scenario it was written for. `node render-check.selftest.mjs`, no server needed. |
17
+ | `baselines.json` | Per-screen recorded element and painted-text counts. |
18
+
19
+ ## Use
20
+
21
+ ```powershell
22
+ cd <worktree>
23
+ ..\iservice365-layer-common\tools\render-harness\harness-init.ps1 # prints fork + port
24
+ Start-Process -WorkingDirectory <fork> cmd.exe -ArgumentList '/c','npx vite --port <port> --strictPort'
25
+ cd <fork>; $env:PORT=<port>
26
+ node render-check.mjs <label> CameraMain NotificationSettings
27
+ ```
28
+
29
+ Re-run `harness-init.ps1` after any edit to `assets`, `components`,
30
+ `composables`, `constants`, `types` or `utils` — `render-check.mjs` compares the
31
+ mirror to the worktree file by file and refuses to run when they disagree.
32
+
33
+ ## What each guard is for
34
+
35
+ Every one of these was a real false pass on 2026-08-14, not a hypothetical.
36
+
37
+ - **Shared mirror.** One `lc/` copy meant two agents silently measured each
38
+ other's branch. Isolation is now derived from the worktree, so there is
39
+ nothing to remember, and the mirror carries a stamp the run checks.
40
+ - **Empty state.** A panel scored 8/8 dark-clean while drawing "No access logs
41
+ found". A table was signed off with zero header columns and "No data
42
+ available", so the header and cell rules under review had never been drawn. A
43
+ screen showing its empty state is a fixture bug and fails.
44
+ - **Empty form.** The same bug in a shape nothing else can see — 1112 elements,
45
+ nothing blank, and not one control holding a value.
46
+ - **Blank screen.** Six elements, zero page errors, and a counter read it as a
47
+ pass. Painted text is the honest signal.
48
+ - **Element counts.** `MIN_ELS = 20` was at once too low (a cold partial paint
49
+ at 32 scored ok where warm is 49) and too high (a drawing screen at 19 was
50
+ reported NOT-MOUNTED). Screens legitimately range from 3 to 1100 elements, so
51
+ each carries its own baseline and an un-baselined screen is refused rather
52
+ than guessed at.
53
+ - **Cold compile.** The run waits for the DOM to stop growing instead of a fixed
54
+ timeout, warms every screen first, prints the discarded warm-up numbers so the
55
+ gap is visible, and re-measures any row well under that screen's own best.
56
+ - **Undefined custom properties.** `var(--success)` where the token is `--ok`:
57
+ the declaration is invalid, the element inherits, and nothing reports it. Now
58
+ caught even when the rule matches nothing on the screen — which is how the
59
+ original survived a full 8-shot render.
60
+ - **Disabled controls.** WCAG-1.4.3-exempt, so logging them as failures put
61
+ fictional entries in an AA ledger. Classified as exempt, and the enabled state
62
+ is probed separately so the exemption cannot hide a real failure.
63
+ - **Vuetify defaults.** `VAutocomplete` was missing from the harness, and 19
64
+ components in this package use one — the run would have invented a defect the
65
+ product does not have. The defaults are diffed against `plugins/vuetify.ts`
66
+ every run.
67
+
68
+ ## Baselines
69
+
70
+ `--record` writes the current run's element and painted-text counts:
71
+
72
+ ```powershell
73
+ node render-check.mjs --record base CameraMain
74
+ ```
75
+
76
+ Only record from a run whose **screenshots you have opened**. A baseline
77
+ recorded from a broken render makes the break permanent. A conversion that
78
+ genuinely shrinks a screen below 70% of its baseline will flag — look at the
79
+ shot, then re-record.
80
+
81
+ `baselines.json` accepts `"allowEmpty": true` per screen for the rare component
82
+ whose empty state IS the thing under review.
83
+
84
+ ## Rule that does not bend
85
+
86
+ Fixtures are harness-only. Package code is never bent to suit the harness. If a
87
+ screen will not draw, the fixture is wrong.
@@ -0,0 +1,22 @@
1
+ {
2
+ "CameraMain": {
3
+ "els": 55,
4
+ "textChars": 85,
5
+ "recordedFrom": "0642279"
6
+ },
7
+ "NotificationSettings": {
8
+ "els": 150,
9
+ "textChars": 485,
10
+ "recordedFrom": "0642279"
11
+ },
12
+ "OvernightParkingAvailability": {
13
+ "els": 1132,
14
+ "textChars": 385,
15
+ "recordedFrom": "0642279"
16
+ },
17
+ "BulletinExpirationChip": {
18
+ "els": 3,
19
+ "textChars": 2,
20
+ "recordedFrom": "0642279"
21
+ }
22
+ }
@@ -0,0 +1,123 @@
1
+ # Per-worktree render-harness isolation.
2
+ #
3
+ # WHY THIS EXISTS. The theme-review harness renders ONE `harness/lc/` mirror,
4
+ # synced from a layer-common checkout by `sync-lc.ps1`. When two agents work at
5
+ # once, the second `sync-lc.ps1` overwrites the first agent's mirror, and both
6
+ # then measure whichever checkout synced last. That is silent - the run still
7
+ # prints element counts, contrast readings and screenshots, all of the WRONG
8
+ # branch. On 2026-08-14 it happened repeatedly, and every batch worked around it
9
+ # by hand-forking the harness and hand-picking a port. Hand-picking is what
10
+ # fails: forget it once and the whole run is fiction.
11
+ #
12
+ # So: isolation is DERIVED, never chosen. The fork directory and the dev port
13
+ # both come from the worktree path, so the same worktree always gets the same
14
+ # private harness and the same port, and two different worktrees cannot collide.
15
+ #
16
+ # cd <worktree>; ..\iservice365-layer-common\tools\render-harness\harness-init.ps1
17
+ #
18
+ # Prints the fork path and port. Re-running is safe: it re-syncs `lc/` and
19
+ # leaves the port alone.
20
+ [CmdletBinding()]
21
+ param(
22
+ # The layer-common worktree to render. Defaults to the current git worktree.
23
+ [string]$Worktree = (git rev-parse --show-toplevel),
24
+ # The canonical harness this fork is copied from.
25
+ [string]$Canonical = "C:\Seven365Projects\iService365\_scratch-theme-review\harness"
26
+ )
27
+
28
+ $ErrorActionPreference = "Stop"
29
+
30
+ if (-not (Test-Path "$Worktree\.git")) { throw "not a git worktree: $Worktree" }
31
+ if (-not (Test-Path "$Canonical\src\main.js")) { throw "canonical harness not found: $Canonical" }
32
+
33
+ $slug = Split-Path $Worktree -Leaf
34
+ $fork = Join-Path (Split-Path $Canonical -Parent) "harness-$slug"
35
+
36
+ # --- port, derived ----------------------------------------------------------
37
+ # FNV-1a over the worktree path: same worktree -> same port, every session, on
38
+ # every machine. Then step upward past anything already listening, so a hash
39
+ # collision or a stale server degrades to "next free port" instead of two
40
+ # agents sharing one vite and measuring each other.
41
+ function Get-DerivedPort([string]$s) {
42
+ $h = [uint32]2166136261
43
+ foreach ($c in $s.ToLower().ToCharArray()) {
44
+ # uint64 multiply, then modulo back into 32 bits. PowerShell widens past
45
+ # uint32 and refuses the cast rather than wrapping, and `-band 0xFFFFFFFF`
46
+ # is not the mask it looks like - 5.1 parses that literal as int -1.
47
+ $h = [uint32]((([uint64]($h -bxor [uint32][int]$c)) * 16777619) % 4294967296)
48
+ }
49
+ $p = 5400 + [int]($h % 200)
50
+ $busy = (Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue).LocalPort
51
+ while ($busy -contains $p) { $p++ }
52
+ return $p
53
+ }
54
+
55
+ $portFile = Join-Path $fork ".harness-port"
56
+ if (Test-Path $portFile) {
57
+ $port = [int](Get-Content $portFile -Raw).Trim()
58
+ } else {
59
+ $port = Get-DerivedPort $Worktree
60
+ }
61
+
62
+ # --- fork -------------------------------------------------------------------
63
+ # Keyed on a file the copy produces, not on the directory: a run that died
64
+ # half-way would otherwise leave a directory that looks finished forever.
65
+ if (-not (Test-Path "$fork\vite.config.mjs")) {
66
+ New-Item -ItemType Directory -Path $fork -Force | Out-Null
67
+ # An ALLOWLIST, not "everything minus the big things": the canonical harness
68
+ # has accumulated hundreds of one-off runners, .json measure dumps and logs,
69
+ # and it also contains a stray `nul` that Copy-Item cannot read at all.
70
+ # `node_modules` becomes a junction (a full copy is ~400MB per agent) and
71
+ # `lc` is re-synced below.
72
+ foreach ($n in "src", "apps", "public", "index.html", "package.json", "yarn.lock", "vite.config.mjs", "sync-lc.ps1") {
73
+ Copy-Item (Join-Path $Canonical $n) -Destination $fork -Recurse -Force
74
+ }
75
+ cmd /c mklink /J "$fork\node_modules" "$Canonical\node_modules" | Out-Null
76
+ }
77
+
78
+ # The hardened runner lives in the repo, next to this script, so a fork always
79
+ # gets the CURRENT one rather than whatever the canonical harness was carrying.
80
+ Copy-Item (Join-Path $PSScriptRoot "render-check.mjs") -Destination $fork -Force
81
+ Copy-Item (Join-Path $PSScriptRoot "baselines.json") -Destination $fork -Force -ErrorAction SilentlyContinue
82
+
83
+ # A junctioned `node_modules` SHARES `node_modules/.vite` with the harness it
84
+ # points at. Two servers writing one dep-cache produced 504s that photograph as
85
+ # a blank screen - i.e. a fake "conversion broke the page". Own cache, always.
86
+ $vite = Join-Path $fork "vite.config.mjs"
87
+ $cfg = [System.IO.File]::ReadAllText($vite)
88
+ if ($cfg -notmatch "cacheDir") {
89
+ $cfg = $cfg -replace "(?m)^\s*server:\s*\{", " cacheDir: `"./.vite-cache`",`r`n server: {"
90
+ # UTF8Encoding($false): Set-Content -Encoding utf8 writes a BOM on Windows
91
+ # PowerShell 5.1, and a BOM in a config file makes PostCSS's loader throw,
92
+ # which 500s EVERY .vue file - indistinguishable from a broken checkout.
93
+ [System.IO.File]::WriteAllText($vite, $cfg, (New-Object System.Text.UTF8Encoding($false)))
94
+ }
95
+
96
+ $pkgPath = Join-Path $fork "package.json"
97
+ $pkg = [System.IO.File]::ReadAllText($pkgPath)
98
+ $pkg = $pkg -replace '--port \d+', "--port $port"
99
+ [System.IO.File]::WriteAllText($pkgPath, $pkg, (New-Object System.Text.UTF8Encoding($false)))
100
+ [System.IO.File]::WriteAllText($portFile, "$port", (New-Object System.Text.UTF8Encoding($false)))
101
+
102
+ # --- mirror -----------------------------------------------------------------
103
+ & "$fork\sync-lc.ps1" -Src $Worktree
104
+
105
+ # Stamp WHAT was mirrored, so a measurement can prove which checkout it rendered
106
+ # rather than assume it. `render-check.mjs` refuses to run when this disagrees
107
+ # with the worktree it was pointed at - which is what a foreign `sync-lc.ps1`
108
+ # stamping over the mirror looks like. Written after the sync, because
109
+ # `sync-lc.ps1` deletes everything in `lc/` that is not one of its six dirs.
110
+ $stamp = @{
111
+ worktree = $Worktree
112
+ head = (git -C $Worktree rev-parse HEAD)
113
+ branch = (git -C $Worktree rev-parse --abbrev-ref HEAD)
114
+ port = $port
115
+ } | ConvertTo-Json -Compress
116
+ [System.IO.File]::WriteAllText((Join-Path $fork "lc\.synced-from.json"), $stamp, (New-Object System.Text.UTF8Encoding($false)))
117
+
118
+ Write-Output "harness : $fork"
119
+ Write-Output "port : $port"
120
+ Write-Output "renders : $Worktree @ $(git -C $Worktree rev-parse --short HEAD)"
121
+ Write-Output ""
122
+ Write-Output " Start-Process -WorkingDirectory '$fork' npx -ArgumentList 'vite','--port','$port','--strictPort'"
123
+ Write-Output " cd '$fork'; `$env:PORT=$port; node render-check.mjs <label> <Screen> [Screen...]"
@@ -0,0 +1,229 @@
1
+ // The in-page probe, in its own file so render-check.selftest.mjs can assert on
2
+ // it against a hand-built page instead of hoping some product screen happens to
3
+ // contain a disabled control, an undefined token and an empty state today.
4
+ //
5
+ export default function probe(opts) {
6
+ const root = document.querySelector(".app-content") || document.body;
7
+
8
+ const parseRGB = (s) => {
9
+ const m = String(s).match(/[\d.]+/g);
10
+ return m ? m.slice(0, 4).map(Number) : null;
11
+ };
12
+ const over = (fg, bg) => {
13
+ const a = fg.length > 3 ? fg[3] : 1;
14
+ return [0, 1, 2].map((i) => fg[i] * a + bg[i] * (1 - a));
15
+ };
16
+ const lum = (c) => {
17
+ const f = c.map((v) => {
18
+ v /= 255;
19
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
20
+ });
21
+ return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
22
+ };
23
+ const ratio = (a, b) => {
24
+ const [l1, l2] = [lum(a), lum(b)].sort((x, y) => y - x);
25
+ return (l1 + 0.05) / (l2 + 0.05);
26
+ };
27
+ const backdrop = (el) => {
28
+ const stack = [];
29
+ for (let n = el; n; n = n.parentElement) {
30
+ const bg = parseRGB(getComputedStyle(n).backgroundColor);
31
+ if (!bg) continue;
32
+ const a = bg.length > 3 ? bg[3] : 1;
33
+ if (a === 0) continue;
34
+ stack.push(bg);
35
+ if (a === 1) break;
36
+ }
37
+ let base = [255, 255, 255];
38
+ for (let i = stack.length - 1; i >= 0; i--) base = over(stack[i], base);
39
+ return base;
40
+ };
41
+ const visible = (el) => {
42
+ const r = el.getBoundingClientRect();
43
+ if (r.width < 1 || r.height < 1) return false;
44
+ const s = getComputedStyle(el);
45
+ return s.visibility !== "hidden" && s.display !== "none" && Number(s.opacity) > 0.05;
46
+ };
47
+
48
+ // A disabled control is exempt from WCAG 1.4.3, so its reading is not a
49
+ // defect. It is still recorded - separately - because "exempt" must not be a
50
+ // way for a real failure to disappear.
51
+ const DISABLED_SEL =
52
+ '[disabled],[aria-disabled="true"],.v-btn--disabled,.v-input--disabled,.v-field--disabled,fieldset[disabled]';
53
+ const isDisabled = (el) => !!el.closest(DISABLED_SEL);
54
+
55
+ const ownText = (el) =>
56
+ [...el.childNodes]
57
+ .filter((n) => n.nodeType === 3 && n.textContent.trim())
58
+ .map((n) => n.textContent.trim())
59
+ .join(" ");
60
+
61
+ const contrast = (only) => {
62
+ const out = [];
63
+ const seen = new Set();
64
+ for (const el of root.querySelectorAll("*")) {
65
+ if (only && !only.has(el)) continue;
66
+ if (!visible(el)) continue;
67
+ const own = ownText(el);
68
+ if (!own) continue;
69
+ const s = getComputedStyle(el);
70
+ const fg = parseRGB(s.color);
71
+ if (!fg) continue;
72
+ const bg = backdrop(el);
73
+ const px = parseFloat(s.fontSize);
74
+ const w = parseInt(s.fontWeight, 10) || 400;
75
+ const large = px >= 18.66 || (px >= 14 && w >= 700);
76
+ const need = large ? 3 : 4.5;
77
+ const r = ratio(over(fg, bg), bg);
78
+ if (r + 0.005 < need) {
79
+ const key = `${own.slice(0, 30)}|${s.color}|${px}`;
80
+ if (seen.has(key)) continue;
81
+ seen.add(key);
82
+ out.push({
83
+ text: own.slice(0, 40),
84
+ color: s.color,
85
+ bg: `rgb(${bg.map(Math.round).join(", ")})`,
86
+ px,
87
+ weight: w,
88
+ ratio: Math.round(r * 100) / 100,
89
+ need,
90
+ disabled: isDisabled(el),
91
+ });
92
+ }
93
+ }
94
+ return out;
95
+ };
96
+
97
+ // --- empty state -------------------------------------------------------
98
+ // Vuetify's own no-data slot, plus the hand-written variants the screens use
99
+ // ("No access logs found", "No records", "Nothing to show").
100
+ const EMPTY_RE = /^no\b[\w\s'-]{0,40}\b(data|records?|results?|items?|found|available|yet)\b|^nothing to (show|display)\b/i;
101
+ const emptyText = [];
102
+ for (const el of root.querySelectorAll("*")) {
103
+ if (!visible(el)) continue;
104
+ const t = ownText(el);
105
+ if (t && t.length < 60 && EMPTY_RE.test(t)) emptyText.push(t.slice(0, 50));
106
+ }
107
+
108
+ // A table with zero header cells is the `computedHeaders === undefined`
109
+ // trap: the screen mounts, the toolbar draws, and the thing under review -
110
+ // the header and cell type rules - was never on the glass.
111
+ const tables = [...root.querySelectorAll("table")].filter(visible).map((t) => ({
112
+ cols: t.querySelectorAll("thead th").length,
113
+ rows: [...t.querySelectorAll("tbody tr")].filter((tr) => !EMPTY_RE.test(tr.innerText.trim())).length,
114
+ }));
115
+
116
+ // A form whose every control is empty is the other shape of the same fixture
117
+ // bug, and it does NOT show an empty state or a short DOM - which is how
118
+ // `OvernightParkingAvailability` was signed off with every day toggled off
119
+ // and every time select blank at 1112 elements. That left its Save button
120
+ // permanently disabled, and the disabled ink is the entire source of the
121
+ // 1.75:1 / 2.21:1 readings that went into an AA ledger as real defects.
122
+ // A screen with several controls and not one value in any of them was fed
123
+ // nothing.
124
+ const controls = [...root.querySelectorAll("input, textarea, select, .v-field__input")].filter(visible);
125
+ const filled = controls.filter((c) => {
126
+ if (c.type === "checkbox" || c.type === "radio") return c.checked;
127
+ if ("value" in c && c.value !== undefined) return String(c.value).trim() !== "";
128
+ return c.textContent.trim() !== "";
129
+ }).length;
130
+ const emptyForm = controls.length >= 4 && filled === 0;
131
+
132
+ // --- blank screen ------------------------------------------------------
133
+ // Element count alone said "6 elements, no errors" and a counter read it as a
134
+ // pass. Painted text is the honest signal: a screen nobody can read is blank
135
+ // whatever its element count.
136
+ let textChars = 0;
137
+ for (const el of root.querySelectorAll("*")) if (visible(el)) textChars += ownText(el).length;
138
+
139
+ // --- undefined custom properties ---------------------------------------
140
+ // `color: var(--success)` where the token is `--ok`. An undefined custom
141
+ // property with no fallback invalidates the entire declaration, so the
142
+ // element inherits and nothing anywhere reports a problem.
143
+ //
144
+ // Two passes: collect every custom property DECLARED by any stylesheet, then
145
+ // look for `var(--x)` references (no fallback) to a name nothing declares.
146
+ // Referencing a name that IS declared somewhere is left alone - it may be
147
+ // scoped to a component that this screen does not draw, and guessing there
148
+ // would produce exactly the kind of invented defect this file exists to stop.
149
+ const declared = new Set();
150
+ const refs = new Map(); // --name -> {prop, selector}
151
+ for (const sheet of document.styleSheets) {
152
+ let rules;
153
+ try {
154
+ rules = sheet.cssRules;
155
+ } catch {
156
+ continue;
157
+ }
158
+ if (!rules) continue;
159
+ const id = sheet.ownerNode?.getAttribute?.("data-vite-dev-id") || sheet.href || "";
160
+ const ours = !/vuetify|materialdesignicons/i.test(id);
161
+ const walk = (list) => {
162
+ for (const rule of list) {
163
+ if (rule.cssRules) walk(rule.cssRules);
164
+ const st = rule.style;
165
+ if (!st) continue;
166
+ for (const prop of st) {
167
+ if (prop.startsWith("--")) declared.add(prop);
168
+ if (!ours) continue;
169
+ const v = st.getPropertyValue(prop);
170
+ if (!v.includes("var(")) continue;
171
+ for (const m of v.matchAll(/var\(\s*(--[\w-]+)\s*\)/g))
172
+ if (!refs.has(m[1])) refs.set(m[1], { prop, selector: (rule.selectorText || "").slice(0, 60) });
173
+ }
174
+ }
175
+ };
176
+ walk(rules);
177
+ }
178
+ const undefinedVars = [];
179
+ for (const [name, where] of refs) {
180
+ if (declared.has(name)) continue;
181
+ // last chance: a property set inline or by script rather than by a rule
182
+ if (getComputedStyle(document.documentElement).getPropertyValue(name).trim()) continue;
183
+ undefinedVars.push({ name, ...where });
184
+ }
185
+
186
+ // --- the rest ----------------------------------------------------------
187
+ const bars = [...root.querySelectorAll(".v-toolbar")].filter(visible);
188
+ const legacy = bars.filter(
189
+ (b) =>
190
+ !b.classList.contains("screen-dialog-bar") &&
191
+ [...b.classList].some((c) => /^bg-(grey|blue-grey|white|black)/.test(c)),
192
+ );
193
+ const t = root.querySelector(".screen-title, .page-header__title, h1, .text-h5, .text-h6");
194
+
195
+ const result = {
196
+ els: root.querySelectorAll("*").length,
197
+ textChars,
198
+ title: t ? t.innerText.trim().slice(0, 40) : null,
199
+ bars: bars.length,
200
+ legacy: legacy.length,
201
+ overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
202
+ emptyText: [...new Set(emptyText)],
203
+ tables,
204
+ controls: controls.length,
205
+ filled,
206
+ emptyForm,
207
+ undefinedVars,
208
+ fails: contrast(null),
209
+ };
210
+
211
+ // --- enabled re-probe --------------------------------------------------
212
+ // Runs LAST and mutates the DOM, so nothing above sees it. Strips the
213
+ // disabled state off every control that had one and re-reads contrast on
214
+ // just those elements, so "exempt because disabled" can never be the reason a
215
+ // real failure went unreported.
216
+ if (opts.probeEnabled) {
217
+ const touched = new Set();
218
+ for (const el of root.querySelectorAll(DISABLED_SEL)) {
219
+ el.removeAttribute("disabled");
220
+ el.removeAttribute("aria-disabled");
221
+ for (const c of [...el.classList]) if (/--disabled$/.test(c)) el.classList.remove(c);
222
+ for (const d of el.querySelectorAll("*")) touched.add(d);
223
+ touched.add(el);
224
+ }
225
+ void root.offsetHeight;
226
+ result.enabledFails = touched.size ? contrast(touched) : [];
227
+ }
228
+ return result;
229
+ }
@@ -0,0 +1,306 @@
1
+ // Hardened render check for the theme-review harness.
2
+ //
3
+ // This supersedes the ad-hoc `*-measure.mjs` runners. Every guard below exists
4
+ // because the older runners produced a FALSE PASS on 2026-08-14 - a green row
5
+ // for a screen that had not drawn the thing being reviewed. In order of how
6
+ // much damage each one did:
7
+ //
8
+ // 1. ISOLATION one shared `lc/` mirror, so two agents silently measured
9
+ // each other's branch. Now the mirror is stamped and the
10
+ // run aborts unless the stamp matches what it was pointed
11
+ // at (see harness-init.ps1).
12
+ // 2. EMPTY STATE a panel scored 8/8 dark-clean while drawing "No access
13
+ // logs found"; a table was signed off with zero columns and
14
+ // "No data available". A screen showing its empty state has
15
+ // not been measured - it is a fixture bug, and it FAILS.
16
+ // 3. BASELINE `MIN_ELS = 20` was both too low (a partial paint at 32
17
+ // els scored ok where warm was 49) and too high (a
18
+ // correctly-drawing screen at 19 els was reported
19
+ // NOT-MOUNTED). One global floor cannot be right for
20
+ // screens that legitimately range from 19 to 1100 elements,
21
+ // so each screen carries its own recorded baseline and an
22
+ // un-baselined screen is refused rather than guessed at.
23
+ // 4. COLD COMPILE a cold vite compile paints a PARTIAL screen that still
24
+ // cleared the floor. Warm-up is mandatory and unskippable,
25
+ // and any row well under its own screen's best is remeasured.
26
+ // 5. UNDEFINED VAR `var(--success)` where the token is `--ok`: an undefined
27
+ // custom property makes the whole declaration invalid and
28
+ // the element silently inherits. No error, no warning, and
29
+ // the render looks plausible. Now detected.
30
+ // 6. DISABLED NOISE disabled controls are WCAG-1.4.3-exempt, but the contrast
31
+ // probe logged them as failures, which put fictional
32
+ // entries in an AA ledger. Now classified, and the ENABLED
33
+ // state is probed separately so the exemption never hides a
34
+ // real failure.
35
+ // 7. DEFAULTS DRIFT `VAutocomplete` was missing from the harness's Vuetify
36
+ // defaults, so 19 components would have been measured
37
+ // unstyled - inventing a defect the product does not have.
38
+ // The harness defaults are now diffed against the product's
39
+ // `plugins/vuetify.ts` on every run.
40
+ //
41
+ // Usage:
42
+ // PORT=5412 node render-check.mjs <label> Screen [Screen...]
43
+ // PORT=5412 node render-check.mjs --record <label> Screen [Screen...]
44
+ //
45
+ // `--record` writes/updates baselines.json from THIS run. Only ever record from
46
+ // a run you have opened the screenshots for.
47
+ import { chromium } from "playwright";
48
+ import { writeFileSync, readFileSync, readdirSync, existsSync, mkdirSync } from "node:fs";
49
+ import { createHash } from "node:crypto";
50
+ // The in-page probe lives in its own file so `render-check.selftest.mjs` can
51
+ // assert on it directly. Passed to page.evaluate as a FUNCTION, never as a
52
+ // source string: a string expression cannot receive the argument, so `opts`
53
+ // arrived undefined and the enabled re-probe silently never ran.
54
+ import IN_PAGE from "./probe.mjs";
55
+
56
+ const args = process.argv.slice(2);
57
+ const record = args[0] === "--record" && args.shift();
58
+ const [label, ...screens] = args;
59
+ if (!label || !screens.length) {
60
+ console.error("usage: [--record] node render-check.mjs <label> Screen [Screen...]");
61
+ process.exit(1);
62
+ }
63
+
64
+ const PORT = Number(process.env.PORT || 0);
65
+ const WIDTHS = [360, 768, 1024, 1440];
66
+ const THEMES = ["light", "dark"];
67
+ const PARTIAL = 0.7; // a row this far under its own screen's best is a partial paint
68
+
69
+ let fatal = 0;
70
+ const die = (msg) => {
71
+ console.error(`FATAL: ${msg}`);
72
+ fatal++;
73
+ };
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // GUARD 1 - isolation. Refuse to measure a mirror we cannot prove the origin of.
77
+ // ---------------------------------------------------------------------------
78
+ if (!existsSync("lc/.synced-from.json")) {
79
+ die(
80
+ "lc/ carries no .synced-from.json stamp, so there is no way to tell which\n" +
81
+ " checkout is mounted. Run tools/render-harness/harness-init.ps1 first.",
82
+ );
83
+ }
84
+ // The mirror is compared to the worktree by CONTENT, not by commit: an
85
+ // uncommitted edit is exactly as invisible as a foreign re-sync, and a commit
86
+ // that touches nothing the harness renders should not force a re-sync.
87
+ const MIRRORED = ["assets", "components", "composables", "constants", "types", "utils"];
88
+ const fingerprint = (base) => {
89
+ const files = [];
90
+ const walk = (dir, rel) => {
91
+ if (!existsSync(dir)) return;
92
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
93
+ if (e.isDirectory()) walk(`${dir}/${e.name}`, `${rel}/${e.name}`);
94
+ else files.push([`${rel}/${e.name}`, createHash("sha1").update(readFileSync(`${dir}/${e.name}`)).digest("hex")]);
95
+ }
96
+ };
97
+ for (const d of MIRRORED) walk(`${base}/${d}`, d);
98
+ files.sort((a, b) => (a[0] < b[0] ? -1 : 1));
99
+ return new Map(files);
100
+ };
101
+
102
+ let stamp = null;
103
+ if (!fatal) {
104
+ stamp = JSON.parse(readFileSync("lc/.synced-from.json", "utf8"));
105
+ if (PORT && stamp.port !== PORT)
106
+ die(`mirror was stamped for port ${stamp.port} but PORT=${PORT} - that is another agent's server.`);
107
+
108
+ const mine = fingerprint(stamp.worktree);
109
+ const mirror = fingerprint("lc");
110
+ let diff = null;
111
+ for (const [p, h] of mine) if (mirror.get(p) !== h) { diff = mirror.has(p) ? `differs: ${p}` : `missing: ${p}`; break; }
112
+ if (!diff) for (const p of mirror.keys()) if (!mine.has(p)) { diff = `stale extra: ${p}`; break; }
113
+ if (diff)
114
+ die(
115
+ `lc/ does not match ${stamp.worktree} (${diff}).\n` +
116
+ " Either the worktree moved on, or another agent's sync-lc.ps1 wrote over this mirror.\n" +
117
+ " Re-run harness-init.ps1; measuring now would report someone else's code.",
118
+ );
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // GUARD 2 - Vuetify defaults drift. The harness has to configure Vuetify the
123
+ // same way the product does, or it measures a component the product never ships.
124
+ // Compared by parsing both files rather than by keeping a second hand-written
125
+ // list, which is the thing that went stale in the first place.
126
+ // ---------------------------------------------------------------------------
127
+ const defaultKeys = (src) => {
128
+ const at = src.indexOf("defaults:");
129
+ if (at < 0) return null;
130
+ let depth = 0,
131
+ end = at;
132
+ for (let i = src.indexOf("{", at); i < src.length; i++) {
133
+ if (src[i] === "{") depth++;
134
+ else if (src[i] === "}" && --depth === 0) {
135
+ end = i;
136
+ break;
137
+ }
138
+ }
139
+ const block = src.slice(at, end);
140
+ // only top-level component keys, and not the ones commented out
141
+ return new Set(
142
+ block
143
+ .split("\n")
144
+ .filter((l) => !l.trim().startsWith("//"))
145
+ .join("\n")
146
+ .match(/\bV[A-Z][A-Za-z]*(?=:\s*\{)/g) ?? [],
147
+ );
148
+ };
149
+ if (!fatal && stamp) {
150
+ const productFile = `${stamp.worktree}/plugins/vuetify.ts`;
151
+ if (existsSync(productFile) && existsSync("src/main.js")) {
152
+ const want = defaultKeys(readFileSync(productFile, "utf8"));
153
+ const have = defaultKeys(readFileSync("src/main.js", "utf8"));
154
+ const missing = [...(want ?? [])].filter((k) => !have?.has(k));
155
+ if (missing.length)
156
+ die(
157
+ `harness Vuetify defaults are missing ${missing.join(", ")}, which plugins/vuetify.ts sets.\n` +
158
+ " Those components would render unstyled here and the run would invent a defect.",
159
+ );
160
+ }
161
+ }
162
+
163
+ if (fatal) process.exit(1);
164
+
165
+ const shotDir = `shots-${label}`;
166
+ mkdirSync(shotDir, { recursive: true });
167
+ const baselines = existsSync("baselines.json") ? JSON.parse(readFileSync("baselines.json", "utf8")) : {};
168
+
169
+ // ---------------------------------------------------------------------------
170
+ const browser = await chromium.launch();
171
+ const URL_FOR = (name, theme) => `http://localhost:${PORT}/?screen=module&main=${name}&theme=${theme}`;
172
+
173
+ const probe = async (name, theme, width, shot) => {
174
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
175
+ const errs = [];
176
+ page.on("pageerror", (e) => errs.push(String(e).slice(0, 160)));
177
+ await page.goto(URL_FOR(name, theme), { waitUntil: "networkidle" });
178
+ // Wait for the DOM to STOP GROWING rather than for a fixed 900ms. A cold vite
179
+ // compile paints a screen in pieces, and the old fixed wait sometimes ended
180
+ // mid-paint: NFCTagMain came back els:32 bars:0 and scored ok where the warm
181
+ // render is els:49 with two legacy toolbars. That is worse than a blank,
182
+ // because every number in the row looks like a successful conversion.
183
+ let last = -1;
184
+ for (let i = 0; i < 40; i++) {
185
+ await page.waitForTimeout(150);
186
+ const n = await page.evaluate(() => (document.querySelector(".app-content") || document.body).querySelectorAll("*").length);
187
+ if (n === last && i >= 3) break; // 3 polls minimum, then two agreeing reads
188
+ last = n;
189
+ }
190
+ const r = await page.evaluate(IN_PAGE, { probeEnabled: false });
191
+ if (shot) await page.screenshot({ path: shot, fullPage: true });
192
+ // after the screenshot, because it edits the DOM
193
+ const withEnabled = await page.evaluate(IN_PAGE, { probeEnabled: true });
194
+ await page.close();
195
+ return { name, theme, width, ...r, enabledFails: withEnabled.enabledFails ?? [], errs };
196
+ };
197
+
198
+ // Mandatory warm-up. Not a flag: a cold compile paints a partial screen that
199
+ // looks like a pass, and an option to skip this is an option to publish fiction.
200
+ const warm = new Map();
201
+ for (const name of screens) warm.set(name, (await probe(name, "light", 1440, null)).els);
202
+ // Printed, not just used: on a cold vite cache the warm-up number comes back
203
+ // materially UNDER the steady one, and that gap is the whole of trap 4. Seeing
204
+ // it is how you know the warm-up earned its place in this run rather than in
205
+ // some other one.
206
+ console.log(`warm-up (discarded): ${[...warm].map(([n, e]) => `${n} els:${e}`).join(", ")}\n`);
207
+
208
+ const rows = [];
209
+ for (const name of screens) {
210
+ const slug = name.replace(/\//g, "-");
211
+ for (const theme of THEMES)
212
+ for (const width of WIDTHS)
213
+ rows.push(await probe(name, theme, width, `${shotDir}/${slug}--${theme}--${width}.png`));
214
+ }
215
+
216
+ // Partial-paint guard: seeded with the warm-up so a screen whose every measured
217
+ // row is partial still has something honest to be compared against.
218
+ const best = new Map(warm);
219
+ for (const r of rows) best.set(r.name, Math.max(best.get(r.name) ?? 0, r.els));
220
+ for (let i = 0; i < rows.length; i++) {
221
+ const r = rows[i];
222
+ if (r.els >= best.get(r.name) * PARTIAL) continue;
223
+ const slug = r.name.replace(/\//g, "-");
224
+ console.log(` re-measuring ${r.name} ${r.theme} ${r.width} (els:${r.els} vs best ${best.get(r.name)})`);
225
+ rows[i] = await probe(r.name, r.theme, r.width, `${shotDir}/${slug}--${r.theme}--${r.width}.png`);
226
+ rows[i].remeasured = true;
227
+ }
228
+ await browser.close();
229
+
230
+ writeFileSync(`${label}-measure.json`, JSON.stringify(rows, null, 2));
231
+
232
+ if (record) {
233
+ for (const name of screens) {
234
+ const mine = rows.filter((r) => r.name === name);
235
+ baselines[name] = {
236
+ ...baselines[name],
237
+ els: Math.max(...mine.map((r) => r.els)),
238
+ textChars: Math.max(...mine.map((r) => r.textChars)),
239
+ recordedFrom: stamp.head.slice(0, 7),
240
+ };
241
+ }
242
+ writeFileSync("baselines.json", JSON.stringify(baselines, null, 2) + "\n");
243
+ console.log(`recorded ${screens.length} baseline(s) -> baselines.json`);
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Verdict. Every one of these is a hard fail; none of them used to be.
248
+ // ---------------------------------------------------------------------------
249
+ let bad = 0;
250
+ for (const r of rows) {
251
+ const base = baselines[r.name];
252
+ const problems = [];
253
+
254
+ if (!base) problems.push("NO-BASELINE");
255
+ else if (r.els < Math.floor(base.els * PARTIAL)) problems.push(`UNDER-BASELINE (${r.els}/${base.els})`);
256
+
257
+ if (r.textChars === 0) problems.push("BLANK");
258
+ else if (base && r.textChars < Math.floor(base.textChars * PARTIAL))
259
+ problems.push(`UNDER-TEXT (${r.textChars}/${base.textChars})`);
260
+
261
+ if (r.els < best.get(r.name) * PARTIAL) problems.push(`PARTIAL (${r.els}/${best.get(r.name)})`);
262
+
263
+ if (!base?.allowEmpty) {
264
+ if (r.emptyText.length) problems.push(`EMPTY-STATE "${r.emptyText[0]}"`);
265
+ if (r.emptyForm) problems.push(`EMPTY-FORM (0/${r.controls} controls hold a value)`);
266
+ for (const t of r.tables) {
267
+ if (!t.cols) problems.push("TABLE-0-COLS");
268
+ else if (!t.rows) problems.push("TABLE-0-ROWS");
269
+ }
270
+ }
271
+ if (r.undefinedVars.length)
272
+ problems.push(`UNDEFINED-VAR ${r.undefinedVars.map((v) => v.name).join(",")}`);
273
+
274
+ const aa = r.fails.filter((f) => !f.disabled);
275
+ const exempt = r.fails.length - aa.length;
276
+ if (aa.length) problems.push(`AA:${aa.length}`);
277
+ if (r.enabledFails.length) problems.push(`AA-ENABLED:${r.enabledFails.length}`);
278
+ if (r.overflow > 0) problems.push(`OVERFLOW+${r.overflow}`);
279
+ if (r.legacy) problems.push(`LEGACY:${r.legacy}`);
280
+
281
+ if (problems.length) bad++;
282
+ console.log(
283
+ `${r.name.padEnd(28)} ${r.theme.padEnd(5)} ${String(r.width).padStart(4)} els:${String(r.els).padStart(4)}` +
284
+ ` txt:${String(r.textChars).padStart(5)} bars:${r.bars}(legacy ${r.legacy}) title:${r.title ?? "NONE"}` +
285
+ `${exempt ? ` exempt:${exempt}` : ""} ${problems.join(" ") || "ok"}`,
286
+ );
287
+ if (r.errs.length) console.log(" pageerror: " + r.errs[0]);
288
+ }
289
+ console.log(`\n${rows.length} rows, ${bad} with problems. json -> ${label}-measure.json, shots -> ${shotDir}/`);
290
+
291
+ const agg = new Map();
292
+ for (const r of rows)
293
+ for (const f of [...r.fails.filter((x) => !x.disabled), ...r.enabledFails.map((x) => ({ ...x, forced: true }))]) {
294
+ const k = `${f.color}|${f.px}|${f.weight}|${f.bg}|${f.forced ? "en" : ""}`;
295
+ if (!agg.has(k)) agg.set(k, { ...f, where: new Set() });
296
+ agg.get(k).where.add(`${r.name}/${r.theme}`);
297
+ }
298
+ if (agg.size) {
299
+ console.log("\nDISTINCT CONTRAST FAILURES (worst first; [forced-enabled] = read after removing the disabled state)");
300
+ for (const f of [...agg.values()].sort((a, b) => a.ratio - b.ratio))
301
+ console.log(
302
+ ` ${String(f.ratio).padStart(5)}:1 (need ${f.need}) ${f.px}px/${f.weight} ${f.color} on ${f.bg}` +
303
+ ` "${f.text}"${f.forced ? " [forced-enabled]" : ""} [${[...f.where].join(", ")}]`,
304
+ );
305
+ }
306
+ process.exit(bad ? 1 : 0);
@@ -0,0 +1,129 @@
1
+ // Proves each detector in probe.mjs FAILS the scenario it was written for, and
2
+ // passes the corrected version of that same scenario.
3
+ //
4
+ // Against a hand-built page rather than a product screen, on purpose: which
5
+ // screen happens to contain a disabled control or an undefined token changes
6
+ // week to week, and a guard nobody can demonstrate failing is not a guard.
7
+ //
8
+ // node render-check.selftest.mjs
9
+ import { chromium } from "playwright";
10
+ import probe from "./probe.mjs";
11
+
12
+ let failed = 0;
13
+ const check = (name, cond, got) => {
14
+ console.log(`${cond ? " ok " : "FAIL "}${name}${cond ? "" : ` got: ${JSON.stringify(got)}`}`);
15
+ if (!cond) failed++;
16
+ };
17
+
18
+ const page = await (await chromium.launch()).newPage({ viewport: { width: 1200, height: 900 } });
19
+ const run = async (html, opts = { probeEnabled: true }) => {
20
+ await page.setContent(`<div class="app-content">${html}</div>`, { waitUntil: "load" });
21
+ return page.evaluate(probe, opts);
22
+ };
23
+
24
+ // --- empty state -----------------------------------------------------------
25
+ // The HID access-logs panel scored 8/8 dark-clean while drawing this string.
26
+ check(
27
+ "empty state: 'No access logs found' is detected",
28
+ (await run(`<p>No access logs found</p>`)).emptyText.length === 1,
29
+ );
30
+ check(
31
+ "empty state: real content is not",
32
+ (await run(`<p>Lobby ANPR</p><p>Carpark Exit</p>`)).emptyText.length === 0,
33
+ );
34
+
35
+ // --- table shape -----------------------------------------------------------
36
+ // CameraMain: computedHeaders returned undefined, so the table drew no columns
37
+ // at all and the header/cell rules under review were never on the glass.
38
+ const noCols = await run(`<table><thead><tr></tr></thead><tbody><tr><td>x</td></tr></tbody></table>`);
39
+ check("table: zero header columns is detected", noCols.tables[0].cols === 0, noCols.tables);
40
+ const ok = await run(
41
+ `<table><thead><tr><th>URL</th><th>Type</th></tr></thead><tbody><tr><td>a</td><td>b</td></tr></tbody></table>`,
42
+ );
43
+ check("table: a drawn table reports its columns and rows", ok.tables[0].cols === 2 && ok.tables[0].rows === 1, ok.tables);
44
+ const noRows = await run(
45
+ `<table><thead><tr><th>URL</th></tr></thead><tbody><tr><td>No data available</td></tr></tbody></table>`,
46
+ );
47
+ check("table: the no-data row is not counted as a row", noRows.tables[0].rows === 0, noRows.tables);
48
+
49
+ // --- blank screen ----------------------------------------------------------
50
+ // OvernightParkingAvailability once rendered blank with zero page errors and
51
+ // six elements, and a counter read that as a pass.
52
+ check("blank: no painted text reports textChars 0", (await run(`<div><span></span><span></span></div>`)).textChars === 0);
53
+ const painted = await run(`<p>Seventh Condominium</p>`);
54
+ check("blank: painted text is counted", painted.textChars === 19, painted.textChars);
55
+
56
+ // --- empty form ------------------------------------------------------------
57
+ // The other shape of the same fixture bug: 1112 elements, nothing blank, and
58
+ // not one control holding a value - which is what left Save disabled and put
59
+ // its disabled ink into an AA ledger as a real defect.
60
+ const blankForm = await run(
61
+ `<form>${'<input type="text" value="">'.repeat(3)}<input type="checkbox"><input type="checkbox"></form>`,
62
+ );
63
+ check("empty form: no control holds a value is detected", blankForm.emptyForm === true, blankForm);
64
+ const oneValue = await run(
65
+ `<form><input type="text" value="22:00">${'<input type="text" value="">'.repeat(2)}<input type="checkbox" checked><input type="checkbox"></form>`,
66
+ );
67
+ check("empty form: one saved value clears it", oneValue.emptyForm === false, oneValue);
68
+
69
+ // --- undefined custom property ---------------------------------------------
70
+ // `var(--success)` where the token is `--ok`. The declaration is invalid, the
71
+ // element inherits, and nothing anywhere reports a problem.
72
+ const badVar = await run(
73
+ `<style>:root{--ok:#0e7c58}.day-tick--on{color:var(--success)}</style><p class="day-tick--on">on</p>`,
74
+ );
75
+ check(
76
+ "undefined var: var(--success) with no such token is detected",
77
+ badVar.undefinedVars.length === 1 && badVar.undefinedVars[0].name === "--success",
78
+ badVar.undefinedVars,
79
+ );
80
+ const goodVar = await run(
81
+ `<style>:root{--ok:#0e7c58}.day-tick--on{color:var(--ok)}</style><p class="day-tick--on">on</p>`,
82
+ );
83
+ check("undefined var: the correct token is not flagged", goodVar.undefinedVars.length === 0, goodVar.undefinedVars);
84
+ // The 2026-08-14 bug survived a full 8-shot render because the element never
85
+ // drew. Catching it only when it draws would not have caught it.
86
+ const unrendered = await run(
87
+ `<style>:root{--ok:#0e7c58}.day-tick--on{color:var(--success)}</style><p>no tick on this screen</p>`,
88
+ );
89
+ check(
90
+ "undefined var: caught even when nothing on the screen uses the rule",
91
+ unrendered.undefinedVars.length === 1,
92
+ unrendered.undefinedVars,
93
+ );
94
+ // A name that IS declared elsewhere must not be flagged, or every scoped
95
+ // component variable becomes an invented defect.
96
+ const scoped = await run(
97
+ `<style>.card{--pad:8px}.other{padding:var(--pad)}</style><p class="other">x</p>`,
98
+ );
99
+ check("undefined var: a property declared elsewhere is left alone", scoped.undefinedVars.length === 0, scoped.undefinedVars);
100
+
101
+ // --- disabled classification + enabled re-probe ------------------------------
102
+ // Disabled controls are WCAG-1.4.3-exempt. Logging them as failures put
103
+ // fictional entries in an AA ledger; hiding them entirely would let a real
104
+ // failure escape behind the exemption. Both, separately.
105
+ const dis = await run(
106
+ `<style>button{background:#ffffff;color:#c9c9c9;font-size:14px}</style><button disabled>Save</button>`,
107
+ );
108
+ check("disabled: the reading is captured", dis.fails.length === 1, dis.fails);
109
+ check("disabled: and classified exempt, not counted as AA", dis.fails[0]?.disabled === true, dis.fails[0]);
110
+ check(
111
+ "disabled: the ENABLED state is probed separately and still fails",
112
+ dis.enabledFails.length === 1,
113
+ dis.enabledFails,
114
+ );
115
+ const enabled = await run(
116
+ `<style>button{background:#ffffff;color:#c9c9c9;font-size:14px}</style><button>Save</button>`,
117
+ );
118
+ check("disabled: an enabled control is not marked exempt", enabled.fails[0]?.disabled === false, enabled.fails[0]);
119
+ const contrasty = await run(
120
+ `<style>button{background:#ffffff;color:#1b1d21;font-size:14px}</style><button disabled>Save</button>`,
121
+ );
122
+ check(
123
+ "disabled: a disabled control that also passes enabled reports nothing",
124
+ contrasty.fails.length === 0 && contrasty.enabledFails.length === 0,
125
+ contrasty.fails,
126
+ );
127
+
128
+ console.log(failed ? `\n${failed} FAILED` : "\nall detectors fire");
129
+ process.exit(failed ? 1 : 0);