@techierathore/techieflow 1.0.4 → 1.1.1

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 (148) 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/_owner-language.md +1 -0
  5. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
  6. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
  7. package/.claude/commands/TechieFlow/tasks/amend-docs.md +2 -2
  8. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
  9. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +5 -5
  10. package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
  11. package/.claude/commands/TechieFlow/tasks/fix-issues.md +3 -2
  12. package/.claude/commands/TechieFlow/tasks/generate-html.md +2 -2
  13. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +2 -2
  14. package/.claude/commands/TechieFlow/tasks/mockups.md +2 -2
  15. package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
  16. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
  17. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
  18. package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
  19. package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
  20. package/.opencode/plugin/techieflow.js +194 -14
  21. package/.tfcore/agents/flow-master.md +2 -0
  22. package/.tfcore/agents/verifier.md +2 -2
  23. package/.tfcore/core-config.yaml +2 -1
  24. package/.tfcore/hooks/block-git.sh +4 -2
  25. package/.tfcore/hooks/guard-build.sh +5 -1
  26. package/.tfcore/hooks/guard-db.sh +112 -13
  27. package/.tfcore/hooks/guard-status-html.sh +86 -2
  28. package/.tfcore/hooks/guard-verify-deps.sh +16 -1
  29. package/.tfcore/hooks/guard-verify.sh +8 -3
  30. package/.tfcore/hooks/sweep-artifacts.sh +46 -0
  31. package/.tfcore/hooks/yolo-allow.sh +27 -0
  32. package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
  33. package/.tfcore/standards/coding-standards-dotnet.md +31 -3
  34. package/.tfcore/standards/owner-words.txt +23 -0
  35. package/.tfcore/tasks/_develop-mode.md +34 -0
  36. package/.tfcore/tasks/_owner-language.md +1 -0
  37. package/.tfcore/tasks/_status-update-gate.md +4 -3
  38. package/.tfcore/tasks/_yolo-mode.md +2 -2
  39. package/.tfcore/tasks/amend-docs.md +2 -2
  40. package/.tfcore/tasks/day1-brownfield.md +3 -3
  41. package/.tfcore/tasks/day1-greenfield.md +5 -5
  42. package/.tfcore/tasks/develop-end-to-end.md +30 -0
  43. package/.tfcore/tasks/fix-issues.md +3 -2
  44. package/.tfcore/tasks/generate-html.md +2 -2
  45. package/.tfcore/tasks/handoff-phase.md +2 -2
  46. package/.tfcore/tasks/mockups.md +2 -2
  47. package/.tfcore/tasks/productguide.md +2 -2
  48. package/.tfcore/tasks/render-workflow-docs.md +1 -1
  49. package/.tfcore/tasks/triage-and-fix.md +2 -2
  50. package/.tfcore/tasks/triage-issues.md +2 -2
  51. package/.tfcore/tasks/verify-phase.md +12 -11
  52. package/.tfcore/telemetry/SCHEMA.md +10 -2
  53. package/.tfcore/telemetry/install-metrics.sh +3 -2
  54. package/.tfcore/telemetry/tf-metrics.sh +38 -2
  55. package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
  56. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
  57. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
  58. package/.tfcore/templates/v4custom/app-brd-tmpl.md +7 -4
  59. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
  60. package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
  61. package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
  62. package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
  63. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
  64. package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
  65. package/.tfcore/templates/v4custom/html-render-shell.md +6 -2
  66. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  67. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  68. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  69. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  70. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  71. package/.tfcore/utils/tf-appium.mjs +179 -0
  72. package/.tfcore/utils/tf-assets-browser.mjs +96 -0
  73. package/.tfcore/utils/tf-assets.sh +97 -12
  74. package/.tfcore/utils/tf-brd-status.py +14 -5
  75. package/.tfcore/utils/tf-build-list.py +95 -12
  76. package/.tfcore/utils/tf-build-list.sh +2 -1
  77. package/.tfcore/utils/tf-build.sh +163 -15
  78. package/.tfcore/utils/tf-checklist-edit.py +21 -3
  79. package/.tfcore/utils/tf-ci-repro.py +685 -0
  80. package/.tfcore/utils/tf-ci-repro.sh +24 -0
  81. package/.tfcore/utils/tf-day1-files.py +55 -28
  82. package/.tfcore/utils/tf-develop.sh +264 -0
  83. package/.tfcore/utils/tf-devguide-list.py +53 -24
  84. package/.tfcore/utils/tf-doc-check.py +218 -26
  85. package/.tfcore/utils/tf-doc-tests.sh +116 -0
  86. package/.tfcore/utils/tf-emit.sh +132 -45
  87. package/.tfcore/utils/tf-feedback.sh +13 -0
  88. package/.tfcore/utils/tf-fix-close.sh +87 -31
  89. package/.tfcore/utils/tf-goal.sh +12 -12
  90. package/.tfcore/utils/tf-harness.sh +9 -4
  91. package/.tfcore/utils/tf-lock.sh +29 -0
  92. package/.tfcore/utils/tf-log-miss.py +34 -8
  93. package/.tfcore/utils/tf-login.mjs +167 -0
  94. package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
  95. package/.tfcore/utils/tf-menus.mjs +401 -0
  96. package/.tfcore/utils/tf-misses-md.py +10 -4
  97. package/.tfcore/utils/tf-mockup-parity.mjs +763 -71
  98. package/.tfcore/utils/tf-mockup-parity.sh +25 -2
  99. package/.tfcore/utils/tf-owner-text.py +233 -0
  100. package/.tfcore/utils/tf-owner-text.sh +14 -0
  101. package/.tfcore/utils/tf-phase.sh +38 -4
  102. package/.tfcore/utils/tf-portable.sh +230 -0
  103. package/.tfcore/utils/tf-render-html.py +79 -5
  104. package/.tfcore/utils/tf-req-ids.py +36 -0
  105. package/.tfcore/utils/tf-selfcheck.sh +39 -6
  106. package/.tfcore/utils/tf-split-brd.py +11 -1
  107. package/.tfcore/utils/tf-stack-check.sh +3 -0
  108. package/.tfcore/utils/tf-status-evidence.sh +11 -9
  109. package/.tfcore/utils/tf-status-facts.py +160 -45
  110. package/.tfcore/utils/tf-theme.mjs +189 -0
  111. package/.tfcore/utils/tf-triage.py +74 -17
  112. package/.tfcore/utils/tf-triage.sh +2 -0
  113. package/.tfcore/utils/tf-verify-boot.sh +529 -54
  114. package/.tfcore/utils/tf-verify-emit.sh +57 -17
  115. package/.tfcore/utils/tf-verify-env.sh +33 -4
  116. package/.tfcore/utils/tf-verify-list.py +290 -24
  117. package/.tfcore/utils/tf-verify-list.sh +3 -1
  118. package/.tfcore/utils/tf-verify-native.mjs +307 -0
  119. package/.tfcore/utils/tf-verify-screens.mjs +341 -44
  120. package/.tfcore/utils/tf-verify-screens.sh +23 -5
  121. package/.tfcore/utils/tf-verify-tests.sh +310 -19
  122. package/.tfcore/utils/tf-verify-verdict.py +163 -21
  123. package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
  124. package/.tfcore/utils/tf-verify-zoom.sh +29 -0
  125. package/.tfcore/utils/tf-yolo-alias.sh +84 -0
  126. package/.tfcore/utils/tf-yolo-perms.py +135 -0
  127. package/.tfcore/utils/tf-yolo-start.sh +44 -0
  128. package/.tfcore/utils/tf-yolo.sh +31 -3
  129. package/.tfcore/utils/tf_defect.py +27 -0
  130. package/.tfcore/utils/tf_feedback.py +272 -0
  131. package/.tfcore/utils/tf_roadmap.py +35 -0
  132. package/.tfcore/utils/tf_stack_check.py +93 -0
  133. package/README.md +6 -2
  134. package/docs/TechieFlow-Installation.md +5 -3
  135. package/opencode.jsonc +6 -2
  136. package/package.json +1 -1
  137. package/scripts/install.mjs +13 -2
  138. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  139. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  140. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  141. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  142. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  143. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  144. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  145. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  146. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  147. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  148. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
