diff --git a/.gitignore b/.gitignore index a961549c..0e38d571 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,6 @@ native/macos/MCPProxy/.build/ .wrangler/ .run + +# demo pipeline: playwright node_modules symlink (recreated at capture time) +scripts/demo/node_modules diff --git a/README.md b/README.md index 29015511..bb4f27e2 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,36 @@ -# MCPProxy – Smart Proxy for AI Agents +# πŸ›‘οΈ MCPProxy β€” Supercharge AI Agents, Safely -**MCPProxy** is an open-source desktop application that super-charges AI agents with intelligent tool discovery, massive token savings, and built-in security quarantine against malicious MCP servers. +> One safe endpoint in front of every MCP server. -### **πŸ“š [Read the Documentation](https://docs.mcpproxy.app/)** +[![Release](https://img.shields.io/github/v/release/smart-mcp-proxy/mcpproxy-go?sort=semver)](https://github.com/smart-mcp-proxy/mcpproxy-go/releases) +[![Build](https://github.com/smart-mcp-proxy/mcpproxy-go/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/smart-mcp-proxy/mcpproxy-go/actions/workflows/unit-tests.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/smart-mcp-proxy/mcpproxy-go)](https://goreportcard.com/report/github.com/smart-mcp-proxy/mcpproxy-go) +[![Go Reference](https://pkg.go.dev/badge/github.com/smart-mcp-proxy/mcpproxy-go.svg)](https://pkg.go.dev/github.com/smart-mcp-proxy/mcpproxy-go) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![GitHub stars](https://img.shields.io/github/stars/smart-mcp-proxy/mcpproxy-go?style=social)](https://github.com/smart-mcp-proxy/mcpproxy-go/stargazers) - -[![MCPProxy Demo](https://img.youtube.com/vi/2aKrgJnbbcw/0.jpg)](https://youtu.be/2aKrgJnbbcw) + + MCPProxy β€” Supercharge AI Agents, Safely + -🌐 Visit mcpproxy.app +

+ MCPProxy web UI demo: server dashboard, tool discovery, activity log, and security quarantine +

+ +

+ πŸ“Ί Watch the full walkthrough  Β·  + πŸ“š Read the docs  Β·  + 🌐 mcpproxy.app +

+ +> The demo above shows the **embedded web UI**. The MCPProxy **core is a single binary for macOS, Linux, and Windows** β€” the web UI ships inside it, with no extra service to run. On **macOS**, an optional **menu‑bar app** adds one‑click convenience (start/stop, server health, quarantine, logs).
- - System Tray - Upstream Servers - + MCPProxy macOS menu-bar app      - - System Tray - Quarantine Management - + MCPProxy macOS app β€” Activity log with sensitive-data detection
- System Tray - Upstream Servers          System Tray - Quarantine Management + macOS menu‑bar app   Β·   Activity log & audit in the macOS app
@@ -27,7 +39,7 @@ - **Scale beyond API limits** – Federate hundreds of MCP servers while bypassing Cursor's 40-tool limit and OpenAI's 128-function cap. - **Save tokens & accelerate responses** – Agents load just one `retrieve_tools` function instead of hundreds of schemas. Research shows ~99 % token reduction with **43 % accuracy improvement**. - **Advanced security protection** – Automatic quarantine blocks Tool Poisoning Attacks until you manually approve new servers. -- **Works offline & cross-platform** – Native binaries for macOS (Intel & Apple Silicon), Windows (x64 & ARM64), and Linux (x64 & ARM64) with system-tray UI. +- **Works offline & cross-platform** – A single core binary for macOS (Intel & Apple Silicon), Windows (x64 & ARM64), and Linux (x64 & ARM64), with the **web UI embedded**. macOS additionally ships an optional menu-bar app. --- diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 00000000..56742381 Binary files /dev/null and b/docs/demo.gif differ diff --git a/docs/logo.svg b/docs/logo.svg new file mode 100644 index 00000000..fda93e8e --- /dev/null +++ b/docs/logo.svg @@ -0,0 +1,127 @@ + + + + + + + + + + + mcpproxy shield logo + Blue shield with MCP circles beneath + + + + + + + M + C + P + diff --git a/docs/screenshot-macos-activity.png b/docs/screenshot-macos-activity.png new file mode 100644 index 00000000..13d3896e Binary files /dev/null and b/docs/screenshot-macos-activity.png differ diff --git a/docs/screenshot-macos-tray.png b/docs/screenshot-macos-tray.png new file mode 100644 index 00000000..bf475dc2 Binary files /dev/null and b/docs/screenshot-macos-tray.png differ diff --git a/docs/social.html b/docs/social.html new file mode 100644 index 00000000..65e9d39b --- /dev/null +++ b/docs/social.html @@ -0,0 +1,55 @@ + + + + + + + +
+
+
+
MCPProxy
+

Supercharge AI Agents, Safely

