🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

motionlint

Package Overview
Dependencies
Maintainers
1
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

motionlint - npm Package Compare versions

Comparing version
0.2.0
to
0.2.1
+5
dist/capture/pair.d.ts
export interface StripPanel {
label: string;
png: Buffer;
}
export declare function composeLabeledStrip(panels: StripPanel[]): Promise<Buffer>;
/**
* Composes labeled screenshots side by side into one strip — used by
* comparison review (CURRENT | BASELINE) and the color-scheme sweep
* (LIGHT | DARK | FORCED COLORS). Panels are scaled to a common row height
* so the model compares like with like.
*/
import sharp from "sharp";
const LABEL_BAR_H = 44;
const GUTTER = 16;
const MAX_ROW_H = 1200;
function escapeXml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function labelSvg(text, width) {
return Buffer.from(`<svg width="${width}" height="${LABEL_BAR_H}" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#111318"/>
<text x="14" y="${LABEL_BAR_H / 2 + 5}" font-family="Menlo, monospace" font-size="16" fill="#e8e8ec" letter-spacing="2">${escapeXml(text.toUpperCase())}</text>
</svg>`);
}
export async function composeLabeledStrip(panels) {
if (panels.length < 2)
throw new Error("composeLabeledStrip needs at least two panels");
const metas = await Promise.all(panels.map((p) => sharp(p.png).metadata()));
const rowH = Math.min(MAX_ROW_H, ...metas.map((m) => m.height ?? MAX_ROW_H));
const resized = await Promise.all(panels.map(async (p) => {
const buf = await sharp(p.png).resize({ height: rowH, fit: "inside" }).png().toBuffer();
const meta = await sharp(buf).metadata();
return { label: p.label, png: buf, width: meta.width ?? 0 };
}));
const totalW = resized.reduce((sum, p) => sum + p.width, 0) + GUTTER * (resized.length - 1);
const totalH = LABEL_BAR_H + rowH;
const composites = [];
let x = 0;
for (const p of resized) {
composites.push({ input: labelSvg(p.label, p.width), left: x, top: 0 });
composites.push({ input: p.png, left: x, top: LABEL_BAR_H });
x += p.width + GUTTER;
}
return sharp({
create: { width: totalW, height: totalH, channels: 3, background: { r: 17, g: 19, b: 24 } },
})
.composite(composites)
.png()
.toBuffer();
}
/**
* Debounced, coalescing rerun queue for watch mode. File-change events
* arrive in bursts; at most one run is in flight, and any notifications
* during a run collapse into exactly one follow-up run.
*/
export interface RerunController {
notify: () => void;
stop: () => void;
}
export declare function createRerunQueue(run: () => Promise<void>, debounceMs?: number): RerunController;
/**
* True when a watch event should be ignored because it was caused by our
* own output (report/json writes inside the watched directory), rather than
* a real source change worth rerunning for. Without this, `--watch <dir>`
* that overlaps the report's output directory self-triggers forever.
*/
export declare function isOwnOutputEvent(watchDir: string, filename: string | null, ignorePaths: string[]): boolean;
import { dirname, join, resolve, sep } from "node:path";
export function createRerunQueue(run, debounceMs = 300) {
let timer = null;
let running = false;
let dirty = false;
let stopped = false;
const kick = () => {
if (stopped || running)
return;
running = true;
dirty = false;
void run()
.catch(() => { })
.finally(() => {
running = false;
if (dirty && !stopped)
schedule();
});
};
const schedule = () => {
if (stopped)
return;
if (timer)
clearTimeout(timer);
timer = setTimeout(kick, debounceMs);
};
return {
notify: () => {
if (stopped)
return;
dirty = true;
if (!running)
schedule();
},
stop: () => {
stopped = true;
if (timer)
clearTimeout(timer);
},
};
}
/**
* True when a watch event should be ignored because it was caused by our
* own output (report/json writes inside the watched directory), rather than
* a real source change worth rerunning for. Without this, `--watch <dir>`
* that overlaps the report's output directory self-triggers forever.
*/
export function isOwnOutputEvent(watchDir, filename, ignorePaths) {
if (filename === null)
return false;
// Any path segment named .motionlint is our own scratch/report directory,
// regardless of which specific output paths were configured.
if (filename.split(sep).includes(".motionlint"))
return true;
const resolvedEvent = resolve(join(watchDir, filename));
for (const raw of ignorePaths) {
const ignorePath = resolve(raw);
if (resolvedEvent === ignorePath)
return true;
const ignoreDir = dirname(ignorePath);
if (resolvedEvent === ignoreDir || resolvedEvent.startsWith(ignoreDir + sep))
return true;
}
return false;
}
import type { FlowCaptureResult } from "./types.js";
export interface LatencyMeasurement {
step_index: number;
step_label: string;
action: string;
/** ms from the burst start to the first visually-changed frame; null = nothing changed. */
feedback_ms: number | null;
/** How long the burst watched for a change. */
burst_window_ms: number;
verdict: "instant" | "delayed" | "none";
}
/** Mean absolute grayscale delta (0–255) above which a frame counts as changed. */
export declare const FEEDBACK_DIFF_THRESHOLD = 1;
/** Perceived-instant ceiling (NN/g: <100ms feels immediate). */
export declare const INSTANT_MS = 100;
export declare function frameDiffScore(a: Buffer, b: Buffer): Promise<number>;
export declare function measureFeedbackLatency(capture: FlowCaptureResult): Promise<LatencyMeasurement[]>;
/**
* Deterministic input→feedback latency from flow frame bursts. Each burst's
* frames are pixel-diffed against the burst's first frame; the first frame
* that visibly differs marks when the UI acknowledged the interaction.
*
* Limitation: feedback that completed entirely before the first frame
* (< one burst interval, default 50ms) is invisible to this measurement —
* continuing animation (spinners, transitions) still registers on later
* frames, so "none" verdicts are trustworthy for missing loading feedback.
*/
import sharp from "sharp";
/** Mean absolute grayscale delta (0–255) above which a frame counts as changed. */
export const FEEDBACK_DIFF_THRESHOLD = 1.0;
/** Perceived-instant ceiling (NN/g: <100ms feels immediate). */
export const INSTANT_MS = 100;
/** Interactions that should produce visible acknowledgment. */
const FEEDBACK_ACTIONS = new Set(["click", "type", "press"]);
export async function frameDiffScore(a, b) {
const norm = (png) => sharp(png).resize(64, 64, { fit: "fill" }).grayscale().raw().toBuffer();
const [ra, rb] = await Promise.all([norm(a), norm(b)]);
let sum = 0;
for (let i = 0; i < ra.length; i++)
sum += Math.abs(ra[i] - rb[i]);
return sum / ra.length;
}
export async function measureFeedbackLatency(capture) {
const out = [];
for (const step of capture.step_results) {
if (!step.success)
continue;
if (!FEEDBACK_ACTIONS.has(step.step.do))
continue;
if (step.frame_indices.length < 2)
continue;
const frames = step.frame_indices
.map((i) => capture.frames[i])
.filter(Boolean)
.sort((a, b) => a.t_offset_ms - b.t_offset_ms);
if (frames.length < 2)
continue;
const base = frames[0];
let feedback_ms = null;
for (const f of frames.slice(1)) {
if ((await frameDiffScore(base.png, f.png)) > FEEDBACK_DIFF_THRESHOLD) {
feedback_ms = f.t_offset_ms - base.t_offset_ms;
break;
}
}
out.push({
step_index: step.step_index,
step_label: frames[0].step_label,
action: step.step.do,
feedback_ms,
burst_window_ms: frames[frames.length - 1].t_offset_ms - base.t_offset_ms,
verdict: feedback_ms === null ? "none" : feedback_ms <= INSTANT_MS ? "instant" : "delayed",
});
}
return out;
}
/**
* Deterministic layout linter: converts DomSnapshot measurements (already
* captured for every review) into cited findings. No LLM, no extra probing —
* the numbers were measured by src/capture/dom.ts on the live page.
*/
import type { DomSnapshot } from "../capture/dom.js";
import type { IssueSeverity } from "../types.js";
export type LayoutFindingCategory = "tap_target" | "typography" | "overflow" | "contrast" | "cohesion" | "content";
export interface LayoutFinding {
category: LayoutFindingCategory;
severity: IssueSeverity;
title: string;
/** What is wrong (concrete, with the measured value). */
detail: string;
/** User impact, one sentence. */
why: string;
/** Specific, actionable fix. */
fix: string;
/** The standard cited (WCAG / HIG / house rule). */
standard: string;
/** Where on the page (element text or selector). */
location: string;
}
export interface LayoutAudit {
url: string;
findings: LayoutFinding[];
critical_count: number;
warning_count: number;
suggestion_count: number;
/** 0–100, house weights: critical 25 / warning 10 / suggestion 3. */
score: number;
}
export declare const TAP_TARGET_MIN_PX = 44;
export declare const BODY_FONT_MIN_PX = 16;
export declare const TEXT_FLOOR_PX = 12;
export declare const CONTRAST_MIN_RATIO = 4.5;
export declare const TYPE_SIZE_MAX = 8;
export declare function lintLayout(s: DomSnapshot): LayoutFinding[];
export declare function auditLayout(s: DomSnapshot): LayoutAudit;
export const TAP_TARGET_MIN_PX = 44;
export const BODY_FONT_MIN_PX = 16;
export const TEXT_FLOOR_PX = 12;
export const CONTRAST_MIN_RATIO = 4.5;
export const TYPE_SIZE_MAX = 8;
const SEVERITY_RANK = { critical: 0, warning: 1, suggestion: 2 };
export function lintLayout(s) {
const findings = [];
if (s.horizontal_overflow) {
findings.push({
category: "overflow",
severity: "critical",
title: "Page overflows horizontally",
detail: `The document is ${s.overflow_amount_px}px wider than the viewport.`,
why: "Horizontal scroll on a vertical page reads as broken layout and hides content.",
fix: "Find the overflowing element (often a fixed-width image, table, or negative margin) and constrain it with max-width: 100% or overflow-x: auto on its container.",
standard: "No unintended horizontal scroll at any supported viewport",
location: "document",
});
}
for (const t of s.small_tap_targets) {
findings.push({
category: "tap_target",
severity: "warning",
title: "Tap target below the 44px floor",
detail: `<${t.tag}> "${t.text}" measures ${Math.round(t.rect.w)}x${Math.round(t.rect.h)}px (${t.reason}).`,
why: "Small targets cause mis-taps, especially one-handed on mobile.",
fix: `Grow the hit area to at least ${TAP_TARGET_MIN_PX}px in both dimensions — padding counts, visual size doesn't have to change.`,
standard: `Tap targets ≥ ${TAP_TARGET_MIN_PX}px (WCAG 2.5.8, Apple HIG 44pt)`,
location: t.text || `<${t.tag}>`,
});
}
if (s.body_font_px !== null && s.body_font_px < BODY_FONT_MIN_PX) {
findings.push({
category: "typography",
severity: "suggestion",
title: "Body text below 16px",
detail: `Body copy computes to ${s.body_font_px}px.`,
why: "Sub-16px body text reduces readability for users over 35 and on high-DPI screens.",
fix: `Bump body copy to ${BODY_FONT_MIN_PX}px / line-height 1.5; reserve 14px for captions only.`,
standard: `Body text ≥ ${BODY_FONT_MIN_PX}px`,
location: "body copy",
});
}
if (s.smallest_text !== null && s.smallest_text.px < TEXT_FLOOR_PX) {
findings.push({
category: "typography",
severity: "warning",
title: `Text below the ${TEXT_FLOOR_PX}px floor`,
detail: `Smallest rendered text is ${s.smallest_text.px}px ("${s.smallest_text.sample}").`,
why: "Text this small is illegible for a large share of users and fails zoom expectations.",
fix: `Raise it to at least ${TEXT_FLOOR_PX}px, or cut the copy if it isn't worth reading.`,
standard: `No rendered text below ${TEXT_FLOOR_PX}px`,
location: s.smallest_text.sample,
});
}
for (const pair of s.computed_color_pairs_under_threshold) {
findings.push({
category: "contrast",
severity: "warning",
title: "Text contrast under 4.5:1",
detail: `"${pair.text}" estimates ${pair.ratio_estimate}:1 against its background.`,
why: "Low-contrast text is unreadable in sunlight and for low-vision users.",
fix: "Darken the text or lighten the background until the ratio clears 4.5:1 (large text may use 3:1).",
standard: `Text contrast ≥ ${CONTRAST_MIN_RATIO}:1 (WCAG 1.4.3 AA)`,
location: pair.text,
});
}
if (s.type_size_count > TYPE_SIZE_MAX) {
findings.push({
category: "cohesion",
severity: "suggestion",
title: "Type-size sprawl",
detail: `${s.type_size_count} distinct font sizes render on this page.`,
why: "A sprawling type scale reads as unintentional and weakens hierarchy.",
fix: `Consolidate to a deliberate scale (≤ ${TYPE_SIZE_MAX} sizes is the healthy band).`,
standard: `≤ ${TYPE_SIZE_MAX} distinct type sizes per page`,
location: "document",
});
}
for (const empty of s.empty_lists) {
findings.push({
category: "content",
severity: "suggestion",
title: "Empty list container",
detail: `${empty.selector} renders ${Math.round(empty.rect.w)}x${Math.round(empty.rect.h)}px with no items.`,
why: "An empty region reads as a bug; an empty state is an invitation to act.",
fix: "Render an explicit empty state (message + next action) when the list has no items.",
standard: "Empty states are designed, not blank",
location: empty.selector,
});
}
return findings.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
}
export function auditLayout(s) {
const findings = lintLayout(s);
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;
const penalty = critical_count * 25 + warning_count * 10 + suggestion_count * 3;
return {
url: s.url,
findings,
critical_count,
warning_count,
suggestion_count,
score: Math.max(0, 100 - penalty),
};
}
+8
-0

