Skip to content

Commit 9cb8e90

Browse files
authored
Merge pull request #18 from HeyRenan/fix/actionability-gate
rec: actionability gate — refuse recording against a drifted app state (v1.6.1)
2 parents b993170 + 2cc166b commit 9cb8e90

6 files changed

Lines changed: 171 additions & 9 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
},
66
"metadata": {
77
"description": "Personal marketplace hosting the Showreel plugin",
8-
"version": "1.6.0"
8+
"version": "1.6.1"
99
},
1010
"plugins": [
1111
{
1212
"name": "showreel",
1313
"source": "./showreel",
1414
"description": "Your UI, on its showreel \u2014 annotated screenshots, feature demos, flow walkthrough recordings (gif/mp4), terminal captures and before/after composites in one command each. Self-contained Chromium motor, deterministic placement, self-validated output; no MCP, cheap on tokens.",
15-
"version": "1.6.0"
15+
"version": "1.6.1"
1616
}
1717
]
1818
}

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ Showreel turns "show me" into a finished visual — annotated screenshots, isola
99
demos, flow GIFs, terminal recordings and before/after composites — driven entirely
1010
by CSS selectors and JSON steps.
1111

12+
## [1.6.1] — 2026-07-08
13+
14+
### Fixed
15+
- **Recording against a drifted app state is now refused, not silently mis-recorded.** Re-running a roster after the app moved on — a submitted form's button now `disabled`, a spent one-time action, a control now sitting under an overlay — used to slip the live safeguard: it drove the target with a DOM `el.click()`, which punches through a disabled/covered element without erroring, so the real coordinate-click then hit nothing or the overlay (reading as random clicks). The safeguard now asserts the target is actionable the way the real take clicks it and **refuses** (`[not-actionable]`, exit 2) on a `disabled` / `aria-disabled` / `pointer-events:none` / covered target, routing you to reset the app to a fresh state (occlusion is viewport-guarded, so a below-fold target is not false-flagged).
16+
- **Audit driving now matches real typing.** The safeguard's `fill` sets the value through the native input setter and dispatches `input` + `change` + `blur`, so a submit gated on `change`/blur/controlled-state enables during the audit too — a valid fill→enable-submit flow (the `form-flow` preset) is no longer false-refused.
17+
1218
## [1.6.0] — 2026-07-07
1319

1420
### Added

showreel/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "showreel",
3-
"version": "1.6.0",
3+
"version": "1.6.1",
44
"description": "Your UI, on its showreel \u2014 annotated screenshots, feature demos, flow walkthrough recordings (gif/mp4), terminal captures and before/after composites in one command each. Self-contained Chromium motor, deterministic placement, self-validated output; no MCP, cheap on tokens.",
55
"author": {
66
"name": "Renan"

showreel/scripts/__tests__/rec-steps.test.mjs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,74 @@ test('auditRosterLive gates a MISSING scrollTo target (silent-wrong-scene)', asy
688688
assert.equal((await auditRosterLive([{ zoom: 'out' }], bridge)).errors.length, 0); // "out" is not a selector
689689
});
690690

