# The canvas manifest (critlab.canvas.json)

> The file a publishing agent writes so the canvas shows every screen and state of a prototype with the right names.

Source: https://critlab.app/docs/agents/canvas

critlab draws every version of a prototype as a **canvas**: each screen a frame, one section per breakpoint (Desktop, Tablet, Mobile) when the prototype is responsive, joined by the links between them. Where the screens come from is a file the publishing agent writes, `critlab.canvas.json`. Without it, critlab follows the prototype's links and guesses the names.

## The requirement

Whenever an agent publishes a prototype that has more than one screen, or any designed state (loading, empty, error, success…), it writes or updates `critlab.canvas.json` at the package root, next to `index.html`, before it publishes:

- List every screen with `route` (root-relative, e.g. `/checkout/review`), `label` (the name reviewers see, e.g. "Review order") and `group` (the section it belongs to, e.g. "Checkout").
- Make every designed state reachable from a URL query, e.g. `/checkout/review?state=error`, and list it as its own screen named after the state ("Review order · Error") with `state` set. The canvas never clicks or types, so a state that needs a click to appear cannot be shown.
- Add `edges` for flow steps that no link shows.
- Keep the file in sync on every new version: add new screens, remove deleted ones.

The MCP server sends this requirement to every connected agent, and `critlab_publish` and `crit publish --help` repeat it. The canvas tools (`critlab_publish`, `critlab_get_canvas`, `critlab_rebuild_canvas`) are all in the default `core` tool group.

## Example

```json
{
  "$schema": "https://critlab.app/schemas/canvas-v1.json",
  "version": 1,
  "entry": "/",
  "breakpoints": ["mobile", "desktop"],
  "groups": [
    { "id": "home", "label": "Home & assistant" },
    { "id": "transfer", "label": "Transfer" },
    { "id": "errors", "label": "Errors" }
  ],
  "screens": [
    { "route": "/", "label": "Home", "group": "home" },
    { "route": "/assistant", "label": "Assistant", "group": "home" },
    { "route": "/assistant?state=thinking", "label": "Assistant · Thinking", "group": "home", "state": "loading" },
    { "route": "/transfer/amount", "label": "Amount", "group": "transfer" },
    { "route": "/transfer/review", "label": "Review", "group": "transfer" },
    { "route": "/transfer/review?state=declined", "label": "Card declined", "group": "errors", "state": "error" }
  ],
  "edges": [
    { "from": "/transfer/review", "to": "/transfer/review?state=declined" }
  ]
}
```

## Fields

| Field | Rule |
| --- | --- |
| `version` | Required, exactly `1`. |
| `entry` | Optional, default `"/"`. Must be one of the screens' routes. |
| `breakpoints` | Optional: some of `"desktop"`, `"tablet"`, `"mobile"`. When present it replaces detection. |
| `groups` | Optional, up to 40. `id`: 1–40 lowercase letters, digits and hyphens. `label`: 1–60 characters. |
| `screens` | Required, 1–200. `route`: root-relative, no spaces, backslashes or `..`, a page the package serves, each page once. `label`: 1–120 characters, plain text. `group`: a declared group id; a screen without one is in "Screens". `state`: 1–40 lowercase letters, digits and hyphens. |
| `edges` | Optional, up to 1,000. `from` and `to` are routes of listed screens, never the same screen. |

The file is at most 256 KiB of JSON. Unknown fields are refused at every level, and `$schema` is informational: critlab never fetches it. The same rules as a JSON Schema: [/schemas/canvas-v1.json](/schemas/canvas-v1.json).

A route is a page of the package when critlab would serve an HTML document for it: an HTML file in the package (`/pricing/` for `pricing/index.html`, `/about.html`), or an extensionless route of a single-page app that has a root `index.html` (`/checkout/review`), or a hash route (`/#/cart`). A route to a file the package does not have, or to a file that is not HTML, is refused.

## Checking the file

`crit canvas check <dir>` validates the file locally with the server's own rules, with no network. `crit publish` and `critlab_publish` check it again before uploading, and the server checks it before storing anything: a broken file refuses the whole publish, naming the field and the fix, for example:

```text
critlab.canvas.json: screens[3].route "/pricing.html" is not a page in this package (no such HTML file). Hint: use a route the package serves, e.g. "/pricing/" for pricing/index.html, or remove the screen.
```

