← Motion Review
API
Tokens

Run Motion Review from your own code

The one metered lane, review, is available over HTTP and returns one JSON object. Its job, in the source skill's words: review animation and motion code against a high craft bar derived from Emil Kowalski's design engineering philosophy. The division of labour: before any run, the page has read the code: it numbered every line, listed every animation (its properties, duration, easing and the kind of element it moves), and flagged the mechanical triggers - each page finding has an id (F1, F2 ...), its line, its code, and the After the standards give when the value is fixed. It hands all of that to the run as facts. The run makes the calls only a reviewer can make and writes the table and the decision; the page then holds the reply to its own reading (check() in motionreply.js).

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"motion-review"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a key the run needs is missing. The page always sends all 6 of task, subject, context, files, facts, previous; context and previous are plain sentences when there is nothing to say.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: the review is metered and needs a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

One lane: task is always review. If it is missing, review anyway and set "task": "review".

The same lane does two jobs, told apart by previous:

Every run sends the same 6 fields. The decision: "block" when there is any feel-breaking regression, animation on a keyboard or high-frequency action, scale(0) or ease-in on UI, or a non-GPU animation with an easy GPU fix; otherwise "approve" (no feel-breaking regression, nothing that should be deleted, durations and easing within bounds, interruptibility handled where needed, reduced motion respected). Approval is earned. The listing sent in files is capped at 40,000 characters: files that fit go whole, the rest are cut to the windows around their animations (and marked).

The input

FieldRequiredWhat
taskyesalways review. If it is missing, review anyway and set "task": "review".
subjectyeswhat is under review, with the number of files, lines and animations.
contextyes (a sentence when none was given)what the component is, how often people see it and the product's personality, in the user's words, or a sentence saying none was given (then judge from the code and say what you assumed in caveats).
filesyesthe pasted code, each file under a "=== path (N lines) ===" heading, every line prefixed with its line number and "| ". A line "[... lines A-B not sent ...]" marks code the page read but did not send.
factsyesthe page's reading: the animations (path:line, type, selector, element kind, properties, duration, easing and its class), whether reduced motion and a hover gate exist, the Motion version if a package.json was pasted, and the page findings (id | severity | standard | tier | path:line | code | the page's After). A line starting "must:" applies to your reply.
previousyes (a sentence on a first review)for a re-review, the findings of the earlier review (a Before | After | Why table); otherwise a sentence saying this is the first review.
retry_notenooptional; if present, your previous reply was unusable - follow it.

The posture

You are a senior design engineer with a brutal eye for craft. Motion that feels right, not motion that merely runs: a transition that works but feels sluggish, lands from the wrong origin, fires too often or drops frames is a regression, not a pass. You review animation and motion only - never other code.

The ten standards the model applies

From the source skill, @emilkowalski/review-animations - the only standard the run applies:

  1. Justified motion - every animation answers "why does this animate?": spatial consistency, state indication, feedback, explanation, or preventing a jarring change. "It looks cool" on a frequently seen element is a block.
  2. Frequency-appropriate - keyboard-initiated and 100+/day actions get no animation; tens/day get reduced motion; occasional gets standard; rare or first-time can have delight.
  3. Responsive easing - entering and exiting use ease-out or a strong custom curve; ease-in on UI is a block; built-in CSS easings are too weak for deliberate animations. On-screen movement: ease-in-out. Hover and colour change: ease. Constant motion: linear.
  4. Sub-300ms UI - button press 100-160ms, tooltips and small popovers 125-200ms, dropdowns and selects 150-250ms, modals and drawers 200-500ms; marketing can be longer. Anything slower on a UI element needs a reason.
  5. Origin and physical correctness - popovers, dropdowns and tooltips scale from their trigger (transform-origin - the side of the trigger they open from, or var(--transform-origin) where Base UI or Radix sets it), not center; modals are exempt and stay centered. Never animate from scale(0): start from scale(0.9-0.97) plus opacity.
  6. Interruptibility - rapidly triggered or gesture-driven motion (toasts, toggles, drags) uses transitions or springs that retarget from the current state, not keyframes that restart from zero.
  7. GPU-only properties - animate transform and opacity only. Width, height, margin, padding, top and left are performance findings; so are Motion x / y / scale shorthands on versions before motion 14.2 when the page is busy, layout animations under load, and a CSS variable on a parent driving a child's transform.
  8. Accessibility - prefers-reduced-motion is honoured (gentler, not zero: keep opacity and colour, drop movement); hover motion is gated behind @media (hover: hover) and (pointer: fine).
  9. Asymmetric enter and exit - deliberate actions (a press, a hold, a destructive confirm) animate slower; system responses snap.
  10. Cohesion - motion matches the component's personality and the product: playful can be bouncier (springs keep bounce at 0.1-0.3), a dashboard stays crisp; groups stagger 30-80ms between items. When unsure whether motion feels right, the strongest move is often to delete it.

Exact values: strong ease-out cubic-bezier(0.23, 1, 0.32, 1); strong ease-in-out cubic-bezier(0.77, 0, 0.175, 1); iOS-like drawer cubic-bezier(0.32, 0.72, 0, 1); button press scale(0.97) on :active with transition: transform 160ms ease-out; Apple-style spring { type: "spring", duration: 0.5, bounce: 0.2 }; entry without JS through @starting-style. In Motion, a curve is written as an array, e.g. ease: [0.23, 1, 0.32, 1].

