@7365admin1/layer-common 3.2.2-staging.121 → 3.2.2-staging.123
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.
- package/assets/css/primitives.css +92 -0
- package/assets/css/tokens.css +16 -0
- package/components/AppButton.vue +10 -1
- package/components/StatusChip.vue +63 -0
- package/components/VisitorManagement.vue +20 -13
- package/nuxt.config.ts +6 -1
- package/package.json +22 -1
- package/plugins/vuetify.ts +37 -1
- package/utils/materialSymbols.ts +363 -0
- package/.changeset/README.md +0 -8
- package/.changeset/camera-wall-gated-endpoint.md +0 -47
- package/.changeset/camera-wall-plain-english.md +0 -60
- package/.changeset/camera-wall-selection-and-guidance.md +0 -65
- package/.changeset/config.json +0 -11
- package/.changeset/dark-primary-contrast.md +0 -39
- package/.changeset/dashboard-dark-theme-contrast.md +0 -15
- package/.changeset/notification-preferences.md +0 -17
- package/.changeset/pm-sidebar-grouping-and-list-scaffold.md +0 -27
- package/.changeset/service-provider-invitation-actions.md +0 -28
- package/.editorconfig +0 -12
- package/.github/workflows/main.yml +0 -17
- package/.github/workflows/publish-staging.yml +0 -47
- package/.github/workflows/publish.yml +0 -39
- package/PUBLISHING.md +0 -269
- package/components/ScheduleTaskAreaFormDialog.vue +0 -141
- package/components/ScheduleTaskAreaUpdateMoreAction.vue +0 -104
- package/components/TableWithButton.vue +0 -94
- package/test/visitor-socket.test.mjs +0 -36
- package/tools/render-harness/README.md +0 -87
- package/tools/render-harness/baselines.json +0 -117
- package/tools/render-harness/harness-init.ps1 +0 -127
- package/tools/render-harness/probe.mjs +0 -229
- package/tools/render-harness/render-check.mjs +0 -306
- package/tools/render-harness/render-check.selftest.mjs +0 -129
|
@@ -1,229 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,306 +0,0 @@
|
|
|
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);
|
|
@@ -1,129 +0,0 @@
|
|
|
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);
|