@@ -2,7 +2,8 @@
2
2
  // Run through tf-verify-screens.sh, not directly. Sitting 4c, 2026-09-06.
3
3
  //
4
4
  // Two of the seven verify checks, over one browser session:
5
- // render every control the mockup anchors (data-testid) is present and shows something; no
5
+ // render every control the mockup anchors (data-testid) and shows at that width is present and
6
+ // shows something (one the mockup hides at that width only is not owed there); no
6
7
  // table with a header and no rows; no blank page; no error banner; no console error.
7
8
  // visual no two visible controls overlap; no anchored control has zero size or sits off-screen;
8
9
  // no horizontal overflow; the page has a stylesheet. Overlap and off-screen are measured
@@ -10,6 +11,10 @@
10
11
  // so a wide table inside a horizontal scroller is not read as covering its neighbour.
11
12
  // Measurement waits for the screen's first render (--render-wait, default 5000 ms) before reading
12
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.
13
18
  // At every width it saves a full-page screenshot. Verdicts: render OK | EMPTY | ERROR | UNREACHABLE,
14
19
  // visual OK | FAIL, each with its findings. Findings use the telemetry classes (blank-data,
15
20
  // zero-rows, exception, overlap, clipped, offscreen, other). It writes a JSON file the verdict
@@ -22,6 +27,9 @@
22
27
  import { chromium } from 'playwright';
23
28
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
24
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';
25
33
 
26
34
  const argv = process.argv.slice(2);
27
35
  const arg = (n, d = null) => { const i = argv.indexOf(n); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
@@ -53,31 +61,147 @@ const ATTR = arg('--testid-attr', 'data-testid');
53
61
  // own banner. A project on neither still gets every other render check; it simply cannot be
54
62
  // told about a banner nobody named.
55
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');
56
72
 
57
73
  if (!BASE && !CDP) { console.error('tf-verify-screens: --base URL or --cdp URL is required'); process.exit(3); }
58
74
 
59
75
  // ---------------------------------------------------------------- screens
60
76
  let screens = [];
77
+ // Sample values for route parameters (Lekhak TF-005): `/admin/llm-signin/{ProviderId:long}` opened as
78
+ // written answers 404. --route-value ProviderId=21 (repeatable), or "route_values" in the list, at its
79
+ // top or on one screen. A screen whose route still needs a value is not driven, and says which.
80
+ const ROUTE_VALUES = {};
81
+ for (const kv of args('--route-value')) { const m = kv.match(/^([^=]+)=(.*)$/); if (m) ROUTE_VALUES[m[1].trim().toLowerCase()] = m[2]; }
82
+ let listValues = {};
61
83
  if (LIST) {
62
84
  const l = JSON.parse(readFileSync(LIST, 'utf8'));
63
- screens = (l.screens || []).map((s) => ({ name: s.name, route: s.route, mockup: s.mockup || '', rows: s.rows || [] }));
85
+ listValues = l.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 || '' }));
64
87
  }
65
88
  for (const s of args('--screen')) {
66
89
  const m = s.match(/^([^=]+)=(.+)$/);
67
90
  if (m) screens.push({ name: m[1], route: m[2], mockup: `${MOCKUPS}/${m[1].toLowerCase().replace(/[^a-z0-9]+/g, '-')}.html`, rows: [] });
68
91
  }
69
92
  if (!screens.length) { console.error('tf-verify-screens: no screens (--list <json> or --screen name=/route)'); process.exit(3); }
