← View Transition Desk
API
Tokens

Run View Transition Desk from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page masks the JSX, checks every ViewTransition against the skill's rules, makes the mechanical fixes itself and writes the CSS recipes the class names need, then hands it all to the run as facts. The run writes the review or the rewrite; the page then holds it to its own facts and re-scans the upgraded file with the same scanner (vtreply.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":"view-transition-desk"}), 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 required key (task, unit, code, facts) is missing. The page always sends all six of task, unit, code, css, carry, facts; carry is an empty string for review and css may be empty.
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: both lanes are metered and need 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

Two lanes, chosen by task, in the order the work runs - inspect, then produce:

A missing or unknown task is answered as review. Both lanes send the same six fields. The facts are the page's scan, one finding per line with its id, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the review's decisions into carry for the rewrite lane. The review sends at most 24,000 characters of code (the middle of a longer file is cut on whole lines and marked; every finding is still in the facts); the rewrite lane takes at most 16,000.

The input

FieldRequiredWhat
taskyesreview or rewrite. If it is missing or neither, do review and set "task": "review".
unityesthe file's path and the framework (the Next.js App Router, or React 19.3+).
codeyesthe component file as pasted (before the page's changes). A line starting "// [... N lines cut by the page" marks code the page scanned but did not send.
csssend it (may be empty)the global stylesheet as pasted; may be empty.
carrysend it (empty for review)rewrite task only: the review's decisions, one per line as id | confirmed or cleared or extra | where | fix or reason; may be empty.
factsyesthe page's scan: the classes the props name and what the page's CSS to add holds, then one finding per line as id | area | severity | line | where | evidence | source | what the page did (fixed, left, or candidate). "fixed" means the page made the change the finding's fix names in its own copy (and CSS), which the person already has; the code field is still the original paste, so judge it as written. A "candidate" is something the file offers to animate. A line starting "must:" applies to your reply.
retry_notenooptional; if present, your previous reply was unusable - follow it.

The rules the model applies

From the source skill, @vercel-labs/react-view-transitions - the only standard the run applies:

House rules

The replies

review

Decide each of the page's findings, pick the patterns that fit, map the navigations, add what a scan cannot see and put the steps in order.

  1. summary - one sentence, at most 40 words: what will not animate today, what the page already fixed, and the first step.
  2. patterns - one object per pattern that fits this file, in the priority order above: pattern (exactly one of "shared element", "suspense reveal", "list identity", "state change", "route change"), where (the component or element), why (one sentence: what it communicates here). Patterns come from the ViewTransitions already in the file (working or broken) as well as from the candidates; a confirmed Suspense candidate means a "suspense reveal", a confirmed list candidate a "list identity". [] only when nothing in the file should animate.
  3. nav_map - one string per navigation the file triggers: "from -> to | forward, back, lateral or sequential | the VT pattern" (e.g. "/photos -> /photos/[id] | forward | directional slide + shared photo morph"). [] when the file triggers no navigation.
  4. findings - one object per page finding you confirm, in id order: id, widget (where it sits, e.g. "Gallery"), issue (one sentence), fix (one sentence naming one decision - never "X, or Y"; when the right choice needs another file, decide for this file and say what the other file must hold), code (the lines for that piece only, at most 8; "" when fix says it all). Two findings never get fixes that contradict each other.
  5. cleared - one object per page finding that is not a problem here, with id and why (one sentence tied to the code). Every page id appears exactly once, in findings or in cleared.
  6. extra - at most 5 issues the scan cannot see, each tied to a line of the code: id (X1, X2, ...), area (placement, triggers, names, props, suspense, layout, css or a11y), line (a number), widget, issue, fix. Examples: a state update that should run in startTransition, a persistent header that needs a viewTransitionName, an element rendered in both a fallback and its content, a shared name whose target view is in another file, a link that needs transitionTypes. [] when there are none.
  7. plan - 2 to 10 short strings, the steps in order (triggers and placement first, then names and type maps, then the patterns in priority order, the CSS last), each naming the ids it covers (e.g. "Key the photo name per item and give it share (V3, V4)"). Every confirmed id appears in a step. [] only when nothing is confirmed.
  8. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "review",
  "summary": "one sentence",
  "patterns": [ { "pattern": "shared element", "where": "Gallery photo", "why": "one sentence" } ],
  "nav_map": [ "/photos -> /photos/[id] | forward | directional slide + shared photo morph" ],
  "findings": [ { "id": "V1", "widget": "the file", "issue": "one sentence", "fix": "one sentence", "code": "import { ViewTransition, startTransition } from \"react\";" } ],
  "cleared": [ { "id": "V4", "why": "one sentence" } ],
  "extra": [ { "id": "X1", "area": "triggers", "line": 15, "widget": "Gallery", "issue": "one sentence", "fix": "one sentence" } ],
  "plan": [ "Key the photo name per item and give it share (V3, V4)" ],
  "caveats": [ ]
}