@@ -35,3 +35,11 @@ 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}";

learned?: string | null;
/** Before/after comparison mode — the image is CURRENT | BASELINE side by side. */
compare?: {
baselineUrl: string;
};
/** Color-scheme sweep mode — the image shows the same page under multiple schemes. */
schemePair?: {
schemes: string[];
};
}
export declare function buildPrompt(opts: PromptOptions): Promise<string>;

@@ -98,2 +98,18 @@ import { readFile } from "node:fs/promises";

}
if (opts.compare) {
parts.push(`\n\n## Comparison mode\n` +
`This image shows TWO captures of the same page side by side, each under a labeled bar: ` +
`LEFT is the CURRENT build under review; RIGHT is the BASELINE (${opts.compare.baselineUrl}). ` +
`Report only differences between the two. Regressions the current build introduces are issues ` +
`(location must name the affected region, prefixed "[current]"). Improvements belong in strengths. ` +
`If the two sides are visually identical, return an empty issues array and say so in the summary.`);
}
if (opts.schemePair) {
parts.push(`\n\n## Color-scheme sweep\n` +
`This image shows the SAME page rendered under different color schemes (${opts.schemePair.schemes.join(" / ")}), ` +
`each under a labeled bar. Judge each non-light rendering on its own merits AND against the light rendering: ` +
`unreadable or low-contrast text, hardcoded light backgrounds or images that ignore prefers-color-scheme, ` +
`borders/dividers that vanish, unstyled form controls, illegible logos or icons. ` +
`Prefix each issue's location with the scheme it appears in, e.g. "[dark]".`);
}
if (opts.elements?.length) {

@@ -100,0 +116,0 @@ 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`);

+2
-0

@@ -13,2 +13,4 @@ import { type Browser, type BrowserContext, type Page } from "playwright";

auth?: AuthConfig;
colorScheme?: "light" | "dark";
forcedColors?: boolean;
}

@@ -15,0 +17,0 @@ /**

@@ -38,2 +38,4 @@ import { chromium } from "playwright";

: undefined,
colorScheme: opts.colorScheme,
forcedColors: opts.forcedColors ? "active" : undefined,
});

@@ -40,0 +42,0 @@ if (opts.auth?.cookies?.length) {

@@ -21,2 +21,8 @@ export interface SitemapParse {

export declare function discoverNextAppRoutes(cwd: string): Promise<string[]>;
/**
* Map a target URL's path+query onto a baseline URL's origin — used when one
* --against baseline must be compared against many discovered routes. A
* single-target review uses the baseline verbatim instead.
*/
export declare function mapPathOntoBaseline(target: string, baseline: string): string;
export interface DiscoverRoutesOptions {

@@ -30,1 +36,9 @@ url: string;

export declare function discoverRoutes(opts: DiscoverRoutesOptions): Promise<string[]>;
/**
* Discover Storybook stories via the static index (`/index.json`, Storybook 7+).
* Returns iframe paths reviewable as plain routes. Best-effort: any failure → [].
*/
export declare function discoverStorybookStories(baseUrl: string, opts?: {
fetchImpl?: typeof fetch;
limit?: number;
}): Promise<string[]>;

@@ -115,2 +115,11 @@ /**

}
/**
* Map a target URL's path+query onto a baseline URL's origin — used when one
* --against baseline must be compared against many discovered routes. A
* single-target review uses the baseline verbatim instead.
*/
export function mapPathOntoBaseline(target, baseline) {
const t = new URL(target);
return new URL(t.pathname + t.search, baseline).toString();
}
/** Merge sitemap + Next.js app-dir discovery: deduped, "/" first, capped. */

@@ -127,1 +136,24 @@ export async function discoverRoutes(opts) {

}
/**
* Discover Storybook stories via the static index (`/index.json`, Storybook 7+).
* Returns iframe paths reviewable as plain routes. Best-effort: any failure → [].
*/
export async function discoverStorybookStories(baseUrl, opts = {}) {
const fetchImpl = opts.fetchImpl ?? fetch;
const limit = opts.limit ?? 20;
try {
const res = await fetchImpl(new URL("/index.json", baseUrl).toString());
if (!res.ok)
return [];
const json = (await res.json());
if (!json.entries)
return [];
return Object.values(json.entries)
.filter((e) => e.type === "story" && typeof e.id === "string")
.map((e) => `/iframe.html?id=${encodeURIComponent(e.id)}&viewMode=story`)
.slice(0, limit);
}
catch {
return [];
}
}

@@ -51,2 +51,4 @@ import { mkdir, writeFile } from "node:fs/promises";

auth: opts.auth,
colorScheme: opts.colorScheme,
forcedColors: opts.forcedColors,
});

@@ -53,0 +55,0 @@ const page = await session.context.newPage();

@@ -7,3 +7,3 @@ import { Command } from "commander";

import { parseInteractionsFromString } from "../capture/interactions.js";
import { discoverRoutes } from "../capture/discover.js";
import { discoverRoutes, mapPathOntoBaseline } from "../capture/discover.js";
import { appendRun, detectRegressions, loadHistory, recordFromReport, saveHistory } from "../eval/history.js";

@@ -30,2 +30,3 @@ import { buildAddenda, loadAddendaLines, saveAddenda } from "../eval/evolve.js";

.option("--discover-routes", "Auto-discover routes from /sitemap.xml and a Next.js app directory in cwd.", false)
.option("--storybook", "Treat the URL as a Storybook: discover stories from /index.json and review each story iframe.", false)
.option("-v, --viewport <name>", "Single viewport (mobile|tablet|desktop). Repeatable: -v mobile -v desktop.", collectViewport, [])

@@ -44,2 +45,5 @@ .option("--viewports <list>", "Comma-separated viewport names.")

.option("--state-grid", "Also capture an interaction-state grid (default/hover/focus/active per element) and review it.", false)
.option("--against <url>", "Compare against a baseline URL (e.g. production): reviews CURRENT vs BASELINE side by side and reports only differences.")
.option("--schemes", "Also capture each viewport under prefers-color-scheme: dark and review light|dark side by side.", false)
.option("--forced-colors", "Add a forced-colors (Windows High Contrast) panel to the --schemes strip.", false)
.option("--ci", "Exit with non-zero code if issues exceed the configured threshold.", false)

@@ -144,5 +148,7 @@ .option("--threshold <severity>", `CI severity threshold: ${VALID_SEVERITIES.join("|")}.`)

.option("--settle <ms>", "Time to wait after load for animations to register.", "1500")
.option("--layout", "Also lint layout (tap targets, text size, contrast, overflow) from DOM measurements. Deterministic — no vision model.", false)
.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)
.option("--watch [dir]", "Re-run the audit when files under [dir] (default: cwd) change; prints score deltas until Ctrl-C.")
.action(async (url, opts) => {

@@ -282,38 +288,118 @@ try {

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)}`);
// Tracks the combined critical count from the most recent run, for the
// --ci exit check below (kept outside runOnce so watch mode's repeated
// reruns don't need to thread it through the return value).
let lastCriticalCount = 0;
const runOnce = async (allowOpen) => {
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);
let layout;
if (opts.layout) {
const { captureScreenshot } = await import("../capture/screenshot.js");
const { auditLayout } = await import("../lint/layout.js");
if (!opts.quiet)
console.error(kleur.cyan(`→ Measuring layout…`));
const cap = await captureScreenshot({
url,
viewport: { name: "audit", width: w || 1280, height: h || 800 },
fullPage: true,
withDom: true,
});
if (cap.dom) {
layout = auditLayout(cap.dom);
if (!opts.quiet)
console.error(kleur.gray(` layout score ${layout.score}/100 · ${layout.findings.length} finding(s)`));
}
}
if (audit.findings.length > 12)
console.error(kleur.dim(` …and ${audit.findings.length - 12} more (see the report)`));
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, layout), "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 });
const jsonPayload = layout ? { ...audit, layout } : audit;
await writeFile(resolvePath(opts.json), JSON.stringify(jsonPayload, null, 2), "utf8");
console.error(kleur.green(` json → ${opts.json}`));
}
if (opts.open && allowOpen) {
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();
}
lastCriticalCount = audit.critical_count + (layout?.critical_count ?? 0);
return audit.score;
};
if (opts.watch === undefined || opts.watch === false) {
await runOnce(true);
if (opts.ci && lastCriticalCount > 0)
process.exit(1);
return;
}
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.ci)
console.error(kleur.yellow(" --ci is ignored in watch mode."));
const { watch } = await import("node:fs");
const { createRerunQueue, isOwnOutputEvent } = await import("./watch.js");
const dir = typeof opts.watch === "string" ? opts.watch : process.cwd();
// Our own report writes (below) land inside `dir` by default (cwd), so the
// watcher must recognize and ignore them — otherwise every rerun retriggers
// itself forever, each one launching headless Chromium.
const ignorePaths = [resolvePath(opts.output ?? ".motionlint/audit/index.html")];
if (opts.json)
ignorePaths.push(resolvePath(opts.json));
let lastScore = null;
let isFirstRun = true;
const timestamp = () => new Date().toTimeString().slice(0, 8);
const runAndReport = async () => {
const allowOpen = isFirstRun;
isFirstRun = false;
try {
const score = await runOnce(allowOpen);
const delta = lastScore === null ? "" : ` (Δ ${score - lastScore >= 0 ? "+" : ""}${score - lastScore})`;
console.error(kleur.cyan(`[${timestamp()}] audit ${score}/100${delta}`));
lastScore = score;
}
catch (err) {
// A failed rerun must not kill the watch loop — report it and keep watching.
console.error(kleur.red(`[${timestamp()}] audit failed: ${err.message}`));
}
};
await runAndReport();
const queue = createRerunQueue(runAndReport, 300);
let watcher;
try {
watcher = watch(dir, { recursive: true }, (_event, filename) => {
if (!isOwnOutputEvent(dir, filename, ignorePaths))
queue.notify();
});
}
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();
catch (err) {
console.error(kleur.red(`--watch needs recursive fs.watch (macOS, Windows, or Linux with Node 20+): ${err.message}`));
process.exit(1);
}
if (opts.ci && audit.critical_count > 0)
process.exit(1);
console.error(kleur.gray(` watching ${dir} — Ctrl-C to stop`));
await new Promise((resolveDone) => {
process.once("SIGINT", () => {
queue.stop();
watcher.close();
resolveDone();
});
});
}

