@techierathore/techieflow 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.claude/commands/TechieFlow/agents/analyst.md +5 -8
  2. package/.claude/commands/TechieFlow/agents/architect.md +1 -10
  3. package/.claude/commands/TechieFlow/agents/flow-master.md +1 -0
  4. package/.claude/commands/TechieFlow/agents/verifier.md +1 -0
  5. package/.claude/commands/TechieFlow/tasks/_owner-language.md +27 -0
  6. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
  7. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
  8. package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
  9. package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
  10. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
  11. package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
  12. package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
  13. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
  14. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +3 -3
  15. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
  16. package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
  17. package/.claude/commands/TechieFlow/tasks/generate-html.md +7 -3
  18. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +20 -18
  19. package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
  20. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  21. package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
  22. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
  23. package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
  24. package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
  25. package/.opencode/plugin/techieflow.js +181 -9
  26. package/.tfcore/agents/analyst.md +5 -8
  27. package/.tfcore/agents/architect.md +1 -10
  28. package/.tfcore/agents/flow-master.md +1 -0
  29. package/.tfcore/agents/verifier.md +1 -0
  30. package/.tfcore/hooks/block-git.sh +4 -2
  31. package/.tfcore/hooks/guard-build.sh +5 -1
  32. package/.tfcore/hooks/guard-db.sh +6 -4
  33. package/.tfcore/hooks/guard-metrics.sh +13 -1
  34. package/.tfcore/hooks/guard-status-html.sh +86 -2
  35. package/.tfcore/hooks/guard-verify-deps.sh +124 -0
  36. package/.tfcore/hooks/guard-verify.sh +8 -3
  37. package/.tfcore/hooks/sweep-artifacts.sh +46 -0
  38. package/.tfcore/routing.yaml +61 -0
  39. package/.tfcore/standards/owner-words.txt +23 -0
  40. package/.tfcore/tasks/_owner-language.md +27 -0
  41. package/.tfcore/tasks/_smoke-test-policy.md +1 -1
  42. package/.tfcore/tasks/_status-update-gate.md +4 -3
  43. package/.tfcore/tasks/amend-docs.md +6 -5
  44. package/.tfcore/tasks/build-phase.md +1 -1
  45. package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
  46. package/.tfcore/tasks/create-doc.md +4 -0
  47. package/.tfcore/tasks/create-project-brief.md +22 -0
  48. package/.tfcore/tasks/day1-brownfield.md +3 -3
  49. package/.tfcore/tasks/day1-greenfield.md +3 -3
  50. package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
  51. package/.tfcore/tasks/fix-issues.md +2 -2
  52. package/.tfcore/tasks/generate-html.md +7 -3
  53. package/.tfcore/tasks/handoff-phase.md +20 -18
  54. package/.tfcore/tasks/metrics-report.md +2 -0
  55. package/.tfcore/tasks/mockups.md +1 -1
  56. package/.tfcore/tasks/productguide.md +2 -2
  57. package/.tfcore/tasks/render-workflow-docs.md +1 -1
  58. package/.tfcore/tasks/triage-issues.md +2 -2
  59. package/.tfcore/tasks/verify-phase.md +1 -1
  60. package/.tfcore/telemetry/SCHEMA.md +61 -1
  61. package/.tfcore/telemetry/install-metrics.sh +3 -2
  62. package/.tfcore/telemetry/tf-metrics.sh +435 -21
  63. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
  64. package/.tfcore/templates/v4custom/app-brd-tmpl.md +5 -3
  65. package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
  66. package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
  67. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
  68. package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
  69. package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
  70. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
  71. package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
  72. package/.tfcore/templates/v4custom/html-render-shell.md +4 -2
  73. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  74. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  75. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  76. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  77. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  78. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  79. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  80. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  81. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  82. package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
  83. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  84. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  85. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  86. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  87. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  88. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
  89. package/.tfcore/utils/tf-assets-browser.mjs +96 -0
  90. package/.tfcore/utils/tf-assets.sh +97 -12
  91. package/.tfcore/utils/tf-brd-status.py +14 -5
  92. package/.tfcore/utils/tf-build-list.py +99 -11
  93. package/.tfcore/utils/tf-build-list.sh +5 -3
  94. package/.tfcore/utils/tf-build.sh +88 -15
  95. package/.tfcore/utils/tf-ci-repro.py +685 -0
  96. package/.tfcore/utils/tf-ci-repro.sh +24 -0
  97. package/.tfcore/utils/tf-devguide-list.py +47 -22
  98. package/.tfcore/utils/tf-doc-check.py +242 -10
  99. package/.tfcore/utils/tf-doc-tests.sh +79 -0
  100. package/.tfcore/utils/tf-emit.sh +413 -19
  101. package/.tfcore/utils/tf-feedback.sh +13 -0
  102. package/.tfcore/utils/tf-fix-close.sh +113 -31
  103. package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
  104. package/.tfcore/utils/tf-goal.sh +105 -18
  105. package/.tfcore/utils/tf-harness.sh +9 -4
  106. package/.tfcore/utils/tf-lock.sh +29 -0
  107. package/.tfcore/utils/tf-log-miss.py +34 -8
  108. package/.tfcore/utils/tf-login.mjs +167 -0
  109. package/.tfcore/utils/tf-misses-md.py +24 -21
  110. package/.tfcore/utils/tf-misses-md.sh +5 -4
  111. package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
  112. package/.tfcore/utils/tf-mockup-parity.sh +14 -2
  113. package/.tfcore/utils/tf-model-pick.sh +475 -0
  114. package/.tfcore/utils/tf-owner-text.py +233 -0
  115. package/.tfcore/utils/tf-owner-text.sh +14 -0
  116. package/.tfcore/utils/tf-phase.sh +33 -1
  117. package/.tfcore/utils/tf-portable.sh +230 -0
  118. package/.tfcore/utils/tf-render-html.py +80 -2
  119. package/.tfcore/utils/tf-routing-bind.sh +87 -1
  120. package/.tfcore/utils/tf-routing.sh +7 -0
  121. package/.tfcore/utils/tf-selfcheck.sh +356 -0
  122. package/.tfcore/utils/tf-split-brd.py +17 -2
  123. package/.tfcore/utils/tf-status-evidence.sh +11 -9
  124. package/.tfcore/utils/tf-status-facts.py +133 -44
  125. package/.tfcore/utils/tf-triage.py +62 -16
  126. package/.tfcore/utils/tf-triage.sh +2 -0
  127. package/.tfcore/utils/tf-verify-boot.sh +263 -43
  128. package/.tfcore/utils/tf-verify-emit.sh +20 -0
  129. package/.tfcore/utils/tf-verify-env.sh +33 -4
  130. package/.tfcore/utils/tf-verify-list.py +75 -8
  131. package/.tfcore/utils/tf-verify-screens.mjs +254 -43
  132. package/.tfcore/utils/tf-verify-screens.sh +18 -4
  133. package/.tfcore/utils/tf-verify-tests.sh +272 -28
  134. package/.tfcore/utils/tf-verify-verdict.py +59 -11
  135. package/.tfcore/utils/tf_defect.py +27 -0
  136. package/.tfcore/utils/tf_feedback.py +209 -0
  137. package/.tfcore/utils/tf_roadmap.py +35 -0
  138. package/README.md +19 -5
  139. package/docs/TechieFlow-Installation.md +5 -3
  140. package/opencode.jsonc +24 -0
  141. package/package.json +1 -1
  142. package/scripts/install.mjs +4 -0
  143. package/.tfcore/templates/architecture-tmpl.yaml +0 -650
  144. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
  145. package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
  146. package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
  147. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
  148. package/.tfcore/templates/market-research-tmpl.yaml +0 -252
  149. package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
  150. package/.tfcore/utils/techieflow-doc-template.md +0 -325
