@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.
- package/.claude/commands/TechieFlow/agents/analyst.md +97 -0
- package/.claude/commands/TechieFlow/agents/architect.md +84 -0
- package/.claude/commands/TechieFlow/agents/flow-master.md +217 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +72 -0
- package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +138 -0
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +98 -0
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +78 -0
- package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +77 -0
- package/.claude/commands/TechieFlow/tasks/advanced-elicitation.md +117 -0
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +149 -0
- package/.claude/commands/TechieFlow/tasks/author-brd.md +79 -0
- package/.claude/commands/TechieFlow/tasks/build-phase.md +231 -0
- package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +278 -0
- package/.claude/commands/TechieFlow/tasks/create-doc.md +101 -0
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +507 -0
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +224 -0
- package/.claude/commands/TechieFlow/tasks/devguide.md +224 -0
- package/.claude/commands/TechieFlow/tasks/document-project.md +343 -0
- package/.claude/commands/TechieFlow/tasks/execute-checklist.md +86 -0
- package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +136 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +134 -0
- package/.claude/commands/TechieFlow/tasks/generate-html.md +115 -0
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +126 -0
- package/.claude/commands/TechieFlow/tasks/index-docs.md +173 -0
- package/.claude/commands/TechieFlow/tasks/kb-mode-interaction.md +75 -0
- package/.claude/commands/TechieFlow/tasks/log-miss.md +160 -0
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +143 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +122 -0
- package/.claude/commands/TechieFlow/tasks/productguide.md +105 -0
- package/.claude/commands/TechieFlow/tasks/refresh-status.md +135 -0
- package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +117 -0
- package/.claude/commands/TechieFlow/tasks/shard-doc.md +185 -0
- package/.claude/commands/TechieFlow/tasks/split-brd.md +131 -0
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +157 -0
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +579 -0
- package/.claude/commands/generate-html.md +124 -0
- package/.codex/config.toml +46 -0
- package/.codex/hooks.json +63 -0
- package/.codex/rules/techieflow.rules +8 -0
- package/.opencode/command/generate-html.md +129 -0
- package/.opencode/plugin/techieflow.js +350 -0
- package/.tfcore/TOKEN-GUIDE.md +59 -0
- package/.tfcore/agents/analyst.md +97 -0
- package/.tfcore/agents/architect.md +84 -0
- package/.tfcore/agents/flow-master.md +217 -0
- package/.tfcore/agents/verifier.md +72 -0
- package/.tfcore/checklists/architect-checklist.md +438 -0
- package/.tfcore/core-config.yaml +47 -0
- package/.tfcore/data/brainstorming-techniques.md +36 -0
- package/.tfcore/data/elicitation-methods.md +154 -0
- package/.tfcore/data/techieflow-kb.md +807 -0
- package/.tfcore/data/technical-preferences.md +3 -0
- package/.tfcore/enhanced-ide-development-workflow.md +248 -0
- package/.tfcore/hooks/block-git.sh +301 -0
- package/.tfcore/hooks/codex-adapter.py +248 -0
- package/.tfcore/hooks/guard-artifacts.sh +101 -0
- package/.tfcore/hooks/guard-status-html.sh +102 -0
- package/.tfcore/hooks/guard-status.sh +124 -0
- package/.tfcore/hooks/guard-verify.sh +108 -0
- package/.tfcore/hooks/metrics-session.sh +129 -0
- package/.tfcore/hooks/session-pointer.sh +52 -0
- package/.tfcore/hooks/sweep-artifacts.sh +198 -0
- package/.tfcore/install-manifest.yaml +231 -0
- package/.tfcore/routing.yaml +105 -0
- package/.tfcore/tasks/_metrics-emit-gate.md +138 -0
- package/.tfcore/tasks/_smoke-test-policy.md +98 -0
- package/.tfcore/tasks/_status-update-gate.md +78 -0
- package/.tfcore/tasks/_yolo-mode.md +77 -0
- package/.tfcore/tasks/advanced-elicitation.md +117 -0
- package/.tfcore/tasks/amend-docs.md +149 -0
- package/.tfcore/tasks/author-brd.md +79 -0
- package/.tfcore/tasks/build-phase.md +231 -0
- package/.tfcore/tasks/create-deep-research-prompt.md +278 -0
- package/.tfcore/tasks/create-doc.md +101 -0
- package/.tfcore/tasks/day1-brownfield.md +507 -0
- package/.tfcore/tasks/day1-greenfield.md +224 -0
- package/.tfcore/tasks/devguide.md +224 -0
- package/.tfcore/tasks/document-project.md +343 -0
- package/.tfcore/tasks/execute-checklist.md +86 -0
- package/.tfcore/tasks/facilitate-brainstorming-session.md +136 -0
- package/.tfcore/tasks/fix-issues.md +134 -0
- package/.tfcore/tasks/generate-html.md +115 -0
- package/.tfcore/tasks/handoff-phase.md +126 -0
- package/.tfcore/tasks/index-docs.md +173 -0
- package/.tfcore/tasks/kb-mode-interaction.md +75 -0
- package/.tfcore/tasks/log-miss.md +160 -0
- package/.tfcore/tasks/metrics-report.md +143 -0
- package/.tfcore/tasks/mockups.md +122 -0
- package/.tfcore/tasks/productguide.md +105 -0
- package/.tfcore/tasks/refresh-status.md +135 -0
- package/.tfcore/tasks/render-workflow-docs.md +117 -0
- package/.tfcore/tasks/shard-doc.md +185 -0
- package/.tfcore/tasks/split-brd.md +131 -0
- package/.tfcore/tasks/triage-issues.md +157 -0
- package/.tfcore/tasks/verify-phase.md +579 -0
- package/.tfcore/telemetry/SCHEMA.md +678 -0
- package/.tfcore/telemetry/install-metrics.sh +390 -0
- package/.tfcore/telemetry/pre-commit +94 -0
- package/.tfcore/telemetry/tf-metrics.sh +1415 -0
- package/.tfcore/templates/architecture-tmpl.yaml +650 -0
- package/.tfcore/templates/brainstorming-output-tmpl.yaml +156 -0
- package/.tfcore/templates/brownfield-architecture-tmpl.yaml +476 -0
- package/.tfcore/templates/competitor-analysis-tmpl.yaml +306 -0
- package/.tfcore/templates/front-end-architecture-tmpl.yaml +218 -0
- package/.tfcore/templates/fullstack-architecture-tmpl.yaml +823 -0
- package/.tfcore/templates/market-research-tmpl.yaml +252 -0
- package/.tfcore/templates/project-brief-tmpl.yaml +221 -0
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +61 -0
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +127 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +204 -0
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +90 -0
- package/.tfcore/templates/v4custom/app-claude-md-tmpl.md +33 -0
- package/.tfcore/templates/v4custom/app-devguide-tmpl.md +167 -0
- package/.tfcore/templates/v4custom/app-editorconfig-tmpl.editorconfig +44 -0
- package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +31 -0
- package/.tfcore/templates/v4custom/app-productguide-tmpl.md +54 -0
- package/.tfcore/templates/v4custom/app-project-status-tmpl.md +98 -0
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +48 -0
- package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +67 -0
- package/.tfcore/templates/v4custom/build-invocation-ladder.md +190 -0
- package/.tfcore/templates/v4custom/html-render-shell.md +502 -0
- package/.tfcore/templates/v4custom/metrics-report-template.md +273 -0
- package/.tfcore/templates/v4custom/opencode-operating-contract.md +56 -0
- package/.tfcore/user-guide.md +633 -0
- package/.tfcore/utils/techieflow-doc-template.md +325 -0
- package/.tfcore/utils/tf-assets.sh +310 -0
- package/.tfcore/utils/tf-codex-bind.py +196 -0
- package/.tfcore/utils/tf-codex-telemetry.py +60 -0
- package/.tfcore/utils/tf-emit.sh +1075 -0
- package/.tfcore/utils/tf-gitignore-audit.sh +281 -0
- package/.tfcore/utils/tf-goal.sh +421 -0
- package/.tfcore/utils/tf-harness.sh +160 -0
- package/.tfcore/utils/tf-mockup-parity.mjs +555 -0
- package/.tfcore/utils/tf-mockup-parity.sh +65 -0
- package/.tfcore/utils/tf-opencode-audit.py +126 -0
- package/.tfcore/utils/tf-perf.sh +241 -0
- package/.tfcore/utils/tf-render-html.py +715 -0
- package/.tfcore/utils/tf-render-html.sh +28 -0
- package/.tfcore/utils/tf-routing-bind.sh +216 -0
- package/.tfcore/utils/tf-routing.sh +247 -0
- package/.tfcore/utils/tf-yolo.sh +119 -0
- package/.tfcore/working-in-the-brownfield.md +606 -0
- package/LICENSE +201 -0
- package/README.md +1212 -0
- package/WORKFLOW.html +1986 -0
- package/docs/TechieFlow-Installation.md +299 -0
- package/opencode.jsonc +929 -0
- package/package.json +60 -0
- package/scripts/install.mjs +992 -0
- package/scripts/test-install.mjs +505 -0
- 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" "$@"
|