@techierathore/techieflow 1.1.0 → 1.2.0

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.
Files changed (106) hide show
  1. package/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
  2. package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
  3. package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
  4. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +7 -5
  5. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
  6. package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
  7. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
  8. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +4 -4
  9. package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
  10. package/.claude/commands/TechieFlow/tasks/fix-issues.md +1 -0
  11. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  12. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
  13. package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
  14. package/.opencode/plugin/techieflow.js +15 -7
  15. package/.tfcore/agents/flow-master.md +2 -0
  16. package/.tfcore/agents/verifier.md +2 -2
  17. package/.tfcore/core-config.yaml +2 -1
  18. package/.tfcore/hooks/block-git.sh +25 -2
  19. package/.tfcore/hooks/guard-db.sh +106 -9
  20. package/.tfcore/hooks/guard-status-html.sh +5 -1
  21. package/.tfcore/hooks/guard-verify-deps.sh +16 -1
  22. package/.tfcore/hooks/yolo-allow.sh +27 -0
  23. package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
  24. package/.tfcore/standards/coding-standards-dotnet.md +31 -3
  25. package/.tfcore/tasks/_develop-mode.md +34 -0
  26. package/.tfcore/tasks/_smoke-test-policy.md +7 -5
  27. package/.tfcore/tasks/_yolo-mode.md +2 -2
  28. package/.tfcore/tasks/amend-docs.md +1 -1
  29. package/.tfcore/tasks/day1-brownfield.md +2 -2
  30. package/.tfcore/tasks/day1-greenfield.md +4 -4
  31. package/.tfcore/tasks/develop-end-to-end.md +30 -0
  32. package/.tfcore/tasks/fix-issues.md +1 -0
  33. package/.tfcore/tasks/mockups.md +1 -1
  34. package/.tfcore/tasks/triage-and-fix.md +2 -2
  35. package/.tfcore/tasks/verify-phase.md +12 -11
  36. package/.tfcore/telemetry/SCHEMA.md +6 -2
  37. package/.tfcore/telemetry/tf-metrics.sh +19 -0
  38. package/.tfcore/templates/stack-defaults/dotnet.md +5 -1
  39. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
  40. package/.tfcore/templates/v4custom/app-brd-tmpl.md +2 -1
  41. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
  42. package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
  43. package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +4 -1
  44. package/.tfcore/templates/v4custom/html-render-shell.md +2 -0
  45. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  46. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  47. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  48. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  49. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  50. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  51. package/.tfcore/utils/tf-appium.mjs +179 -0
  52. package/.tfcore/utils/tf-brd-status.py +10 -1
  53. package/.tfcore/utils/tf-build-list.py +16 -1
  54. package/.tfcore/utils/tf-build.sh +75 -0
  55. package/.tfcore/utils/tf-checklist-edit.py +28 -3
  56. package/.tfcore/utils/tf-day1-files.py +55 -28
  57. package/.tfcore/utils/tf-develop.sh +264 -0
  58. package/.tfcore/utils/tf-devguide-list.py +6 -2
  59. package/.tfcore/utils/tf-doc-check.py +124 -21
  60. package/.tfcore/utils/tf-doc-tests.sh +40 -3
  61. package/.tfcore/utils/tf-emit.sh +40 -10
  62. package/.tfcore/utils/tf-fix-close.sh +13 -7
  63. package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
  64. package/.tfcore/utils/tf-menus.mjs +401 -0
  65. package/.tfcore/utils/tf-mockup-parity.mjs +361 -28
  66. package/.tfcore/utils/tf-mockup-parity.sh +14 -3
  67. package/.tfcore/utils/tf-owner-text.py +27 -5
  68. package/.tfcore/utils/tf-phase.sh +24 -4
  69. package/.tfcore/utils/tf-render-html.py +19 -3
  70. package/.tfcore/utils/tf-req-ids.py +36 -0
  71. package/.tfcore/utils/tf-stack-check.sh +3 -0
  72. package/.tfcore/utils/tf-status-facts.py +90 -15
  73. package/.tfcore/utils/tf-theme.mjs +189 -0
  74. package/.tfcore/utils/tf-triage.py +30 -4
  75. package/.tfcore/utils/tf-verify-boot.sh +296 -21
  76. package/.tfcore/utils/tf-verify-emit.sh +37 -17
  77. package/.tfcore/utils/tf-verify-list.py +219 -20
  78. package/.tfcore/utils/tf-verify-list.sh +3 -1
  79. package/.tfcore/utils/tf-verify-native.mjs +307 -0
  80. package/.tfcore/utils/tf-verify-screens.mjs +184 -20
  81. package/.tfcore/utils/tf-verify-screens.sh +17 -4
  82. package/.tfcore/utils/tf-verify-tests.sh +107 -17
  83. package/.tfcore/utils/tf-verify-verdict.py +118 -15
  84. package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
  85. package/.tfcore/utils/tf-verify-zoom.sh +29 -0
  86. package/.tfcore/utils/tf-yolo-alias.sh +84 -0
  87. package/.tfcore/utils/tf-yolo-perms.py +135 -0
  88. package/.tfcore/utils/tf-yolo-start.sh +44 -0
  89. package/.tfcore/utils/tf-yolo.sh +31 -3
  90. package/.tfcore/utils/tf_feedback.py +86 -18
  91. package/.tfcore/utils/tf_stack_check.py +93 -0
  92. package/README.md +2 -2
  93. package/opencode.jsonc +6 -2
  94. package/package.json +1 -1
  95. package/scripts/install.mjs +14 -3
  96. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  97. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  98. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  99. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  100. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  101. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  102. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  103. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  104. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  105. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  106. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