@@ -507,2 +593,34 @@ async function runEvalCommand(opts) {

}
if (opts.storybook) {
const { discoverStorybookStories } = await import("../capture/discover.js");
const stories = await discoverStorybookStories(rawUrl);
if (stories.length === 0) {
throw new Error("No Storybook stories found at /index.json — is this a Storybook 7+ URL?");
}
const base = new URL(rawUrl);
const merged = new Set(targets.filter((t) => t !== rawUrl)); // review stories, not the manager shell
for (const path of stories) {
merged.add(new URL(path, base).toString());
}
targets = [...merged];
if (!opts.quiet) {
console.error(kleur.gray(` discovered ${stories.length} story(ies) → reviewing ${targets.length} URL(s)`));
}
}
// With multiple targets (--routes / --discover-routes / --storybook), a
// single flat --against URL must not be reused verbatim for every route —
// that would compare, say, /pricing against the baseline's homepage. Map
// each target's path+search onto the baseline origin instead. A single
// target, though, uses the baseline exactly as the user typed it — mapping
// it would silently override a deliberately different baseline path.
const baselineFor = (target) => {
if (!opts.against)
return null;
if (targets.length > 1)
return mapPathOntoBaseline(target, opts.against);
return opts.against;
};
if (opts.against && targets.length > 1 && !opts.quiet) {
console.error(kleur.gray(` --against maps each route onto ${new URL(opts.against).origin}`));
}
let highestExit = 0;