691+
test('auditRosterLive refuses a not-actionable interaction target (app-state drift)', async () => {
692+
// The audit drives clicks with el.click(), which punches through a disabled or
693+
// covered element without throwing; the real take clicks by coordinate and hits
694+
// nothing (disabled) or the overlay (random click). The actionability gate
695+
// catches the re-record-against-drifted-state failure BEFORE any video.
696+
const acts = {
697+
'#submit': { ok: false, reason: 'disabled' },
698+
'#cta': { ok: false, reason: 'covered', by: 'div.cookie-banner' },
699+
'#email': { ok: false, reason: 'disabled' },
700+
'#go': { ok: true },
701+
};
702+
const bridge = {
703+
measure: async () => ({ visible: true, w: 120, h: 36, cx: 200, cy: 300 }),
704+
click: async () => {}, fill: async () => {}, select: async () => {}, settle: async () => {},
705+
actionable: async (sel) => acts[sel] || { ok: true },
706+
};
707+
// disabled click -> not-actionable, message routes the operator to reset state
708+
const dis = (await auditRosterLive([{ click: '#submit' }], bridge)).errors;
709+
assert.equal(dis.length, 1);
710+
assert.equal(dis[0].kind, 'not-actionable');
711+
assert.match(dis[0].message, /reset/i);
712+
// covered click -> not-actionable, message names the overlay on top
713+
const cov = (await auditRosterLive([{ click: '#cta' }], bridge)).errors;
714+
assert.equal(cov.length, 1);
715+
assert.equal(cov[0].kind, 'not-actionable');
716+
assert.match(cov[0].message, /cookie-banner/);
717+
// a fill target is gated too (the audit fills with el.value=, same punch-through)
718+
const fil = (await auditRosterLive([{ fill: '#email', text: 'a@b.com' }], bridge)).errors;
719+
assert.equal(fil.length, 1);
720+
assert.equal(fil[0].kind, 'not-actionable');
721+
// an actionable target renders clean — the happy path is untouched
722+
assert.equal((await auditRosterLive([{ click: '#go' }], bridge)).errors.length, 0);
723+
});
724+
725+
test('auditRosterLive: a target enabled by an EARLIER step is not falsely refused', async () => {
726+
// form-flow: #submit starts disabled and an earlier fill enables it. The gate
727+
// checks actionability AFTER driving prior steps, so #submit must read
728+
// actionable at its click. Guards the regression where a valid fill->submit
729+
// flow would be refused. (The real bridge's fill fires input+change via the
730+
// native setter so the DOM actually flips; here the fake bridge models that.)
731+
const makeFormBridge = () => {
732+
let filled = false;
733+
return {
734+
measure: async () => ({ visible: true, w: 120, h: 36, cx: 200, cy: 300 }),
735+
click: async () => {}, settle: async () => {}, select: async () => {},
736+
fill: async () => { filled = true; },
737+
actionable: async (sel) => (sel === '#submit' && !filled ? { ok: false, reason: 'disabled' } : { ok: true }),
738+
};
739+
};
740+
const roster = [{ fill: '#email', text: 'a@b.com' }, { click: '#submit', note: 'Enviar' }];
741+
const errs = (await auditRosterLive(roster, makeFormBridge())).errors;
742+
assert.equal(errs.length, 0, 'a fill that enables the submit must not trip not-actionable');
743+
// control: the SAME submit with no preceding fill IS refused (disabled on load)
744+
const bare = (await auditRosterLive([{ click: '#submit', note: 'Enviar' }], makeFormBridge())).errors;
745+
assert.equal(bare.length, 1);
746+
assert.equal(bare[0].kind, 'not-actionable');
747+
});
748+
749+
test('auditRosterLive: a bridge without actionable() adds no errors (back-compat)', async () => {
750+
// fake bridges (and any external caller) predating the actionability gate must
751+
// keep passing — the check is guarded on the method existing.
752+
const bridge = {
753+
measure: async () => ({ visible: true, w: 120, h: 36, cx: 200, cy: 300 }),
754+
click: async () => {}, fill: async () => {}, select: async () => {}, settle: async () => {},
755+
};
756+
assert.equal((await auditRosterLive([{ click: '#submit' }], bridge)).errors.length, 0);
757+
});
758+
691759
test('auditRosterLive warns on zoom-churn: re-framing a nested element of the same card across an "out"', async () => {
692760
// #rollback-countdown lives INSIDE #deploy-panel — framing the panel, pulling
693761
// out, then framing a child (or back to the panel) re-zooms the same card. The

showreel/scripts/rec-steps.mjs

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -759,8 +759,27 @@ export function makeAuditBridge(page, vw, vh) {
759759
return { w: r.width, h: r.height, cx: r.left + r.width / 2, cy: r.top + r.height / 2, visible };
760760
}, sel),
761761
click: (sel) => page.evaluate((q) => { const el = document.querySelector(q); if (el) el.click(); }, sel),
762-
fill: (sel, t) => page.evaluate(({ q, t }) => { const el = document.querySelector(q); if (el) { el.value = t; el.dispatchEvent(new Event('input', { bubbles: true })); } }, { q: sel, t }),
763-
select: (sel, o) => page.evaluate(({ q, o }) => { const el = document.querySelector(q); if (el) { const x = [...el.options].find((p) => p.text.trim() === o || p.value === o); if (x) { el.value = x.value; el.dispatchEvent(new Event('change', { bubbles: true })); } } }, { q: sel, o }),
762+
// Type/select the way the REAL take does (keyboard + coordinate change), so
763+
// the audit's post-action DOM — and the actionability verdict riding it —
764+
// matches what the recording produces. A raw `el.value=` fires no `change`
765+
// and is invisible to a controlled (React) input (its value setter is
766+
// overridden on the instance): use the prototype's native setter and
767+
// dispatch input + change (+ blur) so a submit gated on change/blur/state
768+
// actually enables here too. Without this the gate FALSE-REFUSES a valid
769+
// fill->enable-submit flow (the shipped form-flow preset).
770+
fill: (sel, t) => page.evaluate(({ q, t }) => {
771+
const el = document.querySelector(q); if (!el) return;
772+
// native value setter ONLY on a real input/textarea — calling the input
773+
// prototype's setter on any other element throws "Illegal invocation".
774+
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype
775+
: el instanceof HTMLInputElement ? HTMLInputElement.prototype : null;
776+
const desc = proto && Object.getOwnPropertyDescriptor(proto, 'value');
777+
if (desc && desc.set) desc.set.call(el, t); else el.value = t;
778+
el.dispatchEvent(new Event('input', { bubbles: true }));
779+
el.dispatchEvent(new Event('change', { bubbles: true }));
780+
el.dispatchEvent(new Event('blur', { bubbles: true }));
781+
}, { q: sel, t }),
782+
select: (sel, o) => page.evaluate(({ q, o }) => { const el = document.querySelector(q); if (el) { const x = [...el.options].find((p) => p.text.trim() === o || p.value === o); if (x) { el.value = x.value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } } }, { q: sel, o }),
764783
settle: (ms) => page.waitForTimeout(ms),
765784
contains: (host, sel) => page.evaluate(({ h, s }) => {
766785
const he = document.querySelector(h), se = document.querySelector(s);
@@ -773,9 +792,54 @@ export function makeAuditBridge(page, vw, vh) {
773792
const noCrop = cap.MARGIN * Math.min(vw / r.width, vh / r.height);
774793
return Math.max(1, Math.min(Math.min(cap.MAX, fit * cap.ZOOM), noCrop));
775794
}, { q: sel, vw, vh, cap: FRAME }),
795+
// Is an INTERACTION target truly clickable, the way the real take clicks it?
796+
// The audit drives with el.click()/el.value= (above), which PUNCH THROUGH a
797+
// disabled or covered element — el.click() never throws on a disabled control
798+
// and ignores pointer-events + occlusion. The recorded take clicks by
799+
// coordinate (p.mouse.click), so it clicks nothing (disabled) or the overlay
800+
// on top (reads as a random click). This asserts the target the way the take
801+
// will hit it. Occlusion is viewport-guarded: elementFromPoint returns null
802+
// for a point below the fold, which would false-flag every off-screen target
803+
// as covered — the disabled/aria/pointer-events checks are position-free and
804+
// always run. Returns { ok } or { ok:false, reason, by? }.
805+
actionable: (sel) => page.evaluate(({ q, vw, vh }) => {
806+
const el = document.querySelector(q);
807+
if (!el) return { ok: false, reason: 'missing' };
808+
if (el.disabled === true) return { ok: false, reason: 'disabled' };
809+
if (el.getAttribute('aria-disabled') === 'true') return { ok: false, reason: 'aria-disabled' };
810+
if (getComputedStyle(el).pointerEvents === 'none') return { ok: false, reason: 'pointer-events' };
811+
const r = el.getBoundingClientRect();
812+
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
813+
const inViewport = cx >= 0 && cx <= vw && cy >= 0 && cy <= vh;
814+
if (inViewport) {
815+
const top = document.elementFromPoint(cx, cy);
816+
if (top && top !== el && !el.contains(top) && !top.contains(el)) {
817+
const tag = top.tagName.toLowerCase();
818+
const cls = typeof top.className === 'string' && top.className.trim()
819+
? '.' + top.className.trim().split(/\s+/).join('.') : '';
820+
return { ok: false, reason: 'covered', by: tag + cls };
821+
}
822+
}
823+
return { ok: true };
824+
}, { q: sel, vw, vh }),
776825
};
777826
}
778827

