@techierathore/techieflow 1.0.4 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
  2. package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
  3. package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
  4. package/.claude/commands/TechieFlow/tasks/_owner-language.md +1 -0
  5. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
  6. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
  7. package/.claude/commands/TechieFlow/tasks/amend-docs.md +2 -2
  8. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
  9. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +5 -5
  10. package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
  11. package/.claude/commands/TechieFlow/tasks/fix-issues.md +3 -2
  12. package/.claude/commands/TechieFlow/tasks/generate-html.md +2 -2
  13. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +2 -2
  14. package/.claude/commands/TechieFlow/tasks/mockups.md +2 -2
  15. package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
  16. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
  17. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
  18. package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
  19. package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
  20. package/.opencode/plugin/techieflow.js +194 -14
  21. package/.tfcore/agents/flow-master.md +2 -0
  22. package/.tfcore/agents/verifier.md +2 -2
  23. package/.tfcore/core-config.yaml +2 -1
  24. package/.tfcore/hooks/block-git.sh +4 -2
  25. package/.tfcore/hooks/guard-build.sh +5 -1
  26. package/.tfcore/hooks/guard-db.sh +112 -13
  27. package/.tfcore/hooks/guard-status-html.sh +86 -2
  28. package/.tfcore/hooks/guard-verify-deps.sh +16 -1
  29. package/.tfcore/hooks/guard-verify.sh +8 -3
  30. package/.tfcore/hooks/sweep-artifacts.sh +46 -0
  31. package/.tfcore/hooks/yolo-allow.sh +27 -0
  32. package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
  33. package/.tfcore/standards/coding-standards-dotnet.md +31 -3
  34. package/.tfcore/standards/owner-words.txt +23 -0
  35. package/.tfcore/tasks/_develop-mode.md +34 -0
  36. package/.tfcore/tasks/_owner-language.md +1 -0
  37. package/.tfcore/tasks/_status-update-gate.md +4 -3
  38. package/.tfcore/tasks/_yolo-mode.md +2 -2
  39. package/.tfcore/tasks/amend-docs.md +2 -2
  40. package/.tfcore/tasks/day1-brownfield.md +3 -3
  41. package/.tfcore/tasks/day1-greenfield.md +5 -5
  42. package/.tfcore/tasks/develop-end-to-end.md +30 -0
  43. package/.tfcore/tasks/fix-issues.md +3 -2
  44. package/.tfcore/tasks/generate-html.md +2 -2
  45. package/.tfcore/tasks/handoff-phase.md +2 -2
  46. package/.tfcore/tasks/mockups.md +2 -2
  47. package/.tfcore/tasks/productguide.md +2 -2
  48. package/.tfcore/tasks/render-workflow-docs.md +1 -1
  49. package/.tfcore/tasks/triage-and-fix.md +2 -2
  50. package/.tfcore/tasks/triage-issues.md +2 -2
  51. package/.tfcore/tasks/verify-phase.md +12 -11
  52. package/.tfcore/telemetry/SCHEMA.md +10 -2
  53. package/.tfcore/telemetry/install-metrics.sh +3 -2
  54. package/.tfcore/telemetry/tf-metrics.sh +38 -2
  55. package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
  56. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
  57. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
  58. package/.tfcore/templates/v4custom/app-brd-tmpl.md +7 -4
  59. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
  60. package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
  61. package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
  62. package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
  63. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
  64. package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
  65. package/.tfcore/templates/v4custom/html-render-shell.md +6 -2
  66. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  67. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  68. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  69. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  70. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  71. package/.tfcore/utils/tf-appium.mjs +179 -0
  72. package/.tfcore/utils/tf-assets-browser.mjs +96 -0
  73. package/.tfcore/utils/tf-assets.sh +97 -12
  74. package/.tfcore/utils/tf-brd-status.py +14 -5
  75. package/.tfcore/utils/tf-build-list.py +95 -12
  76. package/.tfcore/utils/tf-build-list.sh +2 -1
  77. package/.tfcore/utils/tf-build.sh +163 -15
  78. package/.tfcore/utils/tf-checklist-edit.py +21 -3
  79. package/.tfcore/utils/tf-ci-repro.py +685 -0
  80. package/.tfcore/utils/tf-ci-repro.sh +24 -0
  81. package/.tfcore/utils/tf-day1-files.py +55 -28
  82. package/.tfcore/utils/tf-develop.sh +264 -0
  83. package/.tfcore/utils/tf-devguide-list.py +53 -24
  84. package/.tfcore/utils/tf-doc-check.py +218 -26
  85. package/.tfcore/utils/tf-doc-tests.sh +116 -0
  86. package/.tfcore/utils/tf-emit.sh +132 -45
  87. package/.tfcore/utils/tf-feedback.sh +13 -0
  88. package/.tfcore/utils/tf-fix-close.sh +87 -31
  89. package/.tfcore/utils/tf-goal.sh +12 -12
  90. package/.tfcore/utils/tf-harness.sh +9 -4
  91. package/.tfcore/utils/tf-lock.sh +29 -0
  92. package/.tfcore/utils/tf-log-miss.py +34 -8
  93. package/.tfcore/utils/tf-login.mjs +167 -0
  94. package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
  95. package/.tfcore/utils/tf-menus.mjs +401 -0
  96. package/.tfcore/utils/tf-misses-md.py +10 -4
  97. package/.tfcore/utils/tf-mockup-parity.mjs +763 -71
  98. package/.tfcore/utils/tf-mockup-parity.sh +25 -2
  99. package/.tfcore/utils/tf-owner-text.py +233 -0
  100. package/.tfcore/utils/tf-owner-text.sh +14 -0
  101. package/.tfcore/utils/tf-phase.sh +38 -4
  102. package/.tfcore/utils/tf-portable.sh +230 -0
  103. package/.tfcore/utils/tf-render-html.py +79 -5
  104. package/.tfcore/utils/tf-req-ids.py +36 -0
  105. package/.tfcore/utils/tf-selfcheck.sh +39 -6
  106. package/.tfcore/utils/tf-split-brd.py +11 -1
  107. package/.tfcore/utils/tf-stack-check.sh +3 -0
  108. package/.tfcore/utils/tf-status-evidence.sh +11 -9
  109. package/.tfcore/utils/tf-status-facts.py +160 -45
  110. package/.tfcore/utils/tf-theme.mjs +189 -0
  111. package/.tfcore/utils/tf-triage.py +74 -17
  112. package/.tfcore/utils/tf-triage.sh +2 -0
  113. package/.tfcore/utils/tf-verify-boot.sh +529 -54
  114. package/.tfcore/utils/tf-verify-emit.sh +57 -17
  115. package/.tfcore/utils/tf-verify-env.sh +33 -4
  116. package/.tfcore/utils/tf-verify-list.py +290 -24
  117. package/.tfcore/utils/tf-verify-list.sh +3 -1
  118. package/.tfcore/utils/tf-verify-native.mjs +307 -0
  119. package/.tfcore/utils/tf-verify-screens.mjs +341 -44
  120. package/.tfcore/utils/tf-verify-screens.sh +23 -5
  121. package/.tfcore/utils/tf-verify-tests.sh +310 -19
  122. package/.tfcore/utils/tf-verify-verdict.py +163 -21
  123. package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
  124. package/.tfcore/utils/tf-verify-zoom.sh +29 -0
  125. package/.tfcore/utils/tf-yolo-alias.sh +84 -0
  126. package/.tfcore/utils/tf-yolo-perms.py +135 -0
  127. package/.tfcore/utils/tf-yolo-start.sh +44 -0
  128. package/.tfcore/utils/tf-yolo.sh +31 -3
  129. package/.tfcore/utils/tf_defect.py +27 -0
  130. package/.tfcore/utils/tf_feedback.py +272 -0
  131. package/.tfcore/utils/tf_roadmap.py +35 -0
  132. package/.tfcore/utils/tf_stack_check.py +93 -0
  133. package/README.md +6 -2
  134. package/docs/TechieFlow-Installation.md +5 -3
  135. package/opencode.jsonc +6 -2
  136. package/package.json +1 -1
  137. package/scripts/install.mjs +13 -2
  138. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  139. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  140. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  141. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  142. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  143. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  144. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  145. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  146. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  147. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  148. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