93
+ const fillRoute = (s) => {
94
+ const vals = {};
95
+ for (const src of [listValues, s.route_values || {}]) for (const [k, v] of Object.entries(src)) vals[k.toLowerCase()] = String(v);
96
+ Object.assign(vals, ROUTE_VALUES);
97
+ const need = [];
98
+ const route = s.route.replace(/\/?\{\*{0,2}([A-Za-z_][\w]*)(?::[^}=?]*)?(?:=([^}?]*))?(\?)?\}/g, (whole, name, dflt, opt) => {
99
+ const lead = whole.startsWith('/') ? '/' : '';
100
+ const v = vals[name.toLowerCase()] ?? dflt;
101
+ if (v !== undefined && v !== '') return lead + encodeURIComponent(v);
102
+ if (opt) return '';
103
+ need.push(name); return whole;
104
+ });
105
+ return { route, need };
106
+ };
107
+ const skipped = [];
108
+ screens = screens.filter((s) => {
109
+ const f = fillRoute(s);
110
+ if (f.need.length) { skipped.push({ name: s.name, route: s.route, rows: s.rows, needs: f.need }); return false; }
111
+ if (f.route !== s.route) s.route_pattern = s.route;
112
+ s.route = f.route;
113
+ return true;
114
+ });
115
+ for (const k of skipped) console.log(`SKIP ${k.name} (${k.route}) — not driven: the route needs a value; pass ${k.needs.map((n) => `--route-value ${n}=<a real ${n}>`).join(' ')}`);
70
116
 
71
117
  const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
72
118
  const anchorsOf = (mockup) => {
73
119
  if (!mockup || !existsSync(mockup)) return null;
74
- const html = readFileSync(mockup, 'utf8');
75
- const re = new RegExp(`${ATTR}\\s*=\\s*["']([^"']+)["']`, 'g');
120
+ // Only an attribute ON AN ELEMENT is a control the page must carry. A stylesheet rule such as
121
+ // [data-testid="source-mode"] .tab{…}, a script string or a comment naming one is not: TfLens's
122
+ // shared mockup stylesheet made every screen owe a dialog it never draws (TF-026).
123
+ const html = readFileSync(mockup, 'utf8')
124
+ .replace(/<!--[\s\S]*?-->/g, '')
125
+ .replace(/<(style|script)\b[\s\S]*?<\/\1\s*>/gi, '');
126
+ const re = new RegExp(`<[^>]*?\\b${ATTR}\\s*=\\s*["']([^"']+)["']`, 'g');
76
127
  const out = new Set(); let m;
77
128
  while ((m = re.exec(html))) out.add(m[1]);
78
129
  return [...out];
79
130
  };
80
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
+
140
+ // The anchors a mockup HIDES at one width and shows at another: a sidebar that becomes a slide-out
141
+ // menu on a phone is not owed at 390px, and asking for it failed every such screen there (TF-032,
142
+ // TfLens 2026-09-11). Worked out by drawing the mockup itself at each width. An anchor the mockup
143
+ // hides at every width -- a closed dialog -- is still owed, as before. None is excused when the
144
+ // mockup cannot be drawn.
145
+ async function hiddenByWidth(b, mockup, anchors) {
146
+ const out = {};
147
+ if (!b || !mockup || !existsSync(mockup) || !anchors || !anchors.length) return out;
148
+ const shown = {};
149
+ for (const width of WIDTHS) {
150
+ let ctx;
151
+ try {
152
+ ctx = await b.newContext({ viewport: { width, height: width < 600 ? 844 : 800 } });
153
+ const p = await ctx.newPage();
154
+ await p.goto('file://' + resolve(mockup), { waitUntil: 'load', timeout: 15000 });
155
+ shown[width] = await p.evaluate(({ anchors, attr }) => anchors.filter((id) => {
156
+ const el = document.querySelector(`[${attr}="${CSS.escape(id)}"]`);
157
+ if (!el) return false;
158
+ const r = el.getBoundingClientRect();
159
+ const seen = el.checkVisibility ? el.checkVisibility({ visibilityProperty: true, opacityProperty: true }) : true;
160
+ return seen && r.width > 0 && r.height > 0;
161
+ }), { anchors, attr: ATTR });
162
+ } catch (e) { return {}; } finally { if (ctx) await ctx.close().catch(() => {}); }
163
+ }
164
+ const everShown = new Set(Object.values(shown).flat());
165
+ for (const width of WIDTHS) out[width] = anchors.filter((id) => everShown.has(id) && !shown[width].includes(id));
166
+ return out;
167
+ }
168
+
169
+ // The anchors a mockup marks as belonging to another state of the screen (Lekhak TF-005): a reason
170
+ // shown only while the database is down, a result shown only after "Test". The mockup says so with
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
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).
174
+ async function stateOnlyOf(b, mockup, anchors) {
175
+ if (!mockup || !existsSync(mockup) || !anchors || !anchors.length) return [];
176
+ if (b) {
177
+ let ctx;
178
+ try {
179
+ ctx = await b.newContext();
180
+ const p = await ctx.newPage();
181
+ await p.goto('file://' + resolve(mockup), { waitUntil: 'load', timeout: 15000 });
182
+ return await p.evaluate(({ anchors, attr }) => anchors.filter((id) => {
183
+ const el = document.querySelector(`[${attr}="${CSS.escape(id)}"]`);
184
+ return !!el && !!el.closest('[data-tf-state],[data-state-testid],[data-tf-sample]');
185
+ }), { anchors, attr: ATTR });
186
+ } catch (e) { /* fall through to the mark on the control itself */ } finally { if (ctx) await ctx.close().catch(() => {}); }
187
+ }
188
+ const html = readFileSync(mockup, 'utf8');
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 };
203
+ }
204
+
81
205
  // ---------------------------------------------------------------- browser
