@techierathore/techieflow 1.0.4 → 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 (98) hide show
  1. package/.claude/commands/TechieFlow/tasks/_owner-language.md +1 -0
  2. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
  3. package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
  4. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +1 -1
  5. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +1 -1
  6. package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
  7. package/.claude/commands/TechieFlow/tasks/generate-html.md +2 -2
  8. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +2 -2
  9. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  10. package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
  11. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
  12. package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
  13. package/.opencode/plugin/techieflow.js +179 -7
  14. package/.tfcore/hooks/block-git.sh +4 -2
  15. package/.tfcore/hooks/guard-build.sh +5 -1
  16. package/.tfcore/hooks/guard-db.sh +6 -4
  17. package/.tfcore/hooks/guard-status-html.sh +86 -2
  18. package/.tfcore/hooks/guard-verify.sh +8 -3
  19. package/.tfcore/hooks/sweep-artifacts.sh +46 -0
  20. package/.tfcore/standards/owner-words.txt +23 -0
  21. package/.tfcore/tasks/_owner-language.md +1 -0
  22. package/.tfcore/tasks/_status-update-gate.md +4 -3
  23. package/.tfcore/tasks/amend-docs.md +1 -1
  24. package/.tfcore/tasks/day1-brownfield.md +1 -1
  25. package/.tfcore/tasks/day1-greenfield.md +1 -1
  26. package/.tfcore/tasks/fix-issues.md +2 -2
  27. package/.tfcore/tasks/generate-html.md +2 -2
  28. package/.tfcore/tasks/handoff-phase.md +2 -2
  29. package/.tfcore/tasks/mockups.md +1 -1
  30. package/.tfcore/tasks/productguide.md +2 -2
  31. package/.tfcore/tasks/render-workflow-docs.md +1 -1
  32. package/.tfcore/tasks/triage-issues.md +2 -2
  33. package/.tfcore/telemetry/SCHEMA.md +4 -0
  34. package/.tfcore/telemetry/install-metrics.sh +3 -2
  35. package/.tfcore/telemetry/tf-metrics.sh +19 -2
  36. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
  37. package/.tfcore/templates/v4custom/app-brd-tmpl.md +5 -3
  38. package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
  39. package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
  40. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
  41. package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
  42. package/.tfcore/templates/v4custom/html-render-shell.md +4 -2
  43. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  44. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  45. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  46. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  47. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  48. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  49. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  50. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  51. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
  52. package/.tfcore/utils/tf-assets-browser.mjs +96 -0
  53. package/.tfcore/utils/tf-assets.sh +97 -12
  54. package/.tfcore/utils/tf-brd-status.py +14 -5
  55. package/.tfcore/utils/tf-build-list.py +80 -12
  56. package/.tfcore/utils/tf-build-list.sh +2 -1
  57. package/.tfcore/utils/tf-build.sh +88 -15
  58. package/.tfcore/utils/tf-ci-repro.py +685 -0
  59. package/.tfcore/utils/tf-ci-repro.sh +24 -0
  60. package/.tfcore/utils/tf-devguide-list.py +47 -22
  61. package/.tfcore/utils/tf-doc-check.py +136 -6
  62. package/.tfcore/utils/tf-doc-tests.sh +79 -0
  63. package/.tfcore/utils/tf-emit.sh +92 -35
  64. package/.tfcore/utils/tf-feedback.sh +13 -0
  65. package/.tfcore/utils/tf-fix-close.sh +79 -29
  66. package/.tfcore/utils/tf-goal.sh +12 -12
  67. package/.tfcore/utils/tf-harness.sh +9 -4
  68. package/.tfcore/utils/tf-lock.sh +29 -0
  69. package/.tfcore/utils/tf-log-miss.py +34 -8
  70. package/.tfcore/utils/tf-login.mjs +167 -0
  71. package/.tfcore/utils/tf-misses-md.py +10 -4
  72. package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
  73. package/.tfcore/utils/tf-mockup-parity.sh +14 -2
  74. package/.tfcore/utils/tf-owner-text.py +233 -0
  75. package/.tfcore/utils/tf-owner-text.sh +14 -0
  76. package/.tfcore/utils/tf-phase.sh +15 -1
  77. package/.tfcore/utils/tf-portable.sh +230 -0
  78. package/.tfcore/utils/tf-render-html.py +60 -2
  79. package/.tfcore/utils/tf-selfcheck.sh +39 -6
  80. package/.tfcore/utils/tf-split-brd.py +11 -1
  81. package/.tfcore/utils/tf-status-evidence.sh +11 -9
  82. package/.tfcore/utils/tf-status-facts.py +72 -32
  83. package/.tfcore/utils/tf-triage.py +62 -16
  84. package/.tfcore/utils/tf-triage.sh +2 -0
  85. package/.tfcore/utils/tf-verify-boot.sh +263 -43
  86. package/.tfcore/utils/tf-verify-emit.sh +20 -0
  87. package/.tfcore/utils/tf-verify-env.sh +33 -4
  88. package/.tfcore/utils/tf-verify-list.py +75 -8
  89. package/.tfcore/utils/tf-verify-screens.mjs +171 -32
  90. package/.tfcore/utils/tf-verify-screens.sh +8 -3
  91. package/.tfcore/utils/tf-verify-tests.sh +215 -14
  92. package/.tfcore/utils/tf-verify-verdict.py +48 -9
  93. package/.tfcore/utils/tf_defect.py +27 -0
  94. package/.tfcore/utils/tf_feedback.py +209 -0
  95. package/.tfcore/utils/tf_roadmap.py +35 -0
  96. package/README.md +4 -0
  97. package/docs/TechieFlow-Installation.md +5 -3
  98. package/package.json +1 -1