@@ -71,13 +71,21 @@ def entries_of(text):
71
71
  out, current = {}, ""
72
72
  lines = text.splitlines()
73
73
  for i, line in enumerate(lines):
74
- m = re.match(r"^##\s+(.+)$", line)
74
+ # a heading one level down (`### Page: …` under `## UI / Pages`) names the section too, and any
75
+ # other one (`### Cross-page: …`) ends the page above it
76
+ m = re.match(r"^#{2,3}\s+(.+)$", line)
75
77
  if m:
76
78
  current = re.sub(r"^(?:page|screen)\s*:\s*", "", m.group(1).strip(), flags=re.I)
77
79
  continue
78
80
  a = re.search(r"<a id=['\"]d-(req-[a-z]+-\d+)['\"]", line, re.I)
79
81
  if a:
80
82
  rid = a.group(1).upper()
83
+ # an anchor written above its own page heading belongs to that page, not the one before
84
+ # (AppManager's older entries: `<a id>` then `### Page:` then the row)
85
+ nxt = next((l for l in lines[i + 1:i + 3] if l.strip()), "")
86
+ h = re.match(r"^#{2,3}\s+(?:page|screen)\s*:\s*(.+)$", nxt, re.I)
87
+ if h:
88
+ current = h.group(1).strip()
81
89
  e = {"section": current, "acceptance": "", "mockup": "", "brd": "", "perf_budget": ""}
82
90
  for j in range(i, min(i + 14, len(lines))):
83
91
  l = lines[j]
@@ -117,6 +125,28 @@ def screens_of_uidesign(path):
117
125
  return out
118
126
 
119
127
 
128
+ def screens_of_checklist(text, ent):
129
+ """[{name, route, mockup}] from the checklist's own '## Page: Name (`/route`, …)' headings, at ## or
130
+ ###, for a project with no UIDesign: the first route that can be opened as typed (no `{id}`),
131
+ the mockup from the first entry under the heading. A heading with no such route names no screen.
132
+ Without this every UI row of such a project was graded by its test only (AppManager TF-016)."""
133
+ out, seen = [], set()
134
+ for line in text.splitlines():
135
+ m = re.match(r"^#{2,3}\s+(?:page|screen)\s*:\s*(.+?)\s*\((.*)\)\s*$", line, re.I)
136
+ if not m or norm_name(m.group(1)) in seen:
137
+ continue
138
+ inner = m.group(2)
139
+ cands = re.findall(r"`([^`]+)`", inner) or re.split(r"\s*,\s*", inner)
140
+ route = next((c.strip() for c in cands if c.strip().startswith("/") and "{" not in c and " " not in c.strip()), "")
141
+ if not route:
142
+ continue
143
+ section = re.sub(r"^#+\s+(?:page|screen)\s*:\s*", "", line.strip(), flags=re.I)
144
+ mock = next((e["mockup"] for e in ent.values() if e["section"] == section and e["mockup"]), "")
145
+ seen.add(norm_name(m.group(1)))
146
+ out.append({"name": m.group(1).strip(), "route": route, "mockup": mock, "section": section})
147
+ return out
148
+
149
+
120
150
  def dialogs_of_brd(path):
121
151
  """name -> parent route, from the BRD screens table rows whose Route cell reads 'on /route'."""
122
152
  out = {}
@@ -131,17 +161,47 @@ def dialogs_of_brd(path):
131
161
  return out
132
162
 
133
163
 
164
+ def usage_guide(app):
165
+ """docs/{App}-UsageGuide.md, or docs/{App}-Usage-Guide.md: the two spellings tf-doc-check.py
166
+ accepts. Only the first was looked for, so AppManager's guide read as having no test users
167
+ (AppManager TF-009)."""
168
+ for name in (f"{app}-UsageGuide.md", f"{app}-Usage-Guide.md"):
169
+ p = os.path.join("docs", name)
170
+ if os.path.isfile(p):
171
+ return p
172
+ return os.path.join("docs", f"{app}-UsageGuide.md")
173
+
174
+
134
175
  def test_users(path):
176
+ """The rows of the guide's Test users table. The heading may sit at ## or ### (a guide that
177
+ numbers its chapters nests it one level down), and the table may be the template's
178
+ `| # | User | Password source | Role |` or any table whose header names a user or email column
179
+ and a role column, read by those names (AppManager TF-009)."""
135
180
  users = []
136
181
  if not os.path.isfile(path):
137
182
  return users
138
- m = re.search(r"(?ms)^##\s+(?:\d+[.)]\s*)?Test users[^\n]*\n(.*?)(?=^## |\Z)", read(path))
183
+ m = re.search(r"(?ms)^#{2,3}\s+(?:\d+[.)]\s*)?Test users[^\n]*\n(.*?)(?=^#{1,3} |\Z)", read(path))
139
184
  if not m:
140
185
  return users
186
+ cols = None
141
187
  for line in m.group(1).splitlines():
188
+ if not line.strip().startswith("|"):
189
+ continue
142
190
  c = [x.strip().strip("`* ") for x in line.strip().strip("|").split("|")]
