Skip to content

dta diff in pull requests

dta diff renders your stories at the base and at the head of a pull request, compares them and explains each change by the token that caused it. In GitHub it shows up in two places:

  • A status check, “dta diff”: green when nothing needs a look, pending with changes to review, red with unexplained changes above your threshold.
  • One comment that updates on every push: the changes grouped by token, a table per story and viewport, and a link to the full report (each changed story with the change outlined and named; see Reading the report).

A change is explained when putting the token back to its base value in the rendered page makes it go away, wherever your CSS declares the variable (see How changes are explained).

Everything is rendered in your CI. Nothing is stored between runs: the baseline is built from the base commit in the same run. Where the report is hosted is up to you: DTA runs a publish hook you write, and this page has three recipes.

One command sets it up: npx dta init. In a project that already has a dta.config.ts it goes straight to this step; in a new one it is the wizard’s last question (--diff runs only this step, also in a new config). It detects your Storybook, package manager, Node version and default branch, asks only what it cannot find (which pages to check without Storybook; whether people outside your team open pull requests), and writes:

  • the diff section of dta.config.ts (the build command, the output folder and which pages to render),
  • the GitHub workflow: one file or three,
  • playwright-core as a dev dependency, and Chromium if you say so.

--yes answers with what it detected and safe defaults, for CI and coding agents. Then check the setup and render a report locally before you open a pull request:

Terminal window
npx dta diff --check # config, browser, git history, base, stories
npx dta diff # report in .dta/diff/index.html

diff.stories says what dta diff screenshots, in three forms:

  • A list of pages, the simplest form for a site: each path is rendered from diff.outDir as it is, no manifest needed. A page the base does not have yet counts as added.

    dta.config.ts
    diff: {
    build: 'npm run build',
    outDir: 'dist',
    stories: ['/', '/pricing', '/checkout'],
    }
  • A Storybook build: stories: { type: 'storybook' } renders every story of storybook-static/index.json on its own; see dta diff with Storybook.

  • A stories manifest (advanced): a site that builds its own story pages can list them in a dta.stories/1 manifest inside the output (stories: { manifest: 'stories/manifest.json' }), with an id, a title and optional widths per page:

    dist/stories/manifest.json
    {
    "schema": "dta.stories/1",
    "stories": [
    {
    "id": "button--variants",
    "title": "Buttons",
    "url": "/stories/button/",
    "viewports": [1280, 390]
    }
    ]
    }

npx dta diff --list prints what it found.

Run npx dta init again. It detects the project again, compares with what is there, lists what it would change (after a switch to pnpm: build: npx dta build && … → pnpm exec dta build && … and .github/workflows/dta-diff.yml (updated); after adding Storybook: stories: pages / → Storybook) and asks once; with no changes it says so and writes nothing:

$ dta init --diff
T ✨ dta init
|
o Found
|
| Storybook build-storybook → storybook-static, from the "build-storybook" script in package.json
| Package manager npm, from package-lock.json
| Node 22, from .nvmrc
| Build npm run build-storybook, from the "build-storybook" script in package.json
| Git host GitHub, from the origin remote
| Default branch main, from the local branches
|
* Up to date: nothing to change.
|
— dta diff is set up.

It only touches what it wrote: a key you changed in the diff section is kept and listed as kept, keys you added stay, and a workflow file you edited is not overwritten (init names it). A diff section you wrote yourself in a TypeScript or JavaScript config is not edited: init prints the keys to add to it instead (dta.config.json gets them merged in, your keys kept). A diff.publish hook in the config goes into the report workflow; add the secrets it needs as env of that step. In a git repository it also adds .dta/diff/ (the report folder) to .gitignore, unless that is already ignored. Everything else is plain config: every key is in the configuration reference.

index.html in the report folder is one static page (relative links only, no network, works from disk). It opens with the verdict and the counts, then the changes by token change (what the token was and is, a sample or two of the stories it explains and a contact sheet of the rest), the unexplained stories one by one, added and removed stories, every story in a table, and the token changes.