The page holds it to its own facts (check() in vtreply.js; any warning makes the reply flagged): the reply answers the task it was sent; every page finding id appears exactly once across findings and cleared, and no id the page never found; a cleared id has a reason; a confirmed id has an issue and a fix; at most five extra issues, each on a line inside the paste; patterns from the skill's five, in priority order, naming the reveal and list patterns when those candidates are confirmed; a fix order of 2 to 10 steps that names every confirmed id; a summary; no web link in the prose.

rewrite

Rewrite the whole component file with the patterns implemented, and write the CSS to add.

  1. summary - one sentence, at most 40 words: what the rewrite changed and what it left for the team.
  2. code - the complete file. Keep every component, export, prop and import still used, every element and class; change what the findings name, plus every carried extra whose edit is made in this file (list it in changes; one that needs another file goes in a caveat), plus the imports a change needs. Implement every pattern the carried review picked, following the rules above. Never shorten the file with "..." or "unchanged" - write every line. If the code holds a line starting "// [... N lines cut by the page", keep that line exactly where it is.
  3. css - the CSS to add to the global stylesheet: a ::view-transition rule for every class the file's ViewTransition props name that the pasted stylesheet does not already style (the skill's recipes for its own class names), the timing variables and keyframes those rules use, and the reduced-motion block unless the stylesheet has one. Never repeat a rule the stylesheet already has.
  4. changes - one object per page finding you acted on (including the ones the page already fixed, which the rewrite keeps), in id order: id, change (one sentence naming where). Carried extra ids (X1, ...) go after the page's ids.
  5. kept - one object per page finding you did not change: id, why (one sentence - the review cleared it, or it needs a file the paste does not hold). Every page id appears exactly once, in changes or in kept; an id the carried review cleared belongs in kept.
  6. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "rewrite",
  "summary": "one sentence",
  "code": "the complete component file",
  "css": "the CSS to add",
  "changes": [ { "id": "V1", "change": "one sentence" } ],
  "kept": [ { "id": "V4", "why": "one sentence" } ],
  "caveats": [ ]
}

The page requires: the reply answers the task it was sent; every page finding id appears exactly once across changes and kept; every component survives; the file is not shortened (at least 70% of its lines) and elides nothing; and the rewritten code is scanned again with the pasted stylesheet plus its CSS - a rule the changes claim fixed that still fires, a rule the paste never had, a class with no rule, a missing reduced-motion block, or flushSync / document.startViewTransition left in, is flagged. A component the rewrite drops or renames is flagged.

Worked examples

Three runs from the page's free samples (invented apps): the review of a photo gallery whose transitions never fire, the rewrite of the same file carrying that review, and the review of a product page with no transitions yet. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the code, the facts 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.

The page read it as: Animates with gaps - 2 blocker, 5 major, 1 minor; findings V1 unstable-import, V2 flush-sync, V3 list-outer, V4 none-no-share, V5 reduced-motion, V6 static-name, V7 css-missing, V8 placement.

Input (facts is 2311 characters; the first 1397 are shown):

