@techierathore/techieflow 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.claude/commands/TechieFlow/agents/analyst.md +5 -8
  2. package/.claude/commands/TechieFlow/agents/architect.md +1 -10
  3. package/.claude/commands/TechieFlow/agents/flow-master.md +1 -0
  4. package/.claude/commands/TechieFlow/agents/verifier.md +1 -0
  5. package/.claude/commands/TechieFlow/tasks/_owner-language.md +27 -0
  6. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
  7. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
  8. package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
  9. package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
  10. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
  11. package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
  12. package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
  13. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
  14. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +3 -3
  15. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
  16. package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
  17. package/.claude/commands/TechieFlow/tasks/generate-html.md +7 -3
  18. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +20 -18
  19. package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
  20. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  21. package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
  22. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
  23. package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
  24. package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
  25. package/.opencode/plugin/techieflow.js +181 -9
  26. package/.tfcore/agents/analyst.md +5 -8
  27. package/.tfcore/agents/architect.md +1 -10
  28. package/.tfcore/agents/flow-master.md +1 -0
  29. package/.tfcore/agents/verifier.md +1 -0
  30. package/.tfcore/hooks/block-git.sh +4 -2
  31. package/.tfcore/hooks/guard-build.sh +5 -1
  32. package/.tfcore/hooks/guard-db.sh +6 -4
  33. package/.tfcore/hooks/guard-metrics.sh +13 -1
  34. package/.tfcore/hooks/guard-status-html.sh +86 -2
  35. package/.tfcore/hooks/guard-verify-deps.sh +124 -0
  36. package/.tfcore/hooks/guard-verify.sh +8 -3
  37. package/.tfcore/hooks/sweep-artifacts.sh +46 -0
  38. package/.tfcore/routing.yaml +61 -0
  39. package/.tfcore/standards/owner-words.txt +23 -0
  40. package/.tfcore/tasks/_owner-language.md +27 -0
  41. package/.tfcore/tasks/_smoke-test-policy.md +1 -1
  42. package/.tfcore/tasks/_status-update-gate.md +4 -3
  43. package/.tfcore/tasks/amend-docs.md +6 -5
  44. package/.tfcore/tasks/build-phase.md +1 -1
  45. package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
  46. package/.tfcore/tasks/create-doc.md +4 -0
  47. package/.tfcore/tasks/create-project-brief.md +22 -0
  48. package/.tfcore/tasks/day1-brownfield.md +3 -3
  49. package/.tfcore/tasks/day1-greenfield.md +3 -3
  50. package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
  51. package/.tfcore/tasks/fix-issues.md +2 -2
  52. package/.tfcore/tasks/generate-html.md +7 -3
  53. package/.tfcore/tasks/handoff-phase.md +20 -18
  54. package/.tfcore/tasks/metrics-report.md +2 -0
  55. package/.tfcore/tasks/mockups.md +1 -1
  56. package/.tfcore/tasks/productguide.md +2 -2
  57. package/.tfcore/tasks/render-workflow-docs.md +1 -1
  58. package/.tfcore/tasks/triage-issues.md +2 -2
  59. package/.tfcore/tasks/verify-phase.md +1 -1
  60. package/.tfcore/telemetry/SCHEMA.md +61 -1
  61. package/.tfcore/telemetry/install-metrics.sh +3 -2
  62. package/.tfcore/telemetry/tf-metrics.sh +435 -21
  63. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
  64. package/.tfcore/templates/v4custom/app-brd-tmpl.md +5 -3
  65. package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
  66. package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
  67. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
  68. package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
  69. package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
  70. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
  71. package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
  72. package/.tfcore/templates/v4custom/html-render-shell.md +4 -2
  73. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  74. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  75. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  76. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  77. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  78. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  79. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  80. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  81. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  82. package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
  83. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  84. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  85. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  86. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  87. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  88. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
  89. package/.tfcore/utils/tf-assets-browser.mjs +96 -0
  90. package/.tfcore/utils/tf-assets.sh +97 -12
  91. package/.tfcore/utils/tf-brd-status.py +14 -5
  92. package/.tfcore/utils/tf-build-list.py +99 -11
  93. package/.tfcore/utils/tf-build-list.sh +5 -3
  94. package/.tfcore/utils/tf-build.sh +88 -15
  95. package/.tfcore/utils/tf-ci-repro.py +685 -0
  96. package/.tfcore/utils/tf-ci-repro.sh +24 -0
  97. package/.tfcore/utils/tf-devguide-list.py +47 -22
  98. package/.tfcore/utils/tf-doc-check.py +242 -10
  99. package/.tfcore/utils/tf-doc-tests.sh +79 -0
  100. package/.tfcore/utils/tf-emit.sh +413 -19
  101. package/.tfcore/utils/tf-feedback.sh +13 -0
  102. package/.tfcore/utils/tf-fix-close.sh +113 -31
  103. package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
  104. package/.tfcore/utils/tf-goal.sh +105 -18
  105. package/.tfcore/utils/tf-harness.sh +9 -4
  106. package/.tfcore/utils/tf-lock.sh +29 -0
  107. package/.tfcore/utils/tf-log-miss.py +34 -8
  108. package/.tfcore/utils/tf-login.mjs +167 -0
  109. package/.tfcore/utils/tf-misses-md.py +24 -21
  110. package/.tfcore/utils/tf-misses-md.sh +5 -4
  111. package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
  112. package/.tfcore/utils/tf-mockup-parity.sh +14 -2
  113. package/.tfcore/utils/tf-model-pick.sh +475 -0
  114. package/.tfcore/utils/tf-owner-text.py +233 -0
  115. package/.tfcore/utils/tf-owner-text.sh +14 -0
  116. package/.tfcore/utils/tf-phase.sh +33 -1
  117. package/.tfcore/utils/tf-portable.sh +230 -0
  118. package/.tfcore/utils/tf-render-html.py +80 -2
  119. package/.tfcore/utils/tf-routing-bind.sh +87 -1
  120. package/.tfcore/utils/tf-routing.sh +7 -0
  121. package/.tfcore/utils/tf-selfcheck.sh +356 -0
  122. package/.tfcore/utils/tf-split-brd.py +17 -2
  123. package/.tfcore/utils/tf-status-evidence.sh +11 -9
  124. package/.tfcore/utils/tf-status-facts.py +133 -44
  125. package/.tfcore/utils/tf-triage.py +62 -16
  126. package/.tfcore/utils/tf-triage.sh +2 -0
  127. package/.tfcore/utils/tf-verify-boot.sh +263 -43
  128. package/.tfcore/utils/tf-verify-emit.sh +20 -0
  129. package/.tfcore/utils/tf-verify-env.sh +33 -4
  130. package/.tfcore/utils/tf-verify-list.py +75 -8
  131. package/.tfcore/utils/tf-verify-screens.mjs +254 -43
  132. package/.tfcore/utils/tf-verify-screens.sh +18 -4
  133. package/.tfcore/utils/tf-verify-tests.sh +272 -28
  134. package/.tfcore/utils/tf-verify-verdict.py +59 -11
  135. package/.tfcore/utils/tf_defect.py +27 -0
  136. package/.tfcore/utils/tf_feedback.py +209 -0
  137. package/.tfcore/utils/tf_roadmap.py +35 -0
  138. package/README.md +19 -5
  139. package/docs/TechieFlow-Installation.md +5 -3
  140. package/opencode.jsonc +24 -0
  141. package/package.json +1 -1
  142. package/scripts/install.mjs +4 -0
  143. package/.tfcore/templates/architecture-tmpl.yaml +0 -650
  144. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
  145. package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
  146. package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
  147. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
  148. package/.tfcore/templates/market-research-tmpl.yaml +0 -252
  149. package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
  150. package/.tfcore/utils/techieflow-doc-template.md +0 -325