+
One safe endpoint in front of every MCP server.
+
+
πŸ” Discover
+
πŸ›‘οΈ Quarantine
+
πŸ”— Federate
+
⚑ βˆ’99% tokens
+
+
+
mcpproxy.app
+ + diff --git a/docs/social.png b/docs/social.png new file mode 100644 index 00000000..2053c3cb Binary files /dev/null and b/docs/social.png differ diff --git a/scripts/demo/README.md b/scripts/demo/README.md new file mode 100644 index 00000000..1cef342d --- /dev/null +++ b/scripts/demo/README.md @@ -0,0 +1,20 @@ +# Demo asset pipeline + +Regenerates the README hero banner and the (web-UI-only) demo GIF. Run from repo root. + +1. `scripts/demo/render-banner.sh` # docs/social.html (+ docs/logo.svg) -> docs/social.png +2. Boot a demo mcpproxy and capture the web UI: + ``` + cd scripts/demo + ln -sfn ../../e2e/playwright/node_modules ./node_modules + MCPPROXY_BASE_URL=http://127.0.0.1:18082 MCPPROXY_API_KEY= \ + ./node_modules/.bin/playwright test --config=playwright.config.ts + ``` + # produces /tmp/demo-webui//video.webm for the 4 beats +3. `scripts/demo/build-demo.sh` # stitches the 4 web beats -> docs/demo.gif + docs/demo.webp + +The macOS tray menu and native app are shown as **static screenshots** in the README +(`docs/screenshot-macos-tray.png`, `docs/screenshot-macos-activity.png`), not in the GIF. + +All outputs are committed under docs/. social.png is also uploaded manually to +GitHub Settings -> Social preview (one-time, cannot be scripted). diff --git a/scripts/demo/build-demo.sh b/scripts/demo/build-demo.sh new file mode 100755 index 00000000..5e575299 --- /dev/null +++ b/scripts/demo/build-demo.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Stitch the demo GIF from four web-UI video segments (web-UI-only; the macOS tray +# is shown as a static screenshot in the README instead). +# +# Inputs (produced by capture-webui.spec.ts): +# /tmp/demo-webui/*1-servers*/video.webm server cards / federation +# /tmp/demo-webui/*2-tools*/video.webm tools / discovery +# /tmp/demo-webui/*3-activity*/video.webm activity log / audit (detail drawer) +# /tmp/demo-webui/*4-security*/video.webm quarantine close-up +# Outputs: docs/demo.gif + docs/demo.webp +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WEB=/tmp/demo-webui +WORK=$(mktemp -d) +W=860; H=538; FPS=15; BG="#0f172a"; WEBSPEED=1.8 # speed up web segments to fit size budget + +# Ordered web segments (the leading number in the test name fixes the order). +# Portable array fill (avoid mapfile β€” macOS ships bash 3.2). +WEBS=() +for pat in 1-servers 2-tools 3-activity 4-security; do + f=$(find "$WEB" -name '*.webm' -path "*$pat*" | head -1) + [ -n "$f" ] && WEBS+=("$f") +done +[ "${#WEBS[@]}" -eq 4 ] || { echo "expected 4 web videos in $WEB, got ${#WEBS[@]} (run capture-webui.spec.ts)"; exit 1; } + +# Segments 0..3 β€” web videos, scaled to canvas, sped up, no audio +i=0 +for v in "${WEBS[@]}"; do + ffmpeg -y -i "$v" -an \ + -vf "setpts=PTS/${WEBSPEED},scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2:color=${BG},fps=${FPS},format=yuv420p" \ + -c:v libx264 -pix_fmt yuv420p "$WORK/seg${i}.mp4" + i=$((i+1)) +done + +# Concat with the concat FILTER (re-encodes β€” normalizes SAR/timebase; the concat +# demuxer with -c copy silently drops mismatched segments). +ffmpeg -y -i "$WORK/seg0.mp4" -i "$WORK/seg1.mp4" -i "$WORK/seg2.mp4" -i "$WORK/seg3.mp4" \ + -filter_complex "[0:v]setsar=1,fps=${FPS}[a];[1:v]setsar=1,fps=${FPS}[b];[2:v]setsar=1,fps=${FPS}[c];[3:v]setsar=1,fps=${FPS}[d];[a][b][c][d]concat=n=4:v=1:a=0[v]" \ + -map "[v]" -c:v libx264 -pix_fmt yuv420p "$WORK/full.mp4" + +# Palette-optimized GIF (-threads 1 dodges an ffmpeg 8.0 paletteuse threading bug) +ffmpeg -y -threads 1 -i "$WORK/full.mp4" -vf "fps=${FPS},scale=${W}:-2:flags=lanczos,palettegen=stats_mode=diff" "$WORK/pal.png" +ffmpeg -y -threads 1 -i "$WORK/full.mp4" -i "$WORK/pal.png" \ + -lavfi "fps=${FPS},scale=${W}:-2:flags=lanczos,paletteuse=dither=bayer:bayer_scale=3" "$ROOT/docs/demo.gif" + +# WebP (smaller; also autoplays in README). Non-fatal β€” the GIF is the README embed. +ffmpeg -y -threads 1 -i "$WORK/full.mp4" -vcodec libwebp -filter:v "fps=${FPS},scale=${W}:-2" \ + -lossless 0 -compression_level 6 -q:v 55 -loop 0 -an "$ROOT/docs/demo.webp" || \ + { echo "WARN: webp encode failed (non-fatal); removing partial"; rm -f "$ROOT/docs/demo.webp"; } + +echo "Wrote docs/demo.gif ($(du -h "$ROOT/docs/demo.gif" | cut -f1)) and docs/demo.webp ($(du -h "$ROOT/docs/demo.webp" | cut -f1))" +rm -rf "$WORK" diff --git a/scripts/demo/capture-tray.md b/scripts/demo/capture-tray.md new file mode 100644 index 00000000..d4474804 --- /dev/null +++ b/scripts/demo/capture-tray.md @@ -0,0 +1,18 @@ +# Tray capture shot-list (mcpproxy-ui-test MCP) + +Goal: ~12 still frames of the macOS tray menu showing upstream servers + health, +to be assembled into a ~5s montage (beat 1 of the demo GIF). + +Prereq: MCPProxy tray .app running with 3-4 healthy demo servers configured; +`mcpproxy-ui-test` MCP connected (bundle id com.smartmcpproxy.mcpproxy.dev). + +Capture into /tmp/demo-tray/ as frame-01.png, frame-02.png, ... in this order: + +1. frame-01..03: `screenshot_status_bar_menu` β€” closed β†’ menu opening (3 shots) +2. frame-04..06: `list_menu_items` then `screenshot_status_bar_menu` with the + "Upstream Servers" submenu expanded (servers + green health dots) +3. frame-07..09: hover/select a single server submenu (its tools count, status) +4. frame-10..12: `screenshot_status_bar_menu` returning to the top menu (close) + +Naming MUST be zero-padded frame-NN.png so ffmpeg globbing is ordered. +mkdir -p /tmp/demo-tray before capturing. diff --git a/scripts/demo/capture-webui.spec.ts b/scripts/demo/capture-webui.spec.ts new file mode 100644 index 00000000..3819c160 --- /dev/null +++ b/scripts/demo/capture-webui.spec.ts @@ -0,0 +1,83 @@ +import { test, type Page } from '@playwright/test'; + +// Records four web-UI walkthroughs as video (saved to outputDir by the `video` +// config). Navigation + dwell driven, robust to frontend version drift, with a +// spinner guard so we never film a loading state. Run against a LIVE mcpproxy: +// MCPPROXY_BASE_URL=http://127.0.0.1:18082 MCPPROXY_API_KEY=... \ +// ./node_modules/.bin/playwright test --config=playwright.config.ts + +const KEY = process.env.MCPPROXY_API_KEY || ''; +const q = `?apikey=${KEY}`; + +// Seed the API key into localStorage before any page script runs. The frontend +// reads it there (api.ts) without depending on the ?apikey URL param surviving the +// SPA router, and it persists across this context's navigations. +test.beforeEach(async ({ context }) => { + await context.addInitScript((key) => { + try { localStorage.setItem('mcpproxy-api-key', key); } catch { /* ignore */ } + }, KEY); +}); + +// Settle: domcontentloaded, dismiss the onboarding wizard + auth modal if present, +// wait for any spinner to disappear, then dwell. Never networkidle β€” SSE keeps the +// network busy forever. +async function ready(page: Page, dwellMs: number) { + await page.waitForLoadState('domcontentloaded'); + // Dismiss the first-run onboarding wizard if it overlays the page. + await page.locator('[data-test="close-wizard"]').click({ timeout: 2500 }).catch(() => {}); + // Re-submit the key if the auth modal shows (transient validation failure). + try { + const keyInput = page.locator('input[placeholder*="api key" i]').first(); + if (await keyInput.isVisible({ timeout: 2000 })) { + await keyInput.fill(KEY); + await page.getByRole('button', { name: /set key/i }).click({ timeout: 3000 }); + await keyInput.waitFor({ state: 'detached', timeout: 6000 }).catch(() => {}); + } + } catch { /* no modal */ } + await page + .locator('.loading, .spinner, [class*="spinner"], [class*="loading"], [role="progressbar"]') + .first() + .waitFor({ state: 'detached', timeout: 6000 }) + .catch(() => {}); + await page.waitForTimeout(dwellMs); +} + +test('1 servers', async ({ page }) => { + await page.goto(`/ui/servers${q}`); + await ready(page, 3000); // KPI cards (total/connected/quarantined/tools) + server cards + await page.mouse.wheel(0, 350); + await page.waitForTimeout(1600); + await page.mouse.wheel(0, -350); + await page.waitForTimeout(700); +}); + +test('2 tools discovery', async ({ page }) => { + await page.goto(`/ui/tools${q}`); + await ready(page, 3000); // 39 tools, approval states β€” no search interaction + await page.mouse.wheel(0, 320); + await page.waitForTimeout(1800); + await page.mouse.wheel(0, -320); + await page.waitForTimeout(700); +}); + +test('3 activity log', async ({ page }) => { + await page.goto(`/ui/activity${q}`); + await ready(page, 2600); // populated: successes + a sensitive-data flag + // Open the detail drawer for the newest *Tool Call* (the sensitive-data echo) to + // show full event details. Each row's last button is the chevron -> selectActivity(). + // Targeting a Tool Call (not System Start/Stop) gives a meaningful payload + the + // sensitive-data detection. + try { + const toolRow = page.locator('tbody tr').filter({ hasText: /Tool Call/i }).first(); + await toolRow.locator('button').last().click({ timeout: 4000 }); + await page.waitForTimeout(3200); // dwell on the detail drawer + } catch { + await page.waitForTimeout(2500); + } +}); + +test('4 security quarantine', async ({ page }) => { + await page.goto(`/ui/servers/memory${q}`); + await ready(page, 3400); // ServerDetail: 'Security Quarantine' alert + Approve action + await page.waitForTimeout(800); +}); diff --git a/scripts/demo/playwright.config.ts b/scripts/demo/playwright.config.ts new file mode 100644 index 00000000..84f8e59a --- /dev/null +++ b/scripts/demo/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from '@playwright/test'; + +// Captures the live mcpproxy web UI as video for the README demo GIF. +// Point baseURL at a running mcpproxy; pass the API key via MCPPROXY_API_KEY. +export default defineConfig({ + testDir: '.', + timeout: 60000, + fullyParallel: false, + workers: 1, + retries: 0, + // The @playwright/test runner manages contexts itself, so use `video` (not the + // raw recordVideo context option) and collect the .webm files from outputDir. + outputDir: '/tmp/demo-webui', + use: { + headless: true, + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 2, + baseURL: process.env.MCPPROXY_BASE_URL || 'http://127.0.0.1:8080', + launchOptions: { + executablePath: + '/Users/user/Library/Caches/ms-playwright/chromium-1217/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing', + }, + video: { mode: 'on', size: { width: 1440, height: 900 } }, + }, +}); diff --git a/scripts/demo/render-banner.sh b/scripts/demo/render-banner.sh new file mode 100755 index 00000000..c3a30138 --- /dev/null +++ b/scripts/demo/render-banner.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Render docs/social.html -> docs/social.png at 1280x640 via headless Chromium. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHROME="/Users/$(whoami)/Library/Caches/ms-playwright/chromium-1217/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" +[ -x "$CHROME" ] || { echo "Chromium 1217 not found at: $CHROME"; exit 1; } + +"$CHROME" --headless=new --hide-scrollbars --force-device-scale-factor=2 \ + --window-size=1280,640 \ + --screenshot="$ROOT/docs/social.png" \ + --default-background-color=00000000 \ + "file://$ROOT/docs/social.html" + +echo "Wrote $ROOT/docs/social.png" diff --git a/specs/051-readme-hero-demo/design.md b/specs/051-readme-hero-demo/design.md new file mode 100644 index 00000000..d20cfe4a --- /dev/null +++ b/specs/051-readme-hero-demo/design.md @@ -0,0 +1,120 @@ +# Design β€” README hero revamp + demo asset pipeline + +**Spec:** 051-readme-hero-demo +**Date:** 2026-05-21 +**Status:** Approved (brainstorming) β€” pending implementation plan +**Owner:** @Dumbris + +## Summary + +Replace the top of the repo `README.md` with a CodexBar-style hero: a designed +full-width banner plus one cohesive animated demo GIF, in the project's +blueβ†’green-on-navy brand palette. Build the assets through a scripted, re-runnable +pipeline so they can be regenerated rather than hand-crafted once. + +This is Phase-1 item #5 from the OSS repo-hygiene work (PR #487 shipped the badge +row; this completes the hero/demo half that could not be auto-generated). + +## Motivation + +The repo underperforms the product visually. The README currently opens with a bare +YouTube thumbnail link and two small externally-hosted tray screenshots. CodexBar +(steipete) demonstrates the target: emoji H1 + tagline + flat badge row + a designed +banner + one tight product shot. We add motion (a GUI demo GIF) on top of that polish. + +## Key constraint that shapes the design + +- The **macOS UI-test MCP** (`mcpproxy-ui-test`) produces **still screenshots**, not + video (`screenshot_window`, `screenshot_status_bar_menu`, `click_menu_item`, …). +- **Playwright** *can* record **real video** of the web UI (`recordVideo` on the + browser context). + +Therefore the "one cohesive GIF" is assembled from two source types: +true-motion web-UI video segments + a tray frame-montage (stills with synthetic +pans/cross-fades), stitched with ffmpeg. The whole thing is scriptable and CI-friendly. + +## Decisions (locked during brainstorming) + +| Topic | Decision | +|-------|----------| +| Hero treatment | Option A β€” designed banner (static), with a demo GIF directly beneath | +| Banner style | #2 "frosted tiles" | +| Headline | "Supercharge AI Agents, Safely" | +| Banner palette | navy `#0f172a`, blue `#3b82f6`/`#60a5fa`, green `#005533`/`#006644` | +| Banner pills | πŸ” Discover Β· πŸ›‘οΈ Quarantine Β· πŸ”— Federate Β· ⚑ βˆ’99% tokens | +| Demo structure | One cohesive ~16s GIF (not multiple clips) | +| Demo surfaces & order | tray menu β†’ web-UI server cards β†’ web-UI activity log | +| Recording | tray via UI-test MCP (frames); web UI via Playwright (video) | + +## README top-of-page structure + +Replaces current README lines ~1–25: + +``` +# πŸ›‘οΈ MCPProxy β€” Supercharge AI Agents, Safely +> One safe endpoint in front of every MCP server. (≀8-word tagline) +[ badge row β€” already shipped in #487, unchanged ] +[ FULL-WIDTH HERO BANNER ] docs/social.png, wrapped in +[ ONE COHESIVE DEMO GIF ] docs/demo.gif +πŸ“Ί Watch the full walkthrough β†’ (existing YouTube link, demoted to one line) +## Why MCPProxy? (keep existing bold-lead bullets) +``` + +- **Remove**: the two `mcpproxy.app/images/menu_*.png` thumbnails (superseded by GIF). +- **Demote**: bare YouTube thumbnail β†’ a one-line text link under the GIF. +- **Keep**: everything from "Why MCPProxy?" downward, including the install/config + sections and the existing local screenshots used in feature sections. + +## Assets + +### 1. Hero banner +- `docs/social.html` β€” checked-in 1280Γ—640 canvas (frosted-tiles design above). +- `docs/social.png` β€” rendered from it via headless Chromium (reuse the + Playwright/Chromium already installed at `e2e/playwright/node_modules`). +- Same PNG is uploaded to **GitHub Settings β†’ Social preview** (manual, one-time). + +### 2. Demo GIF β€” storyboard (~16s) +1. **Tray menu** (~5s) β€” open menubar menu, show upstream servers with live health, + hover a server. Source: UI-test MCP frame sequence β†’ montage. +2. **Server cards** (~6s) β€” web UI dashboard, healthy cards, click into a server + detail. Source: Playwright `recordVideo` (real motion). +3. **Activity log** (~5s) β€” web UI activity stream; a tool call with a sensitive-data + flag expands. Source: Playwright `recordVideo`. + +Stitch: ffmpeg concat (tray montage + 2 webm segments) β†’ palette-optimized +`docs/demo.gif` (≀900px wide, 15fps, target <8MB) and optional `docs/demo.webp`. + +### 3. Existing static screenshots +Keep `docs/screenshots/tray-macos/*.png` (4 files) for lower feature sections. No +re-shoot. + +## Production pipeline β€” `scripts/demo/` + +| File | Purpose | +|------|---------| +| `render-banner.sh` | Headless-Chromium screenshot of `social.html` β†’ `social.png` | +| `capture-tray.md` | UI-test MCP shot-list (menu items + order) for the tray montage | +| `capture-webui.spec.ts` | Playwright spec: boot throwaway mcpproxy (CLAUDE.md UI-test pattern), seed 3–4 demo servers, record the two web-UI segments | +| `build-demo.sh` | ffmpeg stitch β†’ `demo.gif` + `demo.webp` | + +The Playwright spec follows the existing UI-verification pattern documented in +CLAUDE.md (throwaway data-dir, pinned Chromium 1217, `domcontentloaded` not +`networkidle`, `data-test` selectors). + +## Scope / non-goals + +**In scope:** banner (`social.html` + `social.png`), one demo GIF, README hero +rewrite, scripted `scripts/demo/` pipeline, social-preview PNG. + +**Out of scope (this round):** asciinema/terminal GIF; DiΓ‘taxis docs restructure; +website redesign. The glass-pill motif is available to reuse on the website later. + +**Requires the user (cannot be scripted):** uploading `social.png` to GitHub +Settings β†’ Social preview. + +## Success criteria + +- README opens with banner + autoplaying demo GIF; renders correctly on github.com. +- `demo.gif` ≀ 8MB, ≀ 900px wide, autoplays + loops inline. +- All four `scripts/demo/` artifacts run end-to-end and regenerate the committed assets. +- Social-preview PNG produces a correct unfurl on X/Slack/HN (manual verify). diff --git a/specs/051-readme-hero-demo/plan.md b/specs/051-readme-hero-demo/plan.md new file mode 100644 index 00000000..2aae00e5 --- /dev/null +++ b/specs/051-readme-hero-demo/plan.md @@ -0,0 +1,593 @@ +# README Hero Banner + Demo GIF β€” Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the top of `README.md` with a CodexBar-style designed hero banner plus one cohesive ~16s demo GIF (tray menu β†’ web-UI server cards β†’ activity log), produced by a scripted, re-runnable pipeline. + +**Architecture:** A checked-in `docs/social.html` is rendered to `docs/social.png` via headless Chromium. The demo GIF is assembled from a macOS-tray still-frame montage (captured via the `mcpproxy-ui-test` MCP) and two real-motion web-UI video segments (captured via Playwright `recordVideo`), stitched with ffmpeg into `docs/demo.gif`. A `scripts/demo/` folder holds all generators so assets can be regenerated, not hand-made once. + +**Tech Stack:** HTML/CSS (banner), headless Chromium (existing Playwright/Chromium at `e2e/playwright/node_modules`, pinned 1217), Playwright/TypeScript (web-UI capture), `mcpproxy-ui-test` MCP (tray capture), ffmpeg (stitch/encode), bash (orchestration). + +**Prerequisites (verify before Task 1):** +- `ffmpeg` available: `which ffmpeg` (install via `brew install ffmpeg` if missing). +- Built `mcpproxy` binary at repo root (`make build`). +- The `mcpproxy-ui-test` MCP server built per CLAUDE.md (`/tmp/mcpproxy-ui-test`) and the MCPProxy tray `.app` running, for Task 5 only. + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `docs/social.html` | The 1280Γ—640 banner source (frosted-tiles design) | +| `docs/social.png` | Rendered banner (committed; also GitHub social preview) | +| `docs/demo.gif` | Final stitched demo (committed, README embed) | +| `docs/demo.webp` | Optional smaller variant (committed) | +| `scripts/demo/render-banner.sh` | social.html β†’ social.png via headless Chromium | +| `scripts/demo/capture-tray.md` | UI-test MCP shot-list for the tray montage | +| `scripts/demo/capture-webui.spec.ts` | Playwright spec: boot throwaway mcpproxy, seed demo servers + a flagged activity, record 2 web-UI segments | +| `scripts/demo/playwright.config.ts` | Pins Chromium 1217, viewport, recordVideo dir | +| `scripts/demo/build-demo.sh` | ffmpeg: tray montage + 2 webm β†’ demo.gif + demo.webp | +| `scripts/demo/README.md` | How to regenerate every asset (run order) | +| `README.md` | Hero rewrite (lines ~1–25) | + +--- + +## Task 1: Scaffold `scripts/demo/` and verify ffmpeg + +**Files:** +- Create: `scripts/demo/README.md` + +- [ ] **Step 1: Verify ffmpeg present** + +Run: `which ffmpeg && ffmpeg -version | head -1` +Expected: a path and a version line. If missing: `brew install ffmpeg`, then re-run. + +- [ ] **Step 2: Create the folder + run-order doc** + +Create `scripts/demo/README.md`: + +```markdown +# Demo asset pipeline + +Regenerates the README hero banner and demo GIF. Run from repo root. + +1. `scripts/demo/render-banner.sh` # docs/social.html -> docs/social.png +2. Follow `scripts/demo/capture-tray.md` # produces /tmp/demo-tray/frame-*.png +3. `cd scripts/demo && ../../e2e/playwright/node_modules/.bin/playwright test capture-webui.spec.ts` + # produces /tmp/demo-webui/*.webm +4. `scripts/demo/build-demo.sh` # stitches -> docs/demo.gif + docs/demo.webp + +All outputs are committed under docs/. social.png is also uploaded manually to +GitHub Settings -> Social preview (one-time, cannot be scripted). +``` + +- [ ] **Step 3: Commit** + +```bash +git add scripts/demo/README.md +git commit -m "docs(051): scaffold demo asset pipeline folder" +``` + +--- + +## Task 2: Build the hero banner source (`docs/social.html`) + +**Files:** +- Create: `docs/social.html` + +- [ ] **Step 1: Write the banner HTML** + +Create `docs/social.html` (1280Γ—640, frosted-tiles design, brand palette): + +```html + + + + + + + +
+
+
+
MCPProxy
+

