@techierathore/techieflow 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
  2. package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
  3. package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
  4. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +7 -5
  5. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
  6. package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
  7. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
  8. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +4 -4
  9. package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
  10. package/.claude/commands/TechieFlow/tasks/fix-issues.md +1 -0
  11. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  12. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
  13. package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
  14. package/.opencode/plugin/techieflow.js +15 -7
  15. package/.tfcore/agents/flow-master.md +2 -0
  16. package/.tfcore/agents/verifier.md +2 -2
  17. package/.tfcore/core-config.yaml +2 -1
  18. package/.tfcore/hooks/block-git.sh +25 -2
  19. package/.tfcore/hooks/guard-db.sh +106 -9
  20. package/.tfcore/hooks/guard-status-html.sh +5 -1
  21. package/.tfcore/hooks/guard-verify-deps.sh +16 -1
  22. package/.tfcore/hooks/yolo-allow.sh +27 -0
  23. package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
  24. package/.tfcore/standards/coding-standards-dotnet.md +31 -3
  25. package/.tfcore/tasks/_develop-mode.md +34 -0
  26. package/.tfcore/tasks/_smoke-test-policy.md +7 -5
  27. package/.tfcore/tasks/_yolo-mode.md +2 -2
  28. package/.tfcore/tasks/amend-docs.md +1 -1
  29. package/.tfcore/tasks/day1-brownfield.md +2 -2
  30. package/.tfcore/tasks/day1-greenfield.md +4 -4
  31. package/.tfcore/tasks/develop-end-to-end.md +30 -0
  32. package/.tfcore/tasks/fix-issues.md +1 -0
  33. package/.tfcore/tasks/mockups.md +1 -1
  34. package/.tfcore/tasks/triage-and-fix.md +2 -2
  35. package/.tfcore/tasks/verify-phase.md +12 -11
  36. package/.tfcore/telemetry/SCHEMA.md +6 -2
  37. package/.tfcore/telemetry/tf-metrics.sh +19 -0
  38. package/.tfcore/templates/stack-defaults/dotnet.md +5 -1
  39. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
  40. package/.tfcore/templates/v4custom/app-brd-tmpl.md +2 -1
  41. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
  42. package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
  43. package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +4 -1
  44. package/.tfcore/templates/v4custom/html-render-shell.md +2 -0
  45. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  46. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  47. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  48. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  49. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  50. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  51. package/.tfcore/utils/tf-appium.mjs +179 -0
  52. package/.tfcore/utils/tf-brd-status.py +10 -1
  53. package/.tfcore/utils/tf-build-list.py +16 -1
  54. package/.tfcore/utils/tf-build.sh +75 -0
  55. package/.tfcore/utils/tf-checklist-edit.py +28 -3
  56. package/.tfcore/utils/tf-day1-files.py +55 -28
  57. package/.tfcore/utils/tf-develop.sh +264 -0
  58. package/.tfcore/utils/tf-devguide-list.py +6 -2
  59. package/.tfcore/utils/tf-doc-check.py +124 -21
  60. package/.tfcore/utils/tf-doc-tests.sh +40 -3
  61. package/.tfcore/utils/tf-emit.sh +40 -10
  62. package/.tfcore/utils/tf-fix-close.sh +13 -7
  63. package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
  64. package/.tfcore/utils/tf-menus.mjs +401 -0
  65. package/.tfcore/utils/tf-mockup-parity.mjs +361 -28
  66. package/.tfcore/utils/tf-mockup-parity.sh +14 -3
  67. package/.tfcore/utils/tf-owner-text.py +27 -5
  68. package/.tfcore/utils/tf-phase.sh +24 -4
  69. package/.tfcore/utils/tf-render-html.py +19 -3
  70. package/.tfcore/utils/tf-req-ids.py +36 -0
  71. package/.tfcore/utils/tf-stack-check.sh +3 -0
  72. package/.tfcore/utils/tf-status-facts.py +90 -15
  73. package/.tfcore/utils/tf-theme.mjs +189 -0
  74. package/.tfcore/utils/tf-triage.py +30 -4
  75. package/.tfcore/utils/tf-verify-boot.sh +296 -21
  76. package/.tfcore/utils/tf-verify-emit.sh +37 -17
  77. package/.tfcore/utils/tf-verify-list.py +219 -20
  78. package/.tfcore/utils/tf-verify-list.sh +3 -1
  79. package/.tfcore/utils/tf-verify-native.mjs +307 -0
  80. package/.tfcore/utils/tf-verify-screens.mjs +184 -20
  81. package/.tfcore/utils/tf-verify-screens.sh +17 -4
  82. package/.tfcore/utils/tf-verify-tests.sh +107 -17
  83. package/.tfcore/utils/tf-verify-verdict.py +118 -15
  84. package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
  85. package/.tfcore/utils/tf-verify-zoom.sh +29 -0
  86. package/.tfcore/utils/tf-yolo-alias.sh +84 -0
  87. package/.tfcore/utils/tf-yolo-perms.py +135 -0
  88. package/.tfcore/utils/tf-yolo-start.sh +44 -0
  89. package/.tfcore/utils/tf-yolo.sh +31 -3
  90. package/.tfcore/utils/tf_feedback.py +86 -18
  91. package/.tfcore/utils/tf_stack_check.py +93 -0
  92. package/README.md +2 -2
  93. package/opencode.jsonc +6 -2
  94. package/package.json +1 -1
  95. package/scripts/install.mjs +14 -3
  96. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  97. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  98. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  99. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  100. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  101. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  102. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  103. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  104. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  105. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  106. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
@@ -40,10 +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
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';
47
49
 