828+
// Human message for a not-actionable interaction target. Routes the operator to
829+
// the REAL cause — the app drifted from the state the roster was authored for —
830+
// because the recorder cannot reset server-side state (a fresh browser context
831+
// won't un-submit a form). Pure; unit-tested through auditRosterLive.
832+
export function actionabilityMessage(sel, act) {
833+
const reason = act && act.reason;
834+
if (reason === 'covered')
835+
return `"${sel}" is covered by ${(act && act.by) || 'another element'} at its click point — the real mouse click would hit that overlay, not the target (reads as a random click). Dismiss/hide the overlay first (an earlier click to close it, or {"hide":"<overlay>"}).`;
836+
if (reason === 'pointer-events')
837+
return `"${sel}" has pointer-events:none — the real mouse click can't actuate it. Reveal/enable it first, or target the actionable element.`;
838+
if (reason === 'missing')
839+
return `"${sel}" matches no element — the step fires on nothing.`;
840+
return `"${sel}" is disabled — the app is in a state this roster wasn't authored for (a form already submitted, a one-time action already spent). The recorder can't reset server-side state; reset the demo/app to a fresh state before recording (or pass --no-safeguards to override).`;
841+
}
842+
779843
// Live audit (needs a page): walks the roster against the REAL rendered DOM and
780844
// returns hard ERRORS for the two failures static analysis can't see:
781845
// - off-screen: an action/primitive anchored to an element whose center is
@@ -873,13 +937,35 @@ export async function auditRosterLive(steps, bridge) {
873937
if (!(await measure(sel))) errors.push({ step: i + 1, kind: 'missing', message: `${what} "${sel}" matches no element — the scene never ${what === 'zoom target' ? 'frames it (renders wide, reads as a random pan)' : 'scrolls (silently shows the wrong position)'}.` });
874938
}
875939

