.agents/skills/ux-audit/references/layer-3-dynamic.md
L1 reads code, L2 looks at a render; L3 drives the product like a user and measures it. It's the only layer that reaches states that exist only at runtime (in-progress, forced error / empty), that verifies a journey stitches step-to-step, and that produces numbers — CLS, LCP, INP, long-tasks — which no screenshot or code read can give.
This layer runs on the agent-testing framework. Read that skill first; L3 assumes its
Step 0 (env + auth) is already green. Everything below is CDP-based, so it also works
headless in cloud under xvfb-run.
Part of the ux-audit skill — see ../SKILL.md.
Connect to the running app (Electron or web) and use:
| Command | Use in an audit |
|---|---|
agent-browser --cdp 9222 snapshot -i | accessibility/DOM tree + interactive elements (find controls, assert presence) |
agent-browser --cdp 9222 screenshot | render evidence at each step (feeds L2 checks) |
agent-browser --cdp 9222 eval "<js>" | instrument — inject web-vitals, read state, force conditions |
type / click (see agent-testing ui/) | drive the journey |
scripts/record-gif.sh | time-based evidence (streaming, a layout jump) |
Constraint: resizing the Electron window triggers a full SPA reload — do responsive / multi-viewport sweeps against web Chrome over CDP, not by resizing Electron.
Define the surface's User Journey as ordered steps, then walk it, capturing evidence at each and asserting forward momentum. Example (home → task):
snapshot + screenshot; assert the primary control is focusable.Record per step: snapshot, screenshot, and pass/fail against the expected state. A step
with no forward path, a dead spinner, or a wiped input is a finding.
The states L1/L2 can't reach, made reachable:
Network.emulateNetworkConditions {offline:true} (or block the
specific request route), then trigger the fetch; watch for the failure+retry UI. This is
how you catch the "permanent skeleton" (ux §4.2) live.Screenshots can't quantify jank; inject observers via eval. Inject before navigating /
triggering, then read the accumulated value after the surface settles.
Cumulative Layout Shift (the one L1/L2 can't measure):
// 1) inject before load/trigger: agent-browser --cdp 9222 eval "<this>"
window.__cls = 0;
new PerformanceObserver((l) => {
for (const e of l.getEntries()) if (!e.hadRecentInput) window.__cls += e.value;
}).observe({ type: 'layout-shift', buffered: true });
// 2) after the surface settles: agent-browser --cdp 9222 eval "window.__cls"
Same shape for the rest: observe({ type: 'largest-contentful-paint' }) → LCP;
observe({ type: 'longtask' }) → main-thread blocking; INP via the web-vitals lib if
present. Read them after the loading→content swap, which is exactly when skeleton-height
mismatches (ux Feedback §4.1) show up.
Thresholds (Core Web Vitals): CLS ≤ 0.1 good · ≤ 0.25 needs-work · else poor. LCP ≤ 2.5s good. INP ≤ 200ms good. Report the number and the verdict, and point at the block that shifted.
Per journey: step-by-step pass/fail with snapshot/screenshot evidence; forced-state
findings (error/empty/loading) that L1 only inferred, now confirmed live; and a perf
table (CLS / LCP / INP / long-task with verdicts). Feed all into the shared report shape.