143
- if len(c) >= 4 and c[0].isdigit():
144
- users.append({"user": c[1], "password_source": c[2], "role": c[3]})
191
+ if cols is None:
192
+ low = [x.lower() for x in c]
193
+ def col(*names):
194
+ return next((i for i, h in enumerate(low) if any(n in h for n in names)), None)
195
+ cols = {"user": col("user", "email", "login", "account"), "pw": col("password", "secret"), "role": col("role")}
196
+ if cols["user"] is None or cols["role"] is None:
197
+ cols = {"user": 1, "pw": 2, "role": 3} # the template's numbered shape
198
+ continue
199
+ if set("".join(c)) <= set("-: "):
200
+ continue # the header rule
201
+ if len(c) > max(cols["user"], cols["role"]) and c[cols["user"]] and not c[cols["user"]].isdigit():
202
+ users.append({"user": c[cols["user"]],
203
+ "password_source": c[cols["pw"]] if cols["pw"] is not None and len(c) > cols["pw"] else "",
204
+ "role": c[cols["role"]]})
145
205
  return users
146
206
 
147
207
 
@@ -186,12 +246,15 @@ def main(argv):
186
246
  if not rows:
187
247
  die(f"{cl} has no REQ rows")
188
248
  ent = entries_of(text)
189
- screens = screens_of_uidesign(os.path.join("docs", f"{pfx}UIDesign.md"))
249
+ screens, source = screens_of_uidesign(os.path.join("docs", f"{pfx}UIDesign.md")), "UIDesign"
250
+ if not screens:
251
+ screens, source = screens_of_checklist(text, ent), "checklist's Page headings"
190
252
  dialogs = dialogs_of_brd(os.path.join("docs", f"{pfx}BRD.md"))
191
- users = test_users(os.path.join("docs", f"{app}-UsageGuide.md"))
253
+ users = test_users(usage_guide(app))
192
254
  by_name = {norm_name(s["name"]): s for s in screens}
193
255
  by_mock = {s["mockup"]: s for s in screens if s["mockup"]}
194
256
  by_route = {s["route"].lower(): s for s in screens}
257
+ by_section = {s["section"]: s for s in screens if s.get("section")}
195
258
 
196
259
  work, skipped, unresolved = [], 0, []
197
260
  for r in rows:
@@ -204,6 +267,10 @@ def main(argv):
204
267
  row = dict(r, acceptance=e["acceptance"], mockup=e["mockup"], brd=e["brd"], perf_budget=e["perf_budget"],
205
268
  section=e["section"], screen="", route="", dialog="")
206
269
  cand = [screen_from_acceptance(e["acceptance"]), e["section"], r["title"]]
270
+ if e["section"] in by_section: # a screen read from the checklist: the heading the row sits under
271
+ s = by_section[e["section"]]
272
+ row["screen"], row["route"] = s["name"], s["route"]
273
+ cand = []
207
274
  for c in cand:
208
275
  n = norm_name(c)
209
276
  if not n:
@@ -236,7 +303,7 @@ def main(argv):
236
303
  if not work:
237
304
  print("NOTHING: no row in scope. Run the status gate and stop.")
238
305
  print()
239
- print(f"## Screens to drive ({len(screen_list)} of {len(screens)} in the UIDesign)")
306
+ print(f"## Screens to drive ({len(screen_list)} of {len(screens)} in the {source})")
240
307
  for s in screen_list:
241
308
  print(f"- {s['name']} ({s['route']}) — mockup {s['mockup'] or 'none'} — rows {', '.join(s['rows'])}")
242
309
  print()
@@ -259,7 +326,7 @@ def main(argv):
259
326
  os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
260
327
  with open(out_path, "w", encoding="utf-8") as f:
261
328
  json.dump({"app": app, "scope": scope, "phase": phase, "kind": cfg("appKind", "app"), "checklist": cl,
262
- "rows": work, "screens": screen_list, "unresolved": unresolved, "test_users": users,
329
+ "rows": work, "screens": screen_list, "screens_source": source, "unresolved": unresolved, "test_users": users,
263
330
  "skipped_na": skipped, "total": len(rows)}, f, indent=1)
264
331
  print()
265
332
  print(f"JSON: {out_path}")
@@ -2,17 +2,23 @@
2
2
  // Run through tf-verify-screens.sh, not directly. Sitting 4c, 2026-09-06.
3
3
  //
4
4
  // Two of the seven verify checks, over one browser session:
5
- // render every control the mockup anchors (data-testid) is present and shows something; no
6
- // table with a header and no rows; no blank page; no Blazor error; no console error.
5
+ // render every control the mockup anchors (data-testid) and shows at that width is present and
6
+ // shows something (one the mockup hides at that width only is not owed there); no
7
+ // table with a header and no rows; no blank page; no error banner; no console error.
7
8
  // visual no two visible controls overlap; no anchored control has zero size or sits off-screen;
8
- // no horizontal overflow; the page has a stylesheet.
9
+ // no horizontal overflow; the page has a stylesheet. Overlap and off-screen are measured
10
+ // on the rectangle an element PAINTS in — its box clipped by every scrolling ancestor —
11
+ // so a wide table inside a horizontal scroller is not read as covering its neighbour.
12
+ // Measurement waits for the screen's first render (--render-wait, default 5000 ms) before reading
13
+ // the page: it waits for content to appear, knowing nothing about how the page is built.
9
14
  // At every width it saves a full-page screenshot. Verdicts: render OK | EMPTY | ERROR | UNREACHABLE,
10
15
  // visual OK | FAIL, each with its findings. Findings use the telemetry classes (blank-data,
11
16
  // zero-rows, exception, overlap, clipped, offscreen, other). It writes a JSON file the verdict
12
17
  // script reads and prints one line per screen. It never decides a row's status.
13
18
  //
14
- // Modes: --base URL (a served app, a new browser) or --cdp URL (attach to a running Blazor Hybrid
15
- // app's WebView2 over its DevTools port; screens are reached by pushState, the way the router does).
19
+ // Modes: --base URL (a served app, a new browser) or --cdp URL (attach to an already-running
20
+ // embedded-browser desktop head over its DevTools port; screens are reached by pushState, the way
21
+ // the router does).
16
22
 
17
23
  import { chromium } from 'playwright';
18
24
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -33,32 +39,132 @@ const LOGIN_PATH = arg('--login-path');
33
39
  const USER = arg('--user'); const PASS = arg('--password');
34
40
  const COOKIE = arg('--cookie'); const STORAGE = arg('--storage-state');
35
41
  const SETTLE = parseInt(arg('--wait', '1500'), 10);