`crit publish --canvas <file>` publishes another file as the package's `critlab.canvas.json`.

## Without the file

Publishing a package with two or more HTML pages and no `critlab.canvas.json` prints a note saying so. critlab then builds the canvas by following the links it finds in the rendered pages:

- It opens each page in the same sandbox reviewers use and reads the `href` of every link. It never clicks, types or submits, so a screen that only a button's script reaches is invisible to it — list that screen in the file.
- Only links into the prototype itself count. It stops at 40 found screens, 4 links deep, 120 snapshots and 4 minutes, and says when it stopped early.
- A screen's name is its page title (a site suffix shared by every page removed), else its first heading, else its route. Its group is the route's first path segment.
- Pages that are not part of the flow go under "Other screens", the last group. Three kinds: a page no other page links to (critlab looks at every HTML file in the package, so it finds pages nothing points at), a not-found page (its title or first heading says so, in English, German, Croatian, Spanish, French and a dozen other languages, or its file is `404.html` or `404/index.html`; it goes there even when something links to it), and a page that renders blank at desktop size, such as a Netlify forms stub, when nothing links to it. Every one is still a screen you can open and comment on, and nothing is dropped. A page that renders blank at desktop size is not rendered again at tablet and phone sizes: it keeps a "Blank page" frame in every column. A page something links to stays in its group whatever it is called, so an "Offline" or "Card declined" page the flow reaches is a normal screen. The entry is never moved.

When the file exists, the crawl still runs, but only to find what the file is missing: routes it finds that the file does not list are shown under "Other screens" and reported, never dropped. The file wins everywhere else: a screen it lists keeps the group, the name and the renders the file gave it, whatever the page is (a listed 404 page stays in the group you put it in).

## Breakpoints

Desktop is always a section. Without `breakpoints` in the file, critlab opens the entry page and up to three more pages (the first ones its crawl visited, blank pages skipped) at phone (390 × 844) and tablet (820 × 1180) sizes, compares each with that same page at desktop (1280), and adds a section when at least one of those pages qualifies and at least half of the measured pages do:

- **Mobile.** A page qualifies when it declares `width=device-width` in a viewport meta, does not scroll sideways because of the phone, and its `@media` rules or layout change at 390.
- **Tablet.** A page qualifies when it does not scroll sideways because of the tablet width and its `@media` rules at 820 are neither its desktop rules nor its phone rules: a layout of its own.

Sideways scrolling counts against a page only when the narrow width causes it (it scrolls sideways at 390 or 820 but not at 1280) and only when a reader could see it: the page does not clip it itself (`overflow-x: hidden` or `clip` on `html` or `body`), and visible content reaches past the right edge. An off-canvas menu that is hidden, fixed to the window or clipped by an ancestor does not count. Because the pages vote, one redirect stub or one odd page cannot decide for the canvas, while a desktop page shrunk onto a phone on most of its pages never gets a Mobile section. The extra pages count against the 120-snapshot limit.

Detection says why. `crit canvas <slug>` and `critlab_get_canvas` print one line per breakpoint under the list of sections:

```text
Breakpoints: Desktop, Tablet
  Desktop: always shown
  Tablet: detected — 3 of 4 measured pages have a layout of their own at 820 px
  Mobile: not detected — none of the 3 measured pages fit a 390 px phone and change layout (at least half must). /, /about.html, /contact.html: no width=device-width viewport meta
  To show a breakpoint detection missed, list it in critlab.canvas.json: "breakpoints": ["desktop", "tablet", "mobile"] (the file's list replaces detection).
```