@@ -14,7 +14,10 @@ For each row, the seven checks in order, and the first that fails is the verdict
14
14
  render every anchored control on its screen shows something — screens.json
15
15
  assets every stylesheet and script its screen declares arrived — assets.json
16
16
  visual nothing overlaps, nothing clipped or off-screen — screens.json
17
- mockup the screen carries the structure its mockup draws — parity.json
17
+ mockup the screen carries the structure its mockup draws — parity.json, found by the screen's
18
+ name, its mockup's file name or its route ({id} in the row's route = one path segment, so
19
+ /processes/6/run counts for /processes/{id}/run); a driven screen with a mockup and no
20
+ comparison found FAILs this check, it is never skipped (Chatur TF-008)
18
21
  speed within the row's perf-budget, only when it declares one — perf/<REQ>.json
19
22
  (standards has no script yet and is never listed as run)
20
23
 
@@ -22,7 +25,9 @@ Verdicts: PASS · FAIL (acceptance) · BUILD-FAIL · RENDER-FAIL · ASSET-FAIL
22
25
  PERF-FAIL · NOT-TESTED (no test carried the id, or every test carrying it was skipped; the screen
23
26
  checks passed or did not run) ·
24
27
  NOT-OBSERVABLE (an NFR row with no budget and no unit test) · NOT-DRIVEN (its screen was never driven:
25
- the head could not be booted, or has no driver). A row is Verified only on PASS. NOT-* rows keep their
28
+ the head could not be booted, or has no driver) · DEFECT-OPEN (every check passed, but the old Remarks
29
+ carry a defect no verify can see, tf_defect.py: the row goes to Needs re-verify, and so to a fix, with
30
+ the defect kept; AppManager TF-021). A row is Verified only on PASS. NOT-* rows keep their
26
31
  status and get a Remark saying so; nothing is ever written as a static-only pass.
27
32
 