42
+ // how long to keep waiting for the FIRST render after the page has settled. Plenty of stacks
43
+ // paint after the network goes quiet — a server-held connection that renders once it is up, a
44
+ // client router resolving its first route, a lazily loaded bundle — so measuring at
45
+ // domcontentloaded, at networkidle or even after --wait can read an empty document and report
46
+ // every anchored control as missing while the tool's own screenshot shows them (TF-021, TfLens
47
+ // 2026-09-09). Nothing here knows which stack it is looking at: it waits for CONTENT.
48
+ const RENDER_WAIT = parseInt(arg('--render-wait', '5000'), 10);
36
49
  const ATTR = arg('--testid-attr', 'data-testid');
50
+ // The banner a stack shows when its client side has fallen over. There is no neutral way to
51
+ // detect one -- every stack names its own -- so this is a LIST a project extends rather than a
52
+ // selector the framework assumes: --error-selector '.my-crash-banner', repeatable. The two
53
+ // defaults are one widely used framework's id and a plain attribute any project can put on its
54
+ // own banner. A project on neither still gets every other render check; it simply cannot be
55
+ // told about a banner nobody named.
56
+ const ERROR_SELECTORS = [...args('--error-selector'), '#blazor-error-ui', '[data-error-ui]'];
37
57
 
38
58
  if (!BASE && !CDP) { console.error('tf-verify-screens: --base URL or --cdp URL is required'); process.exit(3); }
39
59
 
40
60
  // ---------------------------------------------------------------- screens
41
61
  let screens = [];
62
+ // Sample values for route parameters (Lekhak TF-005): `/admin/llm-signin/{ProviderId:long}` opened as
63
+ // written answers 404. --route-value ProviderId=21 (repeatable), or "route_values" in the list, at its
64
+ // top or on one screen. A screen whose route still needs a value is not driven, and says which.
65
+ const ROUTE_VALUES = {};
66
+ for (const kv of args('--route-value')) { const m = kv.match(/^([^=]+)=(.*)$/); if (m) ROUTE_VALUES[m[1].trim().toLowerCase()] = m[2]; }
67
+ let listValues = {};
42
68
  if (LIST) {
43
69
  const l = JSON.parse(readFileSync(LIST, 'utf8'));
44
- screens = (l.screens || []).map((s) => ({ name: s.name, route: s.route, mockup: s.mockup || '', rows: s.rows || [] }));
70
+ listValues = l.route_values || {};
71
+ screens = (l.screens || []).map((s) => ({ name: s.name, route: s.route, mockup: s.mockup || '', rows: s.rows || [], route_values: s.route_values || {} }));
45
72
  }
46
73
  for (const s of args('--screen')) {
47
74
  const m = s.match(/^([^=]+)=(.+)$/);
48
75
  if (m) screens.push({ name: m[1], route: m[2], mockup: `${MOCKUPS}/${m[1].toLowerCase().replace(/[^a-z0-9]+/g, '-')}.html`, rows: [] });
49
76
  }
50
77
  if (!screens.length) { console.error('tf-verify-screens: no screens (--list <json> or --screen name=/route)'); process.exit(3); }
78
+ const fillRoute = (s) => {
79
+ const vals = {};
80
+ for (const src of [listValues, s.route_values || {}]) for (const [k, v] of Object.entries(src)) vals[k.toLowerCase()] = String(v);
81
+ Object.assign(vals, ROUTE_VALUES);
82
+ const need = [];
83
+ const route = s.route.replace(/\/?\{\*{0,2}([A-Za-z_][\w]*)(?::[^}=?]*)?(?:=([^}?]*))?(\?)?\}/g, (whole, name, dflt, opt) => {
84
+ const lead = whole.startsWith('/') ? '/' : '';
85
+ const v = vals[name.toLowerCase()] ?? dflt;
86
+ if (v !== undefined && v !== '') return lead + encodeURIComponent(v);
87
+ if (opt) return '';
88
+ need.push(name); return whole;
89
+ });
90
+ return { route, need };
91
+ };
92
+ const skipped = [];
93
+ screens = screens.filter((s) => {
94
+ const f = fillRoute(s);
95
+ if (f.need.length) { skipped.push({ name: s.name, route: s.route, rows: s.rows, needs: f.need }); return false; }
96
+ if (f.route !== s.route) s.route_pattern = s.route;
97
+ s.route = f.route;
98
+ return true;
99
+ });
100
+ for (const k of skipped) console.log(`SKIP ${k.name} (${k.route}) — not driven: the route needs a value; pass ${k.needs.map((n) => `--route-value ${n}=<a real ${n}>`).join(' ')}`);
51
101
 
52
102
  const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
53
103
  const anchorsOf = (mockup) => {
54
104
  if (!mockup || !existsSync(mockup)) return null;
55
- const html = readFileSync(mockup, 'utf8');
56
- const re = new RegExp(`${ATTR}\\s*=\\s*["']([^"']+)["']`, 'g');
105
+ // Only an attribute ON AN ELEMENT is a control the page must carry. A stylesheet rule such as
106
+ // [data-testid="source-mode"] .tab{…}, a script string or a comment naming one is not: TfLens's
107
+ // shared mockup stylesheet made every screen owe a dialog it never draws (TF-026).
108
+ const html = readFileSync(mockup, 'utf8')
109
+ .replace(/<!--[\s\S]*?-->/g, '')
110
+ .replace(/<(style|script)\b[\s\S]*?<\/\1\s*>/gi, '');
111
+ const re = new RegExp(`<[^>]*?\\b${ATTR}\\s*=\\s*["']([^"']+)["']`, 'g');
57
112
  const out = new Set(); let m;
58
113
  while ((m = re.exec(html))) out.add(m[1]);
59
114
  return [...out];
60
115
  };
61
116
 
