@techierathore/techieflow 1.1.0 → 1.1.1

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