@@ -523,2 +641,5 @@ for (const url of targets) {

stateGrid: opts.stateGrid ?? false,
againstUrl: baselineFor(url),
schemes: opts.schemes ?? false,
forcedColors: opts.forcedColors ?? false,
format,

@@ -525,0 +646,0 @@ outputPath: opts.output === false ? null : opts.output ?? undefined,

@@ -129,2 +129,18 @@ import { isAbsolute, relative } from "node:path";

}
if (report.latency?.length) {
lines.push("", "## Input feedback latency", "");
lines.push("| Step | Action | Feedback | Verdict |", "| --- | --- | --- | --- |");
for (const m of report.latency) {
const feedback = m.feedback_ms === null
? `no visual feedback within ${m.burst_window_ms}ms`
: `${m.feedback_ms}ms`;
lines.push(`| ${m.step_label} | ${m.action} | ${feedback} | ${m.verdict} |`);
}
const bad = report.latency.filter((m) => m.verdict !== "instant");
for (const m of bad) {
lines.push("", m.verdict === "none"
? `- ⚠ **${m.step_label}**: the UI never visibly acknowledged the ${m.action} — add immediate feedback (pressed state, spinner, skeleton) within 100ms.`
: `- ⚠ **${m.step_label}**: first feedback at ${m.feedback_ms}ms — aim for <100ms perceived-instant acknowledgment.`);
}
}
if (report.analysis.strengths.length > 0) {

@@ -131,0 +147,0 @@ lines.push(`## Strengths`);

@@ -45,2 +45,4 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";

opts.onProgress?.({ type: "capture_finished", total_frames: capture.frames.length, duration_ms: capture.total_duration_ms });
const { measureFeedbackLatency } = await import("./latency.js");
const latency = await measureFeedbackLatency(capture);
// Build the contact sheet.

@@ -82,2 +84,3 @@ const sheetBuf = await buildContactSheet(capture.frames);

analysis,
latency,
contact_sheet_path: sheetPath,

@@ -84,0 +87,0 @@ video_path: capture.video_path,

@@ -83,2 +83,4 @@ import type { AnalysisResult, Viewport } from "../types.js";

analysis: AnalysisResult;
/** Deterministic input→feedback measurements, one per interaction burst. */
latency?: import("./latency.js").LatencyMeasurement[];
contact_sheet_path?: string;

@@ -85,0 +87,0 @@ video_path?: string;

@@ -5,5 +5,18 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";

import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { runReview } from "../pipeline.js";
import { loadConfig } from "../config/loader.js";
import { sharedReviewGate } from "../resources/limiter.js";
/**
* Reported to MCP clients in the initialize handshake. Read from package.json so it
* cannot drift from the published package the way the previous hardcoded literal did.
*/
const PACKAGE_VERSION = (() => {
try {
return createRequire(import.meta.url)("../../package.json").version ?? "0.0.0";
}
catch {
return "0.0.0";
}
})();
const TOOLS = [

@@ -203,3 +216,3 @@ {

export async function startMcpServer() {
const server = new Server({ name: "motionlint", version: "0.1.0" }, { capabilities: { tools: {}, resources: {} } });
const server = new Server({ name: "motionlint", version: PACKAGE_VERSION }, { capabilities: { tools: {}, resources: {} } });
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));

@@ -206,0 +219,0 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => ({

@@ -29,2 +29,8 @@ import type { AnalysisEntry, CaptureResult, InteractionStep, OutputFormat, ReviewReport, MotionLintConfig, Viewport, VisionProvider } from "./types.js";

stateGrid?: boolean;
/** Compare against this baseline URL: each viewport analyzes a CURRENT|BASELINE strip instead of a plain capture. */
againstUrl?: string | null;
/** Also capture each viewport under dark (and optionally forced-colors) and review the strip. */
schemes?: boolean;
/** Include a forced-colors: active panel in the scheme strip. */
forcedColors?: boolean;
onProgress?: (event: ProgressEvent) => void;

@@ -31,0 +37,0 @@ }

@@ -109,2 +109,113 @@ import { mkdir, writeFile } from "node:fs/promises";

}
// Before/after comparison: swap each per-viewport capture for a labeled
// CURRENT|BASELINE strip. Same analyze-call count as a plain review; DOM
// refs are dropped because composite coordinates don't map to the page.
const comparing = Boolean(opts.againstUrl);
if (opts.againstUrl) {
const { composeLabeledStrip } = await import("./capture/pair.js");
for (let i = 0; i < captures.length; i++) {
const current = captures[i];
if (current.viewport.name === "interaction-states")
continue;
const baseline = await captureScreenshot({
url: opts.againstUrl,
viewport: current.viewport,
fullPage: opts.fullPage ?? true,
waitFor: config.waitFor,
waitTimeout: config.waitTimeout,
auth: config.auth,
interactions: opts.interactions,
});
const strip = await composeLabeledStrip([
{ label: "current", png: current.screenshot },
{ label: "baseline", png: baseline.screenshot },
]);
let screenshotPath = current.screenshotPath;
if (screenshotPath) {
const stripPath = screenshotPath.replace(/\.png$/, "") + "-vs-baseline.png";
try {
await writeFile(stripPath, strip);
screenshotPath = stripPath;
}
catch (err) {
// The strip write is a report-linking convenience, not the analysis
// itself (the in-memory buffer is what gets analyzed either way).
// Drop the link rather than fail the run or point at the stale
// pre-transform image.
onProgress?.({ type: "memory_warning", message: `comparison strip write failed: ${err.message}` });
screenshotPath = undefined;
}
}
captures[i] = {
...current,
screenshot: strip,
screenshotPath,
fullPage: false,
dom: undefined,
viewport: { ...current.viewport, name: `${current.viewport.name}-vs-baseline` },
};
}
}
// Color-scheme sweep: one extra pseudo-viewport per real viewport showing
// light | dark (| forced-colors) renderings side by side.
const schemeNames = new Set();
if (opts.schemes) {
const { composeLabeledStrip } = await import("./capture/pair.js");
const baseCaptures = captures.filter((c) => c.viewport.name !== "interaction-states" && !c.viewport.name.endsWith("-vs-baseline"));
for (const light of baseCaptures) {
try {
const dark = await captureScreenshot({
url,
viewport: light.viewport,
fullPage: opts.fullPage ?? true,
waitFor: config.waitFor,
waitTimeout: config.waitTimeout,
auth: config.auth,
interactions: opts.interactions,
colorScheme: "dark",
});
const panels = [
{ label: "light", png: light.screenshot },
{ label: "dark", png: dark.screenshot },
];
if (opts.forcedColors) {
const forced = await captureScreenshot({
url,
viewport: light.viewport,
fullPage: opts.fullPage ?? true,
waitFor: config.waitFor,
waitTimeout: config.waitTimeout,
auth: config.auth,
interactions: opts.interactions,
forcedColors: true,
});
panels.push({ label: "forced colors", png: forced.screenshot });
}
const strip = await composeLabeledStrip(panels);
const name = `${light.viewport.name}-schemes`;
schemeNames.add(name);
const capture = {
url,
viewport: { name, width: light.viewport.width, height: light.viewport.height },
screenshot: strip,
fullPage: false,
timestamp: new Date().toISOString(),
};
onProgress?.({ type: "capture_done", capture });
captures.push(capture);
}
catch {
/* scheme sweep is an enhancement, never a run failure */
}
}
}
if (opts.schemes && opts.againstUrl && schemeNames.size === 0) {
// The comparison block above already replaced every per-viewport capture
// with a CURRENT|BASELINE strip, so there was nothing left for the scheme
// sweep to pair against — flag it rather than silently doing nothing.
onProgress?.({
type: "memory_warning",
message: "--schemes is skipped when --against is active (comparison replaces the per-viewport captures)",
});
}
const tokenLimit = opts.maxTokens !== undefined ? opts.maxTokens : config.resources.maxTokensPerRun;

@@ -131,2 +242,8 @@ let usage = emptyRunUsage(typeof tokenLimit === "number" && tokenLimit > 0 ? tokenLimit : null);

...(isGrid ? { stateGrid: { states: GRID_STATES, elements: gridElements } } : {}),
...(comparing && capture.viewport.name.endsWith("-vs-baseline")
? { compare: { baselineUrl: opts.againstUrl } }
: {}),
...(schemeNames.has(capture.viewport.name)
? { schemePair: { schemes: opts.forcedColors ? ["light", "dark", "forced-colors"] : ["light", "dark"] } }
: {}),
});