Supercharge AI Agents, Safely

+
One safe endpoint in front of every MCP server.
+
+
πŸ” Discover
+
πŸ›‘οΈ Quarantine
+
πŸ”— Federate
+
⚑ βˆ’99% tokens
+
+
+
mcpproxy.app
+ + +``` + +- [ ] **Step 2: Eyeball it in a browser** + +Run: `open docs/social.html` +Expected: navy banner, gradient-clipped headline "Supercharge AI Agents, Safely", four frosted pills, aurora glow. Window is 1280Γ—640. + +- [ ] **Step 3: Commit** + +```bash +git add docs/social.html +git commit -m "feat(051): add hero banner source (frosted-tiles social.html)" +``` + +--- + +## Task 3: Render banner β†’ `docs/social.png` + +**Files:** +- Create: `scripts/demo/render-banner.sh` +- Create (output): `docs/social.png` + +- [ ] **Step 1: Write the render script** + +Create `scripts/demo/render-banner.sh`: + +```bash +#!/usr/bin/env bash +# Render docs/social.html -> docs/social.png at 1280x640 via headless Chromium. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHROME="/Users/$(whoami)/Library/Caches/ms-playwright/chromium-1217/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" +[ -x "$CHROME" ] || { echo "Chromium 1217 not found at: $CHROME"; exit 1; } + +"$CHROME" --headless=new --hide-scrollbars --force-device-scale-factor=2 \ + --window-size=1280,640 \ + --screenshot="$ROOT/docs/social.png" \ + --default-background-color=00000000 \ + "file://$ROOT/docs/social.html" + +echo "Wrote $ROOT/docs/social.png" +``` + +- [ ] **Step 2: Make executable and run** + +Run: +```bash +chmod +x scripts/demo/render-banner.sh && scripts/demo/render-banner.sh +``` +Expected: "Wrote .../docs/social.png". + +- [ ] **Step 3: Verify dimensions (retina β†’ 2560Γ—1280)** + +Run: `sips -g pixelWidth -g pixelHeight docs/social.png` +Expected: pixelWidth 2560, pixelHeight 1280 (2Γ— scale of 1280Γ—640). If 1280Γ—640, the `--force-device-scale-factor=2` flag was ignored β€” acceptable but note it. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/demo/render-banner.sh docs/social.png +git commit -m "feat(051): render social.png banner via headless chromium" +``` + +--- + +## Task 4: Write the tray capture shot-list + +**Files:** +- Create: `scripts/demo/capture-tray.md` + +- [ ] **Step 1: Write the shot-list** + +Create `scripts/demo/capture-tray.md`: + +```markdown +# Tray capture shot-list (mcpproxy-ui-test MCP) + +Goal: ~12 still frames of the macOS tray menu showing upstream servers + health, +to be assembled into a ~5s montage (beat 1 of the demo GIF). + +Prereq: MCPProxy tray .app running with 3-4 healthy demo servers configured; +`mcpproxy-ui-test` MCP connected (bundle id com.smartmcpproxy.mcpproxy.dev). + +Capture into /tmp/demo-tray/ as frame-01.png, frame-02.png, ... in this order: + +1. frame-01..03: `screenshot_status_bar_menu` β€” closed β†’ menu opening (3 shots) +2. frame-04..06: `list_menu_items` then `screenshot_status_bar_menu` with the + "Upstream Servers" submenu expanded (servers + green health dots) +3. frame-07..09: hover/select a single server submenu (its tools count, status) +4. frame-10..12: `screenshot_status_bar_menu` returning to the top menu (close) + +Naming MUST be zero-padded frame-NN.png so ffmpeg globbing is ordered. +mkdir -p /tmp/demo-tray before capturing. +``` + +- [ ] **Step 2: Commit** + +```bash +git add scripts/demo/capture-tray.md +git commit -m "docs(051): add tray capture shot-list for demo montage" +``` + +--- + +## Task 5: Capture tray frames (manual, via UI-test MCP) + +**Files:** +- Create (output, NOT committed): `/tmp/demo-tray/frame-*.png` + +> This task runs the `mcpproxy-ui-test` MCP tools. It is interactive and macOS-only. +> The frames are scratch input to Task 7; they are not committed. + +- [ ] **Step 1: Prep demo config + start tray** + +Ensure 3–4 demo servers exist in the tray's config (e.g. `github`, `filesystem`, +`fetch`, `time`) and the `.app` is running per CLAUDE.md tray build steps. + +Run: `mkdir -p /tmp/demo-tray` + +- [ ] **Step 2: Capture frames per the shot-list** + +Use the MCP tools in this order, saving each returned screenshot to +`/tmp/demo-tray/frame-NN.png`: +- `check_accessibility` (sanity) +- `screenshot_status_bar_menu` Γ—3 (frames 01–03) +- `list_menu_items` then `screenshot_status_bar_menu` Γ—3 (frames 04–06) +- `click_menu_item` into a server, `screenshot_status_bar_menu` Γ—3 (frames 07–09) +- `screenshot_status_bar_menu` Γ—3 (frames 10–12) + +- [ ] **Step 3: Verify frame set** + +Run: `ls /tmp/demo-tray/frame-*.png | wc -l` +Expected: 12 (or however many the shot-list produced; β‰₯8 minimum). + +No commit (scratch files). + +--- + +## Task 6: Capture web-UI video via Playwright + +**Files:** +- Create: `scripts/demo/playwright.config.ts` +- Create: `scripts/demo/capture-webui.spec.ts` +- Create (output, NOT committed): `/tmp/demo-webui/*.webm` + +- [ ] **Step 1: Write the Playwright config** + +Create `scripts/demo/playwright.config.ts`: + +```ts +import { defineConfig } from '@playwright/test'; +export default defineConfig({ + testDir: '.', + timeout: 60000, + fullyParallel: false, + workers: 1, + retries: 0, + use: { + headless: true, + viewport: { width: 1280, height: 800 }, + baseURL: 'http://127.0.0.1:18082', + launchOptions: { + executablePath: '/Users/user/Library/Caches/ms-playwright/chromium-1217/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing', + }, + recordVideo: { dir: '/tmp/demo-webui', size: { width: 1280, height: 800 } }, + }, +}); +``` + +- [ ] **Step 2: Write the capture spec** + +Create `scripts/demo/capture-webui.spec.ts`. It assumes a throwaway mcpproxy is +already running on :18082 with demo servers + at least one flagged activity (Step 3 +boots it). The spec records two videos: server cards, then activity log. + +```ts +import { test, expect } from '@playwright/test'; + +const KEY = 'uitest'; + +test('server cards walkthrough', async ({ page }) => { + await page.goto(`/ui/?apikey=${KEY}`); + await page.waitForLoadState('domcontentloaded'); + // Dashboard with server cards + await page.locator('[data-test="server-card"]').first().waitFor({ timeout: 15000 }); + await page.waitForTimeout(1500); // let cards settle (green health) + await page.locator('[data-test="server-card"]').first().hover(); + await page.waitForTimeout(1000); + await page.locator('[data-test="server-card"]').first().click(); // into server detail + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(2500); // dwell on detail +}); + +test('activity log walkthrough', async ({ page }) => { + await page.goto(`/ui/activity?apikey=${KEY}`); + await page.waitForLoadState('domcontentloaded'); + await page.locator('[data-test="activity-row"]').first().waitFor({ timeout: 15000 }); + await page.waitForTimeout(1500); + // expand the flagged (sensitive-data) row + const flagged = page.locator('[data-test="activity-row"]').filter({ hasText: /flag|sensitive|critical/i }).first(); + await (await flagged.count() ? flagged : page.locator('[data-test="activity-row"]').first()).click(); + await page.waitForTimeout(2500); // dwell on detail +}); +``` + +> NOTE on selectors: verify `data-test="server-card"`, `data-test="activity-row"` +> exist in `frontend/src/`. If a selector is missing, grep the relevant Vue view for +> the real `data-test` attribute and update the spec β€” do NOT invent one. If activity +> rows lack `data-test`, add it to the component in this task (follow the project's +> existing `data-test` convention) and rebuild the frontend with `make build`. + +- [ ] **Step 3: Boot a throwaway mcpproxy with demo data** + +Run (adapts the CLAUDE.md UI-test pattern; seeds 4 servers): +```bash +pkill -f 'mcpproxy serve.*18082' 2>/dev/null; sleep 1 +rm -rf /tmp/mcpproxy-demo/{config.db,index.bleve,logs} 2>/dev/null; mkdir -p /tmp/mcpproxy-demo +cat > /tmp/mcpproxy-demo/mcp_config.json <<'EOF' +{ "listen":"127.0.0.1:18082","data_dir":"/tmp/mcpproxy-demo","api_key":"uitest", + "enable_web_ui":true,"enable_socket":false,"telemetry":{"enabled":false}, + "mcpServers":[ + {"name":"github","command":"npx","args":["-y","@modelcontextprotocol/server-github"],"protocol":"stdio","enabled":true}, + {"name":"filesystem","command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/tmp"],"protocol":"stdio","enabled":true}, + {"name":"fetch","command":"uvx","args":["mcp-server-fetch"],"protocol":"stdio","enabled":true}, + {"name":"time","command":"uvx","args":["mcp-server-time"],"protocol":"stdio","enabled":true} + ] } +EOF +./mcpproxy serve --config=/tmp/mcpproxy-demo/mcp_config.json --listen=127.0.0.1:18082 --log-level=info > /tmp/mcpproxy-demo/server.log 2>&1 & +until curl -sf -H "X-API-Key: uitest" http://127.0.0.1:18082/api/v1/status >/dev/null; do sleep 1; done +echo "mcpproxy demo up on :18082" +``` + +To guarantee a flagged activity row exists, make one tool call carrying a fake +secret (detected by the sensitive-data scanner): +```bash +curl -s -H "X-API-Key: uitest" -X POST http://127.0.0.1:18082/api/v1/... # see note +``` +> NOTE: if no simple REST path triggers a tool call, instead use the CLI: +> `./mcpproxy call_tool_read ...` is not guaranteed; simplest reliable path is to let +> the activity log show normal `tool_call` rows and drop the `/flag/` filter in the +> spec (it already falls back to the first row). Flagged-row is best-effort. + +- [ ] **Step 4: Run the capture** + +Run: +```bash +cd scripts/demo +ln -sfn /Users/user/repos/mcpproxy-go/e2e/playwright/node_modules ./node_modules +./node_modules/.bin/playwright test --config=playwright.config.ts --reporter=list +``` +Expected: 2 passed. Two `.webm` files appear in `/tmp/demo-webui/`. + +- [ ] **Step 5: Verify videos** + +Run: `ls -la /tmp/demo-webui/*.webm && echo "count: $(ls /tmp/demo-webui/*.webm | wc -l)"` +Expected: 2 webm files, each non-zero size. + +- [ ] **Step 6: Commit the specs (not the videos)** + +```bash +git add scripts/demo/playwright.config.ts scripts/demo/capture-webui.spec.ts +git commit -m "feat(051): playwright web-ui capture for demo (server cards + activity)" +``` + +--- + +## Task 7: Stitch the demo GIF (`build-demo.sh`) + +**Files:** +- Create: `scripts/demo/build-demo.sh` +- Create (output): `docs/demo.gif`, `docs/demo.webp` + +- [ ] **Step 1: Write the build script** + +Create `scripts/demo/build-demo.sh`: + +```bash +#!/usr/bin/env bash +# Stitch tray montage (stills) + 2 web-UI webm segments -> docs/demo.gif + .webp +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TRAY=/tmp/demo-tray +WEB=/tmp/demo-webui +WORK=$(mktemp -d) +W=900; FPS=15 + +[ -d "$TRAY" ] || { echo "missing $TRAY (run Task 5)"; exit 1; } +ls "$WEB"/*.webm >/dev/null 2>&1 || { echo "missing $WEB/*.webm (run Task 6)"; exit 1; } + +# 1) tray montage: each still held 0.42s, gentle crossfade via framerate ramp +ffmpeg -y -framerate 1/0.42 -pattern_type glob -i "$TRAY/frame-*.png" \ + -vf "scale=${W}:-2:flags=lanczos,fps=${FPS}" -pix_fmt yuv420p "$WORK/seg0.mp4" + +# 2) normalize the two web-UI segments to same width/fps (ordered: cards, then activity) +i=1 +for v in $(ls "$WEB"/*.webm | sort); do + ffmpeg -y -i "$v" -vf "scale=${W}:-2:flags=lanczos,fps=${FPS}" -pix_fmt yuv420p "$WORK/seg${i}.mp4" + i=$((i+1)) +done + +# 3) concat +: > "$WORK/list.txt" +for f in "$WORK"/seg0.mp4 "$WORK"/seg1.mp4 "$WORK"/seg2.mp4; do echo "file '$f'" >> "$WORK/list.txt"; done +ffmpeg -y -f concat -safe 0 -i "$WORK/list.txt" -c copy "$WORK/full.mp4" + +# 4) palette-optimized GIF +ffmpeg -y -i "$WORK/full.mp4" -vf "fps=${FPS},scale=${W}:-2:flags=lanczos,palettegen=stats_mode=diff" "$WORK/pal.png" +ffmpeg -y -i "$WORK/full.mp4" -i "$WORK/pal.png" \ + -lavfi "fps=${FPS},scale=${W}:-2:flags=lanczos,paletteuse=dither=bayer:bayer_scale=3" "$ROOT/docs/demo.gif" + +# 5) webp (smaller, also autoplays in README) +ffmpeg -y -i "$WORK/full.mp4" -vcodec libwebp -filter:v "fps=${FPS},scale=${W}:-2" \ + -lossless 0 -compression_level 6 -q:v 55 -loop 0 -an "$ROOT/docs/demo.webp" + +echo "Wrote docs/demo.gif ($(du -h "$ROOT/docs/demo.gif" | cut -f1)) and docs/demo.webp ($(du -h "$ROOT/docs/demo.webp" | cut -f1))" +rm -rf "$WORK" +``` + +- [ ] **Step 2: Run it** + +Run: `chmod +x scripts/demo/build-demo.sh && scripts/demo/build-demo.sh` +Expected: "Wrote docs/demo.gif (...) and docs/demo.webp (...)". + +- [ ] **Step 3: Verify size + dimensions budget** + +Run: +```bash +GIFKB=$(du -k docs/demo.gif | cut -f1); echo "gif KB: $GIFKB" +sips -g pixelWidth docs/demo.gif +test "$GIFKB" -lt 8192 && echo "OK under 8MB" || echo "TOO BIG β€” lower W to 760 or FPS to 12 in build-demo.sh and re-run" +``` +Expected: width 900, "OK under 8MB". If too big, reduce `W` or `FPS` and re-run Step 2. + +- [ ] **Step 4: Eyeball the GIF** + +Run: `open docs/demo.gif` +Expected: tray menu beat β†’ server cards beat β†’ activity log beat, ~16s, loops. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/demo/build-demo.sh docs/demo.gif docs/demo.webp +git commit -m "feat(051): build stitched demo gif (tray + web-ui)" +``` + +--- + +## Task 8: Rewrite the README hero + +**Files:** +- Modify: `README.md:1-25` + +- [ ] **Step 1: Read the current top of README** + +Run: `sed -n '1,26p' README.md` +Confirm lines 1–25 match the block being replaced (H1, doc link, old video comment, +YouTube thumbnail, the two `mcpproxy.app/images/menu_*.png` `
` block). + +- [ ] **Step 2: Replace lines 1–25 with the new hero** + +Replace from `# MCPProxy – Smart Proxy for AI Agents` through the closing `
` of +the tray-thumbnail block (the `` caption line) with: + +```markdown +# πŸ›‘οΈ MCPProxy β€” Supercharge AI Agents, Safely + +> One safe endpoint in front of every MCP server. + +[![Release](https://img.shields.io/github/v/release/smart-mcp-proxy/mcpproxy-go?sort=semver)](https://github.com/smart-mcp-proxy/mcpproxy-go/releases) +[![Build](https://github.com/smart-mcp-proxy/mcpproxy-go/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/smart-mcp-proxy/mcpproxy-go/actions/workflows/unit-tests.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/smart-mcp-proxy/mcpproxy-go)](https://goreportcard.com/report/github.com/smart-mcp-proxy/mcpproxy-go) +[![Go Reference](https://pkg.go.dev/badge/github.com/smart-mcp-proxy/mcpproxy-go.svg)](https://pkg.go.dev/github.com/smart-mcp-proxy/mcpproxy-go) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![GitHub stars](https://img.shields.io/github/stars/smart-mcp-proxy/mcpproxy-go?style=social)](https://github.com/smart-mcp-proxy/mcpproxy-go/stargazers) + +
+ MCPProxy β€” Supercharge AI Agents, Safely + + +