Each changed story is one row:

  • “With this change”, the change marked. The screenshot with your change, cropped a little below the lowest change. Every element a changed token affects is outlined in orange with a dark line, and under it a note names the token and its change: Colors.Brand.Primary · Nova 500 → 600 (colours as the alias step) or Radius.Card · 8 → 24 (sizes as numbers). Two tokens on one element stack their notes. A note only sits on the screenshot where it covers nothing but one flat colour; where it would cover part of the story (text, another element, an edge), the element gets a small numbered marker instead (placed next to the outline where it covers nothing either) and its notes are listed under the image with the same number. Elements explained by the same token changes share one entry (“1, 2”), as the light and dark versions of a component usually are. This depends on the width you read at: on a phone the notes are larger relative to the screenshot, so more of them are listed. Below the image, a table lists each token with its value before and with this change, and the elements it outlines; each column names its side’s ref and short hash.
  • Show before. Swaps the image for the base screenshot in the same place, so a small step is visible by flicking back and forth. Outlines and notes hide while “before” shows; the heading says which side you see: “With this change” or “Before this change”, each with its ref and short hash.
  • Unexplained changes. When no token accounts for some pixels (a component, content or layout change), the row opens with the diff overlay on: the changed pixels in orange over “after”, a dashed box around them and the note “unexplained · no token”. Turn the overlay off to see the page itself; “Show before” sits next to it. Explained rows have no overlay; their outline already says where.
  • Whole images. Every row links the full before, after and diff PNGs.

Which commits. “Before” is the base dta diff compared with, for example main · 2ee9b31 (the merge base with your default branch, or what --base named). “With this change” is the checkout dta diff ran in, for example feature/x · 9f3c1a2: the branch and its commit, only the hash on a detached HEAD (as in most pull-request checkouts in CI), and + uncommitted changes when anything in the repository was edited, staged or untracked (and not ignored) at the start of the run, because dta diff renders those too. Reports written before this was recorded say “working tree”. The intro and summary.md (the comment) name both sides the same way; result.json has them as base and head.

Added and removed stories show their one side (“With this change” for a new story, “Before this change” for a removed one). The row is the design system’s Diff card.

One file is enough when everyone who opens pull requests can push to the repository (private and team repositories): one job runs dta diff, sets the status check and writes the comment, and the same file handles approval. Three files are for public repositories: a pull request from a fork must never run with write permissions, so the run, the report and the approval are split. dta init asks which one you need and writes it for your package manager and Node version.

