Files
PaperClipAI/scripts/storybook-visual-baseline.mjs
c07e650cd7 feat(ui): single-source design tokens, visual regression suite, and theme retune (#9134)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Its UI is the operator's daily surface: task lists, boards, budgets,
agent status — all built on shadcn components and Tailwind
> - Visual values (colors, spacing, type sizes, radii) were hardcoded at
~1,600 call sites: the same "small gray label" was 9/10/11px depending
on the file, charts disagreed with chips about status colors, two
toggle-switch implementations coexisted in two greens, and there was no
visual regression coverage
> - This made the UI drift-prone and made any restyle a
hundreds-of-files project, which discourages design iteration
> - This pull request extracts visual values into a single token layer
in `ui/src/index.css`, adds a Storybook visual regression suite backed
by external immutable baseline archives, and then applies a deliberate
retune reviewed change-by-change on screenshot diffs
> - The benefit is that Paperclip's look becomes a config surface:
retheming is a token edit reviewed as a snapshot diff, drift is blocked
by a token gate, and future UI PRs can prove exactly what changed
visually without committing hundreds of PNGs

## Linked Issues or Issue Description

No existing public issue covers this work (searched "design tokens",
"visual regression", "design system" across issues and PRs). Related in
spirit: Refs #8982 (theming a hardcoded panel — a one-off instance of
the same problem class this PR addresses systematically).

**Problem (feature-request form):** UI visual values are hardcoded per
call site with no source of truth and no regression coverage;
consistency depends on reviewer memory, and restyling requires mass file
edits.
**Proposed solution (this PR):** a single token layer + enforcement gate
+ externally stored visual snapshot suite, then an intentional restyle
on top of that foundation.

## What Changed

- **Token extraction (zero visual change, machine-verified during
development):** committed codemods (`scripts/codemod-*.mjs`) moved
~1,600 hardcoded color/type/spacing/radius/shadow/misc values into named
tokens in a non-inline `:root` block of `ui/src/index.css`.
- **Visual regression suite:** `pnpm test:storybook-visual` covers 255
stories × light/dark = 510 Playwright screenshots at `maxDiffPixels: 0`,
plus new primitive-coverage stories and deterministic-render fixes.
- **External visual baselines:** committed PNG snapshots were removed.
`tests/storybook-visual/baseline-manifest.json` pins an immutable
archive URL/hash/size/count, and `scripts/storybook-visual-baseline.mjs`
handles `download`, `verify`, `pack`, and trusted maintainer `upload`
flows.
- **Opt-in visual CI artifacts:** added a `Storybook Visual` workflow
that runs on manual dispatch or PRs labeled `storybook-visual`,
downloads/verifies the baseline, runs Playwright, and uploads Playwright
report/test-result artifacts for review. Normal PR runs do not mutate
baseline objects.
- **Token gate:** `pnpm check:token-gates` — zero hex literals, zero
arbitrary bracket values, zero raw font-sizes in `ui/src/components/**`
and `ui/src/pages/**`, with a documented inline allowlist for legitimate
opt-outs.
- **Theme retune (intentional, snapshot-reviewed):** new base theme
values; radius ladder derived from a single `--radius` knob; micro-type
cluster collapsed to a named ladder (`--text-nano/micro/compact` +
Tailwind `text-xs`/`text-sm`); letter-spacing collapsed to named steps.
- **One status-color vocabulary:** charts, quota/budget bar fills,
RUNNING/live chips, and liveness indicators all use the canonical
`--status-*` hues. Light-mode legibility fixes for red alert surfaces
that used dark-tuned text classes.
- **One switch:** `ToggleSwitch` restyled to the registry capsule form,
second hand-rolled implementation removed, and all call sites unified.
- **Docs:** `DESIGN.md` is the design contract; `doc/design/` holds
audit reports, decision logs, and updated guidance for external baseline
review/update workflows.
- Dead code removed (`agentStatusBadge` duplicate map), byte-identical
contrast constants consolidated, semantic renames
(`--project-seed`/`--project-none`, `--liveness-blue`).

## Verification