+ MCPProxy demo: tray menu, server cards, and activity log +

+ +

+ πŸ“Ί Watch the full walkthrough  Β·  + πŸ“š Read the docs  Β·  + 🌐 mcpproxy.app +

+``` + +- [ ] **Step 3: Verify nothing below "Why MCPProxy?" changed** + +Run: `grep -n '## Why MCPProxy?' README.md` +Expected: exactly one match; content after it unchanged. Also: +Run: `grep -c 'mcpproxy.app/images/menu_' README.md` +Expected: `0` (old thumbnails removed). + +- [ ] **Step 4: Verify asset links resolve locally** + +Run: `ls docs/social.png docs/demo.gif` +Expected: both exist (relative paths in README will resolve on github.com). + +- [ ] **Step 5: Commit** + +```bash +git add README.md +git commit -m "docs(051): new README hero β€” banner + demo gif" +``` + +--- + +## Task 9: Final verification + handoff + +**Files:** none (verification only) + +- [ ] **Step 1: Confirm all committed assets present** + +Run: `git ls-files docs/social.html docs/social.png docs/demo.gif docs/demo.webp scripts/demo/` +Expected: all listed, including the 5 `scripts/demo/` files. + +- [ ] **Step 2: Render README locally to sanity-check markdown** + +Run: `grep -n 'docs/social.png\|docs/demo.gif' README.md` +Expected: banner `` and `` present. + +- [ ] **Step 3: Push branch + open PR** + +```bash +git push -u origin feat/051-readme-hero-demo +gh pr create --base main --title "docs(051): README hero banner + demo GIF" \ + --body "Implements specs/051-readme-hero-demo. Adds frosted-tiles hero banner (docs/social.html -> docs/social.png), one cohesive demo GIF (tray + web UI, docs/demo.gif), and the scripted scripts/demo/ pipeline. README hero rewritten. Follow-up: upload docs/social.png to Settings -> Social preview." +``` + +- [ ] **Step 4: Remind user of the one manual step** + +Print: "Upload `docs/social.png` to GitHub β†’ Settings β†’ Social preview to control +HN/Slack/X unfurls. This is the only step that cannot be scripted." + +--- + +## Self-review notes (author) + +- **Spec coverage:** banner (T2–3) βœ“, demo GIF tray+webUI (T4–7) βœ“, README hero with + remove/demote/keep rules (T8) βœ“, scripted pipeline (T1,3,4,6,7 + scripts/demo/README) βœ“, + social-preview reminder (T9) βœ“, keep existing screenshots (untouched by T8) βœ“. +- **Known soft spots flagged inline, not hidden:** (a) `data-test` selectors in the + Playwright spec must be verified against `frontend/src/` β€” instruction says grep, do + not invent; (b) the flagged-activity row is best-effort with a first-row fallback; + (c) retina scale factor may be ignored by headless Chromium β€” acceptable. +- **Non-TDD task types:** asset generation uses verification steps (dimensions, file + size, visual eyeball) in place of unit tests, which is the correct analog here.