@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
@@ -40,9 +40,12 @@
40
40
  // means for a REQ.
41
41
 
42
42
  import { chromium } from 'playwright';
43
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
44
- import { dirname, resolve } from 'node:path';
43
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
44
+ import { basename, dirname, join, resolve } from 'node:path';
45
45
  import { pathToFileURL } from 'node:url';
46
+ import { signIn, reach } from './tf-login.mjs';
47
+ import { parseThemes, useTheme, surface, differs, looksDark } from './tf-theme.mjs';
48
+ import { mockupMenus, walkMenus, placeholdersOnPage, reachability } from './tf-menus.mjs';
46
49
 
47
50
  // ---------------------------------------------------------------- arguments
48
51
  const argv = process.argv.slice(2);
@@ -53,10 +56,19 @@ const arg = (name, dflt = null) => {
53
56
  const flag = (name) => argv.includes(name);
54
57
 
55
58
  const BASE = (arg('--base') || '').replace(/\/$/, '');
59
+ // An embedded-browser desktop head, attached over its DevTools port as tf-verify-screens does; its
60
+ // screens are opened the way its router does, by pushState (Lekhak TF-006).
61
+ const CDP = arg('--cdp') || '';
56
62
  const MOCKUPS = arg('--mockups', 'docs/mockups');
57
63
  const WIDTHS = (arg('--widths', '1280,390') || '').split(',').map((w) => parseInt(w.trim(), 10)).filter(Boolean);
58
64
  const JSON_OUT = arg('--json-out');
59
65
  const COOKIE = arg('--cookie');
66
+ // A sign-in the page keeps for itself sets no cookie, so --cookie could not reach AppManager's screens
67
+ // at all: "app returned HTTP 401" at both widths (AppManager TF-011). The same --login-path, --user,
68
+ // --password and --storage-state tf-verify-screens takes, through the shared recipe in tf-login.mjs.
69
+ const LOGIN_PATH = arg('--login-path'); const USER = arg('--user'); const PASS = arg('--password');
70
+ const STORAGE = arg('--storage-state');
71
+ const LOGIN_OPTS = { base: BASE, loginPath: LOGIN_PATH, user: USER, password: PASS, settle: 1500, renderWait: 5000 };
60
72
  const HEADERS = [];
61
73
  for (let i = 0; i < argv.length; i++) if (argv[i] === '--header' && argv[i + 1]) HEADERS.push(argv[i + 1]);
62
74
  const SCREENS = [];
@@ -66,11 +78,118 @@ for (let i = 0; i < argv.length; i++) {
66
78
  SCREENS.push({ name: name.trim(), route: rest.join('=').trim() || '/' + name.trim() });
67
79
  }
68
80
  }
81
+ // Where each screen's mockup is (Lekhak TF-022). --list names the list.json tf-verify-list.sh wrote,
82
+ // which holds the path it resolved per screen (docs/mockups/admin/prompt-manager.html); a --screen is
83
+ // matched to a listed screen by its mockup's stem or its name, and with no --screen every listed screen
84
+ // that has a mockup and a route without a {value} is driven. Without a list, a mockup missing at the top
85
+ // of --mockups is looked for in its subfolders, and used when exactly one file has that name.
86
+ const MOCK_OF = {};
87
+ const LIST = arg('--list');
88
+ if (LIST) {
89
+ const slug = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
90
+ let listed = [];
91
+ try { listed = JSON.parse(readFileSync(LIST, 'utf8')).screens || []; }
92
+ catch (e) { console.error(`tf-mockup-parity: --list ${LIST} unreadable (${String(e.message).slice(0, 80)})`); }
93
+ const byKey = {};
94
+ for (const ls of listed) {
95
+ if (!ls.mockup) continue;
96
+ byKey[basename(ls.mockup).replace(/\.html$/, '')] ??= ls;
97
+ byKey[slug(ls.name)] ??= ls;
98
+ }
99
+ if (!SCREENS.length) {
100
+ for (const ls of listed) {
101
+ if (ls.mockup && ls.route && !ls.route.includes('{')) SCREENS.push({ name: basename(ls.mockup).replace(/\.html$/, ''), route: ls.route });
102
+ }
103
+ }
104
+ for (const s of SCREENS) {
105
+ const ls = byKey[s.name] || byKey[slug(s.name)];
106
+ if (ls) MOCK_OF[s.name] = ls.mockup;
107
+ }
108
+ }
109
+ const findMockup = (name) => {
110
+ if (MOCK_OF[name]) return resolve(MOCK_OF[name]);
111
+ const top = resolve(MOCKUPS, `${name}.html`);
112
+ if (existsSync(top)) return top;
113
+ const hits = [];
114
+ const walk = (dir, depth) => {
115
+ if (depth > 4) return;
116
+ let ents = [];
117
+ try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
118
+ for (const e of ents) {
119
+ if (e.isDirectory() && !e.name.startsWith('.')) walk(join(dir, e.name), depth + 1);
120
+ else if (e.isFile() && e.name === `${name}.html` && dir !== resolve(MOCKUPS)) hits.push(join(dir, e.name));
121
+ }
122
+ };
123
+ walk(resolve(MOCKUPS), 0);
124
+ return hits.length === 1 ? hits[0] : top;
125
+ };
69
126
  const THIN_RATIO = parseFloat(arg('--thin-ratio', '0.25'));
127
+ // --keep-app-theme compares the app in the theme it is showing, not the mockup's (Lekhak TF-010)
128
+ const KEEP_APP_THEME = flag('--keep-app-theme');
129
+ // Lekhak TF-024: a screen is compared in the mockup's own theme and then in the other one. --themes
130
+ // light,dark runs both; light or dark alone runs only that one; auto (the default) runs the other one
131
+ // once the app shows it has it (what it paints changes when that theme is put on it). In the other
132
+ // theme the mockup is drawn in it too; when the mockup does not change under it (no dark styles in the
133
+ // mockup), the app is compared with the mockup as drawn and colour is left out, since the mockup says
134
+ // nothing about it; every other class is compared.
135
+ const THEMES = parseThemes(arg('--themes', 'auto'));
136
+ const wantTheme = (t) => !THEMES || THEMES.includes(t);
137
+ let otherSeen = false; // auto: has any screen shown the other theme yet?
138
+ // The theme a page declares: data-* attributes on <html> and <body> whose name says theme, mode or
139
+ // scheme (data-theme, data-bs-theme, data-site-theme, data-color-mode …), and the class tokens there
140
+ // that name a light or dark mode (dark, light, theme-dark, dark-mode …), which a class-driven dark mode
141
+ // such as Tailwind's `dark` uses (Lekhak TF-013). A utility class such as bg-light does not count.
142
+ // Nothing about any one stack. The class tokens are kept under `<where>|class`, '' when there are none.
143
+ const READ_THEME = () => {
144
+ const MODE = /^(?:(?:theme|mode|scheme|color)[-_])?(?:dark|light)(?:[-_](?:theme|mode|scheme))?$/i;
145
+ const out = {};
146
+ for (const [where, el] of [['html', document.documentElement], ['body', document.body]]) {
147
+ if (!el) continue;
148
+ for (const a of el.attributes) if (/^data-.*(theme|mode|scheme)/i.test(a.name)) out[`${where}|${a.name}`] = a.value;
149
+ out[`${where}|class`] = [...el.classList].filter((c) => MODE.test(c)).join(' ');
150
+ }
151
+ return out;
152
+ };
153
+ // A page declares a theme when it has such an attribute or mode class. A mockup that declares none
154
+ // leaves the app as it is.
155
+ const DECLARES_THEME = (t) => !!t && Object.entries(t).some(([k, v]) => !k.endsWith('|class') || v);
156
+ // Sets those attributes and mode classes on this page; returns what it had, so the same call puts it
157
+ // back. An attribute the page lacked is removed again on the way back (value null). For `|class` the
158
+ // page's own mode tokens are replaced by the given ones; every other class is left alone.
159
+ const APPLY_THEME = (t) => {
160
+ const MODE = /^(?:(?:theme|mode|scheme|color)[-_])?(?:dark|light)(?:[-_](?:theme|mode|scheme))?$/i;
161
+ const before = {}; let changed = false;
162
+ for (const [k, v] of Object.entries(t || {})) {
163
+ const [where, name] = k.split('|');
164
+ const el = where === 'body' ? document.body : document.documentElement;
165
+ if (!el) continue;
166
+ if (name === 'class') {
167
+ const had = [...el.classList].filter((c) => MODE.test(c));
168
+ const want = String(v || '').split(/\s+/).filter(Boolean);
169
+ before[k] = had.join(' ');
170
+ if (had.slice().sort().join(' ') !== want.slice().sort().join(' ')) changed = true;
171
+ for (const c of had) el.classList.remove(c);
172
+ for (const c of want) el.classList.add(c);
173
+ continue;
174
+ }
175
+ const had = el.getAttribute(name);
176
+ before[k] = had;
177
+ if (had !== v) changed = true;
178
+ if (v === null) el.removeAttribute(name); else el.setAttribute(name, v);
179
+ }
180
+ return { before, changed };
181
+ };
70
182
  const MAX_FINDINGS_PER_SCREEN = parseInt(arg('--max-findings', '40'), 10);
183
+ // Chatur TF-011: a closed menu bar matches its mockup, so menus drawn open (data-tf-state="popup-open") are
184
+ // opened and every item clicked (tf-menus.mjs). --placeholder-text and --no-click are repeatable;
185
+ // --no-menus leaves the menus closed.
186
+ const many = (name) => argv.flatMap((a, i) => (a === name && i + 1 < argv.length ? [argv[i + 1]] : []));
187
+ const PLACEHOLDER_EXTRA = many('--placeholder-text');
188
+ const NO_CLICK_EXTRA = many('--no-click');
189
+ const WALK_MENUS = !flag('--no-menus');
71
190
 