82
206
  let browser, cdpPage = null;
83
207
  if (CDP) {
@@ -89,13 +213,18 @@ if (CDP) {
89
213
  } else {
90
214
  browser = await chromium.launch();
91
215
  }
216
+ // mockups are drawn in a browser of our own: an attached desktop head is the app's, not ours
217
+ const mockBrowser = CDP ? await chromium.launch().catch(() => null) : browser;
92
218
 
93
219
  function writeOut(extra, results = [], login = null) {
94
220
  const summary = {
95
221
  mode: CDP ? 'cdp' : 'base', base: BASE || CDP, widths: WIDTHS, login,
222
+ themes: THEMES || (darkOffered ? ['light', 'dark'] : ['light']),
96
223
  screens: results,
224
+ skipped, // screens not driven because their route needs a value (TF-005)
97
225
  summary: {
98
226
  screens: results.length,
227
+ skipped: skipped.length,
99
228
  render_ok: results.filter((r) => r.render === 'OK').length,
100
229
  render_fail: results.filter((r) => r.render === 'EMPTY' || r.render === 'ERROR').length,
101
230
  unreachable: results.filter((r) => r.render === 'UNREACHABLE').length,
@@ -109,28 +238,18 @@ function writeOut(extra, results = [], login = null) {
109
238
  return summary;
110
239
  }
111
240
 
112
- async function login(page) {
113
- if (!LOGIN_PATH || !USER) return { attempted: false };
114
- try {
115
- await page.goto(BASE + LOGIN_PATH, { waitUntil: 'domcontentloaded', timeout: 30000 });
116
- const user = page.locator(`[${ATTR}*="user" i], [${ATTR}*="email" i], input[type="email"], input[name*="user" i], input[name*="email" i], input[id*="user" i], input[id*="email" i], input[type="text"]`).first();
117
- const pass = page.locator(`[${ATTR}*="pass" i], input[type="password"]`).first();
118
- const btn = page.locator(`[${ATTR}*="login" i], [${ATTR}*="signin" i], [${ATTR}*="submit" i], button[type="submit"], form button, input[type="submit"]`).first();
119
- await user.fill(USER, { timeout: 10000 });
120
- await pass.fill(PASS || '', { timeout: 10000 });
121
- await Promise.all([page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}), btn.click({ timeout: 10000 })]);
122
- await page.waitForTimeout(SETTLE);
123
- const url = page.url();
124
- const still = url.startsWith(BASE + LOGIN_PATH);
125
- return { attempted: true, ok: !still, url };
126
- } catch (e) {
127
- return { attempted: true, ok: false, error: e.message.split('\n')[0] };
128
- }
129
- }
241
+ // The sign-in recipe (TF-033, TF-044) and the way a screen behind an in-page sign-in is reached
242
+ // (AppManager TF-006) live in tf-login.mjs since AppManager TF-011, shared with tf-mockup-parity
243
+ // and tf-assets so every check reaches a screen the same way.
244
+ import { signIn, signedOut as signedOutShared, navigateInApp as navigateInAppShared } from './tf-login.mjs';
245
+ const LOGIN_OPTS = { base: BASE, loginPath: LOGIN_PATH, user: USER, password: PASS, attr: ATTR, settle: SETTLE, renderWait: RENDER_WAIT };
246
+ const login = (page) => signIn(page, LOGIN_OPTS);
130
247
 
131
248
  async function contextFor(width) {
132
249
  if (CDP) { await cdpPage.setViewportSize({ width, height: width < 600 ? 844 : 800 }).catch(() => {}); return { page: cdpPage, close: async () => {} }; }
133
- const opts = { viewport: { width, height: width < 600 ? 844 : 800 } };
250
+ // a development head on HTTPS carries a certificate nothing trusts; tf-mockup-parity and tf-assets
251
+ // already accept it, and without it every screen was UNREACHABLE (Lekhak TF-002)
252
+ const opts = { viewport: { width, height: width < 600 ? 844 : 800 }, ignoreHTTPSErrors: true };
134
253
  if (STORAGE && existsSync(STORAGE)) opts.storageState = STORAGE;
135
254
  const ctx = await browser.newContext(opts);
136
255
  if (COOKIE) {
@@ -157,6 +276,13 @@ async function navigate(page, route) {
157
276
  } catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
158
277
  }
159
278
 
279
+ // Signed out: on the sign-in address, or drawing a sign-in form — a password field WITH a sign-in
280
+ // button. A password field alone is not the sign-in page: AppManager's signed-in "Create user" form
281
+ // has one for the new account and was graded unreachable (AppManager TF-012). tf-login.mjs decides.
282
+ const signedOut = (page) => signedOutShared(page, LOGIN_OPTS);
283
+ // Open a route from inside the page, the way its own links do (tf-login.mjs).
284
+ const navigateInApp = (page, route) => navigateInAppShared(page, LOGIN_OPTS, route);
285
+
160
286
  // Wait for the screen's first render before measuring anything. Returns the milliseconds waited.
161
287
  // It never decides anything: when nothing appears within RENDER_WAIT the page is graded exactly as
162
288
  // it stands, so a control that is genuinely missing is still reported (TF-021).
@@ -228,8 +354,21 @@ async function inspect(page, anchors, attr, errorSelectors) {
228
354
  if (!el) { out.anchors.push({ id, present: false }); continue; }
229
355
  const tag = el.tagName.toLowerCase();
230
356
  const r = el.getBoundingClientRect();
231
- const text = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
232
- 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
+ const filled = text.length > 0 || ['input', 'select', 'textarea', 'img', 'svg', 'canvas', 'video', 'iframe', 'progress', 'meter'].includes(tag)
370
+ || el.matches(bar) || el.querySelector(`img,svg,canvas,input,select,textarea,button,a,${bar}`) !== null
371
+ || [...el.children].some(paints);
233
372
  const c = clipRect(el);
234
373
  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),
235
374
  cx: Math.round(c.x), cy: Math.round(c.y), cw: Math.round(c.w), ch: Math.round(c.h), clipped: c.clipped });
@@ -246,8 +385,20 @@ async function inspect(page, anchors, attr, errorSelectors) {
246
385
  els.forEach((el, i) => {
247
386
  const r = el.getBoundingClientRect();
248
387
  const c = clipRect(el); // what is painted, not what is laid out
249
- out.boxes.push({ i, id: el.getAttribute(attr) || '', tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || '').trim().slice(0, 40),
250
- 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) });
388
+ // An inline element that wraps paints one fragment per line, and its bounding box is the union
389
+ // of them: two sentences sharing a line then "overlap" across the tile's whole width though no
390
+ // pixel of one is drawn over the other (TF-036, TfLens /effort 2026-09-11). Keep the fragments,
391
+ // each cut to the painted rectangle, and compare those.
392
+ let frags = null;
393
+ if (getComputedStyle(el).display === 'inline') {
394
+ const fs = [...el.getClientRects()].map((f) => {
395
+ const x1 = Math.max(f.left, c.x), x2 = Math.min(f.right, c.x + c.w), y1 = Math.max(f.top, c.y), y2 = Math.min(f.bottom, c.y + c.h);
396
+ return { x: x1, y: y1, w: Math.max(0, x2 - x1), h: Math.max(0, y2 - y1) };
397
+ }).filter((f) => f.w > 0 && f.h > 0);
398
+ if (fs.length > 1) frags = fs;
399
+ }
400
+ out.boxes.push({ i, id: el.getAttribute(attr) || '', tag: el.tagName.toLowerCase(), text: String(el.innerText || el.value || '').trim().slice(0, 40),
401
+ 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 });
251
402
  });