@@ -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,7 +2,8 @@
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
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
6
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
9
  // no horizontal overflow; the page has a stylesheet. Overlap and off-screen are measured
@@ -58,26 +59,112 @@ if (!BASE && !CDP) { console.error('tf-verify-screens: --base URL or --cdp URL i
58
59
 
59
60
  // ---------------------------------------------------------------- screens
60
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 = {};
61
68
  if (LIST) {
62
69
  const l = JSON.parse(readFileSync(LIST, 'utf8'));
63
- 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 || {} }));
64
72
  }
65
73
  for (const s of args('--screen')) {
66
74
  const m = s.match(/^([^=]+)=(.+)$/);
67
75
  if (m) screens.push({ name: m[1], route: m[2], mockup: `${MOCKUPS}/${m[1].toLowerCase().replace(/[^a-z0-9]+/g, '-')}.html`, rows: [] });
68
76
  }
69
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(' ')}`);
70
101
 
71
102
  const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
72
103
  const anchorsOf = (mockup) => {
73
104
  if (!mockup || !existsSync(mockup)) return null;
74
- const html = readFileSync(mockup, 'utf8');
75
- 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');
76
112
  const out = new Set(); let m;
77
113
  while ((m = re.exec(html))) out.add(m[1]);
78
114
  return [...out];
79
115
  };
80
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
+
81
168
  // ---------------------------------------------------------------- browser
82
169
  let browser, cdpPage = null;
83
170
  if (CDP) {
@@ -89,13 +176,17 @@ if (CDP) {
89
176
  } else {
90
177
  browser = await chromium.launch();
91
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;
92
181
 
93
182
  function writeOut(extra, results = [], login = null) {
94
183
  const summary = {
95
184
  mode: CDP ? 'cdp' : 'base', base: BASE || CDP, widths: WIDTHS, login,
96
185
  screens: results,
186
+ skipped, // screens not driven because their route needs a value (TF-005)
97
187
  summary: {
98
188
  screens: results.length,
189
+ skipped: skipped.length,
99
190
  render_ok: results.filter((r) => r.render === 'OK').length,
100
191
  render_fail: results.filter((r) => r.render === 'EMPTY' || r.render === 'ERROR').length,
101
192
  unreachable: results.filter((r) => r.render === 'UNREACHABLE').length,
@@ -109,28 +200,18 @@ function writeOut(extra, results = [], login = null) {
109
200
  return summary;
110
201
  }
111
202
 
112
- async function login(page) {
113
- if (!LOGIN_PATH || !USER) return { attempted: false };
114
- try {
115
- await page.goto(BASE + LOGIN_PATH, { waitUntil: 'domcontentloaded', timeout: 30000 });
116
- 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();
117
- const pass = page.locator(`[${ATTR}*="pass" i], input[type="password"]`).first();
118
- const btn = page.locator(`[${ATTR}*="login" i], [${ATTR}*="signin" i], [${ATTR}*="submit" i], button[type="submit"], form button, input[type="submit"]`).first();
119
- await user.fill(USER, { timeout: 10000 });
120
- await pass.fill(PASS || '', { timeout: 10000 });
121
- await Promise.all([page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}), btn.click({ timeout: 10000 })]);
122
- await page.waitForTimeout(SETTLE);
123
- const url = page.url();
124
- const still = url.startsWith(BASE + LOGIN_PATH);
125
- return { attempted: true, ok: !still, url };
126
- } catch (e) {
127
- return { attempted: true, ok: false, error: e.message.split('\n')[0] };
128
- }
129
- }
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);
130
209
 
131
210
  async function contextFor(width) {
132
211
  if (CDP) { await cdpPage.setViewportSize({ width, height: width < 600 ? 844 : 800 }).catch(() => {}); return { page: cdpPage, close: async () => {} }; }
133
- 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 };
134
215
  if (STORAGE && existsSync(STORAGE)) opts.storageState = STORAGE;
135
216
  const ctx = await browser.newContext(opts);
136
217
  if (COOKIE) {
@@ -157,6 +238,13 @@ async function navigate(page, route) {
157
238
  } catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
158
239
  }
159
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
+
160
248
  // Wait for the screen's first render before measuring anything. Returns the milliseconds waited.
161
249
  // It never decides anything: when nothing appears within RENDER_WAIT the page is graded exactly as
162
250
  // it stands, so a control that is genuinely missing is still reported (TF-021).
@@ -246,8 +334,20 @@ async function inspect(page, anchors, attr, errorSelectors) {
246
334
  els.forEach((el, i) => {
247
335
  const r = el.getBoundingClientRect();
248
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
+ }
249
349
  out.boxes.push({ i, id: el.getAttribute(attr) || '', tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || '').trim().slice(0, 40),
250
- 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) });
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 });
251
351
  });
252
352
  // ancestry so a button inside its card is never an "overlap"
253
353
  out.rel = [];
@@ -258,15 +358,18 @@ async function inspect(page, anchors, attr, errorSelectors) {
258
358
  }, { anchors, attr, errorSelectors });
259
359
  }
260
360
 
261
- function grade(info, consoleErrors, width) {
361
+ function grade(info, consoleErrors, width, excused = [], stateOnly = []) {
262
362
  const findings = [];
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)) };
263
366
  if (info.error_banner) findings.push({ check: 'render', class: 'exception', detail: `the application's error banner is showing (${info.error_banner})` });