876-
// drive real state so later anchors see the post-action DOM
940+
// actionability gate: assert each interaction target is clickable the way the
941+
// real take clicks it (by coordinate), not the way the audit drives it (DOM
942+
// el.click(), which punches through disabled/covered). This is the app-state-
943+
// drift failure the operator hits re-recording — a form already submitted, a
944+
// spent one-time action, a CTA that changed and now covers/overlays.
945+
const notActionable = new Set();
946+
if (typeof bridge.actionable === 'function') {
947+
const interactionSels = [];
948+
if (typeof s.click === 'string') interactionSels.push(s.click);
949+
const fSpec = fillSpec(s); if (fSpec && fSpec.sel) interactionSels.push(fSpec.sel);
950+
const slSpec = selectSpec(s); if (slSpec && slSpec.sel) interactionSels.push(slSpec.sel);
951+
for (const sel of interactionSels) {
952+
const act = await bridge.actionable(sel);
953+
if (act && !act.ok) {
954+
errors.push({ step: i + 1, kind: 'not-actionable', message: actionabilityMessage(sel, act) });
955+
notActionable.add(sel);
956+
}
957+
}
958+
}
959+
960+
// drive real state so later anchors see the post-action DOM. A target flagged
961+
// not-actionable is skipped — driving it would either no-op (disabled) or
962+
// punch through to a covered element and mutate state off the wrong node.
877963
try {
878964
// a click can kick off an async flow (deploy progress reveals toast/new
879965
// row ~2s later); settle long enough for those reveals before later checks.
880-
if (typeof s.click === 'string') { await bridge.click(s.click); await bridge.settle(2300); }
881-
const fl = s.fill; if (typeof fl === 'string' && typeof s.text === 'string') { await bridge.fill(fl, s.text); }
882-
if (typeof s.select === 'string' && typeof s.option === 'string') { await bridge.select(s.select, s.option); }
966+
if (typeof s.click === 'string' && !notActionable.has(s.click)) { await bridge.click(s.click); await bridge.settle(2300); }
967+
const fl = s.fill; if (typeof fl === 'string' && typeof s.text === 'string' && !notActionable.has(fl)) { await bridge.fill(fl, s.text); }
968+
if (typeof s.select === 'string' && typeof s.option === 'string' && !notActionable.has(s.select)) { await bridge.select(s.select, s.option); }
883969
} catch { /* driving is best-effort; a failed click just leaves state as-is */ }
884970
}
885971
return { errors, warnings };

showreel/skills/showreel/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ Whatever you pick: every label visibly connects to its target, and the final fra
7474

7575
**Discipline:** `--dry` is MANDATORY before any recording — resolves every selector ([ok]/[MISS]), no recording. A [MISS] on a glide/camera/click/fill/select target is a frozen dead beat — fix to zero MISS, then record ONCE.
7676

77+
**Fresh app state before every (re-)record.** A roster is authored for the app in state A; re-running it later hits state B, where the action already happened — a submitted form's button is now `disabled`, a one-time CTA is spent, a control changed and an overlay/next-page element sits where the target was. The recorder then clicks nothing (disabled) or the wrong thing (reads as random clicks). `--dry` will NOT catch this (a disabled button still resolves). The live safeguard now **refuses** (exit 2, `[not-actionable]`) when a click/fill/select target is `disabled` / `aria-disabled` / `pointer-events:none` / covered by another element. The recorder CANNOT reset server-side state (a fresh browser context won't un-submit a form) — so on a `not-actionable` refusal, **reset the demo/app to a fresh state and re-run**; never `--no-safeguards` past it (that records the broken flow).
78+
7779
**Render mode (decides at the command line, not in the frames):** a MOVING reel — any `glide`/`follow`/`camera`/`fill`/`select` — records realtime `--fps 30`, NO `--offline` (15fps stills → slideshow), NO `--pace fast` (collapses easing), NO `speed` (a no-op in realtime). `--pace fast` and `--offline` are ONLY for quick static/dwell-only proofs.
7880

7981
**Presets:** don't write step JSON from scratch. Copy a starting roster from `scripts/presets/``form-flow.json`, `nav-flow.json`, `dashboard.json` — and swap the selectors for the page under test.

0 commit comments

Comments
 (0)