252
403
  // ancestry so a button inside its card is never an "overlap"
253
404
  out.rel = [];
@@ -258,15 +409,18 @@ async function inspect(page, anchors, attr, errorSelectors) {
258
409
  }, { anchors, attr, errorSelectors });
259
410
  }
260
411
 
261
- function grade(info, consoleErrors, width) {
412
+ function grade(info, consoleErrors, width, excused = [], stateOnly = []) {
262
413
  const findings = [];
414
+ // a control of another state is graded only when this view draws it, visible and filled (TF-005)
415
+ const soft = new Set(stateOnly);
416
+ info = { ...info, anchors: info.anchors.filter((a) => !soft.has(a.id) || (a.present && a.visible && a.filled && a.w > 0 && a.h > 0)) };
263
417
  if (info.error_banner) findings.push({ check: 'render', class: 'exception', detail: `the application's error banner is showing (${info.error_banner})` });
264
418
  // a failed fetch ("Failed to load resource … 404") is the assets check's finding, not a page error
265
419
  const pageErrors = consoleErrors.filter((e) => !/^Failed to load resource/i.test(e));
266
420
  for (const e of pageErrors.slice(0, 3)) findings.push({ check: 'render', class: 'exception', detail: `console error: ${e.slice(0, 120)}` });
267
421
  if (info.text_len < 15 && info.controls === 0 && !info.anchors.some((a) => a.present)) findings.push({ check: 'render', class: 'blank-data', detail: `the page is blank (${info.text_len} characters of text, no control)` });
268
422
  for (const a of info.anchors) {
269
- if (!a.present) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" is not on the page` });
423
+ if (!a.present && !excused.includes(a.id)) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" is not on the page` });
270
424
  else if (a.visible && !a.filled) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" (${a.tag}) is empty` });
271
425
  }
272
426
  for (const t of info.tables) {
@@ -291,13 +445,32 @@ function grade(info, consoleErrors, width) {
291
445
  const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
292
446
  const iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
293
447
  if (ix <= 4 || iy <= 4) continue;
294
- const inter = ix * iy, small = Math.min(a.w * a.h, b.w * b.h);
448
+ let inter = ix * iy, small = Math.min(a.w * a.h, b.w * b.h);
449
+ if (a.frags || b.frags) { // a wrapped inline element: what its fragments share (TF-036)
450
+ const fa = a.frags || [a], fb = b.frags || [b];
451
+ const area = (fs) => fs.reduce((s, f) => s + f.w * f.h, 0);
452
+ inter = 0;
453
+ for (const p of fa) for (const q of fb) {
454
+ const px = Math.min(p.x + p.w, q.x + q.w) - Math.max(p.x, q.x), py = Math.min(p.y + p.h, q.y + q.h) - Math.max(p.y, q.y);
455
+ if (px > 4 && py > 4) inter += px * py;
456
+ }
457
+ if (inter === 0) continue;
458
+ small = Math.min(area(fa), area(fb));
459
+ }
295
460
  if (inter / small > 0.9) continue; // one lies inside the other by design (a badge on a card)
296
461
  if (inter / small < 0.25) continue; // a touch, not an overlap
297
462
  overlaps++;
298
463
  const nm = (e) => e.id || `${e.tag} "${e.text || ''}"`;
299
464
  findings.push({ check: 'visual', class: 'overlap', detail: `${nm(a)} overlaps ${nm(b)} at ${width}px` });
300
465
  }
466
+ // Lekhak TF-024: text a reader cannot read, and a white panel left in a dark shell
467
+ const c = info.contrast;
468
+ if (c) {
469
+ for (const x of c.low) findings.push({ check: 'visual', class: 'low-contrast',
470
+ 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}` : ''}` });
471
+ for (const x of c.light) findings.push({ check: 'visual', class: 'light-surface',
472
+ 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}` : ''}` });
473
+ }
301
474
  const render = findings.some((f) => f.check === 'render' && f.class === 'exception') ? 'ERROR'
302
475
  : findings.some((f) => f.check === 'render') ? 'EMPTY' : 'OK';
303
476
  const visual = findings.some((f) => f.check === 'visual') ? 'FAIL' : 'OK';
@@ -309,6 +482,7 @@ mkdirSync(SHOTS, { recursive: true });
309
482
  const results = [];
310
483
  let loginResult = null;
311
484
  let firstUnreachable = false;
485
+ let darkOffered = false; // auto mode: has any screen shown a dark theme yet? (Lekhak TF-024)
312
486
  for (const width of WIDTHS) {
313
487
  const { page, close } = await contextFor(width);
314
488
  const consoleErrors = [];
@@ -317,39 +491,162 @@ for (const width of WIDTHS) {
317
491
  if (!CDP) { const l = await login(page); if (loginResult === null) loginResult = l; }
318
492
  for (const s of screens) {
319
493
  let r = results.find((x) => x.name === s.name);
320
- if (!r) { r = { name: s.name, route: s.route, mockup: s.mockup, rows: s.rows, anchors: anchorsOf(s.mockup), widths: [] }; results.push(r); }
494
+ if (!r) {
495
+ r = { name: s.name, route: s.route, mockup: s.mockup, rows: s.rows, anchors: anchorsOf(s.mockup), widths: [] }; results.push(r);
496
+ r.hidden_by_width = await hiddenByWidth(mockBrowser, s.mockup, r.anchors);
497
+ r.state_only = await stateOnlyOf(mockBrowser, s.mockup, r.anchors);
498
+ if (s.route_pattern) r.route_pattern = s.route_pattern;
499
+ if (s.seed) r.seed = runSeed(s.seed);
500
+ }
501
+ const excused = (r.hidden_by_width || {})[width] || [];
502
+ const stateOnly = r.state_only || [];
321
503
  consoleErrors.length = 0;
322
- const nav = await navigate(page, s.route);
504
+ let nav = await navigate(page, s.route);
323
505
  const shot = `${SHOTS}/${slug(s.name)}-${width}.png`;
324
506
  const entry = { width, url: nav.url || '', status: nav.status, screenshot: shot, console_errors: [] };
507
+ // --- AppManager TF-006: a sign-in the page keeps for itself, not in a cookie. The document for a
508
+ // signed-in screen answers 401 because the server cannot see the sign-in; the page then draws the
509
+ // screen once its live connection reads the session in the tab, or never, when the sign-in lives
510
+ // only in that connection. So a 401 or 403 is judged by what the page draws: signed in, the screen
511
+ // is graded; still signed out, the tool signs in again and opens the screen from inside the page.
512
+ if (!CDP && LOGIN_PATH && USER && (nav.status === 401 || nav.status === 403)) {
513
+ await waitForRender(page, [], ATTR);
514
+ let how = `answered HTTP ${nav.status}, then drew the screen signed in`;
515
+ if (await signedOut(page)) {
516
+ how = '';
517
+ const again = await login(page);
518
+ if (again.ok && await navigateInApp(page, s.route) && !(await signedOut(page))) how = `answered HTTP ${nav.status}; opened from inside the page after signing in`;
519
+ }
520
+ if (how) { entry.reached = how; nav = { status: 200, url: page.url() }; entry.url = nav.url; }
521
+ }
325
522
  const redirectedToLogin = LOGIN_PATH && nav.url && new URL(nav.url).pathname.startsWith(LOGIN_PATH) && !s.route.startsWith(LOGIN_PATH);
326
523
  if (nav.status === 0 || nav.status >= 400 || redirectedToLogin) {
327
524
  entry.render = 'UNREACHABLE'; entry.visual = 'n/a';
328
525
  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}` }];
329
526
  if (nav.status === 0 && results.length === 1 && width === WIDTHS[0]) firstUnreachable = true;
330
- } else {
331
- entry.render_wait_ms = await waitForRender(page, r.anchors || [], ATTR);
332
- const info = await inspect(page, r.anchors || [], ATTR, ERROR_SELECTORS);
333
- const g = grade(info, consoleErrors, width);
334
- Object.assign(entry, g);
335
- entry.console_errors = consoleErrors.slice(0, 5);
336
- entry.anchors_present = (info.anchors || []).filter((a) => a.present).length;
527
+ try { await page.screenshot({ path: shot, fullPage: !CDP }); } catch (e) { entry.screenshot = ''; }
528
+ r.widths.push(entry);
529
+ continue;
530
+ }
531
+ entry.render_wait_ms = await waitForRender(page, (r.anchors || []).filter((id) => !excused.includes(id) && !stateOnly.includes(id)), ATTR);
532
+ // one pass per theme on the same opened screen; each pass puts the page back as it found it, so an
533
+ // attached desktop head is left in the viewer's theme (Lekhak TF-024)
534
+ let lightSurface = null;
535
+ for (const theme of THEMES || ['light', 'dark']) {
536
+ const restore = await useTheme(page, theme);
537
+ try {
538
+ if (theme === 'light') lightSurface = await surface(page);
539
+ if (theme === 'dark' && !THEMES && !darkOffered) {
540
+ // auto: the app has a dark theme when dark changes what it paints. Asked screen by screen until
541
+ // one answers yes; from then on every screen is graded in dark, so a screen that ignores the
542
+ // dark theme altogether is graded in it too, and fails.
543
+ darkOffered = differs(lightSurface, await surface(page));
544
+ if (!darkOffered) { r.dark = 'not offered'; continue; }
545
+ }
546
+ const te = theme === 'light' ? entry : { ...entry, screenshot: `${SHOTS}/${slug(s.name)}-${width}-${theme}.png` };
547
+ te.theme = theme;
548
+ const info = await inspect(page, r.anchors || [], ATTR, ERROR_SELECTORS);
549
+ info.contrast = await audit(page, theme === 'dark', MIN_CONTRAST);
550
+ const g = grade(info, theme === 'light' ? consoleErrors : [], width, excused, stateOnly);
551
+ // a screen still showing placeholder text its mockup does not draw is not built (Chatur TF-011)
552
+ if (theme === (THEMES || ['light'])[0]) {
553
+ 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}"` });
554
+ if (g.render === 'OK' && g.findings.some((f) => f.class === 'placeholder')) g.render = 'EMPTY';
555
+ }
556
+ if (r.seed && !r.seed.ok) {
557
+ g.findings.unshift({ check: 'render', class: 'other', detail: `seed ${r.seed.path} failed (exit ${r.seed.exit ?? '?'}): ${r.seed.output}` });
558
+ if (g.render === 'OK') g.render = 'EMPTY';
559
+ }
560
+ if (theme === 'dark') for (const f of g.findings) { f.theme = 'dark'; f.detail += ' in dark mode'; }
561
+ if (excused.length) te.hidden_in_mockup = excused;
562
+ if (stateOnly.length) te.state_only = stateOnly;
563
+ Object.assign(te, g);
564
+ te.console_errors = theme === 'light' ? consoleErrors.slice(0, 5) : [];
565
+ te.anchors_present = (info.anchors || []).filter((a) => a.present).length;
566
+ try { await page.screenshot({ path: te.screenshot, fullPage: !CDP }); } catch (e) { te.screenshot = ''; }
567
+ r.widths.push(te);
568
+ if (theme === 'dark') r.dark = 'graded';
569
+ } finally { await restore(); }
337
570
  }
