diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000..a74f3c7 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,35 @@ +{ + "mode": "pre", + "tag": "beta", + "initialVersions": { + "@agent-devtools/docs": "0.0.5", + "@agent-devtools/example-angular-vite": "0.0.0", + "@agent-devtools/example-html": "0.0.0", + "@agent-devtools/example-next": "0.0.0", + "@agent-devtools/example-next-pages": "0.0.0", + "@agent-devtools/example-nuxt": "0.0.0", + "@agent-devtools/example-nuxt2": "0.0.0", + "@agent-devtools/example-react-vite": "0.0.0", + "@agent-devtools/example-svelte-vite": "0.0.0", + "@agent-devtools/example-sveltekit": "0.0.0", + "@agent-devtools/example-vue-vite": "0.0.0", + "@agent-devtools/example-vue2-vite": "0.0.0", + "@agent-devtools/angular": "1.2.1", + "@agent-devtools/core": "1.2.1", + "@agent-devtools/e2e": "0.0.0", + "@agent-devtools/harness-core": "1.2.1", + "@agent-devtools/html": "1.2.1", + "@agent-devtools/next": "1.2.1", + "@agent-devtools/next-pages": "1.2.1", + "@agent-devtools/nuxt": "1.2.1", + "@agent-devtools/nuxt2": "1.2.1", + "@agent-devtools/react": "1.2.1", + "@agent-devtools/svelte": "1.2.1", + "@agent-devtools/sveltekit": "1.2.1", + "@agent-devtools/vite": "1.2.1", + "@agent-devtools/vue": "1.2.1", + "@agent-devtools/vue2": "1.2.1", + "@agent-devtools/widget-core": "1.2.1" + }, + "changesets": [] +} diff --git a/.gitignore b/.gitignore index cd301b5..002c7fe 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,13 @@ coverage/ # MCP tool artifacts (Playwright snapshots, etc.) .playwright-mcp/ +# Playwright E2E run output + marketing demo capture scratch (the final GIF +# is committed under assets/; the .webm/frames are regenerable via +# packages/e2e/scripts/capture-demo.mjs) +packages/e2e/test-results/ +packages/e2e/playwright-report/ +packages/e2e/demo-capture/ + # npm pack smoke-test output .pack-out/ diff --git a/README.ko.md b/README.ko.md index edb4401..169beea 100644 --- a/README.ko.md +++ b/README.ko.md @@ -36,7 +36,11 @@ ![agent-devtools demo: launcher → picker → composer → live edit](./assets/demo.gif) -위젯에서 비활성화된 "장바구니 담기" 버튼을 픽하고 "사이즈를 골랐는데 왜 계속 disabled 야?" 라고 묻는다. 에이전트는 React fiber 체인을 거슬러 `ProductDetail` 부모까지 따라가고, picker 가 함께 실어 보낸 import 들(`useCart`, `selectors/inventory.ts`)을 따라가며 핸들러와 selector 실제 코드를 읽는다. 그러고 나서 빠져 있는 의존을 설명하거나 곧바로 `Edit` 으로 고친다. "이 리스트가 mutation 후에 왜 갱신 안 돼?", "이 폼이 validation 에러를 삼키는데 어디서 잡고 있어?" 같은 질문도 같은 흐름으로 처리된다 — picker 가 이미 컨텍스트를 패키징했기에 에이전트가 grep 부터 시작하지 않는다. +Checkout 카드의 터무니없이 부풀려진 **Grand total** 을 픽하고 _"이게 $11,691 일 리 없다, $1,400 근처여야 한다 — 숫자가 어느 파일에서 잘못되는지 추적해서 고쳐줘"_ 라고 묻는다. picker 가 컴포넌트 체인과 소스 라인을 함께 실어 보냈기에, 에이전트는 `OrderSummary.tsx` 를 읽고 import 를 따라 `cart.ts` 로 들어간 뒤 `money.ts` 를 열어 단위 계약(모든 금액은 정수 cents, `applyTaxCents` 는 이미 cents 를 반환, `formatCents` 가 100 으로 한 번만 나눔)을 확인한다. 그리고 세금을 한 번 더 100 배 하는 군더더기 `* 100` 을 범인으로 지목해, 정작 클릭하지도 않은 `cart.ts` 에 `Edit` 을 적용한다. Vite HMR 이 같은 탭 안에서 빨간 합계를 올바른 초록 `$1,402.92` 로 무너뜨린다 — IDE 없이. "이 리스트가 mutation 후에 왜 갱신 안 돼?", "이 폼이 validation 에러를 삼키는데 어디서 잡고 있어?" 같은 질문도 같은 흐름으로 처리된다 — picker 가 이미 컨텍스트를 패키징했기에 에이전트가 grep 부터 시작하지 않는다. + +