28
33
  Writes docs/.last-verify.json (the ledger the verify hook reads: date, app, scope, booted, the checks
@@ -38,6 +43,9 @@ import re
38
43
  import sys
39
44
 
40
45
  HERE = os.path.dirname(os.path.abspath(__file__))
46
+ sys.path.insert(0, HERE)
47
+ import tf_defect # noqa: E402
48
+ import tf_stack_check # noqa: E402
41
49
  FAILING = {"BUILD-FAIL": "build", "FAIL": "acceptance", "RENDER-FAIL": "render", "ASSET-FAIL": "assets",
42
50
  "VISUAL-FAIL": "visual", "MOCKUP-FAIL": "mockup-parity", "PERF-FAIL": "perf"}
43
51
  STATUS = {"PASS": "Verified", "FAIL": "FAIL", "BUILD-FAIL": "FAIL", "RENDER-FAIL": "Needs re-verify", "ASSET-FAIL": "Needs re-verify",
@@ -71,6 +79,42 @@ def norm_route(r):
71
79
  return r or "/"
72
80
 
73
81
 
82
+ def route_matches(pattern, route):
83
+ """A filled-in route against its pattern: /processes/6/run matches /processes/{id}/run, one path
84
+ segment per {…} (Chatur TF-008). A pattern with no {…} matches only itself. A query string on the
85
+ opened route (/start?all=1) is not part of the screen."""
86
+ p, r = norm_route(pattern), norm_route((route or "").split("?")[0].split("#")[0])
87
+ if "{" not in p:
88
+ return p == r
89
+ rx = "".join("[^/]+" if part.startswith("{") else re.escape(part) for part in re.split(r"(\{[^}]*\})", p))
90
+ return re.fullmatch(rx, r) is not None
91
+
92
+
93
+ def stem(path):
94
+ return os.path.basename(path or "")[:-5].lower() if (path or "").lower().endswith(".html") else ""
95
+
96
+
97
+ def parity_for(r, mockup, comparisons):
98
+ """The mockup comparison that graded a row's screen: named for the screen, for its mockup file
99
+ (the Mockup parity: line names each screen by its mockup's stem), or opened at the screen's route,
100
+ a {value} in it standing for any one segment. The worst one when several match: one per value
101
+ (Chatur TF-008)."""
102
+ screen, route = (r.get("screen") or "").lower(), r.get("route") or ""
103
+ hits = [s for s in comparisons
104
+ if (s.get("screen") or "").lower() in (screen, stem(mockup))
105
+ or (route and s.get("route") and route_matches(route, s.get("route")))]
106
+ order = {"FAIL": 0, "ERROR": 1, "UNGRADEABLE": 2, "NO-MOCKUP": 3, "PASS": 4}
107
+ return min(hits, key=lambda s: order.get(s.get("verdict"), 5)) if hits else None
108
+
109
+
110
+ def quoted(s, n=None):
111
+ """A test's own words — its title or its failure message — in backticks, so the Remark shows them as
112
+ quoted and tf-doc-check reads them as quoted: a title saying "Found/Not found" failed the checker's
113
+ not-present rule on every verify (Lekhak TF-012)."""
114
+ s = re.sub(r"\s+", " ", (s or "").replace("`", "'").replace("|", "/")).strip()
115
+ return f"`{s[:n] if n else s}`" if s else ""
116
+
117
+
74
118
  def words(s, n=60):
75
119
  w = s.split()
76
120
  return s if len(w) <= n else " ".join(w[:n]) + "…" # the ellipsis rides on the last word: the checker counts words, and a bare "…" was a 61st (miss 29)
@@ -92,15 +136,25 @@ def main(argv):
92
136
  tests_ran = os.path.isfile(os.path.join(d, "tests.json"))
93
137
  screens = load(os.path.join(d, "screens.json")) or {}
94
138
  scr_by_name = {s["name"]: s for s in screens.get("screens", [])}
139
+ # screens tf-verify-screens did not drive because their route needs a sample value (Lekhak TF-005)
140
+ scr_skipped = {s["name"]: s for s in screens.get("skipped", []) or []}
95
141
  assets = load(os.path.join(d, "assets.json")) or {}
96
142
  pages = {norm_route(p.get("path")): p for p in assets.get("pages", [])}
97
143
  parity = load(os.path.join(d, "parity.json")) or {}
98
- par_by_name = {s.get("screen"): s for s in parity.get("screens", [])}
99
- par_by_route = {norm_route(s.get("route")): s for s in parity.get("screens", [])}
144
+ comparisons = parity.get("screens", []) or []
145
+ # the mockup each listed screen is graded against; a row's own *Mockup:* line when its screen has none
146
+ mock_of = {s.get("name"): s.get("mockup") or "" for s in lst.get("screens", []) or []}
100
147
  grade = perf_grader()
101
148
 
102
149
  booted = boot.get("mode") not in (None, "none")
103
150
  build_failed = boot.get("reason_kind") == "build-error"
151
+ # the packages the Architecture requires are in the code (tf_stack_check.py): a model once replaced the
152
+ # mandated UI library with plain components and had every row Verified
153
+ stack = tf_stack_check.check(os.getcwd(), app)
154
+ if stack["verdict"] == "FAIL":
155
+ build_failed = True
156
+ boot = dict(boot, reason=f"the Architecture requires {', '.join(stack['missing'])} and no project file references "
157
+ + ("it" if len(stack["missing"]) == 1 else "them") + " (tf-stack-check.sh): add the package, never a workaround")
104
158
  boot_text = (f"{boot.get('head')} via {boot.get('rung')} at {boot.get('url')}" if booted
105
159
  else f"not booted: {boot.get('reason')}")
106
160
  checks_ran = set()
@@ -114,6 +168,7 @@ def main(argv):
114
168
  notes = []
115
169
  scr = scr_by_name.get(r.get("screen") or "")
116
170
  driven = bool(scr) and scr.get("render") != "UNREACHABLE"
171
+ nonames = " (names not measured on a Mac)" if scr and scr.get("anchors_not_measured") else ""
117
172
  t = tests.get(rid)
118
173
  # A row whose every clause was SKIPPED is NOT-TESTED: the acceptance gate did not run on
119
174
  # it. It is neither a pass nor a defect, so it grades exactly like a row with no test at
@@ -122,6 +177,21 @@ def main(argv):
122
177
  if skipped_only:
123
178
  notes.append(f"acceptance not measured ({len(t.get('skipped') or [])} test(s) skipped, none ran)")
124
179
  t = None
180
+ # the OS zoom keys (coding-standards-dotnet.md §9b): a row whose acceptance carries "zoom-check: keys"
181
+ # or "zoom-check: kept" is graded by tf-verify-zoom.sh --json-out zoom/<REQ>.json, which stands as its
182
+ # test; a failing zoom outranks a passing test, and "kept" counts only a run made after a restart
183
+ if r.get("zoom_check"):
184
+ zf = load(os.path.join(d, "zoom", f"{rid}.json"))
185
+ if zf is None:
186
+ notes.append("zoom not measured (no tf-verify-zoom run for this row)")
187
+ elif r["zoom_check"] == "kept" and not zf.get("kept_checked"):
188
+ notes.append("zoom kept across a restart not measured (run with --expect-restored after a restart)")
189
+ else:
190
+ zfail = [c for c in zf.get("checks", []) if not c.get("ok")]
191
+ zt = {"result": "FAIL" if zfail else "PASS", "tests": [f"tf-verify-zoom {zf.get('head', '')}".strip()],
192
+ "reason": f"zoom: {zfail[0]['text']}" if zfail else "", "screenshot": zf.get("evidence", "")}
193
+ if not t or (zfail and t.get("result") == "PASS"):
194
+ t = zt
125
195
  # every check on its own: (name, ran, failed, verdict, class, detail, evidence); the first failure wins
126
196
  checks = []
127
197
 
@@ -129,7 +199,7 @@ def main(argv):
129
199
  checks.append(("build", booted or build_failed, build_failed, "BUILD-FAIL", "build-error", boot.get("reason", "build failed"), ""))
130
200
  # 2 acceptance
131
201
  if t:
132
- checks.append(("acceptance", True, t["result"] != "PASS", "FAIL", "assert-fail", t.get("reason") or "test failed", t.get("screenshot") or ""))
202
+ checks.append(("acceptance", True, t["result"] != "PASS", "FAIL", "assert-fail", quoted(t.get("reason")) or "test failed", t.get("screenshot") or ""))
133
203
  # 3 render
134
204
  if scr:
135
205
  if scr.get("render") == "UNREACHABLE":
@@ -140,6 +210,10 @@ def main(argv):
140
210
  w, f = bad[0] if bad else (None, None)
141
211
  checks.append(("render", True, bool(bad), "RENDER-FAIL", f["class"] if f else None,
142
212
  f"{f['detail']} on {r['screen']} @{w['width']}" if f else "", w.get("screenshot", "") if w else ""))
213
+ # a Mac web view head: blank, error bar, overlap and the picture were checked, the
214
+ # controls by name were not (owner decision A, 2026-10-07): said, never counted as a pass
215
+ if scr.get("anchors_not_measured") and bad:
216
+ notes.append(f"control names not measured on {r['screen']} (a Mac web view does not show them)")
143
217
  # 4 assets
144
218
  pg = pages.get(norm_route(r.get("route"))) if r.get("route") else None
145
219
  if pg and pg.get("graded", 0) > 0:
@@ -155,16 +229,40 @@ def main(argv):
155
229
  checks.append(("visual", True, bool(bad), "VISUAL-FAIL", f["class"] if f else None,
156
230
  f"{f['detail']} on {r['screen']}" if f else "", w.get("screenshot", "") if w else ""))
157
231
  # 6 mockup parity
158
- ps = (par_by_name.get(r.get("screen")) or par_by_route.get(norm_route(r.get("route")))) if r.get("screen") else None
232
+ mockup = mock_of.get(r.get("screen")) or r.get("mockup") or ""
233
+ ps = parity_for(r, mockup, comparisons) if r.get("screen") else None
234
+ if not ps and driven and mockup and boot.get("mode") == "appium":
235
+ # a Mac head: tf-mockup-parity does not run there (verify-phase step 5), so no comparison is
236
+ # expected, and the row is never failed for its absence; said, not counted as a pass
237
+ notes.append("mockup not compared on a Mac head (compare it on the web or Windows head)")
238
+ elif not ps and driven and mockup:
239
+ # Chatur TF-008: /processes/6/run was compared and FAILed, but the row's route reads
240
+ # /processes/{id}/run and the comparison was named process-run, so it was never found and
241
+ # 11 rows were Verified without it. A driven screen that has a mockup and no comparison fails.
242
+ checks.append(("mockup-parity", True, True, "MOCKUP-FAIL", "other",
243
+ f"mockup-parity not found: {r['screen']} ({r.get('route')}) has mockup {mockup} and "
244
+ + ("parity.json has no comparison for it" if comparisons else "no mockup comparison ran")
245
+ + f"; run tf-mockup-parity.sh --screen {stem(mockup) or 'name'}={r.get('route')} with its value filled in",
246
+ os.path.join(d, "parity.json") if comparisons else ""))
159
247
  if ps:
160
248
  v = ps.get("verdict")
161
249
  if v in ("PASS", "FAIL"):
162
250
  f = (ps.get("findings") or [{}])[0]
163
251
  checks.append(("mockup-parity", True, v == "FAIL", "MOCKUP-FAIL", "mockup-drift",
164
- f"{f.get('class', 'drift')} on {f.get('key', ps.get('screen'))} @{f.get('width', '')}: {str(f.get('detail', ''))[:80]}",
252
+ f"{f.get('class', 'drift')} on {f.get('key', ps.get('screen'))} @{f.get('width', '')}"
253
+ + (f" in {f['theme']} mode" if f.get("theme") else "") + f": {str(f.get('detail', ''))[:80]}",
254
+ os.path.join(d, "parity.json")))
255
+ elif v == "ERROR":
256
+ checks.append(("mockup-parity", True, True, "MOCKUP-FAIL", "other",
257
+ f"mockup-parity could not open {ps.get('route')}: "
258
+ + str(next((w.get("error") for w in ps.get("widths", []) if w.get("error")), "every width failed"))[:80],
165
259
  os.path.join(d, "parity.json")))
166
260
  elif v == "UNGRADEABLE":
167
261
  notes.append("mockup-parity UNGRADEABLE (add data-testid anchors to the mockup)")
262
+ nm = (ps.get("coverage") or {}).get("not_measured") or []
263
+ if nm: # sample rows the app had no data for (Chatur TF-002)
264
+ notes.append(f"mockup-parity: {len(nm)} sample box(es) not measured, the app drew no data there "
265
+ "(a tests/verify/seed/ script puts it in the drawn state)")
168
266
  elif v == "NO-MOCKUP":
169
267
  notes.append("no mockup to compare")
170
268
  # 7 speed
@@ -192,42 +290,62 @@ def main(argv):
192
290
  detail = "NFR row with no perf-budget and no unit test carrying its id"
193
291
  elif not t and r.get("screen") and not driven:
194
292
  verdict = "NOT-DRIVEN"
293
+ sk = scr_skipped.get(r["screen"])
195
294
  detail = (f"{boot.get('head', 'the app')} not driven: {boot.get('reason')}" if not booted
295
+ else f"screen {r['screen']} was not driven: {sk['reason']}" if sk and sk.get("reason")
296
+ else f"screen {r['screen']} was not driven: its route {sk['route']} needs --route-value "
297
+ + ", ".join(f"{n}=<id>" for n in sk.get("needs", [])) if sk
196
298
  else f"screen {r['screen']} was not driven in this run")
197
299
  elif not t:
198
300
  verdict = "NOT-TESTED"
199
301
  detail = (f"every test named {rid} was skipped" if skipped_only else f"no test named {rid} ran") \
200
- + (f"; screen {r['screen']} renders and looks right" if driven else "")
201
- elif r.get("screen") and not driven and not booted:
302
+ + (f"; screen {r['screen']} renders and looks right{nonames}" if driven else "")
303
+ elif r.get("screen") and not driven:
304
+ # a passing test is not a screen check: a row on a screen that was not driven is never
305
+ # Verified, booted or not. The Settings rows of /settings/{tab} were (Chatur TF-003).
202
306
  verdict = "NOT-DRIVEN"
203
- detail = f"test passed but {boot.get('head', 'the app')} was not driven: {boot.get('reason')}"
307
+ sk = scr_skipped.get(r["screen"])
308
+ detail = ("test passed but " + (f"{boot.get('head', 'the app')} was not driven: {boot.get('reason')}" if not booted
309
+ else f"screen {r['screen']} was not driven: {sk['reason']}" if sk and sk.get("reason")
310
+ else f"screen {r['screen']} was not driven: its route {sk['route']} needs --route-value "
311
+ + ", ".join(f"{n}=<id>" for n in sk.get("needs", [])) if sk
312
+ else f"screen {r['screen']} was not driven in this run"))
204
313
  else:
205
314
  verdict = "PASS"
206
- parts = [f"test {t['tests'][0][:60]}" if t and t.get("tests") else "test passed"]
315
+ parts = [f"test {quoted(t['tests'][0], 60)}" if t and t.get("tests") else "test passed"]
207
316
  if driven:
208
- parts.append(f"{r['screen']} renders and looks right @{'/'.join(str(w['width']) for w in scr['widths'])}")
317
+ ws = list(dict.fromkeys(str(w['width']) for w in scr['widths']))
318
+ th = list(dict.fromkeys(w['theme'] for w in scr['widths'] if w.get('theme')))
319
+ parts.append(f"{r['screen']} renders and looks right @{'/'.join(ws)}" + (f" in {' and '.join(th)} mode" if len(th) > 1 else "") + nonames)
209
320
  if "mockup-parity" in ran:
210
321
  parts.append("matches its mockup")
211
322
  if "perf" in ran:
212
323
  parts.append("within its speed budget")
213
324
  detail = "; ".join(parts)
214
325
 
215
- status = STATUS.get(verdict, prior)
326
+ # A defect found by the guide, the owner's testing or production is not in the acceptance line, so
327
+ # a pass cannot clear it and a rewrite must not erase it (TF-021). tests/regression/run.sh am_021.
328
+ kept = tf_defect.unseen(r.get("remarks"))
329
+ if kept and verdict == "PASS":
330
+ verdict = "DEFECT-OPEN"
331
+ status = STATUS.get(verdict, "Needs re-verify" if verdict == "DEFECT-OPEN" else prior)
216
332
  pct = r.get("pct")
217
333
  if verdict == "PASS":
218
334
  pct = 100
219
- elif verdict in ("RENDER-FAIL", "ASSET-FAIL", "VISUAL-FAIL", "MOCKUP-FAIL", "PERF-FAIL"):
335
+ elif verdict in ("DEFECT-OPEN", "RENDER-FAIL", "ASSET-FAIL", "VISUAL-FAIL", "MOCKUP-FAIL", "PERF-FAIL"):
220
336
  pct = min(pct if pct is not None else 75, 75)
221
337
  gate = FAILING.get(verdict)
222
338
  marks = {"FAIL": "⚠ acceptance", "BUILD-FAIL": "⚠ build", "RENDER-FAIL": "⚠ render", "ASSET-FAIL": "⚠ assets",
223
339
  "VISUAL-FAIL": "⚠ visual", "MOCKUP-FAIL": "⚠ mockup-parity", "PERF-FAIL": "⚠ perf",
224
- "NOT-TESTED": "not verified", "NOT-OBSERVABLE": "not observable", "NOT-DRIVEN": "not verified", "PASS": "PASS"}
340
+ "NOT-TESTED": "not verified", "NOT-OBSERVABLE": "not observable", "NOT-DRIVEN": "not verified", "PASS": "PASS",
341
+ "DEFECT-OPEN": "checks pass, defect open"}
225
342
  remark = f"{today} verify: {marks[verdict]} — {detail}"
226
343
  if evidence:
227
344
  remark += f" ({evidence})"
228
345
  if notes:
229
346
  remark += "; " + "; ".join(notes[:2])
230
- remark = words(remark.replace("|", "/"), 60)
347
+ carried = words(" ".join(kept).replace("|", "/"), 30) if kept else ""
348
+ remark = words(remark.replace("|", "/"), 60 - len(carried.split())) + (f" {carried}" if carried else "")
231
349
  emit = verdict == "PASS" or gate is not None
232
350
  row = {"id": rid, "class": r["class"], "verdict": verdict, "gate": gate, "gates_run": ran, "failure_class": cls if gate else None,
233
351
  "prior_verdict": prior or None, "status": status, "pct": pct, "remark": remark, "screen": r.get("screen"),
@@ -240,13 +358,37 @@ def main(argv):
240
358
  for c in ("build", "acceptance", "render", "assets", "visual", "mockup-parity", "perf"):
241
359
  if any(c in x["gates_run"] for x in rows_out):
242
360
  checks.append(c)
243
- ledger = {"date": today, "app": app, "scope": lst.get("scope", "all"), "booted": boot_text, "gates": checks,
244
- "evidence": d, "run_id": started, "rows": ledger_rows}
361
+ # Lekhak TF-007: a targeted verify rewrote the ledger with its own rows only (395 became 1), so every
362
+ # other row lost its last verdict. The ledger is kept row by row: a row this run graded is replaced,
363
+ # with the date and run it was graded in; the rest keep theirs. A scope-all run starts it afresh.
364
+ # The verify guard unlocks a Verified only for a row graded today, and fix-close reads only the rows
365
+ # graded since its fix began, by these dates.
366
+ # Sevak TF-005: every row carries the phase whose checklist it was graded in, and a scope-all run
367
+ # starts afresh only for its own phase. Phase 2's run used to wipe phase 1's verdicts. A row from an
368
+ # older ledger that names no phase is kept.
369
+ phase = lst.get("phase") or 1
370
+ all_rows, row_dates, row_runs = dict(ledger_rows), {k: today for k in ledger_rows}, {k: started for k in ledger_rows}
371
+ row_phases = {k: phase for k in ledger_rows}
372
+ prev = load(os.path.join("docs", ".last-verify.json")) if os.path.isfile(os.path.join("docs", ".last-verify.json")) else None
373
+ if isinstance(prev, dict) and prev.get("app") in (None, app) \
374
+ and isinstance(prev.get("rows"), dict) and prev.get("mode") != "reconcile":
375
+ for k, v in prev["rows"].items():
376
+ was = (prev.get("row_phases") or {}).get(k, prev.get("phase"))
377
+ if k not in all_rows and (lst.get("scope", "all") != "all" or was != phase):
378
+ all_rows[k] = v
379
+ row_dates[k] = (prev.get("row_dates") or {}).get(k, prev.get("date", ""))
380
+ row_runs[k] = (prev.get("row_runs") or {}).get(k, prev.get("run_id", ""))
381
+ if was is not None:
382
+ row_phases[k] = was
383
+ ledger = {"date": today, "app": app, "scope": lst.get("scope", "all"), "phase": phase, "checklist": lst.get("checklist"),
384
+ "booted": boot_text, "gates": checks,
385
+ "evidence": d, "run_id": started, "rows": all_rows, "row_dates": row_dates, "row_runs": row_runs,
386
+ "row_phases": row_phases, "this_run": sorted(ledger_rows)}
245
387
  os.makedirs("docs", exist_ok=True)
246
388
  with open(os.path.join("docs", ".last-verify.json"), "w", encoding="utf-8") as f:
247
389
  json.dump(ledger, f, indent=1)
248
390
  with open(os.path.join(d, "verdicts.json"), "w", encoding="utf-8") as f:
249
- json.dump({"app": app, "scope": lst.get("scope", "all"), "date": today, "started": started, "booted": boot_text,
391
+ json.dump({"app": app, "scope": lst.get("scope", "all"), "phase": phase, "date": today, "started": started, "booted": boot_text,
250
392
  "build_result": "fail" if build_failed else ("pass" if booted else "not-run"), "checks": checks,
251
393
  "checklist": lst.get("checklist"), "rows": rows_out}, f, indent=1)
252
394
 
@@ -275,7 +417,7 @@ def main(argv):
275
417
  counts = {}
276
418
  for x in rows_out:
277
419
  counts[x["verdict"]] = counts.get(x["verdict"], 0) + 1
278
- print(f"# tf-verify-verdict — {app} — scope {lst.get('scope')} — booted: {boot_text}")
420
+ print(f"# tf-verify-verdict — {app} — phase {phase} ({lst.get('checklist')}) — scope {lst.get('scope')} — booted: {boot_text}")
279
421
  print(f"Checks that ran: {', '.join(checks) or 'none'}. Rows: " + ", ".join(f"{v} {k}" for k, v in sorted(counts.items())))
280
422
  print()
281
423
  print("| ID | Verdict | First failing check | Status | Detail |")
@@ -283,7 +425,7 @@ def main(argv):
283
425
  for t in table:
284
426
  print("| " + " | ".join(str(x).replace("|", "/") for x in t) + " |")
285
427
  print()
286
- print(f"Ledger: docs/.last-verify.json ({len(ledger_rows)} rows). Verdicts: {os.path.join(d, 'verdicts.json')}."
428
+ print(f"Ledger: docs/.last-verify.json ({len(ledger_rows)} rows graded now, {len(all_rows)} in all). Verdicts: {os.path.join(d, 'verdicts.json')}."
287
429
  + (f" Checklist: {applied} row(s) rewritten in {lst.get('checklist')}." if apply else " Checklist untouched (no --apply)."))
288
430
  return 0
289
431
 
@@ -0,0 +1,193 @@
1
+ // tf-verify-zoom.mjs — presses the OS zoom keys on a Blazor Hybrid head and measures what they did
2
+ // (coding-standards-dotnet.md §9b; Lekhak, 2026-10-11). Called by tf-verify-zoom.sh; see there.
3
+ //
4
+ // Mac (--appium): mac2 presses Cmd+= / Cmd+- / Cmd+0 as real keys, so they go through the menu bar the
5
+ // way a person's do; macOS takes the window alone after each. The accessibility tree does not follow a
6
+ // WKWebView's page zoom (its text heights stayed 13 px at 130% on BlogAdmin), so the level is measured
7
+ // from the pictures: the scale that best maps the actual-size picture onto the zoomed one, from the
8
+ // first row where the two differ (the web view's top edge; the title bar above it does not zoom).
9
+ // Windows and any Chromium web view (--cdp): Playwright presses Ctrl+= / Ctrl+- / Ctrl+0 in the page
10
+ // and reads the zoom it paints at (CSS zoom on <html> times the device pixel ratio).
11
+
12
+ import { execFileSync } from 'node:child_process';
13
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
14
+ import { inflateSync } from 'node:zlib';
15
+ import { join } from 'node:path';
16
+
17
+ const args = process.argv.slice(2);
18
+ const opt = (k, d = '') => { const i = args.indexOf(k); return i >= 0 && i + 1 < args.length ? args[i + 1] : d; };
19
+ const flag = (k) => args.includes(k);
20
+ const OUT = opt('--out', 'tests/.artifacts/verify/zoom');
21
+ const LEAVE = parseInt(opt('--leave', '0'), 10) || 0;
22
+ const STEPS = [
23
+ // what is pressed, how often, the level expected afterwards (1.0 = actual size)
24
+ { key: 'reset', n: 1, want: 1.0, say: 'actual size' },
25
+ { key: 'in', n: 3, want: 1.3, say: 'zoom in 3 times' },
26
+ { key: 'out', n: 1, want: 1.2, say: 'zoom out once' },
27
+ { key: 'reset', n: 1, want: 1.0, say: 'actual size again' },
28
+ ];
29
+ const TOL = 0.05;
30
+
31
+ // ---- PNG, 8-bit RGB or RGBA, not interlaced: what screencapture and sips write -------------------
32
+ export function decodePng(buf) {
33
+ let p = 8, w = 0, h = 0, ct = 0; const idat = [];
34
+ while (p < buf.length) {
35
+ const len = buf.readUInt32BE(p); const type = buf.toString('latin1', p + 4, p + 8);
36
+ const data = buf.subarray(p + 8, p + 8 + len);
37
+ if (type === 'IHDR') { w = data.readUInt32BE(0); h = data.readUInt32BE(4); ct = data[9]; if (data[8] !== 8 || data[12]) throw new Error('only 8-bit, non-interlaced PNG'); }
38
+ if (type === 'IDAT') idat.push(data);
39
+ if (type === 'IEND') break;
40
+ p += 12 + len;
41
+ }
42
+ const bpp = ct === 6 ? 4 : ct === 2 ? 3 : ct === 0 ? 1 : ct === 4 ? 2 : 0;
43
+ if (!bpp) throw new Error(`PNG colour type ${ct} not read`);
44
+ const raw = inflateSync(Buffer.concat(idat)); const stride = w * bpp;
45
+ const px = Buffer.alloc(h * stride); const gray = new Float32Array(w * h);
46
+ for (let y = 0; y < h; y++) {
47
+ const f = raw[y * (stride + 1)]; const src = y * (stride + 1) + 1; const row = y * stride;
48
+ for (let x = 0; x < stride; x++) {
49
+ const a = x >= bpp ? px[row + x - bpp] : 0; const b = y ? px[row - stride + x] : 0; const c = x >= bpp && y ? px[row - stride + x - bpp] : 0;
50
+ let v = raw[src + x];
51
+ if (f === 1) v += a; else if (f === 2) v += b; else if (f === 3) v += (a + b) >> 1;
52
+ else if (f === 4) { const pp = a + b - c; const pa = Math.abs(pp - a), pb = Math.abs(pp - b), pc = Math.abs(pp - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
53
+ px[row + x] = v & 255;
54
+ }
55
+ for (let x = 0; x < w; x++) { const o = row + x * bpp; gray[y * w + x] = bpp >= 3 ? 0.299 * px[o] + 0.587 * px[o + 1] + 0.114 * px[o + 2] : px[o]; }
56
+ }
57
+ return { w, h, gray };
58
+ }
59
+
60
+ // a picture shrunk to 640 px wide by sips (macOS), so the search stays quick
61
+ function load(file) {
62
+ const small = file.replace(/\.png$/, '.small.png');
63
+ try { execFileSync('sips', ['-Z', '1000', file, '--out', small], { stdio: 'ignore' }); } catch { /* not macOS: read it whole */ }
64
+ return decodePng(readFileSync(existsSync(small) ? small : file));
65
+ }
66
+
67
+ // The page zoom that turns picture `base` (actual size) into picture `zoomed`. Page zoom scales every
68
+ // horizontal position from the web view's left edge, but not always vertical ones: a header or sidebar
69
+ // sized to the window height keeps its height (BlogAdmin's did), so a 2-D match read 121% for 130%.
70
+ // So the two pictures are compared across the window only: for each column, how much vertical edge
71
+ // (a border, a box side, the strokes of text) it holds below the title bar, and the scale that best
72
+ // lines the actual-size profile up with the zoomed one. The title bar is the rows the two share.
73
+ export function scaleBetween(zoomed, base) {
74
+ const { w, h } = base; if (zoomed.w !== w || zoomed.h !== h) return { scale: 0, why: 'the two pictures differ in size' };
75
+ let top = 0;
76
+ for (; top < h; top++) { let d = 0; for (let x = 0; x < w; x++) d += Math.abs(zoomed.gray[top * w + x] - base.gray[top * w + x]); if (d / w > 2) break; }
77
+ if (top >= h - 10) return { scale: 1, top, why: 'the pictures are the same' };
78
+ const profile = (img) => {
79
+ const p = new Float64Array(w);
80
+ for (let y = top; y < h; y++) for (let x = 0; x + 1 < w; x++) p[x] += Math.abs(img.gray[y * w + x + 1] - img.gray[y * w + x]);
81
+ return p;
82
+ };
83
+ const pz = profile(zoomed), pb = profile(base);
84
+ let best = { scale: 0, r: -2 };
85
+ for (let s = 0.5; s <= 3.0001; s += 0.01) {
86
+ let n = 0, sa = 0, sb = 0, saa = 0, sbb = 0, sab = 0;
87
+ for (let x = 0; x < w; x++) {
88
+ const bx = x / s; const i = Math.floor(bx); if (i + 1 >= w) break;
89
+ const a = pz[x], b = pb[i] + (pb[i + 1] - pb[i]) * (bx - i);
90
+ n++; sa += a; sb += b; saa += a * a; sbb += b * b; sab += a * b;
91
+ }
92
+ if (n < w / 4) continue;
93
+ const r = (sab - (sa * sb) / n) / Math.sqrt(Math.max(1e-9, (saa - (sa * sa) / n) * (sbb - (sb * sb) / n)));
94
+ if (r > best.r) best = { scale: Math.round(s * 100) / 100, r };
95
+ }
96
+ return { scale: best.scale, top };
97
+ }
98
+
99
+ const RESULT = { checks: [], kept_checked: false, head: '', evidence: '' };
100
+ const line = (ok, text) => { console.log(`${ok ? 'OK ' : 'FAIL'} ${text}`); RESULT.checks.push({ ok, text }); return ok; };
101
+
102
+ async function mac() {
103
+ const { openSession, parseSource, appPid, windowShot } = await import('./tf-appium.mjs');
104
+ let boot = {}; try { boot = JSON.parse(readFileSync('tests/.artifacts/verify/boot.json', 'utf8')); } catch { /* flags only */ }
105
+ const appPath = opt('--app', boot.app_path || ''); const bundleId = opt('--bundle', boot.bundle || '');
106
+ let s;
107
+ try { s = await openSession(opt('--appium'), { bundleId, appPath }); }
108
+ catch (e) { line(false, `no session on the app at ${opt('--appium')}: ${e.message} (boot it with bash .tfcore/utils/tf-verify-boot.sh start --head maccatalyst)`); return 2; }
109
+ const pid = appPid(appPath);
110
+ let ok = true; let k = 0;
111
+ const shot = (name, same = false) => { const f = join(OUT, `${String(same ? k : k++).padStart(2, '0')}-${name}-mac.png`); return windowShot(pid, f) ? f : ''; };
112
+ const press = async (key) => {
113
+ await s.call('POST', '/execute/sync', { script: 'macos: keys', args: [{ keys: [{ key: { in: '=', out: '-', reset: '0' }[key], modifierFlags: 1 << 4 }] }] });
114
+ await new Promise((r) => setTimeout(r, 700));
115
+ };
116
+ // the View menu names the three commands
117
+ const nodes = parseSource(await s.source());
118
+ const view = nodes.find((n) => n.type === 'MenuBarItem' && /^view$/i.test(n.title || n.label));
119
+ const items = []; if (view) { const todo = [...view.kids]; while (todo.length) { const n = nodes[todo.shift()]; if (n.type === 'MenuItem') items.push(n.title || n.label); todo.push(...n.kids); } }
120
+ const missing = ['Zoom In', 'Zoom Out', 'Actual Size'].filter((t) => !items.includes(t));
121
+ ok = line(!missing.length, missing.length ? `the View menu has no ${missing.join(', ')} (it has: ${items.filter(Boolean).join(', ') || 'no View menu'})` : 'View › Zoom In, Zoom Out, Actual Size are in the menu bar') && ok;
122
+ // A mac2 session starts the app again: wait for a drawn page that holds still for two pictures
123
+ // (the first ones on BlogAdmin were the bare window while Blazor started)
124
+ const ink = (f) => { const g = load(f); let e = 0; for (let i = 1; i < g.gray.length; i++) e += Math.abs(g.gray[i] - g.gray[i - 1]); return e / g.gray.length; };
125
+ let opened = ''; let last = -1;
126
+ for (let t = Date.now(); Date.now() - t < 60000;) {
127
+ const f = shot('as-opened', true); const e = f ? ink(f) : 0; // a bare window reads 0.04, a drawn page 0.96
128
+ if (e > 0.2 && last > 0 && Math.abs(e - last) / last < 0.02) { opened = f; break; }
129
+ last = e; await new Promise((r) => setTimeout(r, 1500));
130
+ }
131
+ k++;
132
+ if (!opened) { await s.close(); line(false, 'the app showed no drawn page within 60 s'); return 2; }
133
+ await press('reset'); const actual = shot('actual-size');
134
+ if (!opened || !actual) { await s.close(); return line(false, 'could not take the window (the terminal needs Screen Recording)') ? 0 : 5; }
135
+ RESULT.head = 'maccatalyst'; RESULT.evidence = actual;
136
+ const base = load(actual);
137
+ const restored = scaleBetween(load(opened), base).scale;
138
+ console.log(`INFO opened at ${Math.round(restored * 100)}% (the level the last run left; run again after a restart with --leave 3 before it to check the level is kept)`);
139
+ if (flag('--expect-restored')) RESULT.kept_checked = true;
140
+ if (flag('--expect-restored')) ok = line(Math.abs(restored - parseFloat(opt('--expect-restored'))) <= TOL, `opened at ${Math.round(restored * 100)}%, expected ${Math.round(parseFloat(opt('--expect-restored')) * 100)}% from the last run`) && ok;
141
+ for (const st of STEPS.slice(1)) {
142
+ for (let i = 0; i < st.n; i++) await press(st.key);
143
+ const f = shot(st.key); const got = f ? scaleBetween(load(f), base).scale : 0;
144
+ ok = line(Math.abs(got - st.want) <= TOL, `${st.say} (Cmd+${{ in: '=', out: '-', reset: '0' }[st.key]}): ${Math.round(got * 100)}%, expected ${Math.round(st.want * 100)}%`) && ok;
145
+ }
146
+ for (let i = 0; i < LEAVE; i++) await press('in');
147
+ if (LEAVE) console.log(`INFO left at ${100 + LEAVE * 10}% for the restart check`);
148
+ await s.close();
149
+ return ok ? 0 : 5;
150
+ }
151
+
152
+ async function cdp() {
153
+ const { chromium } = await import('playwright');
154
+ let browser;
155
+ try { browser = await chromium.connectOverCDP(opt('--cdp')); }
156
+ catch (e) { line(false, `nothing answers at ${opt('--cdp')}: ${e.message.split('\n')[0]}`); return 2; }
157
+ const page = browser.contexts().flatMap((c) => c.pages()).find((p) => !p.url().startsWith('devtools:')) || null;
158
+ if (!page) { line(false, 'no page to drive at ' + opt('--cdp')); return 2; }
159
+ const level = () => page.evaluate(() => (parseFloat(getComputedStyle(document.documentElement).zoom) || 1) * window.devicePixelRatio);
160
+ let ok = true; let k = 0;
161
+ const press = async (key) => { await page.keyboard.press({ in: 'Control+Equal', out: 'Control+Minus', reset: 'Control+Digit0' }[key]); await page.waitForTimeout(400); };
162
+ RESULT.head = 'cdp';
163
+ const opened = await level(); await press('reset'); const base = await level();
164
+ console.log(`INFO opened at ${Math.round((opened / base) * 100)}% (the level the last run left)`);
165
+ if (flag('--expect-restored')) RESULT.kept_checked = true;
166
+ if (flag('--expect-restored')) ok = line(Math.abs(opened / base - parseFloat(opt('--expect-restored'))) <= TOL, `opened at ${Math.round((opened / base) * 100)}%, expected ${Math.round(parseFloat(opt('--expect-restored')) * 100)}%`) && ok;
167
+ for (const st of STEPS.slice(1)) {
168
+ for (let i = 0; i < st.n; i++) await press(st.key);
169
+ await page.screenshot({ path: join(OUT, `${String(k++).padStart(2, '0')}-${st.key}-cdp.png`) }).catch(() => {});
170
+ const got = (await level()) / base;
171
+ ok = line(Math.abs(got - st.want) <= TOL, `${st.say} (Ctrl+${{ in: '=', out: '-', reset: '0' }[st.key]}): ${Math.round(got * 100)}%, expected ${Math.round(st.want * 100)}%`) && ok;
172
+ }
173
+ // Ctrl+wheel up is one step in
174
+ await page.mouse.move(200, 200); await page.keyboard.down('Control'); await page.mouse.wheel(0, -100); await page.keyboard.up('Control'); await page.waitForTimeout(400);
175
+ const wheel = (await level()) / base;
176
+ ok = line(Math.abs(wheel - 1.1) <= TOL, `Ctrl+wheel up: ${Math.round(wheel * 100)}%, expected 110%`) && ok;
177
+ await press('reset');
178
+ for (let i = 0; i < LEAVE; i++) await press('in');
179
+ if (LEAVE) console.log(`INFO left at ${100 + LEAVE * 10}% for the restart check`);
180
+ await browser.close().catch(() => {});
181
+ return ok ? 0 : 5;
182
+ }
183
+
184
+ if (process.argv[1] && process.argv[1].endsWith('tf-verify-zoom.mjs')) {
185
+ if (flag('--scale')) { // tf-verify-zoom.mjs --scale <zoomed.png> <actual.png>: the measure alone
186
+ console.log(scaleBetween(load(args[args.indexOf('--scale') + 1]), load(args[args.indexOf('--scale') + 2])).scale);
187
+ process.exit(0);
188
+ }
189
+ mkdirSync(OUT, { recursive: true });
190
+ const rc = flag('--appium') ? await mac() : flag('--cdp') ? await cdp() : (console.error('tf-verify-zoom: --appium <url> or --cdp <url>'), 3);
191
+ if (opt('--json-out')) writeFileSync(opt('--json-out'), JSON.stringify({ ...RESULT, verdict: rc === 0 ? 'PASS' : 'FAIL' }, null, 1));
192
+ process.exit(rc);
193
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # tf-verify-zoom.sh — presses the OS zoom keys on a booted Blazor Hybrid head and says what they did
3
+ # (coding-standards-dotnet.md §9b; Lekhak, 2026-10-11).
4
+ #
5
+ # bash .tfcore/utils/tf-verify-zoom.sh --appium http://localhost:4723 [--leave 3] [--expect-restored 1.3]
6
+ # a Mac Catalyst head booted by tf-verify-boot.sh (its .app and bundle come from boot.json)
7
+ # bash .tfcore/utils/tf-verify-zoom.sh --cdp <the boot url> [--leave 3] [--expect-restored 1.3]
8
+ # a Windows (WebView2) head, or any Chromium web view, over its debugging address
9
+ #
10
+ # Checks, in order: (Mac) View › Zoom In, Zoom Out and Actual Size are in the menu bar; the level the
11
+ # app opened at is printed, then Cmd/Ctrl+0 is actual size; zoom in 3 times reads 130%, zoom out once
12
+ # 120%, actual size 100%; (Windows) Ctrl+wheel up reads 110%. The kept level across a restart is two
13
+ # runs: the first with --leave 3 (it ends at 130%), then stop and start the head, then the second with
14
+ # --expect-restored 1.3. Pictures go to tests/.artifacts/verify/zoom/. The Mac level is measured from
15
+ # the window's pictures, because a WKWebView's accessibility tree does not follow its page zoom.
16
+ # Exit 0 every check passed · 5 one failed · 2 nothing to drive · 3 usage · 4 Playwright missing (--cdp).
17
+ set -uo pipefail
18
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ case "${1:-}" in
20
+ -h|--help) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
21
+ "") sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 3 ;;
22
+ esac
23
+ command -v node >/dev/null 2>&1 || { echo "tf-verify-zoom: node is required (bash .tfcore/utils/tf-verify-env.sh)" >&2; exit 4; }
24
+ case " $* " in *" --cdp "*)
25
+ node -e "import('playwright').then(()=>process.exit(0)).catch(()=>process.exit(1))" 2>/dev/null || {
26
+ echo "tf-verify-zoom: the 'playwright' package is not resolvable from this folder; run bash .tfcore/utils/tf-verify-env.sh" >&2; exit 4; } ;;
27
+ esac
28
+ source "$HERE/tf-lock.sh"; tf_take_lock verify-screens # one check drives the app at a time
29
+ node "$HERE/tf-verify-zoom.mjs" "$@"