117
+ // The anchors a mockup HIDES at one width and shows at another: a sidebar that becomes a slide-out
118
+ // menu on a phone is not owed at 390px, and asking for it failed every such screen there (TF-032,
119
+ // TfLens 2026-09-11). Worked out by drawing the mockup itself at each width. An anchor the mockup
120
+ // hides at every width -- a closed dialog -- is still owed, as before. None is excused when the
121
+ // mockup cannot be drawn.
122
+ async function hiddenByWidth(b, mockup, anchors) {
123
+ const out = {};
124
+ if (!b || !mockup || !existsSync(mockup) || !anchors || !anchors.length) return out;
125
+ const shown = {};
126
+ for (const width of WIDTHS) {
127
+ let ctx;
128
+ try {
129
+ ctx = await b.newContext({ viewport: { width, height: width < 600 ? 844 : 800 } });
130
+ const p = await ctx.newPage();
131
+ await p.goto('file://' + resolve(mockup), { waitUntil: 'load', timeout: 15000 });
132
+ shown[width] = await p.evaluate(({ anchors, attr }) => anchors.filter((id) => {
133
+ const el = document.querySelector(`[${attr}="${CSS.escape(id)}"]`);
134
+ if (!el) return false;
135
+ const r = el.getBoundingClientRect();
136
+ const seen = el.checkVisibility ? el.checkVisibility({ visibilityProperty: true, opacityProperty: true }) : true;
137
+ return seen && r.width > 0 && r.height > 0;
138
+ }), { anchors, attr: ATTR });
139
+ } catch (e) { return {}; } finally { if (ctx) await ctx.close().catch(() => {}); }
140
+ }
141
+ const everShown = new Set(Object.values(shown).flat());
142
+ for (const width of WIDTHS) out[width] = anchors.filter((id) => everShown.has(id) && !shown[width].includes(id));
143
+ return out;
144
+ }
145
+
146
+ // The anchors a mockup marks as belonging to another state of the screen (Lekhak TF-005): a reason
147
+ // shown only while the database is down, a result shown only after "Test". The mockup says so with
148
+ // data-tf-state="<state>" (or data-state-testid="<id>", Lekhak TF-011) on the control or on a box around it. Such a control is not owed on the
149
+ // first view; when it is drawn and visible it is graded like any other.
150
+ async function stateOnlyOf(b, mockup, anchors) {
151
+ if (!mockup || !existsSync(mockup) || !anchors || !anchors.length) return [];
152
+ if (b) {
153
+ let ctx;
154
+ try {
155
+ ctx = await b.newContext();
156
+ const p = await ctx.newPage();
157
+ await p.goto('file://' + resolve(mockup), { waitUntil: 'load', timeout: 15000 });
158
+ return await p.evaluate(({ anchors, attr }) => anchors.filter((id) => {
159
+ const el = document.querySelector(`[${attr}="${CSS.escape(id)}"]`);
160
+ return !!el && !!el.closest('[data-tf-state],[data-state-testid]');
161
+ }), { anchors, attr: ATTR });
162
+ } catch (e) { /* fall through to the mark on the control itself */ } finally { if (ctx) await ctx.close().catch(() => {}); }
163
+ }
164
+ const html = readFileSync(mockup, 'utf8');
165
+ return anchors.filter((id) => new RegExp(`<[^>]*\\bdata-(?:tf-state|state-testid)\\b[^>]*\\b${ATTR}\\s*=\\s*["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']|<[^>]*\\b${ATTR}\\s*=\\s*["']${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["'][^>]*\\bdata-(?:tf-state|state-testid)\\b`).test(html));
166
+ }
167
+
62
168
  // ---------------------------------------------------------------- browser
63
169
  let browser, cdpPage = null;