{
 "task": "review",
 "unit": "File: app/photos/gallery.tsx\nFramework: Next.js App Router (React canary bundled - never install react@canary)",
 "code": "\"use client\";\n\nimport { useState } from \"react\";\nimport { unstable_ViewTransition as ViewTransition, startTransition } from \"react\";\nimport { flushSync } from \"react-dom\";\nimport Link from \"next/link\";\n\ntype Photo = { id: string; src: string; title: string };\n\nexport function Gallery({ photos }: { photos: Photo[] }) {\n  const [sort, setSort] = useState<\"new\" | \"old\">(\"new\");\n  const ordered = sort === \"new\" ? photos : [...photos].reverse();\n\n  function toggle() {\n    flushSync(() => setSort(sort === \"new\" ? \"old\" : \"new\"));\n  }\n\n  return (\n    <section>\n      <button onClick={toggle}>Sort: {sort}</button>\n      <ul className=\"grid\">\n        {ordered.map((photo) => (\n          <li key={photo.id}>\n            <ViewTransition name=\"photo\" default=\"none\">\n              <Link href={`/photos/${photo.id}`}>\n                <img src={photo.src} alt={photo.title} />\n              </Link>\n            </ViewTransition>\n          </li>\n        ))}\n      </ul>\n      <div className=\"panel\">\n        <ViewTransition enter=\"fade-in\" exit=\"fade-out\">\n          <p>{ordered.length} photos</p>\n        </ViewTransition>\n      </div>\n    </section>\n  );\n}\n",
 "css": ".grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }\n::view-transition-old(.fade-out) {\n  animation: 150ms ease-in fade reverse;\n}\n@keyframes fade {\n  from { opacity: 0; }\n  to { opacity: 1; }\n}\n",
 "carry": "",
 "facts": "The page's scan of 40 lines - 2 ViewTransitions, 1 named VT; 1 component (Gallery); 6 of 8 findings fixed by the page; verdict: Animates with gaps.\nClasses the VT props name: fade-in, fade-out; stylesheet pasted (9 lines); the page's CSS to add holds fade-in and the reduced-motion block.\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- V1 | imports | major | line 4 | the file | unstable_ViewTransition is the canary name - React 19.3 and the Next.js App Router export ViewTransition. | source: import { unstable_ViewTransition as ViewTransition, startTransition } from \"react\"; | fixed\n- V2 | triggers | major | line 15 | Gallery | flushSync(...) commits synchronously, so the view transition is skipped. | source: flushSync(() => setSort(sort === \"new\" ? \"old\" : \"new\")); | fixed\n- V3 | names | major | line 24 | Gallery | A named VT inside a list item with no keyed <ViewTransition> around the item - the shared-element morph has no list-identity layer, so a reorder animates nothing. | source: <ViewTransition name=\"photo\" default=\"none\"> | left\n- V4 | props | blocker | line 24 | Gallery | A named VT with default=\"none\" and no share prop - share resolves to none, so the morph silently never fires. | source: <ViewTransition name=\"photo\" default=\"none\"> | fixed\n- V5 | a11y | minor | line 24 | Gallery | The stylesheet has no prefers-reduced-motion\n\n[... 914 more characters cut from this display ...]"
}

Reply:

{
 "task": "review",
 "summary": "The sort toggle and the photo morph never animate today; the page already swapped the canary import, moved the toggle to startTransition, named each photo per id with share and added the CSS; start by giving each list item a keyed ViewTransition.",
 "patterns": [
  {
   "pattern": "shared element",
   "where": "Gallery photo image",
   "why": "The thumbnail morphs into the large photo on the detail view, so the person sees the same picture going deeper."
  },
  {
   "pattern": "list identity",
   "where": "Gallery photo grid (ul.grid)",
   "why": "Toggling the sort reorders the same photos, so each card should glide to its new slot instead of jumping."
  },
  {
   "pattern": "state change",
   "where": "Gallery photo count panel",
   "why": "The count panel fades in when photos appear and fades out when the grid empties."
  }
 ],
 "nav_map": [
  "/photos -> /photos/[id] | forward | nav-forward slide in each page + shared photo morph"
 ],
 "findings": [
  {
   "id": "V1",
   "widget": "the file",
   "issue": "The import uses unstable_ViewTransition, the canary export name, while the Next.js App Router exports ViewTransition directly.",
   "fix": "Import ViewTransition and startTransition from react by their stable names, as the page did.",
   "code": "import { ViewTransition, startTransition } from \"react\";"
  },
  {
   "id": "V2",
   "widget": "Gallery",
   "issue": "toggle wraps setSort in flushSync, which commits synchronously and skips the view transition for the reorder.",
   "fix": "Run the sort update inside startTransition and drop the flushSync import, as the page did.",
   "code": "function toggle() {\n  startTransition(() => setSort(sort === \"new\" ? \"old\" : \"new\"));\n}"
  },
  {
   "id": "V3",
   "widget": "Gallery",
   "issue": "Each photo has only the inner named ViewTransition, so a sort change has no list-identity layer and the cards do not move.",
   "fix": "Wrap each item in an outer ViewTransition keyed by photo.id, with no DOM node between the map and that VT, and keep the named VT inside it.",
   "code": "{ordered.map((photo) => (\n  <ViewTransition key={photo.id}>\n    <li>\n      ...named photo VT...\n    </li>\n  </ViewTransition>\n))}"
  },
  {
   "id": "V4",
   "widget": "Gallery",
   "issue": "The named photo VT sets default=\"none\" without share, so share resolves to none and the morph never fires.",
   "fix": "Add share=\"morph\" next to default=\"none\" so only the shared pair animates, as the page did.",
   "code": "<ViewTransition name={\"photo-\" + photo.id} share=\"morph\" default=\"none\">"
  },
  {
   "id": "V5",
   "widget": "Gallery",
   "issue": "The pasted stylesheet holds no prefers-reduced-motion block, so every slide and morph plays for people who asked for less motion.",
   "fix": "Add the reduced-motion block to the global stylesheet, as the page did.",
   "code": ""
  },
  {
   "id": "V6",
   "widget": "Gallery",
   "issue": "Every photo in the map mounts a ViewTransition called photo, yet only one VT with a given name can be mounted.",
   "fix": "Build the name from the photo id so each item is unique, as the page did.",
   "code": "<ViewTransition name={\"photo-\" + photo.id} share=\"morph\" default=\"none\">"
  },
  {
   "id": "V7",
   "widget": "Gallery",
   "issue": "The panel VT names fade-in for enter, but the stylesheet only styles fade-out, so the enter has no animation.",
   "fix": "Add the fade-in recipe and the timing variables it uses to the global stylesheet, as the page did.",
   "code": ""
  },
  {
   "id": "V8",
   "widget": "Gallery",
   "issue": "The panel VT sits inside div.panel, which mounts and unmounts with it, so its enter and exit never activate.",
   "fix": "Move the ViewTransition outside the panel div and render it conditionally on ordered.length so the VT itself appears and disappears.",
   "code": "{ordered.length > 0 && (\n  <ViewTransition enter=\"fade-in\" exit=\"fade-out\">\n    <div className=\"panel\">\n      <p>{ordered.length} photos</p>\n    </div>\n  </ViewTransition>\n)}"
  }
 ],
 "cleared": [],
 "extra": [
  {
   "id": "X1",
   "area": "triggers",
   "line": 25,
   "widget": "Gallery",
   "issue": "The photo Link carries no transition type, so the detail page cannot tell a forward step from any other navigation.",
   "fix": "Pass transitionTypes={[\"nav-forward\"]} on the Link so the detail page's directional VT resolves the forward slide."
  },
  {
   "id": "X2",
   "area": "names",
   "line": 24,
   "widget": "Gallery",
   "issue": "The photo name only morphs if the detail view mounts a VT with the same photo-id name, and that view is not in this file.",
   "fix": "On the /photos/[id] page, wrap the large image in ViewTransition name={\"photo-\" + id} share=\"morph\" with the same id."
  }
 ],
 "plan": [
  "Swap the canary import for ViewTransition and move the sort toggle into startTransition (V1, V2)",
  "Move the panel VT outside its div and render it only while there are photos (V8)",
  "Name each photo per id and give it share=\"morph\" beside default=\"none\" (V6, V4)",
  "Wrap each list item in an outer ViewTransition keyed by photo.id around the named photo VT (V3)",
  "Add transitionTypes to the photo Link and the matching photo name on the detail page (X1, X2)",
  "Add the fade-in and morph recipes, the timing variables and the reduced-motion block to the global stylesheet (V7, V5)"
 ],
 "caveats": [
  "The matching photo name and the nav-forward / nav-back slide belong in the /photos/[id] page file, which the paste does not hold.",
  "transitionTypes on next/link needs Next.js 16.2 or newer; older versions need startTransition with addTransitionType and router.push.",
  "Once the panel VT can exit, its fade-out sits on a page with a shared photo morph; swap the exit for slide-down so the source is not dissolved mid-morph."
 ]
}