Remedial preference, earliest first: delete the animation; reduce it; fix the easing; fix the origin; make it interruptible; move it to the GPU; asymmetric timing; polish (blur to mask a crossfade, stagger, @starting-style, a spring for alive elements); accessibility and cohesion.

House rules

The reply

Each key, in the order the reply carries it:

  1. summary - one sentence, at most 40 words: what moves, how it feels, and the decision.
  2. decision - "block" when there is any feel-breaking regression, animation on a keyboard or high-frequency action, scale(0) or ease-in on UI, or a non-GPU animation with an easy GPU fix; otherwise "approve" (no feel-breaking regression, nothing that should be deleted, durations and easing within bounds, interruptibility handled where needed, reduced motion respected). Approval is earned.
  3. findings - the Before / After / Why table, one object per issue, highest impact first: where (path:line), before (the code on that line), after (the replacement code), why (one sentence), tier (feel, simplify, performance, timing, origin or a11y - in that order of impact: feel-breaking regressions, missed simplifications, performance, interruptibility and timing, origin / physicality / cohesion, accessibility), standard (the number 1-10 it breaks), page (the page finding ids it answers, e.g. "F3" or "F4, F10", or "" when only you found it). [] only when nothing needs to change.
  4. page_calls - one object per page finding, in id order: id, call (confirmed or dismissed), why (one sentence). [] when the page found nothing.
  5. verdict - the remaining commentary grouped by impact tier, highest first, omitting empty tiers: objects of tier and points (one to four short sentences each).
  6. resolved - re-review only: one object per finding of the previous review, in its order: before (as the previous review wrote it), status (resolved, remaining or partly), note (one sentence on what the code does now). [] on a first review.
  7. caveats - at most 4 short strings: assumptions about frequency or context, and when feel is uncertain, the advice to review it in slow motion or frame by frame; [] when there is nothing to say.
{
  "task": "review",
  "summary": "one sentence",
  "decision": "block",
  "findings": [ { "where": "styles/menu.css:49", "before": "transition: all 300ms;", "after": "transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1), opacity 200ms cubic-bezier(0.23, 1, 0.32, 1);", "why": "one sentence", "tier": "performance", "standard": 7, "page": "F6" } ],
  "page_calls": [ { "id": "F1", "call": "confirmed", "why": "one sentence" } ],
  "verdict": [ { "tier": "feel", "points": [ "one short sentence" ] } ],
  "resolved": [ ],
  "caveats": [ ]
}

The page holds it to its own reading (check() in motionreply.js; any warning makes the reply flagged): every finding's Before must be the code at the line it cites, every After must stay inside the standards (no scale(0), no ease-in, no transition: all, no UI duration past its budget), every page finding gets exactly one call, the decision must follow from what was confirmed (a confirmed block trigger cannot be approved), and a re-review's "resolved" is checked against the code - a Before still sitting in the files is not resolved.

Worked examples

Three runs from the page's free samples (invented products): the review on "A menu with every trigger" (block), the review on "A calm dashboard" (approve), the re-review on "Re-review after the fixes" (block). Each input is exactly what the page sends; each reply is the saved reply the sample replays - but the files, the facts, the previous review and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.

review - A menu with every trigger

The page read 3 files, 164 lines and 7 animations; the frequency given: tens of times a day. Its own scan: block - a trigger the standards block on (4 block, 6 flag, 1 note): F1 scale0 styles/menu.css:46 (block, standard 5); F2 ease-in styles/menu.css:56 (block, standard 3); F3 keyboard components/CommandPalette.css:15 (block, standard 2); F4 layout-prop styles/menu.css:73 (block, standard 7); F5 slow styles/menu.css:95 (flag, standard 4); F6 all styles/menu.css:49 (flag, standard 7); F7 keyframes-rapid components/Toast.css:23 (flag, standard 6); F8 origin styles/menu.css:45 (flag, standard 5); F9 no-reduced components/CommandPalette.css:15 (flag, standard 8); F10 hover-ungated styles/menu.css:77 (flag, standard 8); F11 slow styles/menu.css:49 (note, standard 4). The reply: block, 9 findings, page calls F1 confirmed, F2 confirmed, F3 confirmed, F4 confirmed, F5 confirmed, F6 confirmed, F7 confirmed, F8 confirmed, F9 confirmed, F10 confirmed, F11 confirmed.

Input (files is 4156 characters; the first 1387 are shown; facts is 3431 characters; the first 1598 are shown):