@@ -183,7 +300,10 @@ const analysis = resolveElementRefs(await provider.analyze(capture.screenshot, prompt, capture.viewport.name), capture.dom);

}
const report = aggregate(url, provider.name, provider.model, reportAnalyses, {
maxFindings: opts.maxFindings !== undefined ? opts.maxFindings : config.maxFindings,
omitted: memoryOmitted,
usage,
});
const report = {
...aggregate(url, provider.name, provider.model, reportAnalyses, {
maxFindings: opts.maxFindings !== undefined ? opts.maxFindings : config.maxFindings,
omitted: memoryOmitted,
usage,
}),
...(opts.againstUrl ? { against: opts.againstUrl } : {}),
};
const format = opts.format ?? "md";

@@ -190,0 +310,0 @@ let rendered;

+11
-3

@@ -13,3 +13,5 @@ import { parseAnalysisResponse } from "../analysis/parser.js";

this.model = opts.model ?? "gpt-4o";
this.baseUrl = opts.baseUrl ?? OPENAI_API;
// OPENAI_BASE_URL points the provider at any OpenAI-compatible endpoint
// (Moonshot/Kimi, Together, vLLM…) — pair it with that service's key.
this.baseUrl = opts.baseUrl ?? process.env.OPENAI_BASE_URL ?? OPENAI_API;
}