The API sends the same facts in `detection` (`reasons` has one sentence for each breakpoint that is missing; `decisions` has the counts and each measured page's verdict). To get a section detection does not give, list it in the file's `breakpoints`: that replaces detection completely.

## Reading the canvas

`crit canvas <slug>` and the `critlab_get_canvas` tool list the sections and why each breakpoint is or is not there, every screen with its route, group and open comments per breakpoint, and the flow. A page the crawl moved under "Other screens" says why in brackets, and the closing note repeats it:

```text
Other screens (2 screens)
  9. Page not found  /404.html  [not-found page]
     Desktop: 0 open · Mobile: 0 open  · id 0acab775-…
  10. /netlify-forms.html  /netlify-forms.html  [blank page, rendered at Desktop only]
     Desktop: 0 open, blank page · Mobile: 0 open, blank page  · id 87ea91be-…

2 pages are under "Other screens", out of the flow: 1 not-found page, 1 blank page (rendered at Desktop only). Every page is still a screen. List a page in critlab.canvas.json to put it in a group.
```

The API sends the same facts in `report.otherScreens` (`route` and `why`: `unlinked`, `not_found` or `blank`) and `report.skippedRenders`. Every screen and every section also carries `other` (`true` for a page under "Other screens", including a route the crawl found that your file does not list; `false` for everything else, even a group you named "Other screens" yourself), so `crit canvas <slug> --json` tells you which screens are out of the flow without reading names. `critlab_get_canvas` with `includeImages: true` returns the screens' snapshots too, so an agent sees what reviewers see. `crit canvas rebuild <slug>` and `critlab_rebuild_canvas` build it again.

## Screenshots of a native app

A team building a native app (SwiftUI, Kotlin, React Native, Flutter) publishes the screens themselves: a folder of PNG, JPEG or WebP screenshots and a `critlab.canvas.json` of the **screens flavour** next to them. critlab compiles the set into a prototype reviewers click through like a clickable mockup, and draws its canvas straight from the set, ready the moment it is published. The flavour is told apart by its top-level `device`; everything else about the file (one name, 256 KiB at most, unknown fields refused, the JSON Schema's `$defs.screens`) is as above.

```bash
crit publish --screenshots ./shots           # the folder holds the images and critlab.canvas.json, no index.html
crit canvas check ./shots                    # the server's rules, locally, plus warnings about the flow
```

The MCP tool is `critlab_publish` with `screenshots: true`. The publishing agent writes the file, exactly as for an HTML prototype: the flow and the device are the agent's to say.

### A screens manifest

```json
{
  "$schema": "https://critlab.app/schemas/canvas-v1.json",
  "version": 1,
  "device": "mobile",
  "platform": "ios",
  "groups": [{ "id": "onboarding", "label": "Onboarding" }],
  "screens": [
    { "id": "welcome", "image": "01-welcome.png", "label": "Welcome", "group": "onboarding", "source": "Sources/WelcomeView.swift" },
    { "id": "signup", "image": "02-signup.png", "label": "Sign up", "group": "onboarding" },
    { "id": "video", "image": "03-video.png", "label": "Product video", "orientation": "landscape" },
    { "id": "signup", "image": "ipad/02-signup.png", "device": "tablet" }
  ],
  "entry": "welcome",
  "edges": [
    { "from": "welcome", "to": "signup", "label": "Tap Get started", "primary": true,
      "hotspot": { "x": 0.08, "y": 0.86, "w": 0.84, "h": 0.07 }, "device": "mobile" },
    { "from": "signup", "to": "video", "label": "Tap Continue", "device": "mobile" }
  ]
}
```

### Fields of the screens flavour

| Field | Rule |
| --- | --- |
| `device` | Required: `"mobile"`, `"tablet"` or `"desktop"`, the set's own device. A screen's own `device` overrides it. |
| `platform` | Optional, at the top or per screen: `ios`, `android`, `macos`, `windows` or `web`. |
| `orientation` | Optional, at the top or per screen: `portrait` or `landscape`; without it, the image's aspect decides. A landscape phone or tablet screen is framed by the device turned sideways (844 × 390, 1180 × 820). |
| `scale` | Optional: image pixels per point, 0.5 to 4 (an `@2x` / `@3x` file name says it too). |
| `screens` | Required, 1 to 200 per set. `id`: 1 to 40 lowercase letters, digits and hyphens, unique per device. `image`: a PNG, JPEG or WebP in the folder. `label`, `group` and `source` (a relative path in the app's repo, the file that draws the screen) as their names say. The same `id` once more with another `device` is that screen's variant on the other device: it lists only `id`, `image` and `device` (and optionally `source`, `platform`, `orientation`, `scale`) and takes its label and group from the first entry. |
| `entry` | Optional, a screen `id`; default the first screen. |
| `edges` | The flow. `from`, `to`: screen ids. `label`: optional, what the step is ("Tap Continue"), shown on the canvas arrow and in the bundle. `hotspot`: optional, `{ x, y, w, h }` as fractions (0 to 1) of the screen IMAGE, the region a tap opens; on a long capture, of the whole image. `primary: true`: the screen's "next" step, which a click anywhere else on the screen (and the → key) follows; at most one per screen and device. `device`: optional, limits the edge to one device. Without it the edge applies on every device that has both screens, and in a set with more than one device an edge with a hotspot must name its `device`. |

A screen's only outgoing edge is its primary when it has no hotspot. An edge with a hotspot is a tap on that region only, unless `primary: true`. An edge with neither is flow only: drawn on the canvas and in the bundle, nothing to click. `edges` left out chains the screens in file order, per device; `edges: []` is an unlinked gallery (scroll and comment only).

**Where the tap is.** The agent knows where the control it tapped is: its frame from the simulator's accessibility tree (an XCUITest element's `frame`, the `bounds` in `adb shell uiautomator dump`) or the UI test that took the screenshot, divided by the screenshot's size in points. For a 390 × 844 pt iPhone screen and a button at `{ x: 32, y: 726, width: 326, height: 60 }` that is `{ "x": 0.082, "y": 0.860, "w": 0.836, "h": 0.071 }`.

**Without the file**, the screens are chained in file order (a folder or an `NN-` prefix is the group, the file name the label), the device is inferred from the pixel size, and the publish says both: `Note [canvas_manifest_missing]` and `Note [device_inferred]`.

### Checking a set

`crit canvas check <dir>` refuses a broken set with the server's own message, naming the field: an `image` that is not in the folder, an unknown `device`, a `source` that is not a relative path, a hotspot that is empty or outside the image, a second primary from one screen. A valid set can still be hard to click through, and the check then warns (the publish still goes through):

```text
Warning [flow_only_edge]: The edge review → signup on mobile is flow only: it is drawn on the canvas, but there is nothing to click. Give it a hotspot, or primary: true.
Warning [no_primary]: The screen review on mobile has 2 edges and no primary: a click outside its hotspots only flashes them. Mark the obvious next step primary: true.
Warning [overlapping_hotspots]: On signup (mobile) the hotspots to review and welcome overlap: where they meet only one of them can be tapped.
Warning [small_hotspot]: The hotspot of review → welcome on mobile is 8 × 17 px at the 390px frame, under the 24 px a tap target needs. Make it at least 24 px each way.
```

The size check measures each hotspot at the width its screen is shown at: Mobile 390 px, Tablet 820 px, Desktop 1,280 px, and 844 px or 1,180 px for a landscape phone or tablet screen.

### The canvas of a set

A set's canvas needs no crawl: one column per device the set has (Desktop, Tablet, Mobile), one band per group in the set's order, each screen in the column of its device (a mixed set shows the same screen in two columns), every frame the screenshot itself, and an arrow for each edge, named by its `label` where the gap between two frames has room. A share link of a set with two or more screens opens on the canvas; a click on a frame opens that screen live; a set of one screen opens the screen. In the live viewer the bar's Screens button lists the set's screens by device and section and opens any of them.

`crit canvas <slug>` and `critlab_get_canvas` print the set's devices, each screen with its own `id`, its page and open comments per device, and the flow per device:

```text
Canvas of "a8f3k2" v2: ready · a screenshot set · 3 screens on Tablet + Mobile (iOS) · from critlab.canvas.json, the flow as written

Onboarding (2 screens)
  1. Welcome [welcome]  (entry)
     Mobile: / · 2 open
  2. Sign up [signup]
     Tablet: /tablet/signup/ · 0 open
     Mobile: /mobile/signup/ · 0 open

Flow:
  Mobile:
    Welcome [welcome] → Sign up [signup]  "Tap Get started"  (primary, tap on a hotspot)
    Sign up [signup] → Product video [video]  "Tap Continue"  (primary, click anywhere)
```

The API's canvas of a set carries `format: "screens"`, `screensSet` (its device, devices, platform and flow), each screen's `screenKey` (its id in the file), each frame's own `route`, `orientation` and `kind: "screenshot"`, and each edge's `breakpoint`, `label`, `primary` and `tap`. There is nothing to rebuild: `crit canvas rebuild` answers `ready` and changes nothing; publish a new version to change the set.