What step 5 prints for it: V1: Import ViewTransition and startTransition from react by their stable names, as the page did. | V2: Run the sort update inside startTransition and drop the flushSync import, as the page did. | V3: Wrap each item in an outer ViewTransition keyed by photo.id, with no DOM node between the map and that VT, and keep the named VT inside it. | V4: Add share="morph" next to default="none" so only the shared pair animates, as the page did. | V5: Add the reduced-motion block to the global stylesheet, as the page did. | V6: Build the name from the photo id so each item is unique, as the page did. | V7: Add the fade-in recipe and the timing variables it uses to the global stylesheet, as the page did. | V8: Move the ViewTransition outside the panel div and render it conditionally on ordered.length so the VT itself appears and disappears.

The page's check: kept - X2 repeats what the page already found on line 24.

The input carries the saved review (R.laneForm fills carry from the review reply), as the page's handoff does. The page re-scanned the rewritten code: 8 findings before, 0 after.

Input (facts is 2334 characters; the first 1397 are shown):

{
 "task": "rewrite",
 "unit": "File: app/photos/gallery.tsx\nFramework: Next.js App Router (React canary bundled - never install react@canary)",
 "code": "\"use client\";\n\nimport { useState } from \"react\";\nimport { unstable_ViewTransition as ViewTransition, startTransition } from \"react\";\nimport { flushSync } from \"react-dom\";\nimport Link from \"next/link\";\n\ntype Photo = { id: string; src: string; title: string };\n\nexport function Gallery({ photos }: { photos: Photo[] }) {\n  const [sort, setSort] = useState<\"new\" | \"old\">(\"new\");\n  const ordered = sort === \"new\" ? photos : [...photos].reverse();\n\n  function toggle() {\n    flushSync(() => setSort(sort === \"new\" ? \"old\" : \"new\"));\n  }\n\n  return (\n    <section>\n      <button onClick={toggle}>Sort: {sort}</button>\n      <ul className=\"grid\">\n        {ordered.map((photo) => (\n          <li key={photo.id}>\n            <ViewTransition name=\"photo\" default=\"none\">\n              <Link href={`/photos/${photo.id}`}>\n                <img src={photo.src} alt={photo.title} />\n              </Link>\n            </ViewTransition>\n          </li>\n        ))}\n      </ul>\n      <div className=\"panel\">\n        <ViewTransition enter=\"fade-in\" exit=\"fade-out\">\n          <p>{ordered.length} photos</p>\n        </ViewTransition>\n      </div>\n    </section>\n  );\n}\n",
 "css": ".grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }\n::view-transition-old(.fade-out) {\n  animation: 150ms ease-in fade reverse;\n}\n@keyframes fade {\n  from { opacity: 0; }\n  to { opacity: 1; }\n}\n",
 "carry": "V1 | confirmed | the file | Import ViewTransition and startTransition from react by their stable names, as the page did.\nV2 | confirmed | Gallery | Run the sort update inside startTransition and drop the flushSync import, as the page did.\nV3 | confirmed | Gallery | Wrap each item in an outer ViewTransition keyed by photo.id, with no DOM node between the map and that VT, and keep the named VT inside it.\nV4 | confirmed | Gallery | Add share=\"morph\" next to default=\"none\" so only the shared pair animates, as the page did.\nV5 | confirmed | Gallery | Add the reduced-motion block to the global stylesheet, as the page did.\nV6 | confirmed | Gallery | Build the name from the photo id so each item is unique, as the page did.\nV7 | confirmed | Gallery | Add the fade-in recipe and the timing variables it uses to the global stylesheet, as the page did.\nV8 | confirmed | Gallery | Move the ViewTransition outside the panel div and render it conditionally on ordered.length so the VT itself appears and disappears.\nX1 | extra | Gallery (line 25) | Pass transitionTypes={[\"nav-forward\"]} on the Link so the detail page's directional VT resolves the forward slide.\nX2 | extra | Gallery (line 24) | On the /photos/[id] page, wrap the large image in ViewTransition name={\"photo-\" + id} share=\"morph\" with the same id.",
 "facts": "The page's scan of 40 lines - 2 ViewTransitions, 1 named VT; 1 component (Gallery); 6 of 8 findings fixed by the page; verdict: Animates with gaps.\nClasses the VT props name: fade-in, fade-out; stylesheet pasted (9 lines); the page's CSS to add holds fade-in and the reduced-motion block.\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- V1 | imports | major | line 4 | the file | unstable_ViewTransition is the canary name - React 19.3 and the Next.js App Router export ViewTransition. | source: import { unstable_ViewTransition as ViewTransition, startTransition } from \"react\"; | fixed\n- V2 | triggers | major | line 15 | Gallery | flushSync(...) commits synchronously, so the view transition is skipped. | source: flushSync(() => setSort(sort === \"new\" ? \"old\" : \"new\")); | fixed\n- V3 | names | major | line 24 | Gallery | A named VT inside a list item with no keyed <ViewTransition> around the item - the shared-element morph has no list-identity layer, so a reorder animates nothing. | source: <ViewTransition name=\"photo\" default=\"none\"> | left\n- V4 | props | blocker | line 24 | Gallery | A named VT with default=\"none\" and no share prop - share resolves to none, so the morph silently never fires. | source: <ViewTransition name=\"photo\" default=\"none\"> | fixed\n- V5 | a11y | minor | line 24 | Gallery | The stylesheet has no prefers-reduced-motion\n\n[... 937 more characters cut from this display ...]"
}