64
170
  if (CDP) {
@@ -70,13 +176,17 @@ if (CDP) {
70
176
  } else {
71
177
  browser = await chromium.launch();
72
178
  }
179
+ // mockups are drawn in a browser of our own: an attached desktop head is the app's, not ours
180
+ const mockBrowser = CDP ? await chromium.launch().catch(() => null) : browser;
73
181
 
74
182
  function writeOut(extra, results = [], login = null) {
75
183
  const summary = {
76
184
  mode: CDP ? 'cdp' : 'base', base: BASE || CDP, widths: WIDTHS, login,
77
185
  screens: results,
186
+ skipped, // screens not driven because their route needs a value (TF-005)
78
187
  summary: {
79
188
  screens: results.length,
189
+ skipped: skipped.length,
80
190
  render_ok: results.filter((r) => r.render === 'OK').length,
81
191
  render_fail: results.filter((r) => r.render === 'EMPTY' || r.render === 'ERROR').length,
82
192
  unreachable: results.filter((r) => r.render === 'UNREACHABLE').length,
@@ -90,28 +200,18 @@ function writeOut(extra, results = [], login = null) {
90
200
  return summary;
91
201
  }
92
202
 
93
- async function login(page) {
94
- if (!LOGIN_PATH || !USER) return { attempted: false };
95
- try {
96
- await page.goto(BASE + LOGIN_PATH, { waitUntil: 'domcontentloaded', timeout: 30000 });
97
- const user = page.locator(`[${ATTR}*="user" i], [${ATTR}*="email" i], input[type="email"], input[name*="user" i], input[name*="email" i], input[id*="user" i], input[id*="email" i], input[type="text"]`).first();
98
- const pass = page.locator(`[${ATTR}*="pass" i], input[type="password"]`).first();
99
- const btn = page.locator(`[${ATTR}*="login" i], [${ATTR}*="signin" i], [${ATTR}*="submit" i], button[type="submit"], form button, input[type="submit"]`).first();
100
- await user.fill(USER, { timeout: 10000 });
101
- await pass.fill(PASS || '', { timeout: 10000 });
102
- await Promise.all([page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}), btn.click({ timeout: 10000 })]);
103
- await page.waitForTimeout(SETTLE);
104
- const url = page.url();
105
- const still = url.startsWith(BASE + LOGIN_PATH);
106
- return { attempted: true, ok: !still, url };
107
- } catch (e) {
108
- return { attempted: true, ok: false, error: e.message.split('\n')[0] };
109
- }
110
- }
203
+ // The sign-in recipe (TF-033, TF-044) and the way a screen behind an in-page sign-in is reached
204
+ // (AppManager TF-006) live in tf-login.mjs since AppManager TF-011, shared with tf-mockup-parity
205
+ // and tf-assets so every check reaches a screen the same way.
206
+ import { signIn, signedOut as signedOutShared, navigateInApp as navigateInAppShared } from './tf-login.mjs';
207
+ const LOGIN_OPTS = { base: BASE, loginPath: LOGIN_PATH, user: USER, password: PASS, attr: ATTR, settle: SETTLE, renderWait: RENDER_WAIT };
208
+ const login = (page) => signIn(page, LOGIN_OPTS);
111
209
 
112
210
  async function contextFor(width) {
113
211
  if (CDP) { await cdpPage.setViewportSize({ width, height: width < 600 ? 844 : 800 }).catch(() => {}); return { page: cdpPage, close: async () => {} }; }
114
- const opts = { viewport: { width, height: width < 600 ? 844 : 800 } };
212
+ // a development head on HTTPS carries a certificate nothing trusts; tf-mockup-parity and tf-assets
213
+ // already accept it, and without it every screen was UNREACHABLE (Lekhak TF-002)
214
+ const opts = { viewport: { width, height: width < 600 ? 844 : 800 }, ignoreHTTPSErrors: true };
115
215
  if (STORAGE && existsSync(STORAGE)) opts.storageState = STORAGE;
116
216
  const ctx = await browser.newContext(opts);
117
217
  if (COOKIE) {
@@ -138,9 +238,36 @@ async function navigate(page, route) {
138
238
  } catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
139
239
  }
140
240
 
241
+ // Signed out: on the sign-in address, or drawing a sign-in form — a password field WITH a sign-in
242
+ // button. A password field alone is not the sign-in page: AppManager's signed-in "Create user" form
243
+ // has one for the new account and was graded unreachable (AppManager TF-012). tf-login.mjs decides.
244
+ const signedOut = (page) => signedOutShared(page, LOGIN_OPTS);
245
+ // Open a route from inside the page, the way its own links do (tf-login.mjs).
246
+ const navigateInApp = (page, route) => navigateInAppShared(page, LOGIN_OPTS, route);
247
+
248
+ // Wait for the screen's first render before measuring anything. Returns the milliseconds waited.
249
+ // It never decides anything: when nothing appears within RENDER_WAIT the page is graded exactly as
250
+ // it stands, so a control that is genuinely missing is still reported (TF-021).
251
+ async function waitForRender(page, anchors, attr) {
252
+ if (RENDER_WAIT <= 0) return 0;
253
+ const t0 = Date.now();
254
+ try {
255
+ // The condition names nothing about how the page is built: either a control the
256
+ // mockup anchors has appeared, or the document has text or a form control in it.
257
+ // An error banner satisfies the second, so a page that failed to start is not
258
+ // waited on to the timeout either.
259
+ await page.waitForFunction(({ anchors, attr }) => {
260
+ if (anchors && anchors.length) return anchors.some((id) => document.querySelector(`[${attr}="${CSS.escape(id)}"]`));
261
+ return (document.body && (document.body.innerText || '').trim().length > 0)
262
+ || document.querySelectorAll('input, select, textarea, img, svg, canvas, video').length > 0;
263
+ }, { anchors, attr }, { timeout: RENDER_WAIT, polling: 100 });
264
+ } catch (e) { /* nothing appeared in time: grade what is there */ }
265
+ return Date.now() - t0;
266
+ }
267
+
141
268
  // what the page says about itself: anchors, tables, text, boxes
142
- async function inspect(page, anchors, attr) {
143
- return page.evaluate(({ anchors, attr }) => {
269
+ async function inspect(page, anchors, attr, errorSelectors) {
270
+ return page.evaluate(({ anchors, attr, errorSelectors }) => {
144
271
  const vis = (el) => {
145
272
  const cs = getComputedStyle(el);
146
273
  if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
@@ -149,13 +276,39 @@ async function inspect(page, anchors, attr) {
149
276
  if (r.width <= 1 && r.height <= 1 && cs.position === 'absolute') return false; // sr-only
150
277
  return true;
151
278
  };
279
+ // The rectangle an element actually PAINTS in: its own box intersected with the clip
280
+ // rectangle of every ancestor that clips (any overflow other than `visible`). A table row
281
+ // inside a horizontal scroller measures 1167px wide in a container 492px wide, so the
282
+ // unclipped box reaches x=1480 while the pixels stop at x=805 — and the card beside it, at
283
+ // x=846, was reported as being overlapped by something nothing draws (TF-021, TfLens
284
+ // 2026-09-09). Overlap and off-screen are judged on this rectangle; zero-size still uses the
285
+ // element's own box, since a control scrolled out of view is not a collapsed one.
286
+ const clipRect = (el) => {
287
+ const r = el.getBoundingClientRect();
288
+ let x1 = r.left, y1 = r.top, x2 = r.right, y2 = r.bottom, clipped = false;
289
+ for (let p = el.parentElement; p; p = p.parentElement) {
290
+ const cs = getComputedStyle(p);
291
+ const cx = cs.overflowX !== 'visible', cy = cs.overflowY !== 'visible';
292
+ if (!cx && !cy) continue;
293
+ const pr = p.getBoundingClientRect();
294
+ const nx1 = cx ? Math.max(x1, pr.left) : x1, nx2 = cx ? Math.min(x2, pr.right) : x2;
295
+ const ny1 = cy ? Math.max(y1, pr.top) : y1, ny2 = cy ? Math.min(y2, pr.bottom) : y2;
296
+ if (nx1 !== x1 || nx2 !== x2 || ny1 !== y1 || ny2 !== y2) clipped = true;
297
+ x1 = nx1; x2 = nx2; y1 = ny1; y2 = ny2;
298
+ }
299
+ return { x: x1, y: y1, w: Math.max(0, x2 - x1), h: Math.max(0, y2 - y1), clipped };
300
+ };
152
301
  const sheetOk = (l) => { try { return !!l.sheet && l.sheet.cssRules.length > 0; } catch (e) { return !!l.sheet; } };
153
302
  const styled = [...document.querySelectorAll('link[rel~="stylesheet"]')].some(sheetOk)
154
303
  || [...document.querySelectorAll('style')].some(sheetOk);
155
304
  const failedSheets = [...document.querySelectorAll('link[rel~="stylesheet"]')].filter((l) => !l.sheet).map((l) => l.getAttribute('href'));
156
305
  const out = { anchors: [], tables: [], text_len: (document.body.innerText || '').trim().length, boxes: [],
157
306
  controls: document.querySelectorAll('input, select, textarea, img, svg, canvas, video').length,
158
- blazor_error: !!document.querySelector('#blazor-error-ui') && vis(document.querySelector('#blazor-error-ui')),
307
+ error_banner: (errorSelectors || []).find((sel) => {
308
+ let el = null;
309
+ try { el = document.querySelector(sel); } catch (e) { return false; } // a bad selector is not a finding
310
+ return !!el && vis(el);
311
+ }) || '',
159
312
  overflow_x: document.documentElement.scrollWidth > document.documentElement.clientWidth + 2,
160
313
  styled, failed_sheets: failedSheets, vw: window.innerWidth, vh: window.innerHeight };
161
314
  for (const id of anchors || []) {
@@ -165,7 +318,9 @@ async function inspect(page, anchors, attr) {
165
318
  const r = el.getBoundingClientRect();
166
319
  const text = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
167
320
  const filled = text.length > 0 || ['input', 'select', 'textarea', 'img', 'svg', 'canvas', 'video', 'iframe'].includes(tag) || el.querySelector('img,svg,canvas,input,select,textarea,button,a') !== null;
168
- out.anchors.push({ id, present: true, visible: vis(el), filled, tag, w: Math.round(r.width), h: Math.round(r.height), x: Math.round(r.left), y: Math.round(r.top) });
321
+ const c = clipRect(el);
322
+ out.anchors.push({ id, present: true, visible: vis(el), filled, tag, w: Math.round(r.width), h: Math.round(r.height), x: Math.round(r.left), y: Math.round(r.top),
323
+ cx: Math.round(c.x), cy: Math.round(c.y), cw: Math.round(c.w), ch: Math.round(c.h), clipped: c.clipped });
169
324
  }
170
325
  for (const t of document.querySelectorAll('table')) {
171
326
  if (!vis(t)) continue;
@@ -178,8 +333,21 @@ async function inspect(page, anchors, attr) {
178
333
  const els = [...document.querySelectorAll(sel)].filter(vis);
179
334
  els.forEach((el, i) => {
180
335
  const r = el.getBoundingClientRect();
336
+ const c = clipRect(el); // what is painted, not what is laid out
337
+ // An inline element that wraps paints one fragment per line, and its bounding box is the union
338
+ // of them: two sentences sharing a line then "overlap" across the tile's whole width though no
339
+ // pixel of one is drawn over the other (TF-036, TfLens /effort 2026-09-11). Keep the fragments,
340
+ // each cut to the painted rectangle, and compare those.
341
+ let frags = null;
342
+ if (getComputedStyle(el).display === 'inline') {
343
+ const fs = [...el.getClientRects()].map((f) => {
344
+ const x1 = Math.max(f.left, c.x), x2 = Math.min(f.right, c.x + c.w), y1 = Math.max(f.top, c.y), y2 = Math.min(f.bottom, c.y + c.h);
345
+ return { x: x1, y: y1, w: Math.max(0, x2 - x1), h: Math.max(0, y2 - y1) };
346
+ }).filter((f) => f.w > 0 && f.h > 0);
347
+ if (fs.length > 1) frags = fs;
348
+ }
181
349
  out.boxes.push({ i, id: el.getAttribute(attr) || '', tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || '').trim().slice(0, 40),
182
- x: r.left, y: r.top, w: r.width, h: r.height, anchored: el.hasAttribute(attr) });
350
+ x: c.x, y: c.y, w: c.w, h: c.h, raw_w: r.width, raw_h: r.height, clipped: c.clipped, anchored: el.hasAttribute(attr), frags });
183
351
  });
184
352
  // ancestry so a button inside its card is never an "overlap"
185
353
  out.rel = [];
@@ -187,18 +355,21 @@ async function inspect(page, anchors, attr) {
187
355
  if (els[a].contains(els[b]) || els[b].contains(els[a])) out.rel.push([a, b]);
188
356
  }
189
357
  return out;
190
- }, { anchors, attr });
358
+ }, { anchors, attr, errorSelectors });
191
359
  }
192
360
 
193
- function grade(info, consoleErrors, width) {
361
+ function grade(info, consoleErrors, width, excused = [], stateOnly = []) {
194
362
  const findings = [];
195
- if (info.blazor_error) findings.push({ check: 'render', class: 'exception', detail: 'the Blazor error bar is showing' });
363
+ // a control of another state is graded only when this view draws it, visible and filled (TF-005)
364
+ const soft = new Set(stateOnly);
365
+ info = { ...info, anchors: info.anchors.filter((a) => !soft.has(a.id) || (a.present && a.visible && a.filled && a.w > 0 && a.h > 0)) };
366
+ if (info.error_banner) findings.push({ check: 'render', class: 'exception', detail: `the application's error banner is showing (${info.error_banner})` });
196
367
  // a failed fetch ("Failed to load resource … 404") is the assets check's finding, not a page error
197
368
  const pageErrors = consoleErrors.filter((e) => !/^Failed to load resource/i.test(e));
198
369
  for (const e of pageErrors.slice(0, 3)) findings.push({ check: 'render', class: 'exception', detail: `console error: ${e.slice(0, 120)}` });
199
370
  if (info.text_len < 15 && info.controls === 0 && !info.anchors.some((a) => a.present)) findings.push({ check: 'render', class: 'blank-data', detail: `the page is blank (${info.text_len} characters of text, no control)` });
200
371
  for (const a of info.anchors) {
201
- if (!a.present) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" is not on the page` });
372
+ if (!a.present && !excused.includes(a.id)) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" is not on the page` });
202
373
  else if (a.visible && !a.filled) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" (${a.tag}) is empty` });
203
374
  }
204
375
  for (const t of info.tables) {
@@ -209,7 +380,10 @@ function grade(info, consoleErrors, width) {
209
380
  if (info.overflow_x) findings.push({ check: 'visual', class: 'offscreen', detail: `the page scrolls sideways at ${width}px` });
210
381
  for (const a of info.anchors) {
211
382
  if (a.present && a.visible && (a.w === 0 || a.h === 0)) findings.push({ check: 'visual', class: 'clipped', detail: `anchored control "${a.id}" has zero ${a.w === 0 ? 'width' : 'height'}` });
212
- if (a.present && a.visible && a.w > 0 && (a.x + a.w > info.vw + 2 || a.x < -2)) findings.push({ check: 'visual', class: 'offscreen', detail: `anchored control "${a.id}" sits outside the viewport at ${width}px` });
383
+ // judged on the painted rectangle: a control inside a scroller is reachable by scrolling
384
+ // that container, which is a layout decision, not a control that has fallen off the page
385
+ const ax = a.cx === undefined ? a.x : a.cx, aw = a.cw === undefined ? a.w : a.cw;
386
+ if (a.present && a.visible && a.w > 0 && aw > 0 && (ax + aw > info.vw + 2 || ax < -2)) findings.push({ check: 'visual', class: 'offscreen', detail: `anchored control "${a.id}" sits outside the viewport at ${width}px` });
213
387
  }
214
388
  const rel = new Set(info.rel.map(([a, b]) => `${a}-${b}`));
215
389
  const boxes = info.boxes.filter((b) => b.w > 0 && b.h > 0);
@@ -220,7 +394,18 @@ function grade(info, consoleErrors, width) {
220
394
  const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
221
395
  const iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
222
396
  if (ix <= 4 || iy <= 4) continue;
223
- const inter = ix * iy, small = Math.min(a.w * a.h, b.w * b.h);
397
+ let inter = ix * iy, small = Math.min(a.w * a.h, b.w * b.h);
398
+ if (a.frags || b.frags) { // a wrapped inline element: what its fragments share (TF-036)
399
+ const fa = a.frags || [a], fb = b.frags || [b];
400
+ const area = (fs) => fs.reduce((s, f) => s + f.w * f.h, 0);
401
+ inter = 0;
402
+ for (const p of fa) for (const q of fb) {
403
+ const px = Math.min(p.x + p.w, q.x + q.w) - Math.max(p.x, q.x), py = Math.min(p.y + p.h, q.y + q.h) - Math.max(p.y, q.y);
404
+ if (px > 4 && py > 4) inter += px * py;
405
+ }
406
+ if (inter === 0) continue;
407
+ small = Math.min(area(fa), area(fb));
408
+ }
224
409
  if (inter / small > 0.9) continue; // one lies inside the other by design (a badge on a card)
225
410
  if (inter / small < 0.25) continue; // a touch, not an overlap
226
411
  overlaps++;
@@ -246,19 +431,44 @@ for (const width of WIDTHS) {
246
431
  if (!CDP) { const l = await login(page); if (loginResult === null) loginResult = l; }
247
432
  for (const s of screens) {
248
433
  let r = results.find((x) => x.name === s.name);
249
- if (!r) { r = { name: s.name, route: s.route, mockup: s.mockup, rows: s.rows, anchors: anchorsOf(s.mockup), widths: [] }; results.push(r); }
434
+ if (!r) {
435
+ r = { name: s.name, route: s.route, mockup: s.mockup, rows: s.rows, anchors: anchorsOf(s.mockup), widths: [] }; results.push(r);
436
+ r.hidden_by_width = await hiddenByWidth(mockBrowser, s.mockup, r.anchors);
437
+ r.state_only = await stateOnlyOf(mockBrowser, s.mockup, r.anchors);
438
+ if (s.route_pattern) r.route_pattern = s.route_pattern;
439
+ }
440
+ const excused = (r.hidden_by_width || {})[width] || [];
441
+ const stateOnly = r.state_only || [];
250
442
  consoleErrors.length = 0;
251
- const nav = await navigate(page, s.route);
443
+ let nav = await navigate(page, s.route);
252
444
  const shot = `${SHOTS}/${slug(s.name)}-${width}.png`;
253
445
  const entry = { width, url: nav.url || '', status: nav.status, screenshot: shot, console_errors: [] };
446
+ // --- AppManager TF-006: a sign-in the page keeps for itself, not in a cookie. The document for a
447
+ // signed-in screen answers 401 because the server cannot see the sign-in; the page then draws the
448
+ // screen once its live connection reads the session in the tab, or never, when the sign-in lives
449
+ // only in that connection. So a 401 or 403 is judged by what the page draws: signed in, the screen
450
+ // is graded; still signed out, the tool signs in again and opens the screen from inside the page.
451
+ if (!CDP && LOGIN_PATH && USER && (nav.status === 401 || nav.status === 403)) {
452
+ await waitForRender(page, [], ATTR);
453
+ let how = `answered HTTP ${nav.status}, then drew the screen signed in`;
454
+ if (await signedOut(page)) {
455
+ how = '';
456
+ const again = await login(page);
457
+ if (again.ok && await navigateInApp(page, s.route) && !(await signedOut(page))) how = `answered HTTP ${nav.status}; opened from inside the page after signing in`;
458
+ }
459
+ if (how) { entry.reached = how; nav = { status: 200, url: page.url() }; entry.url = nav.url; }
460
+ }
254
461
  const redirectedToLogin = LOGIN_PATH && nav.url && new URL(nav.url).pathname.startsWith(LOGIN_PATH) && !s.route.startsWith(LOGIN_PATH);
255
462
  if (nav.status === 0 || nav.status >= 400 || redirectedToLogin) {
256
463
  entry.render = 'UNREACHABLE'; entry.visual = 'n/a';
257
464
  entry.findings = [{ check: 'render', class: 'other', detail: nav.status === 0 ? `could not open ${s.route}: ${nav.error || 'no response'}` : redirectedToLogin ? `${s.route} redirected to the sign-in page (not signed in)` : `${s.route} answered HTTP ${nav.status}` }];
258
465
  if (nav.status === 0 && results.length === 1 && width === WIDTHS[0]) firstUnreachable = true;
259
466
  } else {
260
- const info = await inspect(page, r.anchors || [], ATTR);
261
- const g = grade(info, consoleErrors, width);
467
+ entry.render_wait_ms = await waitForRender(page, (r.anchors || []).filter((id) => !excused.includes(id) && !stateOnly.includes(id)), ATTR);
468
+ const info = await inspect(page, r.anchors || [], ATTR, ERROR_SELECTORS);
469
+ const g = grade(info, consoleErrors, width, excused, stateOnly);
470
+ if (excused.length) entry.hidden_in_mockup = excused;
471
+ if (stateOnly.length) entry.state_only = stateOnly;
262
472
  Object.assign(entry, g);
263
473
  entry.console_errors = consoleErrors.slice(0, 5);
264
474
  entry.anchors_present = (info.anchors || []).filter((a) => a.present).length;
@@ -278,6 +488,7 @@ for (const r of results) {
278
488
  }
279
489
  const out = writeOut({}, results, loginResult);
280
490
  if (loginResult && loginResult.attempted && !loginResult.ok) console.log(`LOGIN failed at ${BASE}${LOGIN_PATH}: ${loginResult.error || 'still on the sign-in page'}`);
281
- console.log(`screens ${out.summary.screens}: render ${out.summary.render_ok} OK / ${out.summary.render_fail} failed / ${out.summary.unreachable} unreachable; visual ${out.summary.visual_ok} OK / ${out.summary.visual_fail} failed; screenshots ${SHOTS}; JSON ${OUT}`);
491
+ console.log(`screens ${out.summary.screens}${skipped.length ? ` (+${skipped.length} not driven: route needs a value)` : ''}: render ${out.summary.render_ok} OK / ${out.summary.render_fail} failed / ${out.summary.unreachable} unreachable; visual ${out.summary.visual_ok} OK / ${out.summary.visual_fail} failed; screenshots ${SHOTS}; JSON ${OUT}`);
492
+ if (CDP && mockBrowser) await mockBrowser.close().catch(() => {});
282
493
  if (!CDP) await browser.close(); else await browser.close().catch(() => {});
283
494
  process.exit(firstUnreachable && out.summary.unreachable === out.summary.screens ? 2 : (out.summary.render_fail + out.summary.visual_fail + out.summary.unreachable) ? 5 : 0);