{
 "task": "review",
 "subject": "Account menu - dropdown, toasts, command palette - 3 files, 164 lines, 7 animations",
 "context": "The account menu in a project-management web app's top bar: a dropdown people open many times a day, toasts that confirm saves, and the Cmd+K command palette.",
 "files": "=== styles/menu.css (100 lines) ===\n  1| /* Account menu in the top bar */\n  2| :root {\n  3|   --menu-bg: #ffffff;\n  4|   --menu-border: #e4e6eb;\n  5|   --menu-text: #1f2430;\n  6|   --menu-muted: #6b7280;\n  7|   --menu-hover: #f3f4f6;\n  8|   --menu-radius: 10px;\n  9|   --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n 10| }\n 11| \n 12| .account-trigger {\n 13|   display: inline-flex;\n 14|   align-items: center;\n 15|   gap: 8px;\n 16|   padding: 4px 8px;\n 17|   border: 0;\n 18|   border-radius: 999px;\n 19|   background: transparent;\n 20|   color: var(--menu-text);\n 21|   cursor: pointer;\n 22| }\n 23| \n 24| .account-trigger:focus-visible {\n 25|   outline: 2px solid #4f6bed;\n 26|   outline-offset: 2px;\n 27| }\n 28| \n 29| .account-trigger .avatar {\n 30|   width: 28px;\n 31|   height: 28px;\n 32|   border-radius: 50%;\n 33| }\n 34| \n 35| .dropdown-menu {\n 36|   position: absolute;\n 37|   top: calc(100% + 6px);\n 38|   right: 0;\n 39|   min-width: 220px;\n 40|   padding: 6px;\n 41|   background: var(--menu-bg);\n 42|   border: 1px solid var(--menu-border);\n 43|   border-radius: var(--menu-radius);\n 44|   box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n 45|   transform-origin: center;\n 46|   transform: scale(0);\n 47|   opacity: 0;\n 48|   pointer-events: none;\n 49|   transition: all 300ms;\n 50| }\n 51| \n 52| .dropdown-menu[data-state=\"open\"] {\n 53|   transform: scale(1);\n 54|   opacity: 1;\n 55|\n\n[... 2769 more characters cut from this display ...]",
 "facts": "files: 3 files, 164 lines - all sent whole\nfrequency the user gave: tens of times a day\nanimations (path:line type selector | element kind | properties | duration | easing (class) | extras):\ncomponents/CommandPalette.css:15 keyframes .command-palette[data-open=\"true\"] | kind palette | opacity, transform | 200ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\ncomponents/Toast.css:23 keyframes .toast | kind toast | opacity, transform | 250ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nstyles/menu.css:49 transition .dropdown-menu | kind dropdown | all | 300ms | ease (weak)\nstyles/menu.css:56 transition .dropdown-menu[data-state=\"open\"] | kind dropdown | - | no duration | ease-in (in)\nstyles/menu.css:73 transition .dropdown-menu .menu-item | kind dropdown | padding-left | 150ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nstyles/menu.css:73 transition .dropdown-menu .menu-item | kind dropdown | background-color | 150ms | ease (weak)\nstyles/menu.css:95 transition .tooltip | kind tooltip | opacity | 450ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nreduced motion handled anywhere: no; hover gate present: no\npage findings (id | severity | standard | tier | path:line | code | the page's After):\nF1 | block | 5 | feel | styles/menu.css:46 | transform: scale(0); | transform: scale(0.95);\nF2 | block | 3 | feel | styles/menu.css:56 | transition-timing-function: ease-in; | transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);\nF3 | block | 2 | simplify | components/CommandPalette.css:15 | animation: palette-in 200ms cubic-bezier(0.23, 1, 0.32, 1) both; | no animation - delete it\nF4 | block | 7\n\n[... 1833 more characters cut from this display ...]",
 "previous": "None - this is the first review."
}

Reply:

{
 "task": "review",
 "summary": "The account dropdown pops from nothing at its center with ease-in, items shove sideways on hover, the tooltip lags and the Cmd+K palette animates a keyboard action - blocked.",
 "decision": "block",
 "findings": [
  {
   "where": "styles/menu.css:46",
   "before": "transform: scale(0);",
   "after": "transform: scale(0.95);",
   "why": "A menu opened many times a day should grow slightly into place, not explode out of a single point.",
   "tier": "feel",
   "standard": 5,
   "page": "F1"
  },
  {
   "where": "styles/menu.css:56",
   "before": "transition-timing-function: ease-in;",
   "after": "transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);",
   "why": "Ease-in holds the menu back at the exact moment the user is waiting for it, so the open feels sluggish.",
   "tier": "feel",
   "standard": 3,
   "page": "F2"
  },
  {
   "where": "components/CommandPalette.css:15",
   "before": "animation: palette-in 200ms cubic-bezier(0.23, 1, 0.32, 1) both;",
   "after": "Delete this animation so the palette appears instantly on Cmd+K.",
   "why": "A keyboard-initiated palette used many times a day should appear the instant the shortcut lands, and with no motion there is nothing left for reduced-motion users to opt out of.",
   "tier": "simplify",
   "standard": 2,
   "page": "F3"
  },
  {
   "where": "styles/menu.css:73",
   "before": "transition: padding-left 150ms var(--ease-out), background-color 150ms ease;",
   "after": "transition: background-color 150ms ease;",
   "why": "The hover nudge animates padding-left, a layout property, on every pass of the pointer and adds nothing a background highlight does not already say.",
   "tier": "performance",
   "standard": 7,
   "page": "F4"
  },
  {
   "where": "styles/menu.css:49",
   "before": "transition: all 300ms;",
   "after": "transition: transform 150ms cubic-bezier(0.23, 1, 0.32, 1), opacity 150ms cubic-bezier(0.23, 1, 0.32, 1);",
   "why": "Transitioning all with a weak default curve at 300ms is over the dropdown budget and touches more than transform and opacity; a menu seen tens of times a day should be quick.",
   "tier": "performance",
   "standard": 7,
   "page": "F6"
  },
  {
   "where": "components/Toast.css:23",
   "before": "animation: toast-in 250ms cubic-bezier(0.23, 1, 0.32, 1) both;",
   "after": "transition: transform 250ms cubic-bezier(0.23, 1, 0.32, 1), opacity 250ms cubic-bezier(0.23, 1, 0.32, 1);",
   "why": "Save toasts fire in quick bursts, and a transition with an @starting-style entry retargets from where each toast is instead of replaying a keyframe from zero.",
   "tier": "timing",
   "standard": 6,
   "page": "F7"
  },
  {
   "where": "styles/menu.css:95",
   "before": "transition: opacity 450ms var(--ease-out);",
   "after": "transition: opacity 200ms var(--ease-out);",
   "why": "At 450ms the tooltip is still fading in after the user has already read the button, well past the 125-200ms tooltip budget.",
   "tier": "timing",
   "standard": 4,
   "page": "F5"
  },
  {
   "where": "styles/menu.css:45",
   "before": "transform-origin: center;",
   "after": "transform-origin: top right;",
   "why": "The menu hangs below the trigger and aligns to its right edge, so it should grow out of that corner rather than its own middle.",
   "tier": "origin",
   "standard": 5,
   "page": "F8"
  },
  {
   "where": "styles/menu.css:77",
   "before": "padding-left: 14px;",
   "after": "Delete this line so the item hover only changes background-color.",
   "why": "The sideways shift fires on touch taps and for every pointer with no hover gate, and a colour-only hover needs no gate at all.",
   "tier": "a11y",
   "standard": 8,
   "page": "F10"
  }
 ],
 "page_calls": [
  {
   "id": "F1",
   "call": "confirmed",
   "why": "The dropdown starts from scale(0), which reads as appearing from nowhere."
  },
  {
   "id": "F2",
   "call": "confirmed",
   "why": "The open state uses ease-in, which delays the response on a UI element."
  },
  {
   "id": "F3",
   "call": "confirmed",
   "why": "The palette opens from a keyboard shortcut, so it should get no animation at all."
  },
  {
   "id": "F4",
   "call": "confirmed",
   "why": "The hover animates padding-left, a layout property, and the nudge itself is better removed."
  },
  {
   "id": "F5",
   "call": "confirmed",
   "why": "A 450ms tooltip fade is more than double the tooltip budget."
  },
  {
   "id": "F6",
   "call": "confirmed",
   "why": "Transitioning all risks animating layout and paint properties alongside transform and opacity."
  },
  {
   "id": "F7",
   "call": "confirmed",
   "why": "Toasts are rapidly triggered, so a keyframe entry should become an interruptible transition."
  },
  {
   "id": "F8",
   "call": "confirmed",
   "why": "A dropdown should scale from its trigger corner, not from its center."
  },
  {
   "id": "F9",
   "call": "confirmed",
   "why": "The palette ignores reduced motion; deleting its animation resolves this together with the keyboard-frequency issue."
  },
  {
   "id": "F10",
   "call": "confirmed",
   "why": "The item hover moves with no fine-pointer gate; removing the shift leaves a colour-only hover that needs none."
  },
  {
   "id": "F11",
   "call": "confirmed",
   "why": "The 300ms dropdown duration is over budget and is fixed by the same rewrite of that transition."
  }
 ],
 "verdict": [
  {
   "tier": "feel",
   "points": [
    "The dropdown is the most-seen motion here, and scale(0) from the center with ease-in makes it feel slow and detached from the avatar.",
    "Once fixed it should feel like it unfolds from the trigger in well under a quarter second."
   ]
  },
  {
   "tier": "simplify",
   "points": [
    "The command palette and the menu item nudge are motion on actions people repeat constantly; deleting both makes the app feel faster."
   ]
  },
  {
   "tier": "timing",
   "points": [
    "Closing can stay as quick as opening or quicker, since dismissing the menu is a system response that should snap.",
    "The tooltip fade should land inside 200ms."
   ]
  },
  {
   "tier": "a11y",
   "points": [
    "No reduced-motion handling exists; wrap the dropdown scale and the toast slide in @media (prefers-reduced-motion: reduce) and keep only the opacity fade there."
   ]
  }
 ],
 "resolved": [],
 "caveats": [
  "Assumed the dropdown is opened tens of times a day, as stated, so it gets reduced, quick motion.",
  "Review the dropdown open and close frame by frame after the change to confirm it grows from the avatar corner."
 ]
}

What step 5 prints for it:

decision: block
styles/menu.css:46 transform: scale(0); -> transform: scale(0.95);
styles/menu.css:56 transition-timing-function: ease-in; -> transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
components/CommandPalette.css:15 animation: palette-in 200ms cubic-bezier(0.23, 1, 0.32, 1) both; -> Delete this animation so the palette appears instantly on Cmd+K.
styles/menu.css:73 transition: padding-left 150ms var(--ease-out), background-color 150ms ease; -> transition: background-color 150ms ease;
styles/menu.css:49 transition: all 300ms; -> transition: transform 150ms cubic-bezier(0.23, 1, 0.32, 1), opacity 150ms cubic-bezier(0.23, 1, 0.32, 1);
components/Toast.css:23 animation: toast-in 250ms cubic-bezier(0.23, 1, 0.32, 1) both; -> transition: transform 250ms cubic-bezier(0.23, 1, 0.32, 1), opacity 250ms cubic-bezier(0.23, 1, 0.32, 1);
styles/menu.css:95 transition: opacity 450ms var(--ease-out); -> transition: opacity 200ms var(--ease-out);
styles/menu.css:45 transform-origin: center; -> transform-origin: top right;
styles/menu.css:77 padding-left: 14px; -> Delete this line so the item hover only changes background-color.

The page's check: kept - nothing to flag

review - A calm dashboard

The page read 1 files, 116 lines and 12 animations; the frequency given: tens of times a day. Its own scan: no mechanical trigger (0 block, 0 flag, 2 note): F1 weak-easing styles/dashboard.css:76 (note, standard 3); F2 slow styles/dashboard.css:76 (note, standard 4). The reply: approve, 2 findings, page calls F1 confirmed, F2 confirmed.

Input (files is 2926 characters; the first 1395 are shown; facts is 2576 characters; the first 1595 are shown):

{
 "task": "review",
 "subject": "Analytics dashboard - calm, crisp motion - 1 file, 116 lines, 12 animations",
 "context": "A finance team's analytics dashboard: cards, a filter drawer, a confirm modal and chart tooltips. Used daily but calmly; the brand is crisp and professional.",
 "files": "=== styles/dashboard.css (116 lines) ===\n  1| /* Analytics dashboard motion tokens */\n  2| :root {\n  3|   --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n  4|   --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);\n  5|   --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);\n  6|   --dur-press: 160ms;\n  7|   --dur-fast: 150ms;\n  8|   --dur-base: 200ms;\n  9| }\n 10| \n 11| .btn {\n 12|   transition: transform var(--dur-press) var(--ease-out);\n 13| }\n 14| \n 15| .btn:active {\n 16|   transform: scale(0.97);\n 17| }\n 18| \n 19| .metric-card {\n 20|   opacity: 0;\n 21|   transform: translateY(8px);\n 22|   animation: card-in 250ms var(--ease-out) forwards;\n 23|   transition: transform var(--dur-base) var(--ease-out);\n 24| }\n 25| \n 26| .metric-card:nth-child(2) { animation-delay: 50ms; }\n 27| .metric-card:nth-child(3) { animation-delay: 100ms; }\n 28| .metric-card:nth-child(4) { animation-delay: 150ms; }\n 29| \n 30| @keyframes card-in {\n 31|   to { opacity: 1; transform: translateY(0); }\n 32| }\n 33| \n 34| @media (hover: hover) and (pointer: fine) {\n 35|   .metric-card:hover {\n 36|     transform: translateY(-2px);\n 37|   }\n 38| }\n 39| \n 40| .filter-drawer {\n 41|   position: fixed;\n 42|   inset: 0 0 0 auto;\n 43|   width: 360px;\n 44|   transform: translateX(100%);\n 45|   transition: transform 240ms var(--ease-drawer);\n 46| }\n 47| \n 48| .filter-drawer[data-open=\"true\"] {\n 49|   transform: translateX(0);\n 50| }\n\n[... 1531 more characters cut from this display ...]",
 "facts": "files: 1 file, 116 lines - all sent whole\nfrequency the user gave: tens of times a day\nanimations (path:line type selector | element kind | properties | duration | easing (class) | extras):\nstyles/dashboard.css:12 transition .btn | kind button | transform | 160ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nstyles/dashboard.css:22 keyframes .metric-card | kind list | opacity, transform | 250ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nstyles/dashboard.css:23 transition .metric-card | kind list | transform | 200ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nstyles/dashboard.css:45 transition .filter-drawer | kind drawer | transform | 240ms | cubic-bezier(0.32, 0.72, 0, 1) (strong)\nstyles/dashboard.css:54 transition .confirm-modal | kind modal | transform | 240ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nstyles/dashboard.css:54 transition .confirm-modal | kind modal | opacity | 240ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nstyles/dashboard.css:65 transition .insights-panel | kind ui | opacity | 200ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nstyles/dashboard.css:65 transition .insights-panel | kind ui | transform | 200ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nstyles/dashboard.css:76 transition .chart-tooltip | kind tooltip | opacity | 220ms | ease (weak)\nstyles/dashboard.css:91 keyframes .metric-card | kind list | opacity | 200ms | ease (weak) | inside reduced-motion\nstyles/dashboard.css:98 transition .filter-drawer | kind drawer | opacity | 150ms | ease (weak) | inside reduced-motion\nstyles/dashboard.css:109 transition .confirm-modal,... | kind modal | opacity | 200ms |\n\n[... 981 more characters cut from this display ...]",
 "previous": "None - this is the first review."
}

Reply:

{
 "task": "review",
 "summary": "Crisp, GPU-only motion across buttons, cards, drawer, modal and panel with honest reduced-motion fallbacks; only the chart tooltip fade runs long on a weak curve, and the card hover lift never plays. Approve.",
 "decision": "approve",
 "findings": [
  {
   "where": "styles/dashboard.css:76",
   "before": "transition: opacity 220ms ease;",
   "after": "transition: opacity 200ms cubic-bezier(0.23, 1, 0.32, 1);",
   "why": "A chart tooltip should appear within the 125-200ms tooltip budget on the strong ease-out, so the value is readable the moment the pointer lands instead of after a soft built-in fade.",
   "tier": "timing",
   "standard": 4,
   "page": "F1"
  },
  {
   "where": "styles/dashboard.css:23",
   "before": "transition: transform var(--dur-base) var(--ease-out);",
   "after": "Delete this transition and the hover lift on lines 34-38: the forwards-filled card-in entrance keeps translateY(0) applied after it ends, which overrides the hover transform, so the lift never plays.",
   "why": "The hover lift is dead motion on a crisp finance dashboard, and deleting it is simpler than restructuring the entrance to let it run.",
   "tier": "simplify",
   "standard": 10,
   "page": ""
  }
 ],
 "page_calls": [
  {
   "id": "F1",
   "call": "confirmed",
   "why": "The tooltip enters on the weak built-in ease where the strong ease-out curve belongs; merged with the duration fix into one change."
  },
  {
   "id": "F2",
   "call": "confirmed",
   "why": "At 220ms the tooltip fade runs past the 125-200ms tooltip budget; merged into the same one-line change as the easing fix."
  }
 ],
 "verdict": [
  {
   "tier": "simplify",
   "points": [
    "The card entrance and its hover lift fight each other, and the entrance wins every time.",
    "Removing the lift keeps the cards calm and matches the crisp brand."
   ]
  },
  {
   "tier": "timing",
   "points": [
    "The button press, drawer, modal and insights panel all sit inside their budgets on strong custom curves.",
    "The card stagger of 50ms sits inside the 30-80ms range."
   ]
  },
  {
   "tier": "origin",
   "points": [
    "The confirm modal stays centered and enters from scale(0.96) with opacity, which is correct for a modal."
   ]
  },
  {
   "tier": "a11y",
   "points": [
    "Reduced motion keeps opacity fades and drops all movement for the cards, drawer, modal and panel.",
    "The hover lift is gated behind a fine pointer."
   ]
  }
 ],
 "resolved": [],
 "caveats": [
  "Assumed tooltips appear tens of times a day as the user scans charts; if they fire on every pointer move across dense charts, consider making repeat tooltips instant.",
  "Review the tooltip fade frame by frame after the change to confirm it no longer lags the pointer."
 ]
}

What step 5 prints for it:

decision: approve
styles/dashboard.css:76 transition: opacity 220ms ease; -> transition: opacity 200ms cubic-bezier(0.23, 1, 0.32, 1);
styles/dashboard.css:23 transition: transform var(--dur-base) var(--ease-out); -> Delete this transition and the hover lift on lines 34-38: the forwards-filled card-in entrance keeps translateY(0) applied after it ends, which overrides the hover transform, so the lift never plays.

The page's check: kept - nothing to flag

re-review - Re-review after the fixes

The page read 3 files, 186 lines and 9 animations; the frequency given: tens of times a day. Its own scan: flags to look at (0 block, 1 flag, 0 note): F1 keyframes-rapid components/Toast.css:23 (flag, standard 6). The reply: block, 2 findings, page calls F1 confirmed. The input's previous carries the "A menu with every trigger" review's 9 findings; its resolved accounts for all of them (7 resolved, 1 partly, 1 remaining).

Input (files is 4699 characters; the first 1372 are shown; facts is 2380 characters; the first 1586 are shown; previous is 2518 characters; the first 1395 are shown):