.github/workflows/dta-diff.yml
# dta diff: screenshots of your stories at the base and the head of every pull request,
# explained by token, as a status check and one comment. One file for repositories without
# outside contributors: a pull request from a fork must not get these write permissions (use
# the three-file setup in "dta diff in pull requests" for public repositories).
# Generated by `dta init --diff`; run it again to update this file.
name: dta diff
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
pull-requests: write # the sticky comment, the approval label
statuses: write # the "dta diff" status check
jobs:
diff:
if: github.event.action != 'labeled' && github.event.action != 'unlabeled'
runs-on: ubuntu-latest
concurrency:
group: dta-diff-${{ github.event.pull_request.number }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0 # the base is built from the merge base with the default branch
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx playwright-core install --with-deps chromium
- uses: Fuasmattn/design-token-authority/actions/diff-pr@main
with:
publish: 'sh .github/dta-diff-publish.sh'
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
# Adding the dta-diff-approved label turns the status of the head commit green.
approve:
if: github.event.label.name == 'dta-diff-approved'
runs-on: ubuntu-latest
steps:
- uses: Fuasmattn/design-token-authority/actions/diff-approve@main

diff-pr runs dta diff, uploads the report as an artifact, runs your publish hook if you set its publish input, and posts the status and the comment. The approval label is created on the first run that needs a review.

Pull requests from forks run with a read-only token and no secrets, so the work is split. The first workflow runs the diff with no permissions to speak of; the second reports it with the permissions and secrets it needs, without running any code from the pull request; the third handles approval.

  1. Run on every pull request and upload the report as an artifact.

    .github/workflows/dta-diff.yml
    # 1/3 · Runs dta diff on every pull request and uploads the report.
    # No secrets, read-only token: safe for pull requests from forks.
    # Generated by `dta init --diff`; run it again to update this file.
    name: dta diff
    on:
    pull_request:
    permissions:
    contents: read
    jobs:
    diff:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    with:
    ref: ${{ github.event.pull_request.head.sha }}
    fetch-depth: 0 # the base is built from the merge base with the default branch
    - uses: actions/setup-node@v4
    with:
    node-version: 24
    cache: npm
    - run: npm ci
    - run: npx playwright-core install --with-deps chromium
    - uses: Fuasmattn/design-token-authority/actions/diff@main
  2. Report when the run finishes: publish (optional), status check, comment.

    This example publishes with the Vercel recipe. Without diff.publish in the config, dta init writes the report workflow with one step and no checkout, install or secrets; the comment then links the report artifact. With it, init adds the checkout, the install and the hook, and a note where its secrets go.

    .github/workflows/dta-diff-report.yml
    # 2/3 · Reports the run on its pull request: publish hook (optional), status check, comment.
    # Runs trusted code from the default branch and may use secrets; it only reads the report.
    # Generated by `dta init --diff`; run it again to update this file.
    name: dta diff report
    on:
    workflow_run:
    workflows: [dta diff]
    types: [completed]
    permissions:
    actions: read # download the report artifact
    pull-requests: write # the sticky comment, removing a stale approval label
    statuses: write # the "dta diff" status check
    jobs:
    report:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4 # the default branch: your config and publish script
    - uses: actions/setup-node@v4
    with:
    node-version: 24
    cache: npm
    - run: npm ci
    - uses: Fuasmattn/design-token-authority/actions/diff-report@main
    with:
    # Publish only reports of branches in this repository: a fork's report is untrusted HTML.
    publish: "${{ github.event.workflow_run.head_repository.full_name == github.repository && 'sh .github/dta-diff-publish.sh' || '' }}"
    env:
    VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
  3. Approve with a label.

    .github/workflows/dta-diff-approve.yml
    # 3/3 · Approval: adding the dta-diff-approved label turns the status of the head commit green.
    # A new push removes the label again (in the report workflow). Checks out nothing.
    # Generated by `dta init --diff`; run it again to update this file.
    name: dta diff approve
    on:
    pull_request_target:
    types: [labeled, unlabeled]
    permissions:
    pull-requests: write
    statuses: write
    jobs:
    approve:
    if: github.event.label.name == 'dta-diff-approved'
    runs-on: ubuntu-latest
    steps:
    - uses: Fuasmattn/design-token-authority/actions/diff-approve@main

The actions only wrap dta diff: diff runs it and uploads the report folder, diff-report runs your publish hook with dta diff --from-report --publish … and posts the status and the comment, diff-approve reacts to the label. The comment is found again by a hidden marker and edited in place.

Explained changes are reviewed once per token change, not once per screenshot: “action.bg 600 → 700 explains 7 stories”. When you have looked at them, add the dta-diff-approved label. The status of the head commit turns green and the comment says who approved it.

An approval belongs to one commit. When you push again, the report workflow removes the label and the new commit needs a new review. Removing the label by hand withdraws the approval.

Thresholds decide what needs a look at all (in the diff section, as changed-pixel ratios):

diff: {
thresholds: { explained: 0, unexplained: 0 }, // the defaults: every change counts
}

Without a hook, the comment has no images and points to the workflow run, where the report folder is an artifact (download it, open index.html). With a hook, the comment shows baseline, candidate and diff thumbnails and links the hosted report.

A publish hook is any command. dta diff passes it the report folder (as the last argument and as DTA_REPORT_DIR) and reads the public URL from the last line it prints:

diff: {
publish: 'sh .github/dta-diff-publish.sh',
// or, when the URL is somewhere else in the output:
// publish: { command: 'sh publish.sh', url: '$.deployment.url' }, // JSON path, or a regex
}

dta diff then rewrites summary.md and result.json with absolute links. A failing hook is reported and exits non-zero, but the diff result is still written and printed.

A preview deployment of the report folder. Create an empty Vercel project for the reports and turn off Deployment Protection for its previews; otherwise GitHub cannot load the thumbnails into the comment. Add VERCEL_TOKEN as a repository secret and set VERCEL_ORG_ID and VERCEL_PROJECT_ID (from the project settings) as environment variables of the report step.

.github/dta-diff-publish.sh
#!/bin/sh
# dta diff publish hook: Vercel preview deployment of the report folder.
# publish: sh .github/dta-diff-publish.sh
# Needs VERCEL_TOKEN, VERCEL_ORG_ID and VERCEL_PROJECT_ID (an empty project just for reports,
# with Deployment Protection off for previews, so GitHub can load the images in the comment).
# `vercel deploy` prints the deployment URL as its last line, which is what dta diff reads.
set -eu
report="$1"
npx --yes vercel@62.1.0 deploy "$report" --token "$VERCEL_TOKEN" --yes

No extra service: the report is committed to a branch of the same repository (dta-diff-assets, one folder per pull request, replaced on every push). The thumbnails load from github.com/<repo>/raw/…. GitHub serves index.html from there as text, so to open the HTML report either download the artifact or let GitHub Pages serve the branch and set DTA_DIFF_PAGES_URL.

The report workflow needs contents: write (add it to its permissions) and the token as GITHUB_TOKEN in the environment of the step.

.github/dta-diff-publish.sh
#!/bin/sh
# dta diff publish hook: commits the report folder to an assets branch of this repository,
# under pr-<number>/<sha>/, and prints its URL. No extra service.
# publish: sh .github/dta-diff-publish.sh
# Needs GITHUB_TOKEN with contents:write and GITHUB_REPOSITORY (both set in GitHub Actions).
# Images in the PR comment load from github.com/<repo>/raw/…; with GitHub Pages serving the
# branch, set DTA_DIFF_PAGES_URL (e.g. https://owner.github.io/repo) to link the HTML report.
set -eu
report="$1"
branch="${DTA_DIFF_BRANCH:-dta-diff-assets}"
remote="${DTA_DIFF_REMOTE:-https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git}"
pr=$(node -p "require(process.argv[1]).pr" "$report/pull-request.json")
sha=$(node -p "require(process.argv[1]).sha" "$report/pull-request.json")
dest="pr-$pr/$sha"
work=$(mktemp -d)
if ! git clone --quiet --depth 1 --branch "$branch" "$remote" "$work" 2>/dev/null; then
git init --quiet "$work"
git -C "$work" checkout --quiet --orphan "$branch"
git -C "$work" remote add origin "$remote"
fi
rm -rf "${work:?}/pr-$pr" # one report per pull request: the latest push
mkdir -p "$work/$dest"
cp -R "$report/." "$work/$dest/"
git -C "$work" add -A
git -C "$work" -c user.name="dta diff" -c user.email="dta-diff@users.noreply.github.com" \
commit --quiet -m "dta diff report for #$pr ($sha)"
git -C "$work" push --quiet origin "$branch" >&2
echo "${DTA_DIFF_PAGES_URL:-https://github.com/${GITHUB_REPOSITORY}/raw/${branch}}/$dest"

Remove a pull request’s reports when it closes:

.github/workflows/dta-diff-cleanup.yml
# Only with the GitHub assets branch recipe: removes a pull request's reports when it closes.
name: dta diff cleanup
on:
pull_request_target:
types: [closed]
permissions:
contents: write
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # the default branch, for the script
- run: sh .github/dta-diff-cleanup.sh "${{ github.event.pull_request.number }}"
env:
GITHUB_TOKEN: ${{ github.token }}
.github/dta-diff-cleanup.sh
#!/bin/sh
# Removes a closed pull request's reports from the assets branch (see github-assets-branch.sh).
# Run it on `pull_request_target: [closed]` with contents:write: sh cleanup.sh <pr number>
set -eu
pr="$1"
branch="${DTA_DIFF_BRANCH:-dta-diff-assets}"
remote="${DTA_DIFF_REMOTE:-https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git}"
work=$(mktemp -d)
git clone --quiet --depth 1 --branch "$branch" "$remote" "$work" 2>/dev/null || exit 0
[ -d "$work/pr-$pr" ] || exit 0
git -C "$work" rm -r --quiet "pr-$pr"
git -C "$work" -c user.name="dta diff" -c user.email="dta-diff@users.noreply.github.com" \
commit --quiet -m "dta diff: remove reports of closed #$pr"
git -C "$work" push --quiet origin "$branch"

For an on-premise host: copy the folder somewhere your web server serves (a mount, or rsync over SSH) and print its URL.

.github/dta-diff-publish.sh
#!/bin/sh
# dta diff publish hook: copies the report folder to a directory your web server serves (an
# on-premise host, a mounted share) and prints its URL. Over SSH, replace the copy with
# rsync -a --delete "$report/" "deploy@diff.example.internal:/srv/dta-diff/$id/"
# Needs DTA_DIFF_ROOT (served directory) and DTA_DIFF_BASE_URL (its public URL).
set -eu
report="$1"
id="${GITHUB_SHA:-$(git rev-parse HEAD)}"
mkdir -p "$DTA_DIFF_ROOT/$id"
cp -R "$report/." "$DTA_DIFF_ROOT/$id/"
echo "$DTA_DIFF_BASE_URL/$id"

Any host that serves static files works the same way: its command line uploads a folder and prints where it went. Start from this template:

.github/dta-diff-publish.sh
#!/bin/sh
# dta diff publish hook, template for any host: upload the report folder, print its URL last.
# publish: sh .github/dta-diff-publish.sh
# dta diff passes the folder as the first argument (also in DTA_REPORT_DIR). The last line of
# stdout must be the URL the folder is served from (index.html is appended for the report link).
# Send everything else your upload prints to stderr, or point diff.publish.url at the URL
# (a JSON path such as $.url, or a regular expression).
set -eu
report="$1"
# One folder per commit, so every push keeps its own report.
id="${GITHUB_SHA:-$(git rev-parse HEAD)}"
# 1. Upload "$report" (index.html, result.json, summary.md, base/, head/, diff/) to your host,
# for example under reports/$id/. Keep the folder structure: all links in it are relative.
echo "upload $report to <your host>/reports/$id/" >&2
# 2. Print the public URL of that folder.
echo "https://reports.example.com/$id"

What the host needs to do:

  • Serve the folder as it is. All links in the report are relative, so any subfolder works.
  • Be reachable by GitHub, if you want thumbnails in the comment: GitHub loads the images through its own proxy, so they must be public. Behind a login or VPN, the thumbnails show as broken images; the report link still works for everyone who can reach the host.
  • Keep one folder per commit or pull request, and remove old ones when you like. DTA never deletes anything there.

If the upload tool prints the URL somewhere other than the last line, tell DTA where to find it instead of rewriting the output: publish: { command: 'sh publish.sh', url: '$.deploy_url' } reads a field of the JSON it prints, and any other value is a regular expression whose first group is the URL.

Try a hook on your last local report before wiring it into CI. Nothing is rendered again:

Terminal window
npx dta diff --from-report --publish "sh .github/dta-diff-publish.sh"

Start with npx dta diff --check. It checks the setup without building or rendering anything and prints one line per check, with the fix for each one that fails:

Terminal window
npx dta diff --check
Check Fails when Fix
config there is no diff section dta init, or add diff: { outDir: 'dist' }
browser playwright-core or Chromium is missing npm i -D playwright-core and npx playwright install chromium, or set DTA_BROWSER_PATH
git history the checkout is shallow or not a git repository fetch-depth: 0 on actions/checkout, or --base <ref|dir>
base there is no default branch or merge base --base <ref> or diff.base
stories the built output has no pages or stories build first (diff.build), then check diff.outDir and diff.stories

Every error dta diff stops with names its fix and the section of these docs that explains it. Two cases are loud on purpose:

  • No browser in CI. With CI=true (GitHub Actions sets it), a missing browser stops the run with an error. Locally dta diff warns and shows token changes only; --tokens-only asks for that on purpose.
  • The base does not build. The run stops with the command and its output instead of counting every story as added.

The report folder is plain files with relative links. dta diff --publish "<command>" works in any CI, and --format json or summary.md feed whatever posts your comments. The result format is documented with its JSON Schema in the repository (docs/diff/result.md).