Reply (long strings shortened for display - see the cuts below):

{
 "task": "rewrite",
 "summary": "Gallery now imports the stable ViewTransition, sorts in startTransition, keys each photo around a per-id morph, mounts the panel VT on its own with a slide-down exit and tags the Link; the detail page name is left for the team.",
 "code": "\"use client\";\n\nimport { useState } from \"react\";\nimport { ViewTransition, startTransition } from \"react\";\nimport Link from \"next/link\";\n\ntype Photo = { id: string; src: string; title: string };\n\nexport function Gallery({ photos }: { photos: Photo[] }) {\n  const [sort, setSort] = useState<\"new\" | \"old\">(\"new\");\n  const [... 955 more characters cut from this display]",
 "css": ":root {\n  --duration-exit: 150ms;\n  --duration-enter: 210ms;\n  --duration-move: 400ms;\n}\n\n::view-transition-new(.fade-in) {\n  animation: var(--duration-enter) ease-out var(--duration-exit) both fade;\n}\n\n::view-transition-old(.slide-down) {\n  animation:\n    var(--duration-exit) ease-out both fade reverse, [... 597 more characters cut from this display]",
 "changes": [
  {
   "id": "V1",
   "change": "The import line now pulls ViewTransition and startTransition from react by their stable names."
  },
  {
   "id": "V2",
   "change": "toggle in Gallery calls startTransition instead of flushSync, and the react-dom flushSync import is gone."
  },
  {
   "id": "V3",
   "change": "Each photo li in Gallery now sits inside an outer ViewTransition keyed by photo.id, directly under the map with no DOM node between."
  },
  {
   "id": "V4",
   "change": "The named photo VT in Gallery carries share=\"morph\" beside default=\"none\", and the CSS adds the morph recipe."
  },
  {
   "id": "V5",
   "change": "The CSS to add ends with the reduced-motion block for the global stylesheet."
  },
  {
   "id": "V6",
   "change": "The photo VT in Gallery is named \"photo-\" + photo.id so every item is unique."
  },
  {
   "id": "V7",
   "change": "The CSS adds the fade-in recipe and the timing variables it uses, reusing the pasted fade keyframes."
  },
  {
   "id": "V8",
   "change": "The panel ViewTransition in Gallery now wraps div.panel and renders only while ordered.length is above zero, exiting with slide-down instead of fade-out since the page has a shared morph."
  },
  {
   "id": "X1",
   "change": "The photo Link in Gallery passes transitionTypes={[\"nav-forward\"]}."
  }
 ],
 "kept": [],
 "caveats": [
  "X2 needs the /photos/[id] page, which the paste does not hold: wrap its large image in ViewTransition name={\"photo-\" + id} share=\"morph\" and give that page its nav-forward / nav-back slide.",
  "transitionTypes on next/link needs Next.js 16.2 or newer; on older versions use startTransition with addTransitionType and router.push.",
  "The pasted fade-out rule is no longer named by the file and can stay or go.",
  "The photo href is now built with string concatenation; it points at the same path as before."
 ]
}

Cut from this display only (the saved reply and the check use the full text): code (1274 characters, 319 shown); css (902 characters, 305 shown).

