pixel-perfect-kit 0.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.
- package/CLONE-ALOYOGA-HEADER.md +76 -0
- package/CLONE-ANY-HEADER.md +106 -0
- package/CLONE-ANY-SITE.md +120 -0
- package/DEVELOP.md +144 -0
- package/LAUNCH-PROMPT.md +66 -0
- package/LEARNINGS.md +398 -0
- package/LICENSE +21 -0
- package/PLAYBOOK.md +260 -0
- package/README.md +219 -0
- package/WORKFLOW.md +257 -0
- package/bin/ppk +4 -0
- package/harness/adopt.js +52 -0
- package/harness/agent-setup.js +55 -0
- package/harness/behavior-selftest.js +243 -0
- package/harness/benchmarks/README.md +34 -0
- package/harness/benchmarks/battery.js +86 -0
- package/harness/benchmarks/detection-power.js +75 -0
- package/harness/capture-build-selftest.js +134 -0
- package/harness/capture-build.js +323 -0
- package/harness/doctor-selftest.js +58 -0
- package/harness/doctor.js +85 -0
- package/harness/fixtures/01-backdrop-color.js +51 -0
- package/harness/fixtures/02-line-strut.js +53 -0
- package/harness/fixtures/03-compat-mode.js +44 -0
- package/harness/fixtures/README.md +31 -0
- package/harness/human-qa-selftest.js +201 -0
- package/harness/human-qa.js +406 -0
- package/harness/merge-snapshot-selftest.js +56 -0
- package/harness/new-target.js +101 -0
- package/harness/regression.js +50 -0
- package/harness/score.js +58 -0
- package/harness/serve.js +149 -0
- package/harness/setup-selftest.js +93 -0
- package/harness/setup.js +158 -0
- package/harness/tunnel-selftest.js +76 -0
- package/harness/tunnel.js +241 -0
- package/harness/workflow-selftest.js +211 -0
- package/harness/workflow.js +739 -0
- package/package.json +40 -0
- package/skill/ditto-finish/SKILL.md +52 -0
- package/skill/pixel-perfect-clone/SKILL.md +49 -0
- package/tools/RUNBOOK.md +228 -0
- package/tools/behavior-capture.js +307 -0
- package/tools/behavior-worksheet.js +82 -0
- package/tools/browser-capture.js +240 -0
- package/tools/cli-selftest.js +136 -0
- package/tools/extract-fonts.js +133 -0
- package/tools/extract-icons.js +255 -0
- package/tools/merge-snapshot.js +56 -0
- package/tools/pixel-diff.js +845 -0
- package/tools/reassemble.js +63 -0
- package/tools/selftest.js +67 -0
- package/tools/sink.js +80 -0
package/WORKFLOW.md
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# WORKFLOW — the enforced pixel-perfect pipeline (`ppk`)
|
|
2
|
+
|
|
3
|
+
The PLAYBOOK describes the method. This describes the **enforcement**: a hard-gated state
|
|
4
|
+
machine that makes an agent (or you) walk the phases **in order**, and **refuses to let a
|
|
5
|
+
clone claim "pixel-perfect" until every gate has exited 0.** The method was prose the agent
|
|
6
|
+
was *asked* to follow; this is a machine that *won't advance* until the objective condition
|
|
7
|
+
is met.
|
|
8
|
+
|
|
9
|
+
It's modeled on [gajae-code](https://github.com/Yeachan-Heo/gajae-code)'s `gjc` workflow
|
|
10
|
+
(`deep-interview → ralplan → ultragoal`), reimplemented natively with zero dependencies —
|
|
11
|
+
see **Benchmark vs gajae-code** below for what we borrowed and what we deliberately didn't.
|
|
12
|
+
|
|
13
|
+
> The kit's one rule holds here too: **a phase is done because its gate exited 0, never
|
|
14
|
+
> because prose says so.**
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## The phases (in order)
|
|
19
|
+
|
|
20
|
+
| # | phase | gate — the objective condition that must hold | kind |
|
|
21
|
+
|---|------------|-----------------------------------------------|------|
|
|
22
|
+
| 1 | `target` | `target.json` has a url + a fixed width | machine |
|
|
23
|
+
| 2 | `assets` | any shipped `.woff2` has real `wOF2` magic bytes; icons/logo attested captured, not redrawn | attested |
|
|
24
|
+
| 3 | `measure` | `live.json` exists, is valid, has elements, measured **at the fixed width** | machine |
|
|
25
|
+
| 4 | `build` | `clone/index.html` built (no scaffold TODO) + a valid `clone.json` captured | machine |
|
|
26
|
+
| 5 | `visual` | `pixel-diff --visual` is green (0 pixel-determining fails, matching widths) | machine |
|
|
27
|
+
| 6 | `coverage` | every painted leaf in `coverage.json` has a measured target on **both** pages | machine |
|
|
28
|
+
| 7 | `strict` | strict deltas are **0**, or every *structural* one is documented in `deviations.json` — a **paint** delta (one that also fails `--visual`) can never be documented away | machine |
|
|
29
|
+
| 8 | `behavior` | every JS-driven dynamic discovered on the LIVE page (animation, rotation, reveal, marquee, counter, hover-mounted content) either reproduces on the clone within a documented tolerance, or is explicitly excused in `behavior-deviations.json` — an empty/absent discovery pass is never a free pass (see "The `behavior` phase" below) | machine |
|
|
30
|
+
| 9 | `human` | the **latest** PingHumans side-by-side round is human-approved — the verdict is re-fetched from the API on every check (a cached approval is never trusted); a rejection surfaces the human's flags as the fix list | machine |
|
|
31
|
+
| 10 | `done` | **default-FAIL final verification:** every earlier gate is **re-run** against the current artifacts (a recorded pass is not trusted — it must still hold), in order, and no phase may be forced | machine |
|
|
32
|
+
|
|
33
|
+
**machine** = the gate fully proves it (exit 0 is a fact). **attested** = it can't be fully
|
|
34
|
+
machine-checked ("this icon is the real one, not hand-drawn"), so the gate does what light
|
|
35
|
+
checks it can and then requires an `--evidence` string, recorded in the audit log and flagged
|
|
36
|
+
`attested` so nobody mistakes an assertion for a proof.
|
|
37
|
+
|
|
38
|
+
The gates are **build-strategy agnostic** — they verify the artifacts, not how you made
|
|
39
|
+
them. The **default** way to satisfy `build` is `ppk capture-build <name>`: build the clone
|
|
40
|
+
*from the captured live DOM* (self-hosted CSS/fonts, doctype preserved byte-for-byte), which
|
|
41
|
+
eliminates the technique-mismatch defect class the gates are structurally blind to
|
|
42
|
+
(LEARNINGS #19). Hand-rebuild only when the deliverable is a component in your own stack.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
node harness/workflow.js status <name> # phase table + the next required action
|
|
50
|
+
node harness/workflow.js gate <name> <phase> # run ONE gate read-only (exit 0/1) — no state change
|
|
51
|
+
node harness/workflow.js advance <name> <phase> [--evidence "…"] [--force]
|
|
52
|
+
node harness/workflow.js ledger <name> # the audit trail (receipts)
|
|
53
|
+
node harness/capture-build.js <name> [domFile] # the default build step: clone FROM the captured DOM
|
|
54
|
+
node harness/human-qa.js file <name> --draft <public-url> [--region "the header"] # file the human round
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### The `behavior` phase (tools/behavior-capture.js + the gate in harness/workflow.js)
|
|
58
|
+
Statics are proven by `visual`/`coverage`/`strict` before this phase runs; `behavior` proves
|
|
59
|
+
the clone's JS-driven dynamics — animations, rotations, reveals, marquees, counters,
|
|
60
|
+
hover-mounted content — reproduce the live page's actual measured behavior, not just its
|
|
61
|
+
static end-state. Method ported verbatim from `lovable_dupe_html/CLONE_PLAYBOOK.md` §8/§8a —
|
|
62
|
+
see `PLAYBOOK.md`'s behavior section for the full technique writeup. In short:
|
|
63
|
+
|
|
64
|
+
1. **Discover on LIVE** with `tools/behavior-capture.js` (`pxBehaviorDiscover()` /
|
|
65
|
+
`pxBehaviorSend(url)` / `pxBehaviorStash()` — same injection + delivery rules as
|
|
66
|
+
`browser-capture.js`, RUNBOOK.md "Golden rules"): a static pass greps `@keyframes` +
|
|
67
|
+
class/data-attribute markers for CANDIDATES, then a dynamic differential pass (a
|
|
68
|
+
`MutationObserver` + per-element opacity/transform/filter snapshots across a scripted
|
|
69
|
+
scroll sweep and hover probes) confirms which candidates actually fire and MEASURES them
|
|
70
|
+
(marquee px/sec, reveal end-states, hover-mount deltas). Save the result as
|
|
71
|
+
`targets/<name>/behaviors-live.json`.
|
|
72
|
+
2. **Reproduce** in one vanilla `clone/fixes.js` (no framework — `harness/capture-build.js
|
|
73
|
+
--fixes` wires `<script src="fixes.js" defer>` before `</body>` and scaffolds a starter
|
|
74
|
+
file the first time; it never overwrites one you've already written). Each behavior gets
|
|
75
|
+
its own guarded `try` block, per the playbook.
|
|
76
|
+
3. **Discover on the CLONE** the same way (with `fixes.js` loaded, captured AFTER the same
|
|
77
|
+
settle procedure as live — scroll sweep + dwell — so end-states match) →
|
|
78
|
+
`targets/<name>/behaviors-clone.json`.
|
|
79
|
+
4. The gate (`harness/workflow.js`, phase `behavior`) compares every live behavior against
|
|
80
|
+
the clone's inventory by KEY (see "Behavior keys" below) and MEASURED value, within the
|
|
81
|
+
tolerances below. A behavior that's genuinely irreproducible statically (e.g. a WebGL
|
|
82
|
+
generative background) is documented in `targets/<name>/behavior-deviations.json` —
|
|
83
|
+
`{ "<key>": { "reason": "WebGL generative — irreproducible statically" } }` — the same
|
|
84
|
+
escape hatch `strict`'s `deviations.json` uses, with the same rule: a behavior that ALSO
|
|
85
|
+
changes what `--visual` would see (a frozen, still-mid-transition end state) is a paint
|
|
86
|
+
delta in spirit and should be fixed, not excused.
|
|
87
|
+
|
|
88
|
+
**"Discovery ran" is itself evidenced**, not inferred from an empty inventory (a
|
|
89
|
+
`behaviors-live.json` with `behaviors: {}` and no `discovery` metadata is indistinguishable
|
|
90
|
+
from a script that silently no-oped, so the gate refuses it as a paint-over). Every capture
|
|
91
|
+
records its own discovery pass metadata — `elementsScanned`, `scrollSweep: {from,to,steps}`,
|
|
92
|
+
`observeMs`, `keyframesFound`, which hover triggers/marquee selectors were probed — and a
|
|
93
|
+
page with genuinely zero dynamic behaviors passes with that metadata cited as the reason.
|
|
94
|
+
|
|
95
|
+
**Behavior keys.** Each discovered behavior gets a stable string key so live and clone
|
|
96
|
+
inventories compare by identity, not by set overlap: `<prefix>:<descriptor>` where prefix is
|
|
97
|
+
`reveal` (scroll/class-toggle reconciled against a static candidate), `mutation` (an observed
|
|
98
|
+
change with no static marker — pure-JS timed rotations), `marquee` (an explicitly-probed
|
|
99
|
+
translating belt), or `hover` (an explicitly-probed hover-mounted panel/menu); descriptor
|
|
100
|
+
prefers `id`/`data-testid`/`aria-label`, falling back to `tag.class1.class2`, falling back to
|
|
101
|
+
a shallow structural path. Stability matters more than prettiness — the gate does an exact
|
|
102
|
+
key match between live and clone.
|
|
103
|
+
|
|
104
|
+
**Tolerances** (documented here so a failure message is self-explanatory, the same way
|
|
105
|
+
`--visual`'s 0.5px is):
|
|
106
|
+
- **speed (`pxPerSec`, marquees/scroll-linked motion): ±15% relative.** Two independent
|
|
107
|
+
1-second `transform` samples carry timer/paint-scheduling jitter (~3–8% observed on a quiet
|
|
108
|
+
machine); 15% absorbs that while a genuinely wrong speed — the common miss is copying the
|
|
109
|
+
wrong keyframe duration or belt width, landing at exactly 2x or 0.5x — still fails by a wide
|
|
110
|
+
margin.
|
|
111
|
+
- **duration (`durationMs`): ±25% relative, 150ms floor.** Wall-clock durations are measured
|
|
112
|
+
across a network+paint round-trip on BOTH captures (more jitter than a same-machine
|
|
113
|
+
transform sample); the floor keeps that noise from flagging very short (<600ms)
|
|
114
|
+
transitions, where 25% would be under a frame's worth of time.
|
|
115
|
+
- **opacity: ±0.05 absolute.** Absorbs float-rounding differences across engines; a
|
|
116
|
+
fully-revealed `1.0` reproduced as a stuck `0.92` is still a real, visible miss.
|
|
117
|
+
- **transform: matrix-aware compare — ±0.5px on the translation components, ±0.001 on the
|
|
118
|
+
linear part; non-matrix strings exact.** Computed `transform` normalizes to
|
|
119
|
+
`none|matrix(...)`, and matrix *translation* inherits layout subpixel rounding — exact
|
|
120
|
+
string equality would flake on jitter `--visual` itself tolerates, while a genuinely wrong
|
|
121
|
+
end-state (`-2125px` reproduced as `-1000px`) still fails by three orders of magnitude.
|
|
122
|
+
- **filter / trigger: exact string match.** Not measurements with sampling noise; `trigger`
|
|
123
|
+
(load/scroll/hover/mutation) records the reproduction TECHNIQUE, which the playbook
|
|
124
|
+
requires to match, not just land on the right pixels by luck.
|
|
125
|
+
- **`observed-mutation` behaviors (interval rotations) compare by KEY PRESENCE + trigger
|
|
126
|
+
only.** A continuously-mutating element's snapshot is whatever frame it was on — end-state
|
|
127
|
+
floats from two independent captures can never agree, so comparing them would be fiction
|
|
128
|
+
dressed as measurement. The contract is: the clone rotates too (key present, same trigger);
|
|
129
|
+
its per-frame states are for the human round to judge.
|
|
130
|
+
|
|
131
|
+
### The `human` phase (harness/human-qa.js)
|
|
132
|
+
Two providers, one contract (an explicit human verdict + pins, machine-checkably
|
|
133
|
+
recorded): **marketplace** (PingHumans, the default — independent reviewers over a
|
|
134
|
+
verified public tunnel) and **local** (`file --local` — the kit's own serve hosts the
|
|
135
|
+
review page at `/__review`: the clone in a same-origin iframe with click-to-pin, the
|
|
136
|
+
same generated step list, the same mandatory verdict buttons; no account, no tunnel).
|
|
137
|
+
The trust model is explicit and travels in the receipts: local rounds record
|
|
138
|
+
`provider:"local"` and verify prints "operator-trusted" — an agent with browser control
|
|
139
|
+
*could* forge a local submission, which is exactly why marketplace review exists and why
|
|
140
|
+
agents are forbidden from ever opening or submitting `/__review` themselves.
|
|
141
|
+
|
|
142
|
+
The gates prove what the tool measures; a human proves the measured set is what a person
|
|
143
|
+
actually *sees* (LEARNINGS "the gate vs your eyes"). The verdict is machine-checkable, so
|
|
144
|
+
this is a **machine** gate: `human-qa.js verify` re-fetches the latest round's verdict from
|
|
145
|
+
the PingHumans API (same authenticated JSON-RPC transport as `npx cpyany wait`; the
|
|
146
|
+
designer's existing cpyany login is reused) and exits 0 only on approval. The generated
|
|
147
|
+
test is **scope-pinned** — the reviewer judges only the cloned region, per-leaf compare
|
|
148
|
+
steps come from `coverage.json`, and JS behavior is marked *informational* in the human
|
|
149
|
+
template (the `behavior` phase gate is what PROVES dynamics now; the informational step is a
|
|
150
|
+
human backstop for taste/feel and anything intentionally excused in `behavior-deviations.json`,
|
|
151
|
+
not a substitute for the gate) — encoding the lessons stripe's 8 unconverged rounds paid for.
|
|
152
|
+
A rejection prints the human's notes as the fix list (PLAYBOOK Phase 6), and after fixing you
|
|
153
|
+
**refile**; verify always judges the latest round, so a stale approval can't carry a
|
|
154
|
+
regressed clone. Rounds and verdicts live in `targets/<name>/human-qa.json` — the receipt
|
|
155
|
+
pins ping_id + verdict content.
|
|
156
|
+
|
|
157
|
+
Or via the shim: `./bin/ppk status <name>` (symlink `bin/ppk` onto your PATH for `ppk status <name>`).
|
|
158
|
+
|
|
159
|
+
`new-target.js` seeds the workflow automatically, so a fresh target starts at phase 1.
|
|
160
|
+
|
|
161
|
+
### What `advance` enforces
|
|
162
|
+
- **In order.** Advancing phase N is refused while any earlier phase is still pending.
|
|
163
|
+
- **On a passing gate only.** If the gate fails, the advance is refused with the exact reason.
|
|
164
|
+
- **Attestation needs evidence.** An attested phase refuses to advance without `--evidence`
|
|
165
|
+
(and the evidence value must be real text, never a flag).
|
|
166
|
+
- **`--force` is the escape hatch — and it's never silent.** *Any* enforcement it bypasses —
|
|
167
|
+
ordering, missing attestation evidence, or a failing gate — records `forced:true` plus an
|
|
168
|
+
`overrode:[…]` list naming exactly what was skipped. The `done` gate refuses a workflow
|
|
169
|
+
containing any forced phase until it is cleanly re-advanced.
|
|
170
|
+
- **Refusals are receipted too.** A rejected advance (out of order, missing evidence, failing
|
|
171
|
+
gate) appends a `gate:"refused"` line to the ledger, so probing the gates leaves a trace.
|
|
172
|
+
|
|
173
|
+
### Receipts / audit trail
|
|
174
|
+
Every advance appends one line to `targets/<name>/workflow.jsonl`:
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
{"ts":"…","phase":"visual","runId":"a1b2c3d4e5","gate":"pass","forced":false,"overrode":[],"sha256":"…","artifact":"targets/x/clone.json","evidence":null,"reason":"--visual PASS — 128 comparisons, 0 fails"}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The `sha256` pins the exact artifact that was verified — a file's bytes, or for a directory
|
|
181
|
+
artifact every file's relative name **and contents** (so a same-named swap changes the hash).
|
|
182
|
+
The receipt proves *what* passed, not just *that* something did. And because `done` re-runs
|
|
183
|
+
every gate, a receipt going stale (artifact edited after certification) is caught before the
|
|
184
|
+
workflow can complete.
|
|
185
|
+
|
|
186
|
+
### State corruption & recovery
|
|
187
|
+
`workflow.json` is validated on every load. If it's truncated/corrupt/invalid-shape, every
|
|
188
|
+
command fails with the recovery instruction instead of a stack trace: re-seed with
|
|
189
|
+
`ppk init <name> --force`. A forced re-seed **appends a `reset` receipt to the ledger** (the
|
|
190
|
+
ledger itself is append-only and survives), then each phase re-advances by re-running its
|
|
191
|
+
gate against the artifacts on disk — nothing green can be claimed that isn't re-proven.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Files the workflow reads/writes (per target)
|
|
196
|
+
|
|
197
|
+
- `target.json` — url + fixed width (written by `new-target.js`).
|
|
198
|
+
- `workflow.json` — phase state (status / runId / sha256 / evidence per phase).
|
|
199
|
+
- `workflow.jsonl` — the append-only audit trail.
|
|
200
|
+
- `live.json` / `clone.json` — the measured snapshots (from the RUNBOOK).
|
|
201
|
+
- `coverage.json` — **you create this**: the enumerated painted leaves in the region, e.g.
|
|
202
|
+
`["logo","nav_first","cart_icon",…]`. The coverage gate checks each has a measured target.
|
|
203
|
+
- `deviations.json` — **you create this** to document accepted structural deltas, e.g.
|
|
204
|
+
`{ "nav_first": { "layout.display": "flex vs inline-block — identical pixels" } }`.
|
|
205
|
+
- `behaviors-live.json` / `behaviors-clone.json` — the discovered + measured JS-driven
|
|
206
|
+
dynamics on each page (from `tools/behavior-capture.js`'s `pxBehaviorDiscover()`), each
|
|
207
|
+
including the discovery pass's own metadata (scroll sweep range, observer duration,
|
|
208
|
+
elements scanned) as evidence the pass actually ran.
|
|
209
|
+
- `behavior-deviations.json` — **you create this** to document behaviors that are
|
|
210
|
+
genuinely irreproducible statically, e.g.
|
|
211
|
+
`{ "mutation:div.hero-canvas": { "reason": "WebGL generative — irreproducible statically" } }`.
|
|
212
|
+
- `human-qa.json` — the human QA rounds (ping_id, approve verdicts, latest fetched
|
|
213
|
+
result per round), written by `harness/human-qa.js`.
|
|
214
|
+
- `tunnel.json` — the public draft url (`harness/tunnel.js` records it only after
|
|
215
|
+
byte-verifying it serves `clone/index.html`); `human-qa.js file` uses it as the
|
|
216
|
+
default `--draft` and re-verifies at file time.
|
|
217
|
+
- `sink-tunnel.json` (workspace-level, not per-target) — a public url in front of the
|
|
218
|
+
snapshot sink (`harness/tunnel.js --sink`, verified by the sink's own empty-POST
|
|
219
|
+
signature), so live pages deliver captures with one `pxSend` call even when the
|
|
220
|
+
automation environment blocks page→localhost fetch.
|
|
221
|
+
- **Merged snapshots**: `tools/merge-snapshot.js` folds a partial re-capture into
|
|
222
|
+
`live.json`/`clone.json` for fast fix-loop iteration, stamping `merged:{at,keys}`.
|
|
223
|
+
The `done` gate refuses stamped snapshots — a fix can displace elements outside the
|
|
224
|
+
re-captured subset, so one final full capture is always required.
|
|
225
|
+
- **Micro-polls**: `human-qa.js poll` puts a ~$0.05 single question in front of a human
|
|
226
|
+
mid-round (recorded under `polls` in `human-qa.json`). Advisory only — the `human`
|
|
227
|
+
gate never reads polls; it requires an approving verdict on a full round.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Benchmark vs gajae-code (`gjc`)
|
|
232
|
+
|
|
233
|
+
gajae-code is the closest existing reference for "an enforced coding-agent workflow." We
|
|
234
|
+
studied its skills (`deep-interview`, `ralplan`, `ultragoal`) as a **pattern reference only** —
|
|
235
|
+
no runtime dependency on `gjc`. How the kit's workflow compares:
|
|
236
|
+
|
|
237
|
+
| dimension | gajae-code (`gjc`) | pixel-perfect-kit (`ppk`) |
|
|
238
|
+
|---|---|---|
|
|
239
|
+
| Gated pipeline | `deep-interview → ralplan → ultragoal → execute` | `target → assets → measure → build → visual → coverage → strict → behavior → human → done` |
|
|
240
|
+
| Refuses to advance | ambiguity must drop below a resolved threshold; mutation blocked pre-approval | each phase gate must exit 0; advances refused out-of-order or on a failing gate |
|
|
241
|
+
| Definition of done | checkpoint with `--evidence` + `--quality-gate-json` | a gate command that exits 0 (`--visual` green, coverage closed, strict documented) |
|
|
242
|
+
| Audit trail | `index.jsonl` with `run_id` / `path` / `sha256` per stage | `workflow.jsonl` with `runId` / `artifact` / content-`sha256` per advance — including **refused** advances and forced **resets** |
|
|
243
|
+
| Override | explicit force override, recorded | `--force` records `forced:true` + `overrode:[order\|evidence\|gate]`; `done` refuses forced phases until cleanly re-advanced |
|
|
244
|
+
| State corruption recovery | `gjc state clear --force --mode <skill>` re-seeds scoped state | corrupt `workflow.json` → self-describing error + `ppk init <name> --force` (receipted reset; ledger survives) |
|
|
245
|
+
| Objectivity | mathematical ambiguity score (partly heuristic) | **fully numeric** — the "done" signal is a pixel diff exiting 0, not a heuristic score |
|
|
246
|
+
|
|
247
|
+
**What we borrowed:** the gated-pipeline shape, receipt-based audit trail, and the
|
|
248
|
+
"can't-advance-until-the-gate-passes" discipline.
|
|
249
|
+
|
|
250
|
+
**What we deliberately didn't:** `gjc`'s multi-agent consensus roles (Planner/Architect/Critic)
|
|
251
|
+
and the mathematical ambiguity scorer. This domain has something `gjc`'s general workflow can't:
|
|
252
|
+
a **fully objective** done-signal (the pixel diff). We don't need a panel to argue about whether
|
|
253
|
+
the clone is right — the numbers decide. The one place human/vision judgment stays in the loop
|
|
254
|
+
is *detection* (PLAYBOOK Phase 6), and that already feeds the DEVELOP meta-loop.
|
|
255
|
+
|
|
256
|
+
**Possible future step (not built):** run the kit as a `gjc` skill so it becomes a domain
|
|
257
|
+
plugin on top of gajae-code's harness. Left out on purpose to keep the kit zero-dependency.
|
package/bin/ppk
ADDED
package/harness/adopt.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// harness/adopt.js — `ppk adopt <name> <url> [width]`: register an EXTERNALLY-BUILT
|
|
2
|
+
// draft for the kit's human-review loop.
|
|
3
|
+
//
|
|
4
|
+
// WHY: the human loop (scope-pinned compare rounds, changelogs on refiles, mandatory
|
|
5
|
+
// verdicts, deviations disclosure, the fix-loop discipline) is useful to ANY builder —
|
|
6
|
+
// ditto's capture-to-code output, a Lovable build, a hand-written recreation. Those
|
|
7
|
+
// builders get you most of the way; the loop with a real human closes the gap. Adopt
|
|
8
|
+
// creates just enough target state for `ppk tunnel` + `ppk human <name> …` to run,
|
|
9
|
+
// WITHOUT the pixel pipeline (no workflow gates — there's no captured live.json/clone.json
|
|
10
|
+
// pair to verify; the human's verdict is the whole check).
|
|
11
|
+
//
|
|
12
|
+
// USAGE: ppk adopt <name> <url> [width=1512]
|
|
13
|
+
// then: ppk tunnel <name> --url http://localhost:3000 (your dev server)
|
|
14
|
+
// ppk human <name> file [--region "…"] (the loop begins)
|
|
15
|
+
"use strict";
|
|
16
|
+
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const path = require("path");
|
|
19
|
+
|
|
20
|
+
const WORK = process.cwd();
|
|
21
|
+
|
|
22
|
+
function main() {
|
|
23
|
+
const [name, url, widthArg] = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
|
24
|
+
if (!name || !url) { console.error("usage: ppk adopt <name> <url> [width=1512]"); process.exit(2); }
|
|
25
|
+
let parsed;
|
|
26
|
+
try { parsed = new URL(url); } catch (e) { console.error(`"${url}" is not a valid url`); process.exit(2); }
|
|
27
|
+
const dir = path.join(WORK, "targets", name);
|
|
28
|
+
if (fs.existsSync(path.join(dir, "target.json"))) {
|
|
29
|
+
console.error(`targets/${name} already exists — pick another name, or continue its loop: ppk human ${name} verify`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
fs.writeFileSync(path.join(dir, "target.json"), JSON.stringify({ name, url: parsed.href, width: +(widthArg || 1512), adopted: true }, null, 2) + "\n");
|
|
34
|
+
fs.writeFileSync(path.join(dir, "NOTES.md"), `# ${name} — ${parsed.href} (ADOPTED external build — human loop only)
|
|
35
|
+
|
|
36
|
+
Built by an external tool (ditto / other); the kit runs the human-review loop, not the
|
|
37
|
+
pixel gates. Record each round: what the human flagged, the root cause, the fix.
|
|
38
|
+
|
|
39
|
+
| round | flagged | root cause | fix |
|
|
40
|
+
|-------|---------|------------|-----|
|
|
41
|
+
| | | | |
|
|
42
|
+
`);
|
|
43
|
+
console.log(`✓ adopted targets/${name} (original: ${parsed.href})
|
|
44
|
+
|
|
45
|
+
next:
|
|
46
|
+
1. run your build's dev server (e.g. ditto output: npm i && npm run dev)
|
|
47
|
+
2. ppk tunnel ${name} --url http://localhost:3000 # public, reachability-verified
|
|
48
|
+
3. ppk human ${name} file [--region "…"] # first review round
|
|
49
|
+
loop: fix what the human pins → refile with --changelog "…" → until the verdict approves.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
main();
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// harness/agent-setup.js — `ppk agent-setup`: teach the user's AI agent to use the kit.
|
|
2
|
+
//
|
|
3
|
+
// The kit's entire new-user experience is "install, then ask your agent to clone a
|
|
4
|
+
// site" — this is the command that makes the second half true. It installs the kit's
|
|
5
|
+
// clone-site skill into the user's agent skill directory (~/.claude/skills/), where
|
|
6
|
+
// Claude Code auto-discovers it: from then on, "clone https://example.com" in any
|
|
7
|
+
// session triggers the skill, which drives the full ppk pipeline and treats the user
|
|
8
|
+
// as the reviewer.
|
|
9
|
+
//
|
|
10
|
+
// USAGE: ppk agent-setup [--force] (--force overwrites an existing install)
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const os = require("os");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
|
|
17
|
+
const PKG = path.resolve(__dirname, "..");
|
|
18
|
+
|
|
19
|
+
function install(homeDir, force) {
|
|
20
|
+
// Every skill the kit ships lives in PKG/skill/<skill-name>/SKILL.md — install them
|
|
21
|
+
// all (pixel-perfect-clone: the full gated pipeline; ditto-finish: bring-your-own
|
|
22
|
+
// builder + the human loop). One kit, several front doors.
|
|
23
|
+
const skillRoot = path.join(PKG, "skill");
|
|
24
|
+
if (!fs.existsSync(skillRoot)) return { ok: false, message: `kit skills missing at ${skillRoot} — broken install; reinstall pixel-perfect-kit`, installed: [] };
|
|
25
|
+
const names = fs.readdirSync(skillRoot, { withFileTypes: true }).filter((e) => e.isDirectory() && fs.existsSync(path.join(skillRoot, e.name, "SKILL.md"))).map((e) => e.name);
|
|
26
|
+
if (!names.length) return { ok: false, message: `no skills found under ${skillRoot} — broken install; reinstall pixel-perfect-kit`, installed: [] };
|
|
27
|
+
|
|
28
|
+
const installed = [], skipped = [];
|
|
29
|
+
for (const n of names) {
|
|
30
|
+
const dest = path.join(homeDir, ".claude", "skills", n, "SKILL.md");
|
|
31
|
+
if (fs.existsSync(dest) && !force) { skipped.push(n); continue; }
|
|
32
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
33
|
+
fs.copyFileSync(path.join(skillRoot, n, "SKILL.md"), dest);
|
|
34
|
+
installed.push(n);
|
|
35
|
+
}
|
|
36
|
+
if (!installed.length) {
|
|
37
|
+
return { ok: false, message: `already installed (${skipped.join(", ")}) — re-run with --force to overwrite with this kit version's copies`, installed };
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, installed, message: `✓ installed skill(s): ${installed.join(", ")}${skipped.length ? ` (kept existing: ${skipped.join(", ")} — --force to refresh)` : ""}
|
|
40
|
+
→ ${path.join(homeDir, ".claude", "skills")}
|
|
41
|
+
Your agent picks them up on its next session. Then just ask it:
|
|
42
|
+
"Clone https://example.com pixel-perfect." (the full gated pipeline)
|
|
43
|
+
"Clone https://example.com with ditto and polish it." (ditto build + human rounds)
|
|
44
|
+
(You'll be the reviewer: answer the pings — pin what looks wrong, always pick a verdict button.)` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function main() {
|
|
48
|
+
const force = process.argv.includes("--force");
|
|
49
|
+
const r = install(os.homedir(), force);
|
|
50
|
+
console.log(r.message);
|
|
51
|
+
process.exit(r.ok ? 0 : 1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (require.main === module) main();
|
|
55
|
+
module.exports = { install };
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// harness/behavior-selftest.js — guards the `behavior` phase gate (harness/workflow.js's
|
|
2
|
+
// behaviorGate). Offline, socket-free: drives the gate purely through
|
|
3
|
+
// targets/<name>/behaviors-live.json / behaviors-clone.json / behavior-deviations.json
|
|
4
|
+
// fixtures on disk (no browser, no network) — the same file-fixture pattern
|
|
5
|
+
// workflow-selftest.js uses for live.json/clone.json.
|
|
6
|
+
//
|
|
7
|
+
// Asserts the contracts the design contract calls out explicitly:
|
|
8
|
+
// - blocks on missing inventory (live, then clone)
|
|
9
|
+
// - blocks on a live behavior absent from the clone inventory, NAMED in the reason
|
|
10
|
+
// - blocks on out-of-tolerance values (speed, opacity, transform mismatch)
|
|
11
|
+
// - passes on within-tolerance reproduction
|
|
12
|
+
// - passes on a documented deviation
|
|
13
|
+
// - fails a paint-over of "no discovery ran" (empty behaviors + no discovery metadata)
|
|
14
|
+
// - passes when discovery legitimately found nothing (metadata present, behaviors empty)
|
|
15
|
+
"use strict";
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const os = require("os");
|
|
18
|
+
const path = require("path");
|
|
19
|
+
const cp = require("child_process");
|
|
20
|
+
|
|
21
|
+
const KIT = path.resolve(__dirname, "..");
|
|
22
|
+
const WF = path.join(KIT, "harness", "workflow.js");
|
|
23
|
+
let failed = 0;
|
|
24
|
+
const ok = (cond, msg) => { if (cond) console.log(` ✓ ${msg}`); else { failed++; console.log(` ✗ ${msg}`); } };
|
|
25
|
+
|
|
26
|
+
const run = (args) => { const r = cp.spawnSync(process.execPath, [WF, ...args], { encoding: "utf8", cwd: KIT }); return { code: r.status, out: (r.stdout || "") + (r.stderr || "") }; };
|
|
27
|
+
|
|
28
|
+
const NAME = "behaviorselftest_" + process.pid;
|
|
29
|
+
const dir = path.join(KIT, "targets", NAME);
|
|
30
|
+
const cleanup = () => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) {} };
|
|
31
|
+
process.on("exit", cleanup);
|
|
32
|
+
cleanup();
|
|
33
|
+
fs.mkdirSync(path.join(dir, "clone", "assets"), { recursive: true });
|
|
34
|
+
|
|
35
|
+
const writeJson = (f, o) => fs.writeFileSync(path.join(dir, f), JSON.stringify(o, null, 2));
|
|
36
|
+
|
|
37
|
+
// discovery metadata that COUNTS as "discovery actually ran" — mirrors the shape
|
|
38
|
+
// tools/behavior-capture.js's discover() emits
|
|
39
|
+
const discoveryMeta = (extra) => Object.assign({
|
|
40
|
+
startedAt: "2026-07-02T00:00:00.000Z", endedAt: "2026-07-02T00:00:03.000Z", durationMs: 3000,
|
|
41
|
+
scrollSweep: { from: 0, to: 4000, steps: 6 },
|
|
42
|
+
observeMs: 1500,
|
|
43
|
+
elementsScanned: 812,
|
|
44
|
+
staticCandidateCount: 5,
|
|
45
|
+
keyframesFound: ["logo-belt"],
|
|
46
|
+
hoverTriggersProbed: [],
|
|
47
|
+
marqueeSelectorsProbed: [],
|
|
48
|
+
}, extra || {});
|
|
49
|
+
|
|
50
|
+
console.log("behavior-selftest — the `behavior` phase gate");
|
|
51
|
+
run(["init", NAME]); // seed workflow.json (harmless if it errors on a fresh dir — ignored)
|
|
52
|
+
|
|
53
|
+
// ── missing live inventory blocks ────────────────────────────────────────────
|
|
54
|
+
ok(run(["gate", NAME, "behavior"]).code === 1, "blocks with no behaviors-live.json");
|
|
55
|
+
const missLive = run(["gate", NAME, "behavior"]);
|
|
56
|
+
ok(/behaviors-live\.json missing/.test(missLive.out), "reason names the missing live inventory file");
|
|
57
|
+
|
|
58
|
+
// ── a paint-over of "no discovery ran" fails: empty behaviors, NO discovery metadata ──
|
|
59
|
+
writeJson("behaviors-live.json", { url: "https://example.com/", behaviors: {} });
|
|
60
|
+
const paintOver = run(["gate", NAME, "behavior"]);
|
|
61
|
+
ok(paintOver.code === 1, "blocks when behaviors-live.json has an empty inventory but NO discovery metadata (paint-over)");
|
|
62
|
+
ok(/discovery pass metadata|paint-over/i.test(paintOver.out), "reason calls out the missing discovery evidence, not just 'no behaviors'");
|
|
63
|
+
|
|
64
|
+
// ── legitimate "discovery ran, found nothing" passes and cites the evidence ──
|
|
65
|
+
writeJson("behaviors-live.json", { url: "https://example.com/", discovery: discoveryMeta(), behaviors: {} });
|
|
66
|
+
const noneFound = run(["gate", NAME, "behavior"]);
|
|
67
|
+
ok(noneFound.code === 0, "passes when discovery metadata is present and genuinely found zero behaviors");
|
|
68
|
+
ok(/elements scanned/.test(noneFound.out) && /scroll swept 0.4000px/.test(noneFound.out.replace(/→/, ".")), "pass reason cites discovery metadata (elements scanned, scroll sweep) as evidence discovery ran");
|
|
69
|
+
|
|
70
|
+
// ── a live behavior with no clone inventory at all blocks ───────────────────
|
|
71
|
+
writeJson("behaviors-live.json", {
|
|
72
|
+
url: "https://example.com/", discovery: discoveryMeta(),
|
|
73
|
+
behaviors: { "marquee:logo_belt": { trigger: "load", kind: "marquee", measured: { pxPerSec: 35.4 } } },
|
|
74
|
+
});
|
|
75
|
+
const noClone = run(["gate", NAME, "behavior"]);
|
|
76
|
+
ok(noClone.code === 1 && /behaviors-clone\.json missing/.test(noClone.out) && /marquee:logo_belt/.test(noClone.out), "blocks when clone inventory is entirely absent, names the live behavior waiting on it");
|
|
77
|
+
|
|
78
|
+
// ── a live behavior absent from the clone inventory blocks, NAMED ───────────
|
|
79
|
+
writeJson("behaviors-clone.json", { url: "http://localhost:8080/", discovery: discoveryMeta(), behaviors: {} });
|
|
80
|
+
const missingOne = run(["gate", NAME, "behavior"]);
|
|
81
|
+
ok(missingOne.code === 1 && /MISSING/.test(missingOne.out) && /marquee:logo_belt/.test(missingOne.out), "blocks when a live behavior is silently missing from the clone, and names it");
|
|
82
|
+
|
|
83
|
+
// ── out-of-tolerance speed blocks (2x speed marquee must not slip through) ──
|
|
84
|
+
writeJson("behaviors-clone.json", {
|
|
85
|
+
url: "http://localhost:8080/", discovery: discoveryMeta(),
|
|
86
|
+
behaviors: { "marquee:logo_belt": { trigger: "load", kind: "marquee", measured: { pxPerSec: 70.8 } } }, // exactly 2x live
|
|
87
|
+
});
|
|
88
|
+
const tooFast = run(["gate", NAME, "behavior"]);
|
|
89
|
+
ok(tooFast.code === 1 && /pxPerSec/.test(tooFast.out) && /marquee:logo_belt/.test(tooFast.out), "blocks a 2x-speed marquee (out of the ±15% pxPerSec tolerance)");
|
|
90
|
+
|
|
91
|
+
// ── within-tolerance speed passes (absorbs sampling noise) ───────────────────
|
|
92
|
+
writeJson("behaviors-clone.json", {
|
|
93
|
+
url: "http://localhost:8080/", discovery: discoveryMeta(),
|
|
94
|
+
behaviors: { "marquee:logo_belt": { trigger: "load", kind: "marquee", measured: { pxPerSec: 36.9 } } }, // ~4% off — within ±15%
|
|
95
|
+
});
|
|
96
|
+
const closeEnough = run(["gate", NAME, "behavior"]);
|
|
97
|
+
ok(closeEnough.code === 0, "passes a marquee sampled ~4% off live speed (within the documented ±15% tolerance)");
|
|
98
|
+
|
|
99
|
+
// ── trigger technique mismatch blocks even if the numbers happen to line up ──
|
|
100
|
+
writeJson("behaviors-live.json", {
|
|
101
|
+
url: "https://example.com/", discovery: discoveryMeta(),
|
|
102
|
+
behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { before: { opacity: 0, transform: "translateY(20px)", filter: "none" }, after: { opacity: 1, transform: "none", filter: "none" } } } },
|
|
103
|
+
});
|
|
104
|
+
writeJson("behaviors-clone.json", {
|
|
105
|
+
url: "http://localhost:8080/", discovery: discoveryMeta(),
|
|
106
|
+
behaviors: { "reveal:hero_heading": { trigger: "load", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "none", filter: "none" } } } },
|
|
107
|
+
});
|
|
108
|
+
const wrongTrigger = run(["gate", NAME, "behavior"]);
|
|
109
|
+
ok(wrongTrigger.code === 1 && /trigger/.test(wrongTrigger.out) && /reveal:hero_heading/.test(wrongTrigger.out), "blocks when the reproduction technique (trigger) differs from live, even with matching end-state numbers");
|
|
110
|
+
|
|
111
|
+
// ── out-of-tolerance end-state opacity blocks (clone frozen short of live's reveal) ──
|
|
112
|
+
writeJson("behaviors-clone.json", {
|
|
113
|
+
url: "http://localhost:8080/", discovery: discoveryMeta(),
|
|
114
|
+
behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 0.4, transform: "none", filter: "none" } } } },
|
|
115
|
+
});
|
|
116
|
+
const frozen = run(["gate", NAME, "behavior"]);
|
|
117
|
+
ok(frozen.code === 1 && /opacity/.test(frozen.out) && /hero_heading/.test(frozen.out), "blocks a reveal frozen short of live's end-state opacity");
|
|
118
|
+
|
|
119
|
+
// ── out-of-tolerance end-state transform blocks (live fully translates away, clone doesn't) ──
|
|
120
|
+
writeJson("behaviors-live.json", {
|
|
121
|
+
url: "https://example.com/", discovery: discoveryMeta(),
|
|
122
|
+
behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "translateY(-40px)", filter: "none" } } } },
|
|
123
|
+
});
|
|
124
|
+
writeJson("behaviors-clone.json", {
|
|
125
|
+
url: "http://localhost:8080/", discovery: discoveryMeta(),
|
|
126
|
+
behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "translateY(-10px)", filter: "none" } } } },
|
|
127
|
+
});
|
|
128
|
+
const wrongTransform = run(["gate", NAME, "behavior"]);
|
|
129
|
+
ok(wrongTransform.code === 1 && /transform/.test(wrongTransform.out) && /hero_heading/.test(wrongTransform.out), "blocks when the reproduced end-state transform string doesn't match live's exactly");
|
|
130
|
+
|
|
131
|
+
// ── matrix translation jitter passes (computed transforms are matrices; layout rounds them) ──
|
|
132
|
+
writeJson("behaviors-live.json", {
|
|
133
|
+
url: "https://example.com/", discovery: discoveryMeta(),
|
|
134
|
+
behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "matrix(1, 0, 0, 1, 0, -40.003)", filter: "none" } } } },
|
|
135
|
+
});
|
|
136
|
+
writeJson("behaviors-clone.json", {
|
|
137
|
+
url: "http://localhost:8080/", discovery: discoveryMeta(),
|
|
138
|
+
behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "matrix(1, 0, 0, 1, 0, -40.2)", filter: "none" } } } },
|
|
139
|
+
});
|
|
140
|
+
ok(run(["gate", NAME, "behavior"]).code === 0, "passes matrix transforms whose translation differs by sub-0.5px layout rounding (matrix-aware compare, not string equality)");
|
|
141
|
+
|
|
142
|
+
// ── observed-mutation (interval rotations) compare by PRESENCE, not frame snapshots ──
|
|
143
|
+
// A rotating hero's transform at snapshot time is whatever frame it was on — two captures of
|
|
144
|
+
// a continuously-mutating element can never agree on end-state floats. Presence + trigger
|
|
145
|
+
// match are the contract; a clone that ALSO rotates passes, one missing the key blocks.
|
|
146
|
+
writeJson("behaviors-live.json", {
|
|
147
|
+
url: "https://example.com/", discovery: discoveryMeta(),
|
|
148
|
+
behaviors: { "mutation:div.hero-rotator": { trigger: "mutation", kind: "observed-mutation", measured: { after: { opacity: 1, transform: "matrix(1, 0, 0, 1, 0, -333.2)", filter: "none" } } } },
|
|
149
|
+
});
|
|
150
|
+
writeJson("behaviors-clone.json", {
|
|
151
|
+
url: "http://localhost:8080/", discovery: discoveryMeta(),
|
|
152
|
+
behaviors: { "mutation:div.hero-rotator": { trigger: "mutation", kind: "observed-mutation", measured: { after: { opacity: 0.4, transform: "matrix(1, 0, 0, 1, 0, -80)", filter: "none" } } } },
|
|
153
|
+
});
|
|
154
|
+
ok(run(["gate", NAME, "behavior"]).code === 0, "passes an interval rotation whose frame snapshots differ (observed-mutation: presence+trigger are the contract, frames are nondeterministic)");
|
|
155
|
+
writeJson("behaviors-clone.json", { url: "http://localhost:8080/", discovery: discoveryMeta(), behaviors: {} });
|
|
156
|
+
ok(run(["gate", NAME, "behavior"]).code === 1, "an observed-mutation key missing from the clone still blocks (the clone must rotate at all)");
|
|
157
|
+
|
|
158
|
+
// reset live back to the simple opacity-reveal case used by the remaining assertions below
|
|
159
|
+
writeJson("behaviors-live.json", {
|
|
160
|
+
url: "https://example.com/", discovery: discoveryMeta(),
|
|
161
|
+
behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { before: { opacity: 0, transform: "translateY(20px)", filter: "none" }, after: { opacity: 1, transform: "none", filter: "none" } } } },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── within-tolerance reproduction passes ─────────────────────────────────────
|
|
165
|
+
writeJson("behaviors-clone.json", {
|
|
166
|
+
url: "http://localhost:8080/", discovery: discoveryMeta(),
|
|
167
|
+
behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 0.98, transform: "none", filter: "none" } } } }, // opacity 0.02 off — within ±0.05
|
|
168
|
+
});
|
|
169
|
+
ok(run(["gate", NAME, "behavior"]).code === 0, "passes a reveal that lands within the ±0.05 opacity tolerance with matching transform/trigger");
|
|
170
|
+
|
|
171
|
+
// ── multiple live behaviors: one within tolerance, one documented deviation ─
|
|
172
|
+
writeJson("behaviors-live.json", {
|
|
173
|
+
url: "https://example.com/", discovery: discoveryMeta(),
|
|
174
|
+
behaviors: {
|
|
175
|
+
"reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "none", filter: "none" } } },
|
|
176
|
+
"generative:webgl_bg": { trigger: "load", kind: "canvas-generative", measured: { after: { opacity: 1 } } },
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
writeJson("behaviors-clone.json", {
|
|
180
|
+
url: "http://localhost:8080/", discovery: discoveryMeta(),
|
|
181
|
+
behaviors: { "reveal:hero_heading": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "none", filter: "none" } } } },
|
|
182
|
+
});
|
|
183
|
+
const undocumented = run(["gate", NAME, "behavior"]);
|
|
184
|
+
ok(undocumented.code === 1 && /generative:webgl_bg/.test(undocumented.out) && /MISSING/.test(undocumented.out), "an undocumented missing behavior still blocks even when other behaviors reproduce cleanly");
|
|
185
|
+
writeJson("behavior-deviations.json", { "generative:webgl_bg": { reason: "WebGL generative canvas — irreproducible statically, per-frame noise procedurally generated" } });
|
|
186
|
+
const withDeviation = run(["gate", NAME, "behavior"]);
|
|
187
|
+
ok(withDeviation.code === 0 && /documented deviation/.test(withDeviation.out) && /generative:webgl_bg/.test(withDeviation.out), "passes once the irreproducible behavior is documented in behavior-deviations.json, and says so");
|
|
188
|
+
|
|
189
|
+
// a deviation entry with an empty/missing reason doesn't count as documented (never a free pass)
|
|
190
|
+
writeJson("behavior-deviations.json", { "generative:webgl_bg": { reason: "" } });
|
|
191
|
+
ok(run(["gate", NAME, "behavior"]).code === 1, "an empty-reason deviation entry does NOT count as documented — still blocks");
|
|
192
|
+
fs.rmSync(path.join(dir, "behavior-deviations.json"));
|
|
193
|
+
|
|
194
|
+
// ── DECLARED rows (environment-inverted runs): every supposed-to-move element needs a
|
|
195
|
+
// disposition — reproduced (descriptor observed firing on the clone) or excused.
|
|
196
|
+
// Silently dropping one is how a single invented animation replaces two real ones. ──
|
|
197
|
+
writeJson("behaviors-live.json", {
|
|
198
|
+
url: "https://example.com/", discovery: discoveryMeta(),
|
|
199
|
+
behaviors: {},
|
|
200
|
+
declared: {
|
|
201
|
+
"declared:video#intro-video": { hints: ["video:scripted:none", "attr:data-video-load-kf"], startState: { opacity: 1, transform: "none", filter: "none" }, text: null },
|
|
202
|
+
"declared:span.gradient-headline": { hints: ["transition-from-start-state"], startState: { opacity: 0, transform: "none", filter: "none" }, text: "18MP Center Stage front" },
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
writeJson("behaviors-clone.json", { url: "http://localhost:8080/", discovery: discoveryMeta(), behaviors: {} });
|
|
206
|
+
const undisposed = run(["gate", NAME, "behavior"]);
|
|
207
|
+
ok(undisposed.code === 1 && /DECLARED/.test(undisposed.out) && /behavior-worksheet/.test(undisposed.out), "undisposed declared rows block, pointing at the worksheet");
|
|
208
|
+
ok(/intro-video/.test(undisposed.out), "the blocked reason names the undisposed declared row");
|
|
209
|
+
|
|
210
|
+
// reproduced via descriptor match: the clone observed the same element firing under an
|
|
211
|
+
// observed prefix (reveal:) — declared:span.gradient-headline ↔ reveal:span.gradient-headline
|
|
212
|
+
writeJson("behaviors-clone.json", {
|
|
213
|
+
url: "http://localhost:8080/", discovery: discoveryMeta(),
|
|
214
|
+
behaviors: { "reveal:span.gradient-headline": { trigger: "scroll", kind: "class-toggle-or-style-mutation", measured: { after: { opacity: 1, transform: "none", filter: "none" } } } },
|
|
215
|
+
});
|
|
216
|
+
writeJson("behavior-deviations.json", { "declared:video#intro-video": { reason: "phone-screen video asset not shipped in the captured DOM — awaiting reviewer description of live playback" } });
|
|
217
|
+
const disposed = run(["gate", NAME, "behavior"]);
|
|
218
|
+
ok(disposed.code === 0 && /2 declared row\(s\) disposed \(1 reproduced, 1 excused\)/.test(disposed.out), "declared rows pass once each is reproduced (descriptor match) or excused, and the receipt counts them");
|
|
219
|
+
|
|
220
|
+
// worksheet CLI mirrors the same disposition logic + prints poll questions for unresolved
|
|
221
|
+
{
|
|
222
|
+
const cpx = require("child_process");
|
|
223
|
+
fs.rmSync(path.join(dir, "behavior-deviations.json"));
|
|
224
|
+
const w1 = cpx.spawnSync(process.execPath, [path.join(KIT, "tools", "behavior-worksheet.js"), NAME], { encoding: "utf8", cwd: KIT });
|
|
225
|
+
ok(w1.status === 1 && /UNRESOLVED/.test(w1.stdout + w1.stderr) && /human-qa\.js poll/.test(w1.stdout + w1.stderr), "worksheet exits 1 on unresolved rows and prints a ready-to-send one-sided poll question");
|
|
226
|
+
writeJson("behavior-deviations.json", { "declared:video#intro-video": { reason: "awaiting reviewer description" } });
|
|
227
|
+
const w2 = cpx.spawnSync(process.execPath, [path.join(KIT, "tools", "behavior-worksheet.js"), NAME], { encoding: "utf8", cwd: KIT });
|
|
228
|
+
ok(w2.status === 0 && /worksheet clean/.test(w2.stdout), "worksheet exits 0 once every row is disposed");
|
|
229
|
+
}
|
|
230
|
+
fs.rmSync(path.join(dir, "behavior-deviations.json"), { force: true });
|
|
231
|
+
fs.rmSync(path.join(dir, "behaviors-live.json"), { force: true });
|
|
232
|
+
fs.rmSync(path.join(dir, "behaviors-clone.json"), { force: true });
|
|
233
|
+
|
|
234
|
+
// ── advance wiring: behavior sits between strict and human in phaseOrder ────
|
|
235
|
+
{
|
|
236
|
+
const { PHASES } = require(WF);
|
|
237
|
+
const keys = PHASES.map((p) => p.key);
|
|
238
|
+
ok(keys.indexOf("behavior") === keys.indexOf("strict") + 1, "behavior phase immediately follows strict");
|
|
239
|
+
ok(keys.indexOf("human") === keys.indexOf("behavior") + 1, "human phase immediately follows behavior");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
console.log(failed ? `\n❌ behavior-selftest: ${failed} assertion(s) failed.` : "\n✓ behavior-selftest: all assertions pass — the gate blocks when unmet and passes when met.");
|
|
243
|
+
process.exit(failed ? 1 : 0);
|