@@ -24,3 +26,3 @@ async isAvailable() {

const dataUrl = `data:${mediaType};base64,${data}`;
const res = await fetch(this.baseUrl, {
const request = (withResponseFormat) => fetch(this.baseUrl, {
method: "POST",

@@ -33,3 +35,3 @@ headers: {

model: this.model,
response_format: { type: "json_object" },
...(withResponseFormat ? { response_format: { type: "json_object" } } : {}),
messages: [{

@@ -44,2 +46,8 @@ role: "user",

});
let res = await request(true);
if (!res.ok && [400, 401, 403].includes(res.status)) {
// Some models/tiers and OpenAI-compatible endpoints reject
// response_format — the prompt already demands JSON, so retry without.
res = await request(false);
}
if (!res.ok) {

@@ -46,0 +54,0 @@ const text = await res.text().catch(() => "");

import type { AnimationAudit } from "./lint.js";
export declare function renderAnimationAuditHtml(audit: AnimationAudit): string;
import type { LayoutAudit } from "../lint/layout.js";
export declare function renderAnimationAuditHtml(audit: AnimationAudit, layout?: LayoutAudit): string;

@@ -102,3 +102,19 @@ /**

}
export function renderAnimationAuditHtml(audit) {
function renderLayoutSection(layout) {
return `
<section class="layout-audit">
<h2>Layout audit — ${layout.score}/100</h2>
<p class="counts">${layout.critical_count} critical · ${layout.warning_count} warning · ${layout.suggestion_count} suggestion</p>
${layout.findings.map((f) => `
<div class="finding sev-${f.severity}">
<div class="top"><span class="badge">${escapeHtml(f.severity)}</span><span class="tag">${escapeHtml(f.category)}</span><h3>${escapeHtml(f.title)}</h3></div>
<p class="loc">${escapeHtml(f.location)}</p>
<p>${escapeHtml(f.detail)}</p>
<p><b>Why:</b> ${escapeHtml(f.why)}</p>
<p><b>Fix:</b> ${escapeHtml(f.fix)}</p>
<p class="std">Standard: ${escapeHtml(f.standard)}</p>
</div>`).join("")}
</section>`;
}
export function renderAnimationAuditHtml(audit, layout) {
const h = headline(audit);

@@ -126,2 +142,4 @@ const summary = `

}
const layoutSection = layout ? renderLayoutSection(layout) : "";
body = `${body}${layoutSection}`;
return htmlShell({

@@ -128,0 +146,0 @@ title: "MotionLint Animation Audit",

@@ -32,2 +32,6 @@ export interface Viewport {

withDom?: boolean;
/** Emulated prefers-color-scheme for the capture context. */
colorScheme?: "light" | "dark";
/** Emulate forced-colors: active (Windows High Contrast). */
forcedColors?: boolean;
}

@@ -108,2 +112,4 @@ export interface CaptureResult {

url: string;
/** Baseline URL when the run was a before/after comparison (--against). */
against?: string;
provider: string;

@@ -110,0 +116,0 @@ model: string;

{
"name": "motionlint",
"version": "0.2.0",
"description": "AI design review in your terminal — automated visual UI/UX analysis using vision LLMs.",
"version": "0.2.1",
"mcpName": "io.github.bobaba99/motionlint",
"description": "Catch bad animations before they ship — deterministic motion audit + vision-LLM design review for your terminal and Claude Code.",
"keywords": [
"ux",
"ui",
"animation",
"motion",
"web-animation",
"design-review",
"linter",
"accessibility",
"playwright",
"mcp",
"model-context-protocol",
"claude-code",
"claude",
"ollama",
"vision",
"mcp",
"claude-code",
"screenshot",
"ux",
"ui",
"ai"

@@ -17,0 +24,0 @@ ],

+67
-34

@@ -5,15 +5,23 @@ # MotionLint

## The problem
**Score any page's animation quality in one command. No API key, no config.**
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.
```bash
npx motionlint audit http://localhost:3000 --open
```
## 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>
<p align="center"><sub>Deterministic — measured from the live page, no LLM involved. One-time prerequisite: <code>npx playwright install chromium</code>.</sub></p>
MotionLint measures the motion your app actually ships — durations, easing curves, stagger intervals, exit timing, reduced-motion support — and scores it against a published set of [animation standards](docs/STANDARDS.md). Ease-in on a dropdown, a 600ms modal, a card that scales from 0, hover motion that fires on touch: all caught, all with the measured value and a concrete fix.
The audit is free and offline. Add an API key and MotionLint also does **vision-LLM design review** — multi-viewport screenshots and 50ms frame bursts of real user journeys, judged by a model and handed back to your coding agent as ranked findings. It runs as an MCP server inside Claude Code and Cursor.
## Why this exists
AI coding agents read JSX, HTML, and CSS — they're blind to what the user actually sees, clicks, and watches animate. Rules in a prompt tell the agent what *should* happen; nothing checks what *did*. Modals that should slide in just pop; loading states get omitted; focus rings disappear. Code review can't catch any of this before merge, because none of it is visible in the diff.
MotionLint closes that loop: it measures the running app and feeds the verdict back.
## How it's different

@@ -23,2 +31,3 @@

| --- | --- | --- | --- |
| **Deterministic motion audit** | **13 checks, measured from the live page — no API key, $0** | ✗ | ✗ |
| Multi-viewport UX review | ranked findings across 12 dimensions | pixel diffs only | generates new layouts from prompts |

@@ -33,41 +42,42 @@ | **Animation review** | **50ms frame bursts via CDP screencast → contact sheet → LLM** | ✗ | ✗ |

## Install
## Start here — no API key needed
```bash
# CLI
npm install -g motionlint # global
npx motionlint review <url> # one-shot, no install
npx playwright install chromium # one-time per machine (~300MB)
npx motionlint audit http://localhost:3000 --open
```
# Claude Code (MCP server)
claude mcp add motionlint -- npx -y motionlint mcp
That's the whole setup for the audit. It's deterministic, runs offline, costs nothing, and works on any URL you can load — your dev server, a staging deploy, or someone else's site. Requires Node 18+.
# One-time per machine: Playwright Chromium (~300MB)
npx playwright install chromium
```
The rules it checks are published in [docs/STANDARDS.md](docs/STANDARDS.md) — read them before you install anything.
Requires Node 18+. Package on npm: [motionlint](https://www.npmjs.com/package/motionlint).
## Then: LLM design review
## Quick start
Set one API key (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GOOGLE_API_KEY` — or run Ollama locally for free) and three more commands unlock:
```bash
# Static review of a URL at mobile + desktop → Markdown report.
npm install -g motionlint
# Multi-viewport UX review of a page → ranked findings across 12 dimensions.
motionlint review http://localhost:3000
# Animation review of a scripted user journey → contact sheet + flow report.
# Animation review of a scripted user journey → frame contact sheet + report.
motionlint flow --spec flows/signup.json
# Detect every animation on a page → interactive HTML tuner.
# Interactive HTML tuner — every animation on the page, with live sliders.
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
### Inside Claude Code / Cursor
# Track provider quality across runs + teach the reviewer from eval misses.
motionlint eval --provider anthropic --evolve
```bash
claude mcp add motionlint -- npx -y motionlint mcp
```
# Interaction affordances — grid each element's default/hover/focus/active states.
motionlint review http://localhost:3000 --state-grid
<details>
<summary><b>Full flag surface</b> — CI gates, route discovery, Storybook, dark mode, baselines</summary>
# Review every route the site knows about (sitemap.xml + Next.js app/ directory).
motionlint review http://localhost:3000 --discover-routes
```bash
# 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

@@ -77,15 +87,34 @@ # Polished, shareable HTML review with embedded screenshots + before/after fixes.

# 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
# Review every route the site knows about (sitemap.xml + Next.js app/ directory).
motionlint review http://localhost:3000 --discover-routes
# Pick a provider explicitly (auto-detect picks the first reachable one).
motionlint review http://localhost:3000 --provider anthropic --model claude-sonnet-4-6
# Storybook mode — discover stories from /index.json, review each story iframe as its own route.
motionlint review http://localhost:6006 --storybook
# Color-scheme sweep — light and dark modes, plus Windows High Contrast.
motionlint review http://localhost:3000 --schemes --forced-colors --format html -o review.html
# Interaction affordances — grid each element's default/hover/focus/active states.
motionlint review http://localhost:3000 --state-grid
# 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
# Before/after comparison — PR preview vs. production baseline.
motionlint review https://pr-123.preview.example.com --against https://prod.example.com
# 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
# Pick a provider explicitly (auto-detect picks the first reachable one).
motionlint review http://localhost:3000 --provider anthropic --model claude-sonnet-4-6
# Track provider quality across runs + teach the reviewer from eval misses.
motionlint eval --provider anthropic --evolve
```
</details>
Package on npm: [motionlint](https://www.npmjs.com/package/motionlint).
Sample terminal output for a flow review:

@@ -238,3 +267,3 @@

1. Runs the journey in headless Chromium via Playwright — clicking, typing, hovering, scrolling, pressing keys exactly like a user would.
2. Captures a **burst of 16 frames over 750ms (50ms intervals) after every interaction** via CDP screencast (`Page.captureScreenshot` JPEG, ~8ms per shot). 50ms is half the human visual-detection threshold and below the industry-typical 100ms minimum animation interval — short animations like 100ms button presses get caught with 2-3 mid-state frames.
2. Captures a **burst of 16 frames over 750ms (50ms intervals) after every interaction** via CDP screencast (`Page.captureScreenshot` JPEG, ~8ms per shot). 50ms is half the human visual-detection threshold and below the industry-typical 100ms minimum animation interval — short animations like 100ms button presses get caught with 2-3 mid-state frames. Every interaction burst is also pixel-diffed for input→feedback latency — interactions with no visible acknowledgment within the burst window are flagged deterministically.
3. Records the **full Playwright video** as an artifact you can scrub later.

@@ -356,2 +385,6 @@ 4. Composites every burst into a labeled **contact sheet** — one row per step, frames laid out in sub-rows.

Add `--layout` to also lint layout (tap targets, text size, contrast, overflow) from live DOM measurements — still deterministic, still no API key.
Add `--watch [dir]` to re-run the audit on file changes under `[dir]` (default: cwd) and print the score with a delta after each run — a live readout while you iterate. Recursive watching requires macOS, Windows, or Linux with Node 20+.
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.

@@ -358,0 +391,0 @@