+ picker 가 PickedEvidence(컴포넌트 체인·소스 파일·셀렉터)를 에이전트에게 그대로 실어 보낸다 +

- 사용자 가이드 (en / ko): - 작동 원리 (한 장의 다이어그램): @@ -44,6 +48,10 @@ ## 카테고리 안에서의 위치 +

+ agent-devtools 의 위치: 에이전트가 페이지 안에서 직접 편집 — IDE 불필요, 추가 구독 없음, 프로덕션 바이트 0 +

+ 가장 가까운 이웃 도구들과의 사실 기반 비교. 우열이 아니라 축이 다르다. | 축 | agent-devtools | In-page → IDE 포워더 (예: Stagewise) | 브라우저 devtools 확장 (예: React DevTools) | In-app 피드백 위젯 (예: ProductLift, Pastel) | diff --git a/README.md b/README.md index c9b09c7..265f4e5 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,11 @@ ![agent-devtools demo: launcher → picker → composer → live edit](./assets/demo.gif) -Pick the disabled "Add to cart" button in the widget and ask "why does this stay disabled after I pick a size?" The agent walks the React fiber chain back to the parent `ProductDetail`, follows the imports the picker shipped along (`useCart`, `selectors/inventory.ts`), reads the actual handler and selector source, then either explains the dependency it's missing or applies an `Edit` to fix it. The same flow handles "why doesn't this list refresh after a mutation?" or "this form swallows the validation error — where is it caught?" — context the picker already packaged so the agent does not have to grep first. +Pick the wildly inflated **Grand total** in the Checkout card and ask _"this should be about $1,400, not $11,691 — trace where the number goes wrong across the files and fix it."_ The picker ships the component chain and the source line, so the agent reads `OrderSummary.tsx`, follows the import into `cart.ts`, then opens `money.ts` to learn the unit contract (every amount is integer cents, `applyTaxCents` already returns cents, `formatCents` divides by 100 once), pins the fault to a stray `* 100` that scales the tax a second time, and applies an `Edit` to `cart.ts` — a file you never clicked. Vite HMR collapses the red total to the correct green `$1,402.92` in the same tab, no IDE involved. The same flow handles "why doesn't this list refresh after a mutation?" or "this form swallows the validation error — where is it caught?" — context the picker already packaged so the agent does not have to grep first. + +

+ The picker ships PickedEvidence — component chain, source file, and selector — straight to the agent +

- User guide (en / ko): - How it works (single-diagram walk-through): @@ -44,6 +48,10 @@ Pick the disabled "Add to cart" button in the widget and ask "why does this stay ## Where this sits in the category +

+ Where agent-devtools sits: the agent edits inside the page — no IDE, no extra subscription, zero production bytes +