- `pnpm check:token-gates` — 3/3 gates CLEAN during the design-system
run
- `pnpm typecheck` && `pnpm --filter @paperclipai/ui build` — green
during the design-system run
- `node --test scripts/__tests__/storybook-visual-baseline.test.mjs` —
pass after external-baseline rework
- `pnpm exec tsc --noEmit --pretty false --module NodeNext
--moduleResolution NodeNext --target ES2022 --types
node,@playwright/test tests/storybook-visual/playwright.config.ts
tests/storybook-visual/storybook-visual.spec.ts` — pass after
external-baseline rework
- `git diff --check origin/pr/9134..HEAD` — pass after external-baseline
rework
- `find tests/storybook-visual -type f -name '*.png' -print | wc -l` —
`0`
- `node scripts/storybook-visual-baseline.mjs verify` — intentionally
fails closed until the first trusted maintainer publishes the baseline
archive and updates `baseline-manifest.json`

## Risks

- **Large but shallow:** the PR still touches many UI files due to
mechanical token extraction and retune work, but committed PNG snapshot
churn has been removed from the branch.
- **Baseline publication required before the visual suite can pass in
clean clones:** the manifest currently has placeholder archive metadata.
A trusted maintainer must publish the first immutable archive, then
update `baseline-manifest.json`.
- **Rendering platform variance:** the external baseline should be
captured in the documented Linux/Chromium environment. Future CI runs
verify against the pinned archive and fail closed on checksum/count
mismatch.
- **Visual CI is opt-in while stabilizing:** add the `storybook-visual`
label or dispatch the workflow manually to produce downloadable
Playwright report/test-result artifacts.
- **Scheduled follow-ups, deliberately out of scope:** Tailwind palette
classes map to semantic tokens in a dedicated pass; card/pill component
consolidation; ESLint ratchet. Tracked in
`doc/design/DECISION-SHEET.md`.

## Model Used

Claude Fable 5 (Anthropic, `claude-fable-5`, Mythos-class tier) with
extended thinking, running in Claude Code with tool use; mechanical
phases delegated to Claude Sonnet subagents. Follow-up external-baseline
rework assisted by OpenAI Codex (`gpt-5` coding agent with repository,
terminal, and GitHub tool use). All bulk rewrites executed via
deterministic, idempotent scripts committed in `scripts/`; intentional
visual changes were human-reviewed on screenshot contact sheets.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run targeted local verification and documented the
intentional baseline-publication failure above
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green *(pending new CI run after this
rework)*
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
*(pending review)*
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code) and
OpenAI Codex

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Dotta <bippadotta@protonmail.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-07 16:22:16 -05:00

