@techierathore/techieflow 1.0.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 (151) hide show
  1. package/.claude/commands/TechieFlow/agents/analyst.md +97 -0
  2. package/.claude/commands/TechieFlow/agents/architect.md +84 -0
  3. package/.claude/commands/TechieFlow/agents/flow-master.md +217 -0
  4. package/.claude/commands/TechieFlow/agents/verifier.md +72 -0
  5. package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +138 -0
  6. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +98 -0
  7. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +78 -0
  8. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +77 -0
  9. package/.claude/commands/TechieFlow/tasks/advanced-elicitation.md +117 -0
  10. package/.claude/commands/TechieFlow/tasks/amend-docs.md +149 -0
  11. package/.claude/commands/TechieFlow/tasks/author-brd.md +79 -0
  12. package/.claude/commands/TechieFlow/tasks/build-phase.md +231 -0
  13. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +278 -0
  14. package/.claude/commands/TechieFlow/tasks/create-doc.md +101 -0
  15. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +507 -0
  16. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +224 -0
  17. package/.claude/commands/TechieFlow/tasks/devguide.md +224 -0
  18. package/.claude/commands/TechieFlow/tasks/document-project.md +343 -0
  19. package/.claude/commands/TechieFlow/tasks/execute-checklist.md +86 -0
  20. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +136 -0
  21. package/.claude/commands/TechieFlow/tasks/fix-issues.md +134 -0
  22. package/.claude/commands/TechieFlow/tasks/generate-html.md +115 -0
  23. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +126 -0
  24. package/.claude/commands/TechieFlow/tasks/index-docs.md +173 -0
  25. package/.claude/commands/TechieFlow/tasks/kb-mode-interaction.md +75 -0
  26. package/.claude/commands/TechieFlow/tasks/log-miss.md +160 -0
  27. package/.claude/commands/TechieFlow/tasks/metrics-report.md +143 -0
  28. package/.claude/commands/TechieFlow/tasks/mockups.md +122 -0
  29. package/.claude/commands/TechieFlow/tasks/productguide.md +105 -0
  30. package/.claude/commands/TechieFlow/tasks/refresh-status.md +135 -0
  31. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +117 -0
  32. package/.claude/commands/TechieFlow/tasks/shard-doc.md +185 -0
  33. package/.claude/commands/TechieFlow/tasks/split-brd.md +131 -0
  34. package/.claude/commands/TechieFlow/tasks/triage-issues.md +157 -0
  35. package/.claude/commands/TechieFlow/tasks/verify-phase.md +579 -0
  36. package/.claude/commands/generate-html.md +124 -0
  37. package/.codex/config.toml +46 -0
  38. package/.codex/hooks.json +63 -0
  39. package/.codex/rules/techieflow.rules +8 -0
  40. package/.opencode/command/generate-html.md +129 -0
  41. package/.opencode/plugin/techieflow.js +350 -0
  42. package/.tfcore/TOKEN-GUIDE.md +59 -0
  43. package/.tfcore/agents/analyst.md +97 -0
  44. package/.tfcore/agents/architect.md +84 -0
  45. package/.tfcore/agents/flow-master.md +217 -0
  46. package/.tfcore/agents/verifier.md +72 -0
  47. package/.tfcore/checklists/architect-checklist.md +438 -0
  48. package/.tfcore/core-config.yaml +47 -0
  49. package/.tfcore/data/brainstorming-techniques.md +36 -0
  50. package/.tfcore/data/elicitation-methods.md +154 -0
  51. package/.tfcore/data/techieflow-kb.md +807 -0
  52. package/.tfcore/data/technical-preferences.md +3 -0
  53. package/.tfcore/enhanced-ide-development-workflow.md +248 -0
  54. package/.tfcore/hooks/block-git.sh +301 -0
  55. package/.tfcore/hooks/codex-adapter.py +248 -0
  56. package/.tfcore/hooks/guard-artifacts.sh +101 -0
  57. package/.tfcore/hooks/guard-status-html.sh +102 -0
  58. package/.tfcore/hooks/guard-status.sh +124 -0
  59. package/.tfcore/hooks/guard-verify.sh +108 -0
  60. package/.tfcore/hooks/metrics-session.sh +129 -0
  61. package/.tfcore/hooks/session-pointer.sh +52 -0
  62. package/.tfcore/hooks/sweep-artifacts.sh +198 -0
  63. package/.tfcore/install-manifest.yaml +231 -0
  64. package/.tfcore/routing.yaml +105 -0
  65. package/.tfcore/tasks/_metrics-emit-gate.md +138 -0
  66. package/.tfcore/tasks/_smoke-test-policy.md +98 -0
  67. package/.tfcore/tasks/_status-update-gate.md +78 -0
  68. package/.tfcore/tasks/_yolo-mode.md +77 -0
  69. package/.tfcore/tasks/advanced-elicitation.md +117 -0
  70. package/.tfcore/tasks/amend-docs.md +149 -0
  71. package/.tfcore/tasks/author-brd.md +79 -0
  72. package/.tfcore/tasks/build-phase.md +231 -0
  73. package/.tfcore/tasks/create-deep-research-prompt.md +278 -0
  74. package/.tfcore/tasks/create-doc.md +101 -0
  75. package/.tfcore/tasks/day1-brownfield.md +507 -0
  76. package/.tfcore/tasks/day1-greenfield.md +224 -0
  77. package/.tfcore/tasks/devguide.md +224 -0
  78. package/.tfcore/tasks/document-project.md +343 -0
  79. package/.tfcore/tasks/execute-checklist.md +86 -0
  80. package/.tfcore/tasks/facilitate-brainstorming-session.md +136 -0
  81. package/.tfcore/tasks/fix-issues.md +134 -0
  82. package/.tfcore/tasks/generate-html.md +115 -0
  83. package/.tfcore/tasks/handoff-phase.md +126 -0
  84. package/.tfcore/tasks/index-docs.md +173 -0
  85. package/.tfcore/tasks/kb-mode-interaction.md +75 -0
  86. package/.tfcore/tasks/log-miss.md +160 -0
  87. package/.tfcore/tasks/metrics-report.md +143 -0
  88. package/.tfcore/tasks/mockups.md +122 -0
  89. package/.tfcore/tasks/productguide.md +105 -0
  90. package/.tfcore/tasks/refresh-status.md +135 -0
  91. package/.tfcore/tasks/render-workflow-docs.md +117 -0
  92. package/.tfcore/tasks/shard-doc.md +185 -0
  93. package/.tfcore/tasks/split-brd.md +131 -0
  94. package/.tfcore/tasks/triage-issues.md +157 -0
  95. package/.tfcore/tasks/verify-phase.md +579 -0
  96. package/.tfcore/telemetry/SCHEMA.md +678 -0
  97. package/.tfcore/telemetry/install-metrics.sh +390 -0
  98. package/.tfcore/telemetry/pre-commit +94 -0
  99. package/.tfcore/telemetry/tf-metrics.sh +1415 -0
  100. package/.tfcore/templates/architecture-tmpl.yaml +650 -0
  101. package/.tfcore/templates/brainstorming-output-tmpl.yaml +156 -0
  102. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +476 -0
  103. package/.tfcore/templates/competitor-analysis-tmpl.yaml +306 -0
  104. package/.tfcore/templates/front-end-architecture-tmpl.yaml +218 -0
  105. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +823 -0
  106. package/.tfcore/templates/market-research-tmpl.yaml +252 -0
  107. package/.tfcore/templates/project-brief-tmpl.yaml +221 -0
  108. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +61 -0
  109. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +127 -0
  110. package/.tfcore/templates/v4custom/app-brd-tmpl.md +204 -0
  111. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +90 -0
  112. package/.tfcore/templates/v4custom/app-claude-md-tmpl.md +33 -0
  113. package/.tfcore/templates/v4custom/app-devguide-tmpl.md +167 -0
  114. package/.tfcore/templates/v4custom/app-editorconfig-tmpl.editorconfig +44 -0
  115. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +31 -0
  116. package/.tfcore/templates/v4custom/app-productguide-tmpl.md +54 -0
  117. package/.tfcore/templates/v4custom/app-project-status-tmpl.md +98 -0
  118. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +48 -0
  119. package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +67 -0
  120. package/.tfcore/templates/v4custom/build-invocation-ladder.md +190 -0
  121. package/.tfcore/templates/v4custom/html-render-shell.md +502 -0
  122. package/.tfcore/templates/v4custom/metrics-report-template.md +273 -0
  123. package/.tfcore/templates/v4custom/opencode-operating-contract.md +56 -0
  124. package/.tfcore/user-guide.md +633 -0
  125. package/.tfcore/utils/techieflow-doc-template.md +325 -0
  126. package/.tfcore/utils/tf-assets.sh +310 -0
  127. package/.tfcore/utils/tf-codex-bind.py +196 -0
  128. package/.tfcore/utils/tf-codex-telemetry.py +60 -0
  129. package/.tfcore/utils/tf-emit.sh +1075 -0
  130. package/.tfcore/utils/tf-gitignore-audit.sh +281 -0
  131. package/.tfcore/utils/tf-goal.sh +421 -0
  132. package/.tfcore/utils/tf-harness.sh +160 -0
  133. package/.tfcore/utils/tf-mockup-parity.mjs +555 -0
  134. package/.tfcore/utils/tf-mockup-parity.sh +65 -0
  135. package/.tfcore/utils/tf-opencode-audit.py +126 -0
  136. package/.tfcore/utils/tf-perf.sh +241 -0
  137. package/.tfcore/utils/tf-render-html.py +715 -0
  138. package/.tfcore/utils/tf-render-html.sh +28 -0
  139. package/.tfcore/utils/tf-routing-bind.sh +216 -0
  140. package/.tfcore/utils/tf-routing.sh +247 -0
  141. package/.tfcore/utils/tf-yolo.sh +119 -0
  142. package/.tfcore/working-in-the-brownfield.md +606 -0
  143. package/LICENSE +201 -0
  144. package/README.md +1212 -0
  145. package/WORKFLOW.html +1986 -0
  146. package/docs/TechieFlow-Installation.md +299 -0
  147. package/opencode.jsonc +929 -0
  148. package/package.json +60 -0
  149. package/scripts/install.mjs +992 -0
  150. package/scripts/test-install.mjs +505 -0
  151. package/scripts/validate.mjs +155 -0