@@ -0,0 +1,307 @@
1
+ // tf-verify-native.mjs — the render and visual checks on a MAUI Mac Catalyst head, over Appium mac2.
2
+ // Run through tf-verify-screens.sh --appium <url>, not directly.
3
+ //
4
+ // The native twin of tf-verify-screens.mjs: the same list.json in, the same screens.json out (mode
5
+ // "appium"), so tf-verify-verdict.sh grades a Mac screen exactly as a web one. The mockup's
6
+ // data-testid anchors are looked for as AutomationId (the accessibility identifier the Mac shows).
7
+ // render every anchored control is present and shows something (text, a value, or content
8
+ // inside it); an anchored list has rows; the window is not blank; no error bar.
9
+ // visual no two visible controls overlap; no anchored control has zero size or sits outside
10
+ // the window. (A native window has no stylesheet and no sideways page scroll to check.)
11
+ // A web view head (Blazor Hybrid; boot.json webview, or --no-names): on a Mac neither data-testid
12
+ // nor an HTML id reaches mac2, so controls are not matched by name. Everything else runs: blank
13
+ // window, error bar (--error-text, repeatable; the defaults are the stock Blazor messages), overlap,
14
+ // and the screenshot. Each screen records anchors_not_measured, and the verdict says the name check
15
+ // was not measured (owner decision A, 2026-10-07).
16
+ // A screen is reached by clicking, inside the app, the control whose AutomationId is
17
+ // nav-<screen name> or, failing that, the tab, button, link or list item labelled with the screen's
18
+ // name, or by clicking in turn the labels the list read from the code for it (list.json `nav`, from
19
+ // tf-verify-list.sh: "Images", or "Users" then "+ Add New User"). Only a screen whose route is "/" may be
20
+ // the one the app opens on; any other screen nothing reached is UNREACHABLE and is never graded on
21
+ // whatever was on view (Lekhak TF-027: Search results passed on BlogAdmin's Dashboard). A screen the
22
+ // list says another project serves (a website page, when the Mac head is the desktop app) is skipped
23
+ // as another head's. The session opens the app by its .app path (boot.json app_path, or --app-path)
24
+ // when known, else by bundle id. Each screen is measured at every --widths (default 1280,390): the
25
+ // window is resized through macOS, the width it really took is recorded (an app's minimum size holds),
26
+ // and the screenshot is that window alone, taken by macOS by its window number.
27
+ // --from-source a.xml,b.xml grades saved page sources instead of a running app (one screen per
28
+ // file, no screenshot): how the self-tests check the grading where there is no Mac.
29
+
30
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
31
+ import { basename, dirname, join, normalize, resolve } from 'node:path';
32
+ import { openSession, parseSource, descendants, appPid, resizeWindow, windowShot, macWindow } from './tf-appium.mjs';
33
+
34
+ const argv = process.argv.slice(2);
35
+ const arg = (n, d = null) => { const i = argv.indexOf(n); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
36
+ const args = (n) => argv.flatMap((a, i) => (a === n && i + 1 < argv.length ? [argv[i + 1]] : []));
37
+ const APPIUM = (arg('--appium') || '').replace(/\/$/, '');
38
+ const BOOT = arg('--boot', 'tests/.artifacts/verify/boot.json');
39
+ const boot = existsSync(BOOT) ? JSON.parse(readFileSync(BOOT, 'utf8')) : {};
40
+ const BUNDLE = arg('--bundle') || boot.bundle_id || '';
41
+ const APP_PATH = arg('--app-path') || boot.app_path || '';
42
+ const LIST = arg('--list');
43
+ const MOCKUPS = arg('--mockups', 'docs/mockups');
44
+ const OUT = arg('--json-out', 'tests/.artifacts/verify/screens.json');
45
+ const SHOTS = arg('--shots-dir', 'tests/.artifacts/verify/screens');
46
+ const RENDER_WAIT = parseInt(arg('--render-wait', '5000'), 10);
47
+ const SETTLE = parseInt(arg('--wait', '1500'), 10); // after a click, before the first look
48
+ const ATTR = arg('--testid-attr', 'data-testid');
49
+ const FROM_SOURCE = (arg('--from-source') || '').split(',').filter(Boolean);
50
+ const NO_NAMES = argv.includes('--no-names') || boot.webview === true;
51
+ const ERROR_TEXTS = [...args('--error-text'), 'An unhandled error has occurred', 'An error has occurred. This application may no longer respond']
52
+ .map((t) => t.toLowerCase());
53
+ if (!FROM_SOURCE.length && (!APPIUM || !(BUNDLE || APP_PATH))) { console.error('tf-verify-native: --appium URL and the app (boot.json from tf-verify-boot.sh, --bundle or --app-path) are required'); process.exit(3); }
54
+
55
+ let screens = [];
56
+ if (LIST && !existsSync(LIST)) { console.error(`tf-verify-native: ${LIST} does not exist; make it first with bash .tfcore/utils/tf-verify-list.sh <App> ui`); process.exit(3); }
57
+ if (LIST) screens = (JSON.parse(readFileSync(LIST, 'utf8')).screens || []).map((s) => ({ name: s.name, route: s.route, mockup: s.mockup || '', rows: s.rows || [],
58
+ nav: s.nav || null, project: s.project || '' }));
59
+ for (const s of args('--screen')) {
60
+ const m = s.match(/^([^=]+)=(.*)$/);
61
+ if (m) screens.push({ name: m[1], route: m[2], mockup: `${MOCKUPS}/${m[1].toLowerCase().replace(/[^a-z0-9]+/g, '-')}.html`, rows: [] });
62
+ }
63
+ if (FROM_SOURCE.length && !screens.length) screens = FROM_SOURCE.map((f) => ({ name: basename(f).replace(/\.xml$/, ''), route: '', mockup: '', rows: [] }));
64
+ if (!screens.length) { console.error('tf-verify-native: no screens (--list <json> or --screen name=route)'); process.exit(3); }
65
+
66
+ const WIDTHS = String(arg('--widths', '1280,390')).split(',').map((x) => parseInt(x, 10)).filter((x) => x > 0);
67
+ // The booted project and every project it references: a screen another project serves is another head's.
68
+ const heads = (() => {
69
+ const out = new Set(); const todo = boot.project ? [dirname(boot.project)] : [];
70
+ while (todo.length) {
71
+ const d = todo.pop().replace(/\/$/, '');
72
+ if (out.has(d)) continue; out.add(d);
73
+ try {
74
+ const proj = readdirSync(d).find((f) => f.endsWith('.csproj'));
75
+ for (const m of readFileSync(`${d}/${proj}`, 'utf8').matchAll(/<ProjectReference\s+Include="([^"]+)"/g))
76
+ todo.push(normalize(join(d, dirname(m[1].replace(/\\/g, '/')))));
77
+ } catch { /* not a project folder */ }
78
+ }
79
+ return out;
80
+ })();
81
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
82
+ const anchorsOf = (mockup) => { // as tf-verify-screens.mjs: an attribute on an element, not in a style or script
83
+ if (!mockup || !existsSync(mockup)) return null;
84
+ const html = readFileSync(mockup, 'utf8').replace(/<!--[\s\S]*?-->/g, '').replace(/<(style|script)\b[\s\S]*?<\/\1\s*>/gi, '');
85
+ const re = new RegExp(`<[^>]*?\\b${ATTR}\\s*=\\s*["']([^"']+)["']`, 'g');
86
+ const out = new Set(); let m;
87
+ while ((m = re.exec(html))) out.add(m[1]);
88
+ return [...out];
89
+ };
90
+
91
+ const LISTS = new Set(['CollectionView', 'Table', 'Outline']);
92
+ const CHROME = new Set(['TabBar', 'Toolbar', 'NavigationBar', 'MenuBar', 'Menu']);
93
+ const LEAF = new Set(['Button', 'StaticText', 'TextField', 'SecureTextField', 'TextView', 'Image', 'CheckBox', 'Switch', 'Slider',
94
+ 'PopUpButton', 'ComboBox', 'Link', 'RadioButton', 'SegmentedControl', 'Stepper', 'DatePicker', 'ProgressIndicator', 'Cell']);
95
+ const WEB_CONTROLS = new Set(['Button', 'Link', 'TextField', 'SecureTextField', 'TextView', 'CheckBox', 'RadioButton', 'PopUpButton', 'ComboBox', 'Heading']);
96
+ const shows = (n) => [n.label, n.value, n.title].some((t) => (t || '').trim().length > 0);
97
+
98
+ function windowOf(nodes) { return nodes.find((n) => n.type === 'Window'); }
99
+ // chrome: bars, menus, and what sits directly on the window (its title, its three buttons)
100
+ function inChrome(nodes, n) {
101
+ if (n.parent >= 0 && nodes[n.parent].type === 'Window') return true;
102
+ for (let p = n.parent; p >= 0; p = nodes[p].parent) if (CHROME.has(nodes[p].type)) return true;
103
+ return CHROME.has(n.type);
104
+ }
105
+
106
+ // the app has drawn something: text or a control in its content, the window's own title not counted
107
+ function hasContent(nodes) {
108
+ const win = windowOf(nodes);
109
+ return !!win && descendants(nodes, win.i).some((n) => !inChrome(nodes, n) && ((n.type === 'StaticText' && shows(n)) || (LEAF.has(n.type) && n.type !== 'StaticText')));
110
+ }
111
+
112
+ function grade(nodes, anchors) {
113
+ const findings = [];
114
+ const win = windowOf(nodes);
115
+ const inside = win ? descendants(nodes, win.i) : [];
116
+ const content = inside.filter((n) => !inChrome(nodes, n));
117
+ const out = { anchors: [] };
118
+ for (const id of NO_NAMES ? [] : anchors || []) {
119
+ const n = inside.find((x) => x.id === id);
120
+ if (!n) { out.anchors.push({ id, present: false }); findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${id}" is not on the screen (no AutomationId="${id}")` }); continue; }
121
+ const sub = descendants(nodes, n.i);
122
+ const lists = [n, ...sub].filter((x) => LISTS.has(x.type));
123
+ const cells = sub.filter((x) => x.type === 'Cell').length;
124
+ const filled = shows(n) || sub.some((x) => shows(x) || x.type === 'Cell' || x.type === 'Image' || x.type === 'TextField');
125
+ out.anchors.push({ id, present: true, type: n.type, filled, x: n.x, y: n.y, w: n.w, h: n.h, rows: lists.length ? cells : undefined });
126
+ if (lists.length && cells === 0) findings.push({ check: 'render', class: 'zero-rows', detail: `list "${id}" (${lists[0].type}) has no rows` });
127
+ else if (!filled) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${id}" (${n.type}) is empty` });
128
+ if (n.w === 0 || n.h === 0) findings.push({ check: 'visual', class: 'clipped', detail: `anchored control "${id}" has zero ${n.w === 0 ? 'width' : 'height'}` });
129
+ else if (win && (n.x < win.x - 2 || n.y < win.y - 2 || n.x + n.w > win.x + win.w + 2 || n.y + n.h > win.y + win.h + 2)) findings.push({ check: 'visual', class: 'offscreen', detail: `anchored control "${id}" sits outside the window` });
130
+ }
131
+ // the error bar: shown text naming the app's own crash (a hidden bar is not in the tree at all)
132
+ const bar = content.find((n) => n.w > 0 && n.h > 0 && ERROR_TEXTS.some((t) => [n.label, n.value].some((v) => (v || '').toLowerCase().includes(t))));
133
+ if (bar) findings.push({ check: 'render', class: 'exception', detail: `the application's error bar is showing ("${(bar.label || bar.value).slice(0, 80)}")` });
134
+ if (!win) findings.push({ check: 'render', class: 'blank-data', detail: 'the app has no window open' });
135
+ else if (!content.some((n) => (n.type === 'StaticText' && shows(n)) || (LEAF.has(n.type) && n.type !== 'StaticText'))) findings.push({ check: 'render', class: 'blank-data', detail: 'the window is blank (no text, no control)' });
136
+ // overlap: visible leaf controls, never a control and one inside it; the same thresholds as the web
137
+ // check, on the rectangle each one PAINTS in. The tree gives a text its full size even where its box
138
+ // cuts it off: a table cell's comment, drawn as "Seeded approved…" in a 50 pt row, reported 170 pt
139
+ // and "overlapped" the button under the table (Lekhak Comments, 2026-10-07). So each rectangle is
140
+ // cut to every container's; the tree says nothing of overflow, so text that truly spills out of its
141
+ // box is not seen here.
142
+ const clip = (n) => {
143
+ let x1 = n.x, y1 = n.y, x2 = n.x + n.w, y2 = n.y + n.h;
144
+ for (let p = n.parent; p >= 0 && nodes[p].type !== 'Window'; p = nodes[p].parent) {
145
+ const a = nodes[p]; if (a.w <= 0 || a.h <= 0) continue;
146
+ x1 = Math.max(x1, a.x); y1 = Math.max(y1, a.y); x2 = Math.min(x2, a.x + a.w); y2 = Math.min(y2, a.y + a.h);
147
+ }
148
+ return { ...n, x: x1, y: y1, w: Math.max(0, x2 - x1), h: Math.max(0, y2 - y1) };
149
+ };
150
+ // In a web view plain text is not compared, as the web check compares controls only (links,
151
+ // buttons, fields, headings): the tree does not say a box scrolls, so the lines of a scrolled log
152
+ // report places under the next card (Lekhak AI Story Studio, 2026-10-07).
153
+ const kinds = NO_NAMES ? WEB_CONTROLS : LEAF;
154
+ const boxes = content.filter((n) => kinds.has(n.type) && n.w > 0 && n.h > 0).map(clip).filter((n) => n.w > 0 && n.h > 0);
155
+ const anc = (a, b) => { for (let p = b.parent; p >= 0; p = nodes[p].parent) if (p === a.i) return true; return false; };
156
+ let overlaps = 0;
157
+ for (let i = 0; i < boxes.length && overlaps < 5; i++) for (let j = i + 1; j < boxes.length && overlaps < 5; j++) {
158
+ const a = boxes[i], b = boxes[j];
159
+ if (anc(a, b) || anc(b, a)) continue;
160
+ const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x), iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
161
+ if (ix <= 4 || iy <= 4) continue;
162
+ const r = (ix * iy) / Math.min(a.w * a.h, b.w * b.h);
163
+ if (r > 0.9 || r < 0.25) continue;
164
+ overlaps++;
165
+ const nm = (e) => e.id || `${e.type} "${(e.label || e.value || '').slice(0, 40)}"`;
166
+ findings.push({ check: 'visual', class: 'overlap', detail: `${nm(a)} overlaps ${nm(b)}` });
167
+ }
168
+ const render = findings.some((f) => f.class === 'exception') ? 'ERROR' : findings.some((f) => f.check === 'render') ? 'EMPTY' : 'OK';
169
+ const visual = findings.some((f) => f.check === 'visual') ? 'FAIL' : 'OK';
170
+ return { render, visual, findings, anchors_present: out.anchors.filter((a) => a.present).length, window: win ? { w: win.w, h: win.h } : null };
171
+ }
172
+
173
+ const skipped = [];
174
+ function writeOut(results, extra = {}, width = 0) {
175
+ const summary = {
176
+ mode: 'appium', base: APPIUM, bundle_id: BUNDLE, widths: width ? [width] : [], login: null, screens: results, skipped,
177
+ anchors_not_measured: NO_NAMES,
178
+ summary: {
179
+ screens: results.length, skipped: skipped.length,
180
+ render_ok: results.filter((r) => r.render === 'OK').length,
181
+ render_fail: results.filter((r) => r.render === 'EMPTY' || r.render === 'ERROR').length,
182
+ unreachable: results.filter((r) => r.render === 'UNREACHABLE').length,
183
+ visual_ok: results.filter((r) => r.visual === 'OK').length,
184
+ visual_fail: results.filter((r) => r.visual === 'FAIL').length,
185
+ },
186
+ ...extra,
187
+ };
188
+ mkdirSync(dirname(resolve(OUT)), { recursive: true });
189
+ writeFileSync(OUT, JSON.stringify(summary, null, 2));
190
+ return summary;
191
+ }
192
+
193
+ let s = null;
194
+ if (!FROM_SOURCE.length) {
195
+ try { s = await openSession(APPIUM, { bundleId: BUNDLE, appPath: APP_PATH }); }
196
+ catch (e) {
197
+ console.log(`UNREACHABLE appium ${APPIUM} (${APP_PATH || BUNDLE}): ${e.message}`);
198
+ console.log(' a mac2 session that cannot start is usually a permission: System Settings > Privacy & Security > Accessibility (the terminal, and WebDriverAgentRunner-Runner)');
199
+ writeOut(screens.map((x) => ({ ...x, anchors: anchorsOf(x.mockup), render: 'UNREACHABLE', visual: 'n/a', widths: [{ width: 0, render: 'UNREACHABLE', visual: 'n/a', findings: [{ check: 'render', class: 'other', detail: `no Appium session: ${e.message}` }] }] })), { unreachable: true, error: e.message });
200
+ process.exit(2);
201
+ }
202
+ }
203
+
204
+ const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms));
205
+ const q = (t) => t.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
206
+ // Button 9, RadioButton 10, Link 42 (a web view's menu), Cell 75: the control labelled `text`, else
207
+ // the one whose label holds it (a menu link reads "📷 Images" for the code's "Images")
208
+ async function control(win, text) {
209
+ for (const value of [`elementType IN {9, 10, 42, 75} AND (label ==[c] '${q(text)}' OR title ==[c] '${q(text)}')`,
210
+ `elementType IN {9, 10, 42, 75} AND label CONTAINS[c] '${q(text)}'`]) {
211
+ const el = await s.find('-ios predicate string', value, win).catch(() => null);
212
+ if (el) return el;
213
+ }
214
+ return null;
215
+ }
216
+ async function reach(sc, first) {
217
+ const name = sc.name;
218
+ const win = await s.find('-ios predicate string', 'elementType == 4').catch(() => null);
219
+ if (sc.nav && sc.nav.length) { // the path the code gives (Lekhak TF-027)
220
+ for (const [i, label] of sc.nav.entries()) {
221
+ const el = await control(win, label);
222
+ if (!el) return { how: '', win, why: `the code links "${label}" to it${i ? ` (after ${sc.nav.slice(0, i).map((x) => `"${x}"`).join(', ')})` : ''}, and no such control was on view` };
223
+ await s.click(el); await sleep(SETTLE);
224
+ }
225
+ return { how: `clicked ${sc.nav.map((x) => `"${x}"`).join(' > ')}`, win };
226
+ }
227
+ const el = await s.find('accessibility id', `nav-${slug(name)}`, win).catch(() => null) || await control(win, name);
228
+ if (el) { await s.click(el); await sleep(SETTLE); return { how: `clicked the control for "${name}"`, win }; }
229
+ // only the start screen may be the one on view without a click; any other was never shown
230
+ if (first && ['', '/'].includes(String(sc.route || '').trim())) return { how: 'the screen the app opens on', win };
231
+ return { how: '', win };
232
+ }
233
+
234
+ mkdirSync(SHOTS, { recursive: true });
235
+ const results = []; let width = 0;
236
+ const PID = !FROM_SOURCE.length && APP_PATH ? appPid(APP_PATH) : 0;
237
+ const before = PID ? macWindow(PID) : null; // put back at the end
238
+ let firstDrawn = false;
239
+ for (const [i, sc] of screens.entries()) {
240
+ // another head's screen: the list says a project the booted one does not include serves it
241
+ if (sc.project && heads.size && !heads.has(normalize(sc.project))) {
242
+ skipped.push({ name: sc.name, route: sc.route, rows: sc.rows, reason: `it is served by ${sc.project}, not by the booted head (${boot.project})` });
243
+ console.log(`SKIP ${sc.name} (${sc.route}) — another head's screen: served by ${sc.project}`);
244
+ continue;
245
+ }
246
+ if (/\{/.test(sc.route || '') && !(sc.nav && sc.nav.length)) {
247
+ skipped.push({ name: sc.name, route: sc.route, rows: sc.rows, reason: `its route ${sc.route} takes a value and no link in the code reaches it` });
248
+ console.log(`SKIP ${sc.name} (${sc.route}) — its route takes a value and no link in the code reaches it`);
249
+ continue;
250
+ }
251
+ const r = { name: sc.name, route: sc.route, mockup: sc.mockup, rows: sc.rows, anchors: anchorsOf(sc.mockup), widths: [] };
252
+ if (NO_NAMES) r.anchors_not_measured = true;
253
+ results.push(r);
254
+ const want = NO_NAMES ? [] : r.anchors || [];
255
+ let nav;
256
+ try {
257
+ // the first look: opening the session starts the app again, and a web view took about eight
258
+ // seconds to draw its first page, menu included (2026-10-07). Wait for it before looking for the menu.
259
+ if (s && !firstDrawn) { const t1 = Date.now(); while (Date.now() - t1 < Math.max(RENDER_WAIT, 20000) && !hasContent(parseSource(await s.source()))) await sleep(300); firstDrawn = true; }
260
+ nav = FROM_SOURCE.length ? { how: `saved source ${FROM_SOURCE[i]}` } : await reach(sc, results.length === 1);
261
+ } catch (e) { nav = { how: '', why: `Appium: ${e.message}` }; }
262
+ if (!nav.how) {
263
+ const detail = nav.why ? `${sc.name} was not reached: ${nav.why}`
264
+ : `no control reaches ${sc.name}: no link to ${sc.route} was found in the code${NO_NAMES ? '' : `; give its tab, flyout item, link or button AutomationId="nav-${slug(sc.name)}"`}, or the label "${sc.name}"`;
265
+ r.widths.push({ width, url: '', status: 0, screenshot: '', render: 'UNREACHABLE', visual: 'n/a', findings: [{ check: 'render', class: 'other', detail }] });
266
+ } else {
267
+ for (const w of PID ? WIDTHS : [0]) {
268
+ const shot = `${SHOTS}/${slug(sc.name)}${w ? `-${w}` : ''}-mac.png`;
269
+ let entry;
270
+ try {
271
+ const size = w ? resizeWindow(PID, w, w < 700 ? 844 : 800) : null;
272
+ if (w) await sleep(SETTLE);
273
+ const t0 = Date.now();
274
+ const src = async () => parseSource(FROM_SOURCE.length ? readFileSync(FROM_SOURCE[i], 'utf8') : await s.source());
275
+ let nodes = await src();
276
+ while (!FROM_SOURCE.length && Date.now() - t0 < RENDER_WAIT && !(want.length ? nodes.some((n) => want.includes(n.id)) : hasContent(nodes))) {
277
+ await sleep(300); nodes = await src();
278
+ }
279
+ const g = grade(nodes, want);
280
+ if (g.window) width = g.window.w;
281
+ entry = { width: g.window ? g.window.w : 0, height: g.window ? g.window.h : 0, url: '', status: 200, reached: nav.how, render_wait_ms: Date.now() - t0,
282
+ screenshot: FROM_SOURCE.length ? '' : shot, render: g.render, visual: g.visual, findings: g.findings, anchors_present: g.anchors_present, console_errors: [] };
283
+ if (w) { entry.requested_width = w; if (!size || Math.abs(size.w - w) > 8) entry.width_note = size ? `the window stayed ${size.w} px wide (the app's minimum)` : 'macOS did not resize the window (Accessibility for the terminal)'; }
284
+ if (NO_NAMES) entry.anchors_not_measured = true;
285
+ if (s) {
286
+ if (!(PID && windowShot(PID, shot))) {
287
+ try { writeFileSync(shot, Buffer.from(await s.shot(nav.win), 'base64')); } catch (e) { entry.screenshot = ''; }
288
+ }
289
+ }
290
+ } catch (e) {
291
+ entry = { width, url: '', status: 0, screenshot: '', render: 'UNREACHABLE', visual: 'n/a', findings: [{ check: 'render', class: 'other', detail: `Appium: ${e.message}` }] };
292
+ }
293
+ r.widths.push(entry);
294
+ }
295
+ }
296
+ const worst = r.widths.find((e) => e.render !== 'OK') || r.widths[0];
297
+ r.render = worst.render; r.visual = r.widths.some((e) => e.visual === 'FAIL') ? 'FAIL' : worst.visual; r.anchors_n = (r.anchors || []).length;
298
+ const notes = [...new Set(r.widths.flatMap((e) => (e.findings || []).map((f) => f.detail + (e.requested_width ? ` @${e.width}` : ''))))].slice(0, 4);
299
+ const sizes = r.widths.filter((e) => e.requested_width).map((e) => `${e.width}${e.width_note ? ` (asked ${e.requested_width})` : ''}`);
300
+ const names = NO_NAMES ? 'names not measured (web view)' : `${r.anchors_n} anchors${r.mockup && !existsSync(r.mockup) ? ' (no mockup file)' : ''}`;
301
+ console.log(`${r.render === 'OK' && r.visual === 'OK' ? 'OK ' : 'FAIL'} ${r.name} (${r.route}) — render ${r.render}, visual ${r.visual}, ${names}${sizes.length ? `, @${sizes.join('/')}` : ''}${r.widths[0].reached ? ` — ${r.widths[0].reached}` : ''}${notes.length ? ' — ' + notes.join('; ') : ''}`);
302
+ }
303
+ if (PID && before) resizeWindow(PID, before.w, before.h);
304
+ if (s) await s.close();
305
+ const out = writeOut(results, {}, width);
306
+ console.log(`screens ${out.summary.screens}${skipped.length ? ` (+${skipped.length} skipped)` : ''}: render ${out.summary.render_ok} OK / ${out.summary.render_fail} failed / ${out.summary.unreachable} unreachable; visual ${out.summary.visual_ok} OK / ${out.summary.visual_fail} failed${NO_NAMES ? '; control names not measured (web view)' : ''}; screenshots ${SHOTS}; JSON ${OUT}`);
307
+ process.exit(out.summary.screens && out.summary.unreachable === out.summary.screens ? 2 : (out.summary.render_fail + out.summary.visual_fail + out.summary.unreachable) ? 5 : 0);
@@ -11,6 +11,10 @@
11
11
  // so a wide table inside a horizontal scroller is not read as covering its neighbour.
12
12
  // Measurement waits for the screen's first render (--render-wait, default 5000 ms) before reading
13
13
  // the page: it waits for content to appear, knowing nothing about how the page is built.
14
+ // Themes (Lekhak TF-024): every screen is graded in light mode, then in dark mode when the app has
15
+ // one (its painted surface changes when dark is put on it; --themes light,dark|light|dark forces the
16
+ // choice). In both, the visual check measures text contrast (3.0 : 1 against what is painted behind
17
+ // it, disabled controls exempt) and, in dark mode, large light panels. tf-theme.mjs does both.
14
18
  // At every width it saves a full-page screenshot. Verdicts: render OK | EMPTY | ERROR | UNREACHABLE,
15
19
  // visual OK | FAIL, each with its findings. Findings use the telemetry classes (blank-data,
16
20
  // zero-rows, exception, overlap, clipped, offscreen, other). It writes a JSON file the verdict
@@ -23,6 +27,9 @@
23
27
  import { chromium } from 'playwright';
24
28
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
29
  import { dirname, resolve } from 'node:path';
30
+ import { spawnSync } from 'node:child_process';
31
+ import { parseThemes, useTheme, surface, differs, audit } from './tf-theme.mjs';
32
+ import { mockupMenus, walkMenus, placeholdersOnPage, reachability } from './tf-menus.mjs';
26
33
 
27
34
  const argv = process.argv.slice(2);
28
35
  const arg = (n, d = null) => { const i = argv.indexOf(n); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
@@ -54,6 +61,14 @@ const ATTR = arg('--testid-attr', 'data-testid');
54
61
  // own banner. A project on neither still gets every other render check; it simply cannot be
55
62
  // told about a banner nobody named.
56
63
  const ERROR_SELECTORS = [...args('--error-selector'), '#blazor-error-ui', '[data-error-ui]'];
64
+ // null = auto: light, and dark when the app has a dark theme (Lekhak TF-024)
65
+ const THEMES = parseThemes(arg('--themes', 'auto'));
66
+ const MIN_CONTRAST = parseFloat(arg('--min-contrast', '3'));
67
+ // Chatur TF-011: the menus are opened and every item clicked (tf-menus.mjs). --placeholder-text adds to
68
+ // the deny list, --no-click to the labels never clicked; --no-menus leaves the menus closed (said in the JSON).
69
+ const PLACEHOLDER_EXTRA = args('--placeholder-text');
70
+ const NO_CLICK_EXTRA = args('--no-click');
71
+ const WALK_MENUS = !argv.includes('--no-menus');
57
72
 
58
73
  if (!BASE && !CDP) { console.error('tf-verify-screens: --base URL or --cdp URL is required'); process.exit(3); }
59
74
 
@@ -68,7 +83,7 @@ let listValues = {};
68
83
  if (LIST) {
69
84
  const l = JSON.parse(readFileSync(LIST, 'utf8'));
70
85
  listValues = l.route_values || {};
71
- screens = (l.screens || []).map((s) => ({ name: s.name, route: s.route, mockup: s.mockup || '', rows: s.rows || [], route_values: s.route_values || {} }));
86
+ screens = (l.screens || []).map((s) => ({ name: s.name, route: s.route, mockup: s.mockup || '', rows: s.rows || [], route_values: s.route_values || {}, seed: s.seed || '' }));
72
87
  }
73
88
  for (const s of args('--screen')) {
74
89
  const m = s.match(/^([^=]+)=(.+)$/);
@@ -114,6 +129,14 @@ const anchorsOf = (mockup) => {
114
129
  return [...out];
115
130
  };
116
131
 
132
+ // the words a mockup draws, so placeholder text the design itself shows is not held against the app
133
+ const mockTexts = {};
134
+ const mockTextOf = (mockup) => {
135
+ if (!mockup || !existsSync(mockup)) return '';
136
+ return mockTexts[mockup] ??= readFileSync(mockup, 'utf8').replace(/<!--[\s\S]*?-->/g, '')
137
+ .replace(/<(style|script)\b[\s\S]*?<\/\1\s*>/gi, '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ');
138
+ };
139
+
117
140
  // The anchors a mockup HIDES at one width and shows at another: a sidebar that becomes a slide-out
118
141
  // menu on a phone is not owed at 390px, and asking for it failed every such screen there (TF-032,
119
142
  // TfLens 2026-09-11). Worked out by drawing the mockup itself at each width. An anchor the mockup
@@ -146,7 +169,8 @@ async function hiddenByWidth(b, mockup, anchors) {
146
169
  // The anchors a mockup marks as belonging to another state of the screen (Lekhak TF-005): a reason
147
170
  // shown only while the database is down, a result shown only after "Test". The mockup says so with
148
171
  // data-tf-state="<state>" (or data-state-testid="<id>", Lekhak TF-011) on the control or on a box around it. Such a control is not owed on the
149
- // first view; when it is drawn and visible it is graded like any other.
172
+ // first view; when it is drawn and visible it is graded like any other. A control on a box marked
173
+ // data-tf-sample (a sample row a fresh app has no data for) is treated the same way (Chatur TF-002).
150
174
  async function stateOnlyOf(b, mockup, anchors) {
151
175
  if (!mockup || !existsSync(mockup) || !anchors || !anchors.length) return [];
152
176
  if (b) {
@@ -157,12 +181,25 @@ async function stateOnlyOf(b, mockup, anchors) {
157
181
  await p.goto('file://' + resolve(mockup), { waitUntil: 'load', timeout: 15000 });
158
182
  return await p.evaluate(({ anchors, attr }) => anchors.filter((id) => {
159
183
  const el = document.querySelector(`[${attr}="${CSS.escape(id)}"]`);
160
- return !!el && !!el.closest('[data-tf-state],[data-state-testid]');
184
+ return !!el && !!el.closest('[data-tf-state],[data-state-testid],[data-tf-sample]');
161
185
  }), { anchors, attr: ATTR });
162
186
  } catch (e) { /* fall through to the mark on the control itself */ } finally { if (ctx) await ctx.close().catch(() => {}); }
163
187
  }
164
188
  const html = readFileSync(mockup, 'utf8');
165
- return anchors.filter((id) => new RegExp(`<[^>]*\\bdata-(?:tf-state|state-testid)\\b[^>]*\\b${ATTR}\\s*=\\s*["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']|<[^>]*\\b${ATTR}\\s*=\\s*["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["'][^>]*\\bdata-(?:tf-state|state-testid)\\b`).test(html));
189
+ return anchors.filter((id) => new RegExp(`<[^>]*\\bdata-(?:tf-state|state-testid|tf-sample)\\b[^>]*\\b${ATTR}\\s*=\\s*["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']|<[^>]*\\b${ATTR}\\s*=\\s*["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["'][^>]*\\bdata-(?:tf-state|state-testid|tf-sample)\\b`).test(html));
190
+ }
191
+
192
+ // A screen whose mockup draws sample data the app has none of is compared against an empty view
193
+ // (Chatur TF-002). Its seed, tests/verify/seed/<mockup name>.sh (tf-verify-list.py finds it), puts the
194
+ // running app in the state the mockup draws, once, before the screen is first opened. It is given the
195
+ // app's address as TF_BASE. A seed that fails is the screen's render finding: the screen was not in
196
+ // the state it is graded in.
197
+ function runSeed(seed) {
198
+ if (!seed) return null;
199
+ if (!existsSync(seed)) return { path: seed, ok: false, output: 'the seed script is not on disk at that path' };
200
+ const r = spawnSync('bash', [seed], { env: { ...process.env, TF_BASE: BASE || CDP }, encoding: 'utf8', timeout: 120000 });
201
+ const output = `${r.stdout || ''}${r.stderr || ''}`.trim().split('\n').slice(-3).join(' | ').slice(0, 300);
202
+ return { path: seed, ok: r.status === 0, exit: r.status, output: r.error ? String(r.error.message) : output };
166
203
  }
167
204
 
168
205
  // ---------------------------------------------------------------- browser
@@ -182,6 +219,7 @@ const mockBrowser = CDP ? await chromium.launch().catch(() => null) : browser;
182
219
  function writeOut(extra, results = [], login = null) {
183
220
  const summary = {
184
221
  mode: CDP ? 'cdp' : 'base', base: BASE || CDP, widths: WIDTHS, login,
222
+ themes: THEMES || (darkOffered ? ['light', 'dark'] : ['light']),
185
223
  screens: results,
186
224
  skipped, // screens not driven because their route needs a value (TF-005)
187
225
  summary: {
@@ -316,8 +354,27 @@ async function inspect(page, anchors, attr, errorSelectors) {
316
354
  if (!el) { out.anchors.push({ id, present: false }); continue; }
317
355
  const tag = el.tagName.toLowerCase();
318
356
  const r = el.getBoundingClientRect();
319
- const text = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
320
- const filled = text.length > 0 || ['input', 'select', 'textarea', 'img', 'svg', 'canvas', 'video', 'iframe'].includes(tag) || el.querySelector('img,svg,canvas,input,select,textarea,button,a') !== null;
357
+ const text = String(el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
358
+ // A progress bar shows its value as a filled width, not as text (Chatur TF-009): a native
359
+ // progress or meter, a role="progressbar" carrying aria-valuenow, or a track holding a fill
360
+ // that has size and paints something (a background, an image or a border).
361
+ const bar = '[role="progressbar"][aria-valuenow],progress,meter';
362
+ const paints = (c) => {
363
+ const cr = c.getBoundingClientRect();
364
+ if (cr.width <= 0 || cr.height <= 0) return false;
365
+ const cs = getComputedStyle(c);
366
+ return !/^(transparent|rgba\(0, 0, 0, 0\))$/.test(cs.backgroundColor) || cs.backgroundImage !== 'none'
367
+ || ['Top', 'Right', 'Bottom', 'Left'].some((s) => parseFloat(cs[`border${s}Width`]) > 0 && cs[`border${s}Style`] !== 'none');
368
+ };
369
+ // A checkbox, switch or radio drawn by a library (TrBlazeUI: <button role="checkbox"
370
+ // aria-checked="false">, no text, no child while unticked) paints its own box, the way a native
371
+ // <input type="checkbox"> does (Lekhak TF-035). Counted filled when it paints; an unpainted one
372
+ // with no size is still empty.
373
+ const toggle = '[role="checkbox"],[role="switch"],[role="radio"],[aria-checked]';
374
+ const filled = text.length > 0 || ['input', 'select', 'textarea', 'img', 'svg', 'canvas', 'video', 'iframe', 'progress', 'meter'].includes(tag)
375
+ || el.matches(bar) || el.querySelector(`img,svg,canvas,input,select,textarea,button,a,${bar}`) !== null
376
+ || (el.matches(toggle) && paints(el)) || [...el.querySelectorAll(toggle)].some(paints)
377
+ || [...el.children].some(paints);
321
378
  const c = clipRect(el);
322
379
  out.anchors.push({ id, present: true, visible: vis(el), filled, tag, w: Math.round(r.width), h: Math.round(r.height), x: Math.round(r.left), y: Math.round(r.top),
323
380
  cx: Math.round(c.x), cy: Math.round(c.y), cw: Math.round(c.w), ch: Math.round(c.h), clipped: c.clipped });
@@ -346,7 +403,7 @@ async function inspect(page, anchors, attr, errorSelectors) {
346
403
  }).filter((f) => f.w > 0 && f.h > 0);
347
404
  if (fs.length > 1) frags = fs;
348
405
  }
349
- out.boxes.push({ i, id: el.getAttribute(attr) || '', tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || '').trim().slice(0, 40),
406
+ out.boxes.push({ i, id: el.getAttribute(attr) || '', tag: el.tagName.toLowerCase(), text: String(el.innerText || el.value || '').trim().slice(0, 40),
350
407
  x: c.x, y: c.y, w: c.w, h: c.h, raw_w: r.width, raw_h: r.height, clipped: c.clipped, anchored: el.hasAttribute(attr), frags });
351
408
  });
352
409
  // ancestry so a button inside its card is never an "overlap"
@@ -412,6 +469,14 @@ function grade(info, consoleErrors, width, excused = [], stateOnly = []) {
412
469
  const nm = (e) => e.id || `${e.tag} "${e.text || ''}"`;
413
470
  findings.push({ check: 'visual', class: 'overlap', detail: `${nm(a)} overlaps ${nm(b)} at ${width}px` });
414
471
  }
472
+ // Lekhak TF-024: text a reader cannot read, and a white panel left in a dark shell
473
+ const c = info.contrast;
474
+ if (c) {
475
+ for (const x of c.low) findings.push({ check: 'visual', class: 'low-contrast',
476
+ detail: `text "${x.text}" in ${x.where} is ${x.ratio} : 1 against its background (${x.fg} on ${x.bg}; ${MIN_CONTRAST} : 1 is the minimum)${c.low_n > c.low.length ? `, one of ${c.low_n}` : ''}` });
477
+ for (const x of c.light) findings.push({ check: 'visual', class: 'light-surface',
478
+ detail: `${x.where} paints a light panel (${x.bg}, ${x.share}% of the window) in dark mode${x.text ? ` ("${x.text}")` : ''}${c.light_n > c.light.length ? `, one of ${c.light_n}` : ''}` });
479
+ }
415
480
  const render = findings.some((f) => f.check === 'render' && f.class === 'exception') ? 'ERROR'
416
481
  : findings.some((f) => f.check === 'render') ? 'EMPTY' : 'OK';
417
482
  const visual = findings.some((f) => f.check === 'visual') ? 'FAIL' : 'OK';
@@ -423,6 +488,7 @@ mkdirSync(SHOTS, { recursive: true });
423
488
  const results = [];
424
489
  let loginResult = null;
425
490
  let firstUnreachable = false;
491
+ let darkOffered = false; // auto mode: has any screen shown a dark theme yet? (Lekhak TF-024)
426
492
  for (const width of WIDTHS) {
427
493
  const { page, close } = await contextFor(width);
428
494
  const consoleErrors = [];
@@ -436,6 +502,7 @@ for (const width of WIDTHS) {
436
502
  r.hidden_by_width = await hiddenByWidth(mockBrowser, s.mockup, r.anchors);
437
503
  r.state_only = await stateOnlyOf(mockBrowser, s.mockup, r.anchors);
438
504
  if (s.route_pattern) r.route_pattern = s.route_pattern;
505
+ if (s.seed) r.seed = runSeed(s.seed);
439
506
  }
440
507
  const excused = (r.hidden_by_width || {})[width] || [];
441
508
  const stateOnly = r.state_only || [];
@@ -463,30 +530,127 @@ for (const width of WIDTHS) {
463
530
  entry.render = 'UNREACHABLE'; entry.visual = 'n/a';
464
531
  entry.findings = [{ check: 'render', class: 'other', detail: nav.status === 0 ? `could not open ${s.route}: ${nav.error || 'no response'}` : redirectedToLogin ? `${s.route} redirected to the sign-in page (not signed in)` : `${s.route} answered HTTP ${nav.status}` }];
465
532
  if (nav.status === 0 && results.length === 1 && width === WIDTHS[0]) firstUnreachable = true;
466
- } else {
467
- entry.render_wait_ms = await waitForRender(page, (r.anchors || []).filter((id) => !excused.includes(id) && !stateOnly.includes(id)), ATTR);
468
- const info = await inspect(page, r.anchors || [], ATTR, ERROR_SELECTORS);
469
- const g = grade(info, consoleErrors, width, excused, stateOnly);
470
- if (excused.length) entry.hidden_in_mockup = excused;
471
- if (stateOnly.length) entry.state_only = stateOnly;
472
- Object.assign(entry, g);
473
- entry.console_errors = consoleErrors.slice(0, 5);
474
- entry.anchors_present = (info.anchors || []).filter((a) => a.present).length;
533
+ try { await page.screenshot({ path: shot, fullPage: !CDP }); } catch (e) { entry.screenshot = ''; }
534
+ r.widths.push(entry);
535
+ continue;
536
+ }
537
+ entry.render_wait_ms = await waitForRender(page, (r.anchors || []).filter((id) => !excused.includes(id) && !stateOnly.includes(id)), ATTR);
538
+ // one pass per theme on the same opened screen; each pass puts the page back as it found it, so an
539
+ // attached desktop head is left in the viewer's theme (Lekhak TF-024)
540
+ let lightSurface = null;
541
+ for (const theme of THEMES || ['light', 'dark']) {
542
+ const restore = await useTheme(page, theme);
543
+ try {
544
+ if (theme === 'light') lightSurface = await surface(page);
545
+ if (theme === 'dark' && !THEMES && !darkOffered) {
546
+ // auto: the app has a dark theme when dark changes what it paints. Asked screen by screen until
547
+ // one answers yes; from then on every screen is graded in dark, so a screen that ignores the
548
+ // dark theme altogether is graded in it too, and fails.
549
+ darkOffered = differs(lightSurface, await surface(page));
550
+ if (!darkOffered) { r.dark = 'not offered'; continue; }
551
+ }
552
+ const te = theme === 'light' ? entry : { ...entry, screenshot: `${SHOTS}/${slug(s.name)}-${width}-${theme}.png` };
553
+ te.theme = theme;
554
+ const info = await inspect(page, r.anchors || [], ATTR, ERROR_SELECTORS);
555
+ info.contrast = await audit(page, theme === 'dark', MIN_CONTRAST);
556
+ const g = grade(info, theme === 'light' ? consoleErrors : [], width, excused, stateOnly);
557
+ // a screen still showing placeholder text its mockup does not draw is not built (Chatur TF-011)
558
+ if (theme === (THEMES || ['light'])[0]) {
559
+ for (const p of await placeholdersOnPage(page, mockTextOf(s.mockup), PLACEHOLDER_EXTRA)) g.findings.push({ check: 'render', class: 'placeholder', detail: `the screen shows placeholder text "${p}"` });
560
+ if (g.render === 'OK' && g.findings.some((f) => f.class === 'placeholder')) g.render = 'EMPTY';
561
+ }
562
+ if (r.seed && !r.seed.ok) {
563
+ g.findings.unshift({ check: 'render', class: 'other', detail: `seed ${r.seed.path} failed (exit ${r.seed.exit ?? '?'}): ${r.seed.output}` });
564
+ if (g.render === 'OK') g.render = 'EMPTY';
565
+ }
566
+ if (theme === 'dark') for (const f of g.findings) { f.theme = 'dark'; f.detail += ' in dark mode'; }
567
+ if (excused.length) te.hidden_in_mockup = excused;
568
+ if (stateOnly.length) te.state_only = stateOnly;
569
+ Object.assign(te, g);
570
+ te.console_errors = theme === 'light' ? consoleErrors.slice(0, 5) : [];
571
+ te.anchors_present = (info.anchors || []).filter((a) => a.present).length;
572
+ try { await page.screenshot({ path: te.screenshot, fullPage: !CDP }); } catch (e) { te.screenshot = ''; }
573
+ r.widths.push(te);
574
+ if (theme === 'dark') r.dark = 'graded';
575
+ } finally { await restore(); }
475
576
  }
476
- try { await page.screenshot({ path: shot, fullPage: !CDP }); } catch (e) { entry.screenshot = ''; }
477
- r.widths.push(entry);
478
577
  }
479
578
  await close();
480
579
  }
580
+
581
+ // ---------------------------------------------------------------- menus (Chatur TF-011)
582
+ // Every menu the mockups draw open is opened in the app and every item clicked, once per menu, on the
583
+ // first screen that draws it, at the first width. What a menu lacks or does wrong is a render finding
584
+ // on every screen whose mockup draws that menu. A screen a menu item links to, which no click reached,
585
+ // is UNREACHABLE.
586
+ let menuWalk = { walked: WALK_MENUS, menus: [], clicks: [] };
587
+ if (WALK_MENUS && mockBrowser) {
588
+ const byScreen = {};
589
+ { const ctx = await mockBrowser.newContext(); const mp = await ctx.newPage();
590
+ for (const s of screens) {
591
+ if (!s.mockup || !existsSync(s.mockup)) continue;
592
+ try { await mp.goto('file://' + resolve(s.mockup), { waitUntil: 'load', timeout: 15000 }); byScreen[s.name] = await mockupMenus(mp, ATTR); } catch (e) { byScreen[s.name] = []; }
593
+ }
594
+ await ctx.close().catch(() => {}); }
595
+ const key = (m) => `${m.trigger}|${m.popup}`;
596
+ const done = new Map(); // menu key -> its findings
597
+ const width = WIDTHS[0];
598
+ const anyMockMenus = Object.values(byScreen).some((l) => l.length);
599
+ for (const s of screens) {
600
+ const r = results.find((x) => x.name === s.name);
601
+ if (!r || r.render === 'UNREACHABLE' || r.widths.every((w) => w.render === 'UNREACHABLE')) continue;
602
+ const todo = (byScreen[s.name] || []).filter((m) => !done.has(key(m)));
603
+ if (anyMockMenus && !todo.length) continue;
604
+ if (!anyMockMenus && done.size) break; // the app's own menus: walked once, on the first screen
605
+ const { page, close } = await contextFor(width);
606
+ try {
607
+ if (!CDP) await login(page);
608
+ const back = async () => { await navigate(page, s.route); await waitForRender(page, (r.anchors || []).slice(0, 20), ATTR); };
609
+ await back();
610
+ const pages = async () => CDP ? browser.contexts().flatMap((c) => c.pages()) : page.context().pages();
611
+ const w = await walkMenus(page, todo, { attr: ATTR, settle: SETTLE, back, pages, keepWindows: !!CDP, mockText: mockTextOf(s.mockup), placeholders: PLACEHOLDER_EXTRA, noClick: NO_CLICK_EXTRA });
612
+ menuWalk.menus.push(...w.menus.map((m) => ({ ...m, screen: s.name })));
613
+ menuWalk.clicks.push(...w.clicks.map(({ ids_before, ...c }) => ({ ...c, landed: c.landed ? { url: c.landed.url, window: c.landed.window } : null, screen: s.name })));
614
+ menuWalk._clicks = (menuWalk._clicks || []).concat(w.clicks);
615
+ if (!w.from_mockup) done.set('app|', w.findings);
616
+ for (const m of todo) done.set(key(m), w.findings.filter((f) => f.popup === m.popup));
617
+ if (!w.from_mockup) { r.menu_findings = w.findings; }
618
+ } catch (e) { menuWalk.error = String(e.message || e).split('\n')[0]; }
619
+ finally { await close(); }
620
+ }
621
+ for (const r of results) {
622
+ const mine = (byScreen[r.name] || []).flatMap((m) => done.get(key(m)) || []).concat(r.menu_findings || []);
623
+ delete r.menu_findings;
624
+ if (!mine.length || !r.widths.length) continue;
625
+ const w0 = r.widths.find((w) => w.render !== 'UNREACHABLE') || r.widths[0];
626
+ w0.findings = [...(w0.findings || []), ...mine.map((f) => ({ check: 'render', class: f.class, detail: f.detail }))];
627
+ if (w0.render === 'OK') w0.render = 'EMPTY';
628
+ }
629
+ const reach = reachability(screens.map((s) => { const r = results.find((x) => x.name === s.name) || {}; return { ...s, anchors: r.anchors, state_only: r.state_only }; }), menuWalk._clicks || []);
630
+ delete menuWalk._clicks;
631
+ menuWalk.reach = reach;
632
+ for (const [name, v] of Object.entries(reach)) {
633
+ const r = results.find((x) => x.name === name);
634
+ if (!r || v.reached) { if (r) r.reached_by = v.by; continue; }
635
+ r.unreachable_by_menu = `no menu click reaches ${r.route}: ${v.tried.join('; ')}`;
636
+ }
637
+ }
638
+
481
639
  for (const r of results) {
482
640
  const rs = r.widths.map((w) => w.render);
483
641
  r.render = rs.includes('UNREACHABLE') ? 'UNREACHABLE' : rs.includes('ERROR') ? 'ERROR' : rs.includes('EMPTY') ? 'EMPTY' : 'OK';
642
+ if (r.unreachable_by_menu) {
643
+ // the verdict reads the first finding of an UNREACHABLE screen as its reason
644
+ r.render = 'UNREACHABLE';
645
+ r.widths[0].findings = [{ check: 'render', class: 'unreachable', detail: r.unreachable_by_menu }, ...(r.widths[0].findings || [])];
646
+ }
484
647
  r.visual = r.widths.some((w) => w.visual === 'FAIL') ? 'FAIL' : r.render === 'UNREACHABLE' ? 'n/a' : 'OK';
485
648
  r.anchors_n = (r.anchors || []).length;
486
649
  const notes = r.widths.flatMap((w) => (w.findings || []).map((f) => `${f.detail}`)).slice(0, 4);
487
- console.log(`${r.render === 'OK' && r.visual === 'OK' ? 'OK ' : 'FAIL'} ${r.name} (${r.route}) — render ${r.render}, visual ${r.visual}, ${r.anchors_n} anchors${r.mockup && !existsSync(r.mockup) ? ' (no mockup file)' : ''}${notes.length ? ' — ' + notes.join('; ') : ''}`);
650
+ r.themes = [...new Set(r.widths.map((w) => w.theme).filter(Boolean))];
651
+ console.log(`${r.render === 'OK' && r.visual === 'OK' ? 'OK ' : 'FAIL'} ${r.name} (${r.route}) — render ${r.render}, visual ${r.visual}, ${r.anchors_n} anchors${r.mockup && !existsSync(r.mockup) ? ' (no mockup file)' : ''}, ${r.themes.join(' + ') || 'no'} theme${r.themes.length === 1 ? '' : 's'}${r.dark === 'not offered' ? ' (the app has no dark theme)' : ''}${notes.length ? ' — ' + notes.join('; ') : ''}`);
488
652
  }
489
- const out = writeOut({}, results, loginResult);
653
+ const out = writeOut({ menus: menuWalk }, results, loginResult);
490
654
  if (loginResult && loginResult.attempted && !loginResult.ok) console.log(`LOGIN failed at ${BASE}${LOGIN_PATH}: ${loginResult.error || 'still on the sign-in page'}`);
491
655
  console.log(`screens ${out.summary.screens}${skipped.length ? ` (+${skipped.length} not driven: route needs a value)` : ''}: render ${out.summary.render_ok} OK / ${out.summary.render_fail} failed / ${out.summary.unreachable} unreachable; visual ${out.summary.visual_ok} OK / ${out.summary.visual_fail} failed; screenshots ${SHOTS}; JSON ${OUT}`);
492
656
  if (CDP && mockBrowser) await mockBrowser.close().catch(() => {});