@@ -43,6 +43,7 @@ import { chromium } from 'playwright';
43
43
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
44
44
  import { dirname, resolve } from 'node:path';
45
45
  import { pathToFileURL } from 'node:url';
46
+ import { signIn, reach } from './tf-login.mjs';
46
47
 
47
48
  // ---------------------------------------------------------------- arguments
48
49
  const argv = process.argv.slice(2);
@@ -53,10 +54,19 @@ const arg = (name, dflt = null) => {
53
54
  const flag = (name) => argv.includes(name);
54
55
 
55
56
  const BASE = (arg('--base') || '').replace(/\/$/, '');
57
+ // An embedded-browser desktop head, attached over its DevTools port as tf-verify-screens does; its
58
+ // screens are opened the way its router does, by pushState (Lekhak TF-006).
59
+ const CDP = arg('--cdp') || '';
56
60
  const MOCKUPS = arg('--mockups', 'docs/mockups');
57
61
  const WIDTHS = (arg('--widths', '1280,390') || '').split(',').map((w) => parseInt(w.trim(), 10)).filter(Boolean);
58
62
  const JSON_OUT = arg('--json-out');
59
63
  const COOKIE = arg('--cookie');
64
+ // A sign-in the page keeps for itself sets no cookie, so --cookie could not reach AppManager's screens
65
+ // at all: "app returned HTTP 401" at both widths (AppManager TF-011). The same --login-path, --user,
66
+ // --password and --storage-state tf-verify-screens takes, through the shared recipe in tf-login.mjs.
67
+ const LOGIN_PATH = arg('--login-path'); const USER = arg('--user'); const PASS = arg('--password');
68
+ const STORAGE = arg('--storage-state');
69
+ const LOGIN_OPTS = { base: BASE, loginPath: LOGIN_PATH, user: USER, password: PASS, settle: 1500, renderWait: 5000 };
60
70
  const HEADERS = [];
61
71
  for (let i = 0; i < argv.length; i++) if (argv[i] === '--header' && argv[i + 1]) HEADERS.push(argv[i + 1]);
62
72
  const SCREENS = [];
@@ -67,10 +77,56 @@ for (let i = 0; i < argv.length; i++) {
67
77
  }
68
78
  }
69
79
  const THIN_RATIO = parseFloat(arg('--thin-ratio', '0.25'));
80
+ // --keep-app-theme compares the app in the theme it is showing, not the mockup's (Lekhak TF-010)
81
+ const KEEP_APP_THEME = flag('--keep-app-theme');
82
+ // The theme a page declares: data-* attributes on <html> and <body> whose name says theme, mode or
83
+ // scheme (data-theme, data-bs-theme, data-site-theme, data-color-mode …), and the class tokens there
84
+ // that name a light or dark mode (dark, light, theme-dark, dark-mode …), which a class-driven dark mode
85
+ // such as Tailwind's `dark` uses (Lekhak TF-013). A utility class such as bg-light does not count.
86
+ // Nothing about any one stack. The class tokens are kept under `<where>|class`, '' when there are none.
87
+ const READ_THEME = () => {
88
+ const MODE = /^(?:(?:theme|mode|scheme|color)[-_])?(?:dark|light)(?:[-_](?:theme|mode|scheme))?$/i;
89
+ const out = {};
90
+ for (const [where, el] of [['html', document.documentElement], ['body', document.body]]) {
91
+ if (!el) continue;
92
+ for (const a of el.attributes) if (/^data-.*(theme|mode|scheme)/i.test(a.name)) out[`${where}|${a.name}`] = a.value;
93
+ out[`${where}|class`] = [...el.classList].filter((c) => MODE.test(c)).join(' ');
94
+ }
95
+ return out;
96
+ };
97
+ // A page declares a theme when it has such an attribute or mode class. A mockup that declares none
98
+ // leaves the app as it is.
99
+ const DECLARES_THEME = (t) => !!t && Object.entries(t).some(([k, v]) => !k.endsWith('|class') || v);
100
+ // Sets those attributes and mode classes on this page; returns what it had, so the same call puts it
101
+ // back. An attribute the page lacked is removed again on the way back (value null). For `|class` the
102
+ // page's own mode tokens are replaced by the given ones; every other class is left alone.
103
+ const APPLY_THEME = (t) => {
104
+ const MODE = /^(?:(?:theme|mode|scheme|color)[-_])?(?:dark|light)(?:[-_](?:theme|mode|scheme))?$/i;
105
+ const before = {}; let changed = false;
106
+ for (const [k, v] of Object.entries(t || {})) {
107
+ const [where, name] = k.split('|');
108
+ const el = where === 'body' ? document.body : document.documentElement;
109
+ if (!el) continue;
110
+ if (name === 'class') {
111
+ const had = [...el.classList].filter((c) => MODE.test(c));
112
+ const want = String(v || '').split(/\s+/).filter(Boolean);
113
+ before[k] = had.join(' ');
114
+ if (had.slice().sort().join(' ') !== want.slice().sort().join(' ')) changed = true;
115
+ for (const c of had) el.classList.remove(c);
116
+ for (const c of want) el.classList.add(c);
117
+ continue;
118
+ }
119
+ const had = el.getAttribute(name);
120
+ before[k] = had;
121
+ if (had !== v) changed = true;
122
+ if (v === null) el.removeAttribute(name); else el.setAttribute(name, v);
123
+ }
124
+ return { before, changed };
125
+ };
70
126
  const MAX_FINDINGS_PER_SCREEN = parseInt(arg('--max-findings', '40'), 10);
71
127
 