264
367
  // a failed fetch ("Failed to load resource … 404") is the assets check's finding, not a page error
265
368
  const pageErrors = consoleErrors.filter((e) => !/^Failed to load resource/i.test(e));
266
369
  for (const e of pageErrors.slice(0, 3)) findings.push({ check: 'render', class: 'exception', detail: `console error: ${e.slice(0, 120)}` });
267
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)` });
268
371
  for (const a of info.anchors) {
269
- 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` });
270
373
  else if (a.visible && !a.filled) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" (${a.tag}) is empty` });
271
374
  }
272
375
  for (const t of info.tables) {
@@ -291,7 +394,18 @@ function grade(info, consoleErrors, width) {
291
394
  const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
292
395
  const iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
293
396
  if (ix <= 4 || iy <= 4) continue;
294
- 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
+ }
295
409
  if (inter / small > 0.9) continue; // one lies inside the other by design (a badge on a card)
296
410
  if (inter / small < 0.25) continue; // a touch, not an overlap
297
411
  overlaps++;
@@ -317,20 +431,44 @@ for (const width of WIDTHS) {
317
431
  if (!CDP) { const l = await login(page); if (loginResult === null) loginResult = l; }
318
432
  for (const s of screens) {
319
433
  let r = results.find((x) => x.name === s.name);
320
- 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 || [];
321
442
  consoleErrors.length = 0;
322
- const nav = await navigate(page, s.route);
443
+ let nav = await navigate(page, s.route);
323
444
  const shot = `${SHOTS}/${slug(s.name)}-${width}.png`;
324
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
+ }
325
461
  const redirectedToLogin = LOGIN_PATH && nav.url && new URL(nav.url).pathname.startsWith(LOGIN_PATH) && !s.route.startsWith(LOGIN_PATH);
326
462
  if (nav.status === 0 || nav.status >= 400 || redirectedToLogin) {
327
463
  entry.render = 'UNREACHABLE'; entry.visual = 'n/a';
328
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}` }];
329
465
  if (nav.status === 0 && results.length === 1 && width === WIDTHS[0]) firstUnreachable = true;
330
466
  } else {
331
- entry.render_wait_ms = await waitForRender(page, r.anchors || [], ATTR);
467
+ entry.render_wait_ms = await waitForRender(page, (r.anchors || []).filter((id) => !excused.includes(id) && !stateOnly.includes(id)), ATTR);
332
468
  const info = await inspect(page, r.anchors || [], ATTR, ERROR_SELECTORS);
333
- const g = grade(info, consoleErrors, width);
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;
334
472
  Object.assign(entry, g);
335
473
  entry.console_errors = consoleErrors.slice(0, 5);
336
474
  entry.anchors_present = (info.anchors || []).filter((a) => a.present).length;
@@ -350,6 +488,7 @@ for (const r of results) {
350
488
  }
351
489
  const out = writeOut({}, results, loginResult);
352
490
  if (loginResult && loginResult.attempted && !loginResult.ok) console.log(`LOGIN failed at ${BASE}${LOGIN_PATH}: ${loginResult.error || 'still on the sign-in page'}`);
353
- 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(() => {});
354
493
  if (!CDP) await browser.close(); else await browser.close().catch(() => {});
355
494
  process.exit(firstUnreachable && out.summary.unreachable === out.summary.screens ? 2 : (out.summary.render_fail + out.summary.visual_fail + out.summary.unreachable) ? 5 : 0);
@@ -7,6 +7,10 @@
7
7
  # bash .tfcore/utils/tf-verify-screens.sh --list … --cdp http://172.18.144.1:9223 # an embedded-browser desktop head
8
8
  # … --error-selector '.my-crash-banner' # the banner THIS stack shows when its client side falls over
9
9
  # # (repeatable; defaults cover one framework's id and [data-error-ui])
10
+ # … --route-value ProviderId=21 # a sample for /x/{ProviderId:long} (repeatable; or "route_values"
11
+ # # in the list); a screen whose route still needs one is SKIPped
12
+ # A mockup control marked data-tf-state="<state>" (on it or a box around it) belongs to another state
13
+ # of the screen: it is not owed on the first view.
10
14
  #
11
15
  # For every screen, at 1280 and 390 px: every control the mockup anchors is present and shows
12
16
  # something, no header-only table, no blank page, no Blazor or console error (render); nothing
@@ -22,10 +26,11 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
26
  # `--help` is a request and exits 0; no argument at all is a mistake and exits 3. (The one-line
23
27
  # form this replaces computed "0--help" as its exit code and printed a bash error under the help.)
24
28
  case "${1:-}" in
25
- -h|--help) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
26
- "") sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 3 ;;
29
+ -h|--help) sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
30
+ "") sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//'; exit 3 ;;
27
31
  esac
28
32
  command -v node >/dev/null 2>&1 || { echo "tf-verify-screens: node is required (bash .tfcore/utils/tf-verify-env.sh)" >&2; exit 4; }
29
33
  node -e "import('playwright').then(()=>process.exit(0)).catch(()=>process.exit(1))" 2>/dev/null || {
30
34
  echo "tf-verify-screens: the 'playwright' package is not resolvable from this folder; run bash .tfcore/utils/tf-verify-env.sh" >&2; exit 4; }
31
- exec node "$HERE/tf-verify-screens.mjs" "$@"
35
+ source "$HERE/tf-lock.sh"; tf_take_lock verify-screens # one browser check at a time (TF-048)
36
+ node "$HERE/tf-verify-screens.mjs" "$@"