feat(ci): distinct exit codes, a pinned and gating Action; release 13.3.0

Every failure exited 1, so a CI gate could not tell a design regression from a
flaky preview deploy.

- exit 0 ok, 1 drift over threshold, 2 extraction failed, 3 navigation timeout;
  wired into extraction, drift, ci and lint
- the Action installs the designlang version it was tagged with, runs
  install-browser, fails on drift by default, annotates each changed token
  (escaped), says why a job failed, and lists Manavarya09 as author
- fix changed-count being written as "0" twice when nothing changed
- README: install-browser, live MCP tools, CLI reference
- 13.3.0 changelog with the breaking changes called out
This commit is contained in:
Manav Arya Singh
2026-09-15 15:54:34 +04:00
parent a6b8671864
commit 13cf3aed17
11 changed files with 205 additions and 36 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
"name": "designlang",
"source": "./",
"description": "Thirteen slash commands wrapping the designlang CLI: /extract (full design language → DTCG, Tailwind, Figma), /site (crawl a whole site → one canonical design system + consistency grade), /grade (shareable HTML report card + SVG badge), /battle (head-to-head graded comparison), /remix (restyle in 6 vocabularies — brutalist, swiss, art-deco, cyberpunk, soft-ui, editorial), /pack (one downloadable design-system bundle), /theme-swap (OKLCH-correct recolour around a new brand primary), /brand (full editorial brand-guidelines book — 13 chapters, hand-off-ready), /pair (fuse two designs across configurable axes — colours from one site, typography from another), /studio (live token-editor with component preview + export), /verify (rebuild from tokens and pixel-diff vs live for a fidelity score), /fidelity (score a clone vs the original — visual pixel-diff + motion fidelity — with a ranked correction plan), /gallery (build a shareable static gallery of measured clones).",
"version": "13.2.0",
"version": "13.3.0",
"author": {
"name": "Manavarya Singh"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "designlang",
"description": "Extract any website's design language and ship it. Fourteen slash commands — /extract, /site, /grade, /battle, /remix, /pack, /theme-swap, /brand, /pair, /studio, /verify, /fidelity, /gallery, /dna — wrap the designlang CLI to pull DTCG tokens, Tailwind/shadcn/Figma vars, motion + voice, synthesize a whole-site canonical design system with a consistency grade, generate shareable graded report cards, head-to-head battle pages, six-vocabulary remixes, downloadable design-system bundles, OKLCH-correct theme recolouring, full editorial brand-guidelines books, design crossovers between two sites, a live token-editor studio, a rebuild-and-pixel-diff fidelity check, a measured clone-vs-original fidelity score (visual + motion) with a ranked correction plan via /fidelity, a shareable static gallery of scored clones via /gallery, and — via /dna — a measured design space that places any site among real design systems by nearest neighbours, per-axis percentiles, and the features that make it look the way it does.",
"version": "13.2.0",
"version": "13.3.0",
"author": {
"name": "Manavarya Singh",
"url": "https://github.com/Manavarya09"
+60
View File
@@ -1,5 +1,65 @@
# Changelog
## [13.3.0] — 2026-09-15
**Ship it where people run it: releases, installs, agents and CI.**
Extraction was already strong; what failed people was everything around it.
npm `latest` sat three months behind the repo, `npm install` downloaded a
browser that CI and Docker can't, the MCP server couldn't extract a URL, and
every failure exited `1`.
**Releases**
- **npm publishes from CI on every `vX.Y.Z` tag**, with provenance. A tag that
doesn't match `package.json` fails the release.
**Install**
- **No browser download at `npm install`.** Run `designlang install-browser`
(`--with-deps` on bare Linux) to fetch the Chromium matching the bundled
Playwright.
- **One launcher everywhere.** All 13 launch sites try the bundled Chromium,
then system Chrome, then fail with an error that names the fix.
**MCP server v2**
- **Extract live URLs as jobs.** `extract_design` returns a job id;
`get_job_status`, `list_jobs`, `cancel_job`.
- **Read, diff, lint and export by job id:** `get_tokens`, `get_colors`,
`get_typography`, `get_components`, `compute_drift`, `get_findings`, and
`export` (dtcg, tailwind, shadcn, figma, css, design-md).
- **Conformance.** An `initialize` without `protocolVersion` is served on the
default revision instead of rejected (#182, #183); `serverInfo.version` is the
package version (it said 7.0.0); `import 'designlang/mcp'` resolves (the export
pointed at a missing file).
- The folder-backed tools and resources are unchanged.
**Exit codes**
| Code | Meaning |
|---|---|
| `0` | Success |
| `1` | Drift over threshold (`drift`, `ci`, `lint`) |
| `2` | Extraction failed: page error, bad input, no browser |
| `3` | Navigation timeout: retryable |
**GitHub Action**
- **Installs the designlang version it was tagged with**, then the browser.
- **One PR annotation per changed token**, escaped, since values come from the
audited site.
- **Says why a job failed**: drift, extraction failure or timeout.
- Fixed: with zero changes, `changed-count` was written as `0` twice, which broke
the `changed` output.
**Breaking**
- The Action's `fail-on-change` now defaults to `true`. Pass `false` to keep
report-only behaviour.
- Extraction failures exit `2` or `3` instead of `1`.
- A fresh install needs `designlang install-browser` or a system Chrome.
## [13.2.0] — 2026-08-31
**Depth pass on extraction: the inventories become systems.**
+19 -4
View File
@@ -215,9 +215,15 @@ than no score at all:
```bash
npm i -g designlang # global
designlang install-browser # one-time: the Chromium designlang drives
npx skills add Manavarya09/design-extract # as an agent skill (40+ agents)
```
`npm install` no longer downloads a browser, so it works in CI, Docker and
behind proxies. `install-browser` fetches the Chromium matching the bundled
Playwright (add `--with-deps` on bare Linux). Without it, designlang falls back
to an installed Google Chrome.
## Use in Claude Code (plugin)
designlang ships as a **Claude Code plugin** — eleven slash commands that wrap
@@ -249,9 +255,17 @@ the commands are available:
| `/gallery [dir]` | build a static shareable gallery of measured clones |
| `/dna <url>` | place a design in the measured design space — nearest systems, per-axis percentiles, outliers |
> Prefer the raw MCP tools? The CLI also ships an MCP server — run
> `designlang mcp --output-dir ./design-extract-output` to serve the latest
> extraction's tokens to any MCP client.
> Prefer raw MCP tools? The CLI ships an MCP server that extracts live URLs:
>
> ```bash
> claude mcp add designlang -- npx -y designlang mcp
> ```
>
> `extract_design(url)` returns a job id; poll `get_job_status`, then pass the
> id to `get_tokens`, `get_colors`, `get_typography`, `get_components`,
> `get_findings`, `compute_drift` (against a baseline job) or `export`
> (dtcg, tailwind, shadcn, figma, css, design-md). Add `--output-dir <dir>` to
> also serve an extraction already on disk as resources.
## Atlas Cloud for `--smart`
@@ -425,7 +439,8 @@ Commands:
brands <urls...> Multi-brand comparison matrix
sync <url> Sync local tokens with live site
history <url> View design change history
mcp Launch stdio MCP server (--output-dir <dir>)
install-browser Download the Chromium designlang drives (--with-deps on bare Linux)
mcp Launch stdio MCP server: extract live URLs as jobs (--output-dir <dir> also serves one on disk)
lint <file> (v9) Audit a local token file (.json/.css) — CI-ready
drift <url> --tokens <file> (v9) Check local tokens for drift against a live site
visual-diff <before> <after> (v9) Side-by-side HTML diff of two URLs
+15 -8
View File
@@ -719,9 +719,10 @@ program
}
} catch (err) {
const { exitCodeForError } = await import('../src/exit-codes.js');
if (jsonMode) {
process.stderr.write(JSON.stringify({ error: err.message }) + '\n');
process.exit(1);
process.exit(exitCodeForError(err));
}
spinner.fail('Extraction failed');
if (err.code === 'BROWSER_UNAVAILABLE' || err.message.includes('playwright')) {
@@ -731,7 +732,7 @@ program
console.error(chalk.red(`\n ${err.message}\n`));
if (opts.verbose) console.error(err.stack);
}
process.exit(1);
process.exit(exitCodeForError(err));
}
});
@@ -2032,10 +2033,12 @@ program
}
if (!r.findings.length) console.log(chalk.green(' ✓ no issues found'));
console.log('');
process.exit(r.findings.some(f => f.severity === 'error') ? 1 : 0);
const { EXIT } = await import('../src/exit-codes.js');
process.exit(r.findings.some(f => f.severity === 'error') ? EXIT.DRIFT : EXIT.OK);
} catch (err) {
const { exitCodeForError } = await import('../src/exit-codes.js');
process.stderr.write(chalk.red(`\n Error: ${err.message}\n\n`));
process.exit(1);
process.exit(exitCodeForError(err));
}
});
@@ -2056,10 +2059,12 @@ program
if (opts.json) { process.stdout.write(JSON.stringify(r, null, 2) + '\n'); }
else { console.log('\n' + formatDriftMarkdown(r) + '\n'); }
const order = ['in-sync', 'minor-drift', 'notable-drift', 'major-drift'];
if (order.indexOf(r.verdict) >= order.indexOf(opts.failOn)) process.exit(1);
const { EXIT } = await import('../src/exit-codes.js');
if (order.indexOf(r.verdict) >= order.indexOf(opts.failOn)) process.exit(EXIT.DRIFT);
} catch (err) {
const { exitCodeForError } = await import('../src/exit-codes.js');
process.stderr.write(chalk.red(`\n Error: ${err.message}\n\n`));
process.exit(1);
process.exit(exitCodeForError(err));
}
});
@@ -2304,10 +2309,12 @@ program
console.log('');
console.log(r.md);
}
if (r.shouldFail) process.exit(1);
const { EXIT } = await import('../src/exit-codes.js');
if (r.shouldFail) process.exit(EXIT.DRIFT);
} catch (err) {
const { exitCodeForError } = await import('../src/exit-codes.js');
spinner.fail(err.message);
process.exit(1);
process.exit(exitCodeForError(err));
}
});
+21 -10
View File
@@ -1,6 +1,6 @@
# designlang — Design Regression Guard
GitHub Action that runs `designlang` on every pull request, diffs the extracted design tokens against a committed baseline, and comments the changes.
GitHub Action that runs `designlang` on every pull request, diffs the extracted design tokens against a committed baseline, annotates each changed token on the PR, and fails the job on drift.
## Example workflow
@@ -20,14 +20,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- uses: Manavarya09/design-extract/github-action@v1
with: { node-version: '22' }
- uses: Manavarya09/design-extract/github-action@v13.3.0
with:
url: https://preview-${{ github.event.number }}.yoursite.dev
baseline: ./design-tokens.baseline.json
fail-on-change: false
```
Pin to a release tag. The action installs the designlang version it was tagged with, so a gate on `@v13.3.0` never changes behaviour under you.
## Inputs
| Name | Required | Default | Description |
@@ -35,7 +36,7 @@ jobs:
| `url` | ✅ | — | URL to extract from. Typically a PR preview deploy. |
| `baseline` | ✅ | — | Path to the committed baseline tokens file. |
| `comment` | | `true` | Post a PR comment with the diff. |
| `fail-on-change` | | `false` | Fail the job if any token changed. |
| `fail-on-change` | | `true` | Fail the job if any token changed. Set `false` to report without gating. |
| `full` | | `false` | Pass `--full` to `designlang`. |
| `extra-args` | | — | Extra CLI args. |
@@ -44,11 +45,21 @@ jobs:
- `changed``true`/`false`
- `changed-count` — number of changed tokens
- `diff-path` — path to the generated diff markdown
- `version` — the designlang version that ran
## Publishing
## Why a job failed
```bash
git tag v1 && git push --tags
```
A failed job always says which of these it was, so a flaky preview deploy is never mistaken for a design regression:
Then list on the GitHub Marketplace via the repo's Actions tab.
| designlang exit | Meaning |
|---|---|
| `0` | Extracted; compared against the baseline |
| `1` | Drift over threshold (`designlang drift` / `ci` / `lint`) |
| `2` | Extraction failed: page error, bad input, no browser |
| `3` | Navigation timeout: retryable, the design was not checked |
The action's own "Fail on change" step exits `1` when tokens changed.
## Upgrading from `@v1`
`fail-on-change` now defaults to `true`. Pass `fail-on-change: false` to keep report-only behaviour.
+49 -8
View File
@@ -1,6 +1,6 @@
name: "designlang — Design Regression Guard"
description: "Diff a site's design tokens against a baseline and comment the delta on the pull request."
author: "masyv"
description: "Extract a live URL, diff its design tokens against a committed baseline, annotate the pull request and fail on drift."
author: "Manavarya09"
branding:
icon: "layers"
color: "orange"
@@ -16,8 +16,8 @@ inputs:
description: "Whether to post a PR comment with the diff (true/false)."
default: "true"
fail-on-change:
description: "Fail the job if any token changed."
default: "false"
description: "Fail the job if any token changed. Set to false to report without gating."
default: "true"
full:
description: "Pass --full to designlang (screenshots, responsive, interactions)."
default: "false"
@@ -31,17 +31,38 @@ inputs:
outputs:
changed:
description: "true/false — whether any tokens changed since the baseline."
value: ${{ steps.diff.outputs.changed }}
changed-count:
description: "Number of changed tokens."
value: ${{ steps.diff.outputs.changed-count }}
diff-path:
description: "Path to the generated diff markdown."
value: ${{ steps.diff.outputs.diff-path }}
version:
description: "The designlang version that ran."
value: ${{ steps.install.outputs.version }}
runs:
using: "composite"
steps:
# Pinned to the designlang release this action was tagged with, so a gate on
# @vX.Y.Z never changes behaviour under you.
- name: Install designlang
id: install
shell: bash
run: npm install -g designlang
env:
ACTION_PATH: ${{ github.action_path }}
run: |
VERSION="$(node -p "require(process.env.ACTION_PATH + '/../package.json').version")"
if npm view "designlang@$VERSION" version >/dev/null 2>&1; then
npm install -g "designlang@$VERSION"
else
echo "::warning::designlang@$VERSION is not on npm (untagged ref?); using the latest release"
npm install -g designlang
VERSION="$(designlang --version)"
fi
designlang install-browser --with-deps
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Extract tokens
shell: bash
@@ -54,20 +75,38 @@ runs:
ARGS="--out .designlang-run"
if [ "$FULL" = "true" ]; then ARGS="$ARGS --full"; fi
if [ -n "$EXTRA" ]; then ARGS="$ARGS $EXTRA"; fi
set +e
designlang "$URL" $ARGS
CODE=$?
set -e
if [ "$CODE" -eq 3 ]; then
echo "::error::designlang timed out loading the page (exit 3). This is retryable; the design was not checked."
elif [ "$CODE" -ne 0 ]; then
echo "::error::designlang could not extract the page (exit $CODE). The design was not checked."
fi
exit "$CODE"
- name: Diff tokens against baseline
id: diff
shell: bash
env:
BASELINE: ${{ inputs.baseline }}
ACTION_PATH: ${{ github.action_path }}
run: |
NEW=$(ls .designlang-run/*-design-tokens.json | head -n 1)
node "${{ github.action_path }}/diff-tokens.cjs" "$BASELINE" "$NEW" > .designlang-run/diff.md || true
CHANGED=$(grep -c '^- ' .designlang-run/diff.md || echo 0)
node "$ACTION_PATH/diff-tokens.cjs" "$BASELINE" "$NEW" > .designlang-run/diff.md || true
CHANGED=$(grep -c '^- ' .designlang-run/diff.md || true)
echo "changed=$([ "$CHANGED" -gt 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "changed-count=$CHANGED" >> "$GITHUB_OUTPUT"
echo "diff-path=.designlang-run/diff.md" >> "$GITHUB_OUTPUT"
# One annotation per token. Values come from the audited site, so escape
# them the way the Actions toolkit does before emitting a command.
grep '^- ' .designlang-run/diff.md | head -n 50 | while IFS= read -r line; do
msg="${line#- }"
msg="${msg//'%'/%25}"
msg="${msg//$'\r'/%0D}"
echo "::warning title=designlang drift::$msg"
done
- name: Comment on PR
if: ${{ inputs.comment == 'true' && github.event_name == 'pull_request' }}
@@ -87,6 +126,8 @@ runs:
- name: Fail on change
if: ${{ inputs.fail-on-change == 'true' && steps.diff.outputs.changed == 'true' }}
shell: bash
env:
COUNT: ${{ steps.diff.outputs.changed-count }}
run: |
echo "::error::Design tokens changed vs baseline."
echo "::error::$COUNT design token(s) changed vs baseline. Update the baseline if the change is intended."
exit 1
+2 -3
View File
@@ -1,13 +1,12 @@
{
"name": "designlang",
"version": "13.2.0",
"version": "13.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "designlang",
"version": "13.2.0",
"hasInstallScript": true,
"version": "13.3.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "designlang",
"version": "13.2.0",
"version": "13.3.0",
"description": "Extract the complete design language from any website and ship it — clone to a working Next.js starter, guard tokens with a CI drift bot, or browse everything in a local studio. Outputs W3C DTCG tokens, motion tokens, typed anatomy stubs, Tailwind config, and ready-to-paste v0 / Lovable / Cursor / Claude-Artifacts prompts.",
"type": "module",
"bin": {
+15
View File
@@ -0,0 +1,15 @@
// Exit codes a CI pipeline can branch on. "The design drifted" and "the tool
// could not extract" used to share exit 1, so a gate could not tell a real
// regression from a flaky preview deploy.
export const EXIT = Object.freeze({
OK: 0,
DRIFT: 1, // a drift/lint gate crossed its threshold
EXTRACTION_FAILED: 2, // browser missing, page error, bad input
NAVIGATION_TIMEOUT: 3, // retryable: try --wait or a later run
});
export function exitCodeForError(err) {
if (err?.name === 'TimeoutError' || /Timeout \d+ms exceeded/.test(err?.message || '')) return EXIT.NAVIGATION_TIMEOUT;
return EXIT.EXTRACTION_FAILED;
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { EXIT, exitCodeForError } from '../src/exit-codes.js';
describe('exit codes', () => {
it('keeps drift and extraction failure distinct', () => {
assert.deepEqual(EXIT, { OK: 0, DRIFT: 1, EXTRACTION_FAILED: 2, NAVIGATION_TIMEOUT: 3 });
});
it('classifies a Playwright navigation timeout as retryable', () => {
const byName = Object.assign(new Error('page.goto: Timeout 30000ms exceeded.'), { name: 'TimeoutError' });
assert.equal(exitCodeForError(byName), EXIT.NAVIGATION_TIMEOUT);
assert.equal(exitCodeForError(new Error('page.goto: Timeout 30000ms exceeded.')), EXIT.NAVIGATION_TIMEOUT);
});
it('classifies everything else as an extraction failure', () => {
assert.equal(exitCodeForError(Object.assign(new Error('No browser available.'), { code: 'BROWSER_UNAVAILABLE' })), EXIT.EXTRACTION_FAILED);
assert.equal(exitCodeForError(new Error('net::ERR_NAME_NOT_RESOLVED')), EXIT.EXTRACTION_FAILED);
assert.equal(exitCodeForError(undefined), EXIT.EXTRACTION_FAILED);
});
});