+ A factual placement next to the closest neighbors — these tools all do something useful, the axes are just different. | Axis | agent-devtools | In-page → IDE forwarder (e.g. Stagewise) | Browser devtools extension (e.g. React DevTools) | In-app feedback widget (e.g. ProductLift, Pastel) | diff --git a/assets/demo.gif b/assets/demo.gif index 6e6610c..4c658f8 100644 Binary files a/assets/demo.gif and b/assets/demo.gif differ diff --git a/assets/gallery-category.png b/assets/gallery-category.png new file mode 100644 index 0000000..ef04a15 Binary files /dev/null and b/assets/gallery-category.png differ diff --git a/assets/gallery-picker-evidence.png b/assets/gallery-picker-evidence.png new file mode 100644 index 0000000..a470772 Binary files /dev/null and b/assets/gallery-picker-evidence.png differ diff --git a/examples/react-vite/src/App.tsx b/examples/react-vite/src/App.tsx index fb260cb..c6b853b 100644 --- a/examples/react-vite/src/App.tsx +++ b/examples/react-vite/src/App.tsx @@ -1,4 +1,5 @@ import { useState, type FormEvent, type JSX } from 'react'; +import { OrderSummary } from './checkout/OrderSummary.js'; interface User { id: number; @@ -33,6 +34,8 @@ export function App(): JSX.Element { question, and watch the streamed response.

+ + setCount((n) => n + 1)} diff --git a/examples/react-vite/src/checkout/OrderSummary.tsx b/examples/react-vite/src/checkout/OrderSummary.tsx new file mode 100644 index 0000000..2f77c7c --- /dev/null +++ b/examples/react-vite/src/checkout/OrderSummary.tsx @@ -0,0 +1,60 @@ +import type { JSX } from 'react'; +import { computeOrderTotals, LINE_ITEMS } from './cart.js'; +import { formatCents } from './money.js'; + +export function OrderSummary(): JSX.Element { + const totals = computeOrderTotals(LINE_ITEMS); + // A grand total more than double the subtotal can't be right for an 8% + // tax — flag it so an obviously-off number doesn't slip past review. + const looksInflated = totals.totalCents > totals.subtotalCents * 2; + + return ( +
+

Checkout

+

+ Review your order before paying. An 8% tax on a $1,299.00 subtotal should land near $1,400. +

+ + + + + + + + + + {LINE_ITEMS.map((item) => ( + + + + + + ))} + + + + + + + + + + + + + + + +
ItemQtyAmount
{item.name}{item.qty}{formatCents(item.priceCents * item.qty)}
Subtotal + {formatCents(totals.subtotalCents)} +
Tax (8%) + {formatCents(totals.taxCents)} +
+ Grand total + {looksInflated && ⚠ looks off} + + {formatCents(totals.totalCents)} +
+
+ ); +} diff --git a/examples/react-vite/src/checkout/cart.ts b/examples/react-vite/src/checkout/cart.ts new file mode 100644 index 0000000..6e4f0c8 --- /dev/null +++ b/examples/react-vite/src/checkout/cart.ts @@ -0,0 +1,37 @@ +import { addCents, applyTaxCents } from './money.js'; + +export interface LineItem { + id: string; + name: string; + qty: number; + /** Unit price, in cents. */ + priceCents: number; +} + +export const TAX_RATE = 0.08; + +/** Order contents. Subtotal is exactly 129900¢ ($1,299.00). */ +export const LINE_ITEMS: LineItem[] = [ + { id: 'kbd', name: 'Mechanical keyboard', qty: 1, priceCents: 89900 }, + { id: 'mse', name: 'Wireless mouse', qty: 1, priceCents: 24900 }, + { id: 'pad', name: 'Desk mat', qty: 2, priceCents: 7550 }, +]; + +export interface OrderTotals { + subtotalCents: number; + taxCents: number; + totalCents: number; +} + +/** Sum of every line item (unit price × quantity), in cents. */ +export function computeSubtotalCents(items: LineItem[]): number { + return items.reduce((sum, item) => addCents(sum, item.priceCents * item.qty), 0); +} + +/** Subtotal, tax, and grand total for an order — all in cents. */ +export function computeOrderTotals(items: LineItem[]): OrderTotals { + const subtotalCents = computeSubtotalCents(items); + const taxCents = applyTaxCents(subtotalCents, TAX_RATE); + const totalCents = addCents(subtotalCents, taxCents); + return { subtotalCents, taxCents, totalCents }; +} diff --git a/examples/react-vite/src/checkout/money.ts b/examples/react-vite/src/checkout/money.ts new file mode 100644 index 0000000..b390a9f --- /dev/null +++ b/examples/react-vite/src/checkout/money.ts @@ -0,0 +1,32 @@ +/** + * Money domain — the single source of truth for the unit contract. + * + * INVARIANT: every monetary amount in this app is an integer number of + * CENTS. Dollars never exist as a float anywhere except at the very edge, + * inside `formatCents`, purely for display. + * + * - `applyTaxCents` takes an amount in cents and returns the tax portion + * ALREADY in cents. + * - `formatCents` divides by 100 exactly once, at the display boundary, + * to render a `$1,299.00` string. + * + * A stray `* 100` or `/ 100` anywhere outside this file is almost always a + * unit bug — the conversion already happened here. + */ + +/** Add two cents amounts. */ +export function addCents(a: number, b: number): number { + return a + b; +} + +/** Tax portion of `amountCents`, returned in cents. */ +export function applyTaxCents(amountCents: number, rate: number): number { + return Math.round(amountCents * rate); +} + +const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); + +/** Render a cents amount as a `$1,299.00` string (the only `/ 100`). */ +export function formatCents(cents: number): string { + return usd.format(cents / 100); +} diff --git a/examples/react-vite/src/styles.css b/examples/react-vite/src/styles.css index 1fff9e6..3eab594 100644 --- a/examples/react-vite/src/styles.css +++ b/examples/react-vite/src/styles.css @@ -55,6 +55,59 @@ h1 { color: #b6bcc6; } +.order-summary { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.order-summary th, +.order-summary td { + padding: 8px 12px; + border-bottom: 1px solid #262a33; +} + +.order-summary th { + color: #9aa1ab; + font-weight: 500; +} + +.order-summary .num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.order-summary tfoot td { + border-bottom: 0; +} + +.order-summary tfoot tr:first-child td { + border-top: 1px solid #2c3140; + padding-top: 12px; +} + +.order-summary .grand-total-row td { + font-size: 19px; + font-weight: 700; + padding-top: 12px; + transition: color 200ms ease; +} + +.order-summary .is-wrong td { + color: #ff6b6b; +} + +.order-summary .is-ok td { + color: #4ade80; +} + +.order-summary .warn { + margin-left: 8px; + font-size: 12px; + font-weight: 600; + color: #ff6b6b; +} + .counter { display: flex; align-items: center; diff --git a/packages/e2e/scripts/capture-demo.mjs b/packages/e2e/scripts/capture-demo.mjs new file mode 100644 index 0000000..35b73b1 --- /dev/null +++ b/packages/e2e/scripts/capture-demo.mjs @@ -0,0 +1,287 @@ +/** + * capture-demo.mjs — record the marketing demo of the agent-devtools widget. + * + * Drives the react-vite example through the full hero flow in one take: + * broken Checkout total -> open launcher -> activate picker -> pick the + * GRAND TOTAL cell -> reveal the picked evidence (source + component chain) + * -> type a question -> a REAL Claude agent reads across cart.ts / money.ts, + * removes a stray `* 100`, and Vite HMR collapses the inflated red total to + * the correct green one — all inside the same browser tab. + * + * Why this script exists: + * The demo GIF must be reproducible. Per the png2gif "decisions in the + * script, judgement in the human" split, everything deterministic (cursor + * path, typing, beat timing, captions, video size) lives here; the encode + * to GIF lives in a separate ffmpeg step the operator runs on the .webm. + * + * Capture-only cosmetics (NOT example source changes): + * - The example `
` is nudged left so the floating composer never + * covers the totals — pure camera framing, injected at runtime. + * - A synthetic cursor + click ripple (headless video has no OS pointer). + * - A caption banner that narrates each beat for a silent, looping GIF. + * + * Prerequisites: + * - examples/react-vite dev server running with the widget shadow root + * OPENED for capture (AGENT_DEVTOOLS_OPEN_SHADOW=1) at BASE_URL. + * - The Checkout bug injected in cart.ts: computeOrderTotals must read + * `applyTaxCents(subtotalCents, TAX_RATE) * 100`. + * - Local Claude Pro/Max CLI auth (both ACP and SDK delegate to it). + * + * Output: a .webm under OUT_DIR (default ./demo-capture). Feed the newest + * .webm to ffmpeg to produce assets/demo.gif. + */ +/* eslint-disable no-console -- CLI capture status output goes to stdout/stderr by design */ +import { chromium } from '@playwright/test'; +import { mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const BASE_URL = process.env.BASE_URL ?? 'http://127.0.0.1:5190'; +const OUT_DIR = resolve(HERE, '..', process.env.OUT_DIR ?? 'demo-capture'); +const W = 1440; +const H = 820; +const QUESTION = + 'This grand total is way off — it should be about $1,400, not $11,691. ' + + 'The line items and subtotal look fine. Trace where the number goes wrong ' + + 'across the files and fix it.'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** Capture-only framing + synthetic cursor + caption banner. */ +async function injectChrome(page) { + await page.addStyleTag({ + content: ` + main { margin: 0 !important; padding: 40px 0 40px 52px !important; max-width: 820px !important; } + #__demo_cursor, #__demo_ring, #__demo_caption { will-change: transform, opacity; } + `, + }); + await page.evaluate(() => { + const cursor = document.createElement('div'); + cursor.id = '__demo_cursor'; + cursor.style.cssText = [ + 'position:fixed;left:0;top:0;width:22px;height:22px;z-index:2147483647', + 'pointer-events:none;transform:translate(-100px,-100px)', + 'transition:transform 600ms cubic-bezier(.22,.61,.36,1)', + "background:url('data:image/svg+xml;utf8," + + encodeURIComponent( + "", + ) + + "') no-repeat center/contain", + 'filter:drop-shadow(0 1px 2px rgba(0,0,0,.5))', + ].join(';'); + document.body.appendChild(cursor); + + const ring = document.createElement('div'); + ring.id = '__demo_ring'; + ring.style.cssText = [ + 'position:fixed;left:0;top:0;width:40px;height:40px;margin:-20px 0 0 -20px', + 'z-index:2147483646;pointer-events:none;border-radius:50%', + 'background:rgba(79,124,255,.5);transform:translate(-100px,-100px) scale(0)', + 'transition:transform 280ms ease', + ].join(';'); + document.body.appendChild(ring); + + const cap = document.createElement('div'); + cap.id = '__demo_caption'; + cap.style.cssText = [ + 'position:fixed;left:52px;bottom:34px;z-index:2147483645;pointer-events:none', + 'max-width:820px;padding:10px 18px;border-radius:999px', + 'background:rgba(15,17,23,.86);border:1px solid rgba(255,255,255,.12)', + 'color:#fff;font:600 17px/1.3 ui-sans-serif,system-ui,-apple-system,sans-serif', + 'box-shadow:0 8px 24px rgba(0,0,0,.4);opacity:0;transform:translateY(8px)', + 'transition:opacity 260ms ease,transform 260ms ease;letter-spacing:.1px', + ].join(';'); + document.body.appendChild(cap); + }); +} + +async function caption(page, text) { + await page.evaluate((t) => { + const cap = document.getElementById('__demo_caption'); + if (!cap) return; + if (!t) { + cap.style.opacity = '0'; + cap.style.transform = 'translateY(8px)'; + return; + } + cap.textContent = t; + cap.style.opacity = '1'; + cap.style.transform = 'translateY(0)'; + }, text); +} + +async function glide(page, x, y, settle = 650) { + await page.evaluate( + ([x, y]) => { + const c = document.getElementById('__demo_cursor'); + if (c) c.style.transform = `translate(${x}px, ${y}px)`; + }, + [x, y], + ); + await page.mouse.move(x, y, { steps: 24 }); + await sleep(settle); +} + +async function ripple(page, x, y) { + await page.evaluate( + ([x, y]) => { + const r = document.getElementById('__demo_ring'); + if (!r) return; + r.style.transition = 'none'; + r.style.transform = `translate(${x}px, ${y}px) scale(0)`; + void r.offsetWidth; + r.style.transition = 'transform 280ms ease'; + r.style.transform = `translate(${x}px, ${y}px) scale(1)`; + }, + [x, y], + ); + await sleep(300); +} + +async function centerOf(locator) { + const box = await locator.boundingBox(); + if (!box) throw new Error('element not visible for centerOf'); + return { x: box.x + box.width / 2, y: box.y + box.height / 2, box }; +} + +async function main() { + mkdirSync(OUT_DIR, { recursive: true }); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: W, height: H }, + deviceScaleFactor: 1, + recordVideo: { dir: OUT_DIR, size: { width: W, height: H } }, + }); + const page = await context.newPage(); + + // Selectors mirror packages/e2e/support/fixtures.ts (open-shadow pierce). + const launcher = page.locator('button[data-agent-devtools-launcher]'); + const composer = page.locator('[data-agent-devtools-composer]'); + const pickBtn = composer.locator('[data-agent-devtools-composer-pick]'); + const textarea = composer.locator('textarea'); + const chip = composer.locator('[data-agent-devtools-composer-chip] > span').first(); + const chipTip = composer.locator('[data-agent-devtools-composer-chip-tooltip]'); + const grandTotal = page.locator('[data-testid="grand-total"]'); + const grandTotalRow = page.locator('.grand-total-row'); + + try { + await page.goto(BASE_URL, { waitUntil: 'networkidle' }); + await injectChrome(page); + await launcher.waitFor({ state: 'visible', timeout: 30_000 }); + + // Beat 1 — establish the broken total. + await glide(page, 470, H / 2, 300); + await caption(page, 'This checkout total is way off — $11,691?'); + await sleep(2000); + + // Beat 2 — open the in-page agent. + await caption(page, 'Open the agent — right inside the page'); + const l = await centerOf(launcher); + await glide(page, l.x, l.y); + await ripple(page, l.x, l.y); + await launcher.click(); + await composer.waitFor({ state: 'visible', timeout: 10_000 }); + await sleep(800); + + // Beat 3 — activate the picker. + await caption(page, 'Aim the picker at the broken number'); + const p = await centerOf(pickBtn); + await glide(page, p.x, p.y); + await ripple(page, p.x, p.y); + await pickBtn.click(); + await sleep(700); + + // Beat 4 — hover then pick the GRAND TOTAL cell. + const g = await centerOf(grandTotal); + await glide(page, g.x, g.y, 950); + await sleep(500); + await ripple(page, g.x, g.y); + await grandTotal.click(); + await composer.waitFor({ state: 'visible', timeout: 10_000 }); + await sleep(700); + + // Beat 5 — reveal the picked evidence (source file + component chain). + await caption(page, 'Picker ships the component + source line'); + const c = await centerOf(chip); + await glide(page, c.x, c.y, 450); + await chip.dispatchEvent('pointerenter'); + await chip.dispatchEvent('focus'); + // Belt-and-suspenders: force the tooltip visible for the capture in case + // the synthetic pointer path doesn't latch the hover state. + await chipTip + .evaluate((el) => { + el.style.opacity = '1'; + el.style.visibility = 'visible'; + }) + .catch(() => {}); + await sleep(2200); + + if (process.env.DRY === '1') { + await page.screenshot({ path: resolve(OUT_DIR, 'dry-typed.png') }); + const tip = page.locator('[data-agent-devtools-composer-chip-tooltip]'); + console.log('DRY tooltip text:', (await tip.textContent().catch(() => '(none)')) ?? '(none)'); + console.log('DRY screenshot written; skipping agent submit'); + return; + } + + await chip.dispatchEvent('pointerleave'); + await chipTip + .evaluate((el) => { + el.style.opacity = '0'; + el.style.visibility = 'hidden'; + }) + .catch(() => {}); + + // Beat 6 — ask in plain English and send. + await caption(page, 'Ask in plain English — no IDE'); + const t = await centerOf(textarea); + await glide(page, t.x, t.y, 400); + await textarea.click(); + await textarea.type(QUESTION, { delay: 20 }); + await sleep(500); + await textarea.press('Enter'); + + // Beat 7 — the real agent reads across files and applies the edit. + await caption(page, 'Agent reads cart.ts → money.ts across files'); + const start = Date.now(); + let fixed = false; + let switched = false; + while (Date.now() - start < 150_000) { + const txt = (await grandTotal.textContent().catch(() => '')) ?? ''; + const cls = (await grandTotalRow.getAttribute('class').catch(() => '')) ?? ''; + if (!switched && Date.now() - start > 6000) { + switched = true; + await caption(page, 'Found it: tax already in cents, scaled ×100 twice'); + } + if (txt.includes('1,402.92') || cls.includes('is-ok')) { + fixed = true; + break; + } + await sleep(400); + } + + // Beat 8 — payoff: the corrected total snaps in live. + await caption(page, 'Fixed live — total snaps to $1,402.92'); + await glide(page, g.x, g.y, 500); + await sleep(2600); + + // Beat 9 — tagline hold. + await caption(page, 'Pick. Ask. Fixed — right in the page.'); + await sleep(2400); + + if (!fixed) console.error('WARN: total never reached the corrected value within 150s'); + else console.log('OK: grand total collapsed to the corrected value'); + } finally { + await page.waitForTimeout(300); + await context.close(); + await browser.close(); + } + + console.log('video written under', OUT_DIR); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/packages/e2e/scripts/capture-still.mjs b/packages/e2e/scripts/capture-still.mjs new file mode 100644 index 0000000..73fb1f7 --- /dev/null +++ b/packages/e2e/scripts/capture-still.mjs @@ -0,0 +1,84 @@ +/** + * capture-still.mjs — capture the raw widget screenshots used to compose the + * Product Hunt gallery stills (see scripts/make-posters.mjs). + * + * Produces, under OUT_DIR (default ./demo-capture): + * - still-composer.png : a tight, high-DPI shot of the composer chip with + * its expanded tooltip (source file + component chain + selector) — the + * real PickedEvidence the picker hands the agent, not a mockup. + * - still-context.png : the same moment framed in the page (the broken red + * Checkout total beside the widget) for an in-context backdrop. + * + * Prerequisites mirror capture-demo.mjs: the example dev server running with + * AGENT_DEVTOOLS_OPEN_SHADOW=1 at BASE_URL, and the Checkout bug injected so + * the backdrop total reads red. + */ +/* eslint-disable no-console -- CLI capture status output goes to stdout/stderr by design */ +import { chromium } from '@playwright/test'; +import { mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const BASE_URL = process.env.BASE_URL ?? 'http://127.0.0.1:5190'; +const OUT_DIR = resolve(HERE, '..', process.env.OUT_DIR ?? 'demo-capture'); +const W = 1600; +const H = 1000; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + mkdirSync(OUT_DIR, { recursive: true }); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: W, height: H }, + deviceScaleFactor: 2, + }); + const page = await context.newPage(); + + const launcher = page.locator('button[data-agent-devtools-launcher]'); + const composer = page.locator('[data-agent-devtools-composer]'); + const pickBtn = composer.locator('[data-agent-devtools-composer-pick]'); + const chip = composer.locator('[data-agent-devtools-composer-chip] > span').first(); + const chipTip = composer.locator('[data-agent-devtools-composer-chip-tooltip]'); + const grandTotal = page.locator('[data-testid="grand-total"]'); + + try { + await page.goto(BASE_URL, { waitUntil: 'networkidle' }); + // Frame the card to the left so the widget and the totals both breathe. + await page.addStyleTag({ + content: `main { margin: 0 !important; padding: 56px 0 56px 64px !important; max-width: 860px !important; }`, + }); + await launcher.waitFor({ state: 'visible', timeout: 30_000 }); + + await launcher.click(); + await composer.waitFor({ state: 'visible', timeout: 10_000 }); + await pickBtn.click(); + await sleep(300); + await grandTotal.click(); + await composer.waitFor({ state: 'visible', timeout: 10_000 }); + await sleep(300); + + // Force the evidence tooltip open and keep it open for the shot. + await chip.dispatchEvent('pointerenter'); + await chip.dispatchEvent('focus'); + await chipTip + .evaluate((el) => { + el.style.opacity = '1'; + el.style.visibility = 'visible'; + }) + .catch(() => {}); + await sleep(400); + + await page.screenshot({ path: resolve(OUT_DIR, 'still-context.png') }); + await composer.screenshot({ path: resolve(OUT_DIR, 'still-composer.png') }); + console.log('stills written under', OUT_DIR); + } finally { + await context.close(); + await browser.close(); + } +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/packages/e2e/scripts/make-posters.mjs b/packages/e2e/scripts/make-posters.mjs new file mode 100644 index 0000000..345383d --- /dev/null +++ b/packages/e2e/scripts/make-posters.mjs @@ -0,0 +1,180 @@ +/** + * make-posters.mjs — render the two static Product Hunt gallery stills. + * + * Output (under OUT_DIR, default ./demo-capture): + * - poster-evidence.png : "the picker hands the agent your component" — frames + * the real composer screenshot (scripts/capture-still.mjs) beside callouts + * for the PickedEvidence fields (component chain, source file, selector). + * - poster-category.png : "where agent-devtools sits" — the category + * positioning table as a clean dark graphic with the product column lit. + * + * Both are brand-consistent dark posters at a 16:9 gallery ratio, rendered at + * 2x for crispness. Run capture-still.mjs first so still-composer.png exists. + */ +/* eslint-disable no-console -- CLI render status output goes to stdout/stderr by design */ +import { chromium } from '@playwright/test'; +import { mkdirSync, readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT_DIR = resolve(HERE, '..', process.env.OUT_DIR ?? 'demo-capture'); +const W = 1600; +const H = 900; + +const composerPath = resolve(OUT_DIR, 'still-composer.png'); +if (!existsSync(composerPath)) { + console.error(`Missing ${composerPath}. Run scripts/capture-still.mjs first.`); + process.exit(1); +} +const composerB64 = readFileSync(composerPath).toString('base64'); + +const LOGO = ` +`; + +const BASE_CSS = ` + * { box-sizing: border-box; margin: 0; padding: 0; } + :root { font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } + body { width: ${W}px; height: ${H}px; background: + radial-gradient(1200px 700px at 78% -10%, #1b2030 0%, #0f1117 55%); color: #e6e8eb; + overflow: hidden; -webkit-font-smoothing: antialiased; } + .wrap { width: 100%; height: 100%; padding: 56px 64px; display: flex; flex-direction: column; } + .brand { display: flex; align-items: center; gap: 12px; } + .brand .name { font-size: 22px; font-weight: 700; letter-spacing: -.2px; } + .brand .name b { color: #a5b4fc; font-weight: 700; } + .kicker { color: #818cf8; font-weight: 700; font-size: 15px; letter-spacing: .12em; text-transform: uppercase; } + h1 { font-size: 46px; line-height: 1.08; letter-spacing: -1px; font-weight: 800; } + h1 .accent { color: #a5b4fc; } + .sub { color: #9aa1ab; font-size: 20px; line-height: 1.5; } + .foot { margin-top: auto; color: #6b7280; font-size: 15px; } + .pill { color:#cbd5e1; background:#181b22; border:1px solid #2a2f3b; border-radius:999px; padding:5px 12px; font-size:14px; font-weight:600; } +`; + +function evidenceHTML() { + return `
+
${LOGO}
agent-devtools
+
+
+
Pick → reasons, not greps
+

It hands the agent your component
not a screenshot.

+
Click any element. The evidence travels with the pick, so the agent starts reasoning across files instead of grepping.
+
COMPONENT CHAIN
OrderSummary → App
+
SOURCE FILE
checkout/OrderSummary.tsx
+
UNIQUE SELECTOR + OUTERHTML
the exact node, every time
+
+
+
+
PickedEvidence ships with every pick · no IDE · BYO Claude Pro / Max
+
`; +} + +function categoryHTML() { + const cols = [ + 'agent-devtools', + 'IDE forwarder\n(e.g. Stagewise)', + 'Browser devtools\n(e.g. React DevTools)', + 'Feedback widget\n(e.g. Pastel)', + ]; + const rows = [ + [ + 'Who edits the code', + 'The agent, in the tab', + 'A separate IDE agent', + 'Nobody — read-only', + 'Nobody — a backlog item', + ], + ['IDE required', 'No', 'Yes', 'No', 'No'], + [ + 'Context sent to the agent', + 'Component chain + source + selector + outerHTML', + 'URL + screenshot + element', + '—', + 'Screenshot + URL', + ], + ['Subscription', 'BYO Claude Pro / Max', 'BYO model API key', 'None', 'Vendor subscription'], + ['Production-bundle bytes', 'Zero — 2-layer dev-only guard', 'Varies', 'Zero', 'Embedded SDK'], + ]; + const head = cols + .map( + (c, i) => + `${c.replace('\n', '
')}${c.includes('\n') ? '
' : ''}`, + ) + .join(''); + const body = rows + .map( + ([label, ...cells]) => + `${label}${cells.map((c, i) => `${c}`).join('')}`, + ) + .join(''); + return `
+
${LOGO}
agent-devtools
+
Honest neighbors, different axes
+

Where agent-devtools sits in the category

+
The agent edits inside the page — no IDE involved, no extra subscription, zero production bytes.
+ ${head}${body}
+
A factual placement next to the closest neighbors — the axes are just different.
+
`; +} + +async function main() { + mkdirSync(OUT_DIR, { recursive: true }); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: W, height: H }, + deviceScaleFactor: 2, + }); + const page = await context.newPage(); + for (const [name, html] of [ + ['poster-evidence', evidenceHTML()], + ['poster-category', categoryHTML()], + ]) { + await page.setContent(html, { waitUntil: 'networkidle' }); + await page.waitForTimeout(150); + await page.screenshot({ path: resolve(OUT_DIR, `${name}.png`) }); + console.log('rendered', `${name}.png`); + } + await context.close(); + await browser.close(); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/packages/e2e/test-results/.last-run.json b/packages/e2e/test-results/.last-run.json deleted file mode 100644 index f740f7c..0000000 --- a/packages/e2e/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "passed", - "failedTests": [] -}