Skip to content

Commit c8e4e3a

Browse files
Merge pull request #62 from CodeWithJuber/claude/uicheck-interaction-loop
feat(uicheck): Playwright interaction loop (forge uicheck interact)
2 parents d3bed8c + 4cf902f commit c8e4e3a

5 files changed

Lines changed: 386 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **Playwright interaction loop**`forge uicheck interact <file-or-url>` drives the
12+
page headless under `prefers-reduced-motion` and checks what it *does*
13+
(console-clean, keyboard-reachable, focus-visible, reduced-motion), where
14+
`uicheck visual` only fingerprints what it paints. The verdict is recorded through
15+
the ledger's cross-family-gated `behavioral` oracle (advisory by default;
16+
`--record` appends it as evidence on the project `fingerprint` claim, `--enforce`
17+
gates). Reuses the visual gate's Playwright resolver + loopback-only target guard;
18+
Playwright stays an optional tier (ADR-0005) with a graceful skip. New
19+
`src/uiinteract.js` (`runInteractions`, `summarizeVerdict`, `verdictOutcome`,
20+
`recordInteraction`) with a browser-free test suite.
921
## [0.15.0] - 2026-07-15
1022

1123
### Added

docs/GUIDE.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -660,8 +660,8 @@ tree (your uncommitted changes wouldn't be in the run); commit/stash first or pa
660660

661661
### `forge uicheck` — deterministic UI checks
662662

663-
Four subcommands: three are static parsing — no LLM, no screenshots — and `visual`
664-
optionally drives a real browser.
663+
Five subcommands: three are static parsing — no LLM, no screenshots — and `visual`
664+
and `interact` optionally drive a real browser.
665665

666666
**`contrast <fg> <bg>`** — exact WCAG math, asserted, never guessed (bare
667667
`forge uicheck <fg> <bg>` still works):
@@ -720,6 +720,33 @@ Forge uicheck visual — rendered fingerprint + design gate
720720
✓ PASS
721721
```
722722

723+
**`interact <file-or-url> [--record] [--enforce] [--json] [--remote]`** — the Playwright
724+
_interaction_ loop: where `visual` fingerprints what the page **paints**, `interact`
725+
checks what it **does**, headless under `prefers-reduced-motion`. Four checks:
726+
`console-clean` (no console errors on load), `keyboard-reachable` (Tab lands on an
727+
interactive control), `focus-visible` (the focused control shows a visible focus ring —
728+
WCAG 2.4.7), and `reduced-motion` (nothing animates when reduced motion is requested).
729+
The verdict is **advisory** by default — it is recorded through the ledger's weakest,
730+
cross-family-gated `behavioral` oracle, so a lone interaction run can never move a claim
731+
on its own (overview §4 honesty register). `--record` appends the verdict as evidence on
732+
your minted project `fingerprint` claim; `--enforce` (or `FORGE_ENFORCE=1`) turns a fail
733+
into a non-zero exit. Playwright and the loopback-only target guard are shared with
734+
`visual` (same _optional tier_, same `--remote` rule).
735+
736+
```console
737+
$ forge uicheck interact src/dash.html --record
738+
Forge uicheck interact — browser interaction checks
739+
740+
driven: file:///…/src/dash.html (headless, prefers-reduced-motion)
741+
✓ console-clean: no console errors on load
742+
✓ keyboard-reachable: Tab reached <button>
743+
✓ focus-visible: the focused control shows a visible focus indicator
744+
✓ reduced-motion: no animations run under prefers-reduced-motion
745+
746+
✓ PASS (advisory)
747+
recorded as behavioral evidence on design claim e7a90b12cd34
748+
```
749+
723750
### `forge dash [--port N]` — the local dashboard
724751

725752
A read-only lens over `.forge/` — stdlib `node:http`, localhost-only, one

src/cli.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1561,6 +1561,63 @@ async function run(argv) {
15611561
if (r.fail) process.exitCode = 1;
15621562
return;
15631563
}
1564+
if (sub === "interact") {
1565+
// The Playwright interaction loop (ROADMAP "Next"): drive the page and check what
1566+
// it DOES — keyboard reach, a visible focus ring, console cleanliness, reduced-
1567+
// motion honesty. Advisory by default (the `behavioral` oracle is cross-family-
1568+
// gated); --enforce or FORGE_ENFORCE=1 turns a fail into a non-zero exit. Playwright
1569+
// is an optional tier (ADR-0005) — its absence is a note and exit 0, never a failure.
1570+
const { runInteractions, recordInteraction } = await import("./uiinteract.js");
1571+
const args = argv.slice(2);
1572+
const json = args.includes("--json");
1573+
const remote = args.includes("--remote");
1574+
const record = args.includes("--record");
1575+
const enforce = args.includes("--enforce") || process.env.FORGE_ENFORCE === "1";
1576+
const targets = args.filter((a) => !a.startsWith("--"));
1577+
if (targets.length !== 1) {
1578+
console.error(
1579+
`usage: ${BRAND.cli} uicheck interact <file-or-url> [--record] [--enforce] [--json] [--remote]`,
1580+
);
1581+
process.exitCode = 1;
1582+
return;
1583+
}
1584+
const r = await runInteractions(targets[0], { remote, cwd: process.cwd() });
1585+
if (!r.ok) {
1586+
const reason = "reason" in r ? r.reason : "interaction run failed";
1587+
if ("skipped" in r && r.skipped) {
1588+
// Graceful absence (ADR-0005): a missing optional tier is not a failure.
1589+
if (json) console.log(JSON.stringify({ skipped: true, reason }, null, 2));
1590+
else {
1591+
heading(`${BRAND.brand} uicheck interact — skipped (no browser runtime)\n`);
1592+
console.log(` ${reason}`);
1593+
console.log(
1594+
" enable it: npm i -D playwright-core (or point FORGE_PLAYWRIGHT at an existing install)",
1595+
);
1596+
}
1597+
return; // exit 0 — the advisory tier is absent
1598+
}
1599+
console.error(reason);
1600+
process.exitCode = 1;
1601+
return;
1602+
}
1603+
const recorded = record ? recordInteraction(process.cwd(), r.url, r.verdict) : null;
1604+
if (json) {
1605+
console.log(JSON.stringify({ url: r.url, ...r.verdict, recorded }, null, 2));
1606+
} else {
1607+
heading(`${BRAND.brand} uicheck interact — browser interaction checks\n`);
1608+
console.log(` driven: ${r.url} (headless, prefers-reduced-motion)`);
1609+
for (const c of r.verdict.checks) console.log(` ${c.ok ? "✓" : "✗"} ${c.id}: ${c.detail}`);
1610+
console.log(`\n ${r.verdict.pass ? "✓ PASS" : "✗ FAIL"}${enforce ? "" : " (advisory)"}`);
1611+
if (recorded)
1612+
console.log(
1613+
recorded.recorded
1614+
? ` recorded as behavioral evidence on design claim ${recorded.claimId.slice(0, 12)}`
1615+
: ` not recorded: ${recorded.reason}`,
1616+
);
1617+
}
1618+
if (!r.verdict.pass && enforce) process.exitCode = 1;
1619+
return;
1620+
}
15641621
if (sub === "fingerprint" || sub === "design") {
15651622
const ui = await import("./uifingerprint.js");
15661623
// `--taste <name>` (design only) takes a VALUE — splice it out before the

src/uiinteract.js

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// forge uiinteract — the OPTIONAL Playwright interaction loop (ROADMAP "Next";
2+
// docs/plans/substrate-v2/07-ui-quality-gate.md). `uicheck visual` fingerprints what
3+
// the browser PAINTS; this checks what it DOES — keyboard reachability, a visible
4+
// focus ring, console cleanliness, and reduced-motion honesty — and feeds the verdict
5+
// back as `behavioral` oracle evidence on the project design (`fingerprint`) claim.
6+
//
7+
// `behavioral` is the ledger's weakest, cross-family-gated oracle (ledger.js ORACLES,
8+
// w=0.3): a lone interaction verdict can never move a claim on its own. That is the
9+
// point — this stays advisory until fixtures promote it (overview §4 honesty register),
10+
// the same guard-over-prose discipline as the visual gate.
11+
//
12+
// ADR-0005: this module is node stdlib only. Playwright is resolved dynamically via
13+
// uivisual.resolvePlaywright(); its absence is a graceful skip, never a failure —
14+
// exactly like `uicheck visual`. The target passes through uivisual.resolveTarget()'s
15+
// security guard (loopback-only unless --remote) BEFORE any browser is launched.
16+
import { outcomeRecord } from "./ledger.js";
17+
import { appendEvidence, loadClaims, repoLedger } from "./ledger_store.js";
18+
import { DEFAULT_VIEWPORTS, resolvePlaywright, resolveTarget } from "./uivisual.js";
19+
import { contentHash } from "./util.js";
20+
21+
/** The interaction checks, in the order they run. Pure data so docs + tests can name them. */
22+
export const INTERACTION_CHECK_IDS = [
23+
"console-clean",
24+
"keyboard-reachable",
25+
"focus-visible",
26+
"reduced-motion",
27+
];
28+
29+
/** Aggregate per-check results into a verdict. Empty → not a pass (nothing was proven). */
30+
export function summarizeVerdict(checks) {
31+
const list = Array.isArray(checks) ? checks : [];
32+
return { pass: list.length > 0 && list.every((c) => c?.ok === true), checks: list };
33+
}
34+
35+
/**
36+
* Express a verdict in the ledger's evidence vocabulary: a `behavioral` outcome that
37+
* confirms (pass) or contradicts (fail) the design claim. The ref is content-addressed
38+
* on the checks so re-running the same verdict is idempotent (appendEvidence dedupes).
39+
* @param {string} url
40+
* @param {{pass:boolean, checks:any[]}} verdict
41+
* @param {{author?:string, t?:number}} [opts]
42+
*/
43+
export function verdictOutcome(url, verdict, { author = "forge-uiinteract", t = 0 } = {}) {
44+
const ref = `ui-interact:${url}#${contentHash(JSON.stringify(verdict?.checks ?? []))}`;
45+
return outcomeRecord({
46+
oracle: "behavioral",
47+
result: verdict?.pass ? "confirm" : "contradict",
48+
ref,
49+
author,
50+
t,
51+
});
52+
}
53+
54+
/**
55+
* Record a verdict as evidence on the project's design (`fingerprint`) claim. No claim
56+
* yet → a no-op with guidance (mirrors `uicheck visual`), never an error.
57+
* @param {string} root
58+
* @param {string} url
59+
* @param {{pass:boolean, checks:any[]}} verdict
60+
* @param {{author?:string, t?:number}} [opts]
61+
* @returns {{recorded:boolean, claimId?:string, reason?:string}}
62+
*/
63+
export function recordInteraction(root, url, verdict, { author = "forge-uiinteract", t = 0 } = {}) {
64+
const dir = repoLedger(root);
65+
const claim = loadClaims(dir).find((c) => c.kind === "fingerprint" && !c.tombstone);
66+
if (!claim)
67+
return {
68+
recorded: false,
69+
reason:
70+
"no project fingerprint claim — mint one with `forge uicheck fingerprint <ui files> --mint`",
71+
};
72+
const o = verdictOutcome(url, verdict, { author, t });
73+
if (!o.ok) return { recorded: false, reason: "reason" in o ? o.reason : "invalid outcome" };
74+
const a = appendEvidence(dir, claim.id, o.outcome);
75+
return a?.ok ? { recorded: true, claimId: claim.id } : { recorded: false, reason: a?.reason };
76+
}
77+
78+
// The in-page probe: one function serialized into the browser. Returns the raw signals;
79+
// the verdict is assembled host-side so the logic stays testable without a browser.
80+
const PROBE = () => {
81+
const el = document.activeElement;
82+
const tag = el && el !== document.body ? el.tagName.toLowerCase() : null;
83+
const interactive =
84+
!!el &&
85+
el !== document.body &&
86+
(["a", "button", "input", "select", "textarea"].includes(tag) ||
87+
el.hasAttribute("tabindex") ||
88+
el.getAttribute("role") === "button");
89+
let focusVisible = false;
90+
if (interactive) {
91+
const s = getComputedStyle(el);
92+
focusVisible =
93+
(s.outlineStyle !== "none" && parseFloat(s.outlineWidth) > 0) ||
94+
(s.boxShadow && s.boxShadow !== "none");
95+
}
96+
const running = (document.getAnimations ? document.getAnimations() : []).filter(
97+
(a) => a.playState === "running",
98+
).length;
99+
return { tag, interactive, focusVisible, running };
100+
};
101+
102+
/**
103+
* Drive `target` in a headless browser under prefers-reduced-motion and run the
104+
* interaction checks. Reuses the visual gate's Playwright resolver + target guard.
105+
* `resolve` is injectable so the skip path is testable without a browser.
106+
* @param {string} target
107+
* @param {{remote?:boolean, cwd?:string, timeoutMs?:number, resolve?:()=>Promise<any>}} [opts]
108+
* @returns {Promise<{ok:true, url:string, verdict:{pass:boolean, checks:any[]}}
109+
* | {ok:false, skipped:boolean, available?:boolean, url:string|null, reason:string}>}
110+
*/
111+
export async function runInteractions(target, opts = {}) {
112+
const {
113+
remote = false,
114+
cwd = process.cwd(),
115+
timeoutMs = 20000,
116+
resolve = resolvePlaywright,
117+
} = opts;
118+
const t = resolveTarget(target, { remote, cwd });
119+
if (!t.ok)
120+
return {
121+
ok: false,
122+
skipped: false,
123+
url: null,
124+
reason: "reason" in t ? t.reason : "target refused",
125+
};
126+
127+
const pw = await resolve();
128+
if (!pw)
129+
return {
130+
ok: false,
131+
skipped: true,
132+
available: false,
133+
url: t.url,
134+
reason:
135+
"no browser runtime — install Playwright or set FORGE_PLAYWRIGHT (interaction checks skipped)",
136+
};
137+
138+
let browser;
139+
try {
140+
browser = await pw.chromium.launch();
141+
const context = await browser.newContext({
142+
reducedMotion: "reduce",
143+
viewport: DEFAULT_VIEWPORTS?.[0] ?? { width: 1280, height: 800 },
144+
});
145+
const page = await context.newPage();
146+
const consoleErrors = [];
147+
page.on("console", (m) => {
148+
if (m.type() === "error") consoleErrors.push(m.text());
149+
});
150+
page.on("pageerror", (e) => consoleErrors.push(String(e?.message ?? e)));
151+
152+
await page.goto(t.url, { waitUntil: "load", timeout: timeoutMs });
153+
await page.keyboard.press("Tab");
154+
const p = await page.evaluate(PROBE);
155+
156+
const checks = [
157+
{
158+
id: "console-clean",
159+
ok: consoleErrors.length === 0,
160+
detail: consoleErrors.length
161+
? `${consoleErrors.length} console error(s); first: ${consoleErrors[0].slice(0, 120)}`
162+
: "no console errors on load",
163+
},
164+
{
165+
id: "keyboard-reachable",
166+
ok: p.interactive,
167+
detail: p.interactive
168+
? `Tab reached <${p.tag}>`
169+
: "Tab did not reach an interactive control (keyboard trap or no focusable UI)",
170+
},
171+
{
172+
id: "focus-visible",
173+
ok: p.interactive ? p.focusVisible : false,
174+
detail: p.focusVisible
175+
? "the focused control shows a visible focus indicator"
176+
: "no visible focus ring on the focused control (WCAG 2.4.7)",
177+
},
178+
{
179+
id: "reduced-motion",
180+
ok: p.running === 0,
181+
detail:
182+
p.running === 0
183+
? "no animations run under prefers-reduced-motion"
184+
: `${p.running} animation(s) still running under prefers-reduced-motion`,
185+
},
186+
];
187+
return { ok: true, url: t.url, verdict: summarizeVerdict(checks) };
188+
} catch (e) {
189+
return {
190+
ok: false,
191+
skipped: true,
192+
available: true,
193+
url: t.url,
194+
reason: `interaction run failed: ${e?.message ?? e}`,
195+
};
196+
} finally {
197+
try {
198+
await browser?.close();
199+
} catch {}
200+
}
201+
}

0 commit comments

Comments
 (0)