338
- try { await page.screenshot({ path: shot, fullPage: !CDP }); } catch (e) { entry.screenshot = ''; }
339
- r.widths.push(entry);
340
571
  }
341
572
  await close();
342
573
  }
574
+
575
+ // ---------------------------------------------------------------- menus (Chatur TF-011)
576
+ // Every menu the mockups draw open is opened in the app and every item clicked, once per menu, on the
577
+ // first screen that draws it, at the first width. What a menu lacks or does wrong is a render finding
578
+ // on every screen whose mockup draws that menu. A screen a menu item links to, which no click reached,
579
+ // is UNREACHABLE.
580
+ let menuWalk = { walked: WALK_MENUS, menus: [], clicks: [] };
581
+ if (WALK_MENUS && mockBrowser) {
582
+ const byScreen = {};
583
+ { const ctx = await mockBrowser.newContext(); const mp = await ctx.newPage();
584
+ for (const s of screens) {
585
+ if (!s.mockup || !existsSync(s.mockup)) continue;
586
+ try { await mp.goto('file://' + resolve(s.mockup), { waitUntil: 'load', timeout: 15000 }); byScreen[s.name] = await mockupMenus(mp, ATTR); } catch (e) { byScreen[s.name] = []; }
587
+ }
588
+ await ctx.close().catch(() => {}); }
589
+ const key = (m) => `${m.trigger}|${m.popup}`;
590
+ const done = new Map(); // menu key -> its findings
591
+ const width = WIDTHS[0];
592
+ const anyMockMenus = Object.values(byScreen).some((l) => l.length);
593
+ for (const s of screens) {
594
+ const r = results.find((x) => x.name === s.name);
595
+ if (!r || r.render === 'UNREACHABLE' || r.widths.every((w) => w.render === 'UNREACHABLE')) continue;
596
+ const todo = (byScreen[s.name] || []).filter((m) => !done.has(key(m)));
597
+ if (anyMockMenus && !todo.length) continue;
598
+ if (!anyMockMenus && done.size) break; // the app's own menus: walked once, on the first screen
599
+ const { page, close } = await contextFor(width);
600
+ try {
601
+ if (!CDP) await login(page);
602
+ const back = async () => { await navigate(page, s.route); await waitForRender(page, (r.anchors || []).slice(0, 20), ATTR); };
603
+ await back();
604
+ const pages = async () => CDP ? browser.contexts().flatMap((c) => c.pages()) : page.context().pages();
605
+ 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 });
606
+ menuWalk.menus.push(...w.menus.map((m) => ({ ...m, screen: s.name })));
607
+ 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 })));
608
+ menuWalk._clicks = (menuWalk._clicks || []).concat(w.clicks);
609
+ if (!w.from_mockup) done.set('app|', w.findings);
610
+ for (const m of todo) done.set(key(m), w.findings.filter((f) => f.popup === m.popup));
611
+ if (!w.from_mockup) { r.menu_findings = w.findings; }
612
+ } catch (e) { menuWalk.error = String(e.message || e).split('\n')[0]; }
613
+ finally { await close(); }
614
+ }
615
+ for (const r of results) {
616
+ const mine = (byScreen[r.name] || []).flatMap((m) => done.get(key(m)) || []).concat(r.menu_findings || []);
617
+ delete r.menu_findings;
618
+ if (!mine.length || !r.widths.length) continue;
619
+ const w0 = r.widths.find((w) => w.render !== 'UNREACHABLE') || r.widths[0];
620
+ w0.findings = [...(w0.findings || []), ...mine.map((f) => ({ check: 'render', class: f.class, detail: f.detail }))];
621
+ if (w0.render === 'OK') w0.render = 'EMPTY';
622
+ }
623
+ 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 || []);
624
+ delete menuWalk._clicks;
625
+ menuWalk.reach = reach;
626
+ for (const [name, v] of Object.entries(reach)) {
627
+ const r = results.find((x) => x.name === name);
628
+ if (!r || v.reached) { if (r) r.reached_by = v.by; continue; }
629
+ r.unreachable_by_menu = `no menu click reaches ${r.route}: ${v.tried.join('; ')}`;
630
+ }
631
+ }
632
+
343
633
  for (const r of results) {
344
634
  const rs = r.widths.map((w) => w.render);
345
635
  r.render = rs.includes('UNREACHABLE') ? 'UNREACHABLE' : rs.includes('ERROR') ? 'ERROR' : rs.includes('EMPTY') ? 'EMPTY' : 'OK';
636
+ if (r.unreachable_by_menu) {
637
+ // the verdict reads the first finding of an UNREACHABLE screen as its reason
638
+ r.render = 'UNREACHABLE';
639
+ r.widths[0].findings = [{ check: 'render', class: 'unreachable', detail: r.unreachable_by_menu }, ...(r.widths[0].findings || [])];
640
+ }
346
641
  r.visual = r.widths.some((w) => w.visual === 'FAIL') ? 'FAIL' : r.render === 'UNREACHABLE' ? 'n/a' : 'OK';
347
642
  r.anchors_n = (r.anchors || []).length;
348
643
  const notes = r.widths.flatMap((w) => (w.findings || []).map((f) => `${f.detail}`)).slice(0, 4);
349
- 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('; ') : ''}`);
644
+ r.themes = [...new Set(r.widths.map((w) => w.theme).filter(Boolean))];
645
+ 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('; ') : ''}`);
350
646
  }