{
 "task": "review",
 "subject": "Account menu - after the fixes - 3 files, 186 lines, 9 animations",
 "context": "The account menu in a project-management web app's top bar: a dropdown people open many times a day, toasts that confirm saves, and the Cmd+K command palette.",
 "files": "=== styles/menu.css (122 lines) ===\n  1| /* Account menu in the top bar */\n  2| :root {\n  3|   --menu-bg: #ffffff;\n  4|   --menu-border: #e4e6eb;\n  5|   --menu-text: #1f2430;\n  6|   --menu-muted: #6b7280;\n  7|   --menu-hover: #f3f4f6;\n  8|   --menu-radius: 10px;\n  9|   --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n 10| }\n 11| \n 12| .account-trigger {\n 13|   display: inline-flex;\n 14|   align-items: center;\n 15|   gap: 8px;\n 16|   padding: 4px 8px;\n 17|   border: 0;\n 18|   border-radius: 999px;\n 19|   background: transparent;\n 20|   color: var(--menu-text);\n 21|   cursor: pointer;\n 22| }\n 23| \n 24| .account-trigger:focus-visible {\n 25|   outline: 2px solid #4f6bed;\n 26|   outline-offset: 2px;\n 27| }\n 28| \n 29| .account-trigger .avatar {\n 30|   width: 28px;\n 31|   height: 28px;\n 32|   border-radius: 50%;\n 33| }\n 34| \n 35| .dropdown-menu {\n 36|   position: absolute;\n 37|   top: calc(100% + 6px);\n 38|   right: 0;\n 39|   min-width: 220px;\n 40|   padding: 6px;\n 41|   background: var(--menu-bg);\n 42|   border: 1px solid var(--menu-border);\n 43|   border-radius: var(--menu-radius);\n 44|   box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n 45|   transform-origin: var(--transform-origin);\n 46|   transform: scale(0.95);\n 47|   opacity: 0;\n 48|   pointer-events: none;\n 49|   transition: transform 180ms var(--ease-out), opacity 180ms var(--ease-out);\n 50| }\n 51| \n 52|\n\n[... 3327 more characters cut from this display ...]",
 "facts": "files: 3 files, 186 lines - all sent whole\nfrequency the user gave: tens of times a day\nanimations (path:line type selector | element kind | properties | duration | easing (class) | extras):\ncomponents/Toast.css:23 keyframes .toast | kind toast | opacity, transform | 250ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nstyles/menu.css:49 transition .dropdown-menu | kind dropdown | transform | 180ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nstyles/menu.css:49 transition .dropdown-menu | kind dropdown | opacity | 180ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nstyles/menu.css:72 transition .dropdown-menu .menu-item | kind dropdown | transform | 150ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nstyles/menu.css:72 transition .dropdown-menu .menu-item | kind dropdown | background-color | 150ms | ease (weak)\nstyles/menu.css:99 transition .tooltip | kind tooltip | opacity | 150ms | cubic-bezier(0.23, 1, 0.32, 1) (strong)\nstyles/menu.css:110 transition .dropdown-menu, .dropdown-menu[data-state=\"open\"] | kind dropdown | opacity | 150ms | ease (weak) | inside reduced-motion\nstyles/menu.css:116 transition .dropdown-menu .menu-item, .dropdown-menu... | kind dropdown | background-color | 150ms | ease (weak) | hover (ungated) | inside reduced-motion\nstyles/menu.css:120 keyframes .toast | kind toast | opacity | no duration | ease (weak) | inside reduced-motion\nreduced motion handled anywhere: yes; hover gate present: yes\npage findings (id | severity | standard | tier | path:line | code | the page's After):\nF1 | flag | 6 | timing | components/Toast.css:23 | animation: toast-in 250ms\n\n[... 794 more characters cut from this display ...]",
 "previous": "| Before | After | Why |\n| --- | --- | --- |\n| `transform: scale(0);` | `transform: scale(0.95);` | A menu opened many times a day should grow slightly into place, not explode out of a single point. (styles/menu.css:46) |\n| `transition-timing-function: ease-in;` | `transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);` | Ease-in holds the menu back at the exact moment the user is waiting for it, so the open feels sluggish. (styles/menu.css:56) |\n| `animation: palette-in 200ms cubic-bezier(0.23, 1, 0.32, 1) both;` | `Delete this animation so the palette appears instantly on Cmd+K.` | A keyboard-initiated palette used many times a day should appear the instant the shortcut lands, and with no motion there is nothing left for reduced-motion users to opt out of. (components/CommandPalette.css:15) |\n| `transition: padding-left 150ms var(--ease-out), background-color 150ms ease;` | `transition: background-color 150ms ease;` | The hover nudge animates padding-left, a layout property, on every pass of the pointer and adds nothing a background highlight does not already say. (styles/menu.css:73) |\n| `transition: all 300ms;` | `transition: transform 150ms cubic-bezier(0.23, 1, 0.32, 1), opacity 150ms cubic-bezier(0.23, 1, 0.32, 1);` | Transitioning all with a weak default curve at 300ms is over the dropdown budget and touches more than transform and opacity; a menu seen tens of\n\n[... 1123 more characters cut from this display ...]"
}

Reply:

{
 "task": "review",
 "summary": "The menu now grows crisply from its trigger and the palette opens instantly, but save toasts still replay a keyframe from zero in bursts and menu items still slide on hover, so this is blocked.",
 "decision": "block",
 "findings": [
  {
   "where": "components/Toast.css:23",
   "before": "animation: toast-in 250ms cubic-bezier(0.23, 1, 0.32, 1) both;",
   "after": "transition: transform 250ms cubic-bezier(0.23, 1, 0.32, 1), opacity 250ms cubic-bezier(0.23, 1, 0.32, 1); @starting-style { opacity: 0; transform: translateY(100%); }",
   "why": "Save toasts arrive in quick bursts, and a keyframe restarts every toast from zero while a transition with an @starting-style entry retargets from wherever each toast already is.",
   "tier": "timing",
   "standard": 6,
   "page": "F1"
  },
  {
   "where": "styles/menu.css:81",
   "before": "transform: translateX(4px);",
   "after": "Delete this line and drop transform from the transition on line 72, leaving transition: background-color 150ms ease; so the item hover only changes background-color.",
   "why": "A menu opened many times a day does not need every item sliding sideways as the pointer passes; the background highlight already says which item is under the pointer.",
   "tier": "simplify",
   "standard": 1,
   "page": ""
  }
 ],
 "page_calls": [
  {
   "id": "F1",
   "call": "confirmed",
   "why": "Toasts that confirm saves can stack in quick succession, so a keyframe entry that restarts from zero is not interruptible."
  }
 ],
 "verdict": [
  {
   "tier": "simplify",
   "points": [
    "The hover nudge is now on the GPU and gated to fine pointers, but it still moves on every pass through a frequently opened menu.",
    "When unsure, deleting the nudge is the stronger move."
   ]
  },
  {
   "tier": "timing",
   "points": [
    "The dropdown at 180ms with the strong ease-out sits well inside the 150-250ms budget.",
    "The tooltip fades in 150ms, inside the 125-200ms tooltip budget.",
    "Closing the menu could snap a little faster than opening, since dismissal is a system response."
   ]
  },
  {
   "tier": "origin",
   "points": [
    "The menu now scales from 0.95 out of the trigger's origin instead of exploding from a single point."
   ]
  },
  {
   "tier": "a11y",
   "points": [
    "Reduced motion keeps the menu and toast fades and drops movement, which is the right gentler fallback.",
    "Once the toast moves to a transition, the reduced-motion block should drop its transform entry too, not only the keyframe name."
   ]
  }
 ],
 "resolved": [
  {
   "before": "transform: scale(0);",
   "status": "resolved",
   "note": "The closed menu now starts from scale(0.95) with opacity 0."
  },
  {
   "before": "transition-timing-function: ease-in;",
   "status": "resolved",
   "note": "The menu now opens on the strong ease-out curve and no ease-in remains."
  },
  {
   "before": "animation: palette-in 200ms cubic-bezier(0.23, 1, 0.32, 1) both;",
   "status": "resolved",
   "note": "The command palette now has no animation and simply shows or hides on Cmd+K."
  },
  {
   "before": "transition: padding-left 150ms var(--ease-out), background-color 150ms ease;",
   "status": "partly",
   "note": "The padding-left animation is gone, but the hover nudge survives as a transform transition instead of being reduced to background-color only."
  },
  {
   "before": "transition: all 300ms;",
   "status": "resolved",
   "note": "The menu now transitions only transform and opacity over 180ms on the strong ease-out curve."
  },
  {
   "before": "animation: toast-in 250ms cubic-bezier(0.23, 1, 0.32, 1) both;",
   "status": "remaining",
   "note": "Toasts still enter with the same keyframe animation that replays from zero."
  },
  {
   "before": "transition: opacity 450ms var(--ease-out);",
   "status": "resolved",
   "note": "The tooltip now fades in over 150ms."
  },
  {
   "before": "transform-origin: center;",
   "status": "resolved",
   "note": "The menu now scales from var(--transform-origin) rather than its own center."
  },
  {
   "before": "padding-left: 14px;",
   "status": "resolved",
   "note": "The ungated hover shift is gone; the plain hover only changes background-color and the remaining movement sits behind the hover and fine-pointer gate."
  }
 ],
 "caveats": [
  "Assumed the menu library sets --transform-origin to the trigger side; if it does not, the menu falls back to scaling from its center.",
  "Review the toast burst in slow motion after the change to confirm each toast retargets smoothly."
 ]
}

What step 5 prints for it:

decision: block
components/Toast.css:23 animation: toast-in 250ms cubic-bezier(0.23, 1, 0.32, 1) both; -> transition: transform 250ms cubic-bezier(0.23, 1, 0.32, 1), opacity 250ms cubic-bezier(0.23, 1, 0.32, 1); @starting-style { opacity: 0; transform: translateY(100%); }
styles/menu.css:81 transform: translateX(4px); -> Delete this line and drop transform from the transition on line 72, leaving transition: background-color 150ms ease; so the item hover only changes background-color.

The page's check: kept - nothing to flag

Build the input yourself

Load the engine in node (global.window = global; require("./motionkit.js"); require("./motionreply.js")), then:

const K = MotionKit, R = MotionReply;
const f = K.desk({ name: "Settings popover", context: "A settings popover people open a few times a day.", freq: "occasional", previous: "",
  files: "==> popover.css <==\n.popover {\n  transform: scale(0);\n  transform-origin: center;\n  transition: transform 400ms ease-in;\n}\n.popover[data-open] {\n  transform: scale(1);\n}\n" });
// f.files, f.units (every animation: path, line, kind, props, dur, ease), f.findings (id, sev, standard, tier, path, line, before, after), f.verdict
const b = K.buildInput(f, "review");
const wire = K.toWire(b.input);                              // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// R.check(reply, b)                                         // {status: kept or flags, notes}
// a re-review: K.desk({ ...same raw, files: the fixed code, previous: R.previousText(reply) })

That 8-line popover opens from scale(0) on ease-in, so the page's free scan already blocks it (F1 scale0, F2 ease-in, of 5 page findings) - fix those before you pay for a run. Get a token for the steps below from /tokens.html.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from https://motion-review.skillsafe.ai/tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://motion-review.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"motion-review"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "review", "subject": "Account menu - dropdown, toasts, command palette - 3 files, 164 ...", "context": "The account menu in a project-management web app's top bar: a ...", "files": "=== styles/menu.css (100 lines) === ...", "facts": "files: 3 files, 164 lines - all sent whole ...", "previous": "None - this is the first review."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses motion-review:review:<inputHash>:a<attempt> - retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. The reply carries summary, decision, findings, page_calls, verdict, resolved and caveats (on a first review resolved is []). The code below prints the decision, then every finding: its where (path:line) and its before -> after.

KEY="motion-review:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r '"decision: \(.decision)", (.findings[] | "\(.where) \(.before) -> \(.after)")' reply.json

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: motion-review:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json