| /** | ||
| * Emil Kowalski's animation standards, distilled into a compact prompt block the | ||
| * vision models can grade against. Keeps the exact values in sync with the | ||
| * deterministic linter (src/tuner/standards.ts) — cite, don't approximate. | ||
| */ | ||
| export declare const ANIMATION_STANDARDS_PROMPT = "## Animation standards (grade motion against these)\n\nDistilled from Emil Kowalski's design-engineering philosophy. Treat a violation as a finding and cite the specific rule.\n\n**Easing**\n- Entering / exiting \u2192 ease-out (starts fast, feels responsive). Moving/morphing on screen \u2192 ease-in-out. Hover/colour \u2192 ease. Constant motion \u2192 linear.\n- `ease-in` on UI is always a finding \u2014 it starts slow and delays the exact moment the user is watching.\n- Built-in CSS easings are weak; strong curves read as intentional (e.g. ease-out `cubic-bezier(0.23, 1, 0.32, 1)`, ease-in-out `cubic-bezier(0.77, 0, 0.175, 1)`).\n\n**Duration** \u2014 UI animations stay under 300ms.\n- Button/press feedback 100\u2013160ms \u00B7 tooltips 125\u2013200ms \u00B7 dropdowns 150\u2013250ms \u00B7 modals/drawers 200\u2013500ms.\n- A 180ms transition feels snappier than a 400ms one. Exits should run ~20% faster than their entrance.\n\n**Physicality**\n- Never scale from 0 \u2014 nothing in the real world appears from nothing. Enter from scale(0.9\u20130.97) + opacity:0.\n- Popovers/dropdowns/tooltips scale from their trigger, not centre (modals are exempt \u2014 they're centred).\n- Pressable elements get subtle press feedback: transform: scale(0.97) on :active.\n\n**Performance & a11y**\n- Animate transform and opacity only; animating width/height/margin/top/left (or `transition: all`) stutters off the GPU.\n- Movement should have a `prefers-reduced-motion` alternative (keep opacity/colour, drop large position changes) \u2014 not zero animation.\n\n**Purpose** \u2014 every animation needs a reason (spatial continuity, state, feedback, explanation, preventing a jarring change). \"It looks cool\" on a frequently-seen element is not a reason; keyboard-initiated actions should not animate."; |
| /** | ||
| * Emil Kowalski's animation standards, distilled into a compact prompt block the | ||
| * vision models can grade against. Keeps the exact values in sync with the | ||
| * deterministic linter (src/tuner/standards.ts) — cite, don't approximate. | ||
| */ | ||
| export const ANIMATION_STANDARDS_PROMPT = `## Animation standards (grade motion against these) | ||
| Distilled from Emil Kowalski's design-engineering philosophy. Treat a violation as a finding and cite the specific rule. | ||
| **Easing** | ||
| - Entering / exiting → ease-out (starts fast, feels responsive). Moving/morphing on screen → ease-in-out. Hover/colour → ease. Constant motion → linear. | ||
| - \`ease-in\` on UI is always a finding — it starts slow and delays the exact moment the user is watching. | ||
| - Built-in CSS easings are weak; strong curves read as intentional (e.g. ease-out \`cubic-bezier(0.23, 1, 0.32, 1)\`, ease-in-out \`cubic-bezier(0.77, 0, 0.175, 1)\`). | ||
| **Duration** — UI animations stay under 300ms. | ||
| - Button/press feedback 100–160ms · tooltips 125–200ms · dropdowns 150–250ms · modals/drawers 200–500ms. | ||
| - A 180ms transition feels snappier than a 400ms one. Exits should run ~20% faster than their entrance. | ||
| **Physicality** | ||
| - Never scale from 0 — nothing in the real world appears from nothing. Enter from scale(0.9–0.97) + opacity:0. | ||
| - Popovers/dropdowns/tooltips scale from their trigger, not centre (modals are exempt — they're centred). | ||
| - Pressable elements get subtle press feedback: transform: scale(0.97) on :active. | ||
| **Performance & a11y** | ||
| - Animate transform and opacity only; animating width/height/margin/top/left (or \`transition: all\`) stutters off the GPU. | ||
| - Movement should have a \`prefers-reduced-motion\` alternative (keep opacity/colour, drop large position changes) — not zero animation. | ||
| **Purpose** — every animation needs a reason (spatial continuity, state, feedback, explanation, preventing a jarring change). "It looks cool" on a frequently-seen element is not a reason; keyboard-initiated actions should not animate.`; |
| /** | ||
| * Resolves model-cited element refs ("E3") against the capture's DOM snapshot. | ||
| * A ref that matches gets its pixel rect attached (drawn on annotated | ||
| * screenshots); a ref the snapshot doesn't know is dropped — the model may | ||
| * only cite elements it was actually shown. | ||
| */ | ||
| import type { AnalysisResult } from "../types.js"; | ||
| import type { DomSnapshot } from "../capture/dom.js"; | ||
| export declare function resolveElementRefs(analysis: AnalysisResult, dom: DomSnapshot | undefined): AnalysisResult; |
| export function resolveElementRefs(analysis, dom) { | ||
| const byRef = new Map((dom?.elements ?? []).map((e) => [e.ref, e.rect])); | ||
| return { | ||
| ...analysis, | ||
| issues: analysis.issues.map((issue) => { | ||
| if (!issue.element_ref) | ||
| return issue; | ||
| const rect = byRef.get(issue.element_ref); | ||
| if (!rect) { | ||
| const { element_ref: _dropped, ...rest } = issue; | ||
| return rest; | ||
| } | ||
| return { ...issue, element_rect: rect }; | ||
| }), | ||
| }; | ||
| } |
| export interface SitemapParse { | ||
| /** Same-origin page pathnames, e.g. "/pricing". */ | ||
| pages: string[]; | ||
| /** Child sitemap URLs (from a sitemap index). */ | ||
| sitemaps: string[]; | ||
| } | ||
| /** Pull page paths and child-sitemap URLs out of a sitemap / sitemap-index document. */ | ||
| export declare function parseSitemapXml(xml: string, origin: string): SitemapParse; | ||
| export interface SitemapDiscoveryOptions { | ||
| /** Max child sitemaps to follow from a sitemap index. */ | ||
| maxChildSitemaps?: number; | ||
| /** Per-request timeout in ms. */ | ||
| timeoutMs?: number; | ||
| /** Injection point for tests. */ | ||
| fetchImpl?: typeof fetch; | ||
| } | ||
| /** Fetch <origin>/sitemap.xml and return same-origin page paths. Best-effort: failures → []. */ | ||
| export declare function discoverRoutesFromSitemap(baseUrl: string, opts?: SitemapDiscoveryOptions): Promise<string[]>; | ||
| /** Scan a Next.js app directory (`app/` or `src/app/`) under cwd for static routes. */ | ||
| export declare function discoverNextAppRoutes(cwd: string): Promise<string[]>; | ||
| export interface DiscoverRoutesOptions { | ||
| url: string; | ||
| cwd?: string; | ||
| limit?: number; | ||
| fetchImpl?: typeof fetch; | ||
| } | ||
| /** Merge sitemap + Next.js app-dir discovery: deduped, "/" first, capped. */ | ||
| export declare function discoverRoutes(opts: DiscoverRoutesOptions): Promise<string[]>; |
| /** | ||
| * Route auto-discovery for `review --discover-routes`. | ||
| * | ||
| * Two sources, merged and deduped: | ||
| * - the site's /sitemap.xml (including one level of sitemap-index children), | ||
| * - a Next.js app directory (`app/` or `src/app/`) in the working directory. | ||
| * | ||
| * Discovery is best-effort: a missing sitemap or absent app directory simply | ||
| * contributes nothing. Dynamic segments (`[slug]`), parallel routes (`@slot`) | ||
| * and private folders (`_lib`) are skipped — we can't guess their params. | ||
| */ | ||
| import { readdir } from "node:fs/promises"; | ||
| import { join } from "node:path"; | ||
| const LOC_RE = /<loc>\s*([^<]+?)\s*<\/loc>/gi; | ||
| function pathnameIfSameOrigin(loc, origin) { | ||
| try { | ||
| const u = new URL(loc); | ||
| if (u.origin !== origin) | ||
| return null; | ||
| return u.pathname || "/"; | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| } | ||
| /** Pull page paths and child-sitemap URLs out of a sitemap / sitemap-index document. */ | ||
| export function parseSitemapXml(xml, origin) { | ||
| const pages = []; | ||
| const sitemaps = []; | ||
| const isIndex = /<sitemapindex[\s>]/i.test(xml); | ||
| for (const match of xml.matchAll(LOC_RE)) { | ||
| const loc = match[1]; | ||
| if (isIndex) { | ||
| sitemaps.push(loc); | ||
| } | ||
| else { | ||
| const path = pathnameIfSameOrigin(loc, origin); | ||
| if (path) | ||
| pages.push(path); | ||
| } | ||
| } | ||
| return { pages, sitemaps }; | ||
| } | ||
| async function fetchText(url, timeoutMs, fetchImpl) { | ||
| try { | ||
| const res = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) }); | ||
| if (!res.ok) | ||
| return null; | ||
| return await res.text(); | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| } | ||
| /** Fetch <origin>/sitemap.xml and return same-origin page paths. Best-effort: failures → []. */ | ||
| export async function discoverRoutesFromSitemap(baseUrl, opts = {}) { | ||
| const { maxChildSitemaps = 5, timeoutMs = 5000, fetchImpl = fetch } = opts; | ||
| let origin; | ||
| try { | ||
| origin = new URL(baseUrl).origin; | ||
| } | ||
| catch { | ||
| return []; | ||
| } | ||
| const xml = await fetchText(`${origin}/sitemap.xml`, timeoutMs, fetchImpl); | ||
| if (!xml) | ||
| return []; | ||
| const parsed = parseSitemapXml(xml, origin); | ||
| const pages = [...parsed.pages]; | ||
| for (const child of parsed.sitemaps.slice(0, maxChildSitemaps)) { | ||
| const childXml = await fetchText(child, timeoutMs, fetchImpl); | ||
| if (!childXml) | ||
| continue; | ||
| pages.push(...parseSitemapXml(childXml, origin).pages); | ||
| } | ||
| return pages; | ||
| } | ||
| const PAGE_FILE_RE = /^page\.(tsx|ts|jsx|js|mdx)$/; | ||
| function isRouteGroup(segment) { | ||
| return segment.startsWith("(") && segment.endsWith(")"); | ||
| } | ||
| function isSkippedSegment(segment) { | ||
| return segment.startsWith("[") || segment.startsWith("@") || segment.startsWith("_"); | ||
| } | ||
| async function walkAppDir(dir, segments, out) { | ||
| let entries; | ||
| try { | ||
| entries = await readdir(dir, { withFileTypes: true }); | ||
| } | ||
| catch { | ||
| return; | ||
| } | ||
| if (entries.some((e) => e.isFile() && PAGE_FILE_RE.test(e.name))) { | ||
| const path = segments.filter((s) => !isRouteGroup(s)).join("/"); | ||
| out.push(`/${path}`.replace(/\/+/g, "/")); | ||
| } | ||
| for (const entry of entries) { | ||
| if (!entry.isDirectory()) | ||
| continue; | ||
| if (isSkippedSegment(entry.name)) | ||
| continue; | ||
| await walkAppDir(join(dir, entry.name), [...segments, entry.name], out); | ||
| } | ||
| } | ||
| /** Scan a Next.js app directory (`app/` or `src/app/`) under cwd for static routes. */ | ||
| export async function discoverNextAppRoutes(cwd) { | ||
| for (const candidate of [join(cwd, "app"), join(cwd, "src", "app")]) { | ||
| const out = []; | ||
| await walkAppDir(candidate, [], out); | ||
| if (out.length > 0) | ||
| return out; | ||
| } | ||
| return []; | ||
| } | ||
| /** Merge sitemap + Next.js app-dir discovery: deduped, "/" first, capped. */ | ||
| export async function discoverRoutes(opts) { | ||
| const limit = opts.limit ?? 20; | ||
| const [sitemap, nextApp] = await Promise.all([ | ||
| discoverRoutesFromSitemap(opts.url, { fetchImpl: opts.fetchImpl }), | ||
| discoverNextAppRoutes(opts.cwd ?? process.cwd()), | ||
| ]); | ||
| const unique = [...new Set([...sitemap, ...nextApp])]; | ||
| unique.sort((a, b) => (a === "/" ? -1 : b === "/" ? 1 : a.localeCompare(b))); | ||
| return unique.slice(0, limit); | ||
| } |
| import type { AuthConfig, Viewport } from "../types.js"; | ||
| export declare const GRID_STATES: readonly ["default", "hover", "focus", "active"]; | ||
| export type GridState = (typeof GRID_STATES)[number]; | ||
| export interface StateGridOptions { | ||
| url: string; | ||
| viewport: Viewport; | ||
| waitFor?: string; | ||
| waitTimeout?: number; | ||
| auth?: AuthConfig; | ||
| /** Max interactive elements sampled (rows). */ | ||
| maxElements?: number; | ||
| } | ||
| export interface StateGridResult { | ||
| /** Composed PNG. */ | ||
| buffer: Buffer; | ||
| width: number; | ||
| height: number; | ||
| /** Row labels in order. */ | ||
| elements: string[]; | ||
| states: readonly GridState[]; | ||
| } | ||
| /** | ||
| * Capture the grid. Returns null when the page exposes no usable interactive | ||
| * elements — callers treat the grid as an enhancement, not a requirement. | ||
| */ | ||
| export declare function captureStateGrid(opts: StateGridOptions): Promise<StateGridResult | null>; |
| /** | ||
| * Interaction-state grids: capture each interactive element in its default / | ||
| * hover / focus / active states and compose one labeled grid image. The model | ||
| * receives a single artifact where affordance problems — invisible focus | ||
| * rings, missing hover feedback, no pressed state — are directly comparable | ||
| * across columns. | ||
| */ | ||
| import sharp from "sharp"; | ||
| import { applyPageAuth, launchBrowserSession } from "./browser.js"; | ||
| export const GRID_STATES = ["default", "hover", "focus", "active"]; | ||
| const CELL_W = 260; | ||
| const CELL_H = 84; | ||
| const GAP = 12; | ||
| const HEADER_H = 28; | ||
| const ROW_LABEL_H = 24; | ||
| const PAD = 14; | ||
| const BG = "#0b0d12"; | ||
| function svgBar(width, height, text, size = 13) { | ||
| const safe = text.replace(/&/g, "&").replace(/</g, "<"); | ||
| return Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"> | ||
| <text x="8" y="${height - 8}" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" | ||
| font-size="${size}" fill="#e7eaf0">${safe}</text> | ||
| </svg>`); | ||
| } | ||
| async function settle(ms) { | ||
| await new Promise((r) => setTimeout(r, ms)); | ||
| } | ||
| function unionClip(a, b, viewport) { | ||
| const x1 = Math.max(0, Math.min(a.x, b?.x ?? a.x)); | ||
| const y1 = Math.max(0, Math.min(a.y, b?.y ?? a.y)); | ||
| const x2 = Math.min(viewport.width, Math.max(a.x + a.width, b ? b.x + b.width : 0)); | ||
| const y2 = Math.min(viewport.height, Math.max(a.y + a.height, b ? b.y + b.height : 0)); | ||
| return { x: x1, y: y1, width: Math.max(1, x2 - x1), height: Math.max(1, y2 - y1) }; | ||
| } | ||
| /** Capture one element's clip in a given interaction state. */ | ||
| async function captureState(locator, state, baseClip, viewport) { | ||
| const page = locator.page(); | ||
| switch (state) { | ||
| case "default": | ||
| break; | ||
| case "hover": | ||
| await locator.hover({ timeout: 2000 }); | ||
| break; | ||
| case "focus": | ||
| await locator.focus({ timeout: 2000 }); | ||
| break; | ||
| case "active": { | ||
| await locator.hover({ timeout: 2000 }); | ||
| await page.mouse.down(); | ||
| break; | ||
| } | ||
| } | ||
| await settle(320); // let ≤300ms transitions finish | ||
| try { | ||
| // Elements can move/grow on hover/focus (scale, lift, focus ring outside | ||
| // the box) — re-measure and widen the clip so the feedback stays in frame. | ||
| const now = await locator.boundingBox().catch(() => null); | ||
| const padded = now | ||
| ? { x: now.x - PAD, y: now.y - PAD, width: now.width + PAD * 2, height: now.height + PAD * 2 } | ||
| : null; | ||
| const clip = unionClip(baseClip, padded, viewport); | ||
| return await page.screenshot({ clip }); | ||
| } | ||
| finally { | ||
| if (state === "active") { | ||
| // Release AWAY from the element: mouseup at the original position would | ||
| // complete a real click — navigating links, submitting forms. | ||
| await page.mouse.move(1, 1); | ||
| await page.mouse.up(); | ||
| } | ||
| else { | ||
| await page.mouse.move(1, 1); | ||
| } | ||
| await locator.evaluate((el) => el.blur?.()).catch(() => { }); | ||
| await settle(320); | ||
| } | ||
| } | ||
| /** | ||
| * Capture the grid. Returns null when the page exposes no usable interactive | ||
| * elements — callers treat the grid as an enhancement, not a requirement. | ||
| */ | ||
| export async function captureStateGrid(opts) { | ||
| const maxElements = opts.maxElements ?? 6; | ||
| const session = await launchBrowserSession({ viewport: opts.viewport, auth: opts.auth }); | ||
| const page = await session.context.newPage(); | ||
| try { | ||
| await applyPageAuth(page, opts.url, opts.auth); | ||
| await page.goto(opts.url, { | ||
| waitUntil: opts.waitFor === "networkidle" ? "networkidle" : "load", | ||
| timeout: opts.waitTimeout ?? 15_000, | ||
| }); | ||
| const startUrl = page.url(); | ||
| const candidates = await page | ||
| .locator("button, a[role=button], a.btn, input[type=submit], [role=button], a") | ||
| .all(); | ||
| const rows = []; | ||
| const seenLabels = new Set(); | ||
| for (const locator of candidates) { | ||
| if (rows.length >= maxElements) | ||
| break; | ||
| // If anything navigated the page, every remaining locator resolves | ||
| // against the wrong document — stop with what we have. | ||
| if (page.url() !== startUrl) | ||
| break; | ||
| const box = await locator.boundingBox().catch(() => null); | ||
| if (!box || box.width < 24 || box.height < 14) | ||
| continue; | ||
| // Only elements fully inside the viewport — clips can't leave it. | ||
| if (box.x < PAD || box.y < PAD) | ||
| continue; | ||
| if (box.x + box.width > opts.viewport.width - PAD) | ||
| continue; | ||
| if (box.y + box.height > opts.viewport.height - PAD) | ||
| continue; | ||
| // Same design, same states: dedupe repeated labels. Unlabeled (icon-only) | ||
| // controls are distinct designs — key those by position instead. | ||
| const text = ((await locator.innerText().catch(() => "")) || "").trim().slice(0, 40); | ||
| const label = text || "(unlabeled)"; | ||
| const dedupeKey = text || `(unlabeled)@${Math.round(box.x)},${Math.round(box.y)}`; | ||
| if (seenLabels.has(dedupeKey)) | ||
| continue; | ||
| seenLabels.add(dedupeKey); | ||
| const clip = { | ||
| x: box.x - PAD, | ||
| y: box.y - PAD, | ||
| width: box.width + PAD * 2, | ||
| height: box.height + PAD * 2, | ||
| }; | ||
| const cells = []; | ||
| let failed = false; | ||
| for (const state of GRID_STATES) { | ||
| try { | ||
| cells.push(await captureState(locator, state, clip, opts.viewport)); | ||
| } | ||
| catch { | ||
| failed = true; | ||
| break; | ||
| } | ||
| } | ||
| if (!failed && page.url() === startUrl) | ||
| rows.push({ label, cells }); | ||
| } | ||
| if (rows.length === 0) | ||
| return null; | ||
| // Compose: uniform cells so state columns align across rows. | ||
| const width = GAP + GRID_STATES.length * (CELL_W + GAP); | ||
| let height = HEADER_H + GAP; | ||
| const composites = []; | ||
| GRID_STATES.forEach((state, col) => { | ||
| composites.push({ input: svgBar(CELL_W, HEADER_H, state.toUpperCase(), 14), top: 0, left: GAP + col * (CELL_W + GAP) }); | ||
| }); | ||
| for (const row of rows) { | ||
| composites.push({ input: svgBar(width - GAP * 2, ROW_LABEL_H, row.label), top: height, left: GAP }); | ||
| height += ROW_LABEL_H; | ||
| for (let col = 0; col < row.cells.length; col++) { | ||
| const cell = await sharp(row.cells[col]) | ||
| .resize({ width: CELL_W, height: CELL_H, fit: "contain", background: BG }) | ||
| .png() | ||
| .toBuffer(); | ||
| composites.push({ input: cell, top: height, left: GAP + col * (CELL_W + GAP) }); | ||
| } | ||
| height += CELL_H + GAP; | ||
| } | ||
| const buffer = await sharp({ | ||
| create: { width, height, channels: 3, background: BG }, | ||
| }) | ||
| .composite(composites) | ||
| .png() | ||
| .toBuffer(); | ||
| return { buffer, width, height, elements: rows.map((r) => r.label), states: GRID_STATES }; | ||
| } | ||
| finally { | ||
| await page.close().catch(() => { }); | ||
| await session.close(); | ||
| } | ||
| } |
| import type { NextAction } from "./types.js"; | ||
| export declare const ADDENDA_HEADER = "<!-- auto-generated by `motionlint eval --evolve` \u2014 safe to edit or delete -->"; | ||
| export declare function actionLine(action: NextAction): string; | ||
| /** | ||
| * Merge new actions into existing addenda lines: newest first, deduped by | ||
| * level/fixture/category key, capped so the prompt cost stays bounded. | ||
| */ | ||
| export declare function buildAddenda(actions: NextAction[], existing?: string[]): string[]; | ||
| export declare function renderAddendaFile(lines: string[]): string; | ||
| /** Existing addenda bullet lines (for merging); [] when the file is absent. */ | ||
| export declare function loadAddendaLines(path: string): Promise<string[]>; | ||
| /** Full addenda text for prompt inclusion; null when absent or empty. */ | ||
| export declare function loadAddendaForPrompt(path: string): Promise<string | null>; | ||
| export declare function saveAddenda(path: string, lines: string[]): Promise<void>; |
| /** | ||
| * Closed-loop prompt evolution: eval `next_actions` (expected issues the model | ||
| * missed, controls it violated) become compact "learned heuristics" lines that | ||
| * the review prompt carries on later runs. The file is a plain markdown | ||
| * artifact the user can read, edit, or delete — the loop has a visible knob. | ||
| */ | ||
| import { mkdir, readFile, writeFile } from "node:fs/promises"; | ||
| import { dirname } from "node:path"; | ||
| export const ADDENDA_HEADER = "<!-- auto-generated by `motionlint eval --evolve` — safe to edit or delete -->"; | ||
| const MAX_LINES = 12; | ||
| function actionKey(line) { | ||
| // Dedupe on the exact line: descriptions are deterministic (authored in | ||
| // truth.json), so re-runs of the same miss collapse while two distinct | ||
| // misses on the same fixture+category never collide. | ||
| return line.trim(); | ||
| } | ||
| export function actionLine(action) { | ||
| const signal = action.expected_signal ? ` — look for: ${action.expected_signal}` : ""; | ||
| return `- Watch for ${action.category} (${action.severity}): ${action.description}${signal} [${action.level}/${action.fixture} · ${action.category}]`; | ||
| } | ||
| /** | ||
| * Merge new actions into existing addenda lines: newest first, deduped by | ||
| * level/fixture/category key, capped so the prompt cost stays bounded. | ||
| */ | ||
| export function buildAddenda(actions, existing = []) { | ||
| const lines = [...actions.map(actionLine), ...existing]; | ||
| const seen = new Set(); | ||
| const out = []; | ||
| for (const line of lines) { | ||
| const key = actionKey(line); | ||
| if (seen.has(key)) | ||
| continue; | ||
| seen.add(key); | ||
| out.push(line); | ||
| if (out.length >= MAX_LINES) | ||
| break; | ||
| } | ||
| return out; | ||
| } | ||
| export function renderAddendaFile(lines) { | ||
| return `${ADDENDA_HEADER}\n\n# Learned review heuristics\n\nDistilled from eval misses — included in review prompts until removed.\n\n${lines.join("\n")}\n`; | ||
| } | ||
| /** Existing addenda bullet lines (for merging); [] when the file is absent. */ | ||
| export async function loadAddendaLines(path) { | ||
| try { | ||
| const text = await readFile(path, "utf8"); | ||
| return text.split("\n").filter((l) => l.startsWith("- ")); | ||
| } | ||
| catch { | ||
| return []; | ||
| } | ||
| } | ||
| /** Full addenda text for prompt inclusion; null when absent or empty. */ | ||
| export async function loadAddendaForPrompt(path) { | ||
| try { | ||
| const text = (await readFile(path, "utf8")).trim(); | ||
| const bullets = text.split("\n").filter((l) => l.startsWith("- ")); | ||
| return bullets.length > 0 ? bullets.join("\n") : null; | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| } | ||
| export async function saveAddenda(path, lines) { | ||
| await mkdir(dirname(path), { recursive: true }); | ||
| await writeFile(path, renderAddendaFile(lines), "utf8"); | ||
| } |
| import type { EvalReport } from "./types.js"; | ||
| export interface EvalRunRecord { | ||
| timestamp: string; | ||
| provider: string; | ||
| model: string; | ||
| levels: Record<string, { | ||
| recall: number; | ||
| control_violations: number; | ||
| passing: boolean; | ||
| }>; | ||
| aggregate_recall: number; | ||
| overall_passing: boolean; | ||
| next_actions: number; | ||
| } | ||
| export interface EvalHistory { | ||
| version: 1; | ||
| runs: EvalRunRecord[]; | ||
| } | ||
| export declare function emptyHistory(): EvalHistory; | ||
| export declare function loadHistory(path: string): Promise<EvalHistory>; | ||
| export declare function saveHistory(path: string, history: EvalHistory): Promise<void>; | ||
| export declare function recordFromReport(report: EvalReport): EvalRunRecord; | ||
| /** Append immutably, keeping the newest MAX_RUNS records. */ | ||
| export declare function appendRun(history: EvalHistory, record: EvalRunRecord): EvalHistory; | ||
| /** Most recent prior run of the same provider+model, if any. */ | ||
| export declare function previousRun(history: EvalHistory, record: EvalRunRecord): EvalRunRecord | null; | ||
| /** | ||
| * Regression messages comparing the current run against the previous run of | ||
| * the same provider+model. Empty when there is no baseline or no regressions. | ||
| */ | ||
| export declare function detectRegressions(history: EvalHistory, record: EvalRunRecord): string[]; |
| /** | ||
| * Provider scorecard history: every eval run appends a compact record keyed by | ||
| * provider+model, so regressions across releases (recall drops, newly failing | ||
| * levels) are caught by comparing against the previous run of the same | ||
| * configuration — not by memory. | ||
| */ | ||
| import { mkdir, readFile, writeFile } from "node:fs/promises"; | ||
| import { dirname } from "node:path"; | ||
| const MAX_RUNS = 100; | ||
| export function emptyHistory() { | ||
| return { version: 1, runs: [] }; | ||
| } | ||
| export async function loadHistory(path) { | ||
| let text; | ||
| try { | ||
| text = await readFile(path, "utf8"); | ||
| } | ||
| catch (err) { | ||
| if (err.code === "ENOENT") | ||
| return emptyHistory(); | ||
| throw err; | ||
| } | ||
| // Corruption is loud, never silently reset — a reset would overwrite the | ||
| // audit trail on the next save (same convention as the memory store). | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(text); | ||
| } | ||
| catch { | ||
| throw new Error(`Eval history ${path} is corrupt (invalid JSON). Delete it to start fresh.`); | ||
| } | ||
| if (parsed?.version !== 1 || !Array.isArray(parsed.runs)) { | ||
| throw new Error(`Eval history ${path} has an unexpected shape. Delete it to start fresh.`); | ||
| } | ||
| return parsed; | ||
| } | ||
| export async function saveHistory(path, history) { | ||
| await mkdir(dirname(path), { recursive: true }); | ||
| await writeFile(path, JSON.stringify(history, null, 2), "utf8"); | ||
| } | ||
| export function recordFromReport(report) { | ||
| const levels = {}; | ||
| let expected = 0; | ||
| let detected = 0; | ||
| for (const level of report.levels) { | ||
| levels[level.level] = { | ||
| recall: level.recall, | ||
| control_violations: level.control_violations, | ||
| passing: level.passing, | ||
| }; | ||
| expected += level.total_expected; | ||
| detected += level.total_detected; | ||
| } | ||
| return { | ||
| timestamp: report.generated_at, | ||
| provider: report.provider, | ||
| model: report.model, | ||
| levels, | ||
| // Same convention as per-level recall: a run with nothing expected | ||
| // (controls only) that produced no misses is perfect, not zero. | ||
| aggregate_recall: expected > 0 ? Math.round((detected / expected) * 1000) / 1000 : 1, | ||
| overall_passing: report.overall_passing, | ||
| next_actions: report.next_actions.length, | ||
| }; | ||
| } | ||
| /** Append immutably, keeping the newest MAX_RUNS records. */ | ||
| export function appendRun(history, record) { | ||
| return { version: 1, runs: [...history.runs, record].slice(-MAX_RUNS) }; | ||
| } | ||
| /** Most recent prior run of the same provider+model, if any. */ | ||
| export function previousRun(history, record) { | ||
| for (let i = history.runs.length - 1; i >= 0; i--) { | ||
| const run = history.runs[i]; | ||
| if (run.provider === record.provider && run.model === record.model) | ||
| return run; | ||
| } | ||
| return null; | ||
| } | ||
| const RECALL_DROP_THRESHOLD = 0.1; | ||
| /** | ||
| * Regression messages comparing the current run against the previous run of | ||
| * the same provider+model. Empty when there is no baseline or no regressions. | ||
| */ | ||
| export function detectRegressions(history, record) { | ||
| const prev = previousRun(history, record); | ||
| if (!prev) | ||
| return []; | ||
| const out = []; | ||
| if (record.aggregate_recall < prev.aggregate_recall - RECALL_DROP_THRESHOLD) { | ||
| out.push(`aggregate recall dropped ${(prev.aggregate_recall * 100).toFixed(1)}% → ${(record.aggregate_recall * 100).toFixed(1)}% (vs ${prev.timestamp})`); | ||
| } | ||
| for (const [level, cur] of Object.entries(record.levels)) { | ||
| const before = prev.levels[level]; | ||
| if (!before) | ||
| continue; | ||
| if (before.passing && !cur.passing) { | ||
| out.push(`${level} newly failing (was passing on ${prev.timestamp})`); | ||
| } | ||
| else if (cur.recall < before.recall - RECALL_DROP_THRESHOLD) { | ||
| out.push(`${level} recall dropped ${(before.recall * 100).toFixed(1)}% → ${(cur.recall * 100).toFixed(1)}%`); | ||
| } | ||
| } | ||
| return out; | ||
| } |
| /** | ||
| * Loads a baseline file (default: .motionlintignore) — one finding hash per | ||
| * line. `#` starts a comment; anything after the first whitespace on a line | ||
| * is treated as a free-form note. A missing file is an empty baseline. | ||
| */ | ||
| export declare function loadBaseline(path: string): Promise<Set<string>>; |
| import { readFile } from "node:fs/promises"; | ||
| /** | ||
| * Loads a baseline file (default: .motionlintignore) — one finding hash per | ||
| * line. `#` starts a comment; anything after the first whitespace on a line | ||
| * is treated as a free-form note. A missing file is an empty baseline. | ||
| */ | ||
| export async function loadBaseline(path) { | ||
| let raw; | ||
| try { | ||
| raw = await readFile(path, "utf8"); | ||
| } | ||
| catch (err) { | ||
| if (err.code === "ENOENT") | ||
| return new Set(); | ||
| throw new Error(`Failed to read baseline file ${path}: ${err.message}`); | ||
| } | ||
| const hashes = raw | ||
| .split("\n") | ||
| .map((line) => line.split("#")[0].trim().split(/\s+/)[0]) | ||
| .filter(Boolean); | ||
| return new Set(hashes); | ||
| } |
| import type { AnalysisEntry } from "../types.js"; | ||
| import { type MemoryStore } from "./store.js"; | ||
| export interface MemoryFilterOptions { | ||
| analyses: AnalysisEntry[]; | ||
| /** The reviewed URL — memory is scoped per URL. */ | ||
| url: string; | ||
| /** Finding hashes the human has permanently waved off. Always suppressed. */ | ||
| baseline: Set<string>; | ||
| store: MemoryStore; | ||
| /** Drop findings already recorded in prior runs (agents that only want deltas). */ | ||
| newOnly: boolean; | ||
| } | ||
| export interface MemoryFilterResult { | ||
| analyses: AnalysisEntry[]; | ||
| by_baseline: number; | ||
| by_memory: number; | ||
| } | ||
| /** | ||
| * Annotates every finding with its cross-run hash and prior sighting count, | ||
| * suppresses baselined findings, and — only in newOnly mode — drops | ||
| * recurrences. Previously-seen findings are annotated rather than silently | ||
| * dropped by default, so an unfixed issue never vanishes from a report. | ||
| */ | ||
| export declare function applyMemory(opts: MemoryFilterOptions): MemoryFilterResult; |
| import { findingHash } from "./hash.js"; | ||
| import { findMatch } from "./store.js"; | ||
| /** | ||
| * Annotates every finding with its cross-run hash and prior sighting count, | ||
| * suppresses baselined findings, and — only in newOnly mode — drops | ||
| * recurrences. Previously-seen findings are annotated rather than silently | ||
| * dropped by default, so an unfixed issue never vanishes from a report. | ||
| */ | ||
| export function applyMemory(opts) { | ||
| let byBaseline = 0; | ||
| let byMemory = 0; | ||
| const analyses = opts.analyses.map((entry) => { | ||
| const kept = []; | ||
| for (const issue of entry.analysis.issues) { | ||
| // Canonical id = the stored entry's hash when this is a (possibly | ||
| // rephrased) recurrence, so baselines keep matching across rewordings. | ||
| const match = findMatch(opts.store, opts.url, issue); | ||
| const hash = match?.hash ?? findingHash(issue); | ||
| if (opts.baseline.has(hash) || opts.baseline.has(findingHash(issue))) { | ||
| byBaseline++; | ||
| continue; | ||
| } | ||
| const previouslySeen = match?.entry.seen_count ?? 0; | ||
| if (opts.newOnly && previouslySeen > 0) { | ||
| byMemory++; | ||
| continue; | ||
| } | ||
| kept.push({ ...issue, hash, previously_seen: previouslySeen }); | ||
| } | ||
| return { ...entry, analysis: { ...entry.analysis, issues: kept } }; | ||
| }); | ||
| return { analyses, by_baseline: byBaseline, by_memory: byMemory }; | ||
| } |
| import type { UXIssue } from "../types.js"; | ||
| /** | ||
| * Stable cross-run identity for a finding: category + normalized issue text + | ||
| * normalized location (the element selector/description). Reuses the same | ||
| * token normalization as self-consistency clustering so cosmetic rephrasing | ||
| * by the vision LLM does not change the hash. Severity is deliberately | ||
| * excluded — the same finding reported at a different severity is the same | ||
| * finding. | ||
| */ | ||
| export declare function findingHash(issue: UXIssue): string; |
| import { createHash } from "node:crypto"; | ||
| import { issueClusterSignature } from "../eval/synonyms.js"; | ||
| function normalizeLocation(location) { | ||
| return location.toLowerCase().replace(/\s+/g, " ").trim(); | ||
| } | ||
| /** | ||
| * Stable cross-run identity for a finding: category + normalized issue text + | ||
| * normalized location (the element selector/description). Reuses the same | ||
| * token normalization as self-consistency clustering so cosmetic rephrasing | ||
| * by the vision LLM does not change the hash. Severity is deliberately | ||
| * excluded — the same finding reported at a different severity is the same | ||
| * finding. | ||
| */ | ||
| export function findingHash(issue) { | ||
| const signature = issueClusterSignature(issue.category, issue.issue); | ||
| const input = `${signature}::${normalizeLocation(issue.location)}`; | ||
| return createHash("sha256").update(input).digest("hex").slice(0, 16); | ||
| } |
| export interface MemoryLockOptions { | ||
| /** Give up waiting after this long. The pipeline treats a timeout as a warning, not a failure. */ | ||
| timeoutMs?: number; | ||
| /** A lock file older than this is considered abandoned and broken. */ | ||
| staleMs?: number; | ||
| /** Retry interval while waiting for a held lock. */ | ||
| pollMs?: number; | ||
| } | ||
| export declare class MemoryLockTimeoutError extends Error { | ||
| } | ||
| /** | ||
| * Runs `fn` while holding an exclusive lock on `storePath`'s companion | ||
| * `.lock` file, so concurrent reviews of one project don't clobber each | ||
| * other's recorded sightings. Locks abandoned by dead or wedged processes | ||
| * are broken by pid-liveness and age checks. Throws MemoryLockTimeoutError | ||
| * if the lock stays held past `timeoutMs` — callers decide whether that is | ||
| * fatal (the review pipeline warns and proceeds unlocked instead). | ||
| * | ||
| * Assumes a local filesystem: the pid probe checks the local process table | ||
| * and wx-create atomicity is not guaranteed on NFS-style mounts. | ||
| */ | ||
| export declare function withMemoryLock<T>(storePath: string, fn: () => Promise<T>, opts?: MemoryLockOptions): Promise<T>; |
| import { randomBytes } from "node:crypto"; | ||
| import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises"; | ||
| import { dirname } from "node:path"; | ||
| import { setTimeout as sleepFor } from "node:timers/promises"; | ||
| const DEFAULTS = { | ||
| // The lock only spans the store's read-modify-write (milliseconds), so | ||
| // waiting past a few seconds means something is wrong, not busy. With | ||
| // staleMs > timeoutMs, a waiter present from the start times out (and the | ||
| // pipeline proceeds unlocked) before it would age-break a wedged-but-live | ||
| // holder — the caller's timeout fallback, not the age check, is the | ||
| // recovery path for that case. Age-breaking covers locks that were already | ||
| // old when a run arrived. | ||
| timeoutMs: 5_000, | ||
| staleMs: 10_000, | ||
| pollMs: 50, | ||
| }; | ||
| export class MemoryLockTimeoutError extends Error { | ||
| } | ||
| function pidAlive(pid) { | ||
| try { | ||
| process.kill(pid, 0); | ||
| return true; | ||
| } | ||
| catch (err) { | ||
| // EPERM = exists but owned by someone else; anything else (ESRCH) = gone. | ||
| return err.code === "EPERM"; | ||
| } | ||
| } | ||
| async function lockIsStale(lockPath, staleMs) { | ||
| try { | ||
| const info = await stat(lockPath); | ||
| if (Date.now() - info.mtimeMs > staleMs) | ||
| return true; | ||
| const raw = JSON.parse(await readFile(lockPath, "utf8")); | ||
| return typeof raw.pid === "number" ? !pidAlive(raw.pid) : false; | ||
| } | ||
| catch { | ||
| // Vanished between checks (owner released it) or unreadable but recent: | ||
| // let the acquire retry decide. | ||
| return false; | ||
| } | ||
| } | ||
| async function tryAcquire(lockPath, token) { | ||
| let handle; | ||
| try { | ||
| handle = await open(lockPath, "wx"); | ||
| } | ||
| catch (err) { | ||
| if (err.code === "EEXIST") | ||
| return false; | ||
| throw err; | ||
| } | ||
| try { | ||
| await handle.writeFile(JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() }), "utf8"); | ||
| } | ||
| finally { | ||
| await handle.close(); | ||
| } | ||
| return true; | ||
| } | ||
| /** | ||
| * Breaks a stale lock atomically: rename gives exactly one waiter the | ||
| * original inode, so a racing waiter can never delete the fresh lock a | ||
| * faster one just created (plain rm would). | ||
| */ | ||
| async function breakStale(lockPath) { | ||
| const victim = `${lockPath}.stale.${process.pid}.${randomBytes(4).toString("hex")}`; | ||
| try { | ||
| await rename(lockPath, victim); | ||
| } | ||
| catch { | ||
| return; // Someone else already broke or released it; just retry acquire. | ||
| } | ||
| await rm(victim, { force: true }); | ||
| } | ||
| /** Removes the lock only if it still carries our token, so a holder whose lock was age-broken never deletes a successor's. */ | ||
| async function releaseOwn(lockPath, token) { | ||
| try { | ||
| const raw = JSON.parse(await readFile(lockPath, "utf8")); | ||
| if (raw.token !== token) | ||
| return; | ||
| } | ||
| catch { | ||
| return; // Already gone or unreadable — nothing of ours to release. | ||
| } | ||
| await rm(lockPath, { force: true }); | ||
| } | ||
| /** | ||
| * Runs `fn` while holding an exclusive lock on `storePath`'s companion | ||
| * `.lock` file, so concurrent reviews of one project don't clobber each | ||
| * other's recorded sightings. Locks abandoned by dead or wedged processes | ||
| * are broken by pid-liveness and age checks. Throws MemoryLockTimeoutError | ||
| * if the lock stays held past `timeoutMs` — callers decide whether that is | ||
| * fatal (the review pipeline warns and proceeds unlocked instead). | ||
| * | ||
| * Assumes a local filesystem: the pid probe checks the local process table | ||
| * and wx-create atomicity is not guaranteed on NFS-style mounts. | ||
| */ | ||
| export async function withMemoryLock(storePath, fn, opts = {}) { | ||
| const { timeoutMs, staleMs, pollMs } = { ...DEFAULTS, ...opts }; | ||
| const lockPath = `${storePath}.lock`; | ||
| const token = `${process.pid}:${randomBytes(8).toString("hex")}`; | ||
| await mkdir(dirname(lockPath), { recursive: true }); | ||
| const deadline = Date.now() + timeoutMs; | ||
| while (!(await tryAcquire(lockPath, token))) { | ||
| if (await lockIsStale(lockPath, staleMs)) { | ||
| await breakStale(lockPath); | ||
| continue; | ||
| } | ||
| if (Date.now() >= deadline) { | ||
| throw new MemoryLockTimeoutError(`Timed out after ${timeoutMs}ms waiting for memory lock ${lockPath} (held by another live run).`); | ||
| } | ||
| await sleepFor(pollMs); | ||
| } | ||
| try { | ||
| return await fn(); | ||
| } | ||
| finally { | ||
| await releaseOwn(lockPath, token); | ||
| } | ||
| } |
| export interface MatchCandidate { | ||
| category: string; | ||
| location: string; | ||
| issue: string; | ||
| } | ||
| /** | ||
| * Fuzzy cross-run identity for findings. Exact hashes under-match on live | ||
| * vision-LLM output — the model rewords location and issue text every run — | ||
| * so recurrence detection additionally compares category compatibility (via | ||
| * the eval synonym graph) and canonical-token overlap across location + text. | ||
| */ | ||
| export declare function findingsMatch(a: MatchCandidate, b: MatchCandidate): boolean; |
| import { canonicalTokens, categoriesAreCompatible } from "../eval/synonyms.js"; | ||
| /** | ||
| * Thresholds calibrated on real cross-run recurrence data (16 live findings, | ||
| * 4 true duplicate pairs): 4+ shared canonical tokens covering at least half | ||
| * of the smaller finding matched every true pair with zero false positives. | ||
| */ | ||
| const MIN_SHARED_TOKENS = 4; | ||
| const MIN_SHARED_FRACTION = 0.5; | ||
| /** | ||
| * Fuzzy cross-run identity for findings. Exact hashes under-match on live | ||
| * vision-LLM output — the model rewords location and issue text every run — | ||
| * so recurrence detection additionally compares category compatibility (via | ||
| * the eval synonym graph) and canonical-token overlap across location + text. | ||
| */ | ||
| export function findingsMatch(a, b) { | ||
| const catA = a.category; | ||
| const catB = b.category; | ||
| if (!categoriesAreCompatible(catA, catB) && !categoriesAreCompatible(catB, catA)) { | ||
| return false; | ||
| } | ||
| const tokensA = canonicalTokens(`${a.location} ${a.issue}`); | ||
| const tokensB = canonicalTokens(`${b.location} ${b.issue}`); | ||
| let shared = 0; | ||
| for (const t of tokensA) | ||
| if (tokensB.has(t)) | ||
| shared++; | ||
| const smaller = Math.min(tokensA.size, tokensB.size); | ||
| return shared >= MIN_SHARED_TOKENS && shared >= smaller * MIN_SHARED_FRACTION; | ||
| } |
| import type { UXIssue } from "../types.js"; | ||
| export interface MemoryEntry { | ||
| first_seen: string; | ||
| last_seen: string; | ||
| seen_count: number; | ||
| category: string; | ||
| location: string; | ||
| issue: string; | ||
| } | ||
| /** Cross-run finding memory, keyed by reviewed URL, then by finding hash. */ | ||
| export interface MemoryStore { | ||
| version: 1; | ||
| urls: Record<string, Record<string, MemoryEntry>>; | ||
| } | ||
| export declare function emptyStore(): MemoryStore; | ||
| export declare function loadMemory(path: string): Promise<MemoryStore>; | ||
| export declare function seenCount(store: MemoryStore, url: string, hash: string): number; | ||
| /** | ||
| * Finds the stored entry this issue corresponds to: exact hash first, then a | ||
| * fuzzy phrasing match (see match.ts). Returns the entry's canonical hash — | ||
| * the id reports print and baseline files reference. | ||
| */ | ||
| export declare function findMatch(store: MemoryStore, url: string, issue: UXIssue): { | ||
| hash: string; | ||
| entry: MemoryEntry; | ||
| } | null; | ||
| /** | ||
| * Returns a new store with this run's findings recorded for `url` (the input | ||
| * store is untouched). Rephrased recurrences merge into their matched entry; | ||
| * each entry counts at most once per run, mirroring the self-consistency rule | ||
| * of not double-counting within a single run. | ||
| */ | ||
| export declare function recordFindings(store: MemoryStore, url: string, issues: UXIssue[], timestamp: string): MemoryStore; | ||
| /** | ||
| * Plain write with no locking of its own — the pipeline serializes the whole | ||
| * load→record→save cycle under withMemoryLock (see memory/lock.ts), so | ||
| * concurrent reviews of one project don't clobber each other's sightings. | ||
| */ | ||
| export declare function saveMemory(path: string, store: MemoryStore): Promise<void>; |
| import { mkdir, readFile, writeFile } from "node:fs/promises"; | ||
| import { dirname } from "node:path"; | ||
| import { findingHash } from "./hash.js"; | ||
| import { findingsMatch } from "./match.js"; | ||
| export function emptyStore() { | ||
| return { version: 1, urls: {} }; | ||
| } | ||
| export async function loadMemory(path) { | ||
| let raw; | ||
| try { | ||
| raw = await readFile(path, "utf8"); | ||
| } | ||
| catch (err) { | ||
| if (err.code === "ENOENT") | ||
| return emptyStore(); | ||
| throw new Error(`Failed to read memory store ${path}: ${err.message}`); | ||
| } | ||
| try { | ||
| const parsed = JSON.parse(raw); | ||
| if (parsed?.version !== 1 || typeof parsed.urls !== "object" || parsed.urls === null) { | ||
| throw new Error("unrecognized shape"); | ||
| } | ||
| return parsed; | ||
| } | ||
| catch (err) { | ||
| throw new Error(`Memory store ${path} is corrupt (${err.message}). Delete it to start fresh.`); | ||
| } | ||
| } | ||
| export function seenCount(store, url, hash) { | ||
| return store.urls[url]?.[hash]?.seen_count ?? 0; | ||
| } | ||
| function resolveHash(forUrl, issue) { | ||
| const exact = findingHash(issue); | ||
| if (forUrl[exact]) | ||
| return exact; | ||
| for (const [hash, entry] of Object.entries(forUrl)) { | ||
| if (findingsMatch(issue, entry)) | ||
| return hash; | ||
| } | ||
| return exact; | ||
| } | ||
| /** | ||
| * Finds the stored entry this issue corresponds to: exact hash first, then a | ||
| * fuzzy phrasing match (see match.ts). Returns the entry's canonical hash — | ||
| * the id reports print and baseline files reference. | ||
| */ | ||
| export function findMatch(store, url, issue) { | ||
| const forUrl = store.urls[url]; | ||
| if (!forUrl) | ||
| return null; | ||
| const hash = resolveHash(forUrl, issue); | ||
| const entry = forUrl[hash]; | ||
| return entry ? { hash, entry } : null; | ||
| } | ||
| /** | ||
| * Returns a new store with this run's findings recorded for `url` (the input | ||
| * store is untouched). Rephrased recurrences merge into their matched entry; | ||
| * each entry counts at most once per run, mirroring the self-consistency rule | ||
| * of not double-counting within a single run. | ||
| */ | ||
| export function recordFindings(store, url, issues, timestamp) { | ||
| // Fuzzy resolution runs against the pre-run snapshot only: entries created | ||
| // by this run must not absorb later findings from the same run, or two | ||
| // distinct-but-similar findings silently collapse into one. | ||
| const snapshot = store.urls[url] ?? {}; | ||
| const forUrl = { ...snapshot }; | ||
| const countedThisRun = new Set(); | ||
| for (const issue of issues) { | ||
| const hash = resolveHash(snapshot, issue); | ||
| if (countedThisRun.has(hash)) | ||
| continue; | ||
| countedThisRun.add(hash); | ||
| const prior = forUrl[hash]; | ||
| forUrl[hash] = prior | ||
| ? { ...prior, last_seen: timestamp, seen_count: prior.seen_count + 1 } | ||
| : { | ||
| first_seen: timestamp, | ||
| last_seen: timestamp, | ||
| seen_count: 1, | ||
| category: issue.category, | ||
| location: issue.location, | ||
| issue: issue.issue, | ||
| }; | ||
| } | ||
| return { ...store, urls: { ...store.urls, [url]: forUrl } }; | ||
| } | ||
| /** | ||
| * Plain write with no locking of its own — the pipeline serializes the whole | ||
| * load→record→save cycle under withMemoryLock (see memory/lock.ts), so | ||
| * concurrent reviews of one project don't clobber each other's sightings. | ||
| */ | ||
| export async function saveMemory(path, store) { | ||
| await mkdir(dirname(path), { recursive: true }); | ||
| await writeFile(path, JSON.stringify(store, null, 2), "utf8"); | ||
| } |
| /** | ||
| * Shared HTML report shell. Every MotionLint HTML report (design review, animation | ||
| * audit) is built from this shell so they read as one product. | ||
| * | ||
| * The shell dogfoods Emil Kowalski's standards in MotionLint's own UI: strong custom | ||
| * easing tokens, sub-300ms durations, entrances from scale(0.97) + opacity (never 0), | ||
| * a 40ms stagger, GPU-only transform/opacity animation, and a reduced-motion path. | ||
| */ | ||
| export declare function escapeHtml(s: string): string; | ||
| /** The shared design tokens + base styles, including the Emil easing curves as CSS vars. */ | ||
| export declare const SHELL_CSS = "\n:root {\n color-scheme: light dark;\n /* Emil's strong easing curves as shared tokens (the linter recommends these verbatim). */\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);\n --dur-fast: 140ms;\n --dur: 220ms;\n --dur-slow: 280ms;\n\n --bg: #f6f7f9;\n --bg-elev: #ffffff;\n --bg-sunken: #eef0f3;\n --border: #e2e5ea;\n --border-strong: #cfd4dc;\n --text: #14161c;\n --text-dim: #5b6472;\n --text-faint: #8a93a3;\n --accent: #5b63f5;\n --accent-soft: rgba(91, 99, 245, 0.1);\n --critical: #e5484d;\n --critical-soft: rgba(229, 72, 77, 0.12);\n --warning: #f2a20c;\n --warning-soft: rgba(242, 162, 12, 0.14);\n --suggestion: #6b7280;\n --suggestion-soft: rgba(107, 114, 128, 0.12);\n --good: #30a46c;\n --good-soft: rgba(48, 164, 108, 0.14);\n --shadow: 0 1px 2px rgba(20, 22, 28, 0.04), 0 8px 24px -12px rgba(20, 22, 28, 0.18);\n}\n@media (prefers-color-scheme: dark) {\n :root {\n --bg: #0b0d12;\n --bg-elev: #14171f;\n --bg-sunken: #0f1218;\n --border: #232833;\n --border-strong: #313846;\n --text: #e8ebf1;\n --text-dim: #9aa3b3;\n --text-faint: #6b7488;\n --accent: #7c84ff;\n --accent-soft: rgba(124, 132, 255, 0.16);\n --critical: #ff6169;\n --critical-soft: rgba(255, 97, 105, 0.16);\n --warning: #ffb84d;\n --warning-soft: rgba(255, 184, 77, 0.16);\n --suggestion: #9aa3b3;\n --suggestion-soft: rgba(154, 163, 179, 0.14);\n --good: #45c98a;\n --good-soft: rgba(69, 201, 138, 0.16);\n --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 32px -16px rgba(0, 0, 0, 0.6);\n }\n}\n\n* { box-sizing: border-box; }\nhtml { -webkit-text-size-adjust: 100%; }\nbody {\n margin: 0;\n background: var(--bg);\n color: var(--text);\n font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n -webkit-font-smoothing: antialiased;\n}\n.wrap { max-width: 960px; margin: 0 auto; padding: 40px 24px 96px; }\na { color: var(--accent); text-decoration: none; }\na:hover { text-decoration: underline; }\ncode, .mono { font-family: ui-monospace, \"SF Mono\", SFMono-Regular, Menlo, monospace; font-size: 0.86em; }\ncode {\n background: var(--bg-sunken); border: 1px solid var(--border);\n padding: 1px 6px; border-radius: 6px; white-space: nowrap;\n}\n\n/* \u2014 Header \u2014 */\n.masthead { display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 8px; }\n.logo {\n width: 40px; height: 40px; border-radius: 11px; flex: none;\n background: linear-gradient(135deg, var(--accent), #a78bfa);\n display: grid; place-items: center; box-shadow: var(--shadow);\n}\n.logo svg { width: 22px; height: 22px; }\n.masthead h1 { margin: 0; font-size: 22px; letter-spacing: -0.02em; }\n.masthead .sub { margin: 2px 0 0; color: var(--text-dim); font-size: 13.5px; }\n.masthead .sub .mono { color: var(--text-faint); }\n\n/* \u2014 Score ring / summary band \u2014 */\n.summary {\n display: flex; align-items: center; gap: 24px; flex-wrap: wrap;\n background: var(--bg-elev); border: 1px solid var(--border);\n border-radius: 16px; padding: 22px 24px; margin: 24px 0 32px;\n box-shadow: var(--shadow);\n}\n.ring { --v: 0; width: 76px; height: 76px; flex: none; position: relative; }\n.ring svg { transform: rotate(-90deg); }\n.ring .track { stroke: var(--bg-sunken); }\n.ring .val { stroke: var(--ring-color, var(--good)); stroke-linecap: round; transition: stroke-dashoffset 700ms var(--ease-out); }\n.ring .num { position: absolute; inset: 0; display: grid; place-items: center; font-weight: 700; font-size: 19px; letter-spacing: -0.02em; }\n.summary .headline { flex: 1 1 220px; }\n.summary .headline h2 { margin: 0 0 4px; font-size: 16px; font-weight: 650; letter-spacing: -0.01em; }\n.summary .headline p { margin: 0; color: var(--text-dim); font-size: 13.5px; }\n.tallies { display: flex; gap: 8px; flex-wrap: wrap; }\n.pill {\n display: inline-flex; align-items: center; gap: 6px;\n font-size: 12.5px; font-weight: 600; padding: 5px 11px; border-radius: 999px;\n border: 1px solid var(--border);\n}\n.pill .dot { width: 7px; height: 7px; border-radius: 50%; }\n.pill.crit { background: var(--critical-soft); color: var(--critical); border-color: transparent; }\n.pill.crit .dot { background: var(--critical); }\n.pill.warn { background: var(--warning-soft); color: var(--warning); border-color: transparent; }\n.pill.warn .dot { background: var(--warning); }\n.pill.sug { background: var(--suggestion-soft); color: var(--suggestion); border-color: transparent; }\n.pill.sug .dot { background: var(--suggestion); }\n.pill.good { background: var(--good-soft); color: var(--good); border-color: transparent; }\n.pill.good .dot { background: var(--good); }\n\n/* \u2014 Section headers \u2014 */\n.section-h { display: flex; align-items: baseline; gap: 10px; margin: 40px 0 16px; }\n.section-h h2 { margin: 0; font-size: 15px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); font-weight: 650; }\n.section-h .count { color: var(--text-faint); font-size: 13px; }\n\n/* \u2014 Finding cards \u2014 */\n.finding {\n background: var(--bg-elev); border: 1px solid var(--border);\n border-left: 3px solid var(--sev-color, var(--suggestion));\n border-radius: 14px; padding: 18px 20px; margin-bottom: 14px;\n box-shadow: var(--shadow);\n opacity: 0; transform: translateY(10px) scale(0.99);\n animation: rise var(--dur-slow) var(--ease-out) forwards;\n}\n.finding.sev-critical { --sev-color: var(--critical); }\n.finding.sev-warning { --sev-color: var(--warning); }\n.finding.sev-suggestion { --sev-color: var(--suggestion); }\n@keyframes rise { to { opacity: 1; transform: none; } }\n.finding .top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }\n.badge {\n font-size: 11px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase;\n padding: 3px 8px; border-radius: 6px; background: var(--sev-color); color: #fff;\n}\n.finding.sev-warning .badge { color: #1a1204; }\n.tag { font-size: 12px; font-weight: 600; color: var(--text-dim); background: var(--bg-sunken); border: 1px solid var(--border); padding: 2px 9px; border-radius: 999px; }\n.finding h3 { margin: 0; font-size: 16px; letter-spacing: -0.01em; flex: 1 1 auto; min-width: 200px; }\n.finding .loc { color: var(--text-faint); font-size: 12.5px; margin: 0 0 12px; }\n.finding .field { margin: 8px 0; font-size: 14px; }\n.finding .field .lbl { color: var(--text-faint); font-weight: 600; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.04em; display: block; margin-bottom: 1px; }\n.finding .std { margin-top: 12px; padding: 9px 12px; background: var(--accent-soft); border-radius: 9px; font-size: 12.5px; color: var(--text-dim); }\n.finding .std b { color: var(--accent); font-weight: 650; }\n\n/* \u2014 Before/after \u2014 */\n.ba { display: grid; grid-template-columns: 1fr auto 1fr; gap: 12px; align-items: stretch; margin: 12px 0 4px; }\n@media (max-width: 560px) { .ba { grid-template-columns: 1fr; } .ba .arrow { transform: rotate(90deg); } }\n.ba .cell { background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; }\n.ba .cell.after { border-color: var(--good); background: var(--good-soft); }\n.ba .cell .cap { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700; color: var(--text-faint); margin-bottom: 5px; }\n.ba .cell.after .cap { color: var(--good); }\n.ba .cell .v { font-family: ui-monospace, \"SF Mono\", Menlo, monospace; font-size: 13px; word-break: break-word; }\n.ba .arrow { align-self: center; color: var(--text-faint); font-size: 18px; }\n\n/* \u2014 Screenshot \u2014 */\n.shot { margin: 8px 0 20px; border-radius: 12px; overflow: hidden; border: 1px solid var(--border); box-shadow: var(--shadow); position: relative; }\n.shot img { display: block; width: 100%; height: auto; }\n\n/* \u2014 Finding annotations drawn over the screenshot \u2014 */\n.anno { position: absolute; border: 2px solid var(--suggestion); border-radius: 6px; pointer-events: none; box-shadow: 0 0 0 2px rgba(0,0,0,0.25); }\n.anno .anno-tag {\n position: absolute; top: -10px; left: -2px; transform: translateY(-100%);\n font: 700 11px ui-monospace, monospace; letter-spacing: 0.04em;\n color: #fff; background: var(--suggestion); border-radius: 5px; padding: 2px 6px;\n}\n.anno.sev-critical { border-color: var(--critical); } .anno.sev-critical .anno-tag { background: var(--critical); }\n.anno.sev-warning { border-color: var(--warning); } .anno.sev-warning .anno-tag { background: var(--warning); color: #1a1204; }\n\n/* \u2014 Footer \u2014 */\n.foot { margin-top: 48px; padding-top: 20px; border-top: 1px solid var(--border); color: var(--text-faint); font-size: 12.5px; text-align: center; }\n.empty { text-align: center; color: var(--text-dim); padding: 48px 24px; background: var(--bg-elev); border: 1px dashed var(--border-strong); border-radius: 14px; }\n.empty .big { font-size: 32px; margin-bottom: 8px; }\n\n@media (prefers-reduced-motion: reduce) {\n .finding { animation: fade var(--dur) ease forwards; transform: none; }\n .ring .val { transition: none; }\n @keyframes fade { to { opacity: 1; } }\n}\n"; | ||
| /** A circular score ring, 0–100. */ | ||
| export declare function scoreRing(score: number): string; | ||
| /** Severity count pills. Renders a single "clean" pill when there are no findings. */ | ||
| export declare function severityPills(counts: { | ||
| critical: number; | ||
| warning: number; | ||
| suggestion: number; | ||
| }): string; | ||
| export interface ShellOptions { | ||
| title: string; | ||
| /** Small monospace subtitle line under the H1 (e.g. the reviewed URL + timestamp). */ | ||
| subtitle: string; | ||
| /** Inner HTML for the <main> body. */ | ||
| body: string; | ||
| /** Extra <style> appended after the shared shell CSS. */ | ||
| extraCss?: string; | ||
| /** Optional <script> body (already escaped/safe). */ | ||
| script?: string; | ||
| } | ||
| /** Wrap body content in the full standalone HTML document. */ | ||
| export declare function htmlShell(opts: ShellOptions): string; |
| /** | ||
| * Shared HTML report shell. Every MotionLint HTML report (design review, animation | ||
| * audit) is built from this shell so they read as one product. | ||
| * | ||
| * The shell dogfoods Emil Kowalski's standards in MotionLint's own UI: strong custom | ||
| * easing tokens, sub-300ms durations, entrances from scale(0.97) + opacity (never 0), | ||
| * a 40ms stagger, GPU-only transform/opacity animation, and a reduced-motion path. | ||
| */ | ||
| export function escapeHtml(s) { | ||
| return String(s) | ||
| .replace(/&/g, "&") | ||
| .replace(/</g, "<") | ||
| .replace(/>/g, ">") | ||
| .replace(/"/g, """); | ||
| } | ||
| /** The shared design tokens + base styles, including the Emil easing curves as CSS vars. */ | ||
| export const SHELL_CSS = ` | ||
| :root { | ||
| color-scheme: light dark; | ||
| /* Emil's strong easing curves as shared tokens (the linter recommends these verbatim). */ | ||
| --ease-out: cubic-bezier(0.23, 1, 0.32, 1); | ||
| --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); | ||
| --dur-fast: 140ms; | ||
| --dur: 220ms; | ||
| --dur-slow: 280ms; | ||
| --bg: #f6f7f9; | ||
| --bg-elev: #ffffff; | ||
| --bg-sunken: #eef0f3; | ||
| --border: #e2e5ea; | ||
| --border-strong: #cfd4dc; | ||
| --text: #14161c; | ||
| --text-dim: #5b6472; | ||
| --text-faint: #8a93a3; | ||
| --accent: #5b63f5; | ||
| --accent-soft: rgba(91, 99, 245, 0.1); | ||
| --critical: #e5484d; | ||
| --critical-soft: rgba(229, 72, 77, 0.12); | ||
| --warning: #f2a20c; | ||
| --warning-soft: rgba(242, 162, 12, 0.14); | ||
| --suggestion: #6b7280; | ||
| --suggestion-soft: rgba(107, 114, 128, 0.12); | ||
| --good: #30a46c; | ||
| --good-soft: rgba(48, 164, 108, 0.14); | ||
| --shadow: 0 1px 2px rgba(20, 22, 28, 0.04), 0 8px 24px -12px rgba(20, 22, 28, 0.18); | ||
| } | ||
| @media (prefers-color-scheme: dark) { | ||
| :root { | ||
| --bg: #0b0d12; | ||
| --bg-elev: #14171f; | ||
| --bg-sunken: #0f1218; | ||
| --border: #232833; | ||
| --border-strong: #313846; | ||
| --text: #e8ebf1; | ||
| --text-dim: #9aa3b3; | ||
| --text-faint: #6b7488; | ||
| --accent: #7c84ff; | ||
| --accent-soft: rgba(124, 132, 255, 0.16); | ||
| --critical: #ff6169; | ||
| --critical-soft: rgba(255, 97, 105, 0.16); | ||
| --warning: #ffb84d; | ||
| --warning-soft: rgba(255, 184, 77, 0.16); | ||
| --suggestion: #9aa3b3; | ||
| --suggestion-soft: rgba(154, 163, 179, 0.14); | ||
| --good: #45c98a; | ||
| --good-soft: rgba(69, 201, 138, 0.16); | ||
| --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 32px -16px rgba(0, 0, 0, 0.6); | ||
| } | ||
| } | ||
| * { box-sizing: border-box; } | ||
| html { -webkit-text-size-adjust: 100%; } | ||
| body { | ||
| margin: 0; | ||
| background: var(--bg); | ||
| color: var(--text); | ||
| font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; | ||
| -webkit-font-smoothing: antialiased; | ||
| } | ||
| .wrap { max-width: 960px; margin: 0 auto; padding: 40px 24px 96px; } | ||
| a { color: var(--accent); text-decoration: none; } | ||
| a:hover { text-decoration: underline; } | ||
| code, .mono { font-family: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; font-size: 0.86em; } | ||
| code { | ||
| background: var(--bg-sunken); border: 1px solid var(--border); | ||
| padding: 1px 6px; border-radius: 6px; white-space: nowrap; | ||
| } | ||
| /* — Header — */ | ||
| .masthead { display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 8px; } | ||
| .logo { | ||
| width: 40px; height: 40px; border-radius: 11px; flex: none; | ||
| background: linear-gradient(135deg, var(--accent), #a78bfa); | ||
| display: grid; place-items: center; box-shadow: var(--shadow); | ||
| } | ||
| .logo svg { width: 22px; height: 22px; } | ||
| .masthead h1 { margin: 0; font-size: 22px; letter-spacing: -0.02em; } | ||
| .masthead .sub { margin: 2px 0 0; color: var(--text-dim); font-size: 13.5px; } | ||
| .masthead .sub .mono { color: var(--text-faint); } | ||
| /* — Score ring / summary band — */ | ||
| .summary { | ||
| display: flex; align-items: center; gap: 24px; flex-wrap: wrap; | ||
| background: var(--bg-elev); border: 1px solid var(--border); | ||
| border-radius: 16px; padding: 22px 24px; margin: 24px 0 32px; | ||
| box-shadow: var(--shadow); | ||
| } | ||
| .ring { --v: 0; width: 76px; height: 76px; flex: none; position: relative; } | ||
| .ring svg { transform: rotate(-90deg); } | ||
| .ring .track { stroke: var(--bg-sunken); } | ||
| .ring .val { stroke: var(--ring-color, var(--good)); stroke-linecap: round; transition: stroke-dashoffset 700ms var(--ease-out); } | ||
| .ring .num { position: absolute; inset: 0; display: grid; place-items: center; font-weight: 700; font-size: 19px; letter-spacing: -0.02em; } | ||
| .summary .headline { flex: 1 1 220px; } | ||
| .summary .headline h2 { margin: 0 0 4px; font-size: 16px; font-weight: 650; letter-spacing: -0.01em; } | ||
| .summary .headline p { margin: 0; color: var(--text-dim); font-size: 13.5px; } | ||
| .tallies { display: flex; gap: 8px; flex-wrap: wrap; } | ||
| .pill { | ||
| display: inline-flex; align-items: center; gap: 6px; | ||
| font-size: 12.5px; font-weight: 600; padding: 5px 11px; border-radius: 999px; | ||
| border: 1px solid var(--border); | ||
| } | ||
| .pill .dot { width: 7px; height: 7px; border-radius: 50%; } | ||
| .pill.crit { background: var(--critical-soft); color: var(--critical); border-color: transparent; } | ||
| .pill.crit .dot { background: var(--critical); } | ||
| .pill.warn { background: var(--warning-soft); color: var(--warning); border-color: transparent; } | ||
| .pill.warn .dot { background: var(--warning); } | ||
| .pill.sug { background: var(--suggestion-soft); color: var(--suggestion); border-color: transparent; } | ||
| .pill.sug .dot { background: var(--suggestion); } | ||
| .pill.good { background: var(--good-soft); color: var(--good); border-color: transparent; } | ||
| .pill.good .dot { background: var(--good); } | ||
| /* — Section headers — */ | ||
| .section-h { display: flex; align-items: baseline; gap: 10px; margin: 40px 0 16px; } | ||
| .section-h h2 { margin: 0; font-size: 15px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); font-weight: 650; } | ||
| .section-h .count { color: var(--text-faint); font-size: 13px; } | ||
| /* — Finding cards — */ | ||
| .finding { | ||
| background: var(--bg-elev); border: 1px solid var(--border); | ||
| border-left: 3px solid var(--sev-color, var(--suggestion)); | ||
| border-radius: 14px; padding: 18px 20px; margin-bottom: 14px; | ||
| box-shadow: var(--shadow); | ||
| opacity: 0; transform: translateY(10px) scale(0.99); | ||
| animation: rise var(--dur-slow) var(--ease-out) forwards; | ||
| } | ||
| .finding.sev-critical { --sev-color: var(--critical); } | ||
| .finding.sev-warning { --sev-color: var(--warning); } | ||
| .finding.sev-suggestion { --sev-color: var(--suggestion); } | ||
| @keyframes rise { to { opacity: 1; transform: none; } } | ||
| .finding .top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; } | ||
| .badge { | ||
| font-size: 11px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; | ||
| padding: 3px 8px; border-radius: 6px; background: var(--sev-color); color: #fff; | ||
| } | ||
| .finding.sev-warning .badge { color: #1a1204; } | ||
| .tag { font-size: 12px; font-weight: 600; color: var(--text-dim); background: var(--bg-sunken); border: 1px solid var(--border); padding: 2px 9px; border-radius: 999px; } | ||
| .finding h3 { margin: 0; font-size: 16px; letter-spacing: -0.01em; flex: 1 1 auto; min-width: 200px; } | ||
| .finding .loc { color: var(--text-faint); font-size: 12.5px; margin: 0 0 12px; } | ||
| .finding .field { margin: 8px 0; font-size: 14px; } | ||
| .finding .field .lbl { color: var(--text-faint); font-weight: 600; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.04em; display: block; margin-bottom: 1px; } | ||
| .finding .std { margin-top: 12px; padding: 9px 12px; background: var(--accent-soft); border-radius: 9px; font-size: 12.5px; color: var(--text-dim); } | ||
| .finding .std b { color: var(--accent); font-weight: 650; } | ||
| /* — Before/after — */ | ||
| .ba { display: grid; grid-template-columns: 1fr auto 1fr; gap: 12px; align-items: stretch; margin: 12px 0 4px; } | ||
| @media (max-width: 560px) { .ba { grid-template-columns: 1fr; } .ba .arrow { transform: rotate(90deg); } } | ||
| .ba .cell { background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; } | ||
| .ba .cell.after { border-color: var(--good); background: var(--good-soft); } | ||
| .ba .cell .cap { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700; color: var(--text-faint); margin-bottom: 5px; } | ||
| .ba .cell.after .cap { color: var(--good); } | ||
| .ba .cell .v { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13px; word-break: break-word; } | ||
| .ba .arrow { align-self: center; color: var(--text-faint); font-size: 18px; } | ||
| /* — Screenshot — */ | ||
| .shot { margin: 8px 0 20px; border-radius: 12px; overflow: hidden; border: 1px solid var(--border); box-shadow: var(--shadow); position: relative; } | ||
| .shot img { display: block; width: 100%; height: auto; } | ||
| /* — Finding annotations drawn over the screenshot — */ | ||
| .anno { position: absolute; border: 2px solid var(--suggestion); border-radius: 6px; pointer-events: none; box-shadow: 0 0 0 2px rgba(0,0,0,0.25); } | ||
| .anno .anno-tag { | ||
| position: absolute; top: -10px; left: -2px; transform: translateY(-100%); | ||
| font: 700 11px ui-monospace, monospace; letter-spacing: 0.04em; | ||
| color: #fff; background: var(--suggestion); border-radius: 5px; padding: 2px 6px; | ||
| } | ||
| .anno.sev-critical { border-color: var(--critical); } .anno.sev-critical .anno-tag { background: var(--critical); } | ||
| .anno.sev-warning { border-color: var(--warning); } .anno.sev-warning .anno-tag { background: var(--warning); color: #1a1204; } | ||
| /* — Footer — */ | ||
| .foot { margin-top: 48px; padding-top: 20px; border-top: 1px solid var(--border); color: var(--text-faint); font-size: 12.5px; text-align: center; } | ||
| .empty { text-align: center; color: var(--text-dim); padding: 48px 24px; background: var(--bg-elev); border: 1px dashed var(--border-strong); border-radius: 14px; } | ||
| .empty .big { font-size: 32px; margin-bottom: 8px; } | ||
| @media (prefers-reduced-motion: reduce) { | ||
| .finding { animation: fade var(--dur) ease forwards; transform: none; } | ||
| .ring .val { transition: none; } | ||
| @keyframes fade { to { opacity: 1; } } | ||
| } | ||
| `; | ||
| const LOGO_SVG = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">' + | ||
| '<path d="M4 17c3-9 4.5-9 7.5 0S16 26 20 7" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>' + | ||
| "</svg>"; | ||
| /** A circular score ring, 0–100. */ | ||
| export function scoreRing(score) { | ||
| const pct = Math.max(0, Math.min(100, score)); | ||
| const r = 32; | ||
| const c = 2 * Math.PI * r; | ||
| const offset = c * (1 - pct / 100); | ||
| const color = pct >= 85 ? "var(--good)" : pct >= 60 ? "var(--warning)" : "var(--critical)"; | ||
| return ` | ||
| <div class="ring" style="--ring-color:${color}"> | ||
| <svg width="76" height="76" viewBox="0 0 76 76"> | ||
| <circle class="track" cx="38" cy="38" r="${r}" fill="none" stroke-width="7"/> | ||
| <circle class="val" cx="38" cy="38" r="${r}" fill="none" stroke-width="7" | ||
| stroke-dasharray="${c.toFixed(1)}" stroke-dashoffset="${offset.toFixed(1)}"/> | ||
| </svg> | ||
| <div class="num">${Math.round(pct)}</div> | ||
| </div>`; | ||
| } | ||
| /** Severity count pills. Renders a single "clean" pill when there are no findings. */ | ||
| export function severityPills(counts) { | ||
| const parts = []; | ||
| if (counts.critical) | ||
| parts.push(`<span class="pill crit"><span class="dot"></span>${counts.critical} critical</span>`); | ||
| if (counts.warning) | ||
| parts.push(`<span class="pill warn"><span class="dot"></span>${counts.warning} warning</span>`); | ||
| if (counts.suggestion) | ||
| parts.push(`<span class="pill sug"><span class="dot"></span>${counts.suggestion} suggestion</span>`); | ||
| if (parts.length === 0) | ||
| parts.push(`<span class="pill good"><span class="dot"></span>clean</span>`); | ||
| return `<div class="tallies">${parts.join("")}</div>`; | ||
| } | ||
| /** Wrap body content in the full standalone HTML document. */ | ||
| export function htmlShell(opts) { | ||
| return `<!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <title>${escapeHtml(opts.title)}</title> | ||
| <style>${SHELL_CSS}${opts.extraCss ?? ""}</style> | ||
| </head> | ||
| <body> | ||
| <div class="wrap"> | ||
| <div class="masthead"> | ||
| <div class="logo">${LOGO_SVG}</div> | ||
| <div> | ||
| <h1>${escapeHtml(opts.title)}</h1> | ||
| <p class="sub">${opts.subtitle}</p> | ||
| </div> | ||
| </div> | ||
| ${opts.body} | ||
| <div class="foot">Generated by <a href="https://github.com/bobaba99/motionlint">MotionLint</a> · animation standards after <a href="https://emilkowal.ski/">Emil Kowalski</a></div> | ||
| </div> | ||
| ${opts.script ? `<script>${opts.script}</script>` : ""} | ||
| </body> | ||
| </html>`; | ||
| } |
| /** | ||
| * Polished, self-contained HTML report for `motionlint review` findings. | ||
| * Embeds each viewport's screenshot inline (base64) so the report is a single | ||
| * shareable file, and pairs every issue with a before → after (issue → fix) panel. | ||
| */ | ||
| import type { ReviewReport } from "../types.js"; | ||
| export declare function renderReviewHtmlReport(report: ReviewReport): string; |
| import { escapeHtml, htmlShell, scoreRing, severityPills } from "./html_shell.js"; | ||
| import { formatUsageLine } from "../resources/usage.js"; | ||
| const SEV_ORDER = { critical: 0, warning: 1, suggestion: 2 }; | ||
| function sortIssues(issues) { | ||
| return [...issues].sort((a, b) => SEV_ORDER[a.severity] - SEV_ORDER[b.severity]); | ||
| } | ||
| function headline(report) { | ||
| if (report.critical_count > 0) { | ||
| return { title: "Critical issues need attention", blurb: "At least one finding blocks task completion or fails an accessibility standard." }; | ||
| } | ||
| if (report.warning_count > 0) { | ||
| return { title: "Usability warnings found", blurb: "No blockers, but several issues measurably degrade the experience." }; | ||
| } | ||
| if (report.suggestion_count > 0) { | ||
| return { title: "Looking good — a few polish notes", blurb: "Only nice-to-have refinements remain." }; | ||
| } | ||
| return { title: "Clean bill of health", blurb: "No UI/UX issues were identified across the reviewed viewports." }; | ||
| } | ||
| function renderIssue(issue, index) { | ||
| const delay = Math.min(index * 40, 320); // 40ms stagger, capped | ||
| const ba = ` | ||
| <div class="ba"> | ||
| <div class="cell"><div class="cap">Current — the issue</div><div class="v" style="white-space:normal;font-family:inherit;font-size:13.5px">${escapeHtml(issue.issue)}</div></div> | ||
| <div class="arrow">→</div> | ||
| <div class="cell after"><div class="cap">Suggested fix</div><div class="v" style="white-space:normal;font-family:inherit;font-size:13.5px">${escapeHtml(issue.fix)}</div></div> | ||
| </div>`; | ||
| const idLine = issue.hash | ||
| ? `<div class="std" style="background:var(--bg-sunken);color:var(--text-dim)">Finding id <code>${escapeHtml(issue.hash)}</code>${issue.previously_seen ? ` · seen in ${issue.previously_seen} prior run${issue.previously_seen === 1 ? "" : "s"}` : ""} — add to the baseline file to suppress.</div>` | ||
| : ""; | ||
| return ` | ||
| <article class="finding sev-${issue.severity}" style="animation-delay:${delay}ms"> | ||
| <div class="top"> | ||
| <span class="badge">${escapeHtml(issue.severity)}</span> | ||
| <span class="tag">${escapeHtml(issue.category)}</span> | ||
| <h3>${escapeHtml(issue.issue)}</h3> | ||
| </div> | ||
| <p class="loc">📍 ${escapeHtml(issue.location || "unspecified location")}${issue.element_ref ? ` · <span class="mono">${escapeHtml(issue.element_ref)}</span>` : ""}</p> | ||
| <div class="field"><span class="lbl">Why it matters</span>${escapeHtml(issue.why_it_matters)}</div> | ||
| ${ba} | ||
| ${idLine} | ||
| </article>`; | ||
| } | ||
| function annotationOverlays(entry) { | ||
| const page = entry.capture.dom?.page; | ||
| if (!page || page.width <= 0 || page.height <= 0) | ||
| return ""; | ||
| // Rects are document coordinates: they only line up with a full-page capture | ||
| // of a page whose width matches the screenshot. Viewport-only captures and | ||
| // horizontally-overflowing pages would misplace every box — skip instead. | ||
| if (!entry.capture.fullPage || entry.capture.dom?.horizontal_overflow) | ||
| return ""; | ||
| const pct = (v, total) => `${((v / total) * 100).toFixed(2)}%`; | ||
| return sortIssues(entry.analysis.issues) | ||
| .filter((i) => i.element_rect) | ||
| .map((i) => { | ||
| const r = i.element_rect; | ||
| const style = `left:${pct(r.x, page.width)};top:${pct(r.y, page.height)};width:${pct(r.w, page.width)};height:${pct(r.h, page.height)}`; | ||
| return `<div class="anno sev-${i.severity}" style="${style}"><span class="anno-tag">${escapeHtml(i.element_ref ?? "")}</span></div>`; | ||
| }) | ||
| .join(""); | ||
| } | ||
| function renderViewport(entry) { | ||
| const { capture, analysis } = entry; | ||
| const shot = capture.screenshot?.length | ||
| ? `<div class="shot"><img alt="${escapeHtml(capture.viewport.name)} screenshot" src="data:image/png;base64,${capture.screenshot.toString("base64")}">${annotationOverlays(entry)}</div>` | ||
| : ""; | ||
| const issues = analysis.issues.length | ||
| ? sortIssues(analysis.issues).map(renderIssue).join("\n") | ||
| : `<div class="empty"><div class="big">✓</div>No issues identified at this viewport.</div>`; | ||
| const strengths = analysis.strengths.length | ||
| ? `<div class="section-h"><h2>Strengths</h2></div><ul style="margin:0;padding-left:20px;color:var(--text-dim)">${analysis.strengths | ||
| .map((s) => `<li>${escapeHtml(s)}</li>`) | ||
| .join("")}</ul>` | ||
| : ""; | ||
| return ` | ||
| <section> | ||
| <div class="section-h"> | ||
| <h2>${escapeHtml(capture.viewport.name)}</h2> | ||
| <span class="count">${capture.viewport.width}×${capture.viewport.height} · score ${analysis.overall_score}/10 · ${analysis.issues.length} issue${analysis.issues.length === 1 ? "" : "s"}</span> | ||
| </div> | ||
| ${analysis.summary ? `<p style="color:var(--text-dim);margin:0 0 4px">${escapeHtml(analysis.summary.replace(/\n+/g, " "))}</p>` : ""} | ||
| ${shot} | ||
| ${issues} | ||
| ${strengths} | ||
| </section>`; | ||
| } | ||
| export function renderReviewHtmlReport(report) { | ||
| const h = headline(report); | ||
| const summary = ` | ||
| <div class="summary"> | ||
| ${scoreRing(report.aggregate_score * 10)} | ||
| <div class="headline"> | ||
| <h2>${escapeHtml(h.title)}</h2> | ||
| <p>${escapeHtml(h.blurb)} · ${report.analyses.length} viewport${report.analyses.length === 1 ? "" : "s"} reviewed · aggregate ${report.aggregate_score}/10${report.usage ? ` · ${escapeHtml(formatUsageLine(report.usage))}` : ""}</p> | ||
| </div> | ||
| ${severityPills({ critical: report.critical_count, warning: report.warning_count, suggestion: report.suggestion_count })} | ||
| </div>`; | ||
| const body = report.analyses.length === 0 | ||
| ? `${summary}<div class="empty"><div class="big">∅</div>No analyses were produced.</div>` | ||
| : `${summary}${report.analyses.map(renderViewport).join("\n")}`; | ||
| return htmlShell({ | ||
| title: "MotionLint Design Review", | ||
| subtitle: `<span class="mono">${escapeHtml(report.url)}</span> · ${escapeHtml(report.provider)} (${escapeHtml(report.model)}) · ${escapeHtml(report.timestamp)}`, | ||
| body, | ||
| }); | ||
| } |
| import type { VisionProvider } from "../types.js"; | ||
| /** | ||
| * Counting semaphore with FIFO wakeups. Bounds how many reviews run at once | ||
| * when concurrent agent calls land on one MCP server process. | ||
| */ | ||
| export declare class Semaphore { | ||
| private readonly max; | ||
| private active; | ||
| private readonly queue; | ||
| constructor(max: number); | ||
| run<T>(fn: () => Promise<T>): Promise<T>; | ||
| private acquire; | ||
| private release; | ||
| } | ||
| export interface RateLimiterOptions { | ||
| limit: number; | ||
| windowMs: number; | ||
| /** Injectable clock for deterministic tests. */ | ||
| now?: () => number; | ||
| sleep?: (ms: number) => Promise<void>; | ||
| } | ||
| /** | ||
| * Sliding-window rate limiter: at most `limit` acquisitions per `windowMs`. | ||
| * Callers over the limit wait until the oldest admission leaves the window. | ||
| */ | ||
| export declare class RateLimiter { | ||
| private timestamps; | ||
| private readonly limit; | ||
| private readonly windowMs; | ||
| private readonly now; | ||
| private readonly sleep; | ||
| constructor(opts: RateLimiterOptions); | ||
| acquire(): Promise<void>; | ||
| } | ||
| /** | ||
| * Wraps a provider so every analyze() call passes through the rate limiter. | ||
| * With no limiter the provider is returned untouched. | ||
| */ | ||
| export declare function withRateLimit(provider: VisionProvider, limiter: RateLimiter | null): VisionProvider; | ||
| export declare function sharedRateLimiter(callsPerMinute: number | null | undefined): RateLimiter | null; | ||
| export declare function sharedReviewGate(maxConcurrent: number | null | undefined): Semaphore | null; |
| import { setTimeout as sleepFor } from "node:timers/promises"; | ||
| /** | ||
| * Counting semaphore with FIFO wakeups. Bounds how many reviews run at once | ||
| * when concurrent agent calls land on one MCP server process. | ||
| */ | ||
| export class Semaphore { | ||
| max; | ||
| active = 0; | ||
| queue = []; | ||
| constructor(max) { | ||
| this.max = max; | ||
| } | ||
| async run(fn) { | ||
| await this.acquire(); | ||
| try { | ||
| return await fn(); | ||
| } | ||
| finally { | ||
| this.release(); | ||
| } | ||
| } | ||
| acquire() { | ||
| if (this.active < this.max) { | ||
| this.active++; | ||
| return Promise.resolve(); | ||
| } | ||
| return new Promise((resolve) => this.queue.push(resolve)); | ||
| } | ||
| release() { | ||
| const next = this.queue.shift(); | ||
| // Hand the slot straight to the next waiter so `active` stays accurate. | ||
| if (next) | ||
| next(); | ||
| else | ||
| this.active--; | ||
| } | ||
| } | ||
| /** | ||
| * Sliding-window rate limiter: at most `limit` acquisitions per `windowMs`. | ||
| * Callers over the limit wait until the oldest admission leaves the window. | ||
| */ | ||
| export class RateLimiter { | ||
| timestamps = []; | ||
| limit; | ||
| windowMs; | ||
| now; | ||
| sleep; | ||
| constructor(opts) { | ||
| this.limit = opts.limit; | ||
| this.windowMs = opts.windowMs; | ||
| this.now = opts.now ?? Date.now; | ||
| this.sleep = opts.sleep ?? ((ms) => sleepFor(ms)); | ||
| } | ||
| async acquire() { | ||
| for (;;) { | ||
| const cutoff = this.now() - this.windowMs; | ||
| this.timestamps = this.timestamps.filter((t) => t > cutoff); | ||
| if (this.timestamps.length < this.limit) { | ||
| this.timestamps = [...this.timestamps, this.now()]; | ||
| return; | ||
| } | ||
| await this.sleep(Math.max(1, this.timestamps[0] + this.windowMs - this.now())); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Wraps a provider so every analyze() call passes through the rate limiter. | ||
| * With no limiter the provider is returned untouched. | ||
| */ | ||
| export function withRateLimit(provider, limiter) { | ||
| if (!limiter) | ||
| return provider; | ||
| return { | ||
| name: provider.name, | ||
| model: provider.model, | ||
| isAvailable: () => provider.isAvailable(), | ||
| analyze: async (screenshot, prompt, viewportName) => { | ||
| await limiter.acquire(); | ||
| return provider.analyze(screenshot, prompt, viewportName); | ||
| }, | ||
| }; | ||
| } | ||
| function validCap(value) { | ||
| return typeof value === "number" && Number.isInteger(value) && value > 0; | ||
| } | ||
| // Process-wide instances keyed by setting, so one ceiling spans every review | ||
| // in the process (multi-route CLI runs, concurrent MCP tool calls) instead of | ||
| // resetting per call. Keying by value means a config edit mid-process leaves | ||
| // the old instance serving in-flight work — the ceiling is only exact for | ||
| // values stable across the process lifetime. | ||
| const rateLimiters = new Map(); | ||
| const reviewGates = new Map(); | ||
| export function sharedRateLimiter(callsPerMinute) { | ||
| if (!validCap(callsPerMinute)) | ||
| return null; | ||
| const existing = rateLimiters.get(callsPerMinute); | ||
| if (existing) | ||
| return existing; | ||
| const limiter = new RateLimiter({ limit: callsPerMinute, windowMs: 60_000 }); | ||
| rateLimiters.set(callsPerMinute, limiter); | ||
| return limiter; | ||
| } | ||
| export function sharedReviewGate(maxConcurrent) { | ||
| if (!validCap(maxConcurrent)) | ||
| return null; | ||
| const existing = reviewGates.get(maxConcurrent); | ||
| if (existing) | ||
| return existing; | ||
| const gate = new Semaphore(maxConcurrent); | ||
| reviewGates.set(maxConcurrent, gate); | ||
| return gate; | ||
| } |
| /** | ||
| * Token-usage normalization. Every provider reports usage under a different | ||
| * shape and vocabulary; these helpers map each raw payload onto the shared | ||
| * TokenUsage type and keep run totals. Missing or malformed usage blocks map | ||
| * to undefined — a provider that reports nothing simply contributes nothing. | ||
| */ | ||
| import type { RunUsage, TokenUsage } from "../types.js"; | ||
| /** Anthropic Messages API: `usage.input_tokens` / `usage.output_tokens`. */ | ||
| export declare function usageFromAnthropic(raw: unknown): TokenUsage | undefined; | ||
| /** OpenAI chat completions: `usage.prompt_tokens` / `usage.completion_tokens`. */ | ||
| export declare function usageFromOpenAI(raw: unknown): TokenUsage | undefined; | ||
| /** Google generateContent: `usageMetadata.promptTokenCount` / `candidatesTokenCount`. */ | ||
| export declare function usageFromGoogle(raw: unknown): TokenUsage | undefined; | ||
| /** Ollama generate: top-level `prompt_eval_count` / `eval_count`. */ | ||
| export declare function usageFromOllama(raw: unknown): TokenUsage | undefined; | ||
| export declare function emptyRunUsage(limit: number | null): RunUsage; | ||
| /** Fold one call's usage into the run total (immutable). */ | ||
| export declare function addUsage(run: RunUsage, call: TokenUsage | undefined): RunUsage; | ||
| /** A budget is exhausted once the run total meets or crosses it. */ | ||
| export declare function budgetExhausted(run: RunUsage): boolean; | ||
| export declare function formatUsageLine(run: RunUsage): string; |
| function count(value) { | ||
| return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; | ||
| } | ||
| function usage(input, output) { | ||
| const input_tokens = count(input); | ||
| const output_tokens = count(output); | ||
| if (input_tokens === 0 && output_tokens === 0) | ||
| return undefined; | ||
| return { input_tokens, output_tokens, total_tokens: input_tokens + output_tokens }; | ||
| } | ||
| /** Anthropic Messages API: `usage.input_tokens` / `usage.output_tokens`. */ | ||
| export function usageFromAnthropic(raw) { | ||
| const u = raw?.usage; | ||
| return usage(u?.input_tokens, u?.output_tokens); | ||
| } | ||
| /** OpenAI chat completions: `usage.prompt_tokens` / `usage.completion_tokens`. */ | ||
| export function usageFromOpenAI(raw) { | ||
| const u = raw?.usage; | ||
| return usage(u?.prompt_tokens, u?.completion_tokens); | ||
| } | ||
| /** Google generateContent: `usageMetadata.promptTokenCount` / `candidatesTokenCount`. */ | ||
| export function usageFromGoogle(raw) { | ||
| const u = raw?.usageMetadata; | ||
| return usage(u?.promptTokenCount, u?.candidatesTokenCount); | ||
| } | ||
| /** Ollama generate: top-level `prompt_eval_count` / `eval_count`. */ | ||
| export function usageFromOllama(raw) { | ||
| const r = raw; | ||
| return usage(r?.prompt_eval_count, r?.eval_count); | ||
| } | ||
| export function emptyRunUsage(limit) { | ||
| return { input_tokens: 0, output_tokens: 0, total_tokens: 0, calls: 0, limit, skipped_viewports: [] }; | ||
| } | ||
| /** Fold one call's usage into the run total (immutable). */ | ||
| export function addUsage(run, call) { | ||
| return { | ||
| ...run, | ||
| calls: run.calls + 1, | ||
| input_tokens: run.input_tokens + (call?.input_tokens ?? 0), | ||
| output_tokens: run.output_tokens + (call?.output_tokens ?? 0), | ||
| total_tokens: run.total_tokens + (call?.total_tokens ?? 0), | ||
| }; | ||
| } | ||
| /** A budget is exhausted once the run total meets or crosses it. */ | ||
| export function budgetExhausted(run) { | ||
| return typeof run.limit === "number" && run.limit > 0 && run.total_tokens >= run.limit; | ||
| } | ||
| export function formatUsageLine(run) { | ||
| const fmt = (n) => n.toLocaleString("en-US"); | ||
| const parts = [`${fmt(run.input_tokens)} in / ${fmt(run.output_tokens)} out · ${run.calls} call${run.calls === 1 ? "" : "s"}`]; | ||
| if (typeof run.limit === "number" && run.limit > 0) { | ||
| parts.push(`budget ${fmt(run.limit)}`); | ||
| if (run.skipped_viewports.length > 0) | ||
| parts.push(`exhausted — skipped: ${run.skipped_viewports.join(", ")}`); | ||
| } | ||
| return parts.join(" · "); | ||
| } |
| import type { AnimationAudit } from "./lint.js"; | ||
| export declare function renderAnimationAuditHtml(audit: AnimationAudit): string; |
| /** | ||
| * Polished HTML report for a deterministic animation audit (the linter's output). | ||
| * Renders each Emil-standard violation with a before → after panel; easing findings | ||
| * get an inline cubic-bezier curve comparison so the fix is visible, not just described. | ||
| */ | ||
| import { escapeHtml, htmlShell, scoreRing, severityPills } from "../report/html_shell.js"; | ||
| const CATEGORY_LABEL = { | ||
| easing: "Easing", | ||
| duration: "Duration", | ||
| physicality: "Physicality", | ||
| performance: "Performance", | ||
| accessibility: "Accessibility", | ||
| cohesion: "Cohesion", | ||
| }; | ||
| /** Parse a cubic-bezier(...) string into its four control points; null for keywords. */ | ||
| function parseBezier(v) { | ||
| const named = { | ||
| ease: [0.25, 0.1, 0.25, 1], | ||
| "ease-in": [0.42, 0, 1, 1], | ||
| "ease-out": [0, 0, 0.58, 1], | ||
| "ease-in-out": [0.42, 0, 0.58, 1], | ||
| linear: [0, 0, 1, 1], | ||
| }; | ||
| const key = v.trim().toLowerCase(); | ||
| if (named[key]) | ||
| return named[key]; | ||
| const m = key.match(/cubic-bezier\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)/); | ||
| if (m) | ||
| return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]; | ||
| return null; | ||
| } | ||
| /** A tiny SVG plot of an easing curve inside a unit box (y inverted for screen coords). */ | ||
| function curveSvg(v, color) { | ||
| const pts = parseBezier(v); | ||
| const W = 96; | ||
| const H = 96; | ||
| const pad = 8; | ||
| const x = (t) => pad + t * (W - 2 * pad); | ||
| const y = (t) => H - pad - t * (H - 2 * pad); | ||
| const grid = `<rect x="${pad}" y="${pad}" width="${W - 2 * pad}" height="${H - 2 * pad}" fill="none" stroke="var(--border)" stroke-width="1"/>`; | ||
| if (!pts) { | ||
| return `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${grid}<line x1="${x(0)}" y1="${y(0)}" x2="${x(1)}" y2="${y(1)}" stroke="${color}" stroke-width="2"/></svg>`; | ||
| } | ||
| const [x1, y1, x2, y2] = pts; | ||
| const d = `M ${x(0)} ${y(0)} C ${x(x1)} ${y(y1)}, ${x(x2)} ${y(y2)}, ${x(1)} ${y(1)}`; | ||
| return `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${grid}<path d="${d}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round"/></svg>`; | ||
| } | ||
| function beforeAfter(finding) { | ||
| if (!finding.current && !finding.suggested) | ||
| return ""; | ||
| // Easing findings render a visual curve comparison; everything else uses value chips. | ||
| if (finding.category === "easing" && finding.current && finding.suggested) { | ||
| return ` | ||
| <div class="ba"> | ||
| <div class="cell"><div class="cap">Current</div><div class="curve">${curveSvg(finding.current, "var(--critical)")}</div><div class="v">${escapeHtml(finding.current)}</div></div> | ||
| <div class="arrow">→</div> | ||
| <div class="cell after"><div class="cap">Suggested</div><div class="curve">${curveSvg(finding.suggested, "var(--good)")}</div><div class="v">${escapeHtml(finding.suggested)}</div></div> | ||
| </div>`; | ||
| } | ||
| return ` | ||
| <div class="ba"> | ||
| <div class="cell"><div class="cap">Current</div><div class="v">${escapeHtml(finding.current ?? "—")}</div></div> | ||
| <div class="arrow">→</div> | ||
| <div class="cell after"><div class="cap">Suggested</div><div class="v">${escapeHtml(finding.suggested ?? "—")}</div></div> | ||
| </div>`; | ||
| } | ||
| function renderFinding(finding, index) { | ||
| const delay = Math.min(index * 40, 320); | ||
| return ` | ||
| <article class="finding sev-${finding.severity}" style="animation-delay:${delay}ms"> | ||
| <div class="top"> | ||
| <span class="badge">${escapeHtml(finding.severity)}</span> | ||
| <span class="tag">${escapeHtml(CATEGORY_LABEL[finding.category])}</span> | ||
| <h3>${escapeHtml(finding.title)}</h3> | ||
| </div> | ||
| <p class="loc">🎯 ${escapeHtml(finding.common_name)} · <code>${escapeHtml(finding.selector)}</code></p> | ||
| <div class="field"><span class="lbl">What's happening</span>${escapeHtml(finding.detail)}</div> | ||
| <div class="field"><span class="lbl">Why it matters</span>${escapeHtml(finding.why)}</div> | ||
| <div class="field"><span class="lbl">Fix</span>${escapeHtml(finding.fix)}</div> | ||
| ${beforeAfter(finding)} | ||
| <div class="std"><b>Standard</b> — ${escapeHtml(finding.standard)}</div> | ||
| </article>`; | ||
| } | ||
| const AUDIT_CSS = ` | ||
| .ba .curve { display: grid; place-items: center; margin: 4px 0 6px; } | ||
| .ba .curve svg { border-radius: 8px; background: var(--bg-elev); } | ||
| .ba .cell { text-align: center; } | ||
| .ba .cell .v { text-align: center; } | ||
| `; | ||
| function headline(audit) { | ||
| if (audit.total_animations === 0) { | ||
| return { title: "No animations detected", blurb: "Nothing on the page opted into a transition, keyframe, or JS tween that MotionLint could measure." }; | ||
| } | ||
| if (audit.critical_count > 0) | ||
| return { title: "Motion needs work", blurb: "Critical deviations from the animation standards were found." }; | ||
| if (audit.warning_count > 0) | ||
| return { title: "A few motion issues", blurb: "No blockers, but some animations drift from the standards." }; | ||
| if (audit.suggestion_count > 0) | ||
| return { title: "Solid motion, minor polish", blurb: "Only nice-to-have refinements remain." }; | ||
| return { title: "Motion is on-standard", blurb: "Every measured animation matches Emil Kowalski's standards." }; | ||
| } | ||
| export function renderAnimationAuditHtml(audit) { | ||
| const h = headline(audit); | ||
| const summary = ` | ||
| <div class="summary"> | ||
| ${scoreRing(audit.score)} | ||
| <div class="headline"> | ||
| <h2>${escapeHtml(h.title)}</h2> | ||
| <p>${escapeHtml(h.blurb)} · ${audit.total_animations} animation${audit.total_animations === 1 ? "" : "s"} measured</p> | ||
| </div> | ||
| ${severityPills({ critical: audit.critical_count, warning: audit.warning_count, suggestion: audit.suggestion_count })} | ||
| </div>`; | ||
| let body; | ||
| if (audit.total_animations === 0) { | ||
| body = `${summary}<div class="empty"><div class="big">🕸️</div>No animations to audit.</div>`; | ||
| } | ||
| else if (audit.findings.length === 0) { | ||
| body = `${summary}<div class="empty"><div class="big">✨</div>All ${audit.total_animations} animations are on-standard. Nothing to fix.</div>`; | ||
| } | ||
| else { | ||
| body = `${summary} | ||
| <div class="section-h"><h2>Findings</h2><span class="count">${audit.findings.length} total</span></div> | ||
| ${audit.findings.map(renderFinding).join("\n")}`; | ||
| } | ||
| return htmlShell({ | ||
| title: "MotionLint Animation Audit", | ||
| subtitle: `<span class="mono">${escapeHtml(audit.url)}</span> · ${audit.viewport.width}×${audit.viewport.height} · ${escapeHtml(audit.captured_at)}`, | ||
| body, | ||
| extraCss: AUDIT_CSS, | ||
| }); | ||
| } |
| /** | ||
| * Deterministic animation linter. | ||
| * | ||
| * Runs the values harvested by the tuner (real transition/keyframe/GSAP durations, | ||
| * easing curves, transforms) against Emil Kowalski's animation standards — no vision | ||
| * model required. Every finding cites the exact standard it violates. | ||
| */ | ||
| import type { IssueSeverity } from "../types.js"; | ||
| import type { DetectedAnimation, TunerCapture } from "./types.js"; | ||
| export type AnimationFindingCategory = "easing" | "duration" | "physicality" | "performance" | "accessibility" | "cohesion"; | ||
| export interface AnimationFinding { | ||
| anim_id: string; | ||
| selector: string; | ||
| common_name: string; | ||
| category: AnimationFindingCategory; | ||
| severity: IssueSeverity; | ||
| title: string; | ||
| /** What is wrong (concrete). */ | ||
| detail: string; | ||
| /** User-impact, one sentence. */ | ||
| why: string; | ||
| /** Specific, actionable fix. */ | ||
| fix: string; | ||
| /** The Emil standard cited. */ | ||
| standard: string; | ||
| /** Current value (the "before"). */ | ||
| current?: string; | ||
| /** Suggested value (the "after"). */ | ||
| suggested?: string; | ||
| } | ||
| export interface AnimationAudit { | ||
| url: string; | ||
| captured_at: string; | ||
| viewport: { | ||
| width: number; | ||
| height: number; | ||
| }; | ||
| total_animations: number; | ||
| findings: AnimationFinding[]; | ||
| critical_count: number; | ||
| warning_count: number; | ||
| suggestion_count: number; | ||
| /** 0–100. 100 = every checked animation is clean. */ | ||
| score: number; | ||
| } | ||
| /** Lint a single detected animation against the standards. */ | ||
| export declare function lintAnimation(anim: DetectedAnimation): AnimationFinding[]; | ||
| /** Lint an entire tuner capture and produce a scored audit. */ | ||
| export declare function auditAnimations(capture: TunerCapture): AnimationAudit; |
| import { DURATION, EASING_CURVES, SCALE, STAGGER, isEaseIn, isWeakBuiltin } from "./standards.js"; | ||
| /** Layout-triggering properties — animating these forces layout + paint off the GPU. */ | ||
| const LAYOUT_PROPS = ["width", "height", "margin", "padding", "top", "left", "right", "bottom"]; | ||
| function rawParams(anim) { | ||
| const raw = (anim.raw ?? {}); | ||
| return (raw.params ?? {}); | ||
| } | ||
| function durationMs(anim) { | ||
| const p = (anim.params ?? []).find((x) => x.name === "duration"); | ||
| return p ? p.value : null; | ||
| } | ||
| function delayMs(anim) { | ||
| const p = (anim.params ?? []).find((x) => x.name === "delay"); | ||
| return p ? p.value : null; | ||
| } | ||
| function keyframesName(anim) { | ||
| const p = rawParams(anim); | ||
| return typeof p.name === "string" && p.name ? p.name : null; | ||
| } | ||
| /** | ||
| * Selector with sibling indices stripped — `li.item:nth-of-type(3)` and | ||
| * `li.item:nth-of-type(7)` normalize to the same string, so structural | ||
| * siblings compare equal while unrelated components stay distinct. | ||
| */ | ||
| function normalizedSelector(anim) { | ||
| return anim.selector.replace(/:nth-of-type\(\d+\)/g, ""); | ||
| } | ||
| /** True median: averages the two middle values for even-length inputs. */ | ||
| function medianOf(values) { | ||
| const sorted = [...values].sort((x, y) => x - y); | ||
| const mid = Math.floor(sorted.length / 2); | ||
| return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2); | ||
| } | ||
| function timingFunction(anim) { | ||
| const p = rawParams(anim); | ||
| const t = p.timing ?? p.easing; | ||
| return typeof t === "string" ? t : null; | ||
| } | ||
| /** Only CSS transitions/keyframes carry a `property`; JS libs animate whatever the tween sets. */ | ||
| function transitionProperty(anim) { | ||
| const p = rawParams(anim); | ||
| return typeof p.property === "string" ? p.property : null; | ||
| } | ||
| function iterationCount(anim) { | ||
| const p = rawParams(anim); | ||
| return typeof p.iteration === "string" ? p.iteration : null; | ||
| } | ||
| /** Heuristic: an animation whose element reads like an entrance / reveal / modal / toast. */ | ||
| function looksLikeEntrance(anim) { | ||
| const hay = `${anim.common_name} ${anim.selector} ${anim.technical_name}`.toLowerCase(); | ||
| return /(modal|dialog|drawer|sheet|toast|popover|dropdown|tooltip|menu|card|hero|reveal|fade|enter|appear|slide)/.test(hay); | ||
| } | ||
| function looksLikeModal(anim) { | ||
| const hay = `${anim.common_name} ${anim.selector}`.toLowerCase(); | ||
| return /(modal|dialog|drawer|sheet|overlay)/.test(hay); | ||
| } | ||
| function looksLikeHover(anim) { | ||
| const prop = (transitionProperty(anim) ?? "").toLowerCase(); | ||
| const hay = `${anim.common_name} ${anim.selector}`.toLowerCase(); | ||
| return /(hover|link|nav|button|btn|color|background)/.test(hay) || /color|background/.test(prop); | ||
| } | ||
| /** Pull the body of a specific `@keyframes <name> { … }` block out of a stylesheet. */ | ||
| function keyframesBlock(css, name) { | ||
| if (!name) | ||
| return null; | ||
| // Escape the name for use in a RegExp, then match its @keyframes block non-greedily. | ||
| const safe = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||
| const re = new RegExp(`@(?:-webkit-)?keyframes\\s+${safe}\\s*\\{([\\s\\S]*?)\\}\\s*\\}`, "i"); | ||
| const m = css.match(re); | ||
| return m ? m[1] : null; | ||
| } | ||
| // scale(0), scale(0.0), scale(.0), scale3d(0,…), scale: 0 — but never scale(0.95). | ||
| const SCALE_ZERO = /\bscale3?d?\(\s*(?:0(?:\.0+)?|\.0+)\s*[,)]/; | ||
| const SCALE_ZERO_PROP = /\bscale\s*:\s*(?:0(?:\.0+)?|\.0+)\b/; | ||
| /** | ||
| * Detect a genuine scale-from-0 entrance using only per-element signals — never a | ||
| * whole-stylesheet scan, which would attribute any page-wide `scale(0)` to this | ||
| * element. Reliable signals: a JS tween's own `scale: 0` var, or a `scale(0)` inside | ||
| * this animation's own named `@keyframes` block. | ||
| */ | ||
| function hasScaleFromZero(anim) { | ||
| const raw = rawParams(anim); | ||
| const vars = (raw.vars ?? raw); | ||
| if (vars && (vars.scale === 0 || vars.scale === "0")) | ||
| return true; | ||
| if (anim.source === "css-keyframes" && typeof raw.name === "string") { | ||
| const block = keyframesBlock(anim.preview_css ?? "", raw.name); | ||
| if (block && (SCALE_ZERO.test(block) || SCALE_ZERO_PROP.test(block))) | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
| function severityRank(s) { | ||
| return s === "critical" ? 0 : s === "warning" ? 1 : 2; | ||
| } | ||
| /** Lint a single detected animation against the standards. */ | ||
| export function lintAnimation(anim) { | ||
| const findings = []; | ||
| const base = { anim_id: anim.id, selector: anim.selector, common_name: anim.common_name }; | ||
| const timing = timingFunction(anim); | ||
| const dur = durationMs(anim); | ||
| const prop = transitionProperty(anim); | ||
| const isEntrance = looksLikeEntrance(anim); | ||
| // — Easing: ease-in on UI is always a finding — | ||
| if (timing && isEaseIn(timing)) { | ||
| findings.push({ | ||
| ...base, | ||
| category: "easing", | ||
| severity: "warning", | ||
| title: "ease-in on UI motion", | ||
| detail: `Uses \`${timing}\`, which starts slow and delays the exact moment the user is watching.`, | ||
| why: "ease-out at the same duration feels faster because it responds immediately; ease-in feels laggy.", | ||
| fix: `Switch to a strong ease-out: \`${EASING_CURVES.easeOut}\`.`, | ||
| standard: "Never `ease-in` on UI. Entering/exiting → ease-out.", | ||
| current: timing, | ||
| suggested: EASING_CURVES.easeOut, | ||
| }); | ||
| } | ||
| else if (timing && isEntrance && isWeakBuiltin(timing)) { | ||
| // — Easing: weak built-in curve on a deliberate entrance — | ||
| const role = looksLikeHover(anim) ? "hover" : "entrance"; | ||
| if (role !== "hover") { | ||
| findings.push({ | ||
| ...base, | ||
| category: "easing", | ||
| severity: "suggestion", | ||
| title: "Weak built-in easing on an entrance", | ||
| detail: `\`${timing}\` is too soft for a deliberate ${role}. Built-in CSS easings barely accelerate.`, | ||
| why: "A stronger curve reads as intentional and responsive rather than generic.", | ||
| fix: `Use a strong ease-out token: \`${EASING_CURVES.easeOut}\`.`, | ||
| standard: "Built-in CSS easings are too weak — use strong custom curves.", | ||
| current: timing, | ||
| suggested: EASING_CURVES.easeOut, | ||
| }); | ||
| } | ||
| } | ||
| // — Duration: UI animation over 300ms — | ||
| if (dur !== null && dur > DURATION.uiMaxMs) { | ||
| const modal = looksLikeModal(anim); | ||
| // Modals/drawers get the 200–500ms band; everything else is capped at 300ms. | ||
| if (!(modal && dur <= DURATION.modalMs[1])) { | ||
| findings.push({ | ||
| ...base, | ||
| category: "duration", | ||
| severity: dur > 600 ? "warning" : "suggestion", | ||
| title: `Duration ${dur}ms exceeds the UI budget`, | ||
| detail: `${dur}ms is over the ${DURATION.uiMaxMs}ms ceiling for UI motion${modal ? " (even the modal/drawer band tops out at 500ms)" : ""}.`, | ||
| why: "Longer animations make the interface feel sluggish; a 180ms transition feels snappier than a 400ms one.", | ||
| fix: modal | ||
| ? `Bring it into the 200–500ms modal/drawer band — try ${DURATION.modalMs[0]}–300ms.` | ||
| : `Reduce to ≤ ${DURATION.uiMaxMs}ms (150–250ms is the sweet spot for most UI).`, | ||
| standard: "UI animations stay under 300ms (modals/drawers: 200–500ms).", | ||
| current: `${dur}ms`, | ||
| suggested: modal ? "≤ 500ms" : "≤ 300ms", | ||
| }); | ||
| } | ||
| } | ||
| // — Physicality: scale(0) entrance — | ||
| if (hasScaleFromZero(anim)) { | ||
| findings.push({ | ||
| ...base, | ||
| category: "physicality", | ||
| severity: "warning", | ||
| title: "Entrance scales from 0", | ||
| detail: "The element grows from `scale(0)` — nothing in the real world appears from nothing.", | ||
| why: "Starting from zero looks unnatural and draws attention to the mechanic instead of the content.", | ||
| fix: `Start from \`scale(${SCALE.recommendedEntranceScale})\` + \`opacity: 0\` instead.`, | ||
| standard: `Never scale(0). Start from ${SCALE.minEntranceScale}–0.97.`, | ||
| current: "scale(0)", | ||
| suggested: `scale(${SCALE.recommendedEntranceScale})`, | ||
| }); | ||
| } | ||
| // — Performance: transition-property "all" — | ||
| if (prop) { | ||
| const props = prop.split(",").map((p) => p.trim().toLowerCase()); | ||
| if (props.includes("all")) { | ||
| findings.push({ | ||
| ...base, | ||
| category: "performance", | ||
| severity: "suggestion", | ||
| title: "transition: all", | ||
| detail: "`transition-property: all` animates every property that changes, including layout properties off the GPU.", | ||
| why: "Unintended properties animate on the main thread and can drop frames.", | ||
| fix: "Name the exact properties — ideally only `transform` and `opacity`.", | ||
| standard: "Animate transform & opacity only; `transition: all` is always a finding.", | ||
| current: prop, | ||
| suggested: "transform, opacity", | ||
| }); | ||
| } | ||
| const layoutHit = props.filter((p) => LAYOUT_PROPS.includes(p)); | ||
| if (layoutHit.length > 0) { | ||
| findings.push({ | ||
| ...base, | ||
| category: "performance", | ||
| severity: "warning", | ||
| title: `Animating layout propert${layoutHit.length === 1 ? "y" : "ies"}: ${layoutHit.join(", ")}`, | ||
| detail: `Animating ${layoutHit.join(", ")} triggers layout + paint + composite on every frame.`, | ||
| why: "Layout-driven animation runs off the GPU and stutters under load.", | ||
| fix: "Re-express the motion with `transform` (translate/scale) and `opacity`.", | ||
| standard: "Only animate transform and opacity — they skip layout/paint.", | ||
| current: layoutHit.join(", "), | ||
| suggested: "transform / opacity", | ||
| }); | ||
| } | ||
| } | ||
| // — Purpose/frequency: infinite loop on a non-decorative element — | ||
| const iter = iterationCount(anim); | ||
| if (iter === "infinite") { | ||
| const decorative = /(spinner|loader|loading|pulse|marquee|ticker|progress|skeleton|shimmer)/.test(`${anim.common_name} ${anim.selector} ${anim.technical_name}`.toLowerCase()); | ||
| if (!decorative) { | ||
| findings.push({ | ||
| ...base, | ||
| category: "performance", | ||
| severity: "suggestion", | ||
| title: "Infinite animation on a non-loader element", | ||
| detail: "An `animation-iteration-count: infinite` loop that isn't a spinner/loader/marquee keeps the compositor awake.", | ||
| why: "Constant motion is distracting on frequently-seen UI and wastes battery / GPU.", | ||
| fix: "Reserve infinite loops for genuine loading indicators; otherwise play once.", | ||
| standard: "Motion needs a purpose — 'it looks cool' on a constant element is not one.", | ||
| current: "infinite", | ||
| suggested: "1 (or a real loading state)", | ||
| }); | ||
| } | ||
| } | ||
| return findings; | ||
| } | ||
| /** Cross-animation cohesion checks (duplicated near-identical curves/durations). */ | ||
| function cohesionFindings(anims) { | ||
| const findings = []; | ||
| const curves = new Set(); | ||
| for (const a of anims) { | ||
| const t = timingFunction(a); | ||
| if (t && t.startsWith("cubic-bezier")) | ||
| curves.add(t.replace(/\s+/g, "")); | ||
| } | ||
| if (curves.size >= 4) { | ||
| findings.push({ | ||
| anim_id: "*", | ||
| selector: "(project-wide)", | ||
| common_name: "Easing tokens", | ||
| category: "cohesion", | ||
| severity: "suggestion", | ||
| title: `${curves.size} distinct hand-rolled easing curves`, | ||
| detail: `The page uses ${curves.size} different cubic-bezier curves. A handful of almost-matching curves is a consolidation smell.`, | ||
| why: "Inconsistent easing makes motion feel incoherent across components.", | ||
| fix: `Consolidate onto shared tokens — e.g. \`--ease-out: ${EASING_CURVES.easeOut}\` and \`--ease-in-out: ${EASING_CURVES.easeInOut}\`.`, | ||
| standard: "Curves and durations should live as shared tokens.", | ||
| current: `${curves.size} curves`, | ||
| suggested: "2–3 shared tokens", | ||
| }); | ||
| } | ||
| return findings; | ||
| } | ||
| /** | ||
| * Stagger choreography check: grouped entrances whose delay intervals fall | ||
| * outside Emil's 30-80ms band. Groups are animations sharing a source and | ||
| * signature (keyframes name, or transition property + duration) with distinct | ||
| * delays — the shape a staggered list renders as. | ||
| */ | ||
| function staggerFindings(anims) { | ||
| const findings = []; | ||
| const groups = new Map(); | ||
| for (const a of anims) { | ||
| if (delayMs(a) === null) | ||
| continue; | ||
| const signature = keyframesName(a) ?? `${transitionProperty(a) ?? "?"}|${durationMs(a) ?? "?"}`; | ||
| // The normalized selector keeps the group honest: only structural siblings | ||
| // (same path modulo :nth-of-type) count as one staggered list — a toast, a | ||
| // modal and a badge reusing the same `fadeIn` keyframes never group. | ||
| const key = `${a.source}|${signature}|${normalizedSelector(a)}`; | ||
| groups.set(key, [...(groups.get(key) ?? []), a]); | ||
| } | ||
| for (const members of groups.values()) { | ||
| if (members.length < 3) | ||
| continue; | ||
| const delays = [...new Set(members.map((m) => delayMs(m)))].sort((x, y) => x - y); | ||
| if (delays.length < 2) | ||
| continue; | ||
| const intervals = delays.slice(1).map((d, i) => d - delays[i]); | ||
| const median = medianOf(intervals); | ||
| if (median >= STAGGER.minMs && median <= STAGGER.maxMs) | ||
| continue; | ||
| const first = members[0]; | ||
| const tooTight = median < STAGGER.minMs; | ||
| findings.push({ | ||
| anim_id: first.id, | ||
| selector: `${members.length} elements`, | ||
| common_name: first.common_name, | ||
| category: "cohesion", | ||
| severity: "suggestion", | ||
| title: tooTight ? "Stagger interval too tight" : "Stagger interval too slow", | ||
| detail: `${members.length} grouped entrances stagger at ~${median}ms intervals${tooTight ? " — they read as one simultaneous blob" : " — the tail of the group feels like it lags"}.`, | ||
| why: tooTight | ||
| ? "Below ~30ms the eye can't separate the items, so the stagger adds delay without adding rhythm." | ||
| : "Above ~80ms the later items feel disconnected from the first, and the page feels slower than it is.", | ||
| fix: `Space the delays ${STAGGER.minMs}-${STAGGER.maxMs}ms apart.`, | ||
| standard: `Stagger grouped entrances ${STAGGER.minMs}-${STAGGER.maxMs}ms apart.`, | ||
| current: `~${median}ms interval`, | ||
| suggested: `${STAGGER.minMs}-${STAGGER.maxMs}ms`, | ||
| }); | ||
| } | ||
| return findings; | ||
| } | ||
| /** Split an animation name into lowercase tokens across camelCase/kebab/snake boundaries. */ | ||
| function nameTokens(name) { | ||
| return name | ||
| .replace(/([a-z0-9])([A-Z])/g, "$1 $2") | ||
| .split(/[^a-zA-Z0-9]+/) | ||
| .map((t) => t.toLowerCase()) | ||
| .filter(Boolean); | ||
| } | ||
| const EXIT_TOKENS = new Set(["out", "exit", "leave", "close", "hide", "dismiss"]); | ||
| const ENTER_TOKENS = new Set(["in", "enter", "open", "show", "appear", "reveal", "entrance"]); | ||
| /** | ||
| * Exit-speed check: exits should run ~20% faster than their entrance. Pairs are | ||
| * matched conservatively by keyframes name — `fadeIn`/`fadeOut`, `slide-in`/ | ||
| * `slide-out` — so a lone name never produces a finding. | ||
| */ | ||
| function exitSpeedFindings(anims) { | ||
| const findings = []; | ||
| const pairs = new Map(); | ||
| for (const a of anims) { | ||
| const name = keyframesName(a); | ||
| const dur = durationMs(a); | ||
| if (!name || dur === null) | ||
| continue; | ||
| const tokens = nameTokens(name); | ||
| const role = tokens.some((t) => EXIT_TOKENS.has(t)) ? "exit" : tokens.some((t) => ENTER_TOKENS.has(t)) ? "enter" : null; | ||
| if (!role) | ||
| continue; | ||
| const base = tokens.filter((t) => !EXIT_TOKENS.has(t) && !ENTER_TOKENS.has(t)).join("-"); | ||
| if (!base) | ||
| continue; | ||
| // Pair only within one element (same normalized selector) so two unrelated | ||
| // components sharing generic fadeIn/fadeOut names never get cross-paired. | ||
| const key = `${base}|${normalizedSelector(a)}`; | ||
| const entry = pairs.get(key) ?? {}; | ||
| entry[role] ??= { anim: a, name, dur }; | ||
| pairs.set(key, entry); | ||
| } | ||
| for (const { enter, exit } of pairs.values()) { | ||
| if (!enter || !exit) | ||
| continue; | ||
| const target = Math.round(enter.dur * (1 - DURATION.exitSpeedup)); | ||
| if (exit.dur <= target) | ||
| continue; | ||
| findings.push({ | ||
| anim_id: exit.anim.id, | ||
| selector: exit.anim.selector, | ||
| common_name: exit.anim.common_name, | ||
| category: "duration", | ||
| severity: "suggestion", | ||
| title: "Exit isn't faster than its entrance", | ||
| detail: `\`${exit.name}\` runs ${exit.dur}ms while its entrance \`${enter.name}\` runs ${enter.dur}ms.`, | ||
| why: "Leaving is a decided action — a slow exit makes dismissal feel like it's resisting the user.", | ||
| fix: `Run exits ~${Math.round(DURATION.exitSpeedup * 100)}% faster than their entrance — try ≤ ${target}ms.`, | ||
| standard: `Exits run ~${Math.round(DURATION.exitSpeedup * 100)}% faster than the matching entrance.`, | ||
| current: `${exit.dur}ms`, | ||
| suggested: `≤ ${target}ms`, | ||
| }); | ||
| } | ||
| return findings; | ||
| } | ||
| /** | ||
| * Page-level accessibility checks (Emil Standard 8). These read the captured | ||
| * stylesheet text, so they only fire when we actually harvested CSS — with no | ||
| * stylesheet in hand we have no evidence a rule is *absent*, and stay silent | ||
| * rather than raise a false positive. | ||
| */ | ||
| function accessibilityFindings(capture) { | ||
| const findings = []; | ||
| const css = capture.animations.find((a) => a.preview_css && a.preview_css.trim())?.preview_css ?? ""; | ||
| if (!css) | ||
| return findings; | ||
| // — Reduced motion: movement-based animation with no reduced-motion escape hatch — | ||
| const hasMovement = capture.animations.some((a) => { | ||
| if (a.source === "css-keyframes") | ||
| return true; | ||
| const prop = (transitionProperty(a) ?? "").toLowerCase(); | ||
| return /\b(transform|all)\b/.test(prop); | ||
| }); | ||
| if (hasMovement && !/prefers-reduced-motion/i.test(css)) { | ||
| findings.push({ | ||
| anim_id: "*", | ||
| selector: "(project-wide)", | ||
| common_name: "Page stylesheet", | ||
| category: "accessibility", | ||
| severity: "warning", | ||
| title: "No prefers-reduced-motion path", | ||
| detail: "The page animates movement (transform / keyframes) but ships no `@media (prefers-reduced-motion: reduce)` rule.", | ||
| why: "Motion-sensitive users get no relief; large movement can trigger nausea or vestibular symptoms.", | ||
| fix: "Add `@media (prefers-reduced-motion: reduce)` that drops transform-based motion while keeping opacity/colour transitions.", | ||
| standard: "Honour prefers-reduced-motion — gentler, not zero.", | ||
| current: "(no reduced-motion rule)", | ||
| suggested: "@media (prefers-reduced-motion: reduce) { … }", | ||
| }); | ||
| } | ||
| // — Hover gating: hover motion that isn't behind (hover: hover) fires on touch taps — | ||
| const hasHover = capture.animations.some(looksLikeHover); | ||
| if (hasHover && !/@media[^{]*hover\s*:\s*hover/i.test(css)) { | ||
| findings.push({ | ||
| anim_id: "*", | ||
| selector: "(project-wide)", | ||
| common_name: "Hover motion", | ||
| category: "accessibility", | ||
| severity: "suggestion", | ||
| title: "Hover motion isn't gated for touch", | ||
| detail: "Hover-triggered motion isn't wrapped in `@media (hover: hover) and (pointer: fine)`.", | ||
| why: "Touch devices fire a synthetic hover on tap, so the animation plays on every touch — a false positive the user never intended.", | ||
| fix: "Gate hover animations behind `@media (hover: hover) and (pointer: fine)`.", | ||
| standard: "Gate hover motion behind (hover: hover) and (pointer: fine).", | ||
| current: "(ungated :hover)", | ||
| suggested: "@media (hover: hover) and (pointer: fine) { … }", | ||
| }); | ||
| } | ||
| return findings; | ||
| } | ||
| /** Lint an entire tuner capture and produce a scored audit. */ | ||
| export function auditAnimations(capture) { | ||
| const perAnim = capture.animations.flatMap(lintAnimation); | ||
| const findings = [ | ||
| ...perAnim, | ||
| ...cohesionFindings(capture.animations), | ||
| ...staggerFindings(capture.animations), | ||
| ...exitSpeedFindings(capture.animations), | ||
| ...accessibilityFindings(capture), | ||
| ].sort((a, b) => severityRank(a.severity) - severityRank(b.severity)); | ||
| const critical_count = findings.filter((f) => f.severity === "critical").length; | ||
| const warning_count = findings.filter((f) => f.severity === "warning").length; | ||
| const suggestion_count = findings.filter((f) => f.severity === "suggestion").length; | ||
| // Score: start at 100, subtract weighted penalties, floor at 0. | ||
| const penalty = critical_count * 25 + warning_count * 10 + suggestion_count * 3; | ||
| const score = Math.max(0, 100 - penalty); | ||
| return { | ||
| url: capture.url, | ||
| captured_at: capture.captured_at, | ||
| viewport: capture.viewport, | ||
| total_animations: capture.animations.length, | ||
| findings, | ||
| critical_count, | ||
| warning_count, | ||
| suggestion_count, | ||
| score, | ||
| }; | ||
| } |
| /** | ||
| * Emil Kowalski's animation standards, encoded as machine-checkable constants. | ||
| * | ||
| * Distilled from https://emilkowal.ski/ and the review-animations / improve-animations | ||
| * skills. These are the exact values the linter cites in findings — never approximate | ||
| * one; copy it from here so every finding points at the same source of truth. | ||
| */ | ||
| /** Strong custom easing curves. Built-in CSS easings are too weak for deliberate UI motion. */ | ||
| export declare const EASING_CURVES: { | ||
| /** Strong ease-out for UI — entrances/exits. Starts fast, feels responsive. */ | ||
| readonly easeOut: "cubic-bezier(0.23, 1, 0.32, 1)"; | ||
| /** Strong ease-in-out for on-screen movement / morphing. */ | ||
| readonly easeInOut: "cubic-bezier(0.77, 0, 0.175, 1)"; | ||
| /** iOS-like drawer curve (Ionic). */ | ||
| readonly drawer: "cubic-bezier(0.32, 0.72, 0, 1)"; | ||
| }; | ||
| /** | ||
| * Duration budgets in milliseconds. UI animations stay under 300ms — a 180ms dropdown | ||
| * feels more responsive than a 400ms one. Exits run ~20% faster than their entrance. | ||
| */ | ||
| export declare const DURATION: { | ||
| /** Hard ceiling for any UI animation. Above this is always a finding. */ | ||
| readonly uiMaxMs: 300; | ||
| /** Button / press feedback. */ | ||
| readonly pressMs: readonly [100, 160]; | ||
| /** Tooltips, small popovers. */ | ||
| readonly tooltipMs: readonly [125, 200]; | ||
| /** Dropdowns, selects. */ | ||
| readonly dropdownMs: readonly [150, 250]; | ||
| /** Modals, drawers — the one UI family allowed past 300ms. */ | ||
| readonly modalMs: readonly [200, 500]; | ||
| /** Exits should be this fraction faster than the matching entrance. */ | ||
| readonly exitSpeedup: 0.2; | ||
| }; | ||
| /** Physicality: nothing in the real world appears from nothing. */ | ||
| export declare const SCALE: { | ||
| /** Never scale below this on an entrance. `scale(0)` is always a finding. */ | ||
| readonly minEntranceScale: 0.9; | ||
| /** Recommended entrance-scale floor (0.9–0.97 is the healthy band). */ | ||
| readonly recommendedEntranceScale: 0.95; | ||
| /** Press-feedback scale (subtle, 0.95–0.98). */ | ||
| readonly pressScale: 0.97; | ||
| }; | ||
| /** Stagger between grouped item entrances. Longer than this feels slow. */ | ||
| export declare const STAGGER: { | ||
| readonly minMs: 30; | ||
| readonly maxMs: 80; | ||
| }; | ||
| /** | ||
| * Easing decision order — what curve a given motion role should use. | ||
| * Mirrors the review-animations STANDARDS "Easing" section. | ||
| */ | ||
| export declare const EASING_RULES: { | ||
| readonly entering: "ease-out"; | ||
| readonly exiting: "ease-out"; | ||
| readonly moving: "ease-in-out"; | ||
| readonly hover: "ease"; | ||
| readonly constant: "linear"; | ||
| readonly default: "ease-out"; | ||
| }; | ||
| /** | ||
| * The tuner's easing presets. The Emil curves lead; the softer/decorative options | ||
| * follow. Consumed by both the tuner UI and the linter's suggested-fix text. | ||
| */ | ||
| export interface EasingPreset { | ||
| name: string; | ||
| value: string; | ||
| description: string; | ||
| /** Emil-endorsed curve for standard UI motion. */ | ||
| recommended?: boolean; | ||
| } | ||
| export declare const EMIL_EASING_PRESETS: EasingPreset[]; | ||
| /** Identify a CSS/JS easing keyword as `ease-in` (banned on UI). */ | ||
| export declare function isEaseIn(timing: string): boolean; | ||
| /** A "weak" built-in easing that Emil flags as too soft for deliberate entrance/exit motion. */ | ||
| export declare function isWeakBuiltin(timing: string): boolean; |
| /** | ||
| * Emil Kowalski's animation standards, encoded as machine-checkable constants. | ||
| * | ||
| * Distilled from https://emilkowal.ski/ and the review-animations / improve-animations | ||
| * skills. These are the exact values the linter cites in findings — never approximate | ||
| * one; copy it from here so every finding points at the same source of truth. | ||
| */ | ||
| /** Strong custom easing curves. Built-in CSS easings are too weak for deliberate UI motion. */ | ||
| export const EASING_CURVES = { | ||
| /** Strong ease-out for UI — entrances/exits. Starts fast, feels responsive. */ | ||
| easeOut: "cubic-bezier(0.23, 1, 0.32, 1)", | ||
| /** Strong ease-in-out for on-screen movement / morphing. */ | ||
| easeInOut: "cubic-bezier(0.77, 0, 0.175, 1)", | ||
| /** iOS-like drawer curve (Ionic). */ | ||
| drawer: "cubic-bezier(0.32, 0.72, 0, 1)", | ||
| }; | ||
| /** | ||
| * Duration budgets in milliseconds. UI animations stay under 300ms — a 180ms dropdown | ||
| * feels more responsive than a 400ms one. Exits run ~20% faster than their entrance. | ||
| */ | ||
| export const DURATION = { | ||
| /** Hard ceiling for any UI animation. Above this is always a finding. */ | ||
| uiMaxMs: 300, | ||
| /** Button / press feedback. */ | ||
| pressMs: [100, 160], | ||
| /** Tooltips, small popovers. */ | ||
| tooltipMs: [125, 200], | ||
| /** Dropdowns, selects. */ | ||
| dropdownMs: [150, 250], | ||
| /** Modals, drawers — the one UI family allowed past 300ms. */ | ||
| modalMs: [200, 500], | ||
| /** Exits should be this fraction faster than the matching entrance. */ | ||
| exitSpeedup: 0.2, | ||
| }; | ||
| /** Physicality: nothing in the real world appears from nothing. */ | ||
| export const SCALE = { | ||
| /** Never scale below this on an entrance. `scale(0)` is always a finding. */ | ||
| minEntranceScale: 0.9, | ||
| /** Recommended entrance-scale floor (0.9–0.97 is the healthy band). */ | ||
| recommendedEntranceScale: 0.95, | ||
| /** Press-feedback scale (subtle, 0.95–0.98). */ | ||
| pressScale: 0.97, | ||
| }; | ||
| /** Stagger between grouped item entrances. Longer than this feels slow. */ | ||
| export const STAGGER = { minMs: 30, maxMs: 80 }; | ||
| /** | ||
| * Easing decision order — what curve a given motion role should use. | ||
| * Mirrors the review-animations STANDARDS "Easing" section. | ||
| */ | ||
| export const EASING_RULES = { | ||
| entering: "ease-out", | ||
| exiting: "ease-out", | ||
| moving: "ease-in-out", | ||
| hover: "ease", | ||
| constant: "linear", | ||
| default: "ease-out", | ||
| }; | ||
| export const EMIL_EASING_PRESETS = [ | ||
| { name: "ease-out (Emil)", value: EASING_CURVES.easeOut, description: "Strong ease-out for UI — entrances & exits. The default.", recommended: true }, | ||
| { name: "ease-in-out (Emil)", value: EASING_CURVES.easeInOut, description: "Strong ease-in-out for on-screen movement / morphing.", recommended: true }, | ||
| { name: "drawer (iOS)", value: EASING_CURVES.drawer, description: "iOS-like drawer curve — bottom sheets, side panels." }, | ||
| { name: "spring (snappy)", value: "cubic-bezier(.34,1.56,.64,1)", description: "Slight overshoot — playful entrances." }, | ||
| { name: "spring (bouncy)", value: "cubic-bezier(.68,-.55,.27,1.55)", description: "Pronounced bounce — drag-to-dismiss only." }, | ||
| { name: "ease", value: "ease", description: "Browser default — acceptable for hover / colour only." }, | ||
| { name: "linear", value: "linear", description: "Constant speed — marquees, progress, spinners." }, | ||
| ]; | ||
| /** Identify a CSS/JS easing keyword as `ease-in` (banned on UI). */ | ||
| export function isEaseIn(timing) { | ||
| const t = timing.trim().toLowerCase(); | ||
| if (t === "ease-in") | ||
| return true; | ||
| // A true ease-in accelerates the whole way: it starts flat (x1 leads, y1 ≈ 0) AND | ||
| // keeps accelerating into the finish (x2 near 1). An ease-in-out also starts flat | ||
| // but *decelerates* at the end (small x2), so we must read the second control point | ||
| // too — otherwise strong ease-in-out curves (e.g. cubic-bezier(0.77,0,0.175,1)) get | ||
| // misflagged as ease-in. | ||
| const m = t.match(/cubic-bezier\(\s*([\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*([\d.]+)\s*,\s*(-?[\d.]+)\s*\)/); | ||
| if (m) { | ||
| const x1 = Number(m[1]); | ||
| const y1 = Number(m[2]); | ||
| const x2 = Number(m[3]); | ||
| if (x1 >= 0.3 && y1 <= 0.05 && x2 >= 0.6) | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
| /** A "weak" built-in easing that Emil flags as too soft for deliberate entrance/exit motion. */ | ||
| export function isWeakBuiltin(timing) { | ||
| const t = timing.trim().toLowerCase(); | ||
| return t === "ease" || t === "ease-out" || t === "linear" || t === "cubic-bezier(0.25, 0.1, 0.25, 1)"; | ||
| } |
@@ -6,3 +6,3 @@ { | ||
| "fallbackProvider": "anthropic", | ||
| "fallbackModel": "claude-sonnet-4-20250514", | ||
| "fallbackModel": "claude-sonnet-5", | ||
| "viewports": { | ||
@@ -9,0 +9,0 @@ "mobile": { "width": 375, "height": 812 }, |
@@ -0,1 +1,2 @@ | ||
| import { ANIMATION_STANDARDS_PROMPT } from "./animation_standards.js"; | ||
| const FLOW_SYSTEM_PROMPT = `You are a senior UX designer and frontend engineer reviewing a USER FLOW captured as a contact sheet of frames. | ||
@@ -46,3 +47,3 @@ | ||
| export function buildFlowPrompt(opts) { | ||
| const lines = [FLOW_SYSTEM_PROMPT]; | ||
| const lines = [FLOW_SYSTEM_PROMPT, "", ANIMATION_STANDARDS_PROMPT]; | ||
| if (opts.preferences_md && opts.preferences_md.trim()) { | ||
@@ -49,0 +50,0 @@ lines.push(""); |
@@ -34,2 +34,4 @@ const VALID_CATEGORIES = new Set([ | ||
| return null; | ||
| const rawRef = String(r.element_ref ?? "").trim().toUpperCase(); | ||
| const element_ref = /^E\d{1,3}$/.test(rawRef) ? rawRef : undefined; | ||
| return { | ||
@@ -42,2 +44,3 @@ category, | ||
| fix: String(r.fix ?? "").trim(), | ||
| ...(element_ref ? { element_ref } : {}), | ||
| }; | ||
@@ -44,0 +47,0 @@ } |
@@ -16,3 +16,22 @@ export declare const DEFAULT_SYSTEM_PROMPT = "You are a senior UX designer and frontend engineer reviewing a screenshot of a web application.\n\nYour job is to identify UI/UX issues comprehensively. Treat this as a structured rubric, NOT a free-form review.\n\n## Procedure (you must do these in order)\n\n### Step 1 \u2014 Description (private; do not include in your final response)\nInternally describe what you see: page type, dominant elements, layout columns, text density, primary calls-to-action, color palette, viewport hints.\n\n### Step 2 \u2014 Walk the rubric\nEvaluate EVERY one of the twelve dimensions below. For each, decide whether it is \"ok\" or has at least one finding. You must produce at least one observation per dimension (either a concrete issue or an explicit \"no finding\"). Internal note only \u2014 your final output will only include the issues, but you must mentally check all twelve before producing the final list.\n\n1. hierarchy \u2014 heading scale, CTA dominance, eye-flow.\n2. spacing \u2014 whitespace consistency, Gestalt proximity, breathing room, padding/margin rhythm.\n3. alignment \u2014 column alignment, baseline alignment, asymmetric edges.\n4. typography \u2014 body size \u2265 16px / line-height \u2265 1.4, \u2264 4 type sizes, line length 45\u201375ch.\n5. color \u2014 palette cohesion, meaningful color use, brand consistency.\n6. contrast \u2014 WCAG AA (4.5:1 normal text, 3:1 large/icon), interactive vs. static distinction.\n7. responsiveness \u2014 overflow, mobile tap targets \u2265 48\u00D748 (Material) / 44\u00D744 (HIG), navigation accessibility.\n8. interaction \u2014 visible affordances, hover/focus/disabled states, destructive vs. safe action distinction.\n9. content \u2014 clarity within 5s, label specificity (verb-object), microcopy, jargon, empty states.\n10. navigation \u2014 discoverability, active-state visibility, escape hatches (Cancel, X, Back).\n11. consistency \u2014 design-system uniformity, identical actions look identical, corner-radius / button language.\n12. loading_state \u2014 skeletons, progress indicators, optimistic feedback, \"nothing happens\" anti-patterns.\n\n### Step 3 \u2014 Produce the output\n\nRespond ONLY with valid JSON. Do not include markdown fences, do not include the rubric checklist itself \u2014 only the issues array, summary, strengths, and viewport.\n\nFor each issue:\n- \"category\": one of [hierarchy, spacing, alignment, typography, color, contrast, responsiveness, interaction, content, navigation, consistency, loading_state]\n- \"severity\": \"critical\" | \"warning\" | \"suggestion\"\n - **critical** = blocks task completion or fails WCAG / known a11y standard\n - **warning** = degrades usability or perceived quality measurably\n - **suggestion** = polish / nice-to-have\n- \"location\": where on the screen (e.g., \"above-the-fold hero CTA\", \"footer link grid\")\n- \"issue\": what is wrong (one sentence, concrete)\n- \"why_it_matters\": user-impact in one sentence\n- \"fix\": specific, actionable recommendation. Quote concrete numbers where applicable (e.g., \"increase to 16px / 1.5 line-height\", \"raise contrast to 4.5:1\").\n\n## Anti-patterns (DO NOT DO)\n\n- Do NOT pad. If the page is well-designed, return a SHORT issues array (or empty). Inflating the list on a clean page is a confabulation failure.\n- Do NOT repeat the same issue under multiple categories. Pick the best-fitting category.\n- Do NOT use vague language like \"improve the design\" \u2014 every issue must be measurable or visually verifiable.\n\n## Response shape (strict)\n\n{\n \"overall_score\": <integer 1-10>,\n \"summary\": \"<2-3 sentence overall assessment>\",\n \"issues\": [\n { \"category\": \"...\", \"severity\": \"...\", \"location\": \"...\", \"issue\": \"...\", \"why_it_matters\": \"...\", \"fix\": \"...\" }\n ],\n \"strengths\": [\"<things done well>\"],\n \"viewport\": \"<the viewport this was captured at>\"\n}"; | ||
| }; | ||
| /** Notable page elements with stable refs — lets the model ground findings via "element_ref". */ | ||
| elements?: Array<{ | ||
| ref: string; | ||
| selector: string; | ||
| label: string; | ||
| rect: { | ||
| x: number; | ||
| y: number; | ||
| w: number; | ||
| h: number; | ||
| }; | ||
| }>; | ||
| /** Interaction-state grid mode — the image is a grid of element states, not a page. */ | ||
| stateGrid?: { | ||
| states: readonly string[]; | ||
| elements: string[]; | ||
| }; | ||
| /** Learned heuristics distilled from eval misses (bullet lines), carried into the rubric. */ | ||
| learned?: string | null; | ||
| } | ||
| export declare function buildPrompt(opts: PromptOptions): Promise<string>; |
@@ -85,4 +85,24 @@ import { readFile } from "node:fs/promises"; | ||
| } | ||
| if (opts.learned) { | ||
| parts.push(`\n\n## Learned heuristics (distilled from prior eval runs)\n` + | ||
| `Past evaluations show these issue patterns are easy to miss on this rubric. Check each one deliberately:\n` + | ||
| opts.learned.trim()); | ||
| } | ||
| if (opts.stateGrid) { | ||
| parts.push(`\n\n## Interaction-state grid mode\n` + | ||
| `This image is NOT a page screenshot. It is a grid: each row is one interactive element (${opts.stateGrid.elements.map((e) => `"${e}"`).join(", ")}), ` + | ||
| `and the columns show that element in its ${opts.stateGrid.states.join(" / ")} states, captured live.\n` + | ||
| `Judge ONLY interaction affordances by comparing columns within each row: does hover give visible feedback, is the focus ring clearly visible (WCAG 2.4.7), does active/pressed state respond, are the states distinguishable from each other and from default? ` + | ||
| `Report findings under the "interaction" category (or "contrast" for low-visibility focus indicators), naming the element row in the location field. ` + | ||
| `Ignore layout/typography/spacing dimensions — the grid's own chrome is not the subject.`); | ||
| } | ||
| if (opts.elements?.length) { | ||
| const lines = opts.elements.map((e) => `${e.ref} — <${e.selector}> "${e.label}" at (${e.rect.x}, ${e.rect.y}) ${e.rect.w}×${e.rect.h}px`); | ||
| parts.push(`\n\n## Interactive elements (stable refs)\n` + | ||
| `These elements were measured on the live page (document coordinates, CSS px):\n` + | ||
| lines.join("\n") + | ||
| `\n\nWhen an issue concerns one of these elements, add "element_ref": "<ref>" (e.g. "element_ref": "E3") to that issue object so the finding can be drawn on the screenshot. Omit it when no listed element fits.`); | ||
| } | ||
| parts.push(`\n\nThis screenshot was captured at the "${opts.viewportName}" viewport.`); | ||
| return parts.join(""); | ||
| } |
@@ -1,2 +0,2 @@ | ||
| import { type Browser, type BrowserContext } from "playwright"; | ||
| import { type Browser, type BrowserContext, type Page } from "playwright"; | ||
| import type { AuthConfig, Viewport } from "../types.js"; | ||
@@ -14,2 +14,8 @@ export interface BrowserSession { | ||
| } | ||
| /** | ||
| * Applies the page-level auth hooks (localStorage seeding, beforeNavigate | ||
| * script) that cookies alone don't cover. Must run before page.goto — both | ||
| * hooks install init scripts. Cookie auth is applied at context creation. | ||
| */ | ||
| export declare function applyPageAuth(page: Page, url: string, auth: AuthConfig | undefined): Promise<void>; | ||
| export declare function launchBrowserSession(opts: BrowserSessionOptions): Promise<BrowserSession>; |
| import { chromium } from "playwright"; | ||
| /** | ||
| * Applies the page-level auth hooks (localStorage seeding, beforeNavigate | ||
| * script) that cookies alone don't cover. Must run before page.goto — both | ||
| * hooks install init scripts. Cookie auth is applied at context creation. | ||
| */ | ||
| export async function applyPageAuth(page, url, auth) { | ||
| if (auth?.localStorage) { | ||
| try { | ||
| const origin = new URL(url).origin; | ||
| await page.addInitScript(({ origin: o, data: d }) => { | ||
| if (typeof window !== "undefined" && window.location.origin === o) { | ||
| for (const [k, v] of Object.entries(d)) { | ||
| try { | ||
| window.localStorage.setItem(k, v); | ||
| } | ||
| catch { /* ignore */ } | ||
| } | ||
| } | ||
| }, { origin, data: auth.localStorage }); | ||
| } | ||
| catch { | ||
| /* invalid url — skip */ | ||
| } | ||
| } | ||
| if (auth?.beforeNavigate) { | ||
| await page.addInitScript(auth.beforeNavigate); | ||
| } | ||
| } | ||
| export async function launchBrowserSession(opts) { | ||
@@ -3,0 +31,0 @@ const browser = await chromium.launch({ headless: true }); |
| import type { Page } from "playwright"; | ||
| /** A notable on-page element the model can cite by ref ("E3") in findings. */ | ||
| export interface DomElementRef { | ||
| ref: string; | ||
| selector: string; | ||
| label: string; | ||
| /** Document coordinates in CSS px (measured at scroll origin). */ | ||
| rect: Rect; | ||
| } | ||
| export interface DomSnapshot { | ||
@@ -9,2 +17,9 @@ url: string; | ||
| }; | ||
| /** Full document dimensions in CSS px — the coordinate space of `elements` rects and full-page screenshots. */ | ||
| page: { | ||
| width: number; | ||
| height: number; | ||
| }; | ||
| /** Notable elements with stable refs, for grounded findings and annotated screenshots. */ | ||
| elements: DomElementRef[]; | ||
| text_outline: string[]; | ||
@@ -11,0 +26,0 @@ forms: Array<{ |
+55
-2
@@ -8,3 +8,15 @@ /** | ||
| export async function captureDomSnapshot(page) { | ||
| return await page.evaluate(() => { | ||
| // Wrapped with a `__name` shim: esbuild-based loaders (tsx) transform this | ||
| // module with keepNames, sprinkling `__name(...)` helper calls through the | ||
| // serialized function body — a helper that doesn't exist in the browser. | ||
| // Building the wrapper via `new Function` keeps the shim (the wrapper source | ||
| // is never transformed) while letting Playwright pass it through | ||
| // Runtime.callFunctionOn — unlike a string, which Runtime.evaluate would | ||
| // refuse on pages with a CSP lacking 'unsafe-eval'. | ||
| const src = snapshotPage.toString(); | ||
| const wrapped = new Function(`var __name = (f) => f; return (${src})();`); | ||
| return await page.evaluate(wrapped); | ||
| } | ||
| function snapshotPage() { | ||
| { | ||
| const doc = document; | ||
@@ -116,2 +128,41 @@ const win = window; | ||
| const progressbars = doc.querySelectorAll("progress, [role=progressbar]").length; | ||
| // Notable elements with stable refs — interactive controls and headings the | ||
| // model can cite (element_ref) so findings map back to exact pixel rects. | ||
| const docRect = (el) => { | ||
| const r = el.getBoundingClientRect(); | ||
| return { | ||
| x: Math.round(r.x + win.scrollX), | ||
| y: Math.round(r.y + win.scrollY), | ||
| w: Math.round(r.width), | ||
| h: Math.round(r.height), | ||
| }; | ||
| }; | ||
| const shortSelector = (el) => { | ||
| const tag = el.tagName.toLowerCase(); | ||
| const id = el.id; | ||
| if (id) | ||
| return `${tag}#${id}`; | ||
| const cls = String(el.className || "").trim().split(/\s+/).filter(Boolean)[0]; | ||
| return cls ? `${tag}.${cls}` : tag; | ||
| }; | ||
| const elements = []; | ||
| const seenRects = new Set(); | ||
| doc.querySelectorAll("h1, h2, h3, button, a, input, select, textarea, [role=button], img").forEach((el) => { | ||
| if (elements.length >= 30) | ||
| return; | ||
| if (!isVisible(el)) | ||
| return; | ||
| const r = docRect(el); | ||
| if (r.w < 8 || r.h < 8) | ||
| return; | ||
| const key = `${r.x},${r.y},${r.w},${r.h}`; | ||
| if (seenRects.has(key)) | ||
| return; | ||
| seenRects.add(key); | ||
| const label = text(el).slice(0, 60) || | ||
| el.getAttribute("aria-label") || | ||
| el.alt || | ||
| el.tagName.toLowerCase(); | ||
| elements.push({ ref: `E${elements.length + 1}`, selector: shortSelector(el), label, rect: r }); | ||
| }); | ||
| // Empty interactive lists. | ||
@@ -177,2 +228,4 @@ const empty_lists = []; | ||
| viewport: { width: win.innerWidth, height: win.innerHeight }, | ||
| page: { width: doc.documentElement.scrollWidth, height: doc.documentElement.scrollHeight }, | ||
| elements, | ||
| text_outline: text_outline.slice(0, 80), | ||
@@ -197,3 +250,3 @@ forms, | ||
| }; | ||
| }); | ||
| } | ||
| } |
| import { mkdir, writeFile } from "node:fs/promises"; | ||
| import { dirname, join } from "node:path"; | ||
| import { createHash } from "node:crypto"; | ||
| import { launchBrowserSession } from "./browser.js"; | ||
| import { applyPageAuth, launchBrowserSession } from "./browser.js"; | ||
| import { captureDomSnapshot } from "./dom.js"; | ||
| function slug(input) { | ||
@@ -21,14 +22,2 @@ return createHash("sha1").update(input).digest("hex").slice(0, 10); | ||
| } | ||
| async function applyLocalStorage(page, origin, data) { | ||
| await page.addInitScript(({ origin: o, data: d }) => { | ||
| if (typeof window !== "undefined" && window.location.origin === o) { | ||
| for (const [k, v] of Object.entries(d)) { | ||
| try { | ||
| window.localStorage.setItem(k, v); | ||
| } | ||
| catch { /* ignore */ } | ||
| } | ||
| } | ||
| }, { origin, data }); | ||
| } | ||
| async function runInteraction(page, step) { | ||
@@ -66,14 +55,3 @@ switch (step.action) { | ||
| try { | ||
| if (opts.auth?.localStorage) { | ||
| try { | ||
| const origin = new URL(opts.url).origin; | ||
| await applyLocalStorage(page, origin, opts.auth.localStorage); | ||
| } | ||
| catch { | ||
| /* invalid url — skip */ | ||
| } | ||
| } | ||
| if (opts.auth?.beforeNavigate) { | ||
| await page.addInitScript(opts.auth.beforeNavigate); | ||
| } | ||
| await applyPageAuth(page, opts.url, opts.auth); | ||
| await page.goto(opts.url, { | ||
@@ -97,2 +75,11 @@ waitUntil: (opts.waitFor === "networkidle" ? "networkidle" : "load"), | ||
| } | ||
| let dom; | ||
| if (opts.withDom) { | ||
| try { | ||
| dom = await captureDomSnapshot(page); | ||
| } | ||
| catch { | ||
| /* the snapshot is an enhancement, never a capture failure */ | ||
| } | ||
| } | ||
| const fullPage = opts.fullPage ?? true; | ||
@@ -129,2 +116,3 @@ const screenshot = await page.screenshot({ type: "png", fullPage }); | ||
| timestamp: new Date().toISOString(), | ||
| ...(dom ? { dom } : {}), | ||
| }; | ||
@@ -131,0 +119,0 @@ } |
+184
-18
@@ -7,4 +7,8 @@ import { Command } from "commander"; | ||
| import { parseInteractionsFromString } from "../capture/interactions.js"; | ||
| import { discoverRoutes } from "../capture/discover.js"; | ||
| import { appendRun, detectRegressions, loadHistory, recordFromReport, saveHistory } from "../eval/history.js"; | ||
| import { buildAddenda, loadAddendaLines, saveAddenda } from "../eval/evolve.js"; | ||
| import { withMemoryLock } from "../memory/lock.js"; | ||
| import { summarize } from "./output.js"; | ||
| const VALID_FORMATS = ["md", "json", "sarif"]; | ||
| const VALID_FORMATS = ["md", "json", "sarif", "html"]; | ||
| const VALID_SEVERITIES = ["critical", "warning", "suggestion"]; | ||
@@ -25,2 +29,3 @@ function fail(message) { | ||
| .option("-r, --routes <list>", "Comma-separated additional paths to also review (joined to base URL).") | ||
| .option("--discover-routes", "Auto-discover routes from /sitemap.xml and a Next.js app directory in cwd.", false) | ||
| .option("-v, --viewport <name>", "Single viewport (mobile|tablet|desktop). Repeatable: -v mobile -v desktop.", collectViewport, []) | ||
@@ -38,4 +43,11 @@ .option("--viewports <list>", "Comma-separated viewport names.") | ||
| .option("--interactions <spec>", "Path to a file or inline JSON with interaction steps.") | ||
| .option("--state-grid", "Also capture an interaction-state grid (default/hover/focus/active per element) and review it.", false) | ||
| .option("--ci", "Exit with non-zero code if issues exceed the configured threshold.", false) | ||
| .option("--threshold <severity>", `CI severity threshold: ${VALID_SEVERITIES.join("|")}.`) | ||
| .option("--max-findings <n>", "Keep only the top N findings per run, severity-ordered (agent focus).") | ||
| .option("--max-pr-annotations <n>", "SARIF only: emit at most N results per report, severity-ordered (reviewer fatigue).") | ||
| .option("--max-tokens <n>", "Token budget for the run — once crossed, remaining viewports are skipped (cost ceiling).") | ||
| .option("--baseline <path>", "Baseline file of finding ids to suppress (default: .motionlintignore).") | ||
| .option("--new-only", "Report only findings not seen in prior runs of the same URL.", false) | ||
| .option("--no-memory", "Disable cross-run memory: no finding ids, no baseline, no state written.") | ||
| .option("--quiet", "Suppress per-issue terminal output (still writes report file).", false) | ||
@@ -66,2 +78,5 @@ .action(async (url, opts) => { | ||
| .option("--consistency <n>", "Self-consistency samples per fixture (1=off, 3=recommended).", "1") | ||
| .option("--history <path>", "Provider scorecard history file (append + regression check).", ".motionlint/eval-history.json") | ||
| .option("--no-history", "Skip recording this run in the scorecard history.") | ||
| .option("--evolve", "Distill next_actions into .motionlint/prompt-addenda.md — review prompts pick it up automatically.", false) | ||
| .option("--ci", "Exit non-zero when the eval is failing on any attempted level.", false) | ||
@@ -83,3 +98,3 @@ .option("--quiet", "Suppress per-fixture progress output.", false) | ||
| .option("--spec <path>", "Path to a flow spec JSON file (alternative to --steps).") | ||
| .option("--name <name>", "Human label for this flow (used in the report filename).", "flow") | ||
| .option("--name <name>", "Human label for this flow (used in the report filename). Defaults to the spec's name.") | ||
| .option("--provider <name>", "Provider override (auto|ollama|anthropic|openai|google|mock).") | ||
@@ -96,3 +111,3 @@ .option("--model <name>", "Model override.") | ||
| .option("--auto-interval", "Scan animations on the page first and pick an interval that captures the shortest animation cleanly (4 frames during it). Falls back to --interval if scan finds nothing.", false) | ||
| .option("-o, --output <path>", "Markdown report path.", ".motionlint/flows/flow.md") | ||
| .option("-o, --output <path>", "Markdown report path. Default: .motionlint/flows/<flow-name>.md.") | ||
| .option("--embed", "Embed the contact sheet inline in the markdown report.", false) | ||
@@ -126,2 +141,20 @@ .option("--ci", "Exit non-zero when any critical finding is reported.", false) | ||
| program | ||
| .command("audit <url>") | ||
| .description("Lint a page's animations against Emil Kowalski's motion standards (easing, duration, physicality). Deterministic — no vision model needed.") | ||
| .option("-o, --output <path>", "Path to write the HTML audit report.", ".motionlint/audit/index.html") | ||
| .option("--json <path>", "Also write the raw audit JSON to this path.") | ||
| .option("--viewport <wxh>", "Capture viewport, e.g. 1280x800.", "1280x800") | ||
| .option("--settle <ms>", "Time to wait after load for animations to register.", "1500") | ||
| .option("--open", "Open the generated report in the default browser.", false) | ||
| .option("--ci", "Exit non-zero if any critical finding is reported.", false) | ||
| .option("--quiet", "Suppress progress output.", false) | ||
| .action(async (url, opts) => { | ||
| try { | ||
| await runAuditCommand(url, opts); | ||
| } | ||
| catch (err) { | ||
| fail(err.message); | ||
| } | ||
| }); | ||
| program | ||
| .command("mcp") | ||
@@ -138,3 +171,3 @@ .description("Run MotionLint as an MCP server over stdio (for Claude Code).") | ||
| const { renderFlowMarkdownReport } = await import("../flow/report.js"); | ||
| const { loadFlowSpec } = await import("../flow/spec.js"); | ||
| const { loadFlowSpec, resolveFlowOverrides } = await import("../flow/spec.js"); | ||
| const { mkdir, writeFile } = await import("node:fs/promises"); | ||
@@ -145,23 +178,25 @@ const { dirname: dn, resolve: resolvePath } = await import("node:path"); | ||
| throw new Error("Provide either --spec <path> or --steps <inline DSL>."); | ||
| const spec = await loadFlowSpec(specSource, opts.url); | ||
| if (opts.name) | ||
| spec.name = opts.name; | ||
| const parsed = await loadFlowSpec(specSource, opts.url); | ||
| const { spec: named, outputPath } = resolveFlowOverrides(parsed, { name: opts.name, output: opts.output }); | ||
| // Burst-interval resolution: spec value (if any) > --auto-interval scan > --interval flag > 50ms. | ||
| const cliInterval = Math.max(20, Math.min(500, Number(opts.interval ?? 50))); | ||
| const cliBurstMs = Math.max(120, Number(opts.burstMs ?? 750)); | ||
| let scannedInterval; | ||
| if (opts.autoInterval) { | ||
| const { recommendIntervalMs } = await import("../flow/auto_interval.js"); | ||
| if (!opts.quiet) | ||
| console.error(kleur.gray(` scanning ${spec.url} for animations…`)); | ||
| const rec = await recommendIntervalMs(spec.url); | ||
| console.error(kleur.gray(` scanning ${named.url} for animations…`)); | ||
| const rec = await recommendIntervalMs(named.url); | ||
| if (!opts.quiet) | ||
| console.error(kleur.gray(` ${rec.reasoning}`)); | ||
| spec.burst_interval_ms = spec.burst_interval_ms ?? rec.interval_ms; | ||
| scannedInterval = rec.interval_ms; | ||
| } | ||
| else { | ||
| spec.burst_interval_ms = spec.burst_interval_ms ?? cliInterval; | ||
| } | ||
| spec.burst_ms = spec.burst_ms ?? cliBurstMs; | ||
| const spec = { | ||
| ...named, | ||
| burst_interval_ms: named.burst_interval_ms ?? scannedInterval ?? cliInterval, | ||
| burst_ms: named.burst_ms ?? cliBurstMs, | ||
| }; | ||
| if (!opts.quiet) | ||
| console.error(kleur.cyan(`→ Running flow "${spec.name}" against ${spec.url} (${spec.steps.length} steps, ${spec.burst_interval_ms}ms intervals × ${spec.burst_ms}ms window)`)); | ||
| const config = await loadConfig(); | ||
| const report = await runFlow({ | ||
@@ -178,2 +213,3 @@ spec, | ||
| preferencesPath: opts.preferences, | ||
| providerCallsPerMinute: config.resources.providerCallsPerMinute, | ||
| onProgress: (event) => { | ||
@@ -204,3 +240,3 @@ if (opts.quiet) | ||
| }); | ||
| const outPath = resolvePath(opts.output ?? ".motionlint/flows/flow.md"); | ||
| const outPath = resolvePath(outputPath); | ||
| await mkdir(dn(outPath), { recursive: true }); | ||
@@ -245,2 +281,47 @@ await writeFile(outPath, renderFlowMarkdownReport(report, { reportDir: dn(outPath), embedSheet: opts.embed ?? false }), "utf8"); | ||
| } | ||
| async function runAuditCommand(url, opts) { | ||
| const { extractAnimations } = await import("../tuner/extract.js"); | ||
| const { auditAnimations } = await import("../tuner/lint.js"); | ||
| const { renderAnimationAuditHtml } = await import("../tuner/audit_report.js"); | ||
| const { mkdir, writeFile } = await import("node:fs/promises"); | ||
| const { dirname, resolve: resolvePath } = await import("node:path"); | ||
| const [w, h] = (opts.viewport ?? "1280x800").split("x").map(Number); | ||
| const settle = Number(opts.settle ?? 1500); | ||
| if (!opts.quiet) | ||
| console.error(kleur.cyan(`→ Auditing animations on ${url}…`)); | ||
| const capture = await extractAnimations({ | ||
| url, | ||
| viewport: { width: w || 1280, height: h || 800 }, | ||
| settleMs: settle, | ||
| }); | ||
| const audit = auditAnimations(capture); | ||
| if (!opts.quiet) { | ||
| console.error(kleur.gray(` measured ${audit.total_animations} animation(s)`)); | ||
| const sev = (n, label, color) => (n > 0 ? color(`${n} ${label}`) : kleur.gray(`0 ${label}`)); | ||
| console.error(` ${sev(audit.critical_count, "critical", kleur.red)} · ${sev(audit.warning_count, "warning", kleur.yellow)} · ${sev(audit.suggestion_count, "suggestion", kleur.gray)} · score ${audit.score}/100`); | ||
| for (const f of audit.findings.slice(0, 12)) { | ||
| const mark = f.severity === "critical" ? kleur.red("✗") : f.severity === "warning" ? kleur.yellow("▲") : kleur.gray("•"); | ||
| console.error(` ${mark} [${f.category}] ${f.title} — ${kleur.dim(f.common_name)}`); | ||
| } | ||
| if (audit.findings.length > 12) | ||
| console.error(kleur.dim(` …and ${audit.findings.length - 12} more (see the report)`)); | ||
| } | ||
| const outPath = resolvePath(opts.output ?? ".motionlint/audit/index.html"); | ||
| await mkdir(dirname(outPath), { recursive: true }); | ||
| await writeFile(outPath, renderAnimationAuditHtml(audit), "utf8"); | ||
| console.error(kleur.green(` report → ${outPath}`)); | ||
| console.error(kleur.gray(` open with: file://${outPath}`)); | ||
| if (opts.json) { | ||
| await mkdir(dirname(resolvePath(opts.json)), { recursive: true }); | ||
| await writeFile(resolvePath(opts.json), JSON.stringify(audit, null, 2), "utf8"); | ||
| console.error(kleur.green(` json → ${opts.json}`)); | ||
| } | ||
| if (opts.open) { | ||
| const { spawn } = await import("node:child_process"); | ||
| const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; | ||
| spawn(opener, [outPath], { detached: true, stdio: "ignore" }).unref(); | ||
| } | ||
| if (opts.ci && audit.critical_count > 0) | ||
| process.exit(1); | ||
| } | ||
| async function runEvalCommand(opts) { | ||
@@ -306,2 +387,46 @@ const { runEval } = await import("../eval/runner.js"); | ||
| } | ||
| // Scorecard history: compare against the previous run of this provider+model, | ||
| // then append under the shared file lock (parallel provider benchmarks are a | ||
| // real workflow — last-writer-wins would silently drop runs). Regressions are | ||
| // called out but never change the exit code — truth.json stays the only gate. | ||
| if (opts.history !== false) { | ||
| const historyPath = typeof opts.history === "string" ? opts.history : ".motionlint/eval-history.json"; | ||
| try { | ||
| await withMemoryLock(historyPath, async () => { | ||
| const history = await loadHistory(historyPath); | ||
| const record = recordFromReport(report); | ||
| const regressions = detectRegressions(history, record); | ||
| await saveHistory(historyPath, appendRun(history, record)); | ||
| console.error(kleur.gray(` history → ${historyPath} (${history.runs.length + 1} runs)`)); | ||
| if (regressions.length > 0) { | ||
| console.error(kleur.red(` ⚠ regressions vs previous ${report.provider} (${report.model}) run:`)); | ||
| for (const r of regressions) | ||
| console.error(kleur.red(` - ${r}`)); | ||
| } | ||
| }); | ||
| } | ||
| catch (err) { | ||
| console.error(kleur.yellow(` history: ${err.message} — run not recorded.`)); | ||
| } | ||
| } | ||
| // Prompt evolution: fold this run's misses into the learned-heuristics file — | ||
| // the same file the review pipeline reads (config.learnedHeuristics). | ||
| if (opts.evolve) { | ||
| const config = await loadConfig(); | ||
| const addendaPath = config.learnedHeuristics; | ||
| if (!addendaPath) { | ||
| console.error(kleur.yellow(" heuristics: learnedHeuristics is disabled (null) in config — skipping --evolve.")); | ||
| } | ||
| else { | ||
| const existing = await loadAddendaLines(addendaPath); | ||
| const lines = buildAddenda(report.next_actions, existing); | ||
| if (lines.length > 0) { | ||
| await saveAddenda(addendaPath, lines); | ||
| console.error(kleur.green(` heuristics → ${addendaPath} (${lines.length} lines; review prompts include them automatically)`)); | ||
| } | ||
| else { | ||
| console.error(kleur.gray(` heuristics: nothing to distill — no next_actions this run`)); | ||
| } | ||
| } | ||
| } | ||
| console.error(""); | ||
@@ -317,2 +442,11 @@ console.error(` Highest passing level: ${report.highest_passing_level ?? "(none)"}`); | ||
| } | ||
| function parsePositiveInt(value, flag) { | ||
| if (value === undefined) | ||
| return undefined; | ||
| const n = Number(value); | ||
| if (!Number.isInteger(n) || n < 1) { | ||
| throw new Error(`Invalid ${flag}: ${value}. Use a positive integer.`); | ||
| } | ||
| return n; | ||
| } | ||
| function resolveViewports(opts) { | ||
@@ -363,4 +497,21 @@ if (opts.viewports) | ||
| config.ci.threshold = opts.threshold; | ||
| const maxFindings = parsePositiveInt(opts.maxFindings, "--max-findings"); | ||
| const maxPrAnnotations = parsePositiveInt(opts.maxPrAnnotations, "--max-pr-annotations"); | ||
| const maxTokens = parsePositiveInt(opts.maxTokens, "--max-tokens"); | ||
| const interactions = await readInteractions(opts.interactions); | ||
| const targets = buildUrlList(rawUrl, opts.routes); | ||
| let targets = buildUrlList(rawUrl, opts.routes); | ||
| if (opts.discoverRoutes) { | ||
| const discovered = await discoverRoutes({ url: rawUrl }); | ||
| const base = new URL(rawUrl); | ||
| const merged = new Set(targets); | ||
| for (const path of discovered) { | ||
| const u = new URL(base.toString()); | ||
| u.pathname = path; | ||
| merged.add(u.toString()); | ||
| } | ||
| targets = [...merged]; | ||
| if (!opts.quiet) { | ||
| console.error(kleur.gray(` discovered ${discovered.length} route(s) → reviewing ${targets.length} URL(s)`)); | ||
| } | ||
| } | ||
| let highestExit = 0; | ||
@@ -380,5 +531,13 @@ for (const url of targets) { | ||
| interactions, | ||
| stateGrid: opts.stateGrid ?? false, | ||
| format, | ||
| outputPath: opts.output ?? undefined, | ||
| outputPath: opts.output === false ? null : opts.output ?? undefined, | ||
| embedScreenshots: opts.embed ?? false, | ||
| maxFindings, | ||
| maxPrAnnotations, | ||
| maxTokens, | ||
| // commander defaults negated flags to true, so only an explicit --no-memory overrides config | ||
| memory: opts.memory === false ? false : undefined, | ||
| baselinePath: opts.baseline ?? undefined, | ||
| newOnly: opts.newOnly === true ? true : undefined, | ||
| onProgress: (event) => { | ||
@@ -397,2 +556,8 @@ if (opts.quiet) | ||
| break; | ||
| case "memory_warning": | ||
| console.error(kleur.yellow(` memory: ${event.message}`)); | ||
| break; | ||
| case "budget_exhausted": | ||
| console.error(kleur.yellow(` budget: ${event.totalTokens.toLocaleString("en-US")} tokens ≥ ${event.limit.toLocaleString("en-US")} — skipping ${event.viewport.name}`)); | ||
| break; | ||
| case "report_written": | ||
@@ -407,3 +572,4 @@ console.error(kleur.green(` report → ${event.path}`)); | ||
| if (!opts.quiet) { | ||
| if (format === "md") { | ||
| // md/html write a file and print the terminal summary; json/sarif stream the payload. | ||
| if (format === "md" || format === "html") { | ||
| process.stdout.write(summarize(result.report) + "\n"); | ||
@@ -410,0 +576,0 @@ } |
+18
-0
| import kleur from "kleur"; | ||
| import { formatUsageLine } from "../resources/usage.js"; | ||
| export function severityColor(s, text) { | ||
@@ -20,2 +21,13 @@ switch (s) { | ||
| `${severityColor("suggestion", `${report.suggestion_count} suggestion`)}`); | ||
| const omittedParts = [ | ||
| report.omitted.by_cap > 0 ? `${report.omitted.by_cap} over the output cap` : null, | ||
| report.omitted.by_baseline > 0 ? `${report.omitted.by_baseline} baselined` : null, | ||
| report.omitted.by_memory > 0 ? `${report.omitted.by_memory} previously seen` : null, | ||
| ].filter(Boolean); | ||
| if (omittedParts.length > 0) { | ||
| lines.push(kleur.dim(`Omitted: ${omittedParts.join(" · ")}`)); | ||
| } | ||
| if (report.usage && report.usage.total_tokens > 0) { | ||
| lines.push(kleur.dim(`Tokens: ${formatUsageLine(report.usage)}`)); | ||
| } | ||
| lines.push(""); | ||
@@ -43,2 +55,8 @@ for (const entry of report.analyses) { | ||
| lines.push(kleur.green(` fix: ${issue.fix}`)); | ||
| if (issue.hash) { | ||
| const seen = issue.previously_seen && issue.previously_seen > 0 | ||
| ? ` · seen in ${issue.previously_seen} prior run${issue.previously_seen === 1 ? "" : "s"}` | ||
| : ""; | ||
| lines.push(kleur.dim(` id: ${issue.hash}${seen}`)); | ||
| } | ||
| lines.push(""); | ||
@@ -45,0 +63,0 @@ } |
@@ -6,3 +6,3 @@ import { cosmiconfig } from "cosmiconfig"; | ||
| fallbackProvider: "anthropic", | ||
| fallbackModel: "claude-sonnet-4-20250514", | ||
| fallbackModel: "claude-sonnet-5", | ||
| viewports: { | ||
@@ -20,3 +20,13 @@ mobile: { width: 375, height: 812 }, | ||
| rules: null, | ||
| learnedHeuristics: ".motionlint/prompt-addenda.md", | ||
| record: false, | ||
| maxFindings: null, | ||
| maxPrAnnotations: null, | ||
| memory: { | ||
| enabled: true, | ||
| path: ".motionlint/memory.json", | ||
| baseline: ".motionlintignore", | ||
| newOnly: false, | ||
| }, | ||
| resources: { maxConcurrentReviews: null, providerCallsPerMinute: null, maxTokensPerRun: null }, | ||
| ci: { threshold: "warning", failOnCritical: true }, | ||
@@ -23,0 +33,0 @@ auth: { cookies: null, localStorage: null, beforeNavigate: null }, |
@@ -19,1 +19,3 @@ import type { IssueCategory } from "../types.js"; | ||
| export declare function issueClusterSignature(category: string, issueText: string): string; | ||
| /** Stopword-filtered, synonym-canonicalized token set — the shared vocabulary for fuzzy matching. */ | ||
| export declare function canonicalTokens(s: string): Set<string>; |
@@ -153,1 +153,5 @@ /** | ||
| } | ||
| /** Stopword-filtered, synonym-canonicalized token set — the shared vocabulary for fuzzy matching. */ | ||
| export function canonicalTokens(s) { | ||
| return new Set(canonicalize(tokenize(s))); | ||
| } |
@@ -1,2 +0,1 @@ | ||
| import { dirname } from "node:path"; | ||
| import type { FlowSpec, FlowReport } from "./types.js"; | ||
@@ -20,2 +19,4 @@ export interface RunFlowOptions { | ||
| preferencesPath?: string; | ||
| /** Process-wide ceiling on provider analyze() calls per minute (config resources.providerCallsPerMinute). */ | ||
| providerCallsPerMinute?: number | null; | ||
| onProgress?: (event: FlowProgress) => void; | ||
@@ -46,5 +47,4 @@ } | ||
| }; | ||
| export declare function ensureDir(p: string | undefined): Promise<string | undefined>; | ||
| export declare function runFlow(opts: RunFlowOptions): Promise<FlowReport>; | ||
| export declare function relPath(target: string | undefined, fromDir: string): string | undefined; | ||
| export declare function reportFilenameForFlow(spec: FlowSpec, ext: string): string; | ||
| export { dirname }; |
+11
-13
| import { mkdir, readFile, writeFile } from "node:fs/promises"; | ||
| import { dirname, join, isAbsolute, relative } from "node:path"; | ||
| import { join, isAbsolute, relative } from "node:path"; | ||
| import { buildContactSheet } from "../capture/contact_sheet.js"; | ||
@@ -8,15 +8,18 @@ import { runFlowCapture } from "../capture/flow_capture.js"; | ||
| import { SelfConsistencyProvider } from "../providers/consistency.js"; | ||
| function ensureDir(p) { | ||
| import { sharedRateLimiter, withRateLimit } from "../resources/limiter.js"; | ||
| import { flowSlug } from "./spec.js"; | ||
| export async function ensureDir(p) { | ||
| if (p) | ||
| await mkdir(p, { recursive: true }); | ||
| return p; | ||
| } | ||
| function flowSlug(name) { | ||
| return name.replace(/[^a-z0-9]+/gi, "-").toLowerCase().replace(/^-+|-+$/g, "") || "flow"; | ||
| } | ||
| export async function runFlow(opts) { | ||
| const artifactDir = opts.artifactDir ?? ".motionlint/flows"; | ||
| const videoDir = opts.videoDir; | ||
| let provider = await resolveProvider({ | ||
| // Rate-limit beneath the consistency wrapper so every sample counts | ||
| // against the ceiling, not just the composite call. | ||
| let provider = withRateLimit(await resolveProvider({ | ||
| provider: opts.provider, | ||
| model: opts.model ?? null, | ||
| }); | ||
| }), sharedRateLimiter(opts.providerCallsPerMinute)); | ||
| if (opts.consistency && opts.consistency > 1) { | ||
@@ -29,3 +32,3 @@ provider = new SelfConsistencyProvider(provider, { samples: opts.consistency }); | ||
| spec: opts.spec, | ||
| videoDir: ensureDir(videoDir), | ||
| videoDir: await ensureDir(videoDir), | ||
| captureAfterEveryInteraction: !opts.noImplicitBursts, | ||
@@ -93,6 +96,1 @@ burstFullPage: opts.burstFullPage ?? false, | ||
| } | ||
| export function reportFilenameForFlow(spec, ext) { | ||
| const stamp = new Date().toISOString().replace(/[:.]/g, "-"); | ||
| return `flow-${flowSlug(spec.name)}-${stamp}.${ext}`; | ||
| } | ||
| export { dirname }; |
+17
-0
@@ -19,1 +19,18 @@ import type { FlowSpec, FlowStep } from "./types.js"; | ||
| export declare function loadFlowSpec(pathOrInline: string, fallbackUrl?: string): Promise<FlowSpec>; | ||
| /** Filesystem-safe slug for a flow name, used for contact-sheet and report filenames. */ | ||
| export declare function flowSlug(name: string): string; | ||
| export interface FlowCliOverrides { | ||
| /** --name, only when explicitly passed on the CLI. */ | ||
| name?: string; | ||
| /** -o/--output, only when explicitly passed on the CLI. */ | ||
| output?: string; | ||
| } | ||
| /** | ||
| * Applies CLI overrides to a parsed spec without mutating it and resolves the | ||
| * report path: an explicit --output wins; otherwise each flow gets its own | ||
| * .motionlint/flows/<slug>.md so runs of different flows never clobber each other. | ||
| */ | ||
| export declare function resolveFlowOverrides(spec: FlowSpec, overrides: FlowCliOverrides): { | ||
| spec: FlowSpec; | ||
| outputPath: string; | ||
| }; |
+16
-0
@@ -91,1 +91,17 @@ import { readFile } from "node:fs/promises"; | ||
| } | ||
| /** Filesystem-safe slug for a flow name, used for contact-sheet and report filenames. */ | ||
| export function flowSlug(name) { | ||
| return name.replace(/[^a-z0-9]+/gi, "-").toLowerCase().replace(/^-+|-+$/g, "") || "flow"; | ||
| } | ||
| /** | ||
| * Applies CLI overrides to a parsed spec without mutating it and resolves the | ||
| * report path: an explicit --output wins; otherwise each flow gets its own | ||
| * .motionlint/flows/<slug>.md so runs of different flows never clobber each other. | ||
| */ | ||
| export function resolveFlowOverrides(spec, overrides) { | ||
| const name = overrides.name || spec.name; | ||
| return { | ||
| spec: { ...spec, name }, | ||
| outputPath: overrides.output ?? `.motionlint/flows/${flowSlug(name)}.md`, | ||
| }; | ||
| } |
+29
-5
@@ -7,2 +7,3 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; | ||
| import { loadConfig } from "../config/loader.js"; | ||
| import { sharedReviewGate } from "../resources/limiter.js"; | ||
| const TOOLS = [ | ||
@@ -25,3 +26,6 @@ { | ||
| record: { type: "boolean", description: "Record a video of the capture." }, | ||
| format: { type: "string", enum: ["md", "json", "sarif"], description: "Output format (default: md)." }, | ||
| format: { type: "string", enum: ["md", "json", "sarif", "html"], description: "Output format (default: md). 'html' is a polished, shareable report." }, | ||
| max_findings: { type: "number", description: "Keep only the top N findings, severity-ordered (agent focus)." }, | ||
| max_pr_annotations: { type: "number", description: "SARIF only: emit at most N results per report, severity-ordered (reviewer fatigue)." }, | ||
| new_only: { type: "boolean", description: "Report only findings not seen in prior runs of the same URL." }, | ||
| }, | ||
@@ -42,3 +46,6 @@ required: ["url"], | ||
| model: { type: "string" }, | ||
| format: { type: "string", enum: ["md", "json", "sarif"] }, | ||
| format: { type: "string", enum: ["md", "json", "sarif", "html"] }, | ||
| max_findings: { type: "number", description: "Keep only the top N findings per route, severity-ordered." }, | ||
| max_pr_annotations: { type: "number", description: "SARIF only: emit at most N results per route report, severity-ordered." }, | ||
| new_only: { type: "boolean", description: "Report only findings not seen in prior runs of each route." }, | ||
| }, | ||
@@ -102,3 +109,3 @@ required: ["base_url", "routes"], | ||
| config.waitFor = args.wait_for; | ||
| const result = await runReview({ | ||
| const review = () => runReview({ | ||
| url: args.url, | ||
@@ -111,3 +118,10 @@ config, | ||
| format: args.format ?? "md", | ||
| maxFindings: args.max_findings, | ||
| maxPrAnnotations: args.max_pr_annotations, | ||
| newOnly: args.new_only, | ||
| }); | ||
| // Gate per review (not per tool call): review_routes funnels each route | ||
| // through here, so gating an outer call too would deadlock at max=1. | ||
| const gate = sharedReviewGate(config.resources.maxConcurrentReviews); | ||
| const result = gate ? await gate.run(review) : await review(); | ||
| lastReport = { rendered: result.rendered, format: result.format, report: result.report, path: result.reportPath }; | ||
@@ -127,2 +141,5 @@ return result; | ||
| format: args.format ?? "md", | ||
| max_findings: args.max_findings, | ||
| max_pr_annotations: args.max_pr_annotations, | ||
| new_only: args.new_only, | ||
| }); | ||
@@ -146,3 +163,4 @@ renderedParts.push(result.rendered); | ||
| spec.name = args.name; | ||
| const report = await runFlow({ | ||
| const config = await loadConfig(); | ||
| const flow = () => runFlow({ | ||
| spec, | ||
@@ -156,3 +174,6 @@ provider: args.provider, | ||
| preferencesPath: args.preferences_path, | ||
| providerCallsPerMinute: config.resources.providerCallsPerMinute, | ||
| }); | ||
| const gate = sharedReviewGate(config.resources.maxConcurrentReviews); | ||
| const report = gate ? await gate.run(flow) : await flow(); | ||
| const outDir = resolve(".motionlint/flows"); | ||
@@ -208,3 +229,6 @@ await mkdir(outDir, { recursive: true }); | ||
| const text = await readFile(lastReport.path, "utf8"); | ||
| const mt = lastReport.format === "md" ? "text/markdown" : "application/json"; | ||
| const mt = lastReport.format === "md" ? "text/markdown" | ||
| : lastReport.format === "html" ? "text/html" | ||
| : lastReport.format === "sarif" || lastReport.format === "json" ? "application/json" | ||
| : "text/plain"; | ||
| return { contents: [{ uri: req.params.uri, mimeType: mt, text }] }; | ||
@@ -211,0 +235,0 @@ } |
+22
-0
@@ -15,2 +15,16 @@ import type { AnalysisEntry, CaptureResult, InteractionStep, OutputFormat, ReviewReport, MotionLintConfig, Viewport, VisionProvider } from "./types.js"; | ||
| embedScreenshots?: boolean; | ||
| /** Per-run output cap override; falls back to config.maxFindings. */ | ||
| maxFindings?: number | null; | ||
| /** PR-surface cap override (SARIF only); falls back to config.maxPrAnnotations. */ | ||
| maxPrAnnotations?: number | null; | ||
| /** Cross-run memory override; falls back to config.memory.enabled. */ | ||
| memory?: boolean; | ||
| /** Baseline file override; falls back to config.memory.baseline. */ | ||
| baselinePath?: string | null; | ||
| /** Report only findings not seen in prior runs; falls back to config.memory.newOnly. */ | ||
| newOnly?: boolean; | ||
| /** Token budget override for this run; falls back to config.resources.maxTokensPerRun. */ | ||
| maxTokens?: number | null; | ||
| /** Also capture an interaction-state grid (hover/focus/active per element) and review it. */ | ||
| stateGrid?: boolean; | ||
| onProgress?: (event: ProgressEvent) => void; | ||
@@ -35,2 +49,10 @@ } | ||
| } | { | ||
| type: "memory_warning"; | ||
| message: string; | ||
| } | { | ||
| type: "budget_exhausted"; | ||
| viewport: Viewport; | ||
| totalTokens: number; | ||
| limit: number; | ||
| } | { | ||
| type: "report_written"; | ||
@@ -37,0 +59,0 @@ path: string; |
+116
-5
| import { mkdir, writeFile } from "node:fs/promises"; | ||
| import { join } from "node:path"; | ||
| import { captureScreenshot } from "./capture/screenshot.js"; | ||
| import { captureStateGrid, GRID_STATES } from "./capture/states.js"; | ||
| import { buildPrompt } from "./analysis/prompt.js"; | ||
| import { resolveElementRefs } from "./analysis/annotate.js"; | ||
| import { loadAddendaForPrompt } from "./eval/evolve.js"; | ||
| import { resolveProvider } from "./providers/resolver.js"; | ||
| import { sharedRateLimiter, withRateLimit } from "./resources/limiter.js"; | ||
| import { addUsage, budgetExhausted, emptyRunUsage } from "./resources/usage.js"; | ||
| import { aggregate } from "./report/aggregate.js"; | ||
| import { loadBaseline } from "./memory/baseline.js"; | ||
| import { applyMemory } from "./memory/filter.js"; | ||
| import { MemoryLockTimeoutError, withMemoryLock } from "./memory/lock.js"; | ||
| import { emptyStore, loadMemory, recordFindings, saveMemory } from "./memory/store.js"; | ||
| import { renderMarkdownReport } from "./report/markdown.js"; | ||
| import { renderJsonReport } from "./report/json.js"; | ||
| import { renderSarifReport } from "./report/sarif.js"; | ||
| import { renderReviewHtmlReport } from "./report/html.js"; | ||
| function pickViewports(cfg, requested) { | ||
@@ -42,3 +52,3 @@ const wanted = requested?.length ? requested : cfg.defaultViewports; | ||
| const viewports = pickViewports(config, opts.viewports); | ||
| const provider = await resolveProvider({ | ||
| const provider = withRateLimit(await resolveProvider({ | ||
| provider: opts.provider ?? config.provider, | ||
@@ -48,3 +58,3 @@ model: opts.model ?? config.model, | ||
| fallbackModel: config.fallbackModel, | ||
| }); | ||
| }), sharedRateLimiter(config.resources.providerCallsPerMinute)); | ||
| onProgress?.({ type: "provider_resolved", provider }); | ||
@@ -65,2 +75,3 @@ const captures = []; | ||
| interactions: opts.interactions, | ||
| withDom: true, | ||
| }); | ||
@@ -70,10 +81,57 @@ onProgress?.({ type: "capture_done", capture }); | ||
| } | ||
| // Interaction-state grid: one extra pseudo-viewport imaging each interactive | ||
| // element across default/hover/focus/active. Best-effort — a page with no | ||
| // usable elements simply contributes nothing. | ||
| let gridElements = null; | ||
| if (opts.stateGrid) { | ||
| const gridViewport = viewports[viewports.length - 1]; | ||
| try { | ||
| const grid = await captureStateGrid({ | ||
| url, | ||
| viewport: gridViewport, | ||
| waitFor: config.waitFor, | ||
| waitTimeout: config.waitTimeout, | ||
| auth: config.auth, | ||
| }); | ||
| if (grid) { | ||
| gridElements = grid.elements; | ||
| const capture = { | ||
| url, | ||
| viewport: { name: "interaction-states", width: grid.width, height: grid.height }, | ||
| screenshot: grid.buffer, | ||
| fullPage: false, | ||
| timestamp: new Date().toISOString(), | ||
| }; | ||
| onProgress?.({ type: "capture_done", capture }); | ||
| captures.push(capture); | ||
| } | ||
| } | ||
| catch { | ||
| /* grid capture is an enhancement, never a run failure */ | ||
| } | ||
| } | ||
| const tokenLimit = opts.maxTokens !== undefined ? opts.maxTokens : config.resources.maxTokensPerRun; | ||
| let usage = emptyRunUsage(typeof tokenLimit === "number" && tokenLimit > 0 ? tokenLimit : null); | ||
| // Learned heuristics from `eval --evolve`, if the project has run it. | ||
| const learned = config.learnedHeuristics ? await loadAddendaForPrompt(config.learnedHeuristics) : null; | ||
| const analyses = []; | ||
| for (const capture of captures) { | ||
| // Cost ceiling: once the running total crosses the budget, stop paying for | ||
| // further viewports — the report carries what was analyzed plus the skip list. | ||
| if (budgetExhausted(usage)) { | ||
| usage = { ...usage, skipped_viewports: [...usage.skipped_viewports, capture.viewport.name] }; | ||
| onProgress?.({ type: "budget_exhausted", viewport: capture.viewport, totalTokens: usage.total_tokens, limit: usage.limit }); | ||
| continue; | ||
| } | ||
| onProgress?.({ type: "analyze_start", viewport: capture.viewport }); | ||
| const isGrid = capture.viewport.name === "interaction-states" && gridElements !== null; | ||
| const prompt = await buildPrompt({ | ||
| viewportName: capture.viewport.name, | ||
| rulesPath: opts.rulesPath ?? config.rules, | ||
| elements: capture.dom?.elements, | ||
| learned, | ||
| ...(isGrid ? { stateGrid: { states: GRID_STATES, elements: gridElements } } : {}), | ||
| }); | ||
| const analysis = await provider.analyze(capture.screenshot, prompt, capture.viewport.name); | ||
| const analysis = resolveElementRefs(await provider.analyze(capture.screenshot, prompt, capture.viewport.name), capture.dom); | ||
| usage = addUsage(usage, analysis.usage); | ||
| const entry = { capture, analysis }; | ||
@@ -83,3 +141,51 @@ analyses.push(entry); | ||
| } | ||
| const report = aggregate(url, provider.name, provider.model, analyses); | ||
| let reportAnalyses = analyses; | ||
| let memoryOmitted; | ||
| if (opts.memory ?? config.memory.enabled) { | ||
| const baseline = await loadBaseline(opts.baselinePath ?? config.memory.baseline); | ||
| const runMemory = async () => { | ||
| let store; | ||
| try { | ||
| store = await loadMemory(config.memory.path); | ||
| } | ||
| catch (err) { | ||
| // A corrupt store must not fail the review — warn and rebuild from this run. | ||
| onProgress?.({ type: "memory_warning", message: err.message }); | ||
| store = emptyStore(); | ||
| } | ||
| const filtered = applyMemory({ | ||
| analyses, | ||
| url, | ||
| baseline, | ||
| store, | ||
| newOnly: opts.newOnly ?? config.memory.newOnly, | ||
| }); | ||
| const observed = analyses.flatMap((entry) => entry.analysis.issues); | ||
| await saveMemory(config.memory.path, recordFindings(store, url, observed, new Date().toISOString())); | ||
| return filtered; | ||
| }; | ||
| let filtered; | ||
| try { | ||
| // Lock spans the whole read-modify-write so concurrent reviews of one | ||
| // project don't clobber each other's recorded sightings. | ||
| filtered = await withMemoryLock(config.memory.path, runMemory); | ||
| } | ||
| catch (err) { | ||
| if (!(err instanceof MemoryLockTimeoutError)) | ||
| throw err; | ||
| // Availability over strictness: a wedged lock must not fail the review. | ||
| onProgress?.({ | ||
| type: "memory_warning", | ||
| message: `${err.message} Proceeding without the lock; concurrently recorded sightings may be lost.`, | ||
| }); | ||
| filtered = await runMemory(); | ||
| } | ||
| reportAnalyses = filtered.analyses; | ||
| memoryOmitted = { by_baseline: filtered.by_baseline, by_memory: filtered.by_memory }; | ||
| } | ||
| const report = aggregate(url, provider.name, provider.model, reportAnalyses, { | ||
| maxFindings: opts.maxFindings !== undefined ? opts.maxFindings : config.maxFindings, | ||
| omitted: memoryOmitted, | ||
| usage, | ||
| }); | ||
| const format = opts.format ?? "md"; | ||
@@ -92,4 +198,9 @@ let rendered; | ||
| case "sarif": | ||
| rendered = renderSarifReport(report); | ||
| rendered = renderSarifReport(report, { | ||
| maxAnnotations: opts.maxPrAnnotations !== undefined ? opts.maxPrAnnotations : config.maxPrAnnotations, | ||
| }); | ||
| break; | ||
| case "html": | ||
| rendered = renderReviewHtmlReport(report); | ||
| break; | ||
| default: | ||
@@ -96,0 +207,0 @@ rendered = renderMarkdownReport(report, { |
| import { parseAnalysisResponse } from "../analysis/parser.js"; | ||
| import { usageFromAnthropic } from "../resources/usage.js"; | ||
| import { compressForLLM } from "./util.js"; | ||
@@ -12,3 +13,3 @@ const ANTHROPIC_API = "https://api.anthropic.com/v1/messages"; | ||
| this.apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY; | ||
| this.model = opts.model ?? "claude-sonnet-4-20250514"; | ||
| this.model = opts.model ?? "claude-sonnet-5"; | ||
| this.maxTokens = opts.maxTokens ?? 4096; | ||
@@ -48,4 +49,4 @@ } | ||
| const text = (json.content ?? []).filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n"); | ||
| return parseAnalysisResponse(text, viewportName); | ||
| return { ...parseAnalysisResponse(text, viewportName), usage: usageFromAnthropic(json) }; | ||
| } | ||
| } |
@@ -86,2 +86,9 @@ import { issueClusterSignature } from "../eval/synonyms.js"; | ||
| strengthSet.add(s); | ||
| // Sum usage across samples — the caller paid for every one of them. | ||
| const reported = runs.filter((r) => r.usage); | ||
| const usage = reported.length === 0 ? undefined : reported.reduce((acc, r) => ({ | ||
| input_tokens: acc.input_tokens + (r.usage?.input_tokens ?? 0), | ||
| output_tokens: acc.output_tokens + (r.usage?.output_tokens ?? 0), | ||
| total_tokens: acc.total_tokens + (r.usage?.total_tokens ?? 0), | ||
| }), { input_tokens: 0, output_tokens: 0, total_tokens: 0 }); | ||
| return { | ||
@@ -93,3 +100,4 @@ overall_score: median, | ||
| viewport, | ||
| ...(usage ? { usage } : {}), | ||
| }; | ||
| } |
| import { parseAnalysisResponse } from "../analysis/parser.js"; | ||
| import { usageFromGoogle } from "../resources/usage.js"; | ||
| import { compressForLLM } from "./util.js"; | ||
@@ -42,4 +43,4 @@ export class GoogleProvider { | ||
| const text = (json.candidates?.[0]?.content?.parts ?? []).map((p) => p.text ?? "").join("\n"); | ||
| return parseAnalysisResponse(text, viewportName); | ||
| return { ...parseAnalysisResponse(text, viewportName), usage: usageFromGoogle(json) }; | ||
| } | ||
| } |
@@ -11,3 +11,3 @@ import type { AnalysisResult, VisionProvider } from "../types.js"; | ||
| isAvailable(): Promise<boolean>; | ||
| analyze(screenshot: Buffer, _prompt: string, viewportName: string): Promise<AnalysisResult>; | ||
| analyze(screenshot: Buffer, prompt: string, viewportName: string): Promise<AnalysisResult>; | ||
| } |
@@ -39,6 +39,9 @@ import { compressForLLM } from "./util.js"; | ||
| } | ||
| async analyze(screenshot, _prompt, viewportName) { | ||
| async analyze(screenshot, prompt, viewportName) { | ||
| // Touch sharp so the mock at least decodes the image (catches truly broken captures). | ||
| const { data } = await compressForLLM(screenshot, { format: "jpeg", maxWidth: 320, quality: 60 }); | ||
| const fingerprint = data.length; | ||
| // When the prompt lists element refs, cite the first one like a real model | ||
| // would — keeps the annotation path exercisable offline. | ||
| const hasRefs = prompt.includes("## Interactive elements (stable refs)"); | ||
| const issues = SAMPLE_ISSUES.map((i, idx) => ({ | ||
@@ -50,2 +53,3 @@ ...i, | ||
| : i.issue, | ||
| ...(hasRefs && idx === 0 ? { element_ref: "E1" } : {}), | ||
| })); | ||
@@ -61,4 +65,6 @@ return { | ||
| viewport: viewportName, | ||
| // Deterministic synthetic usage so budget/accounting paths are testable offline. | ||
| usage: { input_tokens: 1000, output_tokens: 250, total_tokens: 1250 }, | ||
| }; | ||
| } | ||
| } |
| import { parseAnalysisResponse } from "../analysis/parser.js"; | ||
| import { usageFromOllama } from "../resources/usage.js"; | ||
| import { compressForLLM } from "./util.js"; | ||
@@ -49,4 +50,7 @@ export class OllamaProvider { | ||
| const json = (await res.json()); | ||
| return parseAnalysisResponse(json.response ?? "", viewportName); | ||
| // Some hybrid-architecture models (e.g. nemotron3) emit format:"json" | ||
| // output into the `thinking` channel and leave `response` empty. | ||
| const body = json.response?.trim() ? json.response : (json.thinking ?? ""); | ||
| return { ...parseAnalysisResponse(body, viewportName), usage: usageFromOllama(json) }; | ||
| } | ||
| } |
| import { parseAnalysisResponse } from "../analysis/parser.js"; | ||
| import { usageFromOpenAI } from "../resources/usage.js"; | ||
| import { compressForLLM } from "./util.js"; | ||
@@ -46,4 +47,4 @@ const OPENAI_API = "https://api.openai.com/v1/chat/completions"; | ||
| const text = json.choices?.[0]?.message?.content ?? ""; | ||
| return parseAnalysisResponse(text, viewportName); | ||
| return { ...parseAnalysisResponse(text, viewportName), usage: usageFromOpenAI(json) }; | ||
| } | ||
| } |
@@ -40,3 +40,3 @@ import { AnthropicProvider } from "./anthropic.js"; | ||
| for (const name of AUTO_ORDER) { | ||
| const candidate = instantiate(name, name === requested ? (opts.model ?? null) : null); | ||
| const candidate = instantiate(name, opts.model ?? null); | ||
| if (await candidate.isAvailable()) | ||
@@ -43,0 +43,0 @@ return candidate; |
@@ -1,2 +0,13 @@ | ||
| import type { AnalysisEntry, ReviewReport } from "../types.js"; | ||
| export declare function aggregate(url: string, provider: string, model: string, analyses: AnalysisEntry[]): ReviewReport; | ||
| import type { AnalysisEntry, ReviewReport, RunUsage } from "../types.js"; | ||
| export interface AggregateOptions { | ||
| /** Keep only the top N findings across all viewports, severity-ordered. Non-positive or null = uncapped. */ | ||
| maxFindings?: number | null; | ||
| /** Omission counts from upstream filters (baseline / memory), carried into the report. */ | ||
| omitted?: { | ||
| by_baseline?: number; | ||
| by_memory?: number; | ||
| }; | ||
| /** Token accounting for the run, carried into the report. */ | ||
| usage?: RunUsage; | ||
| } | ||
| export declare function aggregate(url: string, provider: string, model: string, analyses: AnalysisEntry[], opts?: AggregateOptions): ReviewReport; |
@@ -1,6 +0,35 @@ | ||
| export function aggregate(url, provider, model, analyses) { | ||
| const SEVERITY_RANK = { critical: 0, warning: 1, suggestion: 2 }; | ||
| /** | ||
| * Per-run output cap: keeps the top `maxFindings` issues by severity across all | ||
| * entries. Ties within a severity keep their original order (earlier viewport, | ||
| * then earlier issue), so the surviving set is deterministic. | ||
| */ | ||
| function capBySeverity(analyses, maxFindings) { | ||
| const indexed = analyses.flatMap((entry, entryIdx) => entry.analysis.issues.map((issue, issueIdx) => ({ entryIdx, issueIdx, severity: issue.severity }))); | ||
| if (indexed.length <= maxFindings) | ||
| return { analyses, dropped: 0 }; | ||
| const kept = new Set([...indexed] | ||
| .sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]) | ||
| .slice(0, maxFindings) | ||
| .map((i) => `${i.entryIdx}:${i.issueIdx}`)); | ||
| return { | ||
| dropped: indexed.length - maxFindings, | ||
| analyses: analyses.map((entry, entryIdx) => ({ | ||
| ...entry, | ||
| analysis: { | ||
| ...entry.analysis, | ||
| issues: entry.analysis.issues.filter((_, issueIdx) => kept.has(`${entryIdx}:${issueIdx}`)), | ||
| }, | ||
| })), | ||
| }; | ||
| } | ||
| export function aggregate(url, provider, model, analyses, opts = {}) { | ||
| const capActive = typeof opts.maxFindings === "number" && Number.isInteger(opts.maxFindings) && opts.maxFindings > 0; | ||
| const capped = capActive | ||
| ? capBySeverity(analyses, opts.maxFindings) | ||
| : { analyses, dropped: 0 }; | ||
| const counts = { critical: 0, warning: 0, suggestion: 0 }; | ||
| let scoreSum = 0; | ||
| let scoreN = 0; | ||
| for (const entry of analyses) { | ||
| for (const entry of capped.analyses) { | ||
| for (const issue of entry.analysis.issues) | ||
@@ -18,3 +47,3 @@ counts[issue.severity]++; | ||
| model, | ||
| analyses, | ||
| analyses: capped.analyses, | ||
| aggregate_score: scoreN > 0 ? Math.round((scoreSum / scoreN) * 10) / 10 : 0, | ||
@@ -24,3 +53,9 @@ critical_count: counts.critical, | ||
| suggestion_count: counts.suggestion, | ||
| omitted: { | ||
| by_cap: capped.dropped, | ||
| by_baseline: opts.omitted?.by_baseline ?? 0, | ||
| by_memory: opts.omitted?.by_memory ?? 0, | ||
| }, | ||
| ...(opts.usage ? { usage: opts.usage } : {}), | ||
| }; | ||
| } |
| import { relative, isAbsolute } from "node:path"; | ||
| import { formatUsageLine } from "../resources/usage.js"; | ||
| const SEVERITY_ORDER = { critical: 0, warning: 1, suggestion: 2 }; | ||
@@ -24,6 +25,17 @@ const SEVERITY_LABEL = { | ||
| function renderIssue(issue) { | ||
| return `- **[${severityBadge(issue.severity)}] ${issue.category}** — _${issue.location || "unknown location"}_ | ||
| - **Issue:** ${issue.issue} | ||
| - **Why it matters:** ${issue.why_it_matters} | ||
| - **Fix:** ${issue.fix}`; | ||
| const lines = [`- **[${severityBadge(issue.severity)}] ${issue.category}** — _${issue.location || "unknown location"}_`]; | ||
| if (issue.element_ref && issue.element_rect) { | ||
| const r = issue.element_rect; | ||
| lines.push(` - **Where:** \`${issue.element_ref}\` at (${r.x}, ${r.y}) ${r.w}×${r.h}px`); | ||
| } | ||
| if (issue.hash) { | ||
| const seen = issue.previously_seen && issue.previously_seen > 0 | ||
| ? ` · seen in ${issue.previously_seen} prior run${issue.previously_seen === 1 ? "" : "s"}` | ||
| : ""; | ||
| lines.push(` - **Id:** \`${issue.hash}\`${seen} — add to the baseline file to suppress`); | ||
| } | ||
| lines.push(` - **Issue:** ${issue.issue}`); | ||
| lines.push(` - **Why it matters:** ${issue.why_it_matters}`); | ||
| lines.push(` - **Fix:** ${issue.fix}`); | ||
| return lines.join("\n"); | ||
| } | ||
@@ -39,2 +51,13 @@ export function renderMarkdownReport(report, opts = {}) { | ||
| lines.push(`- **Issues:** 🚨 ${report.critical_count} critical · ⚠️ ${report.warning_count} warning · 💡 ${report.suggestion_count} suggestion`); | ||
| const omittedParts = [ | ||
| report.omitted.by_cap > 0 ? `${report.omitted.by_cap} over the output cap` : null, | ||
| report.omitted.by_baseline > 0 ? `${report.omitted.by_baseline} baselined` : null, | ||
| report.omitted.by_memory > 0 ? `${report.omitted.by_memory} previously seen` : null, | ||
| ].filter(Boolean); | ||
| if (omittedParts.length > 0) { | ||
| lines.push(`- **Omitted:** ${omittedParts.join(" · ")}`); | ||
| } | ||
| if (report.usage) { | ||
| lines.push(`- **Tokens:** ${formatUsageLine(report.usage)}`); | ||
| } | ||
| lines.push(""); | ||
@@ -41,0 +64,0 @@ if (report.analyses.length === 0) { |
| import type { ReviewReport } from "../types.js"; | ||
| export declare function renderSarifReport(report: ReviewReport): string; | ||
| export interface SarifRenderOptions { | ||
| /** | ||
| * PR-surface cap: emit at most N results, keeping the most severe (ties keep | ||
| * their original order). Bounds how many annotations land on a PR when the | ||
| * SARIF file is uploaded to code scanning. Non-positive or null = uncapped. | ||
| */ | ||
| maxAnnotations?: number | null; | ||
| } | ||
| export declare function renderSarifReport(report: ReviewReport, opts?: SarifRenderOptions): string; |
+49
-21
@@ -8,30 +8,56 @@ const SARIF_VERSION = "2.1.0"; | ||
| }; | ||
| export function renderSarifReport(report) { | ||
| const results = []; | ||
| const SEVERITY_RANK = { critical: 0, warning: 1, suggestion: 2 }; | ||
| function capResults(ranked, maxAnnotations) { | ||
| const capActive = typeof maxAnnotations === "number" && Number.isInteger(maxAnnotations) && maxAnnotations > 0; | ||
| if (!capActive || ranked.length <= maxAnnotations) { | ||
| return { results: ranked.map((r) => r.result), dropped: 0 }; | ||
| } | ||
| // Stable sort: within a severity, earlier results (earlier viewport, then | ||
| // earlier issue) win — mirrors the per-run cap in aggregate.ts. | ||
| const kept = new Set(ranked | ||
| .map((r, idx) => ({ idx, rank: SEVERITY_RANK[r.severity] })) | ||
| .sort((a, b) => a.rank - b.rank) | ||
| .slice(0, maxAnnotations) | ||
| .map((r) => r.idx)); | ||
| return { | ||
| results: ranked.filter((_, idx) => kept.has(idx)).map((r) => r.result), | ||
| dropped: ranked.length - kept.size, | ||
| }; | ||
| } | ||
| export function renderSarifReport(report, opts = {}) { | ||
| const ranked = []; | ||
| for (const entry of report.analyses) { | ||
| const fileRef = entry.capture.screenshotPath ?? `${entry.capture.url}#${entry.capture.viewport.name}`; | ||
| for (const issue of entry.analysis.issues) { | ||
| results.push({ | ||
| ruleId: `${issue.category}/${issue.severity}`, | ||
| level: SEVERITY_LEVEL[issue.severity], | ||
| message: { | ||
| text: `${issue.issue}\n\nWhy it matters: ${issue.why_it_matters}\n\nFix: ${issue.fix}`, | ||
| ranked.push({ | ||
| severity: issue.severity, | ||
| result: { | ||
| ruleId: `${issue.category}/${issue.severity}`, | ||
| level: SEVERITY_LEVEL[issue.severity], | ||
| message: { | ||
| text: `${issue.issue}\n\nWhy it matters: ${issue.why_it_matters}\n\nFix: ${issue.fix}`, | ||
| }, | ||
| locations: [{ | ||
| physicalLocation: { | ||
| artifactLocation: { uri: fileRef }, | ||
| region: { startLine: 1 }, | ||
| }, | ||
| logicalLocations: [{ | ||
| name: issue.location, | ||
| kind: "uiElement", | ||
| }], | ||
| }], | ||
| // Stable cross-run identity so SARIF consumers (e.g. GitHub code | ||
| // scanning) can dedup the same finding across runs and PRs. | ||
| ...(issue.hash ? { partialFingerprints: { "motionlintFinding/v1": issue.hash } } : {}), | ||
| properties: { | ||
| viewport: entry.capture.viewport.name, | ||
| category: issue.category, | ||
| ...(issue.previously_seen !== undefined ? { previously_seen: issue.previously_seen } : {}), | ||
| }, | ||
| }, | ||
| locations: [{ | ||
| physicalLocation: { | ||
| artifactLocation: { uri: fileRef }, | ||
| region: { startLine: 1 }, | ||
| }, | ||
| logicalLocations: [{ | ||
| name: issue.location, | ||
| kind: "uiElement", | ||
| }], | ||
| }], | ||
| properties: { | ||
| viewport: entry.capture.viewport.name, | ||
| category: issue.category, | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
| const { results, dropped } = capResults(ranked, opts.maxAnnotations); | ||
| const sarif = { | ||
@@ -55,2 +81,4 @@ $schema: SARIF_SCHEMA, | ||
| aggregate_score: report.aggregate_score, | ||
| ...(dropped > 0 ? { omitted_by_pr_cap: dropped } : {}), | ||
| ...(report.usage ? { token_usage: report.usage } : {}), | ||
| }, | ||
@@ -57,0 +85,0 @@ }], |
| import { createHash } from "node:crypto"; | ||
| import { chromium } from "playwright"; | ||
| import { INSTRUMENTATION_SOURCE } from "./instrument.js"; | ||
| const EASING_PRESETS = [ | ||
| { name: "linear", value: "linear", description: "Constant speed (no acceleration)." }, | ||
| { name: "ease", value: "ease", description: "Default browser ease." }, | ||
| { name: "ease-in-out", value: "ease-in-out", description: "Slow start and end." }, | ||
| { name: "ease-out (recommended)", value: "cubic-bezier(.16,1,.3,1)", description: "Soft expo-out — best for entrances." }, | ||
| { name: "spring (snappy)", value: "cubic-bezier(.34,1.56,.64,1)", description: "Slight overshoot." }, | ||
| { name: "spring (bouncy)", value: "cubic-bezier(.68,-.55,.27,1.55)", description: "Pronounced bounce — use sparingly." }, | ||
| { name: "decelerate (Material)", value: "cubic-bezier(0,0,.2,1)", description: "Material decelerate curve." }, | ||
| ]; | ||
| import { EMIL_EASING_PRESETS } from "./standards.js"; | ||
| // The tuner offers Emil Kowalski's strong curves first (the linter recommends these | ||
| // verbatim), then the softer/decorative options. Single source of truth in standards.ts. | ||
| const EASING_PRESETS = EMIL_EASING_PRESETS.map(({ name, value, description }) => ({ name, value, description })); | ||
| function parseDurationMs(s) { | ||
@@ -14,0 +9,0 @@ if (typeof s !== "string") |
+49
-5
@@ -0,1 +1,2 @@ | ||
| import { auditAnimations } from "./lint.js"; | ||
| function escapeHtml(s) { | ||
@@ -71,2 +72,18 @@ return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | ||
| .empty { padding: 60px; text-align: center; color: var(--text-dim); } | ||
| /* Emil-standards lint callout inside a card */ | ||
| .card { grid-template-rows: 110px 220px auto auto auto; } | ||
| .lint { display: flex; flex-direction: column; gap: 6px; } | ||
| .lint .clean { font-size: 12.5px; color: var(--accent-2); display: flex; align-items: center; gap: 6px; } | ||
| .lint .finding { display: flex; gap: 8px; align-items: flex-start; font-size: 12.5px; background: var(--bg-elev); border: 1px solid var(--border); border-left: 3px solid var(--warning); border-radius: 8px; padding: 7px 10px; } | ||
| .lint .finding.crit { border-left-color: var(--danger); } | ||
| .lint .finding .sev { font-weight: 700; font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--warning); flex: none; margin-top: 1px; } | ||
| .lint .finding.crit .sev { color: var(--danger); } | ||
| .lint .finding .body b { color: var(--text); font-weight: 650; } | ||
| .lint .finding .body { color: var(--text-dim); } | ||
| .lint .finding .body .fix { color: var(--accent-2); } | ||
| .header-lint { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; color: var(--text-dim); } | ||
| .header-lint .chip { font-weight: 700; padding: 2px 9px; border-radius: 999px; font-size: 12px; } | ||
| .header-lint .chip.warn { background: rgba(255,181,71,.18); color: var(--warning); } | ||
| .header-lint .chip.crit { background: rgba(255,93,108,.18); color: var(--danger); } | ||
| .header-lint .chip.ok { background: rgba(41,230,196,.18); color: var(--accent-2); } | ||
| `; | ||
@@ -80,3 +97,3 @@ const TUNER_JS = ` | ||
| state[a.id] = { | ||
| preset: 'ease-out (recommended)', | ||
| preset: 'ease-out (Emil)', | ||
| values: Object.fromEntries(a.params.map(p => [p.name, p.value])), | ||
@@ -212,3 +229,3 @@ comments: '' | ||
| console.log('[ml-tuner] reset click', { animId: anim.id }); | ||
| state[anim.id].preset = 'ease-out (recommended)'; | ||
| state[anim.id].preset = 'ease-out (Emil)'; | ||
| anim.params.forEach(p => { state[anim.id].values[p.name] = p.value; }); | ||
@@ -253,3 +270,3 @@ refreshPreview(anim, 'reset-click'); | ||
| for (const k of Object.keys(orig)) if (orig[k] !== next[k]) diff[k] = { from: orig[k], to: next[k] }; | ||
| const presetChanged = state[anim.id].preset !== 'ease-out (recommended)'; | ||
| const presetChanged = state[anim.id].preset !== 'ease-out (Emil)'; | ||
| const comment = state[anim.id].comments.trim(); | ||
@@ -322,3 +339,14 @@ if (Object.keys(diff).length === 0 && !presetChanged && !comment) return null; | ||
| `; | ||
| function renderAnimationCard(anim) { | ||
| function renderLintBlock(findings) { | ||
| if (findings.length === 0) { | ||
| return `<div class="lint"><div class="clean">✓ On-standard — no animation-standard violations.</div></div>`; | ||
| } | ||
| const rows = findings.map((f) => { | ||
| const crit = f.severity === "critical" ? " crit" : ""; | ||
| const fix = f.suggested ? ` <span class="fix">→ ${escapeHtml(f.suggested)}</span>` : ""; | ||
| return `<div class="finding${crit}"><span class="sev">${escapeHtml(f.severity)}</span><span class="body"><b>${escapeHtml(f.title)}</b> — ${escapeHtml(f.fix)}${fix}</span></div>`; | ||
| }).join(""); | ||
| return `<div class="lint">${rows}</div>`; | ||
| } | ||
| function renderAnimationCard(anim, findings) { | ||
| const presets = anim.presets.map((p) => `<option value="${escapeAttr(p.name)}">${escapeHtml(p.name)} — ${escapeHtml(p.description)}</option>`).join(""); | ||
@@ -342,2 +370,3 @@ const sliders = anim.params.map((p) => ` | ||
| <div class="preview-frame" data-anim="${escapeAttr(anim.id)}"></div> | ||
| ${renderLintBlock(findings)} | ||
| <div class="row"> | ||
@@ -362,4 +391,18 @@ <button class="btn ghost" data-replay="${escapeAttr(anim.id)}">▶ Replay</button> | ||
| export function renderTunerHTML(capture) { | ||
| // Run the deterministic Emil-standards linter and group findings by animation id. | ||
| const audit = auditAnimations(capture); | ||
| const byAnim = new Map(); | ||
| for (const f of audit.findings) { | ||
| const list = byAnim.get(f.anim_id) ?? []; | ||
| list.push(f); | ||
| byAnim.set(f.anim_id, list); | ||
| } | ||
| const totalFlagged = audit.critical_count + audit.warning_count + audit.suggestion_count; | ||
| const headerLint = capture.animations.length | ||
| ? `<div class="header-lint">standards: ${totalFlagged === 0 | ||
| ? `<span class="chip ok">all clean</span>` | ||
| : `${audit.critical_count ? `<span class="chip crit">${audit.critical_count} critical</span>` : ""}${audit.warning_count ? `<span class="chip warn">${audit.warning_count} warning</span>` : ""}${audit.suggestion_count ? `<span class="chip warn">${audit.suggestion_count} suggestion</span>` : ""} · score ${audit.score}/100`}</div>` | ||
| : ""; | ||
| const cards = capture.animations.length | ||
| ? capture.animations.map(renderAnimationCard).join("\n") | ||
| ? capture.animations.map((a) => renderAnimationCard(a, byAnim.get(a.id) ?? [])).join("\n") | ||
| : `<div class="empty">No animations detected on this page.</div>`; | ||
@@ -380,2 +423,3 @@ return `<!doctype html> | ||
| <div class="meta">${capture.animations.length} animation${capture.animations.length === 1 ? "" : "s"} detected</div> | ||
| ${headerLint} | ||
| <div class="right"> | ||
@@ -382,0 +426,0 @@ <button class="btn ghost" id="download">↓ Download .md</button> |
+73
-1
@@ -30,2 +30,4 @@ export interface Viewport { | ||
| auth?: AuthConfig; | ||
| /** Also capture a DOM snapshot (element refs + measurements) alongside the screenshot. */ | ||
| withDom?: boolean; | ||
| } | ||
@@ -40,2 +42,4 @@ export interface CaptureResult { | ||
| timestamp: string; | ||
| /** DOM snapshot captured with the screenshot (when withDom was set). */ | ||
| dom?: import("./capture/dom.js").DomSnapshot; | ||
| } | ||
@@ -51,3 +55,31 @@ export type IssueCategory = "hierarchy" | "spacing" | "alignment" | "typography" | "color" | "contrast" | "responsiveness" | "interaction" | "content" | "navigation" | "consistency" | "loading_state"; | ||
| fix: string; | ||
| /** Cross-run finding identity (set when memory is enabled). Add it to the baseline file to suppress the finding. */ | ||
| hash?: string; | ||
| /** How many prior runs recorded this finding at this URL (set when memory is enabled). */ | ||
| previously_seen?: number; | ||
| /** Ref of the DOM element this issue concerns (e.g. "E3"), cited by the model from the capture's element list. */ | ||
| element_ref?: string; | ||
| /** The cited element's rect in document CSS px — resolved from the DOM snapshot, drawn on annotated screenshots. */ | ||
| element_rect?: { | ||
| x: number; | ||
| y: number; | ||
| w: number; | ||
| h: number; | ||
| }; | ||
| } | ||
| /** Tokens consumed by one provider call, normalized across providers. */ | ||
| export interface TokenUsage { | ||
| input_tokens: number; | ||
| output_tokens: number; | ||
| total_tokens: number; | ||
| } | ||
| /** Token totals for a whole review run, plus budget bookkeeping. */ | ||
| export interface RunUsage extends TokenUsage { | ||
| /** Provider analyze() calls made (counted even when a provider reports no usage). */ | ||
| calls: number; | ||
| /** Token budget for this run (resources.maxTokensPerRun / --max-tokens). null = unlimited. */ | ||
| limit: number | null; | ||
| /** Viewports skipped because the budget was exhausted mid-run. */ | ||
| skipped_viewports: string[]; | ||
| } | ||
| export interface AnalysisResult { | ||
@@ -59,2 +91,4 @@ overall_score: number; | ||
| viewport: string; | ||
| /** Tokens the provider reported for this call; absent when the API returned no usage block. */ | ||
| usage?: TokenUsage; | ||
| } | ||
@@ -65,2 +99,11 @@ export interface AnalysisEntry { | ||
| } | ||
| /** Findings removed from the report before rendering, by mechanism. */ | ||
| export interface OmittedCounts { | ||
| /** Dropped by the per-run output cap (maxFindings). */ | ||
| by_cap: number; | ||
| /** Suppressed because their hash is listed in the baseline file. */ | ||
| by_baseline: number; | ||
| /** Dropped as previously-seen recurrences (newOnly mode). */ | ||
| by_memory: number; | ||
| } | ||
| export interface ReviewReport { | ||
@@ -76,2 +119,5 @@ timestamp: string; | ||
| suggestion_count: number; | ||
| omitted: OmittedCounts; | ||
| /** Token accounting for the run (set whenever the pipeline made provider calls). */ | ||
| usage?: RunUsage; | ||
| } | ||
@@ -98,2 +144,20 @@ export interface VisionProvider { | ||
| } | ||
| export interface ResourceConfig { | ||
| /** Max reviews running at once in one process (MCP server under concurrent agent calls). null = unlimited. */ | ||
| maxConcurrentReviews: number | null; | ||
| /** Ceiling on vision-provider analyze() calls per minute across the process (quota / spend control). null = unlimited. */ | ||
| providerCallsPerMinute: number | null; | ||
| /** Token budget per review run — once total tokens cross it, remaining viewports are skipped. null = unlimited. */ | ||
| maxTokensPerRun: number | null; | ||
| } | ||
| export interface MemoryConfig { | ||
| /** Master switch for cross-run memory (annotation, baseline, persistence). */ | ||
| enabled: boolean; | ||
| /** JSON store path, keyed by reviewed URL. */ | ||
| path: string; | ||
| /** Baseline file of finding hashes to always suppress. */ | ||
| baseline: string; | ||
| /** Report only findings not seen in prior runs. */ | ||
| newOnly: boolean; | ||
| } | ||
| export interface MotionLintConfig { | ||
@@ -112,6 +176,14 @@ provider: string; | ||
| rules: string | null; | ||
| /** Learned-heuristics file (written by `eval --evolve`) included in review prompts when present. null disables. */ | ||
| learnedHeuristics: string | null; | ||
| record: boolean; | ||
| /** Per-run output cap: keep only the top N findings, severity-ordered. null = uncapped. */ | ||
| maxFindings: number | null; | ||
| /** PR-surface cap: emit at most N SARIF results per report, severity-ordered. null = uncapped. */ | ||
| maxPrAnnotations: number | null; | ||
| memory: MemoryConfig; | ||
| resources: ResourceConfig; | ||
| ci: CIConfig; | ||
| auth: AuthConfig; | ||
| } | ||
| export type OutputFormat = "md" | "json" | "sarif"; | ||
| export type OutputFormat = "md" | "json" | "sarif" | "html"; |
+1
-1
| { | ||
| "name": "motionlint", | ||
| "version": "0.1.0", | ||
| "version": "0.2.0", | ||
| "description": "AI design review in your terminal — automated visual UI/UX analysis using vision LLMs.", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
+304
-203
| # MotionLint | ||
| > **AI design review in your terminal — for what users actually see, click, and watch animate.** Three modes: static UX review, scripted-flow animation review (16 frames per burst at 50ms intervals via CDP screencast), and an interactive animation tuner that hands changes back to Claude Code. Works as a CLI or as an MCP server inside Claude Code, Cursor, or any MCP-aware client. | ||
| [](https://www.npmjs.com/package/motionlint) [](LICENSE) [](https://github.com/bobaba99/motionlint/actions/workflows/ci.yml) | ||
| [](https://www.npmjs.com/package/motionlint) [](LICENSE) | ||
| ## The problem | ||
| ## What it does | ||
| AI coding agents read JSX, HTML, and CSS — they're blind to what the user actually sees, clicks, and watches animate. Spacing that looks correct in code renders broken; modals that should slide in just pop; loading states get omitted; focus rings disappear. Code review can't catch any of this before merge. | ||
| | Command | What it answers | | ||
| | --- | --- | | ||
| | `motionlint review <url>` | *"How does this page **look**?"* — captures full-page screenshots at multiple viewports, sends them to a vision LLM, returns ranked UX issues across 12 dimensions (hierarchy, spacing, contrast, responsiveness, …). | | ||
| | `motionlint flow --spec flow.json` | *"How does this page **behave**?"* — runs a scripted user journey through Playwright, captures a 50ms-interval frame burst after every interaction, and asks the LLM to grade animation quality (missing transitions, jank, missing loading states, choreography, scroll-driven effects). | | ||
| | `motionlint tune <url>` | *"Let me **tune** these animations live."* — detects every animation on a page (CSS + Motion One / GSAP / anime.js / auto-animate / lottie), opens an interactive HTML page with sliders + presets, exports a Claude Code-ready prompt. | | ||
| | `motionlint mcp` | *"Run me as an MCP server"* — exposes all of the above as MCP tools (`review_url`, `review_flow`, `tune_animations`, …) so Claude Code can drive them directly inside a chat. | | ||
| ## What MotionLint does | ||
| MotionLint is a vision-LLM design reviewer that runs in your terminal and as an MCP server inside Claude Code, Cursor, or any MCP-aware client. It captures what your app actually *does* — multi-viewport screenshots, 50ms-interval frame bursts after every interaction, and an interactive timing tuner — then hands ranked, actionable findings back to your coding agent. | ||
| <p align="center"> | ||
| <img src="docs/media/cli-audit.gif" width="800" alt="motionlint audit running in a terminal: the demo app's /loading route scores 64/100 with findings across duration, easing and accessibility"> | ||
| </p> | ||
| <p align="center"><sub><code>motionlint audit</code> scoring a page — deterministic, no LLM. More demos: clone the repo and open <a href="demo/walkthrough/">demo/walkthrough/index.html</a>.</sub></p> | ||
| ## How it's different | ||
| | | MotionLint | Visual regression tools (Percy, Chromatic, Playwright snapshots) | AI design generators (v0, Galileo, Claude Design, Stitch) | | ||
| | --- | --- | --- | --- | | ||
| | Multi-viewport UX review | ranked findings across 12 dimensions | pixel diffs only | generates new layouts from prompts | | ||
| | **Animation review** | **50ms frame bursts via CDP screencast → contact sheet → LLM** | ✗ | ✗ | | ||
| | **Live animation tuning** | **Shadow-DOM previews + sliders + Claude Code export** | ✗ | generates new motion, doesn't tune what's there | | ||
| | Native MCP server | ✓ stdio MCP for Claude Code / Cursor | ✗ | varies | | ||
| | CI gate | ✓ SARIF + exit codes for code scanning | ✓ image diff thresholds | ✗ | | ||
| | Validated quality | **100% recall · 0% FPR on 24-fixture stress test** | n/a | n/a | | ||
| The conceptual gap MotionLint closes: visual-regression tools catch what *changed* but not whether the new pixels are *good*; AI design tools generate from scratch but don't review what's already running. MotionLint reviews live behavior with a vision LLM and feeds the verdict back into the coding loop. | ||
| ## Install | ||
| ```bash | ||
| # CLI | ||
| npm install -g motionlint # global | ||
| npx motionlint review <url> # one-shot, no install | ||
| # Claude Code (MCP server) | ||
| claude mcp add motionlint -- npx -y motionlint mcp | ||
| # One-time per machine: Playwright Chromium (~300MB) | ||
| npx playwright install chromium | ||
| ``` | ||
| Requires Node 18+. Package on npm: [motionlint](https://www.npmjs.com/package/motionlint). | ||
| ## Quick start | ||
| ```bash | ||
| # Static review of a URL at mobile + desktop → Markdown report. | ||
| motionlint review http://localhost:3000 | ||
| # Animation review of a scripted user journey → contact sheet + flow report. | ||
| motionlint flow --spec flows/signup.json | ||
| # Detect every animation on a page → interactive HTML tuner. | ||
| motionlint tune http://localhost:3000 | ||
| # Lint a page's motion against Emil Kowalski's standards → polished HTML audit (no LLM). | ||
| motionlint audit http://localhost:3000 --open | ||
| # Track provider quality across runs + teach the reviewer from eval misses. | ||
| motionlint eval --provider anthropic --evolve | ||
| # Interaction affordances — grid each element's default/hover/focus/active states. | ||
| motionlint review http://localhost:3000 --state-grid | ||
| # Review every route the site knows about (sitemap.xml + Next.js app/ directory). | ||
| motionlint review http://localhost:3000 --discover-routes | ||
| # Polished, shareable HTML review with embedded screenshots + before/after fixes. | ||
| motionlint review http://localhost:3000 --format html -o review.html | ||
| # CI mode — non-zero exit on critical issues, SARIF output for code scanning. | ||
| motionlint review https://staging.acme.dev --ci --threshold critical --format sarif -o ux.sarif | ||
| # Pick a provider explicitly (auto-detect picks the first reachable one). | ||
| motionlint review http://localhost:3000 --provider anthropic --model claude-sonnet-4-6 | ||
| # Agent focus — keep only the top 5 findings, and only ones not seen in prior runs. | ||
| motionlint review http://localhost:3000 --max-findings 5 --new-only | ||
| # Reviewer focus — cap the SARIF upload at 10 annotations per report. | ||
| motionlint review https://staging.acme.dev --format sarif -o ux.sarif --max-pr-annotations 10 | ||
| ``` | ||
| Sample terminal output for a flow review: | ||
| ```text | ||
| $ motionlint flow --spec flows/signup.json --provider anthropic | ||
| → Running flow "signup-happy-path" against http://localhost:3000/signup (11 steps, 50ms intervals × 750ms window) | ||
| provider: anthropic (claude-sonnet-4-20250514) | ||
| provider: anthropic (claude-sonnet-4-6) | ||
| capturing flow… | ||
| ✓ step 1: 16 frames ✓ step 2: 16 frames ✓ step 3: 16 frames … | ||
| captured 176 frames in 31s | ||
| contact sheet → .motionlint/flows/signup-…png | ||
| contact sheet → .motionlint/flows/signup-happy-path-…png | ||
| analyzing flow… | ||
| report → .motionlint/flows/signup.md | ||
| report → .motionlint/flows/signup-happy-path.md | ||
@@ -33,13 +106,48 @@ Score: 4/10 · 3 critical findings | ||
| ## Why | ||
| ## Try the demo | ||
| AI coding agents read JSX/HTML/CSS — they're blind to what the user actually sees, clicks, and watches animate. Spacing that looks correct in code renders badly. Hover states never get checked. A modal that *should* slide in just pops. MotionLint plugs the visual + motion feedback loop into the same terminal where you write the code. | ||
| A multi-route TS animation showcase ships in [demo/](demo/) — covering Motion One, GSAP, anime.js, @formkit/auto-animate, and lottie-web — including a cat-themed one-pager that exercises every MotionLint capability in a single URL: | ||
| ```bash | ||
| node demo/server.mjs # http://localhost:4173 | ||
| motionlint review http://localhost:4173/cat --record --embed | ||
| motionlint flow --spec flows/signup.json | ||
| motionlint tune http://localhost:4173/dashboard | ||
| ``` | ||
| Routes available: `/`, `/pricing`, `/signup`, `/dashboard`, `/loading`, `/cat`. Reports go to `.motionlint/reports/`, screenshots to `.motionlint/screenshots/`, videos to `.motionlint/videos/`. | ||
| ## Setup | ||
| ### API keys | ||
| MotionLint auto-loads a `.env` file from the working directory at startup: | ||
| ```bash | ||
| # .env (gitignored) | ||
| ANTHROPIC_API_KEY=sk-ant-... | ||
| # or | ||
| OPENAI_API_KEY=sk-... | ||
| # or | ||
| GOOGLE_API_KEY=... | ||
| # or run a local Ollama (no key needed) — auto-detected on http://localhost:11434 | ||
| ``` | ||
| Real environment variables take precedence over `.env`. With no key set and no Ollama running, MotionLint falls back to a deterministic **mock provider** so the full pipeline (capture → analysis → report) still runs end-to-end for smoke tests. | ||
| ### Provider auto-detect | ||
| MotionLint auto-detects in this order: **Ollama (local) → Anthropic → OpenAI → Google**. The first one with a working API key (or running service) wins. Override with `--provider <name>` and `--model <id>`. See [Providers in depth](#providers-in-depth) for the per-provider quality scorecard and how to pick. | ||
| --- | ||
| > *Everything below is for readers who want to understand how MotionLint works under the hood, pick the right provider for their workflow, or wire it into CI.* | ||
| ## Validated quality across providers | ||
| The flow-review pipeline was stress-tested across **12 popular web-app animation patterns × 2 variants** (24 fixtures total) covering: staggered entrances, hover/press/focus states, modal entrances, loading skeletons, form errors, toasts, counter ramps, multi-animation dashboards, modal-with-content stagger, rich form feedback (focus + press + spinner + success), and scroll-driven animations (progress bar + IntersectionObserver reveal + parallax). | ||
| The flow-review pipeline was stress-tested across **12 popular web-app animation patterns × 2 variants** (24 fixtures total) — staggered entrances, hover/press/focus, modal entrances, loading skeletons, form errors, toasts, counter ramps, multi-animation dashboards, modal-with-content stagger, rich form feedback (focus + press + spinner + success), and scroll-driven animations (progress bar + IntersectionObserver reveal + parallax). | ||
| Run on **2026-04-28** against the latest model from each major provider: | ||
| Run on **2026-04-29** against the latest model from each major provider plus three Ollama-served local models: | ||
| | Provider · model | Recall (broken caught) | FPR (clean flagged) | Score gap | Wall time | | ||
| | Provider · model | Recall (broken caught) | FPR (clean flagged) | Score gap | Wall time¹ | | ||
| | --- | --- | --- | --- | --- | | ||
@@ -49,26 +157,77 @@ | **OpenAI · gpt-5.5** | **100%** (12/12) | **0%** (0/12) | +4.2 | 11.6 min | | ||
| | **Anthropic · claude-sonnet-4-6** | **100%** (12/12) | 8% (1/12) | +5.1 | 14.2 min | | ||
| | **Ollama · nemotron3:33b** (local, 27 GB) | **100%** (12/12) | 25% (3/12) | +5.1 | 7.7 min | | ||
| | **Google · gemini-3.1-pro-preview** | 92% (11/12) | **0%** (0/12) | +5.5 | 5.3 min | | ||
| | **Ollama · gemma3:4b (local)** | 83% (10/12) | 17% (2/12) | +1.1 | 3.6 min | | ||
| | **Ollama · gemma3:4b** (local, 3.3 GB) | 83% (10/12) | 17% (2/12) | +1.1 | 3.6 min | | ||
| | **Ollama · glm-ocr** (local, 2.2 GB) | 33% (4/12) | 33% (4/12) | +0.3 | 11.3 min | | ||
| **Read this as:** four out of five providers are shippable. **OpenAI gpt-5.5 is the only provider with both 100% recall AND 0% FPR.** Both Anthropic models (Opus 4.7 and Sonnet 4.6) match on recall but flag the same one clean fixture as critical — Sonnet is the better Anthropic value (~5× cheaper per token than Opus, equivalent quality on this test). Gemini 3.1 Pro is ~3× faster and 5× cheaper, at the cost of one missed broken pattern. Local Ollama is workable for iteration loops but flags too many clean implementations to use as a CI gate. | ||
| ¹ Local-model wall times measured on an Apple M4 Max (128 GB unified). Cloud-provider times reflect API latency, not local compute. | ||
| **Read this as:** six of the seven model combinations are shippable for at least one workflow. **OpenAI gpt-5.5 remains the only provider with 100% recall AND 0% FPR** — the safest hard CI gate. The standout new result: **nemotron3:33b is the first 100%-recall local model**, ties Sonnet 4.6 on score gap, runs entirely on-device, and costs $0 — but its 25% false-positive rate (3 clean fixtures flagged critical) means it's better as an iteration-loop reviewer than a merge-blocker on a powerful local machine. Both Anthropic models match on recall and flag the same one clean fixture; Sonnet 4.6 is the better Anthropic value (~5× cheaper per token than Opus, equivalent quality on this test). Gemini 3.1 Pro is ~3× faster and 5× cheaper than Sonnet, at the cost of one missed broken pattern. **glm-ocr is too weak for this task** (33% recall, 33% FPR — barely above coin-flip) and is documented here only so future readers don't try the same path. | ||
| Full per-provider scorecards in [.motionlint/stress/](.motionlint/stress/) after running [scripts/run-all-benchmarks.mjs](scripts/run-all-benchmarks.mjs). | ||
| ## Use cases | ||
| ## Providers in depth | ||
| MotionLint is built around four concrete workflows: | ||
| | Provider | Default model | Setup | Quality (24 fixtures) | Cost per review¹ | | ||
| | --- | --- | --- | --- | --- | | ||
| | `openai` | `gpt-5.5` | `OPENAI_API_KEY=…` | **100% recall · 0% FPR · +4.2 gap** | ~$0.005 | | ||
| | `anthropic` | `claude-opus-4-7` | `ANTHROPIC_API_KEY=…` | **100% recall** · 8% FPR · +4.2 gap | ~$0.025 | | ||
| | `anthropic` | `claude-sonnet-4-6` | `ANTHROPIC_API_KEY=…` | **100% recall** · 8% FPR · +5.1 gap | ~$0.005 | | ||
| | `ollama` | `nemotron3:33b` (local, 27 GB) | `ollama serve` + `ollama pull nemotron3:33b` | **100% recall** · 25% FPR · +5.1 gap | $0 | | ||
| | `google` | `gemini-3.1-pro-preview` | `GOOGLE_API_KEY=…` | 92% recall · **0% FPR** · +5.5 gap | ~$0.001 | | ||
| | `ollama` | `gemma3:4b` (local, 3.3 GB) | `ollama serve` + `ollama pull gemma3:4b` | 83% recall · 17% FPR · +1.1 gap | $0 | | ||
| | `ollama` | `glm-ocr` (local, 2.2 GB) | `ollama serve` + `ollama pull glm-ocr` | 33% recall · 33% FPR · +0.3 gap | $0 | | ||
| | `mock` | heuristic stub | (auto fallback) | n/a — deterministic stub for CI smoke tests | $0 | | ||
| - **Pre-merge UX guardrail.** You're a solo dev or 2-person startup with no designer. You finished a feature on a branch, the preview deploy is up, and you'd like a sanity-check before shipping. Run `motionlint review https://pr-123.preview.example.com --ci --threshold critical` in CI; a warning-or-worse blocks the merge until you've at least seen the issues. | ||
| - **MCP design colleague inside Claude Code.** Add MotionLint as an MCP server (`claude mcp add motionlint -- npx -y motionlint mcp`). Then ask CC: *"Use motionlint to review the local app at mobile and desktop and tell me the top 3 issues to fix."* CC drives the review tool and gets back annotated feedback in the same conversation. | ||
| - **Continuous quality monitoring.** Schedule a nightly cron (`motionlint review https://prod.example.com --format sarif -o ux.sarif`) and surface SARIF in your code-scanning dashboard so production regressions get caught the morning after. | ||
| - **Animation / flow QA on a feature you just shipped.** Static screenshots can't tell you whether a button has a press state, whether the modal slides in or just pops, whether there's a spinner during the API call, or whether the success animation stutters. `motionlint flow` runs a scripted user journey through Playwright like a human would, captures frame bursts at every interaction, records video, and asks the LLM to review the *animation behavior* across the captured frames. See *Flow review* below. | ||
| ¹ Order-of-magnitude estimate per static review at the default 2 viewports. Flow review is one composite image per flow but the contact sheet is bigger. The Animation Tuner makes 0 LLM calls. | ||
| Plus a structural workflow worth its own section: | ||
| ### How to pick | ||
| - **Live animation tuning + handoff to Claude Code.** Capture every animation on a page, tune timing/easing/delay live with sliders, export a structured prompt CC can act on directly. See *Animation Tuner* below. | ||
| - **Best quality, hard CI gate.** OpenAI `gpt-5.5` — the only provider that hit 100% recall *and* 0% FPR. | ||
| - **Best Anthropic value.** Anthropic `claude-sonnet-4-6` ties Opus 4.7 on recall and FPR (both 100% / 8%) and is **5× cheaper per token**. Pass `--model claude-opus-4-7` for the Opus tier; otherwise Sonnet 4.6. | ||
| - **Best local quality (NEW).** Ollama `nemotron3:33b` — first local model at 100% recall, ties Sonnet 4.6 on score gap. 25% FPR keeps it out of hard CI gates, but it's the right pick for iteration loops and air-gapped reviews when you have ≥32 GB unified memory and don't want to pay per-call. | ||
| - **Cost-sensitive CI.** Google `gemini-3.1-pro-preview` — 5× cheaper than Anthropic Sonnet, ~3× faster, 0% FPR, missed one broken pattern. Run the stress test on your own flows before relying on it as a hard merge gate. | ||
| - **Lightweight local.** Ollama `gemma3:4b` (3.3 GB). 83% recall, 17% FPR. Use when nemotron3:33b doesn't fit in memory or when you need faster turn-around per fixture. | ||
| - **Skip:** Ollama `glm-ocr` is OCR-tuned and too weak for general design review (33% recall / 33% FPR). | ||
| ## Flow review | ||
| ### Switching providers | ||
| Static screenshots can't tell you whether a flow's *animations* and *interaction states* work. They can only tell you whether the final frame looks right. The `motionlint flow` command fills that gap. | ||
| Every command honours `--provider` and `--model`: | ||
| ```bash | ||
| motionlint review http://localhost:3000 --provider openai --model gpt-5.5 | ||
| motionlint flow --spec flows/signup.json --provider google --model gemini-3.1-pro-preview | ||
| motionlint review http://localhost:3000 --provider ollama --model llava:13b | ||
| ``` | ||
| ### Benchmarking your own provider | ||
| To compare a new provider against the same 24-fixture stress test: | ||
| ```bash | ||
| node -e " | ||
| import('./dist/config/env.js').then(async ({ loadEnv }) => { | ||
| loadEnv(); | ||
| const { runStress, renderStressMarkdown } = await import('./dist/flow/stress.js'); | ||
| const { writeFile, mkdir } = await import('node:fs/promises'); | ||
| const { resolve } = await import('node:path'); | ||
| await mkdir('.motionlint/stress', { recursive: true }); | ||
| const r = await runStress({ | ||
| stressPath: resolve('eval/animation-stress.json'), | ||
| fixturesDir: resolve('eval/animation-fixtures'), | ||
| artifactDir: resolve('.motionlint/stress'), | ||
| provider: 'YOUR_PROVIDER', // 'openai' | 'google' | 'ollama' | ||
| }); | ||
| await writeFile('.motionlint/stress/SCORECARD.md', renderStressMarkdown(r), 'utf8'); | ||
| console.error('Recall:', (r.broken_recall*100).toFixed(0)+'%, FPR:', (r.good_false_positive_rate*100).toFixed(0)+'%, gap:', r.avg_score_gap.toFixed(1)); | ||
| }); | ||
| " | ||
| ``` | ||
| Open `.motionlint/stress/SCORECARD.md` for the per-pattern breakdown. | ||
| ## How `motionlint flow` works | ||
| Static screenshots can't tell you whether a flow's animations and interaction states work — only whether the final frame looks right. `motionlint flow` fills that gap. | ||
| Given a scripted user journey, it: | ||
@@ -80,3 +239,3 @@ | ||
| 4. Composites every burst into a labeled **contact sheet** — one row per step, frames laid out in sub-rows. | ||
| 5. Sends the sheet to the vision LLM with a flow-aware rubric that focuses on: missing animations, buggy/janky animations, missing loading states, perceived performance, affordance & state changes, choreography, smoothness, accidental flicker, navigation continuity, reduced-motion respect. | ||
| 5. Sends the sheet to the vision LLM with a flow-aware rubric covering: missing animations, buggy/janky animations, missing loading states, perceived performance, affordance & state changes, choreography, smoothness, accidental flicker, navigation continuity, reduced-motion respect. | ||
| 6. Produces a Markdown report with per-step trace, ranked findings, and a **"Prompt for Claude Code"** block at the bottom — paste it into CC and it acts on the findings directly. | ||
@@ -86,3 +245,3 @@ | ||
| A single recording can capture and analyze multiple concurrent animations. The harness has been validated on: | ||
| A single recording can capture and analyze multiple concurrent animations. Validated on: | ||
@@ -94,9 +253,9 @@ - **Dashboard reveal** (3 concurrent: tile stagger + counter ramps + chart bar rise) | ||
| The LLM correctly identifies *which* animations are broken without false-flagging the working ones — see the validated quality table at the top. | ||
| The LLM correctly identifies *which* animations are broken without false-flagging the working ones — see the validated-quality table. | ||
| ### Scroll-driven animations | ||
| For sites with scroll-linked animations (parallax, scroll-progress bars, IntersectionObserver reveals), `scroll <px>` steps animate the scroll over the burst window via `requestAnimationFrame`, so each frame shows progressive scroll position and the LLM sees the animation timing as the page scrolls. | ||
| For sites with scroll-linked animations, `scroll <px>` steps animate the scroll over the burst window via `requestAnimationFrame` so each frame shows progressive scroll position and the LLM sees the timing as the page scrolls. | ||
| ### Examples | ||
| ### Flow examples | ||
@@ -113,3 +272,3 @@ ```bash | ||
| # Pass team motion preferences (motion philosophy + inspirations + accepted defaults) | ||
| # Pass team motion preferences (philosophy + inspirations + accepted defaults) | ||
| # Embedded into the prompt AND the report's CC handoff block. | ||
@@ -141,8 +300,12 @@ motionlint flow --spec flows/signup.json --preferences flows/preferences.md | ||
| Three ready-to-run sample flows ship with the repo: [flows/signup.json](flows/signup.json), [flows/loading-state.json](flows/loading-state.json), and [flows/preferences.md](flows/preferences.md) (sample team-preferences markdown). | ||
| Three ready-to-run sample flows ship in the repo: [flows/signup.json](flows/signup.json), [flows/loading-state.json](flows/loading-state.json), and [flows/preferences.md](flows/preferences.md). | ||
| ## Animation Tuner | ||
| ## How the Animation Tuner works | ||
| Most AI coding tools generate animations from scratch. MotionLint lets you **tune the animations that are already running on your page**, in real time, and hand the changes back to your coding agent as a structured prompt. | ||
| Most AI coding tools generate animations from scratch. The Tuner lets you **tune the animations that are already running on your page**, in real time, and hand the changes back to your coding agent as a structured prompt. | ||
| <p align="center"> | ||
| <img src="docs/media/tuner.gif" width="800" alt="The Animation Tuner: replaying a detected animation, dragging its duration slider from 300ms to 150ms, then applying the ease-out (Emil) preset"> | ||
| </p> | ||
| ```bash | ||
@@ -155,12 +318,11 @@ motionlint tune http://localhost:3000 --open | ||
| 1. Opens your app in headless Chromium with an instrumentation script that hooks the major TS animation libraries (Motion One, GSAP, anime.js, @formkit/auto-animate, lottie-web) plus all CSS transitions and `@keyframes` running on the page. | ||
| 2. Captures every detected animation: the element selector, source library, timing parameters, and the bounding box. | ||
| 2. Captures every detected animation: the element selector, source library, timing parameters, and bounding box. | ||
| 3. Generates a self-contained interactive HTML page at `.motionlint/tuner/index.html` (auto-opens with `--open`): | ||
| - **Live preview surface** per animation (Shadow DOM — no iframes, no flash, themed to the source page). | ||
| - **Sliders** for duration / delay / stagger / speed. | ||
| - **Easing-preset dropdown** (linear, ease-out, spring snappy, spring bouncy, Material decelerate, custom cubic-bezier). | ||
| - **Easing-preset dropdown** — Emil Kowalski's strong curves lead (ease-out, ease-in-out, iOS drawer), then the softer/decorative options. | ||
| - **Inline standards linting** — each card flags where the animation deviates from the motion standards (severity badge, fix, suggested value), with a header score. | ||
| - **Comments box** per animation for design rationale. | ||
| 4. Exports a markdown file + a Claude-Code-ready prompt with a structured `changes[]` JSON block. Paste that into CC, and it edits your codebase to apply the new parameters. | ||
| 4. Exports a markdown file plus a Claude-Code-ready prompt with a structured `changes[]` JSON block. Paste that into CC and it edits your codebase to apply the new parameters. | ||
| This is the "tighter feedback loop than Claude Design or Google Stitch" angle: you're tuning what's *already in production*, not generating new designs from scratch. | ||
| ```text | ||
@@ -175,71 +337,33 @@ $ motionlint tune http://localhost:3000 | ||
| ## Setup | ||
| ## Animation standards — `motionlint audit` | ||
| ```bash | ||
| # Install (global or one-shot) | ||
| npm install -g motionlint # global install | ||
| npx motionlint review <url> # one-off without install | ||
| MotionLint encodes [Emil Kowalski's](https://emilkowal.ski/) design-engineering standards as a **deterministic linter** — no vision model, no API key, no cost. `motionlint audit` instruments the page, reads the real timing/easing/transform values every animation is running, and grades them: | ||
| # Install Playwright Chromium (one-time per machine, ~300MB) | ||
| npx playwright install chromium | ||
| ``` | ||
| <p align="center"> | ||
| <img src="docs/media/audit-report.gif" width="800" alt="The audit HTML report: score ring, then scrolling through findings — each shows what's happening, why it matters, the fix, and current vs suggested easing curves drawn as graphs"> | ||
| </p> | ||
| **Requires Node 18+**. | ||
| | Category | What it catches | The standard | | ||
| | --- | --- | --- | | ||
| | **Easing** | `ease-in` on UI; weak built-in curves on deliberate entrances | Entering/exiting → strong ease-out `cubic-bezier(0.23, 1, 0.32, 1)`; never `ease-in` | | ||
| | **Duration** | UI motion over the 300ms ceiling (modals/drawers get 200–500ms) | A 180ms transition feels snappier than a 400ms one; exits ~20% faster | | ||
| | **Physicality** | `scale(0)` entrances | Nothing appears from nothing — start from `scale(0.95)` + `opacity: 0` | | ||
| | **Performance** | `transition: all`, animating layout properties, stray infinite loops | Animate `transform` and `opacity` only — they skip layout/paint | | ||
| | **Cohesion** | Hand-rolled easing-curve sprawl; stagger intervals outside the 30–80ms band | Curves and durations should live as shared tokens; grouped entrances stagger 30–80ms apart | | ||
| | **Duration (pairs)** | Exits that aren't faster than their entrance (`fadeIn` 300ms / `fadeOut` 300ms) | Exits run ~20% faster than the matching entrance | | ||
| ### API keys | ||
| MotionLint auto-loads a `.env` file from the working directory at startup. Drop your provider key in: | ||
| ```bash | ||
| # .env (gitignored) | ||
| ANTHROPIC_API_KEY=sk-ant-... | ||
| # or | ||
| OPENAI_API_KEY=sk-... | ||
| # or | ||
| GOOGLE_API_KEY=... | ||
| # or run a local Ollama (no key needed) — MotionLint auto-detects on http://localhost:11434 | ||
| motionlint audit http://localhost:3000 --open # polished HTML report, scored 0–100 | ||
| motionlint audit http://localhost:3000 --json audit.json --ci # machine-readable; non-zero on critical | ||
| ``` | ||
| Real environment variables take precedence over `.env`. With no key set and no Ollama running, MotionLint falls back to a **mock provider** so the full pipeline (capture → analysis → report) still runs end-to-end for smoke tests. | ||
| The report pairs every finding with a **before → after** panel; easing findings render a live cubic-bezier curve comparison so the fix is visible, not just described. The same standards feed the `flow` review prompt (so vision findings cite concrete rules) and appear inline in the Animation Tuner. | ||
| ## Quick start | ||
| ## MCP server — tools, resources, deployment | ||
| ### 1. CLI | ||
| ```bash | ||
| # Single URL, default viewports (mobile + desktop), Markdown report. | ||
| motionlint review http://localhost:3000 | ||
| # Multiple routes, all viewports, video recording, embed screenshots. | ||
| motionlint review http://localhost:3000 \ | ||
| --routes /,/pricing,/signup,/dashboard \ | ||
| --viewports mobile,tablet,desktop \ | ||
| --record \ | ||
| --embed | ||
| # CI mode — exit non-zero on critical issues. | ||
| motionlint review https://staging.acme.dev --ci --threshold critical | ||
| # Pick a provider explicitly. | ||
| motionlint review http://localhost:3000 --provider anthropic --model claude-sonnet-4-20250514 | ||
| motionlint review http://localhost:3000 --provider openai --model gpt-4o | ||
| motionlint review http://localhost:3000 --provider google --model gemini-1.5-pro | ||
| motionlint review http://localhost:3000 --provider ollama --model llava:13b | ||
| # JSON / SARIF for tooling. | ||
| motionlint review http://localhost:3000 --format json -o ux.json | ||
| motionlint review http://localhost:3000 --format sarif -o ux.sarif | ||
| # Run interactions before capture. | ||
| motionlint review http://localhost:3000/signup \ | ||
| --interactions '[{"action":"type","selector":"#email","value":"a@b.co"},{"action":"click","selector":"button[type=submit]"},{"action":"wait","ms":500}]' | ||
| ``` | ||
| ### 2. MCP server (for Claude Code, Cursor, any MCP-aware client) | ||
| MotionLint ships an MCP server over stdio so an LLM agent can drive it directly inside a chat. The `motionlint mcp` subcommand boots it; the agent client spawns the process when a tool is called. | ||
| #### Installation in Claude Code | ||
| ### Installing in Claude Code | ||
| One-liner using the published npm package: | ||
| Published-npm version (recommended): | ||
@@ -250,3 +374,3 @@ ```bash | ||
| Or against a local checkout (handy while developing): | ||
| Local checkout (handy while developing): | ||
@@ -259,5 +383,5 @@ ```bash | ||
| 1. Confirm it appears: `claude mcp list` — you should see `motionlint` with status `running` or `available`. | ||
| 2. Make sure API keys are reachable. The MCP server inherits the env it's spawned in. For Claude Code on macOS, the cleanest path is to put `ANTHROPIC_API_KEY=...` (or `OPENAI_API_KEY` / `GOOGLE_API_KEY`) in a `.env` file in the project directory you're working from — MotionLint auto-loads it on startup. Alternatively export it in your shell before launching CC. | ||
| 3. First run: `npx playwright install chromium` if you haven't already. (MotionLint prints a postinstall reminder when you `npm install` it.) | ||
| 1. Confirm it appears: `claude mcp list` — `motionlint` should show as `running` or `available`. | ||
| 2. Make sure API keys are reachable. The MCP server inherits the env it's spawned in. Cleanest path: drop a `.env` file in the project directory you're working from — MotionLint auto-loads it on startup. | ||
| 3. First run: `npx playwright install chromium` if you haven't already. | ||
@@ -272,18 +396,16 @@ Then in Claude Code: | ||
| #### Tools exposed | ||
| ### Tools exposed | ||
| | Tool | What it does | | ||
| | --- | --- | | ||
| | `review_url(url, viewports?, provider?, model?, wait_for?, record?, format?)` | Static UX review of a URL at multiple viewports. Returns a markdown / JSON / SARIF report. | | ||
| | `review_routes(base_url, routes, viewports?, ...)` | Same review across multiple routes of one app. | | ||
| | `review_flow(url, steps?\|spec_path?, preferences_path?, provider?, ...)` | Animation/interaction review of a scripted user journey. Captures frame bursts after every interaction, builds a contact sheet, returns a flow report with the structured CC handoff block at the bottom. | | ||
| | `tune_animations(url, viewport_*?, settle_ms?, output?)` | Detects every animation on a page and writes an interactive HTML tuner. Returns the file path so the agent can ask the user to open it in their browser. | | ||
| | `review_url(url, viewports?, provider?, model?, wait_for?, record?, format?, max_findings?, max_pr_annotations?, new_only?)` | Static UX review of a URL at multiple viewports. Returns a markdown / JSON / SARIF report. | | ||
| | `review_routes(base_url, routes, viewports?, ..., max_findings?, max_pr_annotations?, new_only?)` | Same review across multiple routes of one app. | | ||
| | `review_flow(url, steps?\|spec_path?, preferences_path?, provider?, ...)` | Animation/interaction review of a scripted user journey. Returns a flow report with the structured CC handoff block. | | ||
| | `tune_animations(url, viewport_*?, settle_ms?, output?)` | Detects every animation on a page and writes an interactive HTML tuner. Returns the file path. | | ||
| | `get_latest_report(format?)` | Returns the most recent review/flow report content. | | ||
| Resources: | ||
| Resources: `motionlint://reports/latest` — the most recent report content. | ||
| - `motionlint://reports/latest` — the most recent report content. | ||
| ### Deployment checklist | ||
| #### Deployment checklist | ||
| Before deploying or sharing the MCP server with other users: | ||
@@ -293,73 +415,24 @@ | ||
| - [ ] **Playwright Chromium installed** on the target machine: `npx playwright install chromium`. The postinstall hook reminds you, but it's not enforced (we don't auto-download a 300 MB binary on `npm install`). | ||
| - [ ] **API keys reachable** — either via shell env or via a `.env` file in the working directory the MCP client launches from. Mock provider works without keys for smoke testing. | ||
| - [ ] **API keys reachable** — either via shell env or via a `.env` file in the working directory the MCP client launches from. | ||
| - [ ] **Smoke-test the MCP surface.** `npm test` includes an MCP smoke test that boots the server, lists tools, and asserts the expected tool surface. | ||
| - [ ] **No secrets committed.** `.env` is gitignored; `.env.example` should be a placeholder. Worth a final `git diff --cached | grep -i 'sk-\|api_key'` before pushing. | ||
| - [ ] **Confirm with `claude mcp list`** that the server shows up and isn't erroring at startup. | ||
| - [ ] **For npm publish:** bump `version` in `package.json`, then `npm publish` (the `prepublishOnly` script runs the build automatically). | ||
| ## Providers | ||
| ## CI integration | ||
| MotionLint auto-detects in this order: **Ollama (local) → Anthropic → OpenAI → Google**. The first one with a working API key (or running service) wins. Override with `--provider`. | ||
| | Provider | Default model | Setup | Stress-test quality (24 fixtures, 2026-04-28) | Cost per review¹ | | ||
| | --- | --- | --- | --- | --- | | ||
| | `openai` | `gpt-5.5` | `OPENAI_API_KEY=…` | **100% recall · 0% FPR · +4.2 gap** | ~$0.005 | | ||
| | `anthropic` | `claude-opus-4-7` | `ANTHROPIC_API_KEY=…` | **100% recall** · 8% FPR · +4.2 gap | ~$0.025 | | ||
| | `anthropic` | `claude-sonnet-4-6` | `ANTHROPIC_API_KEY=…` | **100% recall** · 8% FPR · +5.1 gap | ~$0.005 | | ||
| | `google` | `gemini-3.1-pro-preview` | `GOOGLE_API_KEY=…` | 92% recall · **0% FPR** · +5.5 gap | ~$0.001 | | ||
| | `ollama` | `gemma3:4b` (local) | `ollama serve` + `ollama pull gemma3:4b` | 83% recall · 17% FPR · +1.1 gap | $0 | | ||
| | `mock` | heuristic stub | (auto fallback) | n/a — deterministic stub for CI smoke tests | $0 | | ||
| ¹ Order-of-magnitude estimate per static review at the default 2 viewports. Flow review is 1 image per flow but the contact sheet is bigger; the Animation Tuner makes 0 LLM calls. | ||
| ### How to pick | ||
| - **Best quality, shipping.** OpenAI `gpt-5.5` — the only provider that hit 100% recall *and* 0% false-positive rate on the stress test. | ||
| - **Best Anthropic value.** Anthropic `claude-sonnet-4-6` ties Opus 4.7 on recall and FPR (both 100% / 8%) and is **5× cheaper per token**. Pass `--model claude-opus-4-7` if you specifically want the Opus tier; otherwise Sonnet 4.6 is the better value. | ||
| - **Cost-sensitive CI.** Google `gemini-3.1-pro-preview` — 5× cheaper than Anthropic Sonnet, ~3× faster, 0% FPR, missed one broken pattern. Run the stress test on your own flows before relying on it as a hard merge gate. | ||
| - **Offline / no-network / iteration loops.** Ollama with `gemma3:4b`. Local, free, no rate limits, but the higher FPR (2 of 12 clean implementations got hallucinated criticals) means it's not safe as a CI gate. Use it for prompt-tuning and quick smoke runs. | ||
| If no provider is reachable and you didn't pass `--provider mock`, MotionLint falls back to the **mock** provider so the full pipeline (capture → analysis → report) still runs end-to-end. Set an API key for real analysis. | ||
| ### Switching providers | ||
| ```bash | ||
| # Explicit pick (overrides auto-detect) | ||
| motionlint review http://localhost:3000 --provider anthropic --model claude-sonnet-4-20250514 | ||
| motionlint review http://localhost:3000 --provider openai --model gpt-4o | ||
| motionlint review http://localhost:3000 --provider google --model gemini-1.5-pro | ||
| motionlint review http://localhost:3000 --provider ollama --model llava:13b | ||
| # Same for flow / tune / eval — every command honours --provider and --model. | ||
| motionlint flow --spec flows/signup.json --provider google --model gemini-1.5-pro | ||
| ```yaml | ||
| # .github/workflows/ux.yml | ||
| - run: npm ci | ||
| - run: npx playwright install chromium | ||
| - run: npx motionlint review $STAGING_URL --ci --threshold critical --format sarif -o ux.sarif | ||
| - uses: github/codeql-action/upload-sarif@v3 | ||
| with: { sarif_file: ux.sarif } | ||
| ``` | ||
| ### Benchmarking your own provider | ||
| MotionLint exits with `1` when critical issues exceed the configured threshold (`failOnCritical`) — wire it as a status check. | ||
| To compare a new provider against Anthropic's 100%/0% baseline on the same 24-fixture stress test: | ||
| ## What it captures · what it analyzes | ||
| ```bash | ||
| node -e " | ||
| import('./dist/config/env.js').then(async ({ loadEnv }) => { | ||
| loadEnv(); | ||
| const { runStress, renderStressMarkdown } = await import('./dist/flow/stress.js'); | ||
| const { writeFile, mkdir } = await import('node:fs/promises'); | ||
| const { resolve } = await import('node:path'); | ||
| await mkdir('.motionlint/stress', { recursive: true }); | ||
| const r = await runStress({ | ||
| stressPath: resolve('eval/animation-stress.json'), | ||
| fixturesDir: resolve('eval/animation-fixtures'), | ||
| artifactDir: resolve('.motionlint/stress'), | ||
| provider: 'YOUR_PROVIDER', // 'openai' | 'google' | 'ollama' | ||
| }); | ||
| await writeFile('.motionlint/stress/SCORECARD.md', renderStressMarkdown(r), 'utf8'); | ||
| console.error('Recall:', (r.broken_recall*100).toFixed(0)+'%, FPR:', (r.good_false_positive_rate*100).toFixed(0)+'%, gap:', r.avg_score_gap.toFixed(1)); | ||
| }); | ||
| " | ||
| ``` | ||
| **Captures:** | ||
| Open `.motionlint/stress/SCORECARD.md` for the per-pattern breakdown. | ||
| ## What it captures | ||
| - **Full-page screenshots** at three default viewports (mobile 375 / tablet 768 / desktop 1440). Override via config. | ||
@@ -371,5 +444,8 @@ - **Above-the-fold** screenshots with `--no-full-page`. | ||
| ## What it analyzes | ||
| <p align="center"> | ||
| <img src="docs/media/flow-contact-sheet.png" width="800" alt="A flow burst-capture contact sheet: timestamped frames of the signup form animating, laid out in a grid — this is what the vision model reviews"> | ||
| </p> | ||
| <p align="center"><sub>A <code>motionlint flow</code> contact sheet — timestamped bursts after each interaction, exactly what the vision model sees.</sub></p> | ||
| MotionLint sends each screenshot to a vision model with an opinionated UX-review system prompt covering twelve dimensions (`hierarchy`, `spacing`, `alignment`, `typography`, `color`, `contrast`, `responsiveness`, `interaction`, `content`, `navigation`, `consistency`, `loading_state`). For each issue the model returns: | ||
| **Analyzes:** each screenshot is sent to a vision model with an opinionated UX-review system prompt covering twelve dimensions (`hierarchy`, `spacing`, `alignment`, `typography`, `color`, `contrast`, `responsiveness`, `interaction`, `content`, `navigation`, `consistency`, `loading_state`). For each issue the model returns: | ||
@@ -389,4 +465,12 @@ ```json | ||
| ## Configuration | ||
| Every review capture also takes a **DOM snapshot**: notable elements (headings, CTAs, inputs) get stable refs (`E1`, `E2`, …) with measured pixel rects, listed in the prompt so the model can ground a finding with `"element_ref": "E3"`. Cited refs resolve back to their rects and are **drawn as severity-colored bounding boxes on the screenshot** in the HTML report (and reported as `Where: E3 at (x, y) w×h` in markdown). Refs the page never listed are dropped — the model can't annotate what it wasn't shown. | ||
| With `--format html` the findings render as a single shareable report — score ring, per-dimension breakdown, and an issue → fix panel per finding with the annotated screenshot: | ||
| <p align="center"> | ||
| <img src="docs/media/review-report.gif" width="720" alt="The HTML review report: score ring animates in, then issue-to-fix panels with embedded screenshots scroll past"> | ||
| </p> | ||
| ## Configuration reference | ||
| Drop a `.motionlintrc.json` in your repo root (or use `motionlint.config.js` / a `"motionlint"` key in `package.json`): | ||
@@ -398,3 +482,3 @@ | ||
| "fallbackProvider": "anthropic", | ||
| "fallbackModel": "claude-sonnet-4-20250514", | ||
| "fallbackModel": "claude-sonnet-5", | ||
| "viewports": { | ||
@@ -413,2 +497,11 @@ "mobile": { "width": 375, "height": 812 }, | ||
| "record": false, | ||
| "maxFindings": null, | ||
| "maxPrAnnotations": null, | ||
| "memory": { | ||
| "enabled": true, | ||
| "path": ".motionlint/memory.json", | ||
| "baseline": ".motionlintignore", | ||
| "newOnly": false | ||
| }, | ||
| "resources": { "maxConcurrentReviews": null, "providerCallsPerMinute": null, "maxTokensPerRun": null }, | ||
| "ci": { "threshold": "warning", "failOnCritical": true }, | ||
@@ -419,27 +512,23 @@ "auth": { "cookies": null, "localStorage": null, "beforeNavigate": null } | ||
| ## CI integration | ||
| ## Review volume control | ||
| ```yaml | ||
| # .github/workflows/ux.yml | ||
| - run: npm ci | ||
| - run: npx playwright install chromium | ||
| - run: npx motionlint review $STAGING_URL --ci --threshold critical --format sarif -o ux.sarif | ||
| - uses: github/codeql-action/upload-sarif@v3 | ||
| with: { sarif_file: ux.sarif } | ||
| ``` | ||
| Re-running review on the same routes used to surface the same findings every run. Two mechanisms keep the output focused: | ||
| MotionLint exits with `1` when critical issues exceed the configured threshold (`failOnCritical`) — wire it as a status check. | ||
| - **Per-run output cap** — `--max-findings N` (or `maxFindings` in config) keeps only the top N findings per run, severity-ordered, so an agent works on what matters most first. The report's `Omitted` line says how many were capped. | ||
| - **PR-surface cap** — `--max-pr-annotations N` (or `maxPrAnnotations` in config; SARIF only) emits at most N results per report, severity-ordered, so a code-scanning upload doesn't flood a PR with annotations. The dropped count lands in the SARIF run's `omitted_by_pr_cap` property. | ||
| - **Resource cap** — `resources.maxConcurrentReviews` bounds how many reviews run at once in one process (an MCP server fielding several agents in flight), and `resources.providerCallsPerMinute` is a process-wide sliding-window ceiling on vision-LLM calls (provider quota / spend control; also applies to `flow` reviews, where each `--consistency` sample counts). Both default to unlimited; both are config-only. Note they compose: a review holding a concurrency slot also waits out the rate limiter, so tight values on both multiply latency. | ||
| - **Cost ceiling** — every provider call's token usage is captured and totalled per run (a `Tokens:` line in reports, `token_usage` in SARIF run properties). `--max-tokens N` (or `resources.maxTokensPerRun` in config) sets a per-run token budget: once the running total crosses it, remaining viewports are skipped and the report lists them under `skipped_viewports`. Providers that report no usage still count calls but consume no budget. | ||
| - **Cross-run memory** — every finding gets a stable id (hash of category + element location + normalized issue text). Recurrence detection goes further than exact hashing: category-synonym compatibility plus canonical-token overlap (thresholds calibrated on real cross-run data) matches the same fault even when the vision LLM rewords it between runs. Sightings are recorded per URL in `.motionlint/memory.json`; recurring findings are annotated with *seen in N prior runs* rather than silently dropped. Opt into deltas-only with `--new-only`. To permanently wave off a finding, copy its id into `.motionlintignore` (one hash per line, `#` comments and trailing notes allowed). Disable everything with `--no-memory`. | ||
| ## Try the demo | ||
| SARIF output carries the finding id as a `partialFingerprint`, so GitHub code scanning dedups the same finding across runs and PRs natively. | ||
| A multi-route TS animation showcase ships in `demo/` (Motion One · GSAP · anime.js · @formkit/auto-animate · lottie-web): | ||
| Concurrent reviews of the same project are safe: the memory store is updated under a stale-aware file lock (`memory.json.lock`), so parallel runs don't clobber each other's recorded sightings. A wedged lock never fails a review — after a short wait the run warns and proceeds without it. | ||
| ```bash | ||
| node demo/server.mjs # http://localhost:4173 | ||
| motionlint review http://localhost:4173 \ | ||
| --routes /,/pricing,/signup,/dashboard,/loading \ | ||
| --viewports mobile,tablet,desktop --record --embed | ||
| ``` | ||
| ## Use cases | ||
| Reports go to `.motionlint/reports/`, screenshots to `.motionlint/screenshots/`, videos to `.motionlint/videos/`. | ||
| - **Pre-merge UX guardrail.** Solo dev or 2-person startup with no designer. Run `motionlint review https://pr-123.preview.example.com --ci --threshold critical` in CI; warning-or-worse blocks the merge until you've at least seen the issues. | ||
| - **MCP design colleague inside Claude Code.** Add MotionLint as an MCP server, then ask CC: *"review the local app at mobile and desktop and tell me the top 3 issues to fix."* CC drives the tool and gets back annotated feedback in the same conversation. | ||
| - **Continuous quality monitoring.** Schedule a nightly cron (`motionlint review https://prod.example.com --format sarif -o ux.sarif`) and surface SARIF in your code-scanning dashboard so production regressions get caught the morning after. | ||
| - **Animation / flow QA on a feature you just shipped.** `motionlint flow` runs a scripted user journey through Playwright like a human would, captures frame bursts at every interaction, records video, and asks the LLM to review the *animation behavior* across the captured frames. | ||
| - **Live animation tuning + handoff to Claude Code.** Capture every animation on a page, tune timing/easing/delay live with sliders, export a structured prompt CC can act on directly. | ||
@@ -475,3 +564,3 @@ ## Project layout | ||
| - Flow review at **50ms inter-frame intervals** via CDP screencast (16 frames × 750ms burst), with multi-animation and scroll-driven support. | ||
| - Animation Tuner with Shadow-DOM previews (no iframe flash), live sliders, easing presets, Claude-Code export. | ||
| - Animation Tuner with Shadow-DOM previews, live sliders, easing presets, Claude-Code export. | ||
| - Animation stress-test harness validated at **100% recall / 0% FPR on 24 fixtures across 12 patterns**. | ||
@@ -482,13 +571,25 @@ - Team motion preferences markdown (`--preferences`) embedded into the LLM rubric and the CC handoff block. | ||
| **v0.2 (in progress)** — shipped so far: | ||
| - Token accounting + per-run cost ceiling (`--max-tokens` / `resources.maxTokensPerRun`; `Tokens:` line in every report). | ||
| - Auto-discover routes (`--discover-routes`: sitemap.xml + Next.js app directory). | ||
| - Annotated bounding boxes: DOM element refs in the prompt, findings drawn on the screenshot in the HTML report. | ||
| - Interaction-state grids (`--state-grid`: default/hover/focus/active per element, one labeled image). | ||
| - Provider scorecard history with per-model regression detection (`.motionlint/eval-history.json`). | ||
| - Closed-loop prompt evolution from eval `next_actions` (`eval --evolve` → learned heuristics in review prompts). | ||
| - Two new audit rules: stagger-interval band (30–80ms) and exit-~20%-faster-than-entrance. | ||
| **v0.2 (next)**: | ||
| - Auto-discover routes (Next.js app directory / sitemap.xml). | ||
| - Interaction-state grids (capture hover/focus/loading variants of the same element in one shot). | ||
| - Annotated bounding boxes on screenshots showing where each finding lives. | ||
| - Closed-loop prompt evolution from eval `next_actions` (auto-tune the system prompt across runs). | ||
| - GitHub Action wrapper (`motionlint-action`). | ||
| - Provider scorecard tracking (per-model regression detection across releases). | ||
| ## Acknowledgments | ||
| MotionLint stands on other people's work: | ||
| - **[Emil Kowalski](https://emilkowal.ski/)** — the animation standards behind `motionlint audit`, the tuner's easing presets, and the flow-review rubric are distilled from his design-engineering writing and his [animations.dev](https://animations.dev/) course. His open-source UI libraries — [sonner](https://github.com/emilkowalski/sonner) (toasts) and [vaul](https://github.com/emilkowalski/vaul) (drawers) — are living reference implementations of the motion these rules describe. MotionLint is an independent project, not affiliated with or endorsed by Emil. | ||
| - **[ctx](https://github.com/ctxrs/ctx)** ([ctx.rs](https://ctx.rs)) — local coding-agent history search. We used it while developing the cross-run memory layer to study how findings survive (or vanish) across agent runs; those experiments directly shaped the finding-id and baseline design. | ||
| ## License | ||
| [MIT](LICENSE) © Resila Technologies Inc. |
Uses eval
Supply chain riskPackage uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 3 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
465447
58.9%138
38%9067
56.35%576
21.26%42
35.48%11
57.14%