351
- const out = writeOut({}, results, loginResult);
647
+ const out = writeOut({ menus: menuWalk }, results, loginResult);
352
648
  if (loginResult && loginResult.attempted && !loginResult.ok) console.log(`LOGIN failed at ${BASE}${LOGIN_PATH}: ${loginResult.error || 'still on the sign-in page'}`);
353
- console.log(`screens ${out.summary.screens}: 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}`);
649
+ 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}`);
650
+ if (CDP && mockBrowser) await mockBrowser.close().catch(() => {});
354
651
  if (!CDP) await browser.close(); else await browser.close().catch(() => {});
355
652
  process.exit(firstUnreachable && out.summary.unreachable === out.summary.screens ? 2 : (out.summary.render_fail + out.summary.visual_fail + out.summary.unreachable) ? 5 : 0);
@@ -7,12 +7,25 @@
7
7
  # bash .tfcore/utils/tf-verify-screens.sh --list … --cdp http://172.18.144.1:9223 # an embedded-browser desktop head
8
8
  # … --error-selector '.my-crash-banner' # the banner THIS stack shows when its client side falls over
9
9
  # # (repeatable; defaults cover one framework's id and [data-error-ui])
10
+ # … --route-value ProviderId=21 # a sample for /x/{ProviderId:long} (repeatable; or "route_values"
11
+ # # in the list); a screen whose route still needs one is SKIPped
12
+ # bash .tfcore/utils/tf-verify-screens.sh --list … --appium http://localhost:4723 # a MAUI Mac Catalyst head
13
+ # (tf-verify-native.mjs: AutomationId for data-testid, the app's window for the screenshot; the
14
+ # bundle id comes from boot.json or --bundle; no Playwright needed)
15
+ # A mockup control marked data-tf-state="<state>" (on it or a box around it) belongs to another state
16
+ # of the screen: it is not owed on the first view.
17
+ # … --themes light,dark | light | dark # default auto: light, then dark once the app shows it has a
18
+ # # dark theme (what it paints changes when dark is put on it)
19
+ # … --min-contrast 3 # the text contrast floor, ratio : 1 (Lekhak TF-024)
10
20
  #
11
21
  # For every screen, at 1280 and 390 px: every control the mockup anchors is present and shows
12
22
  # something, no header-only table, no blank page, no Blazor or console error (render); nothing
13
23
  # overlaps, nothing has zero size or sits off-screen, no sideways scroll, a stylesheet is loaded
14
- # (visual). Overlap and off-screen are measured on the rectangle an element paints in, clipped by
15
- # every scrolling ancestor, and the page is read only after its first render (--render-wait,
24
+ # (visual); every text run reaches 3.0 : 1 against the colour painted behind it (disabled controls
25
+ # exempt) and, in dark mode, no box over 2% of the window paints a light panel (visual). Each theme is
26
+ # put on the page through prefers-color-scheme and the light/dark value of its data-*theme attributes
27
+ # and mode classes, and taken off again after. Overlap and off-screen are measured on the rectangle an
28
+ # element paints in, clipped by every scrolling ancestor, and the page is read only after its first render (--render-wait,
16
29
  # default 5000 ms). Saves a screenshot per screen and width under tests/.artifacts/verify/screens/ and
17
30
  # writes tests/.artifacts/verify/screens.json; that JSON is the evidence a smoke names before a
18
31
  # row is written Implemented, and what the verdict script reads. It decides no row's status.
@@ -22,10 +35,15 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
35
  # `--help` is a request and exits 0; no argument at all is a mistake and exits 3. (The one-line
23
36
  # form this replaces computed "0--help" as its exit code and printed a bash error under the help.)
24
37
  case "${1:-}" in
25
- -h|--help) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
26
- "") sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 3 ;;
38
+ -h|--help) sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
39
+ "") sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 3 ;;
27
40
  esac
28
41
  command -v node >/dev/null 2>&1 || { echo "tf-verify-screens: node is required (bash .tfcore/utils/tf-verify-env.sh)" >&2; exit 4; }
42
+ case " $* " in *" --appium "*)
43
+ source "$HERE/tf-lock.sh"; tf_take_lock verify-screens
44
+ node "$HERE/tf-verify-native.mjs" "$@"; exit $? ;;
45
+ esac
29
46
  node -e "import('playwright').then(()=>process.exit(0)).catch(()=>process.exit(1))" 2>/dev/null || {
30
47
  echo "tf-verify-screens: the 'playwright' package is not resolvable from this folder; run bash .tfcore/utils/tf-verify-env.sh" >&2; exit 4; }
31
- exec node "$HERE/tf-verify-screens.mjs" "$@"
48
+ source "$HERE/tf-lock.sh"; tf_take_lock verify-screens # one browser check at a time (TF-048)
49
+ node "$HERE/tf-verify-screens.mjs" "$@"