What step 5 prints for it: V1: The import line now pulls ViewTransition and startTransition from react by their stable names. | V2: toggle in Gallery calls startTransition instead of flushSync, and the react-dom flushSync import is gone. | V3: Each photo li in Gallery now sits inside an outer ViewTransition keyed by photo.id, directly under the map with no DOM node between. | V4: The named photo VT in Gallery carries share="morph" beside default="none", and the CSS adds the morph recipe. | V5: The CSS to add ends with the reduced-motion block for the global stylesheet. | V6: The photo VT in Gallery is named "photo-" + photo.id so every item is unique. | V7: The CSS adds the fade-in recipe and the timing variables it uses, reusing the pasted fade keyframes. | V8: The panel ViewTransition in Gallery now wraps div.panel and renders only while ordered.length is above zero, exiting with slide-down instead of fade-out since the page has a shared morph. | X1: The photo Link in Gallery passes transitionTypes={["nav-forward"]}.

The page's check: kept - nothing to flag

review - A product page with no transitions yet

The page read it as: No transitions yet - 0 blocker, 0 major, 3 minor; findings V1 audit-nav, V2 audit-suspense, V3 audit-list.

Input:

{
 "task": "review",
 "unit": "File: app/products/[id]/page.tsx\nFramework: Next.js App Router (React canary bundled - never install react@canary)",
 "code": "import { Suspense } from \"react\";\nimport Link from \"next/link\";\nimport { Reviews, ReviewsSkeleton } from \"./reviews\";\n\nexport default async function ProductPage({ product, related }) {\n  return (\n    <main>\n      <img src={product.image} alt={product.name} className=\"hero\" />\n      <h1>{product.name}</h1>\n      <Suspense fallback={<ReviewsSkeleton />}>\n        <Reviews productId={product.id} />\n      </Suspense>\n      <ul>\n        {related.map((r) => (\n          <li key={r.id}>\n            <Link href={`/products/${r.id}`}>{r.name}</Link>\n          </li>\n        ))}\n      </ul>\n    </main>\n  );\n}\n",
 "css": "",
 "carry": "",
 "facts": "The page's scan of 23 lines - 0 ViewTransitions, 0 named VTs; 1 component (ProductPage); 0 of 3 findings fixed by the page; verdict: No transitions yet.\nClasses the VT props name: none; no stylesheet pasted; the page's CSS to add holds nothing.\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- V1 | audit | minor | line 1 | the file | 1 navigation trigger (Link, router.push) and no ViewTransition - candidate route changes (pattern 5) and shared elements (pattern 1). | source: import { Suspense } from \"react\"; | candidate\n- V2 | audit | minor | line 10 | ProductPage | A <Suspense> boundary with no ViewTransition - a candidate Suspense reveal (pattern 2). | source: <Suspense fallback={<ReviewsSkeleton />}> | candidate\n- V3 | audit | minor | line 14 | ProductPage | A list rendered with .map() and no ViewTransition per item - a candidate list identity animation (pattern 3). | source: {related.map((r) => ( | candidate\nmust: account for every finding id above exactly once - in findings (confirmed) or in cleared (with the reason)."
}

Reply:

{
 "task": "review",
 "summary": "Nothing on ProductPage animates yet and the page fixed nothing; the first step is a keyed page-level ViewTransition so a related-product link cross-fades between products, then a Suspense reveal for the reviews.",
 "patterns": [
  {
   "pattern": "shared element",
   "where": "ProductPage hero image (img.hero)",
   "why": "The product image arriving from a product list morphs into the hero, so the person sees the same item going deeper."
  },
  {
   "pattern": "suspense reveal",
   "where": "ProductPage Reviews Suspense boundary",
   "why": "The skeleton slides away and the loaded reviews slide up, which says the data has arrived."
  },
  {
   "pattern": "route change",
   "where": "ProductPage main",
   "why": "A related link swaps one product for a sibling on the same route, so a cross-fade marks a lateral move to a new place."
  }
 ],
 "nav_map": [
  "/products/[id] -> /products/[id] | lateral | keyed page cross-fade (key + name + share=\"auto\")"
 ],
 "findings": [
  {
   "id": "V1",
   "widget": "ProductPage",
   "issue": "The related Links navigate to another product with no ViewTransition anywhere, and the hero image is a natural shared element, yet neither animates.",
   "fix": "Wrap main in a ViewTransition keyed by product.id with a stable name, share=\"auto\" and default=\"none\", and name the hero image per product with share=\"morph\".",
   "code": "<ViewTransition key={product.id} name=\"product-page\" share=\"auto\" default=\"none\">\n  <main>\n    <ViewTransition name={\"product-\" + product.id} share=\"morph\">\n      <img src={product.image} alt={product.name} className=\"hero\" />\n    </ViewTransition>\n    ...\n  </main>\n</ViewTransition>"
  },
  {
   "id": "V2",
   "widget": "ProductPage",
   "issue": "The Reviews Suspense boundary swaps the skeleton for the content with no ViewTransition, so the reveal is an instant jump.",
   "fix": "Wrap the fallback in a VT with exit=\"slide-down\" and the content in a VT with enter=\"slide-up\" and default=\"none\", using string props only.",
   "code": "<Suspense fallback={<ViewTransition exit=\"slide-down\"><ReviewsSkeleton /></ViewTransition>}>\n  <ViewTransition enter=\"slide-up\" default=\"none\">\n    <Reviews productId={product.id} />\n  </ViewTransition>\n</Suspense>"
  }
 ],
 "cleared": [
  {
   "id": "V3",
   "why": "ProductPage is an async server component with no state, so the related list only changes when product.id changes, and the keyed page VT already cross-fades that as one unit; per-item keys would never see a reorder."
  }
 ],
 "extra": [],
 "plan": [
  "Wrap main in a ViewTransition keyed by product.id with a stable name, share=\"auto\" and default=\"none\" (V1)",
  "Name the hero image per product with share=\"morph\" to match the product list view (V1)",
  "Wrap the Reviews fallback and content in separate exit=\"slide-down\" / enter=\"slide-up\" VTs (V2)",
  "Add the slide-up / slide-down and morph recipes, the timing variables and the reduced-motion block to the global stylesheet (V1, V2)"
 ],
 "caveats": [
  "The hero morph needs the product list view, in another file, to mount ViewTransition name={\"product-\" + id} share=\"morph\" on its image.",
  "Arriving here from that list is a forward step; the nav-forward / nav-back slide and the link transitionTypes belong to that navigation, outside this file.",
  "No stylesheet was pasted, so all the CSS goes into the global stylesheet."
 ]
}

What step 5 prints for it: V1: Wrap main in a ViewTransition keyed by product.id with a stable name, share="auto" and default="none", and name the hero image per product with share="morph". | V2: Wrap the fallback in a VT with exit="slide-down" and the content in a VT with enter="slide-up" and default="none", using string props only. | V3: cleared

The page's check: kept - nothing to flag

Build the input yourself

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

const K = VtKit, R = VtReply;
const f = K.form({ name: "app/card.tsx", framework: "next", css: "", carry: "",
  code: "import { ViewTransition } from \"react\";\nexport function Card({ show }) {\n  return show && <ViewTransition enter=\"scale-in\" exit=\"scale-out\"><p>Saved</p></ViewTransition>;\n}" });
// f.findings (each with .auto when the page fixed it), f.scan.migrated (the fixed copy), f.scan.css (the CSS to add), f.checklist
// K.findingsCsv(f), K.itemsCsv(f), K.checklistMd(f) - the free exports
const b = K.buildInput(f, "review");                         // or "rewrite" (R.laneForm(form, "rewrite", review) fills carry)
const wire = K.toWire(b.input);                              // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b)                                         // {status: kept or flags, notes}

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 /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://view-transition-desk.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":"view-transition-desk"}' | 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", "unit": "File: app/photos/gallery.tsx\nFramework: Next.js App Router", "code": "\"use client\";\n...", "css": ".grid { ... }", "carry": "", "facts": "The page's scan of 40 lines - 2 ViewTransitions ...\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- V1 | imports | major | line 4 | ..."}
# - 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 view-transition-desk:<task>:<inputHash>:a<attempt> - the lane is part of the key, 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. A review reply carries summary, patterns, nav_map, findings, cleared, extra, plan and caveats; a rewrite reply carries summary, code, css, changes, kept and caveats. The code below prints the gist of either: each confirmed finding and its fix, or each change.

KEY="view-transition-desk:$(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 'if .task == "rewrite" then (.changes[] | "\(.id): \(.change)") 
       else (.findings[] | "\(.id): \(.fix)") end' 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: view-transition-desk:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json