72
- if (!BASE || SCREENS.length === 0) {
73
- console.error('usage: tf-mockup-parity.sh --base URL --screen name=/route [--screen ...]');
191
+ if ((!BASE && !CDP) || SCREENS.length === 0) {
192
+ console.error('usage: tf-mockup-parity.sh --base URL|--cdp URL --screen name=/route [--screen ...]');
74
193
  process.exit(3);
75
194
  }
76
195
 
@@ -78,7 +197,9 @@ if (!BASE || SCREENS.length === 0) {
78
197
  // Everything below runs INSIDE the browser, on both the mockup and the app, and
79
198
  // returns one flat index keyed by a stable path key. It is deliberately one
80
199
  // evaluate() call: two passes over the same DOM can disagree after a re-layout.
81
- const PROBE = () => {
200
+ // `wanted` names the texts of the mockup's badges, so the app's side can find each one at whatever
201
+ // depth the app drew it (TF-045); the mockup's own probe is called without it.
202
+ const PROBE = (wanted = []) => {
82
203
  const MAX_DEPTH = 4; // descend this far below an anchor
83
204
  const MAX_PER_ANCHOR = 60; // and no further; a huge subtree is noise, not signal
84
205
 
@@ -100,11 +221,23 @@ const PROBE = () => {
100
221
  return false;
101
222
  };
102
223
 
224
+ // --- TF-027: the browser converts the colour, never a regex. getComputedStyle returns a
225
+ // colour in the syntax it was written in, and a component library themed in oklch() came
226
+ // back as "oklch(0.971 0.013 17.38)": read as r/g/b those three numbers are near-black, so
227
+ // every tinted tile, pill and icon in the app bucketed as neutral against a mockup written
228
+ // in hex — "semantic colour differs — mockup negative, app neutral" on tiles plainly red.
229
+ // Painting one pixel and reading it back gives sRGB for any syntax the browser accepts.
230
+ const px = document.createElement('canvas');
231
+ px.width = px.height = 1;
232
+ const pctx = px.getContext('2d', { willReadFrequently: true });
103
233
  const RGB = (v) => {
104
- const m = (v || '').match(/-?[\d.]+/g);
105
- if (!m || m.length < 3) return null;
106
- const a = m.length > 3 ? parseFloat(m[3]) : 1;
107
- return a < 0.05 ? null : { r: +m[0], g: +m[1], b: +m[2] };
234
+ if (!v || v === 'transparent' || !pctx) return null;
235
+ pctx.clearRect(0, 0, 1, 1);
236
+ pctx.fillStyle = 'rgba(0,0,0,0)';
237
+ pctx.fillStyle = v; // an unparseable value leaves it transparent
238
+ pctx.fillRect(0, 0, 1, 1);
239
+ const [r, g, b, a] = pctx.getImageData(0, 0, 1, 1).data;
240
+ return a / 255 < 0.05 ? null : { r, g, b };
108
241
  };
109
242
 
110
243
  // Semantic BUCKET, never the literal colour: two designs may legitimately differ
@@ -114,15 +247,26 @@ const PROBE = () => {
114
247
  const { r, g, b } = c;
115
248
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
116
249
  if (mx - mn < 28) return 'neutral';
117
- if (r > g && r > b) return g > 120 ? 'warning' : 'negative';
118
- if (g > r && g > b) return 'positive';
119
- if (b > r && b > g) return 'accent';
120
- return 'neutral';
250
+ // By hue, never by one channel. "Red is highest, so green above 120 is a warning" put a deep
251
+ // amber (180, 83, 9) in `negative` beside a light amber in `warning` — one warning tone on two
252
+ // sides of the line, reported as a colour difference on TfLens /misses (TF-045).
253
+ const d = mx - mn;
254
+ const h = ((mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60 + 360) % 360;
255
+ if (h < 15 || h >= 330) return 'negative';
256
+ if (h < 70) return 'warning';
257
+ if (h < 185) return 'positive';
258
+ return 'accent';
121
259
  };
122
260
 
261
+ // A border nobody draws carries no colour. `border: 1px solid transparent` is how a mockup
262
+ // reserves space, and a page reset that sets border-color everywhere leaves the property set on
263
+ // elements with no border at all: reading it gave a borderless icon the reset's grey and called
264
+ // it a colour difference (TF-038).
265
+ const drawnBorder = (cs) => (parseFloat(cs.borderTopWidth) || 0) > 0 && !!RGB(cs.borderTopColor);
266
+
123
267
  const semanticColor = (el) => {
124
268
  const cs = getComputedStyle(el);
125
- return bucket(RGB(cs.backgroundColor)) || bucket(RGB(cs.borderTopColor)) || bucket(RGB(cs.color));
269
+ return bucket(RGB(cs.backgroundColor)) || (drawnBorder(cs) ? bucket(RGB(cs.borderTopColor)) : null) || bucket(RGB(cs.color));
126
270
  };
127
271
 
128
272
  // --- TF-009: the seventh class. A mockup says "provisional / estimated /
@@ -136,12 +280,39 @@ const PROBE = () => {
136
280
  const q = (s) => (s === 'none' || s === 'hidden' ? 'none'
137
281
  : s === 'dashed' || s === 'dotted' ? 'dashed' : 'solid');
138
282
  const sides = ['borderTopStyle', 'borderRightStyle', 'borderBottomStyle', 'borderLeftStyle'].map((k) => q(cs[k]));
139
- const w = parseFloat(cs.borderTopWidth) || 0;
140
- const style = w === 0 ? 'none' : sides[0];
141
- return { style, uniform: new Set(sides).size === 1, visible: w > 0 };
283
+ const drawn = drawnBorder(cs); // width AND a colour: transparent draws nothing (TF-038)
284
+ const style = drawn ? sides[0] : 'none';
285
+ return { style, uniform: new Set(sides).size === 1, visible: drawn };
142
286
  };
143
287
 
144
- const hasIcon = (el) => !!el.querySelector('svg, img, i[class*="icon"], span[class*="icon"], [class*="bi-"], [class*="fa-"]');
288
+ const ICON = 'svg, img, i[class*="icon"], span[class*="icon"], [class*="bi-"], [class*="fa-"]';
289
+ // A box the mockup marks as another state of the screen is left out of the comparison (Lekhak
290
+ // TF-011), and so is everything inside it when a box around it is counted: the download icon in a
291
+ // "newer build" banner was reported on the card holding the banner (Chatur TF-001).
292
+ // data-tf-state="sample-data" is not another state: it is sample data, the data-tf-sample rule
293
+ // (Chatur TF-005). Left out here, a list the app fills with real rows read "app carries an icon the
294
+ // mockup does not" on every row.
295
+ const STATE = '[data-tf-state]:not([data-tf-state="sample-data"]),[data-state-testid]';
296
+ const SAMPLE = '[data-tf-sample],[data-tf-state="sample-data"]';
297
+ // A chart's <svg> is not an icon (TF-054): an svg larger than 48px on either side is left out.
298
+ const glyph = (x) => x.tagName.toLowerCase() !== 'svg' || (() => { const r = x.getBoundingClientRect(); return r.width <= 48 && r.height <= 48; })();
299
+ const own = (el, sel) => [...el.querySelectorAll(sel)].filter((x) => (!x.closest(STATE) || el.closest(STATE)) && (sel !== ICON || glyph(x)));
300
+ const ownText = (el) => {
301
+ if (!el.querySelector(STATE) || el.closest(STATE)) return el.textContent || '';
302
+ const c = el.cloneNode(true);
303
+ for (const x of c.querySelectorAll(STATE)) x.remove();
304
+ return c.textContent || '';
305
+ };
306
+ const hasIcon = (el) => own(el, ICON).length > 0;
307
+ // how many icons the element carries at any depth: a component library that wraps an icon
308
+ // one element deeper than the mockup moves it to another path key without removing it (TF-027)
309
+ const iconCount = (el) => own(el, ICON).filter((i) => !i.parentElement || !i.parentElement.closest('svg')).length;
310
+ // Every icon on the page by number, so the node side can tell that an anchor and the wrappers
311
+ // above it hold the same icon as the element it sits in: the icon clause asks "any icon inside?",
312
+ // and one Select chevron was reported at four levels of TfLens /misses (TF-046).
313
+ const iconNo = new Map([...document.querySelectorAll(ICON)].filter(glyph).map((i, n) => [i, n]));
314
+ const iconIds = (el) => own(el, ICON).map((i) => iconNo.get(i));
315
+ const depthOf = (el) => { let n = 0; for (let p = el.parentElement; p; p = p.parentElement) n++; return n; };
145
316
 
146
317
  // "Chrome" = a badge / pill / chip: a small element with its own fill or ring and
147
318
  // a rounded edge. The mockup drawing one and the app rendering bare text is the
@@ -153,10 +324,30 @@ const PROBE = () => {
153
324
  const cs = getComputedStyle(el);
154
325
  const radius = parseFloat(cs.borderTopLeftRadius) || 0;
155
326
  const filled = !!RGB(cs.backgroundColor);
156
- const ringed = (parseFloat(cs.borderTopWidth) || 0) > 0;
327
+ const ringed = drawnBorder(cs); // a transparent ring is not a ring (TF-038)
157
328
  return { badge: (filled || ringed) && radius >= 6, filled, ringed, radius: Math.round(radius) };
158
329
  };
159
330
 
331
+ // --- TF-047: the treatment one element away. A component library draws the ring and the radius on
332
+ // the box AROUND the control the mockup styles (an InputGroup around a filter input, a nav <li>
333
+ // around the active link), and a mockup does the reverse. Keys pair by position, so the badge and
334
+ // stroke clauses compared the control with the plain box beside the one that carries the treatment:
335
+ // "badge on nav > a[4], app plain" on TfLens's sidebar, on every screen. The element's neighbours
336
+ // one step away are kept here — the parent when it is the same row (same height, its other
337
+ // children carrying no text: an icon, or the box a library wraps one in) and the only child when
338
+ // it is — so a clause can accept the treatment there.
339
+ const nearOf = (el) => {
340
+ const out = [];
341
+ const h = el.getBoundingClientRect().height;
342
+ const sameRow = (x) => Math.abs(x.getBoundingClientRect().height - h) <= 8;
343
+ const p = el.parentElement;
344
+ if (p && p !== document.body && sameRow(p)
345
+ && [...p.children].every((s) => s === el || isHidden(s) || !(s.textContent || '').trim())) out.push(p);
346
+ const kids = [...el.children].filter((c) => !isHidden(c));
347
+ if (kids.length === 1 && sameRow(kids[0])) out.push(kids[0]);
348
+ return out;
349
+ };
350
+
160
351
  // Only text that is genuinely a single inline run can be graded for wrapping or
161
352
  // token fit. A container with block children has no meaningful line count, and
162
353
  // pretending otherwise is how a card-shaped anchor buys nothing (TF-011).
@@ -173,8 +364,11 @@ const PROBE = () => {
173
364
  const cs = getComputedStyle(el);
174
365
  let lh = parseFloat(cs.lineHeight);
175
366
  if (!lh || Number.isNaN(lh)) lh = (parseFloat(cs.fontSize) || 16) * 1.2;
176
- const h = el.getBoundingClientRect().height;
177
- if (!h || !lh) return null;
367
+ // the text's height only: padding and borders are not rows. A badge with 4px padding and
368
+ // `line-height: 1` read 18 / 9.756 = 1.85, rounded to 2 rows (AppManager TF-015).
369
+ const h = el.getBoundingClientRect().height
370
+ - ['paddingTop', 'paddingBottom', 'borderTopWidth', 'borderBottomWidth'].reduce((s, k) => s + (parseFloat(cs[k]) || 0), 0);
371
+ if (!(h > 0) || !lh) return null;
178
372
  return Math.max(1, Math.round(h / lh));
179
373
  };
180
374
 
@@ -206,33 +400,90 @@ const PROBE = () => {
206
400
  // --- TF-012 again, on the measuring side. scrollWidth includes the phantom
207
401
  // extent of an sr-only descendant, so the ancestor is measured from the right
208
402
  // edge of its VISIBLE descendants instead whenever a hidden one is present.
403
+ // A descendant inside a scroller is painted only as far as that scroller's edge, so its own box
404
+ // must be cut by every ancestor that clips before it can count as the card overflowing. Without
405
+ // that, a table scrolling inside its wrapper — the layout the BRD asks for — read as a card cut
406
+ // off at 390px (TF-039). The same rectangle tf-verify-screens measures since TF-021.
407
+ // The cut stops BELOW `stop`: the element being measured must not cut its own content, or a card
408
+ // that really clips would read as holding everything (it did, from TF-039 until TF-042).
409
+ const paintedRight = (c, stop) => {
410
+ const r = c.getBoundingClientRect();
411
+ let x1 = r.left, x2 = r.right;
412
+ for (let a = c.parentElement; a && a !== stop; a = a.parentElement) {
413
+ if (getComputedStyle(a).overflowX !== 'visible') {
414
+ const ar = a.getBoundingClientRect();
415
+ x1 = Math.max(x1, ar.left); x2 = Math.min(x2, ar.right);
416
+ }
417
+ }
418
+ return x2 > x1 ? x2 : null;
419
+ };
420
+
421
+ // --- TF-042: overflow is not clipping. An element whose overflow-x is `visible` draws what spills
422
+ // past its edge in full, so it is cut off only where an ancestor that clips ends first. Reading
423
+ // scrollWidth alone called a sidebar "cut off" because its rail handle straddles its edge by 8px,
424
+ // on every TfLens screen, with nothing clipped anywhere up to <html> (2026-09-12).
425
+ const cutAbove = (el, right) => {
426
+ for (let a = el.parentElement; a; a = a.parentElement) {
427
+ if (getComputedStyle(a).overflowX === 'visible') continue;
428
+ if (right > a.getBoundingClientRect().left + a.clientLeft + a.clientWidth + 2) return true;
429
+ }
430
+ return false;
431
+ };
432
+
209
433
  const clipOf = (el) => {
210
434
  const hiddenKids = [...el.querySelectorAll('*')].filter(isHidden);
211
- let overX;
435
+ const box = el.getBoundingClientRect();
436
+ let overX, right;
212
437
  if (hiddenKids.length === 0) {
213
438
  overX = el.scrollWidth - el.clientWidth;
439
+ right = box.left + el.clientLeft + el.scrollWidth;
214
440
  } else {
215
- const box = el.getBoundingClientRect();
216
- let right = box.left;
441
+ right = box.left;
217
442
  for (const c of el.querySelectorAll('*')) {
218
443
  if (isHidden(c)) continue;
219
444
  const r = c.getBoundingClientRect();
220
- if (r.width > 0) right = Math.max(right, r.right);
445
+ if (r.width > 0) {
446
+ const painted = paintedRight(c, el);
447
+ if (painted !== null) right = Math.max(right, painted);
448
+ }
221
449
  }
222
450
  overX = Math.max(0, Math.round(right - (box.left + el.clientWidth)));
223
451
  }
452
+ const cs = getComputedStyle(el);
453
+ const scrolls = (v) => v === 'auto' || v === 'scroll'; // a scroller keeps its own content in reach (TF-053)
454
+ let x = overX > 2 && !scrolls(cs.overflowX);
455
+ if (x && cs.overflowX === 'visible') x = cutAbove(el, right);
224
456
  const overY = el.scrollHeight - el.clientHeight;
225
- return { x: overX > 2, y: overY > 2, sr_excluded: hiddenKids.length };
457
+ return { x, y: overY > 2 && !scrolls(cs.overflowY), sr_excluded: hiddenKids.length };
226
458
  };
227
459
 
228
460
  const sigOf = (el) => {
229
461
  const r = el.getBoundingClientRect();
230
462
  const chrome = chromeOn(el);
463
+ const near = nearOf(el);
231
464
  return {
465
+ // the badge and the visible border one element away, for the clauses to accept (TF-047)
466
+ near: {
467
+ badge: near.some((n) => chromeOn(n)?.badge === true),
468
+ stroke: near.map(strokeOf).find((s) => s.visible) || null,
469
+ },
232
470
  tag: el.tagName.toLowerCase(),
233
- text: (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60),
471
+ text: ownText(el).trim().replace(/\s+/g, ' ').slice(0, 60),
234
472
  badge: chrome ? chrome.badge : null,
473
+ full: ownText(el).trim().replace(/\s+/g, ' ').slice(0, 400),
474
+ // a box the mockup marks as sample data, drawn only when the app has data there (Chatur TF-002)
475
+ sample: el.matches(SAMPLE),
235
476
  icon: hasIcon(el),
477
+ icons: iconCount(el),
478
+ icon_ids: iconIds(el), // which icons, so a finding repeated at a container can be told apart (TF-046)
479
+ // the anchors inside and the icons each holds, so a row graded on its own can be taken off
480
+ // the box around it (Chatur TF-006)
481
+ nested: own(el, '[data-testid]').filter((x) => !isHidden(x))
482
+ .map((x) => ({ id: x.getAttribute('data-testid'), ids: iconIds(x),
483
+ top: x.parentElement?.closest('[data-testid]') === el })).slice(0, 200),
484
+ depth: depthOf(el),
485
+ // badges drawn anywhere inside, so a badge one wrapper deeper still counts under its parent (TF-045)
486
+ badges: own(el, '*').filter((c) => !isHidden(c) && chromeOn(c)?.badge).length,
236
487
  color: semanticColor(el),
237
488
  stroke: strokeOf(el),
238
489
  wrap: lineCount(el),
@@ -252,16 +503,32 @@ const PROBE = () => {
252
503
  // table already was. Keys pair only when both sides produce them, so extra depth
253
504
  // costs precision nothing — it only ever adds comparisons that were impossible
254
505
  // before.
506
+ // A box the mockup marks as another state of the screen (data-tf-state="<state>", or
507
+ // data-state-testid="<id>") is not on the first view, so it is neither walked nor counted when its
508
+ // siblings are numbered: a database-down alert first in a card moved every row below it one place,
509
+ // and the Host/Port row was compared with the alert (Lekhak TF-011).
510
+ const stateOnly = (el) => el.matches(STATE);
255
511
  const keyOf = (el, parentKey) => {
256
512
  const tag = el.tagName.toLowerCase();
257
513
  let n = 0;
258
514
  for (const s of el.parentElement ? el.parentElement.children : []) {
259
515
  if (s === el) break;
260
- if (s.tagName === el.tagName) n++;
516
+ if (s.tagName === el.tagName && !stateOnly(s)) n++;
261
517
  }
262
518
  return `${parentKey} > ${tag}[${n}]`;
263
519
  };
264
520
 
521
+ // --- TF-045: the same badge, one wrapper deeper. Keys are positional, so a component library that
522
+ // wraps a card header's contents in one more <div> moves every badge in it to a key the mockup
523
+ // never has: 32 "missing" findings on three TfLens screens, each drawn on the page with the right
524
+ // text. For every text a mockup badge carries, the elements under the same anchor that carry it
525
+ // are kept here at any depth, so the node side can find the badge where the app put it. Digits
526
+ // are folded, because live data is not the mockup's sample: "87 of 91" is "39 of 41". Case is
527
+ // kept: a card titled "Draft" is not its badge "draft".
528
+ const norm = (t) => (t || '').replace(/\d[\d,.]*/g, '#').replace(/\s+/g, ' ').trim();
529
+ const want = new Set((wanted || []).map(norm).filter(Boolean));
530
+ const pool = {};
531
+
265
532
  const index = {};
266
533
  const anchors = [...document.querySelectorAll('[data-testid]')];
267
534
  const allTestIds = anchors.map((a) => a.getAttribute('data-testid'));
@@ -269,7 +536,7 @@ const PROBE = () => {
269
536
  for (const a of anchors) {
270
537
  const id = a.getAttribute('data-testid');
271
538
  if (!id || index[id]) continue;
272
- if (isHidden(a)) continue;
539
+ if (isHidden(a) || a.closest(STATE)) continue; // TF-011
273
540
  index[id] = sigOf(a);
274
541
  let budget = MAX_PER_ANCHOR;
275
542
  const walk = (el, key, depth) => {
@@ -278,6 +545,7 @@ const PROBE = () => {
278
545
  if (budget <= 0) return;
279
546
  if (c.hasAttribute('data-testid')) continue; // it gets its own top-level entry
280
547
  if (isHidden(c)) continue; // TF-012
548
+ if (stateOnly(c)) continue; // another state of the screen (TF-011)
281
549
  const k = keyOf(c, key);
282
550
  index[k] = sigOf(c);
283
551
  budget--;
@@ -285,11 +553,46 @@ const PROBE = () => {
285
553
  }
286
554
  };
287
555
  walk(a, id, 1);
556
+ if (want.size) {
557
+ const found = [];
558
+ const queue = [[a, id, 0]];
559
+ let seen = 0;
560
+ while (queue.length && seen < 800 && found.length < 30) {
561
+ const [el, key, depth] = queue.shift();
562
+ if (depth >= 12) continue;
563
+ for (const c of el.children) {
564
+ if (c.hasAttribute('data-testid') || isHidden(c) || stateOnly(c)) continue;
565
+ seen++;
566
+ const k = keyOf(c, key);
567
+ if (want.has(norm((c.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60)))) found.push({ key: k, ...sigOf(c) });
568
+ queue.push([c, k, depth + 1]);
569
+ }
570
+ }
571
+ pool[id] = found;
572
+ }
288
573
  }
289
574
 
290
575
  const de = document.documentElement;
576
+ // the shell's content scroll container, when it has one: a box that scrolls inside itself, no
577
+ // taller than the viewport (a table wrapper that grows with its rows is not one), at least 60% of
578
+ // its width and half its height, and across the viewport's centre (a side menu, or a drawer parked
579
+ // off screen, is not one). Without one the document is the app's scroller (AppManager TF-014).
580
+ // A box that only scrolls sideways is not one either: `overflow-x: auto` alone makes the browser
581
+ // compute overflow-y as auto too, so Bootstrap's .table-responsive looked like a shell at 390px
582
+ // (AppManager TF-028). Skipped: a box whose only child is a table, and a box wider inside than
583
+ // out whose height fits its content.
584
+ const scroller = [...document.querySelectorAll('body *')].find((el) => {
585
+ const o = getComputedStyle(el).overflowY;
586
+ if (o !== 'auto' && o !== 'scroll') return false;
587
+ if (el.children.length === 1 && el.children[0].tagName === 'TABLE') return false;
588
+ if (el.scrollWidth > el.clientWidth + 1 && el.scrollHeight <= el.clientHeight + 1) return false;
589
+ const r = el.getBoundingClientRect(), cx = de.clientWidth / 2;
590
+ return r.height <= de.clientHeight + 2 && r.width >= de.clientWidth * 0.6 && r.height >= de.clientHeight / 2
591
+ && r.left <= cx && r.right >= cx;
592
+ });
291
593
  return {
292
594
  index,
595
+ pool,
293
596
  anchors: anchors.length,
294
597
  testids: allTestIds,
295
598
  doc: {
@@ -297,6 +600,7 @@ const PROBE = () => {
297
600
  clientHeight: de.clientHeight,
298
601
  scrollWidth: de.scrollWidth,
299
602
  clientWidth: de.clientWidth,
603
+ scroller: scroller ? `${scroller.tagName.toLowerCase()}${scroller.id ? '#' + scroller.id : ''}${scroller.className ? '.' + String(scroller.className).trim().split(/\s+/)[0] : ''}` : null,
300
604
  },
301
605
  };
302
606
  };
@@ -305,11 +609,18 @@ const PROBE = () => {
305
609
  // A clause returns a finding, `null` (not applicable — do NOT count it as graded),
306
610
  // or `false` (applicable and agreeing). The three are kept distinct because
307
611
  // conflating "agreed" with "not asked" is precisely how TF-011 happened.
612
+ // The same folding the page-side probe uses: spacing and digits (TF-045).
613
+ const norm = (t) => (t || '').replace(/\d[\d,.]*/g, '#').replace(/\s+/g, ' ').trim();
614
+
308
615
  const CLAUSES = {
309
- badge: (m, a) => (m.badge === null || a.badge === null ? null
310
- : m.badge !== a.badge
311
- ? `mockup renders this as a ${m.badge ? 'badge/pill' : 'plain element'}, app renders it as a ${a.badge ? 'badge/pill' : 'plain element'}`
312
- : false),
616
+ badge: (m, a) => {
617
+ if (m.badge === null || a.badge === null) return null;
618
+ if (m.badge === a.badge) return false;
619
+ // TF-047: the side without the treatment carries it one element away — the same control, drawn
620
+ // by a library on the box around it or the box inside it. That is agreement, not drift.
621
+ if ((m.badge ? a : m).near?.badge) return false;
622
+ return `mockup renders this as a ${m.badge ? 'badge/pill' : 'plain element'}, app renders it as a ${a.badge ? 'badge/pill' : 'plain element'}`;
623
+ },
313
624
  icon: (m, a) => (m.icon === a.icon ? false
314
625
  : m.icon && !a.icon ? 'mockup carries an icon here; the app does not'
315
626
  : 'app carries an icon the mockup does not'),
@@ -318,16 +629,26 @@ const CLAUSES = {
318
629
  // TF-009
319
630
  stroke: (m, a) => {
320
631
  if (!m.stroke || !a.stroke) return null;
321
- if (m.stroke.style !== a.stroke.style) {
322
- return `border style differs — mockup ${m.stroke.style}, app ${a.stroke.style}`
323
- + (m.stroke.style === 'dashed' ? ' (a dashed rule is how a mockup says "estimate / provisional"; losing it makes an estimate look measured)' : '');
632
+ let ms = m.stroke, as = a.stroke;
633
+ // TF-047: a side that draws no border of its own is read at the border one element away, and
634
+ // only when the sides disagree — two plain elements inside a ringed box still agree as before.
635
+ if (ms.style !== as.style || ms.visible !== as.visible) {
636
+ if (!ms.visible && m.near?.stroke) ms = m.near.stroke;
637
+ if (!as.visible && a.near?.stroke) as = a.near.stroke;
638
+ }
639
+ if (ms.style !== as.style) {
640
+ return `border style differs — mockup ${ms.style}, app ${as.style}`
641
+ + (ms.style === 'dashed' ? ' (a dashed rule is how a mockup says "estimate / provisional"; losing it makes an estimate look measured)' : '');
324
642
  }
325
- if (m.stroke.visible !== a.stroke.visible) {
326
- return `border presence differs — mockup ${m.stroke.visible ? 'has a visible rule' : 'has none'}, app ${a.stroke.visible ? 'has one' : 'has none'}`;
643
+ if (ms.visible !== as.visible) {
644
+ return `border presence differs — mockup ${ms.visible ? 'has a visible rule' : 'has none'}, app ${as.visible ? 'has one' : 'has none'}`;
327
645
  }
328
646
  return false;
329
647
  },
330
- wrap: (m, a) => (m.wrap === null || a.wrap === null ? null
648
+ // TF-047: a row count says something only about the same text. Live data that reads differently
649
+ // from the mockup's sample wraps differently for that reason alone, so the clause is not applicable
650
+ // (null, never counted as graded) unless the digit-folded texts match.
651
+ wrap: (m, a) => (m.wrap === null || a.wrap === null || norm(m.full) !== norm(a.full) ? null
331
652
  : a.wrap > m.wrap ? `wraps to ${a.wrap} rows where the mockup keeps it on ${m.wrap}` : false),
332
653
  clip: (m, a) => (!m.clip || !a.clip ? null
333
654
  : (a.clip.x && !m.clip.x) ? 'content is cut off horizontally; the mockup is not clipped'
@@ -344,15 +665,153 @@ const CLAUSES = {
344
665
  // as coverage is what let a screen the gate never really looked at read as clean.
345
666
  const CONTENT_CLAUSES = new Set(['badge', 'icon', 'wrap', 'token']);
346
667
 
347
- function diff(mock, app, screen, width) {
668
+ function diff(mock, app, screen, width, skip = null) {
669
+ // --- Chatur TF-002: sample data the app is not showing. A mockup draws a list full of sample rows
670
+ // and a fresh app has none, so every icon on a sample row read as missing. A box the mockup marks
671
+ // data-tf-sample is compared when the app draws something at its place; when it draws nothing
672
+ // there, the box and everything in it are "not measured", and its icons and badges are taken off
673
+ // the boxes around it. A seed script (tests/verify/seed/) puts the app in the drawn state instead.
674
+ const notMeasured = [];
675
+ const mockAll = mock.index; // before anything is dropped (Chatur TF-007)
676
+ // the mockup's sample rows that carry their own anchor (commit-row-N), read before any is dropped
677
+ const fold = (id) => id.replace(/\d+/g, '#');
678
+ const sampleRows = new Set(Object.keys(mock.index).filter((k) => !k.includes(' > ') && mock.index[k].sample));
679
+ const sampleShapes = new Set([...sampleRows].map(fold));
680
+ const idx = { ...mock.index };
681
+ for (const key of Object.keys(mock.index)) {
682
+ const m = mock.index[key];
683
+ if (!m.sample || app.index[key] || notMeasured.some((k) => key.startsWith(k + ' > '))) continue;
684
+ notMeasured.push(key);
685
+ for (const k of Object.keys(idx)) if (k === key || k.startsWith(key + ' > ')) delete idx[k];
686
+ const gone = new Set(m.icon_ids || []);
687
+ for (let up = key; up.includes(' > ');) {
688
+ up = up.slice(0, up.lastIndexOf(' > '));
689
+ const a = idx[up];
690
+ if (!a) continue;
691
+ const icons = Math.max(0, (a.icons ?? 0) - (m.icons ?? 0));
692
+ idx[up] = { ...a, icons, icon: a.icon && icons > 0, icon_ids: (a.icon_ids || []).filter((i) => !gone.has(i)),
693
+ badges: Math.max(0, (a.badges ?? 0) - (m.badges ?? 0) - (m.badge === true ? 1 : 0)) };
694
+ }
695
+ }
696
+ mock = { ...mock, index: idx };
697
+ // --- Chatur TF-006: a sample row graded on its own is not the box's icon too. The mockup's table
698
+ // lost its sample rows' icons when their tbody was not measured, while the app's table still counted
699
+ // every row's: "app carries an icon the mockup does not" on a box whose rows were each compared. A
700
+ // row anchored as a mockup sample row, or as more of the same (commit-row-7 beside commit-row-1), is
701
+ // left out of every box around it on both sides; an icon of the box's own is still counted.
702
+ const strip = (index, goneOf) => Object.fromEntries(Object.entries(index).map(([k, s]) => {
703
+ const gone = goneOf(k, s);
704
+ const ids = (s.icon_ids || []).filter((i) => !gone.has(i));
705
+ const cut = (s.icon_ids || []).length - ids.length;
706
+ if (!cut) return [k, s];
707
+ const icons = Math.max(0, (s.icons ?? 0) - cut);
708
+ return [k, { ...s, icons, icon: icons > 0, icon_ids: ids }];
709
+ }));
710
+ const dropRows = (index, isRow) => strip(index, (k, s) => new Set((s.nested || []).filter((n) => isRow(n.id)).flatMap((n) => n.ids)));
711
+ if (sampleRows.size) {
712
+ mock = { ...mock, index: dropRows(mock.index, (id) => sampleRows.has(id)) };
713
+ app = { ...app, index: dropRows(app.index, (id) => sampleRows.has(id) || (!(id in mock.index) && sampleShapes.has(fold(id)))) };
714
+ }
715
+ // --- Chatur TF-007: the app's rows in the place of sample rows not measured. The mockup's list box
716
+ // lost the icons of sample rows the app does not draw (recent-tflens, tools-table > tbody[0]), while
717
+ // the app's box kept the icons of the real rows it draws there instead (recent-chatur, a wrapped
718
+ // tbody): "app carries an icon the mockup does not" on Start, Prerequisites, Providers, Corrections and
719
+ // the file tree. The app's rows in that place — the children and anchors of the box holding the sample
720
+ // rows that the mockup does not have — are left out of every box around them, as the sample rows are
721
+ // on the mockup's side, there taken off every box too, anchors around the list included.
722
+ // A row's icons come off the boxes AROUND it only: an element above it that holds all of them. A
723
+ // button inside the row keeps its own icon, or it reads as one the mockup does not carry.
724
+ const appAll = app.index;
725
+ const rowsGone = (rows, all) => (k, s) => {
726
+ const had = new Set(all[k]?.icon_ids || s.icon_ids || []);
727
+ return new Set(rows.filter((r) => (s.depth ?? 0) < r.depth && r.ids.every((i) => had.has(i))).flatMap((r) => r.ids));
728
+ };
729
+ const inPlace = [];
730
+ // The app's rows in that place are what the mockup does not have under the same box and of the
731
+ // same kind as the sample row (a tbody for a tbody, an <a> for an <a>), the outermost of them; only
732
+ // when there is none of that kind, every child of the box the mockup does not have. A heading or a
733
+ // toolbar of the box's own is not a row.
734
+ const takeRows = (box, tag) => {
735
+ const a = appAll[box];
736
+ if (!a) return;
737
+ const tagOf = (k) => k.slice(k.lastIndexOf(' > ') + 3).replace(/\[\d+\]$/, '');
738
+ const same = Object.keys(appAll).filter((k) => k.startsWith(box + ' > ') && !(k in mockAll) && tagOf(k) === tag);
739
+ const rows = same.filter((k) => !same.some((o) => o !== k && k.startsWith(o + ' > ')))
740
+ .map((k) => ({ ids: appAll[k].icon_ids || [], depth: appAll[k].depth ?? 0 }));
741
+ for (const n of a.nested || []) {
742
+ if (!(n.id in mockAll) && appAll[n.id]?.tag === tag) rows.push({ ids: n.ids, depth: appAll[n.id].depth ?? 0 });
743
+ }
744
+ if (!rows.length) {
745
+ for (const k of Object.keys(appAll)) {
746
+ if (k.startsWith(box + ' > ') && !k.slice(box.length + 3).includes(' > ') && !(k in mockAll)) {
747
+ rows.push({ ids: appAll[k].icon_ids || [], depth: appAll[k].depth ?? 0 });
748
+ }
749
+ }
750
+ // and the box's own anchors the mockup does not have: a scroll area holding the rows
751
+ for (const n of a.nested || []) {
752
+ if (n.top && !(n.id in mockAll) && appAll[n.id]) rows.push({ ids: n.ids, depth: appAll[n.id].depth ?? 0 });
753
+ }
754
+ }
755
+ inPlace.push(...rows);
756
+ };
757
+ const sampleGone = [];
758
+ for (const k of notMeasured) {
759
+ sampleGone.push({ ids: mockAll[k].icon_ids || [], depth: mockAll[k].depth ?? 0 });
760
+ if (k.includes(' > ')) { takeRows(k.slice(0, k.lastIndexOf(' > ')), mockAll[k].tag); continue; }
761
+ // an anchored sample row: the nearest mockup box holding it
762
+ const box = Object.keys(mockAll).filter((b) => (mockAll[b].nested || []).some((n) => n.id === k))
763
+ .sort((x, y) => (mockAll[y].depth ?? 0) - (mockAll[x].depth ?? 0))[0];
764
+ if (box) takeRows(box, mockAll[k].tag);
765
+ }
766
+ const some = (rows) => rows.filter((r) => r.ids.length);
767
+ if (some(sampleGone).length) mock = { ...mock, index: strip(mock.index, rowsGone(some(sampleGone), mockAll)) };
768
+ if (some(inPlace).length) app = { ...app, index: strip(app.index, rowsGone(some(inPlace), appAll)) };
348
769
  const findings = [];
349
770
  const clauseCoverage = Object.fromEntries(Object.keys(CLAUSES).map((k) => [k, 0]));
350
771
  clauseCoverage.missing = 0;
351
- let compared = 0, contentGraded = 0;
772
+ let compared = 0, contentGraded = 0, relocated = 0;
773
+ const iconsShort = {}; // parent key -> icons the app still owes under it (TF-027)
774
+ const badgesShort = {}; // parent key -> badges the app still owes under it (TF-045)
775
+ const usedApp = new Set();
776
+ const iconSeen = []; // icon findings with the side that holds the icons (TF-046)
777
+
778
+ // --- TF-047: the same icon in another child. Pairing is by position, so a card header that a
779
+ // library wraps in one more <div> pairs the mockup's description with the app's toolbar, and the
780
+ // toolbar's icon reads as one "the mockup does not" carry — while the header holds exactly one
781
+ // icon on both sides. The rule the `missing` clause has followed since TF-027, one wrapper further:
782
+ // an icon finding is dropped when the parent or the grandparent is paired and carries the same
783
+ // number of icons on both sides. Bounded to a few icons, so a coincidence across a whole page
784
+ // (one lost, one added) stays reported.
785
+ const iconMoved = (key) => {
786
+ let k = key;
787
+ for (let up = 0; up < 2; up++) {
788
+ if (!k.includes(' > ')) return false;
789
+ k = k.slice(0, k.lastIndexOf(' > '));
790
+ const pm = mock.index[k], pa = app.index[k];
791
+ if (!pm || !pa) return false;
792
+ const n = pm.icons ?? 0;
793
+ if (n > 0 && n <= 4 && n === (pa.icons ?? 0)) return true;
794
+ }
795
+ return false;
796
+ };
352
797
 
353
798
  for (const key of Object.keys(mock.index)) {
354
799
  const m = mock.index[key];
355
- const a = app.index[key];
800
+ let a = app.index[key];
801
+ let appKey = key;
802
+
803
+ // --- TF-045: a badge the app draws at another depth is the same badge. Found by its text under
804
+ // the same anchor, it is compared like any paired element, so a badge whose colour or ring really
805
+ // differs is still reported, and so is one the app turned into plain text.
806
+ if (!a && m.badge === true && m.text) {
807
+ const root = key.split(' > ')[0];
808
+ const cands = ((app.pool || {})[root] || []).filter((c) => !usedApp.has(c.key) && norm(c.text) === norm(m.text));
809
+ // a badge first, then the same kind of element, then one no mockup key already pairs with;
810
+ // among equals the shallowest, which is the order the probe found them in
811
+ const score = (c) => (c.badge === true ? 4 : 0) + (c.tag === m.tag ? 2 : 0) + (c.key in mock.index ? 0 : 1);
812
+ const pick = cands.reduce((best, c) => (!best || score(c) > score(best) ? c : best), null);
813
+ if (pick) { a = pick; appKey = pick.key; usedApp.add(pick.key); relocated++; }
814
+ }
356
815
 
357
816
  // --- the `missing` clause. Key-pairing alone cannot see an element that is
358
817
  // NOT THERE, and "a control the mockup draws as a badge rendered as plain text"
@@ -367,14 +826,37 @@ function diff(mock, app, screen, width) {
367
826
  if (!a) {
368
827
  const parentKey = key.includes(' > ') ? key.slice(0, key.lastIndexOf(' > ')) : null;
369
828
  const parentPaired = parentKey ? !!app.index[parentKey] : false;
370
- if (parentPaired && (m.badge === true || m.icon === true)) {
829
+ // an icon is missing only when the paired parent carries fewer icons than the mockup's
830
+ // does. Pairing is by position, so an icon the app draws one wrapper deeper sits under
831
+ // another key and was reported missing on every sidebar group and tile (TF-027).
832
+ // and no more reports under one parent than it is icons short
833
+ if (m.icon === true && parentPaired && mock.index[parentKey] && !(parentKey in iconsShort)) {
834
+ iconsShort[parentKey] = Math.max(0, (mock.index[parentKey].icons ?? 0) - (app.index[parentKey].icons ?? 0));
835
+ }
836
+ const iconGone = m.icon === true && parentPaired && (iconsShort[parentKey] || 0) > 0;
837
+ if (iconGone && m.badge !== true) iconsShort[parentKey]--;
838
+ // A badge not found by its text (live data reads otherwise) is missing only while the paired
839
+ // parent draws fewer badges than the mockup's does, the rule icons follow since TF-027 (TF-045).
840
+ if (m.badge === true && parentPaired && mock.index[parentKey] && !(parentKey in badgesShort)) {
841
+ badgesShort[parentKey] = Math.max(0, (mock.index[parentKey].badges ?? 0) - (app.index[parentKey].badges ?? 0));
842
+ }
843
+ const badgeGone = m.badge === true && parentPaired && (badgesShort[parentKey] || 0) > 0;
844
+ if (badgeGone) badgesShort[parentKey]--;
845
+ if (parentPaired && (m.badge === true || iconGone)) {
371
846
  clauseCoverage.missing++;
372
847
  contentGraded++;
848
+ }
849
+ if (parentPaired && (m.badge === true ? badgeGone : iconGone)) {
850
+ // Say what was seen. "Flattened into plain text" is true only when the app shows the text;
851
+ // said of a badge the tool had merely failed to find, it sent a reader after a wrong cause.
852
+ const shown = m.text && norm(app.index[parentKey].full).includes(norm(m.text));
373
853
  findings.push({
374
854
  screen, width, class: 'missing', key,
375
- detail: m.badge
376
- ? `the mockup draws a badge/pill here ("${m.text}") and the app renders no such element — the value is flattened into plain text`
377
- : `the mockup carries an icon here and the app renders no such element`,
855
+ detail: m.badge !== true
856
+ ? `the mockup carries an icon here and the app renders no such element`
857
+ : shown
858
+ ? `the mockup draws a badge/pill here ("${m.text}"); the app shows that text with no badge/pill around it — the value is flattened into plain text`
859
+ : `the mockup draws a badge/pill here ("${m.text}"); it could not be located in the app — no element under this parent carries that text, and the app draws ${app.index[parentKey].badges ?? 0} badge(s) there where the mockup draws ${mock.index[parentKey].badges ?? 0}`,
378
860
  mockup_text: m.text, app_text: null,
379
861
  });
380
862
  }
@@ -382,14 +864,33 @@ function diff(mock, app, screen, width) {
382
864
  }
383
865
  compared++;
384
866
  for (const [name, fn] of Object.entries(CLAUSES)) {
867
+ if (skip && skip.has(name)) continue; // a clause the mockup says nothing about in this theme (TF-024)
385
868
  const r = fn(m, a);
386
869
  if (r === null) continue;
387
870
  clauseCoverage[name]++;
388
871
  if (CONTENT_CLAUSES.has(name)) contentGraded++;
389
- if (r) findings.push({ screen, width, class: name, key, detail: r, mockup_text: m.text, app_text: a.text });
872
+ if (r) {
873
+ if (name === 'icon' && iconMoved(key)) continue; // the same icon, another child (TF-047)
874
+ const f = { screen, width, class: name, key, ...(appKey !== key ? { app_key: appKey } : {}), detail: r, mockup_text: m.text, app_text: a.text };
875
+ findings.push(f);
876
+ if (name === 'icon') iconSeen.push({ f, side: m.icon ? m : a });
877
+ }
390
878
  }
391
879
  }
392
- return { findings, compared, contentGraded, clauseCoverage };
880
+
881
+ // --- TF-046: one icon, one finding. "Is there an icon inside?" is true of the element that carries
882
+ // the icon and of every element above it, so each container repeated the finding. A finding is
883
+ // dropped when every icon it holds is already reported on a deeper element, on the side that has
884
+ // the icons; a container holding an extra icon of its own is still reported.
885
+ const repeat = new Set();
886
+ for (const x of iconSeen) {
887
+ const ids = x.side.icon_ids || [];
888
+ const below = new Set(iconSeen
889
+ .filter((y) => y !== x && y.f.detail === x.f.detail && (y.side.depth ?? 0) > (x.side.depth ?? 0))
890
+ .flatMap((y) => y.side.icon_ids || []));
891
+ if (ids.length && ids.every((i) => below.has(i))) repeat.add(x.f);
892
+ }
893
+ return { findings: findings.filter((f) => !repeat.has(f)), compared, contentGraded, clauseCoverage, relocated, notMeasured };
393
894
  }
394
895
 
395
896
  // ---------------------------------------------------------------------- main
@@ -397,8 +898,30 @@ const results = [];
397
898
  let hardFail = false;
398
899
 
399
900
  const browser = await chromium.launch();
400
- const ctx = await browser.newContext({ ignoreHTTPSErrors: true });
401
- if (COOKIE) {
901
+ const ctxOpts = { ignoreHTTPSErrors: true };
902
+ if (STORAGE && existsSync(STORAGE)) ctxOpts.storageState = STORAGE;
903
+ const ctx = await browser.newContext(ctxOpts);
904
+ let cdpBrowser = null, cdpPage = null;
905
+ if (CDP) {
906
+ try {
907
+ cdpBrowser = await chromium.connectOverCDP(CDP, { timeout: 15000 });
908
+ const c = cdpBrowser.contexts()[0];
909
+ cdpPage = c && c.pages()[0];
910
+ } catch (e) { console.error(`tf-mockup-parity: UNREACHABLE cdp ${CDP}: ${e.message.split('\n')[0]}`); }
911
+ if (!cdpPage) {
912
+ console.error(`tf-mockup-parity: UNREACHABLE cdp ${CDP}: no page in the attached app`);
913
+ await browser.close(); process.exit(2);
914
+ }
915
+ }
916
+ // the attached app's router, not a document load: a desktop head has no server to answer a goto
917
+ async function reachCdp(page, route) {
918
+ try {
919
+ await page.evaluate((r) => { history.pushState({}, '', r); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); }, route);
920
+ await page.waitForTimeout(LOGIN_OPTS.settle);
921
+ return { status: 200, url: page.url() };
922
+ } catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
923
+ }
924
+ if (COOKIE && !CDP) {
402
925
  const u = new URL(BASE);
403
926
  for (const pair of COOKIE.split(';')) {
404
927
  const [name, ...v] = pair.trim().split('=');
@@ -411,53 +934,137 @@ if (HEADERS.length) {
411
934
  await ctx.setExtraHTTPHeaders(h);
412
935
  }
413
936
 
937
+ // One app tab per width, signed in once and kept on the app, and one tab for the mockups. A sign-in
938
+ // the page keeps for itself lives in that tab: a fresh tab per screen would be signed out every time
939
+ // (AppManager TF-011). The mockup tab never touches the app, so the app tab never leaves it.
940
+ const tabs = {};
941
+ let loginResult = null;
942
+ for (const width of WIDTHS) {
943
+ const size = { width, height: width < 700 ? 844 : 800 };
944
+ const app = CDP ? cdpPage : await ctx.newPage(); if (!CDP) await app.setViewportSize(size);
945
+ const mock = await ctx.newPage(); await mock.setViewportSize(size);
946
+ if (!CDP && LOGIN_PATH && USER) { const l = await signIn(app, LOGIN_OPTS); if (loginResult === null) loginResult = l; }
947
+ tabs[width] = { app, mock };
948
+ }
949
+ if (loginResult && loginResult.attempted && !loginResult.ok) console.error(`tf-mockup-parity: LOGIN failed at ${BASE}${LOGIN_PATH}: ${loginResult.error || 'still on the sign-in page'}`);
950
+
414
951
  for (const s of SCREENS) {
415
- const mockPath = resolve(MOCKUPS, `${s.name}.html`);
952
+ const mockPath = findMockup(s.name);
416
953
  // TF-008 §3: a screen with no mockup is reported, never silently passed — the
417
954
  // same discipline `⚠ STATIC-ONLY` already uses. Silence is not evidence.
418
955
  if (!existsSync(mockPath)) {
419
956
  results.push({ screen: s.name, route: s.route, verdict: 'NO-MOCKUP', mockup: mockPath,
420
- note: 'no docs/mockups/<screen>.html — this screen was NOT graded against a design. It must not license a Verified on design grounds.' });
957
+ note: `no ${s.name}.html under ${MOCKUPS} (or several in its subfolders)${LIST ? ` and none named for it in ${LIST}` : '; pass --list tests/.artifacts/verify/list.json'} — this screen was NOT graded against a design. It must not license a Verified on design grounds.` });
421
958
  continue;
422
959
  }
423
960
 
424
961
  const perWidth = [];
962
+ const notOffered = new Set();
425
963
  for (const width of WIDTHS) {
426
- const page = await ctx.newPage();
427
- await page.setViewportSize({ width, height: width < 700 ? 844 : 800 });
428
- let mock, app, docFindings = [];
964
+ const { app: page, mock: mockPage } = tabs[width];
965
+ let mock, app, docFindings = [], reached = '', theme = null, own = 'light', ownSurface = null, mockSurface = null, mockTheme = null, wanted = [];
966
+ // Lekhak TF-025: the mockup's site theme stays on the app through the other-theme pass too, and is
967
+ // taken off only after it. It used to come off after the first pass, so the dark pass compared the
968
+ // app in the viewer's site theme (minimal, grey primary) with the mockup in its own (blue primary).
969
+ let saved = null;
970
+ const unSite = async () => { if (saved && saved.changed) await page.evaluate(APPLY_THEME, saved.before).catch(() => {}); saved = null; };
429
971
  try {
430
- await page.goto(pathToFileURL(mockPath).href, { waitUntil: 'load' });
431
- mock = await page.evaluate(PROBE);
432
- const resp = await page.goto(BASE + s.route, { waitUntil: 'networkidle' });
433
- if (resp && resp.status() >= 400) {
434
- perWidth.push({ width, error: `app returned HTTP ${resp.status()}` });
435
- await page.close();
972
+ await mockPage.goto(pathToFileURL(mockPath).href, { waitUntil: 'load' });
973
+ mock = await mockPage.evaluate(PROBE);
974
+ // The document may answer 401 and still draw the screen signed in (AppManager TF-006/TF-011):
975
+ // reach() judges by what the page draws, signs in again from inside the page when it must, and
976
+ // returns 200 only when the screen was really drawn signed in.
977
+ if (CDP) await page.setViewportSize({ width, height: width < 700 ? 844 : 800 }).catch(() => {});
978
+ const nav = CDP ? await reachCdp(page, s.route) : await reach(page, LOGIN_OPTS, s.route);
979
+ if (nav.status === 0 || nav.status >= 400 || nav.signedOut) {
980
+ perWidth.push({ width, error: nav.status === 0 ? `could not open ${s.route}: ${nav.error || 'no response'}`
981
+ : nav.signedOut ? `app returned HTTP ${nav.document_status ?? nav.status} and stayed signed out (sign-in ${LOGIN_PATH && USER ? 'did not hold' : 'not given: --login-path/--user/--password or --cookie'})`
982
+ : `app returned HTTP ${nav.status}` });
436
983
  continue;
437
984
  }
438
- app = await page.evaluate(PROBE);
985
+ reached = nav.reached || '';
986
+ await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
987
+ wanted = [...new Set(Object.values(mock.index).filter((x) => x.badge === true && x.text).map((x) => x.text))];
988
+ // Lekhak TF-010: the app is drawn in the mockup's theme for the comparison. The app kept the
989
+ // viewer's saved dark theme, the mockup was light, and every primary button read "mockup accent,
990
+ // app neutral". The mockup's theme attributes on <html> and <body> are copied onto the app page
991
+ // and the app's own are put back afterwards, so an attached app is left as the viewer had it.
992
+ // Lekhak TF-013: a light/dark class on <html> or <body> is carried the same way, or a class-driven
993
+ // dark mode (class="dark") kept its dark colours under the mockup's light data-theme.
994
+ const mockRead = KEEP_APP_THEME ? null : await mockPage.evaluate(READ_THEME);
995
+ mockTheme = DECLARES_THEME(mockRead) ? mockRead : null;
996
+ // The mockup's own theme: what it declares, or, declaring none, what it paints (TF-024). A mockup
997
+ // that declares none now has the app put in that theme too (its colour scheme as well as its
998
+ // attributes), so a viewer's saved dark theme no longer decides what a light mockup is compared with.
999
+ mockSurface = await surface(mockPage);
1000
+ own = mockTheme ? (Object.values(mockTheme).some((v) => /dark/i.test(String(v || ''))) ? 'dark' : 'light')
1001
+ : looksDark(mockSurface) ? 'dark' : 'light';
1002
+ saved = mockTheme ? await page.evaluate(APPLY_THEME, mockTheme).catch(() => null) : null;
1003
+ if (saved && saved.changed) { theme = { mockup: mockTheme, app_had: saved.before }; await page.waitForTimeout(400); }
1004
+ const putBack = KEEP_APP_THEME ? null : await useTheme(page, own);
1005
+ // the mockup's site theme is put back after the other-theme pass below (TF-025)
1006
+ try { app = await page.evaluate(PROBE, wanted); ownSurface = await surface(page); }
1007
+ finally { if (putBack) await putBack(); }
439
1008
 
440
1009
  // TF-008 §2. Cheap, no false positives in a shell-scrolled app, and it would
441
1010
  // have caught the /routing void on its own: 2607px of document against a
442
1011
  // 900px viewport, ~1700px of it blank, with the app shell repainted at the
443
1012
  // bottom because the page had escaped the shell's scroll container. No gate
444
1013
  // looked at document height, so that passed too.
445
- if (app.doc.scrollHeight > app.doc.clientHeight + 2) {
1014
+ // Only when the shell has a content scroll container: an app whose document is its only
1015
+ // scroller (a long report page) has escaped nothing (AppManager TF-014).
1016
+ if (app.doc.scroller && app.doc.scrollHeight > app.doc.clientHeight + 2) {
446
1017
  docFindings.push({
447
1018
  screen: s.name, width, class: 'document-scroll', key: 'document',
448
- detail: `document.scrollHeight ${app.doc.scrollHeight} exceeds clientHeight ${app.doc.clientHeight} — the page has escaped the app shell's scroll container`,
1019
+ detail: `document.scrollHeight ${app.doc.scrollHeight} exceeds clientHeight ${app.doc.clientHeight} — the page has escaped the app shell's scroll container (${app.doc.scroller})`,
449
1020
  });
450
1021
  }
451
1022
  } catch (e) {
452
1023
  perWidth.push({ width, error: String(e).slice(0, 200) });
453
- await page.close();
1024
+ await unSite();
454
1025
  continue;
455
1026
  }
456
1027
  const d = diff(mock, app, s.name, width);
457
1028
  d.findings.push(...docFindings);
458
- perWidth.push({ width, ...d, mockAnchors: mock.anchors, appAnchors: app.anchors,
459
- appTestIds: app.testids, mockTestIds: mock.testids });
460
- await page.close();
1029
+ if (wantTheme(own)) {
1030
+ perWidth.push({ width, mode: own, ...d, mockAnchors: mock.anchors, appAnchors: app.anchors,
1031
+ appTestIds: app.testids, mockTestIds: mock.testids, ...(reached ? { reached } : {}), ...(theme ? { theme } : {}) });
1032
+ }
1033
+
1034
+ // --- Lekhak TF-024: the other theme. Both pages are drawn in it and put back after, so an
1035
+ // attached app is left in the viewer's theme. Every finding here says which theme it is in.
1036
+ const other = own === 'dark' ? 'light' : 'dark';
1037
+ if (KEEP_APP_THEME || !wantTheme(other)) { await unSite(); continue; }
1038
+ let backApp = null, backMock = null;
1039
+ try {
1040
+ // The app keeps the mockup's site theme, but gets its own light/dark classes back, so that the
1041
+ // switch below flips them as the app's toggle would (a class-driven dark mode, TF-013).
1042
+ if (saved && saved.changed) {
1043
+ const cls = Object.fromEntries(Object.entries(saved.before).filter(([k]) => k.endsWith('|class')));
1044
+ if (Object.keys(cls).length) await page.evaluate(APPLY_THEME, cls).catch(() => {});
1045
+ }
1046
+ backApp = await useTheme(page, other);
1047
+ backMock = await useTheme(mockPage, other, 0);
1048
+ if (!THEMES && !otherSeen && !differs(ownSurface, await surface(page))) { notOffered.add(other); continue; }
1049
+ otherSeen = true;
1050
+ // A mockup that does not change under the other theme does not draw it: forcing it there only
1051
+ // strips the styles it keys on its own theme (a badge losing its fill). The app in the other theme
1052
+ // is then compared with the mockup as drawn, colour left out.
1053
+ const mockDraws = differs(mockSurface, await surface(mockPage));
1054
+ if (!mockDraws) { await backMock(); backMock = null; }
1055
+ const mockO = mockDraws ? await mockPage.evaluate(PROBE) : mock;
1056
+ const appO = await page.evaluate(PROBE, wanted);
1057
+ const d2 = diff(mockO, appO, s.name, width, mockDraws ? null : new Set(['color']));
1058
+ for (const f of d2.findings) f.theme = other;
1059
+ perWidth.push({ width, mode: other, mockup_draws_theme: mockDraws, ...d2, mockAnchors: mockO.anchors, appAnchors: appO.anchors,
1060
+ appTestIds: appO.testids, mockTestIds: mockO.testids, ...(reached ? { reached } : {}) });
1061
+ } catch (e) {
1062
+ perWidth.push({ width, mode: other, error: String(e).slice(0, 200) });
1063
+ } finally {
1064
+ if (backMock) await backMock();
1065
+ if (backApp) await backApp();
1066
+ await unSite();
1067
+ }
461
1068
  }
462
1069
 
463
1070
  const ok = perWidth.filter((w) => !w.error);
@@ -484,9 +1091,13 @@ for (const s of SCREENS) {
484
1091
  // not read as a thorough screen with one problem — `coverage.ungradeable` stays
485
1092
  // true either way, so the report can say both things at once.
486
1093
  const ungradeable = contentGraded === 0;
1094
+ // A width or theme pass that threw was not compared, so it cannot be part of a PASS: a crash in the
1095
+ // dark pass gave PASS on light alone, with nothing saying dark was missing (MISS-TechieFlow-20261009-04).
1096
+ const failedPasses = perWidth.filter((w) => w.error);
487
1097
  let verdict;
488
1098
  if (ok.length === 0) verdict = 'ERROR';
489
1099
  else if (findings.length) verdict = 'FAIL';
1100
+ else if (failedPasses.length) verdict = 'ERROR';
490
1101
  else if (ungradeable) verdict = 'UNGRADEABLE';
491
1102
  else verdict = 'PASS';
492
1103
 
@@ -501,6 +1112,10 @@ for (const s of SCREENS) {
501
1112
  screen: s.name, route: s.route, verdict,
502
1113
  coverage: {
503
1114
  compared, content_graded: contentGraded, ungradeable,
1115
+ // badges found by their text at another depth than the mockup's (TF-045)
1116
+ relocated: ok.reduce((n, w) => n + (w.relocated || 0), 0),
1117
+ // sample boxes the app drew nothing at, so not compared (Chatur TF-002)
1118
+ not_measured: [...new Set(ok.flatMap((w) => w.notMeasured || []))],
504
1119
  app_controls: appAnchors, mockup_anchors: mockAnchors,
505
1120
  ratio: appAnchors ? +(compared / appAnchors).toFixed(2) : null,
506
1121
  thin: appAnchors > 0 && compared / appAnchors < THIN_RATIO,
@@ -516,16 +1131,93 @@ for (const s of SCREENS) {
516
1131
  // Named so the fix is a mechanical edit to the mockup, not a research task.
517
1132
  add_data_testid_to_mockup: unanchored.slice(0, 30),
518
1133
  },
519
- widths: perWidth.map((w) => ({ width: w.width, error: w.error || null,
520
- compared: w.compared || 0, content_graded: w.contentGraded || 0, findings: (w.findings || []).length })),
1134
+ // the themes it was compared in, and one the app showed no sign of having (TF-024)
1135
+ themes: [...new Set(ok.map((w) => w.mode).filter(Boolean))],
1136
+ ...(notOffered.size ? { themes_not_offered: [...notOffered] } : {}),
1137
+ ...(failedPasses.length ? { not_compared: failedPasses.map((w) => `${w.width}${w.mode ? ' ' + w.mode : ''}: ${w.error}`) } : {}),
1138
+ widths: perWidth.map((w) => ({ width: w.width, ...(w.mode ? { mode: w.mode } : {}),
1139
+ ...(w.mockup_draws_theme === false ? { colour_not_compared: 'the mockup draws no ' + w.mode + ' theme' } : {}), error: w.error || null,
1140
+ compared: w.compared || 0, content_graded: w.contentGraded || 0, findings: (w.findings || []).length,
1141
+ not_measured: (w.notMeasured || []).length,
1142
+ ...(w.reached ? { reached: w.reached } : {}), // how a 401 screen was reached signed in (TF-011)
1143
+ ...(w.theme ? { theme: w.theme } : {}) })), // the app was drawn in the mockup's theme (Lekhak TF-010)
521
1144
  });
522
1145
  }
523
1146
 
1147
+ // ---------------------------------------------------------------- menus (Chatur TF-011)
1148
+ // Each menu the mockups draw open is opened in the app and every item clicked, once, on the first screen
1149
+ // that draws it, at the first width. Its findings go on every screen whose mockup draws it; a screen a
1150
+ // menu item links to and no click reached gets an `unreachable` finding.
1151
+ let menuWalk = { walked: WALK_MENUS, menus: [], clicks: [] };
1152
+ if (WALK_MENUS && results.length) {
1153
+ const width = WIDTHS[0];
1154
+ const { app: page, mock: mockPage } = tabs[width];
1155
+ const byScreen = {}, mockText = {}, mockIds = {};
1156
+ for (const r of results) {
1157
+ if (r.verdict === 'NO-MOCKUP' || r.verdict === 'ERROR') continue;
1158
+ try {
1159
+ await mockPage.goto(pathToFileURL(findMockup(r.screen)).href, { waitUntil: 'load' });
1160
+ byScreen[r.screen] = await mockupMenus(mockPage, 'data-testid');
1161
+ mockText[r.screen] = await mockPage.evaluate(() => document.body.innerText || '');
1162
+ // the controls that say a click landed on this screen: anchors outside any other state or open menu
1163
+ mockIds[r.screen] = await mockPage.evaluate(() => [...document.querySelectorAll('[data-testid]')]
1164
+ .filter((e) => !e.closest('[data-tf-state],[data-state-testid],[data-tf-sample]')).map((e) => e.getAttribute('data-testid')));
1165
+ } catch (e) { byScreen[r.screen] = []; }
1166
+ }
1167
+ const key = (m) => `${m.trigger}|${m.popup}`;
1168
+ const done = new Map();
1169
+ const anyMock = Object.values(byScreen).some((l) => l.length);
1170
+ const allClicks = [];
1171
+ for (const r of results) {
1172
+ if (!(r.screen in byScreen)) continue;
1173
+ const todo = byScreen[r.screen].filter((m) => !done.has(key(m)));
1174
+ if (anyMock && !todo.length) continue;
1175
+ if (!anyMock && done.size) break;
1176
+ const back = async () => { if (CDP) await reachCdp(page, r.route); else await reach(page, LOGIN_OPTS, r.route); };
1177
+ try {
1178
+ if (CDP) await page.setViewportSize({ width, height: width < 700 ? 844 : 800 }).catch(() => {});
1179
+ await back();
1180
+ const pages = async () => CDP ? cdpBrowser.contexts().flatMap((c) => c.pages()) : ctx.pages().filter((p) => p !== mockPage && !Object.values(tabs).some((t) => t.mock === p));
1181
+ const w = await walkMenus(page, todo, { attr: 'data-testid', settle: LOGIN_OPTS.settle, back, pages, keepWindows: !!CDP,
1182
+ mockText: mockText[r.screen], placeholders: PLACEHOLDER_EXTRA, noClick: NO_CLICK_EXTRA });
1183
+ menuWalk.menus.push(...w.menus.map((m) => ({ ...m, screen: r.screen })));
1184
+ menuWalk.clicks.push(...w.clicks.map(({ ids_before, ...c }) => ({ ...c, landed: c.landed ? { url: c.landed.url, window: c.landed.window } : null, screen: r.screen })));
1185
+ allClicks.push(...w.clicks);
1186
+ if (!w.from_mockup) { done.set('app|', w.findings); r._menu = w.findings; }
1187
+ for (const m of todo) done.set(key(m), w.findings.filter((f) => f.popup === m.popup));
1188
+ } catch (e) { menuWalk.error = String(e.message || e).split('\n')[0]; }
1189
+ }
1190
+ // what each screen owes: its menus' findings, placeholder text on the page, and a way in that works
1191
+ const ways = reachability(results.filter((r) => r.screen in byScreen).map((r) => ({ name: r.screen, route: r.route, mockup: findMockup(r.screen), anchors: mockIds[r.screen] || [] })), allClicks);
1192
+ menuWalk.reach = ways;
1193
+ for (const r of results) {
1194
+ if (!(r.screen in byScreen)) continue;
1195
+ const add = (byScreen[r.screen] || []).flatMap((m) => done.get(key(m)) || []).concat(r._menu || [])
1196
+ .map((f) => ({ screen: r.screen, width, class: f.class, key: f.popup ? `menu ${f.popup}` : 'menu', detail: f.detail }));
1197
+ try {
1198
+ if (CDP) await reachCdp(page, r.route); else await reach(page, LOGIN_OPTS, r.route);
1199
+ for (const p of await placeholdersOnPage(page, mockText[r.screen], PLACEHOLDER_EXTRA)) add.push({ screen: r.screen, width, class: 'placeholder', key: 'document', detail: `the screen shows placeholder text "${p}"` });
1200
+ } catch (e) { /* the screen was compared above; a second opening that fails adds nothing */ }
1201
+ const rv = ways[r.screen];
1202
+ if (rv && !rv.reached) add.unshift({ screen: r.screen, width, class: 'unreachable', key: 'document', detail: `no menu click reaches ${r.route}: ${rv.tried.join('; ')}` });
1203
+ if (rv && rv.reached) r.reached_by = rv.by;
1204
+ delete r._menu;
1205
+ if (!add.length) continue;
1206
+ r.findings = [...add, ...r.findings].slice(0, MAX_FINDINGS_PER_SCREEN);
1207
+ r.findings_n = r.findings.length;
1208
+ if (r.verdict === 'PASS' || r.verdict === 'UNGRADEABLE') r.verdict = 'FAIL';
1209
+ hardFail = true;
1210
+ }
1211
+ }
1212
+ for (const r of results) delete r._menu;
1213
+
524
1214
  await browser.close();
1215
+ if (cdpBrowser) await cdpBrowser.close().catch(() => {}); // disconnects; the app keeps running
525
1216
 
526
1217
  const summary = {
527
1218
  status: 'measured',
528
- base: BASE,
1219
+ menus: menuWalk,
1220
+ base: BASE || CDP, mode: CDP ? 'cdp' : 'base',
529
1221
  widths: WIDTHS,
530
1222
  screens_n: results.length,
531
1223
  pass: results.filter((r) => r.verdict === 'PASS').length,