72
- if (!BASE || SCREENS.length === 0) {
73
- console.error('usage: tf-mockup-parity.sh --base URL --screen name=/route [--screen ...]');
128
+ if ((!BASE && !CDP) || SCREENS.length === 0) {
129
+ console.error('usage: tf-mockup-parity.sh --base URL|--cdp URL --screen name=/route [--screen ...]');
74
130
  process.exit(3);
75
131
  }
76
132
 
@@ -78,7 +134,9 @@ if (!BASE || SCREENS.length === 0) {
78
134
  // Everything below runs INSIDE the browser, on both the mockup and the app, and
79
135
  // returns one flat index keyed by a stable path key. It is deliberately one
80
136
  // evaluate() call: two passes over the same DOM can disagree after a re-layout.
81
- const PROBE = () => {
137
+ // `wanted` names the texts of the mockup's badges, so the app's side can find each one at whatever
138
+ // depth the app drew it (TF-045); the mockup's own probe is called without it.
139
+ const PROBE = (wanted = []) => {
82
140
  const MAX_DEPTH = 4; // descend this far below an anchor
83
141
  const MAX_PER_ANCHOR = 60; // and no further; a huge subtree is noise, not signal
84
142
 
@@ -100,11 +158,23 @@ const PROBE = () => {
100
158
  return false;
101
159
  };
102
160
 
161
+ // --- TF-027: the browser converts the colour, never a regex. getComputedStyle returns a
162
+ // colour in the syntax it was written in, and a component library themed in oklch() came
163
+ // back as "oklch(0.971 0.013 17.38)": read as r/g/b those three numbers are near-black, so
164
+ // every tinted tile, pill and icon in the app bucketed as neutral against a mockup written
165
+ // in hex — "semantic colour differs — mockup negative, app neutral" on tiles plainly red.
166
+ // Painting one pixel and reading it back gives sRGB for any syntax the browser accepts.
167
+ const px = document.createElement('canvas');
168
+ px.width = px.height = 1;
169
+ const pctx = px.getContext('2d', { willReadFrequently: true });
103
170
  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] };
171
+ if (!v || v === 'transparent' || !pctx) return null;
172
+ pctx.clearRect(0, 0, 1, 1);
173
+ pctx.fillStyle = 'rgba(0,0,0,0)';
174
+ pctx.fillStyle = v; // an unparseable value leaves it transparent
175
+ pctx.fillRect(0, 0, 1, 1);
176
+ const [r, g, b, a] = pctx.getImageData(0, 0, 1, 1).data;
177
+ return a / 255 < 0.05 ? null : { r, g, b };
108
178
  };
109
179
 
110
180
  // Semantic BUCKET, never the literal colour: two designs may legitimately differ
@@ -114,15 +184,26 @@ const PROBE = () => {
114
184
  const { r, g, b } = c;
115
185
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
116
186
  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';
187
+ // By hue, never by one channel. "Red is highest, so green above 120 is a warning" put a deep
188
+ // amber (180, 83, 9) in `negative` beside a light amber in `warning` — one warning tone on two
189
+ // sides of the line, reported as a colour difference on TfLens /misses (TF-045).
190
+ const d = mx - mn;
191
+ const h = ((mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60 + 360) % 360;
192
+ if (h < 15 || h >= 330) return 'negative';
193
+ if (h < 70) return 'warning';
194
+ if (h < 185) return 'positive';
195
+ return 'accent';
121
196
  };
122
197
 
198
+ // A border nobody draws carries no colour. `border: 1px solid transparent` is how a mockup
199
+ // reserves space, and a page reset that sets border-color everywhere leaves the property set on
200
+ // elements with no border at all: reading it gave a borderless icon the reset's grey and called
201
+ // it a colour difference (TF-038).
202
+ const drawnBorder = (cs) => (parseFloat(cs.borderTopWidth) || 0) > 0 && !!RGB(cs.borderTopColor);
203
+
123
204
  const semanticColor = (el) => {
124
205
  const cs = getComputedStyle(el);
125
- return bucket(RGB(cs.backgroundColor)) || bucket(RGB(cs.borderTopColor)) || bucket(RGB(cs.color));
206
+ return bucket(RGB(cs.backgroundColor)) || (drawnBorder(cs) ? bucket(RGB(cs.borderTopColor)) : null) || bucket(RGB(cs.color));
126
207
  };
127
208
 
128
209
  // --- TF-009: the seventh class. A mockup says "provisional / estimated /
@@ -136,12 +217,22 @@ const PROBE = () => {
136
217
  const q = (s) => (s === 'none' || s === 'hidden' ? 'none'
137
218
  : s === 'dashed' || s === 'dotted' ? 'dashed' : 'solid');
138
219
  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 };
220
+ const drawn = drawnBorder(cs); // width AND a colour: transparent draws nothing (TF-038)
221
+ const style = drawn ? sides[0] : 'none';
222
+ return { style, uniform: new Set(sides).size === 1, visible: drawn };
142
223
  };
143
224
 
144
- const hasIcon = (el) => !!el.querySelector('svg, img, i[class*="icon"], span[class*="icon"], [class*="bi-"], [class*="fa-"]');
225
+ const ICON = 'svg, img, i[class*="icon"], span[class*="icon"], [class*="bi-"], [class*="fa-"]';
226
+ const hasIcon = (el) => !!el.querySelector(ICON);
227
+ // how many icons the element carries at any depth: a component library that wraps an icon
228
+ // one element deeper than the mockup moves it to another path key without removing it (TF-027)
229
+ const iconCount = (el) => [...el.querySelectorAll(ICON)].filter((i) => !i.parentElement || !i.parentElement.closest('svg')).length;
230
+ // Every icon on the page by number, so the node side can tell that an anchor and the wrappers
231
+ // above it hold the same icon as the element it sits in: the icon clause asks "any icon inside?",
232
+ // and one Select chevron was reported at four levels of TfLens /misses (TF-046).
233
+ const iconNo = new Map([...document.querySelectorAll(ICON)].map((i, n) => [i, n]));
234
+ const iconIds = (el) => [...el.querySelectorAll(ICON)].map((i) => iconNo.get(i));
235
+ const depthOf = (el) => { let n = 0; for (let p = el.parentElement; p; p = p.parentElement) n++; return n; };
145
236
 
146
237
  // "Chrome" = a badge / pill / chip: a small element with its own fill or ring and
147
238
  // a rounded edge. The mockup drawing one and the app rendering bare text is the
@@ -153,10 +244,30 @@ const PROBE = () => {
153
244
  const cs = getComputedStyle(el);
154
245
  const radius = parseFloat(cs.borderTopLeftRadius) || 0;
155
246
  const filled = !!RGB(cs.backgroundColor);
156
- const ringed = (parseFloat(cs.borderTopWidth) || 0) > 0;
247
+ const ringed = drawnBorder(cs); // a transparent ring is not a ring (TF-038)
157
248
  return { badge: (filled || ringed) && radius >= 6, filled, ringed, radius: Math.round(radius) };
158
249
  };
159
250
 
251
+ // --- TF-047: the treatment one element away. A component library draws the ring and the radius on
252
+ // the box AROUND the control the mockup styles (an InputGroup around a filter input, a nav <li>
253
+ // around the active link), and a mockup does the reverse. Keys pair by position, so the badge and
254
+ // stroke clauses compared the control with the plain box beside the one that carries the treatment:
255
+ // "badge on nav > a[4], app plain" on TfLens's sidebar, on every screen. The element's neighbours
256
+ // one step away are kept here — the parent when it is the same row (same height, its other
257
+ // children carrying no text: an icon, or the box a library wraps one in) and the only child when
258
+ // it is — so a clause can accept the treatment there.
259
+ const nearOf = (el) => {
260
+ const out = [];
261
+ const h = el.getBoundingClientRect().height;
262
+ const sameRow = (x) => Math.abs(x.getBoundingClientRect().height - h) <= 8;
263
+ const p = el.parentElement;
264
+ if (p && p !== document.body && sameRow(p)
265
+ && [...p.children].every((s) => s === el || isHidden(s) || !(s.textContent || '').trim())) out.push(p);
266
+ const kids = [...el.children].filter((c) => !isHidden(c));
267
+ if (kids.length === 1 && sameRow(kids[0])) out.push(kids[0]);
268
+ return out;
269
+ };
270
+
160
271
  // Only text that is genuinely a single inline run can be graded for wrapping or
161
272
  // token fit. A container with block children has no meaningful line count, and
162
273
  // pretending otherwise is how a card-shaped anchor buys nothing (TF-011).
@@ -173,8 +284,11 @@ const PROBE = () => {
173
284
  const cs = getComputedStyle(el);
174
285
  let lh = parseFloat(cs.lineHeight);
175
286
  if (!lh || Number.isNaN(lh)) lh = (parseFloat(cs.fontSize) || 16) * 1.2;
176
- const h = el.getBoundingClientRect().height;
177
- if (!h || !lh) return null;
287
+ // the text's height only: padding and borders are not rows. A badge with 4px padding and
288
+ // `line-height: 1` read 18 / 9.756 = 1.85, rounded to 2 rows (AppManager TF-015).
289
+ const h = el.getBoundingClientRect().height
290
+ - ['paddingTop', 'paddingBottom', 'borderTopWidth', 'borderBottomWidth'].reduce((s, k) => s + (parseFloat(cs[k]) || 0), 0);
291
+ if (!(h > 0) || !lh) return null;
178
292
  return Math.max(1, Math.round(h / lh));
179
293
  };
180
294
 
@@ -206,33 +320,81 @@ const PROBE = () => {
206
320
  // --- TF-012 again, on the measuring side. scrollWidth includes the phantom
207
321
  // extent of an sr-only descendant, so the ancestor is measured from the right
208
322
  // edge of its VISIBLE descendants instead whenever a hidden one is present.
323
+ // A descendant inside a scroller is painted only as far as that scroller's edge, so its own box
324
+ // must be cut by every ancestor that clips before it can count as the card overflowing. Without
325
+ // that, a table scrolling inside its wrapper — the layout the BRD asks for — read as a card cut
326
+ // off at 390px (TF-039). The same rectangle tf-verify-screens measures since TF-021.
327
+ // The cut stops BELOW `stop`: the element being measured must not cut its own content, or a card
328
+ // that really clips would read as holding everything (it did, from TF-039 until TF-042).
329
+ const paintedRight = (c, stop) => {
330
+ const r = c.getBoundingClientRect();
331
+ let x1 = r.left, x2 = r.right;
332
+ for (let a = c.parentElement; a && a !== stop; a = a.parentElement) {
333
+ if (getComputedStyle(a).overflowX !== 'visible') {
334
+ const ar = a.getBoundingClientRect();
335
+ x1 = Math.max(x1, ar.left); x2 = Math.min(x2, ar.right);
336
+ }
337
+ }
338
+ return x2 > x1 ? x2 : null;
339
+ };
340
+
341
+ // --- TF-042: overflow is not clipping. An element whose overflow-x is `visible` draws what spills
342
+ // past its edge in full, so it is cut off only where an ancestor that clips ends first. Reading
343
+ // scrollWidth alone called a sidebar "cut off" because its rail handle straddles its edge by 8px,
344
+ // on every TfLens screen, with nothing clipped anywhere up to <html> (2026-09-12).
345
+ const cutAbove = (el, right) => {
346
+ for (let a = el.parentElement; a; a = a.parentElement) {
347
+ if (getComputedStyle(a).overflowX === 'visible') continue;
348
+ if (right > a.getBoundingClientRect().left + a.clientLeft + a.clientWidth + 2) return true;
349
+ }
350
+ return false;
351
+ };
352
+
209
353
  const clipOf = (el) => {
210
354
  const hiddenKids = [...el.querySelectorAll('*')].filter(isHidden);
211
- let overX;
355
+ const box = el.getBoundingClientRect();
356
+ let overX, right;
212
357
  if (hiddenKids.length === 0) {
213
358
  overX = el.scrollWidth - el.clientWidth;
359
+ right = box.left + el.clientLeft + el.scrollWidth;
214
360
  } else {
215
- const box = el.getBoundingClientRect();
216
- let right = box.left;
361
+ right = box.left;
217
362
  for (const c of el.querySelectorAll('*')) {
218
363
  if (isHidden(c)) continue;
219
364
  const r = c.getBoundingClientRect();
220
- if (r.width > 0) right = Math.max(right, r.right);
365
+ if (r.width > 0) {
366
+ const painted = paintedRight(c, el);
367
+ if (painted !== null) right = Math.max(right, painted);
368
+ }
221
369
  }
222
370
  overX = Math.max(0, Math.round(right - (box.left + el.clientWidth)));
223
371
  }
372
+ let x = overX > 2;
373
+ if (x && getComputedStyle(el).overflowX === 'visible') x = cutAbove(el, right);
224
374
  const overY = el.scrollHeight - el.clientHeight;
225
- return { x: overX > 2, y: overY > 2, sr_excluded: hiddenKids.length };
375
+ return { x, y: overY > 2, sr_excluded: hiddenKids.length };
226
376
  };
227
377
 
228
378
  const sigOf = (el) => {
229
379
  const r = el.getBoundingClientRect();
230
380
  const chrome = chromeOn(el);
381
+ const near = nearOf(el);
231
382
  return {
383
+ // the badge and the visible border one element away, for the clauses to accept (TF-047)
384
+ near: {
385
+ badge: near.some((n) => chromeOn(n)?.badge === true),
386
+ stroke: near.map(strokeOf).find((s) => s.visible) || null,
387
+ },
232
388
  tag: el.tagName.toLowerCase(),
233
389
  text: (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60),
234
390
  badge: chrome ? chrome.badge : null,
391
+ full: (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 400),
235
392
  icon: hasIcon(el),
393
+ icons: iconCount(el),
394
+ icon_ids: iconIds(el), // which icons, so a finding repeated at a container can be told apart (TF-046)
395
+ depth: depthOf(el),
396
+ // badges drawn anywhere inside, so a badge one wrapper deeper still counts under its parent (TF-045)
397
+ badges: [...el.querySelectorAll('*')].filter((c) => !isHidden(c) && chromeOn(c)?.badge).length,
236
398
  color: semanticColor(el),
237
399
  stroke: strokeOf(el),
238
400
  wrap: lineCount(el),
@@ -252,16 +414,32 @@ const PROBE = () => {
252
414
  // table already was. Keys pair only when both sides produce them, so extra depth
253
415
  // costs precision nothing — it only ever adds comparisons that were impossible
254
416
  // before.
417
+ // A box the mockup marks as another state of the screen (data-tf-state="<state>", or
418
+ // data-state-testid="<id>") is not on the first view, so it is neither walked nor counted when its
419
+ // siblings are numbered: a database-down alert first in a card moved every row below it one place,
420
+ // and the Host/Port row was compared with the alert (Lekhak TF-011).
421
+ const stateOnly = (el) => el.hasAttribute('data-tf-state') || el.hasAttribute('data-state-testid');
255
422
  const keyOf = (el, parentKey) => {
256
423
  const tag = el.tagName.toLowerCase();
257
424
  let n = 0;
258
425
  for (const s of el.parentElement ? el.parentElement.children : []) {
259
426
  if (s === el) break;
260
- if (s.tagName === el.tagName) n++;
427
+ if (s.tagName === el.tagName && !stateOnly(s)) n++;
261
428
  }
262
429
  return `${parentKey} > ${tag}[${n}]`;
263
430
  };
264
431
 
432
+ // --- TF-045: the same badge, one wrapper deeper. Keys are positional, so a component library that
433
+ // wraps a card header's contents in one more <div> moves every badge in it to a key the mockup
434
+ // never has: 32 "missing" findings on three TfLens screens, each drawn on the page with the right
435
+ // text. For every text a mockup badge carries, the elements under the same anchor that carry it
436
+ // are kept here at any depth, so the node side can find the badge where the app put it. Digits
437
+ // are folded, because live data is not the mockup's sample: "87 of 91" is "39 of 41". Case is
438
+ // kept: a card titled "Draft" is not its badge "draft".
439
+ const norm = (t) => (t || '').replace(/\d[\d,.]*/g, '#').replace(/\s+/g, ' ').trim();
440
+ const want = new Set((wanted || []).map(norm).filter(Boolean));
441
+ const pool = {};
442
+
265
443
  const index = {};
266
444
  const anchors = [...document.querySelectorAll('[data-testid]')];
267
445
  const allTestIds = anchors.map((a) => a.getAttribute('data-testid'));
@@ -269,7 +447,7 @@ const PROBE = () => {
269
447
  for (const a of anchors) {
270
448
  const id = a.getAttribute('data-testid');
271
449
  if (!id || index[id]) continue;
272
- if (isHidden(a)) continue;
450
+ if (isHidden(a) || a.closest('[data-tf-state],[data-state-testid]')) continue; // TF-011
273
451
  index[id] = sigOf(a);
274
452
  let budget = MAX_PER_ANCHOR;
275
453
  const walk = (el, key, depth) => {
@@ -278,6 +456,7 @@ const PROBE = () => {
278
456
  if (budget <= 0) return;
279
457
  if (c.hasAttribute('data-testid')) continue; // it gets its own top-level entry
280
458
  if (isHidden(c)) continue; // TF-012
459
+ if (stateOnly(c)) continue; // another state of the screen (TF-011)
281
460
  const k = keyOf(c, key);
282
461
  index[k] = sigOf(c);
283
462
  budget--;
@@ -285,11 +464,40 @@ const PROBE = () => {
285
464
  }
286
465
  };
287
466
  walk(a, id, 1);
467
+ if (want.size) {
468
+ const found = [];
469
+ const queue = [[a, id, 0]];
470
+ let seen = 0;
471
+ while (queue.length && seen < 800 && found.length < 30) {
472
+ const [el, key, depth] = queue.shift();
473
+ if (depth >= 12) continue;
474
+ for (const c of el.children) {
475
+ if (c.hasAttribute('data-testid') || isHidden(c) || stateOnly(c)) continue;
476
+ seen++;
477
+ const k = keyOf(c, key);
478
+ if (want.has(norm((c.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60)))) found.push({ key: k, ...sigOf(c) });
479
+ queue.push([c, k, depth + 1]);
480
+ }
481
+ }
482
+ pool[id] = found;
483
+ }
288
484
  }
289
485
 
290
486
  const de = document.documentElement;
487
+ // the shell's content scroll container, when it has one: a box that scrolls inside itself, no
488
+ // taller than the viewport (a table wrapper that grows with its rows is not one), at least 60% of
489
+ // its width and half its height, and across the viewport's centre (a side menu, or a drawer parked
490
+ // off screen, is not one). Without one the document is the app's scroller (AppManager TF-014).
491
+ const scroller = [...document.querySelectorAll('body *')].find((el) => {
492
+ const o = getComputedStyle(el).overflowY;
493
+ if (o !== 'auto' && o !== 'scroll') return false;
494
+ const r = el.getBoundingClientRect(), cx = de.clientWidth / 2;
495
+ return r.height <= de.clientHeight + 2 && r.width >= de.clientWidth * 0.6 && r.height >= de.clientHeight / 2
496
+ && r.left <= cx && r.right >= cx;
497
+ });
291
498
  return {
292
499
  index,
500
+ pool,
293
501
  anchors: anchors.length,
294
502
  testids: allTestIds,
295
503
  doc: {
@@ -297,6 +505,7 @@ const PROBE = () => {
297
505
  clientHeight: de.clientHeight,
298
506
  scrollWidth: de.scrollWidth,
299
507
  clientWidth: de.clientWidth,
508
+ scroller: scroller ? `${scroller.tagName.toLowerCase()}${scroller.id ? '#' + scroller.id : ''}${scroller.className ? '.' + String(scroller.className).trim().split(/\s+/)[0] : ''}` : null,
300
509
  },
301
510
  };
302
511
  };
@@ -305,11 +514,18 @@ const PROBE = () => {
305
514
  // A clause returns a finding, `null` (not applicable — do NOT count it as graded),
306
515
  // or `false` (applicable and agreeing). The three are kept distinct because
307
516
  // conflating "agreed" with "not asked" is precisely how TF-011 happened.
517
+ // The same folding the page-side probe uses: spacing and digits (TF-045).
518
+ const norm = (t) => (t || '').replace(/\d[\d,.]*/g, '#').replace(/\s+/g, ' ').trim();
519
+
308
520
  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),
521
+ badge: (m, a) => {
522
+ if (m.badge === null || a.badge === null) return null;
523
+ if (m.badge === a.badge) return false;
524
+ // TF-047: the side without the treatment carries it one element away — the same control, drawn
525
+ // by a library on the box around it or the box inside it. That is agreement, not drift.
526
+ if ((m.badge ? a : m).near?.badge) return false;
527
+ return `mockup renders this as a ${m.badge ? 'badge/pill' : 'plain element'}, app renders it as a ${a.badge ? 'badge/pill' : 'plain element'}`;
528
+ },
313
529
  icon: (m, a) => (m.icon === a.icon ? false
314
530
  : m.icon && !a.icon ? 'mockup carries an icon here; the app does not'
315
531
  : 'app carries an icon the mockup does not'),
@@ -318,16 +534,26 @@ const CLAUSES = {
318
534
  // TF-009
319
535
  stroke: (m, a) => {
320
536
  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)' : '');
537
+ let ms = m.stroke, as = a.stroke;
538
+ // TF-047: a side that draws no border of its own is read at the border one element away, and
539
+ // only when the sides disagree — two plain elements inside a ringed box still agree as before.
540
+ if (ms.style !== as.style || ms.visible !== as.visible) {
541
+ if (!ms.visible && m.near?.stroke) ms = m.near.stroke;
542
+ if (!as.visible && a.near?.stroke) as = a.near.stroke;
543
+ }
544
+ if (ms.style !== as.style) {
545
+ return `border style differs — mockup ${ms.style}, app ${as.style}`
546
+ + (ms.style === 'dashed' ? ' (a dashed rule is how a mockup says "estimate / provisional"; losing it makes an estimate look measured)' : '');
324
547
  }
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'}`;
548
+ if (ms.visible !== as.visible) {
549
+ return `border presence differs — mockup ${ms.visible ? 'has a visible rule' : 'has none'}, app ${as.visible ? 'has one' : 'has none'}`;
327
550
  }
328
551
  return false;
329
552
  },
330
- wrap: (m, a) => (m.wrap === null || a.wrap === null ? null
553
+ // TF-047: a row count says something only about the same text. Live data that reads differently
554
+ // from the mockup's sample wraps differently for that reason alone, so the clause is not applicable
555
+ // (null, never counted as graded) unless the digit-folded texts match.
556
+ wrap: (m, a) => (m.wrap === null || a.wrap === null || norm(m.full) !== norm(a.full) ? null
331
557
  : a.wrap > m.wrap ? `wraps to ${a.wrap} rows where the mockup keeps it on ${m.wrap}` : false),
332
558
  clip: (m, a) => (!m.clip || !a.clip ? null
333
559
  : (a.clip.x && !m.clip.x) ? 'content is cut off horizontally; the mockup is not clipped'
@@ -348,11 +574,49 @@ function diff(mock, app, screen, width) {
348
574
  const findings = [];
349
575
  const clauseCoverage = Object.fromEntries(Object.keys(CLAUSES).map((k) => [k, 0]));
350
576
  clauseCoverage.missing = 0;
351
- let compared = 0, contentGraded = 0;
577
+ let compared = 0, contentGraded = 0, relocated = 0;
578
+ const iconsShort = {}; // parent key -> icons the app still owes under it (TF-027)
579
+ const badgesShort = {}; // parent key -> badges the app still owes under it (TF-045)
580
+ const usedApp = new Set();
581
+ const iconSeen = []; // icon findings with the side that holds the icons (TF-046)
582
+
583
+ // --- TF-047: the same icon in another child. Pairing is by position, so a card header that a
584
+ // library wraps in one more <div> pairs the mockup's description with the app's toolbar, and the
585
+ // toolbar's icon reads as one "the mockup does not" carry — while the header holds exactly one
586
+ // icon on both sides. The rule the `missing` clause has followed since TF-027, one wrapper further:
587
+ // an icon finding is dropped when the parent or the grandparent is paired and carries the same
588
+ // number of icons on both sides. Bounded to a few icons, so a coincidence across a whole page
589
+ // (one lost, one added) stays reported.
590
+ const iconMoved = (key) => {
591
+ let k = key;
592
+ for (let up = 0; up < 2; up++) {
593
+ if (!k.includes(' > ')) return false;
594
+ k = k.slice(0, k.lastIndexOf(' > '));
595
+ const pm = mock.index[k], pa = app.index[k];
596
+ if (!pm || !pa) return false;
597
+ const n = pm.icons ?? 0;
598
+ if (n > 0 && n <= 4 && n === (pa.icons ?? 0)) return true;
599
+ }
600
+ return false;
601
+ };
352
602
 
353
603
  for (const key of Object.keys(mock.index)) {
354
604
  const m = mock.index[key];
355
- const a = app.index[key];
605
+ let a = app.index[key];
606
+ let appKey = key;
607
+
608
+ // --- TF-045: a badge the app draws at another depth is the same badge. Found by its text under
609
+ // the same anchor, it is compared like any paired element, so a badge whose colour or ring really
610
+ // differs is still reported, and so is one the app turned into plain text.
611
+ if (!a && m.badge === true && m.text) {
612
+ const root = key.split(' > ')[0];
613
+ const cands = ((app.pool || {})[root] || []).filter((c) => !usedApp.has(c.key) && norm(c.text) === norm(m.text));
614
+ // a badge first, then the same kind of element, then one no mockup key already pairs with;
615
+ // among equals the shallowest, which is the order the probe found them in
616
+ const score = (c) => (c.badge === true ? 4 : 0) + (c.tag === m.tag ? 2 : 0) + (c.key in mock.index ? 0 : 1);
617
+ const pick = cands.reduce((best, c) => (!best || score(c) > score(best) ? c : best), null);
618
+ if (pick) { a = pick; appKey = pick.key; usedApp.add(pick.key); relocated++; }
619
+ }
356
620
 
357
621
  // --- the `missing` clause. Key-pairing alone cannot see an element that is
358
622
  // NOT THERE, and "a control the mockup draws as a badge rendered as plain text"
@@ -367,14 +631,37 @@ function diff(mock, app, screen, width) {
367
631
  if (!a) {
368
632
  const parentKey = key.includes(' > ') ? key.slice(0, key.lastIndexOf(' > ')) : null;
369
633
  const parentPaired = parentKey ? !!app.index[parentKey] : false;
370
- if (parentPaired && (m.badge === true || m.icon === true)) {
634
+ // an icon is missing only when the paired parent carries fewer icons than the mockup's
635
+ // does. Pairing is by position, so an icon the app draws one wrapper deeper sits under
636
+ // another key and was reported missing on every sidebar group and tile (TF-027).
637
+ // and no more reports under one parent than it is icons short
638
+ if (m.icon === true && parentPaired && mock.index[parentKey] && !(parentKey in iconsShort)) {
639
+ iconsShort[parentKey] = Math.max(0, (mock.index[parentKey].icons ?? 0) - (app.index[parentKey].icons ?? 0));
640
+ }
641
+ const iconGone = m.icon === true && parentPaired && (iconsShort[parentKey] || 0) > 0;
642
+ if (iconGone && m.badge !== true) iconsShort[parentKey]--;
643
+ // A badge not found by its text (live data reads otherwise) is missing only while the paired
644
+ // parent draws fewer badges than the mockup's does, the rule icons follow since TF-027 (TF-045).
645
+ if (m.badge === true && parentPaired && mock.index[parentKey] && !(parentKey in badgesShort)) {
646
+ badgesShort[parentKey] = Math.max(0, (mock.index[parentKey].badges ?? 0) - (app.index[parentKey].badges ?? 0));
647
+ }
648
+ const badgeGone = m.badge === true && parentPaired && (badgesShort[parentKey] || 0) > 0;
649
+ if (badgeGone) badgesShort[parentKey]--;
650
+ if (parentPaired && (m.badge === true || iconGone)) {
371
651
  clauseCoverage.missing++;
372
652
  contentGraded++;
653
+ }
654
+ if (parentPaired && (m.badge === true ? badgeGone : iconGone)) {
655
+ // Say what was seen. "Flattened into plain text" is true only when the app shows the text;
656
+ // said of a badge the tool had merely failed to find, it sent a reader after a wrong cause.
657
+ const shown = m.text && norm(app.index[parentKey].full).includes(norm(m.text));
373
658
  findings.push({
374
659
  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`,
660
+ detail: m.badge !== true
661
+ ? `the mockup carries an icon here and the app renders no such element`
662
+ : shown
663
+ ? `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`
664
+ : `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
665
  mockup_text: m.text, app_text: null,
379
666
  });
380
667
  }
@@ -386,10 +673,28 @@ function diff(mock, app, screen, width) {
386
673
  if (r === null) continue;
387
674
  clauseCoverage[name]++;
388
675
  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 });
676
+ if (r) {
677
+ if (name === 'icon' && iconMoved(key)) continue; // the same icon, another child (TF-047)
678
+ const f = { screen, width, class: name, key, ...(appKey !== key ? { app_key: appKey } : {}), detail: r, mockup_text: m.text, app_text: a.text };
679
+ findings.push(f);
680
+ if (name === 'icon') iconSeen.push({ f, side: m.icon ? m : a });
681
+ }
390
682
  }
391
683
  }
392
- return { findings, compared, contentGraded, clauseCoverage };
684
+
685
+ // --- TF-046: one icon, one finding. "Is there an icon inside?" is true of the element that carries
686
+ // the icon and of every element above it, so each container repeated the finding. A finding is
687
+ // dropped when every icon it holds is already reported on a deeper element, on the side that has
688
+ // the icons; a container holding an extra icon of its own is still reported.
689
+ const repeat = new Set();
690
+ for (const x of iconSeen) {
691
+ const ids = x.side.icon_ids || [];
692
+ const below = new Set(iconSeen
693
+ .filter((y) => y !== x && y.f.detail === x.f.detail && (y.side.depth ?? 0) > (x.side.depth ?? 0))
694
+ .flatMap((y) => y.side.icon_ids || []));
695
+ if (ids.length && ids.every((i) => below.has(i))) repeat.add(x.f);
696
+ }
697
+ return { findings: findings.filter((f) => !repeat.has(f)), compared, contentGraded, clauseCoverage, relocated };
393
698
  }
394
699
 
395
700
  // ---------------------------------------------------------------------- main
@@ -397,8 +702,30 @@ const results = [];
397
702
  let hardFail = false;
398
703
 
399
704
  const browser = await chromium.launch();
400
- const ctx = await browser.newContext({ ignoreHTTPSErrors: true });
401
- if (COOKIE) {
705
+ const ctxOpts = { ignoreHTTPSErrors: true };
706
+ if (STORAGE && existsSync(STORAGE)) ctxOpts.storageState = STORAGE;
707
+ const ctx = await browser.newContext(ctxOpts);
708
+ let cdpBrowser = null, cdpPage = null;
709
+ if (CDP) {
710
+ try {
711
+ cdpBrowser = await chromium.connectOverCDP(CDP, { timeout: 15000 });
712
+ const c = cdpBrowser.contexts()[0];
713
+ cdpPage = c && c.pages()[0];
714
+ } catch (e) { console.error(`tf-mockup-parity: UNREACHABLE cdp ${CDP}: ${e.message.split('\n')[0]}`); }
715
+ if (!cdpPage) {
716
+ console.error(`tf-mockup-parity: UNREACHABLE cdp ${CDP}: no page in the attached app`);
717
+ await browser.close(); process.exit(2);
718
+ }
719
+ }
720
+ // the attached app's router, not a document load: a desktop head has no server to answer a goto
721
+ async function reachCdp(page, route) {
722
+ try {
723
+ await page.evaluate((r) => { history.pushState({}, '', r); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); }, route);
724
+ await page.waitForTimeout(LOGIN_OPTS.settle);
725
+ return { status: 200, url: page.url() };
726
+ } catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
727
+ }
728
+ if (COOKIE && !CDP) {
402
729
  const u = new URL(BASE);
403
730
  for (const pair of COOKIE.split(';')) {
404
731
  const [name, ...v] = pair.trim().split('=');
@@ -411,6 +738,20 @@ if (HEADERS.length) {
411
738
  await ctx.setExtraHTTPHeaders(h);
412
739
  }
413
740
 
741
+ // One app tab per width, signed in once and kept on the app, and one tab for the mockups. A sign-in
742
+ // the page keeps for itself lives in that tab: a fresh tab per screen would be signed out every time
743
+ // (AppManager TF-011). The mockup tab never touches the app, so the app tab never leaves it.
744
+ const tabs = {};
745
+ let loginResult = null;
746
+ for (const width of WIDTHS) {
747
+ const size = { width, height: width < 700 ? 844 : 800 };
748
+ const app = CDP ? cdpPage : await ctx.newPage(); if (!CDP) await app.setViewportSize(size);
749
+ const mock = await ctx.newPage(); await mock.setViewportSize(size);
750
+ if (!CDP && LOGIN_PATH && USER) { const l = await signIn(app, LOGIN_OPTS); if (loginResult === null) loginResult = l; }
751
+ tabs[width] = { app, mock };
752
+ }
753
+ 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'}`);
754
+
414
755
  for (const s of SCREENS) {
415
756
  const mockPath = resolve(MOCKUPS, `${s.name}.html`);
416
757
  // TF-008 §3: a screen with no mockup is reported, never silently passed — the
@@ -423,41 +764,59 @@ for (const s of SCREENS) {
423
764
 
424
765
  const perWidth = [];
425
766
  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 = [];
767
+ const { app: page, mock: mockPage } = tabs[width];
768
+ let mock, app, docFindings = [], reached = '', theme = null;
429
769
  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();
770
+ await mockPage.goto(pathToFileURL(mockPath).href, { waitUntil: 'load' });
771
+ mock = await mockPage.evaluate(PROBE);
772
+ // The document may answer 401 and still draw the screen signed in (AppManager TF-006/TF-011):
773
+ // reach() judges by what the page draws, signs in again from inside the page when it must, and
774
+ // returns 200 only when the screen was really drawn signed in.
775
+ if (CDP) await page.setViewportSize({ width, height: width < 700 ? 844 : 800 }).catch(() => {});
776
+ const nav = CDP ? await reachCdp(page, s.route) : await reach(page, LOGIN_OPTS, s.route);
777
+ if (nav.status === 0 || nav.status >= 400 || nav.signedOut) {
778
+ perWidth.push({ width, error: nav.status === 0 ? `could not open ${s.route}: ${nav.error || 'no response'}`
779
+ : 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'})`
780
+ : `app returned HTTP ${nav.status}` });
436
781
  continue;
437
782
  }
438
- app = await page.evaluate(PROBE);
783
+ reached = nav.reached || '';
784
+ await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
785
+ const wanted = [...new Set(Object.values(mock.index).filter((x) => x.badge === true && x.text).map((x) => x.text))];
786
+ // Lekhak TF-010: the app is drawn in the mockup's theme for the comparison. The app kept the
787
+ // viewer's saved dark theme, the mockup was light, and every primary button read "mockup accent,
788
+ // app neutral". The mockup's theme attributes on <html> and <body> are copied onto the app page
789
+ // and the app's own are put back afterwards, so an attached app is left as the viewer had it.
790
+ // Lekhak TF-013: a light/dark class on <html> or <body> is carried the same way, or a class-driven
791
+ // dark mode (class="dark") kept its dark colours under the mockup's light data-theme.
792
+ const mockRead = KEEP_APP_THEME ? null : await mockPage.evaluate(READ_THEME);
793
+ const mockTheme = DECLARES_THEME(mockRead) ? mockRead : null;
794
+ const saved = mockTheme ? await page.evaluate(APPLY_THEME, mockTheme).catch(() => null) : null;
795
+ if (saved && saved.changed) { theme = { mockup: mockTheme, app_had: saved.before }; await page.waitForTimeout(400); }
796
+ try { app = await page.evaluate(PROBE, wanted); }
797
+ finally { if (saved && saved.changed) await page.evaluate(APPLY_THEME, saved.before).catch(() => {}); }
439
798
 
440
799
  // TF-008 §2. Cheap, no false positives in a shell-scrolled app, and it would
441
800
  // have caught the /routing void on its own: 2607px of document against a
442
801
  // 900px viewport, ~1700px of it blank, with the app shell repainted at the
443
802
  // bottom because the page had escaped the shell's scroll container. No gate
444
803
  // looked at document height, so that passed too.
445
- if (app.doc.scrollHeight > app.doc.clientHeight + 2) {
804
+ // Only when the shell has a content scroll container: an app whose document is its only
805
+ // scroller (a long report page) has escaped nothing (AppManager TF-014).
806
+ if (app.doc.scroller && app.doc.scrollHeight > app.doc.clientHeight + 2) {
446
807
  docFindings.push({
447
808
  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`,
809
+ 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
810
  });
450
811
  }
451
812
  } catch (e) {
452
813
  perWidth.push({ width, error: String(e).slice(0, 200) });
453
- await page.close();
454
814
  continue;
455
815
  }
456
816
  const d = diff(mock, app, s.name, width);
457
817
  d.findings.push(...docFindings);
458
818
  perWidth.push({ width, ...d, mockAnchors: mock.anchors, appAnchors: app.anchors,
459
- appTestIds: app.testids, mockTestIds: mock.testids });
460
- await page.close();
819
+ appTestIds: app.testids, mockTestIds: mock.testids, ...(reached ? { reached } : {}), ...(theme ? { theme } : {}) });
461
820
  }
462
821
 
463
822
  const ok = perWidth.filter((w) => !w.error);
@@ -501,6 +860,8 @@ for (const s of SCREENS) {
501
860
  screen: s.name, route: s.route, verdict,
502
861
  coverage: {
503
862
  compared, content_graded: contentGraded, ungradeable,
863
+ // badges found by their text at another depth than the mockup's (TF-045)
864
+ relocated: ok.reduce((n, w) => n + (w.relocated || 0), 0),
504
865
  app_controls: appAnchors, mockup_anchors: mockAnchors,
505
866
  ratio: appAnchors ? +(compared / appAnchors).toFixed(2) : null,
506
867
  thin: appAnchors > 0 && compared / appAnchors < THIN_RATIO,
@@ -517,15 +878,18 @@ for (const s of SCREENS) {
517
878
  add_data_testid_to_mockup: unanchored.slice(0, 30),
518
879
  },
519
880
  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 })),
881
+ compared: w.compared || 0, content_graded: w.contentGraded || 0, findings: (w.findings || []).length,
882
+ ...(w.reached ? { reached: w.reached } : {}), // how a 401 screen was reached signed in (TF-011)
883
+ ...(w.theme ? { theme: w.theme } : {}) })), // the app was drawn in the mockup's theme (Lekhak TF-010)
521
884
  });
522
885
  }
523
886
 
524
887
  await browser.close();
888
+ if (cdpBrowser) await cdpBrowser.close().catch(() => {}); // disconnects; the app keeps running
525
889
 
526
890
  const summary = {
527
891
  status: 'measured',
528
- base: BASE,
892
+ base: BASE || CDP, mode: CDP ? 'cdp' : 'base',
529
893
  widths: WIDTHS,
530
894
  screens_n: results.length,
531
895
  pass: results.filter((r) => r.verdict === 'PASS').length,