mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 20:34:57 +02:00
## 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>
348 lines
11 KiB
JavaScript
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}.`);
|
|
}
|
|
}
|