48
50
  // ---------------------------------------------------------------- arguments
49
51
  const argv = process.argv.slice(2);
@@ -76,9 +78,63 @@ for (let i = 0; i < argv.length; i++) {
76
78
  SCREENS.push({ name: name.trim(), route: rest.join('=').trim() || '/' + name.trim() });
77
79
  }
78
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
+ };
79
126
  const THIN_RATIO = parseFloat(arg('--thin-ratio', '0.25'));
80
127
  // --keep-app-theme compares the app in the theme it is showing, not the mockup's (Lekhak TF-010)
81
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?
82
138
  // The theme a page declares: data-* attributes on <html> and <body> whose name says theme, mode or
83
139
  // scheme (data-theme, data-bs-theme, data-site-theme, data-color-mode …), and the class tokens there
84
140
  // that name a light or dark mode (dark, light, theme-dark, dark-mode …), which a class-driven dark mode
@@ -124,6 +180,13 @@ const APPLY_THEME = (t) => {
124
180
  return { before, changed };
125
181
  };
126
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');
127
190
 
128
191
  if ((!BASE && !CDP) || SCREENS.length === 0) {
129
192
  console.error('usage: tf-mockup-parity.sh --base URL|--cdp URL --screen name=/route [--screen ...]');
@@ -212,7 +275,12 @@ const PROBE = (wanted = []) => {
212
275
  // reads every other border property but not this one keeps missing that whole
213
276
  // vocabulary. Quantised to the three values that carry meaning rather than the
214
277
  // full CSS keyword set, so the clause stays as noise-free as the colour buckets.
278
+ // A checkbox, switch or radio is not compared by its border (Lekhak TF-035): a native
279
+ // <input type="checkbox"> computes border-style none because the browser draws its box, while a
280
+ // library's role="checkbox" draws the same box as a CSS border. Same control, so not applicable.
281
+ const TOGGLE = 'input[type="checkbox"],input[type="radio"],[role="checkbox"],[role="switch"],[role="radio"]';
215
282
  const strokeOf = (el) => {
283
+ if (el.matches(TOGGLE)) return null;
216
284
  const cs = getComputedStyle(el);
217
285
  const q = (s) => (s === 'none' || s === 'hidden' ? 'none'
218
286
  : s === 'dashed' || s === 'dotted' ? 'dashed' : 'solid');
@@ -223,15 +291,32 @@ const PROBE = (wanted = []) => {
223
291
  };
224
292
 
225
293
  const ICON = 'svg, img, i[class*="icon"], span[class*="icon"], [class*="bi-"], [class*="fa-"]';
226
- const hasIcon = (el) => !!el.querySelector(ICON);
294
+ // A box the mockup marks as another state of the screen is left out of the comparison (Lekhak
295
+ // TF-011), and so is everything inside it when a box around it is counted: the download icon in a
296
+ // "newer build" banner was reported on the card holding the banner (Chatur TF-001).
297
+ // data-tf-state="sample-data" is not another state: it is sample data, the data-tf-sample rule
298
+ // (Chatur TF-005). Left out here, a list the app fills with real rows read "app carries an icon the
299
+ // mockup does not" on every row.
300
+ const STATE = '[data-tf-state]:not([data-tf-state="sample-data"]),[data-state-testid]';
301
+ const SAMPLE = '[data-tf-sample],[data-tf-state="sample-data"]';
302
+ // A chart's <svg> is not an icon (TF-054): an svg larger than 48px on either side is left out.
303
+ const glyph = (x) => x.tagName.toLowerCase() !== 'svg' || (() => { const r = x.getBoundingClientRect(); return r.width <= 48 && r.height <= 48; })();
304
+ const own = (el, sel) => [...el.querySelectorAll(sel)].filter((x) => (!x.closest(STATE) || el.closest(STATE)) && (sel !== ICON || glyph(x)));
305
+ const ownText = (el) => {
306
+ if (!el.querySelector(STATE) || el.closest(STATE)) return el.textContent || '';
307
+ const c = el.cloneNode(true);
308
+ for (const x of c.querySelectorAll(STATE)) x.remove();
309
+ return c.textContent || '';
310
+ };
311
+ const hasIcon = (el) => own(el, ICON).length > 0;
227
312
  // how many icons the element carries at any depth: a component library that wraps an icon
228
313
  // 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;
314
+ const iconCount = (el) => own(el, ICON).filter((i) => !i.parentElement || !i.parentElement.closest('svg')).length;
230
315
  // Every icon on the page by number, so the node side can tell that an anchor and the wrappers
231
316
  // above it hold the same icon as the element it sits in: the icon clause asks "any icon inside?",
232
317
  // 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));
318
+ const iconNo = new Map([...document.querySelectorAll(ICON)].filter(glyph).map((i, n) => [i, n]));
319
+ const iconIds = (el) => own(el, ICON).map((i) => iconNo.get(i));
235
320
  const depthOf = (el) => { let n = 0; for (let p = el.parentElement; p; p = p.parentElement) n++; return n; };
236
321
 
237
322
  // "Chrome" = a badge / pill / chip: a small element with its own fill or ring and
@@ -369,10 +454,12 @@ const PROBE = (wanted = []) => {
369
454
  }
370
455
  overX = Math.max(0, Math.round(right - (box.left + el.clientWidth)));
371
456
  }
372
- let x = overX > 2;
373
- if (x && getComputedStyle(el).overflowX === 'visible') x = cutAbove(el, right);
457
+ const cs = getComputedStyle(el);
458
+ const scrolls = (v) => v === 'auto' || v === 'scroll'; // a scroller keeps its own content in reach (TF-053)
459
+ let x = overX > 2 && !scrolls(cs.overflowX);
460
+ if (x && cs.overflowX === 'visible') x = cutAbove(el, right);
374
461
  const overY = el.scrollHeight - el.clientHeight;
375
- return { x, y: overY > 2, sr_excluded: hiddenKids.length };
462
+ return { x, y: overY > 2 && !scrolls(cs.overflowY), sr_excluded: hiddenKids.length };
376
463
  };
377
464
 
378
465
  const sigOf = (el) => {
@@ -383,18 +470,25 @@ const PROBE = (wanted = []) => {
383
470
  // the badge and the visible border one element away, for the clauses to accept (TF-047)
384
471
  near: {
385
472
  badge: near.some((n) => chromeOn(n)?.badge === true),
386
- stroke: near.map(strokeOf).find((s) => s.visible) || null,
473
+ stroke: near.map(strokeOf).find((s) => s && s.visible) || null,
387
474
  },
388
475
  tag: el.tagName.toLowerCase(),
389
- text: (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60),
476
+ text: ownText(el).trim().replace(/\s+/g, ' ').slice(0, 60),
390
477
  badge: chrome ? chrome.badge : null,
391
- full: (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 400),
478
+ full: ownText(el).trim().replace(/\s+/g, ' ').slice(0, 400),
479
+ // a box the mockup marks as sample data, drawn only when the app has data there (Chatur TF-002)
480
+ sample: el.matches(SAMPLE),
392
481
  icon: hasIcon(el),
393
482
  icons: iconCount(el),
394
483
  icon_ids: iconIds(el), // which icons, so a finding repeated at a container can be told apart (TF-046)
484
+ // the anchors inside and the icons each holds, so a row graded on its own can be taken off
485
+ // the box around it (Chatur TF-006)
486
+ nested: own(el, '[data-testid]').filter((x) => !isHidden(x))
487
+ .map((x) => ({ id: x.getAttribute('data-testid'), ids: iconIds(x),
488
+ top: x.parentElement?.closest('[data-testid]') === el })).slice(0, 200),
395
489
  depth: depthOf(el),
396
490
  // 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,
491
+ badges: own(el, '*').filter((c) => !isHidden(c) && chromeOn(c)?.badge).length,
398
492
  color: semanticColor(el),
399
493
  stroke: strokeOf(el),
400
494
  wrap: lineCount(el),
@@ -418,7 +512,7 @@ const PROBE = (wanted = []) => {
418
512
  // data-state-testid="<id>") is not on the first view, so it is neither walked nor counted when its
419
513
  // siblings are numbered: a database-down alert first in a card moved every row below it one place,
420
514
  // 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');
515
+ const stateOnly = (el) => el.matches(STATE);
422
516
  const keyOf = (el, parentKey) => {
423
517
  const tag = el.tagName.toLowerCase();
424
518
  let n = 0;
@@ -447,7 +541,7 @@ const PROBE = (wanted = []) => {
447
541
  for (const a of anchors) {
448
542
  const id = a.getAttribute('data-testid');
449
543
  if (!id || index[id]) continue;
450
- if (isHidden(a) || a.closest('[data-tf-state],[data-state-testid]')) continue; // TF-011
544
+ if (isHidden(a) || a.closest(STATE)) continue; // TF-011
451
545
  index[id] = sigOf(a);
452
546
  let budget = MAX_PER_ANCHOR;
453
547
  const walk = (el, key, depth) => {
@@ -488,9 +582,15 @@ const PROBE = (wanted = []) => {
488
582
  // taller than the viewport (a table wrapper that grows with its rows is not one), at least 60% of
489
583
  // its width and half its height, and across the viewport's centre (a side menu, or a drawer parked
490
584
  // off screen, is not one). Without one the document is the app's scroller (AppManager TF-014).
585
+ // A box that only scrolls sideways is not one either: `overflow-x: auto` alone makes the browser
586
+ // compute overflow-y as auto too, so Bootstrap's .table-responsive looked like a shell at 390px
587
+ // (AppManager TF-028). Skipped: a box whose only child is a table, and a box wider inside than
588
+ // out whose height fits its content.
491
589
  const scroller = [...document.querySelectorAll('body *')].find((el) => {
492
590
  const o = getComputedStyle(el).overflowY;
493
591
  if (o !== 'auto' && o !== 'scroll') return false;
592
+ if (el.children.length === 1 && el.children[0].tagName === 'TABLE') return false;
593
+ if (el.scrollWidth > el.clientWidth + 1 && el.scrollHeight <= el.clientHeight + 1) return false;
494
594
  const r = el.getBoundingClientRect(), cx = de.clientWidth / 2;
495
595
  return r.height <= de.clientHeight + 2 && r.width >= de.clientWidth * 0.6 && r.height >= de.clientHeight / 2
496
596
  && r.left <= cx && r.right >= cx;
@@ -570,7 +670,107 @@ const CLAUSES = {
570
670
  // as coverage is what let a screen the gate never really looked at read as clean.
571
671
  const CONTENT_CLAUSES = new Set(['badge', 'icon', 'wrap', 'token']);
572
672
 
573
- function diff(mock, app, screen, width) {
673
+ function diff(mock, app, screen, width, skip = null) {
674
+ // --- Chatur TF-002: sample data the app is not showing. A mockup draws a list full of sample rows
675
+ // and a fresh app has none, so every icon on a sample row read as missing. A box the mockup marks
676
+ // data-tf-sample is compared when the app draws something at its place; when it draws nothing
677
+ // there, the box and everything in it are "not measured", and its icons and badges are taken off
678
+ // the boxes around it. A seed script (tests/verify/seed/) puts the app in the drawn state instead.
679
+ const notMeasured = [];
680
+ const mockAll = mock.index; // before anything is dropped (Chatur TF-007)
681
+ // the mockup's sample rows that carry their own anchor (commit-row-N), read before any is dropped
682
+ const fold = (id) => id.replace(/\d+/g, '#');
683
+ const sampleRows = new Set(Object.keys(mock.index).filter((k) => !k.includes(' > ') && mock.index[k].sample));
684
+ const sampleShapes = new Set([...sampleRows].map(fold));
685
+ const idx = { ...mock.index };
686
+ for (const key of Object.keys(mock.index)) {
687
+ const m = mock.index[key];
688
+ if (!m.sample || app.index[key] || notMeasured.some((k) => key.startsWith(k + ' > '))) continue;
689
+ notMeasured.push(key);
690
+ for (const k of Object.keys(idx)) if (k === key || k.startsWith(key + ' > ')) delete idx[k];
691
+ const gone = new Set(m.icon_ids || []);
692
+ for (let up = key; up.includes(' > ');) {
693
+ up = up.slice(0, up.lastIndexOf(' > '));
694
+ const a = idx[up];
695
+ if (!a) continue;
696
+ const icons = Math.max(0, (a.icons ?? 0) - (m.icons ?? 0));
697
+ idx[up] = { ...a, icons, icon: a.icon && icons > 0, icon_ids: (a.icon_ids || []).filter((i) => !gone.has(i)),
698
+ badges: Math.max(0, (a.badges ?? 0) - (m.badges ?? 0) - (m.badge === true ? 1 : 0)) };
699
+ }
700
+ }
701
+ mock = { ...mock, index: idx };
702
+ // --- Chatur TF-006: a sample row graded on its own is not the box's icon too. The mockup's table
703
+ // lost its sample rows' icons when their tbody was not measured, while the app's table still counted
704
+ // every row's: "app carries an icon the mockup does not" on a box whose rows were each compared. A
705
+ // row anchored as a mockup sample row, or as more of the same (commit-row-7 beside commit-row-1), is
706
+ // left out of every box around it on both sides; an icon of the box's own is still counted.
707
+ const strip = (index, goneOf) => Object.fromEntries(Object.entries(index).map(([k, s]) => {
708
+ const gone = goneOf(k, s);
709
+ const ids = (s.icon_ids || []).filter((i) => !gone.has(i));
710
+ const cut = (s.icon_ids || []).length - ids.length;
711
+ if (!cut) return [k, s];
712
+ const icons = Math.max(0, (s.icons ?? 0) - cut);
713
+ return [k, { ...s, icons, icon: icons > 0, icon_ids: ids }];
714
+ }));
715
+ const dropRows = (index, isRow) => strip(index, (k, s) => new Set((s.nested || []).filter((n) => isRow(n.id)).flatMap((n) => n.ids)));
716
+ if (sampleRows.size) {
717
+ mock = { ...mock, index: dropRows(mock.index, (id) => sampleRows.has(id)) };
718
+ app = { ...app, index: dropRows(app.index, (id) => sampleRows.has(id) || (!(id in mock.index) && sampleShapes.has(fold(id)))) };
719
+ }
720
+ // --- Chatur TF-007: the app's rows in the place of sample rows not measured. The mockup's list box
721
+ // lost the icons of sample rows the app does not draw (recent-tflens, tools-table > tbody[0]), while
722
+ // the app's box kept the icons of the real rows it draws there instead (recent-chatur, a wrapped
723
+ // tbody): "app carries an icon the mockup does not" on Start, Prerequisites, Providers, Corrections and
724
+ // the file tree. The app's rows in that place — the children and anchors of the box holding the sample
725
+ // rows that the mockup does not have — are left out of every box around them, as the sample rows are
726
+ // on the mockup's side, there taken off every box too, anchors around the list included.
727
+ // A row's icons come off the boxes AROUND it only: an element above it that holds all of them. A
728
+ // button inside the row keeps its own icon, or it reads as one the mockup does not carry.
729
+ const appAll = app.index;
730
+ const rowsGone = (rows, all) => (k, s) => {
731
+ const had = new Set(all[k]?.icon_ids || s.icon_ids || []);
732
+ return new Set(rows.filter((r) => (s.depth ?? 0) < r.depth && r.ids.every((i) => had.has(i))).flatMap((r) => r.ids));
733
+ };
734
+ const inPlace = [];
735
+ // The app's rows in that place are what the mockup does not have under the same box and of the
736
+ // same kind as the sample row (a tbody for a tbody, an <a> for an <a>), the outermost of them; only
737
+ // when there is none of that kind, every child of the box the mockup does not have. A heading or a
738
+ // toolbar of the box's own is not a row.
739
+ const takeRows = (box, tag) => {
740
+ const a = appAll[box];
741
+ if (!a) return;
742
+ const tagOf = (k) => k.slice(k.lastIndexOf(' > ') + 3).replace(/\[\d+\]$/, '');
743
+ const same = Object.keys(appAll).filter((k) => k.startsWith(box + ' > ') && !(k in mockAll) && tagOf(k) === tag);
744
+ const rows = same.filter((k) => !same.some((o) => o !== k && k.startsWith(o + ' > ')))
745
+ .map((k) => ({ ids: appAll[k].icon_ids || [], depth: appAll[k].depth ?? 0 }));
746
+ for (const n of a.nested || []) {
747
+ if (!(n.id in mockAll) && appAll[n.id]?.tag === tag) rows.push({ ids: n.ids, depth: appAll[n.id].depth ?? 0 });
748
+ }
749
+ if (!rows.length) {
750
+ for (const k of Object.keys(appAll)) {
751
+ if (k.startsWith(box + ' > ') && !k.slice(box.length + 3).includes(' > ') && !(k in mockAll)) {
752
+ rows.push({ ids: appAll[k].icon_ids || [], depth: appAll[k].depth ?? 0 });
753
+ }
754
+ }
755
+ // and the box's own anchors the mockup does not have: a scroll area holding the rows
756
+ for (const n of a.nested || []) {
757
+ if (n.top && !(n.id in mockAll) && appAll[n.id]) rows.push({ ids: n.ids, depth: appAll[n.id].depth ?? 0 });
758
+ }
759
+ }
760
+ inPlace.push(...rows);
761
+ };
762
+ const sampleGone = [];
763
+ for (const k of notMeasured) {
764
+ sampleGone.push({ ids: mockAll[k].icon_ids || [], depth: mockAll[k].depth ?? 0 });
765
+ if (k.includes(' > ')) { takeRows(k.slice(0, k.lastIndexOf(' > ')), mockAll[k].tag); continue; }
766
+ // an anchored sample row: the nearest mockup box holding it
767
+ const box = Object.keys(mockAll).filter((b) => (mockAll[b].nested || []).some((n) => n.id === k))
768
+ .sort((x, y) => (mockAll[y].depth ?? 0) - (mockAll[x].depth ?? 0))[0];
769
+ if (box) takeRows(box, mockAll[k].tag);
770
+ }
771
+ const some = (rows) => rows.filter((r) => r.ids.length);
772
+ if (some(sampleGone).length) mock = { ...mock, index: strip(mock.index, rowsGone(some(sampleGone), mockAll)) };
773
+ if (some(inPlace).length) app = { ...app, index: strip(app.index, rowsGone(some(inPlace), appAll)) };
574
774
  const findings = [];
575
775
  const clauseCoverage = Object.fromEntries(Object.keys(CLAUSES).map((k) => [k, 0]));
576
776
  clauseCoverage.missing = 0;
@@ -669,6 +869,7 @@ function diff(mock, app, screen, width) {
669
869
  }
670
870
  compared++;
671
871
  for (const [name, fn] of Object.entries(CLAUSES)) {
872
+ if (skip && skip.has(name)) continue; // a clause the mockup says nothing about in this theme (TF-024)
672
873
  const r = fn(m, a);
673
874
  if (r === null) continue;
674
875
  clauseCoverage[name]++;
@@ -694,7 +895,7 @@ function diff(mock, app, screen, width) {
694
895
  .flatMap((y) => y.side.icon_ids || []));
695
896
  if (ids.length && ids.every((i) => below.has(i))) repeat.add(x.f);
696
897
  }
697
- return { findings: findings.filter((f) => !repeat.has(f)), compared, contentGraded, clauseCoverage, relocated };
898
+ return { findings: findings.filter((f) => !repeat.has(f)), compared, contentGraded, clauseCoverage, relocated, notMeasured };
698
899
  }
699
900
 
700
901
  // ---------------------------------------------------------------------- main
@@ -753,19 +954,25 @@ for (const width of WIDTHS) {
753
954
  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
955
 
755
956
  for (const s of SCREENS) {
756
- const mockPath = resolve(MOCKUPS, `${s.name}.html`);
957
+ const mockPath = findMockup(s.name);
757
958
  // TF-008 §3: a screen with no mockup is reported, never silently passed — the
758
959
  // same discipline `⚠ STATIC-ONLY` already uses. Silence is not evidence.
759
960
  if (!existsSync(mockPath)) {
760
961
  results.push({ screen: s.name, route: s.route, verdict: 'NO-MOCKUP', mockup: mockPath,
761
- note: 'no docs/mockups/<screen>.html — this screen was NOT graded against a design. It must not license a Verified on design grounds.' });
962
+ 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.` });
762
963
  continue;
763
964
  }
764
965
 
765
966
  const perWidth = [];
967
+ const notOffered = new Set();
766
968
  for (const width of WIDTHS) {
767
969
  const { app: page, mock: mockPage } = tabs[width];
768
- let mock, app, docFindings = [], reached = '', theme = null;
970
+ let mock, app, docFindings = [], reached = '', theme = null, own = 'light', ownSurface = null, mockSurface = null, mockTheme = null, wanted = [];
971
+ // Lekhak TF-025: the mockup's site theme stays on the app through the other-theme pass too, and is
972
+ // taken off only after it. It used to come off after the first pass, so the dark pass compared the
973
+ // app in the viewer's site theme (minimal, grey primary) with the mockup in its own (blue primary).
974
+ let saved = null;
975
+ const unSite = async () => { if (saved && saved.changed) await page.evaluate(APPLY_THEME, saved.before).catch(() => {}); saved = null; };
769
976
  try {
770
977
  await mockPage.goto(pathToFileURL(mockPath).href, { waitUntil: 'load' });
771
978
  mock = await mockPage.evaluate(PROBE);
@@ -782,7 +989,7 @@ for (const s of SCREENS) {
782
989
  }
783
990
  reached = nav.reached || '';
784
991
  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))];
992
+ wanted = [...new Set(Object.values(mock.index).filter((x) => x.badge === true && x.text).map((x) => x.text))];
786
993
  // Lekhak TF-010: the app is drawn in the mockup's theme for the comparison. The app kept the
787
994
  // viewer's saved dark theme, the mockup was light, and every primary button read "mockup accent,
788
995
  // app neutral". The mockup's theme attributes on <html> and <body> are copied onto the app page
@@ -790,11 +997,19 @@ for (const s of SCREENS) {
790
997
  // Lekhak TF-013: a light/dark class on <html> or <body> is carried the same way, or a class-driven
791
998
  // dark mode (class="dark") kept its dark colours under the mockup's light data-theme.
792
999
  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;
1000
+ mockTheme = DECLARES_THEME(mockRead) ? mockRead : null;
1001
+ // The mockup's own theme: what it declares, or, declaring none, what it paints (TF-024). A mockup
1002
+ // that declares none now has the app put in that theme too (its colour scheme as well as its
1003
+ // attributes), so a viewer's saved dark theme no longer decides what a light mockup is compared with.
1004
+ mockSurface = await surface(mockPage);
1005
+ own = mockTheme ? (Object.values(mockTheme).some((v) => /dark/i.test(String(v || ''))) ? 'dark' : 'light')
1006
+ : looksDark(mockSurface) ? 'dark' : 'light';
1007
+ saved = mockTheme ? await page.evaluate(APPLY_THEME, mockTheme).catch(() => null) : null;
795
1008
  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(() => {}); }
1009
+ const putBack = KEEP_APP_THEME ? null : await useTheme(page, own);
1010
+ // the mockup's site theme is put back after the other-theme pass below (TF-025)
1011
+ try { app = await page.evaluate(PROBE, wanted); ownSurface = await surface(page); }
1012
+ finally { if (putBack) await putBack(); }
798
1013
 
799
1014
  // TF-008 §2. Cheap, no false positives in a shell-scrolled app, and it would
800
1015
  // have caught the /routing void on its own: 2607px of document against a
@@ -811,12 +1026,50 @@ for (const s of SCREENS) {
811
1026
  }
812
1027
  } catch (e) {
813
1028
  perWidth.push({ width, error: String(e).slice(0, 200) });
1029
+ await unSite();
814
1030
  continue;
815
1031
  }
816
1032
  const d = diff(mock, app, s.name, width);
817
1033
  d.findings.push(...docFindings);
818
- perWidth.push({ width, ...d, mockAnchors: mock.anchors, appAnchors: app.anchors,
819
- appTestIds: app.testids, mockTestIds: mock.testids, ...(reached ? { reached } : {}), ...(theme ? { theme } : {}) });
1034
+ if (wantTheme(own)) {
1035
+ perWidth.push({ width, mode: own, ...d, mockAnchors: mock.anchors, appAnchors: app.anchors,
1036
+ appTestIds: app.testids, mockTestIds: mock.testids, ...(reached ? { reached } : {}), ...(theme ? { theme } : {}) });
1037
+ }
1038
+
1039
+ // --- Lekhak TF-024: the other theme. Both pages are drawn in it and put back after, so an
1040
+ // attached app is left in the viewer's theme. Every finding here says which theme it is in.
1041
+ const other = own === 'dark' ? 'light' : 'dark';
1042
+ if (KEEP_APP_THEME || !wantTheme(other)) { await unSite(); continue; }
1043
+ let backApp = null, backMock = null;
1044
+ try {
1045
+ // The app keeps the mockup's site theme, but gets its own light/dark classes back, so that the
1046
+ // switch below flips them as the app's toggle would (a class-driven dark mode, TF-013).
1047
+ if (saved && saved.changed) {
1048
+ const cls = Object.fromEntries(Object.entries(saved.before).filter(([k]) => k.endsWith('|class')));
1049
+ if (Object.keys(cls).length) await page.evaluate(APPLY_THEME, cls).catch(() => {});
1050
+ }
1051
+ backApp = await useTheme(page, other);
1052
+ backMock = await useTheme(mockPage, other, 0);
1053
+ if (!THEMES && !otherSeen && !differs(ownSurface, await surface(page))) { notOffered.add(other); continue; }
1054
+ otherSeen = true;
1055
+ // A mockup that does not change under the other theme does not draw it: forcing it there only
1056
+ // strips the styles it keys on its own theme (a badge losing its fill). The app in the other theme
1057
+ // is then compared with the mockup as drawn, colour left out.
1058
+ const mockDraws = differs(mockSurface, await surface(mockPage));
1059
+ if (!mockDraws) { await backMock(); backMock = null; }
1060
+ const mockO = mockDraws ? await mockPage.evaluate(PROBE) : mock;
1061
+ const appO = await page.evaluate(PROBE, wanted);
1062
+ const d2 = diff(mockO, appO, s.name, width, mockDraws ? null : new Set(['color']));
1063
+ for (const f of d2.findings) f.theme = other;
1064
+ perWidth.push({ width, mode: other, mockup_draws_theme: mockDraws, ...d2, mockAnchors: mockO.anchors, appAnchors: appO.anchors,
1065
+ appTestIds: appO.testids, mockTestIds: mockO.testids, ...(reached ? { reached } : {}) });
1066
+ } catch (e) {
1067
+ perWidth.push({ width, mode: other, error: String(e).slice(0, 200) });
1068
+ } finally {
1069
+ if (backMock) await backMock();
1070
+ if (backApp) await backApp();
1071
+ await unSite();
1072
+ }
820
1073
  }
821
1074
 
822
1075
  const ok = perWidth.filter((w) => !w.error);
@@ -843,9 +1096,13 @@ for (const s of SCREENS) {
843
1096
  // not read as a thorough screen with one problem — `coverage.ungradeable` stays
844
1097
  // true either way, so the report can say both things at once.
845
1098
  const ungradeable = contentGraded === 0;
1099
+ // A width or theme pass that threw was not compared, so it cannot be part of a PASS: a crash in the
1100
+ // dark pass gave PASS on light alone, with nothing saying dark was missing (MISS-TechieFlow-20261009-04).
1101
+ const failedPasses = perWidth.filter((w) => w.error);
846
1102
  let verdict;
847
1103
  if (ok.length === 0) verdict = 'ERROR';
848
1104
  else if (findings.length) verdict = 'FAIL';
1105
+ else if (failedPasses.length) verdict = 'ERROR';
849
1106
  else if (ungradeable) verdict = 'UNGRADEABLE';
850
1107
  else verdict = 'PASS';
851
1108
 
@@ -862,6 +1119,8 @@ for (const s of SCREENS) {
862
1119
  compared, content_graded: contentGraded, ungradeable,
863
1120
  // badges found by their text at another depth than the mockup's (TF-045)
864
1121
  relocated: ok.reduce((n, w) => n + (w.relocated || 0), 0),
1122
+ // sample boxes the app drew nothing at, so not compared (Chatur TF-002)
1123
+ not_measured: [...new Set(ok.flatMap((w) => w.notMeasured || []))],
865
1124
  app_controls: appAnchors, mockup_anchors: mockAnchors,
866
1125
  ratio: appAnchors ? +(compared / appAnchors).toFixed(2) : null,
867
1126
  thin: appAnchors > 0 && compared / appAnchors < THIN_RATIO,
@@ -877,18 +1136,92 @@ for (const s of SCREENS) {
877
1136
  // Named so the fix is a mechanical edit to the mockup, not a research task.
878
1137
  add_data_testid_to_mockup: unanchored.slice(0, 30),
879
1138
  },
880
- widths: perWidth.map((w) => ({ width: w.width, error: w.error || null,
1139
+ // the themes it was compared in, and one the app showed no sign of having (TF-024)
1140
+ themes: [...new Set(ok.map((w) => w.mode).filter(Boolean))],
1141
+ ...(notOffered.size ? { themes_not_offered: [...notOffered] } : {}),
1142
+ ...(failedPasses.length ? { not_compared: failedPasses.map((w) => `${w.width}${w.mode ? ' ' + w.mode : ''}: ${w.error}`) } : {}),
1143
+ widths: perWidth.map((w) => ({ width: w.width, ...(w.mode ? { mode: w.mode } : {}),
1144
+ ...(w.mockup_draws_theme === false ? { colour_not_compared: 'the mockup draws no ' + w.mode + ' theme' } : {}), error: w.error || null,
881
1145
  compared: w.compared || 0, content_graded: w.contentGraded || 0, findings: (w.findings || []).length,
1146
+ not_measured: (w.notMeasured || []).length,
882
1147
  ...(w.reached ? { reached: w.reached } : {}), // how a 401 screen was reached signed in (TF-011)
883
1148
  ...(w.theme ? { theme: w.theme } : {}) })), // the app was drawn in the mockup's theme (Lekhak TF-010)
884
1149
  });
885
1150
  }
886
1151
 
1152
+ // ---------------------------------------------------------------- menus (Chatur TF-011)
1153
+ // Each menu the mockups draw open is opened in the app and every item clicked, once, on the first screen
1154
+ // that draws it, at the first width. Its findings go on every screen whose mockup draws it; a screen a
1155
+ // menu item links to and no click reached gets an `unreachable` finding.
1156
+ let menuWalk = { walked: WALK_MENUS, menus: [], clicks: [] };
1157
+ if (WALK_MENUS && results.length) {
1158
+ const width = WIDTHS[0];
1159
+ const { app: page, mock: mockPage } = tabs[width];
1160
+ const byScreen = {}, mockText = {}, mockIds = {};
1161
+ for (const r of results) {
1162
+ if (r.verdict === 'NO-MOCKUP' || r.verdict === 'ERROR') continue;
1163
+ try {
1164
+ await mockPage.goto(pathToFileURL(findMockup(r.screen)).href, { waitUntil: 'load' });
1165
+ byScreen[r.screen] = await mockupMenus(mockPage, 'data-testid');
1166
+ mockText[r.screen] = await mockPage.evaluate(() => document.body.innerText || '');
1167
+ // the controls that say a click landed on this screen: anchors outside any other state or open menu
1168
+ mockIds[r.screen] = await mockPage.evaluate(() => [...document.querySelectorAll('[data-testid]')]
1169
+ .filter((e) => !e.closest('[data-tf-state],[data-state-testid],[data-tf-sample]')).map((e) => e.getAttribute('data-testid')));
1170
+ } catch (e) { byScreen[r.screen] = []; }
1171
+ }
1172
+ const key = (m) => `${m.trigger}|${m.popup}`;
1173
+ const done = new Map();
1174
+ const anyMock = Object.values(byScreen).some((l) => l.length);
1175
+ const allClicks = [];
1176
+ for (const r of results) {
1177
+ if (!(r.screen in byScreen)) continue;
1178
+ const todo = byScreen[r.screen].filter((m) => !done.has(key(m)));
1179
+ if (anyMock && !todo.length) continue;
1180
+ if (!anyMock && done.size) break;
1181
+ const back = async () => { if (CDP) await reachCdp(page, r.route); else await reach(page, LOGIN_OPTS, r.route); };
1182
+ try {
1183
+ if (CDP) await page.setViewportSize({ width, height: width < 700 ? 844 : 800 }).catch(() => {});
1184
+ await back();
1185
+ const pages = async () => CDP ? cdpBrowser.contexts().flatMap((c) => c.pages()) : ctx.pages().filter((p) => p !== mockPage && !Object.values(tabs).some((t) => t.mock === p));
1186
+ const w = await walkMenus(page, todo, { attr: 'data-testid', settle: LOGIN_OPTS.settle, back, pages, keepWindows: !!CDP,
1187
+ mockText: mockText[r.screen], placeholders: PLACEHOLDER_EXTRA, noClick: NO_CLICK_EXTRA });
1188
+ menuWalk.menus.push(...w.menus.map((m) => ({ ...m, screen: r.screen })));
1189
+ 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 })));
1190
+ allClicks.push(...w.clicks);
1191
+ if (!w.from_mockup) { done.set('app|', w.findings); r._menu = w.findings; }
1192
+ for (const m of todo) done.set(key(m), w.findings.filter((f) => f.popup === m.popup));
1193
+ } catch (e) { menuWalk.error = String(e.message || e).split('\n')[0]; }
1194
+ }
1195
+ // what each screen owes: its menus' findings, placeholder text on the page, and a way in that works
1196
+ 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);
1197
+ menuWalk.reach = ways;
1198
+ for (const r of results) {
1199
+ if (!(r.screen in byScreen)) continue;
1200
+ const add = (byScreen[r.screen] || []).flatMap((m) => done.get(key(m)) || []).concat(r._menu || [])
1201
+ .map((f) => ({ screen: r.screen, width, class: f.class, key: f.popup ? `menu ${f.popup}` : 'menu', detail: f.detail }));
1202
+ try {
1203
+ if (CDP) await reachCdp(page, r.route); else await reach(page, LOGIN_OPTS, r.route);
1204
+ 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}"` });
1205
+ } catch (e) { /* the screen was compared above; a second opening that fails adds nothing */ }
1206
+ const rv = ways[r.screen];
1207
+ if (rv && !rv.reached) add.unshift({ screen: r.screen, width, class: 'unreachable', key: 'document', detail: `no menu click reaches ${r.route}: ${rv.tried.join('; ')}` });
1208
+ if (rv && rv.reached) r.reached_by = rv.by;
1209
+ delete r._menu;
1210
+ if (!add.length) continue;
1211
+ r.findings = [...add, ...r.findings].slice(0, MAX_FINDINGS_PER_SCREEN);
1212
+ r.findings_n = r.findings.length;
1213
+ if (r.verdict === 'PASS' || r.verdict === 'UNGRADEABLE') r.verdict = 'FAIL';
1214
+ hardFail = true;
1215
+ }
1216
+ }
1217
+ for (const r of results) delete r._menu;
1218
+
887
1219
  await browser.close();
888
1220
  if (cdpBrowser) await cdpBrowser.close().catch(() => {}); // disconnects; the app keeps running
889
1221
 
890
1222
  const summary = {
891
1223
  status: 'measured',
1224
+ menus: menuWalk,
892
1225
  base: BASE || CDP, mode: CDP ? 'cdp' : 'base',
893
1226
  widths: WIDTHS,
894
1227
  screens_n: results.length,
@@ -9,13 +9,24 @@
9
9
  # --login-path /login --user <u> --password <p> (or --storage-state <file>)
10
10
  # and each screen is opened in a tab that signed in through the form, the way
11
11
  # tf-verify-screens.sh does; `widths[].reached` in the JSON says how a 401 screen was reached.
12
+ # --list tests/.artifacts/verify/list.json takes each screen's mockup path from the list
13
+ # tf-verify-list.sh wrote, so a mockup in a subfolder is found (Lekhak TF-022); with no --screen
14
+ # it drives every listed screen. Without a list, a mockup in a subfolder is used when it is the only one.
12
15
  # A desktop head (Lekhak TF-006): --cdp http://<host>:9223 in place of --base attaches to the
13
16
  # running app, as tf-verify-screens.sh --cdp does, and opens each route by pushState.
14
17
  # The app is compared in the mockup's theme: the mockup's data-*theme/mode/scheme attributes on
15
18
  # <html> and <body>, and a light/dark mode class there (class="dark", Lekhak TF-013), are set on the
16
19
  # app page for the comparison and put back after (Lekhak TF-010);
17
- # --keep-app-theme compares the theme the app shows. A mockup box marked data-tf-state or
18
- # data-state-testid (another state of the screen) is left out of the comparison (Lekhak TF-011).
20
+ # Then the screen is compared again in the other theme (Lekhak TF-024), the mockup drawn in it too:
21
+ # --themes light,dark runs both, light or dark alone only that one, and auto (the default) runs the
22
+ # other once the app shows it has it. Colour is not compared in a theme the mockup does not draw.
23
+ # Each finding of that pass carries "theme"; the result lists "themes" it was compared in.
24
+ # --keep-app-theme compares the theme the app shows, and only that one. A mockup box marked data-tf-state or
25
+ # data-state-testid (another state of the screen) is left out of the comparison (Lekhak TF-011),
26
+ # and so are its icons, badges and text when a box around it is compared (Chatur TF-001).
27
+ # A box marked data-tf-sample, or data-tf-state="sample-data" (Chatur TF-005), is sample data: compared
28
+ # when the app draws something in its place,
29
+ # otherwise listed in coverage.not_measured and taken off the boxes around it (Chatur TF-002).
19
30
  #
20
31
  # WHAT IT ASKS: "does the built screen carry the structure its approved mockup
21
32
  # draws?" — at the same viewports, comparing STRUCTURE, never pixels. Pixel diffing
@@ -58,7 +69,7 @@ set -uo pipefail
58
69
 
59
70
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
60
71
 
61
- case "${1:-}" in -h|--help) sed -n '2,48p' "$0"; exit 0 ;; esac
72
+ case "${1:-}" in -h|--help) sed -n '2,56p' "$0"; exit 0 ;; esac
62
73
 
63
74
  command -v node >/dev/null 2>&1 || {
64
75
  echo "tf-mockup-parity: node is required (verify-phase §1 installs the verify environment)." >&2