diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index db18638..89e0b1f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Personal marketplace hosting the Showreel plugin", - "version": "1.4.0" + "version": "1.4.1" }, "plugins": [ { "name": "showreel", "source": "./showreel", "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.", - "version": "1.4.0" + "version": "1.4.1" } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fec64c..d6eb772 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,11 +25,11 @@ jobs: run: cd showreel/scripts && node ensure-deps.mjs - name: ffmpeg for the end-to-end render run: sudo apt-get update -q && sudo apt-get install -y -q ffmpeg - - name: audit the showcase roster (off-screen / broken-anchor gate) + - name: audit the showcase roster (off-screen / anchor / zoom-churn gate — strict) run: | node showreel/scripts/audit-roster.mjs \ "file://$PWD/assets-src/demo/index.html?gate=fail" \ assets-src/showcase-steps.json \ - --width 1280 --height 676 + --width 1280 --height 676 --strict - name: end-to-end offline render (full pipeline, needs chromium + ffmpeg) run: node --test showreel/scripts/__tests__/integration-render.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ff842..9a05cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,32 @@ Showreel turns "show me" into a finished visual — annotated screenshots, isola demos, flow GIFs, terminal recordings and before/after composites — driven entirely by CSS selectors and JSON steps. +## [1.4.1] — 2026-07-06 + +### Fixed +- **Showcase re-rendered — zoom churn removed.** The v1.4.0 showcase re-zoomed the + deploy card three times (it framed `#deploy-panel`, pulled out, framed + `#rollback-countdown` — which lives *inside* the panel — pulled out, then framed + `#deploy-panel` again). Now one held camera on `#deploy-panel` carries the whole + deploy flow (block → auto-rollback → re-run → ship), swapping only the annotation + and the state colour. Authoring fix, not the motor. The committed source + (`assets-src/showcase-steps.json`) is replaced with this didactic flow too, so + the shipped mp4 and its source stay in sync — re-rendering reproduces the fixed + reel, not the old feature-cram roster. + +### Changed +- **Zoom-churn audit now catches nested re-framing.** The audit only flagged + re-framing the *same selector* after a `camera:"out"`; it missed re-framing a + nested / containing element of the same card (the exact bug above). The live + audit (`auditRosterLive`) now runs the DOM `contains` check both ways, so + framing a child — or the parent — of a card framed a beat ago is flagged as + `zoom-churn`. Warning, never fatal — churn is a craft issue, not broken output, + and the `contains` heuristic can flag a motivated return, so blocking every + render would be wrong. CI now audits the committed showcase roster in + `--strict`, so any warning (churn included) fails the build: the project + enforces zero-churn on its own artifacts while everyone else keeps + warn-by-default plus opt-in `--strict`. +1 test (573 total). + ## [1.4.0] — 2026-07-06 ### Changed diff --git a/assets-src/showcase-steps.json b/assets-src/showcase-steps.json index a8babfc..9b668db 100644 --- a/assets-src/showcase-steps.json +++ b/assets-src/showcase-steps.json @@ -1,501 +1,30 @@ [ - { - "modal": "One deploy between you and production.", - "wait": 2500 - }, - { - "scrollTo": "#pipeline", - "wait": 800 - }, - { - "screen": "Lumen — deploy console", - "accent": "#5B8CFF", - "wait": 1200 - }, - { - "camera": { - "sel": ".status-dot", - "zoom": 2 - }, - "accent": "#5B8CFF", - "wait": 1300 - }, - { - "pulse": "#live-dot", - "highlight": "#live-dot", - "note": "Live and healthy", - "wait": 1600 - }, - { - "camera": "out", - "wait": 700 - }, - { - "camera": { - "sel": "#pipeline", - "zoom": 2 - }, - "accent": "#5B8CFF", - "wait": 1300 - }, - { - "progress": "#stage-deploy", - "note": "Rollout fills as the stage completes", - "wait": 2000 - }, - { - "marks": [ - { - "sel": "#stage-build .stage-name", - "badge": "1", - "text": "Compile & cache the image" - }, - { - "sel": "#stage-test .stage-name", - "badge": "2", - "text": "Run the full test suite" - }, - { - "sel": "#stage-push .stage-name", - "badge": "3", - "text": "Push to the registry" - }, - { - "sel": "#stage-deploy .stage-name", - "badge": "4", - "text": "Release to production" - } - ], - "glossary": true, - "stagger": 420, - "wait": 5200 - }, - { - "camera": "out", - "wait": 700 - }, - { - "camera": { - "sel": ".bar-actions", - "zoom": 2 - }, - "accent": "#5B8CFF", - "wait": 1300 - }, - { - "click": "#menu", - "wait": 800 - }, - { - "camera": { - "sel": "#drawer", - "zoom": 1.6 - }, - "wait": 1300 - }, - { - "highlight": "#drawer", - "note": "Every workspace action lives here", - "wait": 1500 - }, - { - "camera": { - "sel": ".bar-actions", - "zoom": 2 - }, - "wait": 1300 - }, - { - "click": "#menu", - "wait": 800 - }, - { - "camera": "out", - "wait": 700 - }, - { - "camera": { - "sel": "#metrics", - "zoom": 2 - }, - "accent": "#FFB84D", - "wait": 1300 - }, - { - "marks": [ - { - "sel": "#p95 .value", - "badge": "1", - "text": "p95 request latency" - }, - { - "sel": "#rps .value", - "badge": "2", - "text": "Sustained throughput" - } - ], - "glossary": true, - "stagger": 360, - "wait": 5220 - }, - { - "camera": "out", - "wait": 700 - }, - { - "camera": { - "sel": "#topology", - "zoom": 2 - }, - "accent": "#8b5cf6", - "wait": 1300 - }, - { - "spotlight": "#svc-api", - "note": "Routes production checkout traffic", - "wait": 1500 - }, - { - "camera": "out", - "wait": 700 - }, - { - "follow": 2, - "glide": "#svc-api", - "accent": "#8b5cf6", - "wait": 1500 - }, - { - "glide": "#svc-db", - "note": "Traffic flows edge to data", - "wait": 1700 - }, - { - "camera": "out", - "wait": 700 - }, - { - "camera": { - "sel": "#history" - }, - "accent": "#5B8CFF", - "wait": 1300 - }, - { - "blur": "#email-1248", - "wait": 800 - }, - { - "redact": "#email-1247", - "wait": 800 - }, - { - "highlight": "#email-1246", - "note": "Only owners see full addresses", - "wait": 1500 - }, - { - "hide": "#row-1246", - "note": "Filter out archived deploys", - "wait": 1500 - }, - { - "camera": "out", - "wait": 900 - }, - { - "modal": "Same console, light surface.", - "fade": 600, - "wait": 1700 - }, - { - "click": "#theme", - "wait": 2200 - }, - { - "camera": { - "sel": "#invite-form" - }, - "accent": "#2B6CE6", - "wait": 1300 - }, - { - "fill": "#iname", - "text": "Dana Reyes", - "delay": 45, - "wait": 700 - }, - { - "fill": "#iemail", - "text": "dana@acme.co", - "delay": 70, - "wait": 700 - }, - { - "select": "#region", - "option": "São Paulo", - "wait": 700 - }, - { - "click": "#invite", - "wait": 800 - }, - { - "note": "Invite sent — pending until accepted", - "rect": "#invite-toast", - "wait": 1500 - }, - { - "camera": "out", - "wait": 700 - }, - { - "camera": { - "sel": "#deploy-panel", - "zoom": 2 - }, - "accent": "#D03A3A", - "wait": 1300 - }, - { - "click": "#deploy", - "wait": 900 - }, - { - "shake": "#deploy", - "wait": 700 - }, - { - "highlight": "#rollback-countdown", - "note": "Auto-rollback armed while it recovers", - "wait": 1500 - }, - { - "camera": { - "sel": "#pipeline", - "zoom": 2 - }, - "accent": "#D03A3A", - "wait": 1300 - }, - { - "highlight": "#stage-deploy", - "note": "Health gate blocked this release", - "wait": 1500 - }, - { - "camera": { - "sel": "#deploy-panel", - "zoom": 2 - }, - "accent": "#1E9E6A", - "wait": 1300 - }, - { - "countdown": { - "sel": "#deploy", - "n": 3 - }, - "note": "Fix verified — shipping now", - "wait": 1500 - }, - { - "click": "#deploy", - "wait": 700 - }, - { - "camera": { - "sel": "#pipeline", - "zoom": 2 - }, - "wait": 1300 - }, - { - "orbit": "#stage-deploy .stage-ring", - "wait": 850 - }, - { - "trail": { - "from": "#stage-push", - "to": "#stage-deploy" - }, - "wait": 800 - }, - { - "glow": "#stage-deploy .stage-ico", - "wait": 800 - }, - { - "camera": { - "sel": "#stage-deploy", - "zoom": 3 - }, - "accent": "#1E9E6A", - "wait": 1300 - }, - { - "checkmark": "#stage-deploy .badge", - "wait": 800 - }, - { - "flash": "#stage-deploy .chip", - "wait": 600 - }, - { - "camera": { - "sel": "#deploy-panel", - "zoom": 2.4 - }, - "accent": "#1E9E6A", - "wait": 1300 - }, - { - "highlight": "#deploy", - "note": "Shipped to production", - "wait": 1500 - }, - { - "confetti": "#deploy", - "wait": 900 - }, - { - "camera": { - "sel": "#deploy-log", - "zoom": 2 - }, - "wait": 1300 - }, - { - "typeon": "#log-body", - "wait": 500 - }, - { - "highlight": "#log-approval", - "note": "Last gate before it ships", - "wait": 1600 - }, - { - "camera": "out", - "wait": 700 - }, - { - "camera": { - "sel": "#topology", - "zoom": 2 - }, - "accent": "#7C4DD6", - "wait": 1300 - }, - { - "ripple": "#svc-cdn", - "wait": 800 - }, - { - "camera": "out", - "wait": 700 - }, - { - "camera": { - "sel": "#metrics", - "zoom": 2 - }, - "accent": "#7C4DD6", - "wait": 1300 - }, - { - "sparkline": "#p95 .spark", - "wait": 950 - }, - { - "countup": "#rps .value", - "wait": 900 - }, - { - "camera": "out", - "wait": 700 - }, - { - "camera": { - "sel": "#history" - }, - "accent": "#1E9E6A", - "wait": 1300 - }, - { - "reveal": "#row-new", - "note": "Logged to deploy history", - "wait": 1500 - }, - { - "glossary": { - "id": "shipped", - "title": "This release", - "items": [ - { - "badge": 1, - "text": "Auth tokens", - "color": "#2B6CE6" - } - ] - }, - "wait": 2200 - }, - { - "live": { - "append": { - "badge": 2, - "text": "Edge cache", - "color": "#16a34a" - } - }, - "wait": 2000 - }, - { - "live": { - "append": { - "badge": 3, - "text": "Audit log", - "color": "#a855f7" - } - }, - "wait": 2200 - }, - { - "camera": "out", - "wait": 700 - }, - { - "camera": { - "sel": "#deploy-detail" - }, - "accent": "#2B6CE6", - "wait": 1300 - }, - { - "kenburns": ".artifact-preview", - "wait": 500 - }, - { - "inset": ".detail__commit .sha", - "note": "Exact commit that shipped", - "wait": 1500 - }, - { - "camera": "out", - "wait": 700 - }, - { - "click": "#theme", - "wait": 1400 - }, - { - "camera": "out", - "wait": 700 - }, - { - "modal": "Blocked, fixed, shipped — safely.", - "accent": "#34D399", - "fade": 800, - "wait": 2800 - } -] \ No newline at end of file + { "screen": "Lumen · Deploy console", "accent": "#3b82f6", "wait": 1200 }, + + { "camera": { "sel": "#pipeline" }, "accent": "#3b82f6", "wait": 1300 }, + { "marks": [ + { "sel": "#stage-build", "badge": "1", "text": "Build cached" }, + { "sel": "#stage-test", "badge": "2", "text": "312 tests pass" }, + { "sel": "#stage-deploy", "badge": "3", "text": "Deploy waits for approval" } + ], "glossary": true, "stagger": 360, "wait": 5580 }, + { "camera": "out", "wait": 700 }, + + { "camera": { "sel": "#deploy-panel", "zoom": 2 }, "accent": "#f59e0b", "wait": 1300 }, + { "glide": "#deploy", "wait": 400 }, + { "click": "#deploy", "wait": 800 }, + { "spotlight": "#deploy", "note": "First deploy hits the health gate", "accent": "#ef4444", "wait": 1500 }, + { "highlight": "#rollback-countdown b", "note": "Auto-rollback protects production", "accent": "#ef4444", "wait": 1500 }, + { "click": "#deploy", "note": "Re-run clears the gate and ships", "accent": "#22c55e", "wait": 1500 }, + { "camera": "out", "wait": 700 }, + + { "camera": { "sel": "#pipeline" }, "accent": "#22c55e", "wait": 1300 }, + { "progress": ".deploy-progress i", "note": "Rollout, region by region", "wait": 1500 }, + { "camera": "out", "wait": 700 }, + + { "camera": { "sel": "#topology" }, "accent": "#8b5cf6", "wait": 1300 }, + { "highlight": "#svc-api .st", "note": "Every service reports live", "wait": 1500 }, + { "camera": "out", "wait": 700 }, + + { "camera": "out", "accent": "#22c55e", "wait": 700 }, + { "modal": "One gate, one re-run — shipped to production.", "wait": 2500 } +] diff --git a/assets/showcase-poster.png b/assets/showcase-poster.png index 0b46906..f56f2d4 100644 Binary files a/assets/showcase-poster.png and b/assets/showcase-poster.png differ diff --git a/assets/showcase.mp4 b/assets/showcase.mp4 index c7ceac0..e69e139 100644 Binary files a/assets/showcase.mp4 and b/assets/showcase.mp4 differ diff --git a/showreel/.claude-plugin/plugin.json b/showreel/.claude-plugin/plugin.json index d0599f0..3a4673d 100644 --- a/showreel/.claude-plugin/plugin.json +++ b/showreel/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "showreel", - "version": "1.4.0", + "version": "1.4.1", "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.", "author": { "name": "Renan" diff --git a/showreel/scripts/__tests__/rec-steps.test.mjs b/showreel/scripts/__tests__/rec-steps.test.mjs index 5d13784..d76b612 100644 --- a/showreel/scripts/__tests__/rec-steps.test.mjs +++ b/showreel/scripts/__tests__/rec-steps.test.mjs @@ -687,3 +687,45 @@ test('auditRosterLive gates a MISSING scrollTo target (silent-wrong-scene)', asy assert.equal((await auditRosterLive([{ zoom: '#far-down' }], bridge)).errors.length, 0); assert.equal((await auditRosterLive([{ zoom: 'out' }], bridge)).errors.length, 0); // "out" is not a selector }); + +test('auditRosterLive warns on zoom-churn: re-framing a nested element of the same card across an "out"', async () => { + // #rollback-countdown lives INSIDE #deploy-panel — framing the panel, pulling + // out, then framing a child (or back to the panel) re-zooms the same card. The + // static auditScenes misses this (the selectors differ); the live DOM `contains` + // check catches it. WARN, never fatal. + const inside = { '#deploy-panel': ['#deploy', '#rollback-countdown'] }; + const bridge = { + measure: async () => ({ visible: true, w: 200, h: 80, cx: 400, cy: 300 }), + click: async () => {}, fill: async () => {}, select: async () => {}, settle: async () => {}, + contains: async (host, sel) => (inside[host] || []).includes(sel), + }; + const churn = [ + { camera: { sel: '#deploy-panel', zoom: 2 } }, + { spotlight: '#deploy' }, + { camera: 'out' }, + { camera: { sel: '#rollback-countdown', zoom: 2 } }, // nested in the panel just left + { highlight: '#rollback-countdown' }, + { camera: 'out' }, + { camera: { sel: '#deploy-panel', zoom: 2 } }, // back to the same card + ]; + const churnWarns = (await auditRosterLive(churn, bridge)).warnings.filter((x) => x.kind === 'zoom-churn'); + assert.equal(churnWarns.length, 2, 'both nested re-frames flagged'); + assert.match(churnWarns[0].message, /same card/); + + // held camera (no "out" between the beats on one panel) — no churn + const held = [ + { camera: { sel: '#deploy-panel', zoom: 2 } }, + { spotlight: '#deploy' }, + { highlight: '#rollback-countdown' }, + { camera: 'out' }, + ]; + assert.equal((await auditRosterLive(held, bridge)).warnings.filter((x) => x.kind === 'zoom-churn').length, 0); + + // two genuinely different cards across an "out" — a motivated move, not churn + const twoCards = [ + { camera: { sel: '#deploy-panel', zoom: 2 } }, + { camera: 'out' }, + { camera: { sel: '#topology', zoom: 2 } }, + ]; + assert.equal((await auditRosterLive(twoCards, bridge)).warnings.filter((x) => x.kind === 'zoom-churn').length, 0); +}); diff --git a/showreel/scripts/audit-roster.mjs b/showreel/scripts/audit-roster.mjs index 7f1df76..8bedfba 100644 --- a/showreel/scripts/audit-roster.mjs +++ b/showreel/scripts/audit-roster.mjs @@ -55,7 +55,8 @@ const page = await (await browser.newContext({ viewport: { width: VW, height: VH await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(350); const bridge = makeAuditBridge(page, VW, VH); -const { errors } = await auditRosterLive(steps, bridge); +const { errors, warnings: liveWarnings } = await auditRosterLive(steps, bridge); +warnings.push(...liveWarnings); await browser.close(); // 3. offline conflicts (informational unless the take is offline) diff --git a/showreel/scripts/rec-steps.mjs b/showreel/scripts/rec-steps.mjs index 7fbefcc..3124b9e 100644 --- a/showreel/scripts/rec-steps.mjs +++ b/showreel/scripts/rec-steps.mjs @@ -793,17 +793,30 @@ export function makeAuditBridge(page, vw, vh) { // Returns { errors: [{step, kind, message}] }. export async function auditRosterLive(steps, bridge) { const errors = []; - if (!Array.isArray(steps)) return { errors }; + const warnings = []; + if (!Array.isArray(steps)) return { errors, warnings }; const measure = bridge.measure; let framed = null; // current camera selector or null (full page) let followActive = false; + let leftFramed = null; // selector framed just before the most recent camera:"out" for (let i = 0; i < steps.length; i++) { const s = steps[i]; if (!s || typeof s !== 'object') continue; - if ('screen' in s || 'modal' in s) { framed = null; followActive = false; continue; } + if ('screen' in s || 'modal' in s) { framed = null; followActive = false; leftFramed = null; continue; } const cam = stepCamera(s); - if (cam === 'out') { framed = null; followActive = false; } + if (cam === 'out') { if (framed) leftFramed = framed; framed = null; followActive = false; } else if (cam) { + // zoom-churn (live, DOM-aware): re-framing the SAME CARD after pulling out — + // a nested/containing element (a sub-region of what was just framed) reads as + // a redundant zoom-out/zoom-in on one region. auditScenes catches the exact + // same-selector case statically; only the live DOM sees that (e.g.) + // "#rollback-countdown" is inside "#deploy-panel" framed a beat ago. Warn. + if (leftFramed && leftFramed !== cam && bridge.contains) { + const sameCard = (await bridge.contains(leftFramed, cam)) || (await bridge.contains(cam, leftFramed)); + if (sameCard) warnings.push({ step: i + 1, kind: 'zoom-churn', + message: `camera re-frames "${cam}" — the same card it framed as "${leftFramed}" a beat ago (one is nested in the other), with a "camera":"out" between. The viewer reads a redundant zoom-out/zoom-in on one region. Hold the zoom on the parent panel and swap the annotation instead of pulling "camera":"out".` }); + } + leftFramed = null; framed = cam; followActive = false; // zoom-reach gate: a camera asking to magnify (zoom>1) an element too // wide/tall to enlarge without cropping is silently clamped to ~1x by the @@ -869,7 +882,7 @@ export async function auditRosterLive(steps, bridge) { if (typeof s.select === 'string' && typeof s.option === 'string') { await bridge.select(s.select, s.option); } } catch { /* driving is best-effort; a failed click just leaves state as-is */ } } - return { errors }; + return { errors, warnings }; } // Sidecar step label: the human note when present, otherwise the take's first