348 lines
11 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import {
createReadStream,
createWriteStream,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
const defaultManifestPath = join(repoRoot, "tests", "storybook-visual", "baseline-manifest.json");
const manifestPath = resolvePath(
process.env.STORYBOOK_VISUAL_BASELINE_MANIFEST ?? defaultManifestPath,
);
const defaultCacheDir = join(repoRoot, "tests", "storybook-visual", ".cache");
const cacheDir = resolvePath(process.env.STORYBOOK_VISUAL_BASELINE_CACHE_DIR ?? defaultCacheDir);
const defaultSnapshotDir = join(repoRoot, "tests", "storybook-visual", ".snapshots");
const snapshotDir = resolvePath(process.env.STORYBOOK_VISUAL_SNAPSHOT_DIR ?? defaultSnapshotDir);
const command = process.argv[2];
const flags = parseFlags(process.argv.slice(3));
try {
if (command === "download") {
await download();
} else if (command === "verify") {
await verify();
} else if (command === "pack") {
await pack();
} else if (command === "upload") {
await upload();
} else {
usage();
process.exit(command ? 1 : 0);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
function resolvePath(path) {
return isAbsolute(path) ? path : resolve(repoRoot, path);
}
function parseFlags(args) {
const result = new Map();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) {
throw new Error(`Unexpected argument: ${arg}`);
}
const equalsIndex = arg.indexOf("=");
if (equalsIndex !== -1) {
result.set(arg.slice(2, equalsIndex), arg.slice(equalsIndex + 1));
continue;
}
const key = arg.slice(2);
const next = args[index + 1];
if (!next || next.startsWith("--")) {
result.set(key, "true");
} else {
result.set(key, next);
index += 1;
}
}
return result;
}
function usage() {
console.log(`Usage: node scripts/storybook-visual-baseline.mjs <command>
Commands:
download Fetch, checksum, and unpack the manifest archive into the snapshot dir.
verify Check the unpacked snapshot count and cached archive checksum.
pack Create a deterministic snapshots.tgz from the snapshot dir.
upload Upload a packed archive to S3 with immutable overwrite checks.
Environment:
STORYBOOK_VISUAL_BASELINE_MANIFEST Manifest path.
STORYBOOK_VISUAL_BASELINE_CACHE_DIR Cache path.
STORYBOOK_VISUAL_SNAPSHOT_DIR Playwright snapshot dir.
STORYBOOK_VISUAL_S3_URI s3://bucket/key target for upload.
STORYBOOK_VISUAL_PUBLIC_URL Public HTTPS URL to write into manifest instructions.
`);
}
function readManifest() {
if (!existsSync(manifestPath)) {
throw new Error(`Missing baseline manifest: ${manifestPath}`);
}
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
if (manifest.version !== 1) {
throw new Error(`Unsupported baseline manifest version: ${manifest.version}`);
}
if (!Number.isInteger(manifest.snapshotCount) || manifest.snapshotCount < 0) {
throw new Error("Manifest snapshotCount must be a non-negative integer.");
}
return manifest;
}
function archivePathFor(manifest) {
const hash = manifest.archive?.sha256;
return join(cacheDir, "archives", `${hash || "unconfigured"}-snapshots.tgz`);
}
async function download() {
const manifest = readManifest();
assertConfiguredArchive(manifest);
mkdirSync(dirname(archivePathFor(manifest)), { recursive: true });
const archivePath = archivePathFor(manifest);
if (!existsSync(archivePath) || sha256File(archivePath) !== manifest.archive.sha256) {
await fetchArchive(manifest.archive.url, archivePath);
}
verifyArchiveFile(manifest, archivePath);
rmSync(snapshotDir, { recursive: true, force: true });
mkdirSync(snapshotDir, { recursive: true });
run("tar", ["-xzf", archivePath, "-C", snapshotDir], "unpack baseline archive");
verifySnapshotCount(manifest, snapshotDir);
console.log(`Downloaded ${manifest.baselineId} to ${relative(repoRoot, snapshotDir)}`);
}
async function verify() {
const manifest = readManifest();
assertConfiguredArchive(manifest);
const archivePath = archivePathFor(manifest);
if (!existsSync(archivePath)) {
throw new Error(
`Missing cached archive ${archivePath}. Run \`pnpm storybook-visual:baseline download\` first.`,
);
}
verifyArchiveFile(manifest, archivePath);
verifySnapshotCount(manifest, snapshotDir);
console.log(
`Verified ${manifest.snapshotCount} snapshots for ${manifest.baselineId} in ${relative(
repoRoot,
snapshotDir,
)}`,
);
}
async function pack() {
const sourceDir = resolvePath(flags.get("source") ?? snapshotDir);
if (!existsSync(sourceDir)) {
throw new Error(`Snapshot source does not exist: ${sourceDir}`);
}
const count = countPngFiles(sourceDir);
if (count === 0) {
throw new Error(`No PNG snapshots found in ${sourceDir}`);
}
const out = resolvePath(
flags.get("out") ?? join(repoRoot, "tests", "storybook-visual", "baseline-review", "snapshots.tgz"),
);
mkdirSync(dirname(out), { recursive: true });
const tempDir = mkdtempSync(join(tmpdir(), "storybook-visual-pack-"));
const tempArchive = join(tempDir, "snapshots.tgz");
try {
run(
"tar",
[
"--sort=name",
"--mtime=@0",
"--owner=0",
"--group=0",
"--numeric-owner",
"--use-compress-program=gzip -n",
"-cf",
tempArchive,
"-C",
sourceDir,
".",
],
"pack deterministic baseline archive",
);
rmSync(out, { force: true });
run("cp", [tempArchive, out], "write packed archive");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
const sha256 = sha256File(out);
const byteSize = statSync(out).size;
const publicUrl = flags.get("public-url") ?? process.env.STORYBOOK_VISUAL_PUBLIC_URL ?? "";
const objectKey = `baselines/storybook-visual/${sha256}/snapshots.tgz`;
console.log(`Packed ${count} PNG snapshots into ${relative(repoRoot, out)}`);
console.log("");
console.log("Manifest archive update:");
console.log(
JSON.stringify(
{
snapshotCount: count,
archive: {
url: publicUrl || `https://<cloudfront-host>/${objectKey}`,
sha256,
byteSize,
objectKey,
},
},
null,
2,
),
);
}
async function upload() {
const archive = resolvePath(flags.get("archive") ?? join(repoRoot, "tests", "storybook-visual", "baseline-review", "snapshots.tgz"));
const s3Uri = flags.get("s3-uri") ?? process.env.STORYBOOK_VISUAL_S3_URI;
if (!s3Uri) {
throw new Error("Missing --s3-uri or STORYBOOK_VISUAL_S3_URI for upload.");
}
if (!s3Uri.startsWith("s3://")) {
throw new Error(`Upload target must be an s3:// URI: ${s3Uri}`);
}
if (!existsSync(archive)) {
throw new Error(`Archive does not exist: ${archive}`);
}
const sha256 = sha256File(archive);
const { bucket, key } = parseS3Uri(s3Uri);
const head = spawnSync(
"aws",
["s3api", "head-object", "--bucket", bucket, "--key", key, "--output", "json"],
{ encoding: "utf8" },
);
if (head.status === 0) {
const metadata = JSON.parse(head.stdout || "{}").Metadata ?? {};
if (metadata.sha256 === sha256) {
console.log(`Archive already exists at ${s3Uri} with matching sha256 ${sha256}.`);
return;
}
throw new Error(`Refusing to overwrite existing S3 object with different sha256: ${s3Uri}`);
}
run(
"aws",
[
"s3",
"cp",
archive,
s3Uri,
"--metadata",
`sha256=${sha256}`,
"--cache-control",
"public, max-age=31536000, immutable",
"--content-type",
"application/gzip",
],
"upload baseline archive",
);
console.log(`Uploaded ${basename(archive)} to ${s3Uri}`);
}
function assertConfiguredArchive(manifest) {
const archive = manifest.archive ?? {};
if (!archive.url || !archive.sha256 || !archive.byteSize) {
throw new Error(
`Baseline manifest ${relative(
repoRoot,
manifestPath,
)} does not point at a published archive yet. Run \`pnpm storybook-visual:baseline pack\`, upload the immutable archive, then update the manifest archive url/sha256/byteSize/snapshotCount.`,
);
}
}
async function fetchArchive(url, destination) {
if (url.startsWith("file://")) {
await pipeline(createReadStream(fileURLToPath(url)), createWriteStream(destination));
return;
}
if (!url.startsWith("https://") && !url.startsWith("http://")) {
throw new Error(`Unsupported archive URL: ${url}`);
}
const response = await fetch(url);
if (!response.ok || !response.body) {
throw new Error(`Failed to download baseline archive: ${response.status} ${response.statusText}`);
}
await pipeline(response.body, createWriteStream(destination));
}
function verifyArchiveFile(manifest, archivePath) {
const actualSha = sha256File(archivePath);
if (actualSha !== manifest.archive.sha256) {
throw new Error(
`Baseline checksum mismatch: expected ${manifest.archive.sha256}, got ${actualSha}`,
);
}
const actualSize = statSync(archivePath).size;
if (actualSize !== manifest.archive.byteSize) {
throw new Error(
`Baseline byte size mismatch: expected ${manifest.archive.byteSize}, got ${actualSize}`,
);
}
}
function verifySnapshotCount(manifest, dir) {
const count = countPngFiles(dir);
if (count !== manifest.snapshotCount) {
throw new Error(
`Baseline snapshot count mismatch: expected ${manifest.snapshotCount}, got ${count} in ${dir}`,
);
}
}
function sha256File(path) {
const hash = createHash("sha256");
hash.update(readFileSync(path));
return hash.digest("hex");
}
function countPngFiles(dir) {
if (!existsSync(dir)) return 0;
let count = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
count += countPngFiles(path);
} else if (entry.isFile() && entry.name.endsWith(".png")) {
count += 1;
}
}
return count;
}
function parseS3Uri(uri) {
const withoutScheme = uri.slice("s3://".length);
const slash = withoutScheme.indexOf("/");
if (slash === -1) throw new Error(`S3 URI must include a key: ${uri}`);
return { bucket: withoutScheme.slice(0, slash), key: withoutScheme.slice(slash + 1) };
}
function run(cmd, args, label) {
const result = spawnSync(cmd, args, {
cwd: repoRoot,
stdio: "inherit",
env: { ...process.env, COPYFILE_DISABLE: "1" },
});
if (result.status !== 0) {
throw new Error(`Failed to ${label}.`);
}
}