Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

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

### Added
Expand Down
31 changes: 29 additions & 2 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -660,8 +660,8 @@ tree (your uncommitted changes wouldn't be in the run); commit/stash first or pa

### `forge uicheck` — deterministic UI checks

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

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

**`interact <file-or-url> [--record] [--enforce] [--json] [--remote]`** — the Playwright
_interaction_ loop: where `visual` fingerprints what the page **paints**, `interact`
checks what it **does**, headless under `prefers-reduced-motion`. Four checks:
`console-clean` (no console errors on load), `keyboard-reachable` (Tab lands on an
interactive control), `focus-visible` (the focused control shows a visible focus ring —
WCAG 2.4.7), and `reduced-motion` (nothing animates when reduced motion is requested).
The verdict is **advisory** by default — it is recorded through the ledger's weakest,
cross-family-gated `behavioral` oracle, so a lone interaction run can never move a claim
on its own (overview §4 honesty register). `--record` appends the verdict as evidence on
your minted project `fingerprint` claim; `--enforce` (or `FORGE_ENFORCE=1`) turns a fail
into a non-zero exit. Playwright and the loopback-only target guard are shared with
`visual` (same _optional tier_, same `--remote` rule).

```console
$ forge uicheck interact src/dash.html --record
Forge uicheck interact — browser interaction checks

driven: file:///…/src/dash.html (headless, prefers-reduced-motion)
✓ console-clean: no console errors on load
✓ keyboard-reachable: Tab reached <button>
✓ focus-visible: the focused control shows a visible focus indicator
✓ reduced-motion: no animations run under prefers-reduced-motion

✓ PASS (advisory)
recorded as behavioral evidence on design claim e7a90b12cd34
```

### `forge dash [--port N]` — the local dashboard

A read-only lens over `.forge/` — stdlib `node:http`, localhost-only, one
Expand Down
57 changes: 57 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -1561,6 +1561,63 @@ async function run(argv) {
if (r.fail) process.exitCode = 1;
return;
}
if (sub === "interact") {
// The Playwright interaction loop (ROADMAP "Next"): drive the page and check what
// it DOES — keyboard reach, a visible focus ring, console cleanliness, reduced-
// motion honesty. Advisory by default (the `behavioral` oracle is cross-family-
// gated); --enforce or FORGE_ENFORCE=1 turns a fail into a non-zero exit. Playwright
// is an optional tier (ADR-0005) — its absence is a note and exit 0, never a failure.
const { runInteractions, recordInteraction } = await import("./uiinteract.js");
const args = argv.slice(2);
const json = args.includes("--json");
const remote = args.includes("--remote");
const record = args.includes("--record");
const enforce = args.includes("--enforce") || process.env.FORGE_ENFORCE === "1";
const targets = args.filter((a) => !a.startsWith("--"));
if (targets.length !== 1) {
console.error(
`usage: ${BRAND.cli} uicheck interact <file-or-url> [--record] [--enforce] [--json] [--remote]`,
);
process.exitCode = 1;
return;
}
const r = await runInteractions(targets[0], { remote, cwd: process.cwd() });
if (!r.ok) {
const reason = "reason" in r ? r.reason : "interaction run failed";
if ("skipped" in r && r.skipped) {
// Graceful absence (ADR-0005): a missing optional tier is not a failure.
if (json) console.log(JSON.stringify({ skipped: true, reason }, null, 2));
else {
heading(`${BRAND.brand} uicheck interact — skipped (no browser runtime)\n`);
console.log(` ${reason}`);
console.log(
" enable it: npm i -D playwright-core (or point FORGE_PLAYWRIGHT at an existing install)",
);
}
return; // exit 0 — the advisory tier is absent
}
console.error(reason);
process.exitCode = 1;
return;
}
const recorded = record ? recordInteraction(process.cwd(), r.url, r.verdict) : null;
if (json) {
console.log(JSON.stringify({ url: r.url, ...r.verdict, recorded }, null, 2));
} else {
heading(`${BRAND.brand} uicheck interact — browser interaction checks\n`);
console.log(` driven: ${r.url} (headless, prefers-reduced-motion)`);
for (const c of r.verdict.checks) console.log(` ${c.ok ? "✓" : "✗"} ${c.id}: ${c.detail}`);
console.log(`\n ${r.verdict.pass ? "✓ PASS" : "✗ FAIL"}${enforce ? "" : " (advisory)"}`);
if (recorded)
console.log(
recorded.recorded
? ` recorded as behavioral evidence on design claim ${recorded.claimId.slice(0, 12)}`
: ` not recorded: ${recorded.reason}`,
);
}
if (!r.verdict.pass && enforce) process.exitCode = 1;
return;
}
if (sub === "fingerprint" || sub === "design") {
const ui = await import("./uifingerprint.js");
// `--taste <name>` (design only) takes a VALUE — splice it out before the
Expand Down
201 changes: 201 additions & 0 deletions src/uiinteract.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// forge uiinteract — the OPTIONAL Playwright interaction loop (ROADMAP "Next";
// docs/plans/substrate-v2/07-ui-quality-gate.md). `uicheck visual` fingerprints what
// the browser PAINTS; this checks what it DOES — keyboard reachability, a visible
// focus ring, console cleanliness, and reduced-motion honesty — and feeds the verdict
// back as `behavioral` oracle evidence on the project design (`fingerprint`) claim.
//
// `behavioral` is the ledger's weakest, cross-family-gated oracle (ledger.js ORACLES,
// w=0.3): a lone interaction verdict can never move a claim on its own. That is the
// point — this stays advisory until fixtures promote it (overview §4 honesty register),
// the same guard-over-prose discipline as the visual gate.
//
// ADR-0005: this module is node stdlib only. Playwright is resolved dynamically via
// uivisual.resolvePlaywright(); its absence is a graceful skip, never a failure —
// exactly like `uicheck visual`. The target passes through uivisual.resolveTarget()'s
// security guard (loopback-only unless --remote) BEFORE any browser is launched.
import { outcomeRecord } from "./ledger.js";
import { appendEvidence, loadClaims, repoLedger } from "./ledger_store.js";
import { DEFAULT_VIEWPORTS, resolvePlaywright, resolveTarget } from "./uivisual.js";
import { contentHash } from "./util.js";

/** The interaction checks, in the order they run. Pure data so docs + tests can name them. */
export const INTERACTION_CHECK_IDS = [
"console-clean",
"keyboard-reachable",
"focus-visible",
"reduced-motion",
];

/** Aggregate per-check results into a verdict. Empty → not a pass (nothing was proven). */
export function summarizeVerdict(checks) {
const list = Array.isArray(checks) ? checks : [];
return { pass: list.length > 0 && list.every((c) => c?.ok === true), checks: list };
}

/**
* Express a verdict in the ledger's evidence vocabulary: a `behavioral` outcome that
* confirms (pass) or contradicts (fail) the design claim. The ref is content-addressed
* on the checks so re-running the same verdict is idempotent (appendEvidence dedupes).
* @param {string} url
* @param {{pass:boolean, checks:any[]}} verdict
* @param {{author?:string, t?:number}} [opts]
*/
export function verdictOutcome(url, verdict, { author = "forge-uiinteract", t = 0 } = {}) {
const ref = `ui-interact:${url}#${contentHash(JSON.stringify(verdict?.checks ?? []))}`;
return outcomeRecord({
oracle: "behavioral",
result: verdict?.pass ? "confirm" : "contradict",
ref,
author,
t,
});
}

/**
* Record a verdict as evidence on the project's design (`fingerprint`) claim. No claim
* yet → a no-op with guidance (mirrors `uicheck visual`), never an error.
* @param {string} root
* @param {string} url
* @param {{pass:boolean, checks:any[]}} verdict
* @param {{author?:string, t?:number}} [opts]
* @returns {{recorded:boolean, claimId?:string, reason?:string}}
*/
export function recordInteraction(root, url, verdict, { author = "forge-uiinteract", t = 0 } = {}) {
const dir = repoLedger(root);
const claim = loadClaims(dir).find((c) => c.kind === "fingerprint" && !c.tombstone);
if (!claim)
return {
recorded: false,
reason:
"no project fingerprint claim — mint one with `forge uicheck fingerprint <ui files> --mint`",
};
const o = verdictOutcome(url, verdict, { author, t });
if (!o.ok) return { recorded: false, reason: "reason" in o ? o.reason : "invalid outcome" };
const a = appendEvidence(dir, claim.id, o.outcome);
return a?.ok ? { recorded: true, claimId: claim.id } : { recorded: false, reason: a?.reason };
}

// The in-page probe: one function serialized into the browser. Returns the raw signals;
// the verdict is assembled host-side so the logic stays testable without a browser.
const PROBE = () => {
const el = document.activeElement;
const tag = el && el !== document.body ? el.tagName.toLowerCase() : null;
const interactive =
!!el &&
el !== document.body &&
(["a", "button", "input", "select", "textarea"].includes(tag) ||
el.hasAttribute("tabindex") ||
el.getAttribute("role") === "button");
let focusVisible = false;
if (interactive) {
const s = getComputedStyle(el);
focusVisible =
(s.outlineStyle !== "none" && parseFloat(s.outlineWidth) > 0) ||
(s.boxShadow && s.boxShadow !== "none");
}
const running = (document.getAnimations ? document.getAnimations() : []).filter(
(a) => a.playState === "running",
).length;
return { tag, interactive, focusVisible, running };
};

/**
* Drive `target` in a headless browser under prefers-reduced-motion and run the
* interaction checks. Reuses the visual gate's Playwright resolver + target guard.
* `resolve` is injectable so the skip path is testable without a browser.
* @param {string} target
* @param {{remote?:boolean, cwd?:string, timeoutMs?:number, resolve?:()=>Promise<any>}} [opts]
* @returns {Promise<{ok:true, url:string, verdict:{pass:boolean, checks:any[]}}
* | {ok:false, skipped:boolean, available?:boolean, url:string|null, reason:string}>}
*/
export async function runInteractions(target, opts = {}) {
const {
remote = false,
cwd = process.cwd(),
timeoutMs = 20000,
resolve = resolvePlaywright,
} = opts;
const t = resolveTarget(target, { remote, cwd });
if (!t.ok)
return {
ok: false,
skipped: false,
url: null,
reason: "reason" in t ? t.reason : "target refused",
};

const pw = await resolve();
if (!pw)
return {
ok: false,
skipped: true,
available: false,
url: t.url,
reason:
"no browser runtime — install Playwright or set FORGE_PLAYWRIGHT (interaction checks skipped)",
};

let browser;
try {
browser = await pw.chromium.launch();
const context = await browser.newContext({
reducedMotion: "reduce",
viewport: DEFAULT_VIEWPORTS?.[0] ?? { width: 1280, height: 800 },
});
const page = await context.newPage();
const consoleErrors = [];
page.on("console", (m) => {
if (m.type() === "error") consoleErrors.push(m.text());
});
page.on("pageerror", (e) => consoleErrors.push(String(e?.message ?? e)));

await page.goto(t.url, { waitUntil: "load", timeout: timeoutMs });
await page.keyboard.press("Tab");
const p = await page.evaluate(PROBE);

const checks = [
{
id: "console-clean",
ok: consoleErrors.length === 0,
detail: consoleErrors.length
? `${consoleErrors.length} console error(s); first: ${consoleErrors[0].slice(0, 120)}`
: "no console errors on load",
},
{
id: "keyboard-reachable",
ok: p.interactive,
detail: p.interactive
? `Tab reached <${p.tag}>`
: "Tab did not reach an interactive control (keyboard trap or no focusable UI)",
},
{
id: "focus-visible",
ok: p.interactive ? p.focusVisible : false,
detail: p.focusVisible
? "the focused control shows a visible focus indicator"
: "no visible focus ring on the focused control (WCAG 2.4.7)",
},
{
id: "reduced-motion",
ok: p.running === 0,
detail:
p.running === 0
? "no animations run under prefers-reduced-motion"
: `${p.running} animation(s) still running under prefers-reduced-motion`,
},
];
return { ok: true, url: t.url, verdict: summarizeVerdict(checks) };
} catch (e) {
return {
ok: false,
skipped: true,
available: true,
url: t.url,
reason: `interaction run failed: ${e?.message ?? e}`,
};
} finally {
try {
await browser?.close();
} catch {}
}
}
Loading
Loading