@@ -0,0 +1,555 @@
1
+ // TechieFlow — mockup-parity gate. Feeds verify-phase §4b2.
2
+ //
3
+ // Driven by tf-mockup-parity.sh; run that, not this.
4
+ //
5
+ // WHAT IT ASKS: "does the built screen carry the structure its approved mockup
6
+ // draws?" — mechanically, at the same viewports, comparing STRUCTURE and never
7
+ // pixels. Pixel diffing on live data is unusable and would be switched off within
8
+ // a week; that is why this grades named structural classes instead.
9
+ //
10
+ // THE FOUR DEFECTS THIS IMPLEMENTATION IS BUILT AROUND (TfLens TF-008/009/011/012).
11
+ // They are not four bugs — they are one shape seen four times: *a gate reports on
12
+ // what it happens to be able to reach, and reports success when it reaches nothing.*
13
+ //
14
+ // TF-008 No gate compared a built screen to its design at all. §4a asks "does the
15
+ // control show data?" (a badge rendered as bare text HAS text) and §4b asks
16
+ // "does anything overlap?" (a header wrapped to two rows overlaps nothing).
17
+ // 13 of 14 screens carried structural drift with every gate green.
18
+ //
19
+ // TF-009 The first implementation graded six classes and `border-style` was in
20
+ // none of them, so a card lost its dashed "this is an estimate" treatment
21
+ // and passed: a dashed grey border and a solid grey border land in the same
22
+ // semantic COLOUR bucket. -> the `stroke` clause below.
23
+ //
24
+ // TF-011 Depth was bounded by how many data-testid anchors the MOCKUP happened to
25
+ // carry, and the gate walked a <table> into tr/td but walked nothing else.
26
+ // So the two screens whose mockups used tables produced 44 findings and
27
+ // looked thorough, while card-built screens produced silence and PASS. The
28
+ // failure is silent AND inverted: **the less of a screen the gate can see,
29
+ // the cleaner its verdict looks.** -> the structural walker, the coverage
30
+ // accounting, and the UNGRADEABLE verdict below. A PASS must mean "graded",
31
+ // never "there was nothing to compare".
32
+ //
33
+ // TF-012 The `clip` clause counted screen-reader-only text as overflow, so every
34
+ // accessible screen failed it — 8 of 10 screens, identical message, always.
35
+ // A finding that appears everywhere always trains a reader to skim the whole
36
+ // report, which is the most expensive failure a gate can have. -> isHidden()
37
+ // and the visible-extent overflow measurement below.
38
+ //
39
+ // It produces FINDINGS and a per-screen verdict. verify-phase §4b2 decides what that
40
+ // means for a REQ.
41
+
42
+ import { chromium } from 'playwright';
43
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
44
+ import { dirname, resolve } from 'node:path';
45
+ import { pathToFileURL } from 'node:url';
46
+
47
+ // ---------------------------------------------------------------- arguments
48
+ const argv = process.argv.slice(2);
49
+ const arg = (name, dflt = null) => {
50
+ const i = argv.indexOf(name);
51
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : dflt;
52
+ };
53
+ const flag = (name) => argv.includes(name);
54
+
55
+ const BASE = (arg('--base') || '').replace(/\/$/, '');
56
+ const MOCKUPS = arg('--mockups', 'docs/mockups');
57
+ const WIDTHS = (arg('--widths', '1280,390') || '').split(',').map((w) => parseInt(w.trim(), 10)).filter(Boolean);
58
+ const JSON_OUT = arg('--json-out');
59
+ const COOKIE = arg('--cookie');
60
+ const HEADERS = [];
61
+ for (let i = 0; i < argv.length; i++) if (argv[i] === '--header' && argv[i + 1]) HEADERS.push(argv[i + 1]);
62
+ const SCREENS = [];
63
+ for (let i = 0; i < argv.length; i++) {
64
+ if (argv[i] === '--screen' && argv[i + 1]) {
65
+ const [name, ...rest] = argv[i + 1].split('=');
66
+ SCREENS.push({ name: name.trim(), route: rest.join('=').trim() || '/' + name.trim() });
67
+ }
68
+ }
69
+ const THIN_RATIO = parseFloat(arg('--thin-ratio', '0.25'));
70
+ const MAX_FINDINGS_PER_SCREEN = parseInt(arg('--max-findings', '40'), 10);
71
+
72
+ if (!BASE || SCREENS.length === 0) {
73
+ console.error('usage: tf-mockup-parity.sh --base URL --screen name=/route [--screen ...]');
74
+ process.exit(3);
75
+ }
76
+
77
+ // ------------------------------------------------------------ page-side probe
78
+ // Everything below runs INSIDE the browser, on both the mockup and the app, and
79
+ // returns one flat index keyed by a stable path key. It is deliberately one
80
+ // evaluate() call: two passes over the same DOM can disagree after a re-layout.
81
+ const PROBE = () => {
82
+ const MAX_DEPTH = 4; // descend this far below an anchor
83
+ const MAX_PER_ANCHOR = 60; // and no further; a huge subtree is noise, not signal
84
+
85
+ // --- TF-012: the third hiding technique, and the only one that leaves a box.
86
+ // display:none and visibility:hidden are already excluded by every geometry
87
+ // check because they have no layout box. The sr-only recipe DOES lay out — a 1px
88
+ // clipped box with white-space:nowrap — so its scrollWidth is meaningless BY
89
+ // CONSTRUCTION (nowrap text in a 1px box guarantees scrollWidth >> clientWidth)
90
+ // and it inflates its ANCESTOR's scrollWidth, which is what the clip clause reads.
91
+ // Visually-hidden text is not visually anything; it is the accessible name a
92
+ // screen reader announces, and a WCAG-conformant app is supposed to have it.
93
+ const isHidden = (el) => {
94
+ const cs = getComputedStyle(el);
95
+ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return true;
96
+ if ((cs.clipPath || '').replace(/\s/g, '') === 'inset(50%)') return true;
97
+ if (/rect\(\s*0(px)?\s*,?\s*0(px)?\s*,?\s*0(px)?\s*,?\s*0(px)?\s*\)/.test(cs.clip || '')) return true;
98
+ const r = el.getBoundingClientRect();
99
+ if (r.width < 2 || r.height < 2) return true; // covers the 1px sr-only box
100
+ return false;
101
+ };
102
+
103
+ const RGB = (v) => {
104
+ const m = (v || '').match(/-?[\d.]+/g);
105
+ if (!m || m.length < 3) return null;
106
+ const a = m.length > 3 ? parseFloat(m[3]) : 1;
107
+ return a < 0.05 ? null : { r: +m[0], g: +m[1], b: +m[2] };
108
+ };
109
+
110
+ // Semantic BUCKET, never the literal colour: two designs may legitimately differ
111
+ // in shade, and grading the exact value would produce a finding on every screen.
112
+ const bucket = (c) => {
113
+ if (!c) return null;
114
+ const { r, g, b } = c;
115
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
116
+ if (mx - mn < 28) return 'neutral';
117
+ if (r > g && r > b) return g > 120 ? 'warning' : 'negative';
118
+ if (g > r && g > b) return 'positive';
119
+ if (b > r && b > g) return 'accent';
120
+ return 'neutral';
121
+ };
122
+
123
+ const semanticColor = (el) => {
124
+ const cs = getComputedStyle(el);
125
+ return bucket(RGB(cs.backgroundColor)) || bucket(RGB(cs.borderTopColor)) || bucket(RGB(cs.color));
126
+ };
127
+
128
+ // --- TF-009: the seventh class. A mockup says "provisional / estimated /
129
+ // inactive" with a border STYLE precisely because it stays legible on both the
130
+ // light and the dark surface without spending a colour on it — so a gate that
131
+ // reads every other border property but not this one keeps missing that whole
132
+ // vocabulary. Quantised to the three values that carry meaning rather than the
133
+ // full CSS keyword set, so the clause stays as noise-free as the colour buckets.
134
+ const strokeOf = (el) => {
135
+ const cs = getComputedStyle(el);
136
+ const q = (s) => (s === 'none' || s === 'hidden' ? 'none'
137
+ : s === 'dashed' || s === 'dotted' ? 'dashed' : 'solid');
138
+ const sides = ['borderTopStyle', 'borderRightStyle', 'borderBottomStyle', 'borderLeftStyle'].map((k) => q(cs[k]));
139
+ const w = parseFloat(cs.borderTopWidth) || 0;
140
+ const style = w === 0 ? 'none' : sides[0];
141
+ return { style, uniform: new Set(sides).size === 1, visible: w > 0 };
142
+ };
143
+
144
+ const hasIcon = (el) => !!el.querySelector('svg, img, i[class*="icon"], span[class*="icon"], [class*="bi-"], [class*="fa-"]');
145
+
146
+ // "Chrome" = a badge / pill / chip: a small element with its own fill or ring and
147
+ // a rounded edge. The mockup drawing one and the app rendering bare text is the
148
+ // canonical TF-008 escape — a status pill flattened to plain text is the one value
149
+ // on a page meant to be read at a glance.
150
+ const chromeOn = (el) => {
151
+ const r = el.getBoundingClientRect();
152
+ if (r.height > 44 || r.height < 6) return null; // a card is not a badge
153
+ const cs = getComputedStyle(el);
154
+ const radius = parseFloat(cs.borderTopLeftRadius) || 0;
155
+ const filled = !!RGB(cs.backgroundColor);
156
+ const ringed = (parseFloat(cs.borderTopWidth) || 0) > 0;
157
+ return { badge: (filled || ringed) && radius >= 6, filled, ringed, radius: Math.round(radius) };
158
+ };
159
+
160
+ // Only text that is genuinely a single inline run can be graded for wrapping or
161
+ // token fit. A container with block children has no meaningful line count, and
162
+ // pretending otherwise is how a card-shaped anchor buys nothing (TF-011).
163
+ const inlineOnly = (el) => {
164
+ for (const c of el.children) {
165
+ const d = getComputedStyle(c).display;
166
+ if (d !== 'inline' && d !== 'inline-block' && d !== 'contents') return false;
167
+ }
168
+ return (el.textContent || '').trim().length > 0;
169
+ };
170
+
171
+ const lineCount = (el) => {
172
+ if (!inlineOnly(el)) return null;
173
+ const cs = getComputedStyle(el);
174
+ let lh = parseFloat(cs.lineHeight);
175
+ if (!lh || Number.isNaN(lh)) lh = (parseFloat(cs.fontSize) || 16) * 1.2;
176
+ const h = el.getBoundingClientRect().height;
177
+ if (!h || !lh) return null;
178
+ return Math.max(1, Math.round(h / lh));
179
+ };
180
+
181
+ // A formatted number must never break mid-digit. The longest unbreakable token is
182
+ // what the cell actually has to fit; the mockup's own column width is the claim
183
+ // that it does.
184
+ const tokenFit = (el) => {
185
+ if (!inlineOnly(el)) return null;
186
+ const text = (el.textContent || '').trim();
187
+ if (!text) return null;
188
+ const longest = text.split(/\s+/).reduce((a, b) => (b.length > a.length ? b : a), '');
189
+ if (longest.length < 4) return null;
190
+ const range = document.createRange();
191
+ let w = 0;
192
+ try {
193
+ const tn = [...el.childNodes].find((n) => n.nodeType === 3 && n.textContent.includes(longest));
194
+ if (!tn) return null;
195
+ const at = tn.textContent.indexOf(longest);
196
+ range.setStart(tn, at);
197
+ range.setEnd(tn, at + longest.length);
198
+ w = range.getBoundingClientRect().width;
199
+ } catch (e) { return null; }
200
+ const cs = getComputedStyle(el);
201
+ const avail = el.clientWidth - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0);
202
+ if (!w || avail <= 0) return null;
203
+ return { fits: w <= avail + 1, token: longest.slice(0, 24) };
204
+ };
205
+
206
+ // --- TF-012 again, on the measuring side. scrollWidth includes the phantom
207
+ // extent of an sr-only descendant, so the ancestor is measured from the right
208
+ // edge of its VISIBLE descendants instead whenever a hidden one is present.
209
+ const clipOf = (el) => {
210
+ const hiddenKids = [...el.querySelectorAll('*')].filter(isHidden);
211
+ let overX;
212
+ if (hiddenKids.length === 0) {
213
+ overX = el.scrollWidth - el.clientWidth;
214
+ } else {
215
+ const box = el.getBoundingClientRect();
216
+ let right = box.left;
217
+ for (const c of el.querySelectorAll('*')) {
218
+ if (isHidden(c)) continue;
219
+ const r = c.getBoundingClientRect();
220
+ if (r.width > 0) right = Math.max(right, r.right);
221
+ }
222
+ overX = Math.max(0, Math.round(right - (box.left + el.clientWidth)));
223
+ }
224
+ const overY = el.scrollHeight - el.clientHeight;
225
+ return { x: overX > 2, y: overY > 2, sr_excluded: hiddenKids.length };
226
+ };
227
+
228
+ const sigOf = (el) => {
229
+ const r = el.getBoundingClientRect();
230
+ const chrome = chromeOn(el);
231
+ return {
232
+ tag: el.tagName.toLowerCase(),
233
+ text: (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60),
234
+ badge: chrome ? chrome.badge : null,
235
+ icon: hasIcon(el),
236
+ color: semanticColor(el),
237
+ stroke: strokeOf(el),
238
+ wrap: lineCount(el),
239
+ clip: clipOf(el),
240
+ token: tokenFit(el),
241
+ w: Math.round(r.width),
242
+ h: Math.round(r.height),
243
+ inline: inlineOnly(el),
244
+ };
245
+ };
246
+
247
+ // --- the walker. TF-011's cheapest real win: the first implementation descended a
248
+ // <table> into tr/td and NOTHING ELSE, which is the entire reason table-shaped
249
+ // mockups produced 44 findings and card-shaped ones produced silence. That was
250
+ // luck, not design. This descends ANY anchored subtree by a structural path key,
251
+ // so a card, a column, a grid, a <dl> and a list are all walked the same way a
252
+ // table already was. Keys pair only when both sides produce them, so extra depth
253
+ // costs precision nothing — it only ever adds comparisons that were impossible
254
+ // before.
255
+ const keyOf = (el, parentKey) => {
256
+ const tag = el.tagName.toLowerCase();
257
+ let n = 0;
258
+ for (const s of el.parentElement ? el.parentElement.children : []) {
259
+ if (s === el) break;
260
+ if (s.tagName === el.tagName) n++;
261
+ }
262
+ return `${parentKey} > ${tag}[${n}]`;
263
+ };
264
+
265
+ const index = {};
266
+ const anchors = [...document.querySelectorAll('[data-testid]')];
267
+ const allTestIds = anchors.map((a) => a.getAttribute('data-testid'));
268
+
269
+ for (const a of anchors) {
270
+ const id = a.getAttribute('data-testid');
271
+ if (!id || index[id]) continue;
272
+ if (isHidden(a)) continue;
273
+ index[id] = sigOf(a);
274
+ let budget = MAX_PER_ANCHOR;
275
+ const walk = (el, key, depth) => {
276
+ if (depth > MAX_DEPTH || budget <= 0) return;
277
+ for (const c of el.children) {
278
+ if (budget <= 0) return;
279
+ if (c.hasAttribute('data-testid')) continue; // it gets its own top-level entry
280
+ if (isHidden(c)) continue; // TF-012
281
+ const k = keyOf(c, key);
282
+ index[k] = sigOf(c);
283
+ budget--;
284
+ walk(c, k, depth + 1);
285
+ }
286
+ };
287
+ walk(a, id, 1);
288
+ }
289
+
290
+ const de = document.documentElement;
291
+ return {
292
+ index,
293
+ anchors: anchors.length,
294
+ testids: allTestIds,
295
+ doc: {
296
+ scrollHeight: de.scrollHeight,
297
+ clientHeight: de.clientHeight,
298
+ scrollWidth: de.scrollWidth,
299
+ clientWidth: de.clientWidth,
300
+ },
301
+ };
302
+ };
303
+
304
+ // ------------------------------------------------------------------- diffing
305
+ // A clause returns a finding, `null` (not applicable — do NOT count it as graded),
306
+ // or `false` (applicable and agreeing). The three are kept distinct because
307
+ // conflating "agreed" with "not asked" is precisely how TF-011 happened.
308
+ const CLAUSES = {
309
+ badge: (m, a) => (m.badge === null || a.badge === null ? null
310
+ : m.badge !== a.badge
311
+ ? `mockup renders this as a ${m.badge ? 'badge/pill' : 'plain element'}, app renders it as a ${a.badge ? 'badge/pill' : 'plain element'}`
312
+ : false),
313
+ icon: (m, a) => (m.icon === a.icon ? false
314
+ : m.icon && !a.icon ? 'mockup carries an icon here; the app does not'
315
+ : 'app carries an icon the mockup does not'),
316
+ color: (m, a) => (m.color === null || a.color === null ? null
317
+ : m.color !== a.color ? `semantic colour differs — mockup ${m.color}, app ${a.color}` : false),
318
+ // TF-009
319
+ stroke: (m, a) => {
320
+ if (!m.stroke || !a.stroke) return null;
321
+ if (m.stroke.style !== a.stroke.style) {
322
+ return `border style differs — mockup ${m.stroke.style}, app ${a.stroke.style}`
323
+ + (m.stroke.style === 'dashed' ? ' (a dashed rule is how a mockup says "estimate / provisional"; losing it makes an estimate look measured)' : '');
324
+ }
325
+ if (m.stroke.visible !== a.stroke.visible) {
326
+ return `border presence differs — mockup ${m.stroke.visible ? 'has a visible rule' : 'has none'}, app ${a.stroke.visible ? 'has one' : 'has none'}`;
327
+ }
328
+ return false;
329
+ },
330
+ wrap: (m, a) => (m.wrap === null || a.wrap === null ? null
331
+ : a.wrap > m.wrap ? `wraps to ${a.wrap} rows where the mockup keeps it on ${m.wrap}` : false),
332
+ clip: (m, a) => (!m.clip || !a.clip ? null
333
+ : (a.clip.x && !m.clip.x) ? 'content is cut off horizontally; the mockup is not clipped'
334
+ : (a.clip.y && !m.clip.y) ? 'content is cut off vertically; the mockup is not clipped' : false),
335
+ token: (m, a) => (m.token === null || a.token === null ? null
336
+ : m.token.fits && !a.token.fits
337
+ ? `value cell is narrower than its longest unbreakable token ("${a.token.token}") — it breaks mid-token`
338
+ : false),
339
+ };
340
+
341
+ // Which clauses require having reached INSIDE a container rather than merely
342
+ // touching its outer box. TF-011's `harness` screen graded three column CONTAINERS
343
+ // and reported PASS: colour and stroke are computable on any box, so counting them
344
+ // as coverage is what let a screen the gate never really looked at read as clean.
345
+ const CONTENT_CLAUSES = new Set(['badge', 'icon', 'wrap', 'token']);
346
+
347
+ function diff(mock, app, screen, width) {
348
+ const findings = [];
349
+ const clauseCoverage = Object.fromEntries(Object.keys(CLAUSES).map((k) => [k, 0]));
350
+ clauseCoverage.missing = 0;
351
+ let compared = 0, contentGraded = 0;
352
+
353
+ for (const key of Object.keys(mock.index)) {
354
+ const m = mock.index[key];
355
+ const a = app.index[key];
356
+
357
+ // --- the `missing` clause. Key-pairing alone cannot see an element that is
358
+ // NOT THERE, and "a control the mockup draws as a badge rendered as plain text"
359
+ // and "an icon omitted" are the first two rows of TF-008's own escape table.
360
+ // A missing element has no signature to compare, so it needs its own clause.
361
+ //
362
+ // Deliberately narrow: only a mockup element that is CHROME (a badge/pill) or
363
+ // CARRIES AN ICON is reported when absent, and only when its parent paired. An
364
+ // unrestricted "the DOM shapes differ" report would fire on every incidental
365
+ // wrapper div and become the always-present finding TF-012 warns about — the
366
+ // fastest way to train a reader to skim the whole report.
367
+ if (!a) {
368
+ const parentKey = key.includes(' > ') ? key.slice(0, key.lastIndexOf(' > ')) : null;
369
+ const parentPaired = parentKey ? !!app.index[parentKey] : false;
370
+ if (parentPaired && (m.badge === true || m.icon === true)) {
371
+ clauseCoverage.missing++;
372
+ contentGraded++;
373
+ findings.push({
374
+ screen, width, class: 'missing', key,
375
+ detail: m.badge
376
+ ? `the mockup draws a badge/pill here ("${m.text}") and the app renders no such element — the value is flattened into plain text`
377
+ : `the mockup carries an icon here and the app renders no such element`,
378
+ mockup_text: m.text, app_text: null,
379
+ });
380
+ }
381
+ continue;
382
+ }
383
+ compared++;
384
+ for (const [name, fn] of Object.entries(CLAUSES)) {
385
+ const r = fn(m, a);
386
+ if (r === null) continue;
387
+ clauseCoverage[name]++;
388
+ if (CONTENT_CLAUSES.has(name)) contentGraded++;
389
+ if (r) findings.push({ screen, width, class: name, key, detail: r, mockup_text: m.text, app_text: a.text });
390
+ }
391
+ }
392
+ return { findings, compared, contentGraded, clauseCoverage };
393
+ }
394
+
395
+ // ---------------------------------------------------------------------- main
396
+ const results = [];
397
+ let hardFail = false;
398
+
399
+ const browser = await chromium.launch();
400
+ const ctx = await browser.newContext({ ignoreHTTPSErrors: true });
401
+ if (COOKIE) {
402
+ const u = new URL(BASE);
403
+ for (const pair of COOKIE.split(';')) {
404
+ const [name, ...v] = pair.trim().split('=');
405
+ if (name && v.length) await ctx.addCookies([{ name: name.trim(), value: v.join('='), domain: u.hostname, path: '/' }]);
406
+ }
407
+ }
408
+ if (HEADERS.length) {
409
+ const h = {};
410
+ for (const line of HEADERS) { const i = line.indexOf(':'); if (i > 0) h[line.slice(0, i).trim()] = line.slice(i + 1).trim(); }
411
+ await ctx.setExtraHTTPHeaders(h);
412
+ }
413
+
414
+ for (const s of SCREENS) {
415
+ const mockPath = resolve(MOCKUPS, `${s.name}.html`);
416
+ // TF-008 §3: a screen with no mockup is reported, never silently passed — the
417
+ // same discipline `⚠ STATIC-ONLY` already uses. Silence is not evidence.
418
+ if (!existsSync(mockPath)) {
419
+ results.push({ screen: s.name, route: s.route, verdict: 'NO-MOCKUP', mockup: mockPath,
420
+ note: 'no docs/mockups/<screen>.html — this screen was NOT graded against a design. It must not license a Verified on design grounds.' });
421
+ continue;
422
+ }
423
+
424
+ const perWidth = [];
425
+ for (const width of WIDTHS) {
426
+ const page = await ctx.newPage();
427
+ await page.setViewportSize({ width, height: width < 700 ? 844 : 800 });
428
+ let mock, app, docFindings = [];
429
+ try {
430
+ await page.goto(pathToFileURL(mockPath).href, { waitUntil: 'load' });
431
+ mock = await page.evaluate(PROBE);
432
+ const resp = await page.goto(BASE + s.route, { waitUntil: 'networkidle' });
433
+ if (resp && resp.status() >= 400) {
434
+ perWidth.push({ width, error: `app returned HTTP ${resp.status()}` });
435
+ await page.close();
436
+ continue;
437
+ }
438
+ app = await page.evaluate(PROBE);
439
+
440
+ // TF-008 §2. Cheap, no false positives in a shell-scrolled app, and it would
441
+ // have caught the /routing void on its own: 2607px of document against a
442
+ // 900px viewport, ~1700px of it blank, with the app shell repainted at the
443
+ // bottom because the page had escaped the shell's scroll container. No gate
444
+ // looked at document height, so that passed too.
445
+ if (app.doc.scrollHeight > app.doc.clientHeight + 2) {
446
+ docFindings.push({
447
+ screen: s.name, width, class: 'document-scroll', key: 'document',
448
+ detail: `document.scrollHeight ${app.doc.scrollHeight} exceeds clientHeight ${app.doc.clientHeight} — the page has escaped the app shell's scroll container`,
449
+ });
450
+ }
451
+ } catch (e) {
452
+ perWidth.push({ width, error: String(e).slice(0, 200) });
453
+ await page.close();
454
+ continue;
455
+ }
456
+ const d = diff(mock, app, s.name, width);
457
+ d.findings.push(...docFindings);
458
+ perWidth.push({ width, ...d, mockAnchors: mock.anchors, appAnchors: app.anchors,
459
+ appTestIds: app.testids, mockTestIds: mock.testids });
460
+ await page.close();
461
+ }
462
+
463
+ const ok = perWidth.filter((w) => !w.error);
464
+ const findings = ok.flatMap((w) => w.findings).slice(0, MAX_FINDINGS_PER_SCREEN);
465
+ const compared = ok.reduce((a, w) => a + (w.compared || 0), 0);
466
+ const contentGraded = ok.reduce((a, w) => a + (w.contentGraded || 0), 0);
467
+ const appAnchors = Math.max(0, ...ok.map((w) => w.appAnchors || 0));
468
+ const mockAnchors = Math.max(0, ...ok.map((w) => w.mockAnchors || 0));
469
+
470
+ // --- TF-011's central rule, encoded. A PASS from a gate whose stated purpose is
471
+ // "a built screen is graded against its approved mockup, mechanically" must mean
472
+ // the screen was GRADED. So a screen where no clause that requires reaching
473
+ // inside a container ever fired is UNGRADEABLE, never PASS. Note the measure is
474
+ // deliberately NOT an anchor ratio: `coverage` graded deeply off 8 body anchors
475
+ // while `harness` graded nothing off 7, because the difference was table-vs-card,
476
+ // not count. Count comparisons that could have produced a finding.
477
+ //
478
+ // An UNGRADEABLE screen is NOT-OBSERVABLE in checklist terms and must not license
479
+ // a `Verified` — the same principle the perf gate already applies with
480
+ // PERF-UNMEASURED.
481
+ //
482
+ // PRECEDENCE: a finding is positive evidence of a defect and outranks the absence
483
+ // of evidence, so FAIL beats UNGRADEABLE. But a FAIL that graded nothing else must
484
+ // not read as a thorough screen with one problem — `coverage.ungradeable` stays
485
+ // true either way, so the report can say both things at once.
486
+ const ungradeable = contentGraded === 0;
487
+ let verdict;
488
+ if (ok.length === 0) verdict = 'ERROR';
489
+ else if (findings.length) verdict = 'FAIL';
490
+ else if (ungradeable) verdict = 'UNGRADEABLE';
491
+ else verdict = 'PASS';
492
+
493
+ // --- TF-011 §2: report the anchor deficit as an ACTIONABLE list, so closing the
494
+ // gap is mechanical rather than a research task.
495
+ const mockSet = new Set(ok.flatMap((w) => w.mockTestIds || []));
496
+ const unanchored = [...new Set(ok.flatMap((w) => w.appTestIds || []))].filter((t) => !mockSet.has(t));
497
+
498
+ if (verdict === 'FAIL' || verdict === 'ERROR') hardFail = true;
499
+
500
+ results.push({
501
+ screen: s.name, route: s.route, verdict,
502
+ coverage: {
503
+ compared, content_graded: contentGraded, ungradeable,
504
+ app_controls: appAnchors, mockup_anchors: mockAnchors,
505
+ ratio: appAnchors ? +(compared / appAnchors).toFixed(2) : null,
506
+ thin: appAnchors > 0 && compared / appAnchors < THIN_RATIO,
507
+ by_clause: ok.reduce((acc, w) => {
508
+ for (const [k, v] of Object.entries(w.clauseCoverage || {})) acc[k] = (acc[k] || 0) + v;
509
+ return acc;
510
+ }, {}),
511
+ },
512
+ findings_n: findings.length,
513
+ findings,
514
+ anchor_deficit: {
515
+ n: unanchored.length,
516
+ // Named so the fix is a mechanical edit to the mockup, not a research task.
517
+ add_data_testid_to_mockup: unanchored.slice(0, 30),
518
+ },
519
+ widths: perWidth.map((w) => ({ width: w.width, error: w.error || null,
520
+ compared: w.compared || 0, content_graded: w.contentGraded || 0, findings: (w.findings || []).length })),
521
+ });
522
+ }
523
+
524
+ await browser.close();
525
+
526
+ const summary = {
527
+ status: 'measured',
528
+ base: BASE,
529
+ widths: WIDTHS,
530
+ screens_n: results.length,
531
+ pass: results.filter((r) => r.verdict === 'PASS').length,
532
+ fail: results.filter((r) => r.verdict === 'FAIL').length,
533
+ ungradeable: results.filter((r) => r.verdict === 'UNGRADEABLE').length,
534
+ // Counted separately from the verdict so a FAIL cannot hide the fact that the
535
+ // rest of the screen was never graded (TF-011).
536
+ screens_with_no_coverage: results.filter((r) => r.coverage && r.coverage.ungradeable).length,
537
+ no_mockup: results.filter((r) => r.verdict === 'NO-MOCKUP').length,
538
+ error: results.filter((r) => r.verdict === 'ERROR').length,
539
+ findings_n: results.reduce((a, r) => a + (r.findings_n || 0), 0),
540
+ screens: results,
541
+ };
542
+
543
+ const text = JSON.stringify(summary, null, 2);
544
+ console.log(text);
545
+ if (JSON_OUT) {
546
+ mkdirSync(dirname(resolve(JSON_OUT)), { recursive: true });
547
+ writeFileSync(resolve(JSON_OUT), text);
548
+ }
549
+
550
+ // 5 = at least one screen FAILed or errored. UNGRADEABLE and NO-MOCKUP are NOT
551
+ // failures — they are absences of evidence, and verify-phase §4b2 must treat them
552
+ // as NOT-OBSERVABLE rather than as either a pass or a defect. Exit 6 says at least
553
+ // one screen could not be graded, so a caller cannot read exit 0 as coverage.
554
+ process.exit(hardFail ? 5
555
+ : (summary.ungradeable + summary.no_mockup + summary.screens_with_no_coverage > 0 ? 6 : 0));
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bash
2
+ # TechieFlow — mockup-parity gate launcher. Feeds verify-phase §4b2.
3
+ #
4
+ # bash .tfcore/utils/tf-mockup-parity.sh --base http://localhost:5099 \
5
+ # --screen login=/login --screen harness=/harness --screen export=/export \
6
+ # --cookie 'AuthCookie=<value from your Playwright login>' \
7
+ # --json-out tests/.artifacts/mockup-parity/run.json
8
+ #
9
+ # WHAT IT ASKS: "does the built screen carry the structure its approved mockup
10
+ # draws?" — at the same viewports, comparing STRUCTURE, never pixels. Pixel diffing
11
+ # on live data is unusable and would be ignored within a week.
12
+ #
13
+ # WHY IT EXISTS (TfLens TF-008, 2026-08-29). A checklist reading 145 `Verified` sat
14
+ # over a running app with structural drift on 13 of 14 comparable screens — 20
15
+ # findings, 15 REQs demoted in one sitting, all found by a human comparing 18
16
+ # screenshots by hand. No gate could have caught any of it, because of what the
17
+ # gates measure rather than because of a bug in them:
18
+ #
19
+ # §4a data-render asks "does the control show data?" A badge rendered as plain
20
+ # text HAS text.
21
+ # §4b visual-truth asks "do controls overlap, clip, or leave the viewport?" A
22
+ # header wrapped to two rows overlaps nothing. A 71px value
23
+ # column that splits `2,287,975,139` across three lines, mid
24
+ # number, overlaps nothing either. A missing icon is nothing to
25
+ # measure.
26
+ #
27
+ # Adding acceptance criteria does not help: in every one of those cases the
28
+ # acceptance existed and was met. The missing thing was a gate that could fail.
29
+ #
30
+ # THE SEVEN CLASSES it grades: badge · icon · color · wrap · clip · token ·
31
+ # **stroke** (border style — TF-009), plus a document-height assertion (TF-008 §2).
32
+ #
33
+ # COVERAGE IS PUBLISHED AND A BARE PASS HAS A FLOOR (TF-011). Every screen reports
34
+ # `{compared, content_graded, app_controls, mockup_anchors}` and a screen where no
35
+ # clause reaching INSIDE a container ever fired is **UNGRADEABLE**, never PASS. That
36
+ # closes the failure mode that is both silent and inverted — the less of a screen
37
+ # the gate can see, the cleaner its verdict looks — which caused a real false
38
+ # statement upstream: "mockup-parity 10 PASS / 2 FAIL / 0 findings" with eight UI
39
+ # rows written `Verified` on that basis, while one screen was visibly wrong.
40
+ #
41
+ # Exit codes: 0 every screen graded and clean · 3 bad arguments · 4 Playwright not
42
+ # installed (run verify-phase §1 first) · 5 at least one screen FAILED
43
+ # · 6 nothing failed but at least one screen was UNGRADEABLE or had no
44
+ # mockup — exit 0 must never be readable as "everything was graded".
45
+
46
+ set -uo pipefail
47
+
48
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
49
+
50
+ case "${1:-}" in -h|--help) sed -n '2,42p' "$0"; exit 0 ;; esac
51
+
52
+ command -v node >/dev/null 2>&1 || {
53
+ echo "tf-mockup-parity: node is required (verify-phase §1 installs the verify environment)." >&2
54
+ exit 4
55
+ }
56
+
57
+ # Playwright is the verify environment's own dependency — verify-phase §1 already
58
+ # ensures it before any gate runs. Say which step to run rather than failing bare.
59
+ node -e "import('playwright').then(()=>process.exit(0)).catch(()=>process.exit(1))" 2>/dev/null || {
60
+ echo "tf-mockup-parity: the 'playwright' package is not resolvable from this repo." >&2
61
+ echo " Run verify-phase §1 (npm install -D @playwright/test && npx playwright install chromium)." >&2
62
+ exit 4
63
+ }
64
+
65
+ exec node "$HERE/tf-mockup-parity.mjs" "$@"