norma-scope 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,6 +33,26 @@ Figma frame name Screenshot filename
33
33
  "Nav Bar" → nav-bar.png
34
34
  ```
35
35
 
36
+ ## Taking the screenshot
37
+
38
+ Norma compares images pixel-for-pixel from the top-left corner — it doesn't understand scrolling or page sections. So your screenshot needs to be a **full-page capture at the exact dimensions `init` showed you** (e.g. `1440×10661px`), not just what's visible in your browser viewport. A partial screenshot (like just the footer) will get compared against the top of the design and produce a meaningless, huge diff %.
39
+
40
+ **Chrome / Edge (Mac and Windows — same steps, since this is a browser feature, not an OS one):**
41
+ 1. Open the page and resize your browser window to match the frame's width (e.g. 1440px wide)
42
+ 2. Open DevTools (`Cmd+Option+I` on Mac, `Ctrl+Shift+I` on Windows)
43
+ 3. Open the Command Menu (`Cmd+Shift+P` on Mac, `Ctrl+Shift+P` on Windows)
44
+ 4. Type "screenshot" and choose **Capture full size screenshot**
45
+ 5. Chrome downloads a PNG of the entire page, full height, no scrolling needed
46
+
47
+ **Firefox (Mac and Windows):**
48
+ 1. Right-click anywhere on the page and choose **Take Screenshot** (or press `Shift+F2`, type `screenshot --fullpage`, then Enter)
49
+ 2. Choose **Save full page**
50
+
51
+ **Browser extensions (any browser, if you prefer a permanent toolbar button):**
52
+ - [GoFullPage](https://chromewebstore.google.com/detail/gofullpage-full-page-scr/fdpohaocaechififmbbbbbknoalclacl) (Chrome) or [FireShot](https://addons.mozilla.org/en-US/firefox/addon/fireshot/) (Firefox) both export a single full-page PNG in one click
53
+
54
+ Once you have the PNG, drop it into `.bridge/screenshots/` with the exact filename `init` printed for that frame, then `git commit` as normal.
55
+
36
56
  ## Running manually
37
57
 
38
58
  The pre-commit hook runs this for you automatically, but you can also run it by hand any time:
@@ -47,6 +67,14 @@ Add `--fresh` to bypass Norma's local Figma cache and force a fresh fetch (usefu
47
67
  npx norma-scope compare --fresh
48
68
  ```
49
69
 
70
+ ## Cleaning up
71
+
72
+ ```bash
73
+ npx norma-scope clean
74
+ ```
75
+
76
+ Empties `.bridge/screenshots/`, `.bridge/diff/`, `.bridge/reports/`, and `.bridge/.cache/`. Useful when switching to a different Figma file, or just to clear out old local artifacts — `.bridge/config.json` is never touched.
77
+
50
78
  ## Config
51
79
 
52
80
  `init` writes `.bridge/config.json`, which is committed to your repo:
@@ -81,12 +109,9 @@ Your Figma personal access token is stored in `.env.local`, which is never commi
81
109
 
82
110
  Only `.bridge/config.json` is committed — everything else is local to your machine.
83
111
 
84
- ## Coming soon
85
-
86
- Norma's current version (V1) is screenshot diffing, local and free, with no LLM involved. Two upgrades are planned:
112
+ Norma's current version (V1) is screenshot diffing, local and free, with no LLM involved.
87
113
 
88
- - **V2 (pro)** LLM code scan that points to the exact lines of code causing a visual mismatch
89
- - **V3 (team)** — runs on every PR via GitHub Action, posts the report as a PR comment, plus a team dashboard and report history
114
+ See [COMMANDS.md](COMMANDS.md) for a full command reference and the step-by-step workflow.
90
115
 
91
116
  ## License
92
117
 
package/dist/cache.js CHANGED
@@ -1,24 +1,61 @@
1
1
  import { readFile, writeFile, mkdir, stat } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { fetchFramePng } from "./figma.js";
4
+ import { fetchFramesPng } from "./figma.js";
5
5
  const CACHE_DIR = path.join(".bridge", ".cache");
6
6
  const TTL_MS = 10 * 60 * 1000; // 10 minutes
7
7
  function cachePathFor(frameId) {
8
8
  const safeId = frameId.replace(/[^a-zA-Z0-9]/g, "_");
9
9
  return path.join(CACHE_DIR, `${safeId}.png`);
10
10
  }
11
+ async function isCacheFresh(cachePath) {
12
+ if (!existsSync(cachePath)) {
13
+ return false;
14
+ }
15
+ const stats = await stat(cachePath);
16
+ return Date.now() - stats.mtimeMs < TTL_MS;
17
+ }
11
18
  export async function getFramePng(fileKey, frameId, token, options = {}) {
12
- const cachePath = cachePathFor(frameId);
13
- if (!options.fresh && existsSync(cachePath)) {
14
- const stats = await stat(cachePath);
15
- const age = Date.now() - stats.mtimeMs;
16
- if (age < TTL_MS) {
17
- return readFile(cachePath);
18
- }
19
+ const result = await getFramesPng(fileKey, [frameId], token, options);
20
+ const png = result.pngs.get(frameId);
21
+ if (!png) {
22
+ throw new Error(result.fetchError ?? `No image returned for frame ${frameId}`);
19
23
  }
20
- const png = await fetchFramePng(fileKey, frameId, token);
21
- await mkdir(CACHE_DIR, { recursive: true });
22
- await writeFile(cachePath, png);
23
24
  return png;
24
25
  }
26
+ export async function getFramesPng(fileKey, frameIds, token, options = {}) {
27
+ const pngs = new Map();
28
+ const staleFrameIds = new Set();
29
+ const toFetch = [];
30
+ for (const frameId of frameIds) {
31
+ const cachePath = cachePathFor(frameId);
32
+ if (!options.fresh && (await isCacheFresh(cachePath))) {
33
+ pngs.set(frameId, await readFile(cachePath));
34
+ }
35
+ else {
36
+ toFetch.push(frameId);
37
+ }
38
+ }
39
+ let fetchError = null;
40
+ if (toFetch.length > 0) {
41
+ try {
42
+ const fetched = await fetchFramesPng(fileKey, toFetch, token);
43
+ await mkdir(CACHE_DIR, { recursive: true });
44
+ for (const [frameId, png] of fetched) {
45
+ pngs.set(frameId, png);
46
+ await writeFile(cachePathFor(frameId), png);
47
+ }
48
+ }
49
+ catch (err) {
50
+ fetchError = err.message;
51
+ for (const frameId of toFetch) {
52
+ const cachePath = cachePathFor(frameId);
53
+ if (existsSync(cachePath)) {
54
+ pngs.set(frameId, await readFile(cachePath));
55
+ staleFrameIds.add(frameId);
56
+ }
57
+ }
58
+ }
59
+ }
60
+ return { pngs, staleFrameIds, fetchError };
61
+ }
package/dist/clean.js ADDED
@@ -0,0 +1,31 @@
1
+ import { rm, mkdir, readdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ const BRIDGE_DIR = ".bridge";
5
+ const CLEAN_TARGETS = [
6
+ { dir: path.join(BRIDGE_DIR, "screenshots"), label: "screenshots" },
7
+ { dir: path.join(BRIDGE_DIR, "diff"), label: "diff images" },
8
+ { dir: path.join(BRIDGE_DIR, "reports"), label: "reports" },
9
+ { dir: path.join(BRIDGE_DIR, ".cache"), label: "Figma cache" },
10
+ ];
11
+ export async function runClean() {
12
+ if (!existsSync(BRIDGE_DIR)) {
13
+ console.log("Norma: no .bridge directory found — nothing to clean.");
14
+ return;
15
+ }
16
+ console.log("Norma — Clean");
17
+ console.log("══════════════════════════════════════════════");
18
+ for (const target of CLEAN_TARGETS) {
19
+ let fileCount = 0;
20
+ if (existsSync(target.dir)) {
21
+ const entries = await readdir(target.dir);
22
+ fileCount = entries.length;
23
+ await rm(target.dir, { recursive: true, force: true });
24
+ }
25
+ await mkdir(target.dir, { recursive: true });
26
+ console.log(` ${target.label.padEnd(14)} → ${fileCount} file(s) removed`);
27
+ }
28
+ console.log("");
29
+ console.log(" .bridge/config.json was left untouched.");
30
+ console.log("══════════════════════════════════════════════");
31
+ }
package/dist/compare.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { getFramePng } from "./cache.js";
4
+ import { getFramesPng } from "./cache.js";
5
5
  import { runDiff } from "./diff.js";
6
6
  import { generateReport } from "./report.js";
7
7
  const BRIDGE_DIR = ".bridge";
@@ -18,6 +18,25 @@ function readEnvToken() {
18
18
  const match = content.match(/^FIGMA_TOKEN=(.*)$/m);
19
19
  return match ? match[1].trim() : null;
20
20
  }
21
+ function parseConfig(raw) {
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(raw);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ if (typeof parsed !== "object" || parsed === null) {
30
+ return null;
31
+ }
32
+ const candidate = parsed;
33
+ if (typeof candidate.figmaFileKey !== "string" ||
34
+ typeof candidate.threshold !== "number" ||
35
+ !Array.isArray(candidate.frames)) {
36
+ return null;
37
+ }
38
+ return candidate;
39
+ }
21
40
  export async function runCompare(options = {}) {
22
41
  if (!existsSync(CONFIG_PATH)) {
23
42
  console.log("Norma: no .bridge/config.json found — run `npx norma-scope init` first.");
@@ -28,30 +47,46 @@ export async function runCompare(options = {}) {
28
47
  console.log("Norma: ⚠ .env.local missing or FIGMA_TOKEN not set — skipping comparison.");
29
48
  return;
30
49
  }
31
- const config = JSON.parse(await readFile(CONFIG_PATH, "utf-8"));
50
+ const config = parseConfig(await readFile(CONFIG_PATH, "utf-8"));
51
+ if (!config) {
52
+ console.log("Norma: ⚠ .bridge/config.json is malformed — skipping comparison. Run `npx norma-scope init` to regenerate it.");
53
+ return;
54
+ }
32
55
  const results = [];
33
56
  const lines = [];
57
+ const framesWithScreenshots = config.frames.filter((frame) => existsSync(path.join(SCREENSHOTS_DIR, frame.screenshot)));
58
+ let figmaPngs = new Map();
59
+ let staleFrameIds = new Set();
60
+ let batchFetchError = null;
61
+ if (framesWithScreenshots.length > 0) {
62
+ const result = await getFramesPng(config.figmaFileKey, framesWithScreenshots.map((f) => f.figmaFrameId), token, { fresh: options.fresh });
63
+ figmaPngs = result.pngs;
64
+ staleFrameIds = result.staleFrameIds;
65
+ if (result.fetchError) {
66
+ batchFetchError = result.fetchError.includes("429")
67
+ ? "rate limited by Figma — try again in a few minutes (Norma caches results for 10 min, so this should resolve on its own)"
68
+ : result.fetchError;
69
+ }
70
+ }
34
71
  for (const frame of config.frames) {
35
72
  const screenshotPath = path.join(SCREENSHOTS_DIR, frame.screenshot);
36
73
  if (!existsSync(screenshotPath)) {
37
74
  lines.push(` ${frame.screenshot.padEnd(22)} → ⚠ skipped (no screenshot found)`);
38
75
  continue;
39
76
  }
40
- let figmaPng;
41
- try {
42
- figmaPng = await getFramePng(config.figmaFileKey, frame.figmaFrameId, token, {
43
- fresh: options.fresh,
44
- });
45
- }
46
- catch (err) {
47
- lines.push(` ${frame.screenshot.padEnd(22)} → ⚠ skipped (Figma fetch failed: ${err.message})`);
77
+ const figmaPng = figmaPngs.get(frame.figmaFrameId);
78
+ if (!figmaPng) {
79
+ const reason = batchFetchError ?? `no image returned for frame ${frame.figmaFrameId}`;
80
+ lines.push(` ${frame.screenshot.padEnd(22)} → ⚠ skipped (Figma fetch failed: ${reason})`);
48
81
  continue;
49
82
  }
50
83
  const diffPath = path.join(DIFF_DIR, frame.screenshot.replace(/\.png$/, "-diff.png"));
51
84
  let mismatchPercent;
85
+ let dimensionMismatch;
52
86
  try {
53
87
  const result = await runDiff(screenshotPath, figmaPng, diffPath);
54
88
  mismatchPercent = result.mismatchPercent;
89
+ dimensionMismatch = result.dimensionMismatch;
55
90
  }
56
91
  catch (err) {
57
92
  lines.push(` ${frame.screenshot.padEnd(22)} → ⚠ skipped (diff failed: ${err.message})`);
@@ -68,6 +103,12 @@ export async function runCompare(options = {}) {
68
103
  ? `⚠ above threshold (${config.threshold}%)`
69
104
  : "✓";
70
105
  lines.push(` ${frame.screenshot.padEnd(22)} → ${mismatchPercent.toFixed(1).padStart(5)}% ${flag}`);
106
+ if (dimensionMismatch) {
107
+ lines.push(` ${"".padEnd(22)} ⚠ image dimensions differ significantly — check screenshot export scale`);
108
+ }
109
+ if (staleFrameIds.has(frame.figmaFrameId)) {
110
+ lines.push(` ${"".padEnd(22)} ⚠ Figma fetch failed — using a cached design image older than 10 min`);
111
+ }
71
112
  }
72
113
  console.log("Norma");
73
114
  console.log("══════════════════════════════════════════════");
package/dist/diff.js CHANGED
@@ -1,6 +1,12 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { PNG } from "pngjs";
3
3
  import pixelmatch from "pixelmatch";
4
+ const DIMENSION_MISMATCH_THRESHOLD = 0.15;
5
+ function isDimensionMismatch(aWidth, aHeight, bWidth, bHeight) {
6
+ const widthDiff = Math.abs(aWidth - bWidth) / Math.max(aWidth, bWidth);
7
+ const heightDiff = Math.abs(aHeight - bHeight) / Math.max(aHeight, bHeight);
8
+ return widthDiff > DIMENSION_MISMATCH_THRESHOLD || heightDiff > DIMENSION_MISMATCH_THRESHOLD;
9
+ }
4
10
  function resizeToCanvas(png, width, height) {
5
11
  if (png.width === width && png.height === height) {
6
12
  return png;
@@ -13,6 +19,7 @@ export async function runDiff(screenshotPath, figmaPngBuffer, outputPath) {
13
19
  const screenshotBuffer = await readFile(screenshotPath);
14
20
  const screenshotPng = PNG.sync.read(screenshotBuffer);
15
21
  const figmaPng = PNG.sync.read(figmaPngBuffer);
22
+ const dimensionMismatch = isDimensionMismatch(screenshotPng.width, screenshotPng.height, figmaPng.width, figmaPng.height);
16
23
  const width = Math.max(screenshotPng.width, figmaPng.width);
17
24
  const height = Math.max(screenshotPng.height, figmaPng.height);
18
25
  const a = resizeToCanvas(screenshotPng, width, height);
@@ -21,5 +28,5 @@ export async function runDiff(screenshotPath, figmaPngBuffer, outputPath) {
21
28
  const mismatchedPixels = pixelmatch(a.data, b.data, diff.data, width, height, { threshold: 0.1 });
22
29
  const mismatchPercent = (mismatchedPixels / (width * height)) * 100;
23
30
  await writeFile(outputPath, PNG.sync.write(diff));
24
- return { mismatchPercent };
31
+ return { mismatchPercent, dimensionMismatch };
25
32
  }
package/dist/figma.js CHANGED
@@ -12,26 +12,52 @@ export async function fetchFrameList(fileKey, token) {
12
12
  const pageChildren = page.children ?? [];
13
13
  for (const node of pageChildren) {
14
14
  if (node.type === "FRAME") {
15
- frames.push({ id: node.id, name: node.name });
15
+ frames.push({
16
+ id: node.id,
17
+ name: node.name,
18
+ width: node.absoluteBoundingBox ? Math.round(node.absoluteBoundingBox.width) : null,
19
+ height: node.absoluteBoundingBox ? Math.round(node.absoluteBoundingBox.height) : null,
20
+ });
16
21
  }
17
22
  }
18
23
  }
19
24
  return frames;
20
25
  }
21
26
  export async function fetchFramePng(fileKey, frameId, token) {
22
- const res = await fetch(`${FIGMA_API_BASE}/images/${fileKey}?ids=${encodeURIComponent(frameId)}&format=png`, { headers: { "X-Figma-Token": token } });
27
+ const result = await fetchFramesPng(fileKey, [frameId], token);
28
+ const png = result.get(frameId);
29
+ if (!png) {
30
+ throw new Error(`No image returned for frame ${frameId}`);
31
+ }
32
+ return png;
33
+ }
34
+ export async function fetchFramesPng(fileKey, frameIds, token) {
35
+ if (frameIds.length === 0) {
36
+ return new Map();
37
+ }
38
+ const idsParam = frameIds.map(encodeURIComponent).join(",");
39
+ const res = await fetch(`${FIGMA_API_BASE}/images/${fileKey}?ids=${idsParam}&format=png`, { headers: { "X-Figma-Token": token } });
23
40
  if (!res.ok) {
24
- throw new Error(`Figma API error exporting frame ${frameId}: ${res.status} ${res.statusText}`);
41
+ throw new Error(`Figma API error exporting frames ${frameIds.join(", ")}: ${res.status} ${res.statusText}`);
25
42
  }
26
43
  const data = (await res.json());
27
- const imageUrl = data.images[frameId];
28
- if (!imageUrl) {
29
- throw new Error(`No image returned for frame ${frameId}`);
30
- }
31
- const imgRes = await fetch(imageUrl);
32
- if (!imgRes.ok) {
33
- throw new Error(`Failed to download exported PNG: ${imgRes.status} ${imgRes.statusText}`);
44
+ const entries = await Promise.all(frameIds.map(async (frameId) => {
45
+ const imageUrl = data.images[frameId];
46
+ if (!imageUrl) {
47
+ return null;
48
+ }
49
+ const imgRes = await fetch(imageUrl);
50
+ if (!imgRes.ok) {
51
+ return null;
52
+ }
53
+ const arrayBuffer = await imgRes.arrayBuffer();
54
+ return [frameId, Buffer.from(arrayBuffer)];
55
+ }));
56
+ const result = new Map();
57
+ for (const entry of entries) {
58
+ if (entry) {
59
+ result.set(entry[0], entry[1]);
60
+ }
34
61
  }
35
- const arrayBuffer = await imgRes.arrayBuffer();
36
- return Buffer.from(arrayBuffer);
62
+ return result;
37
63
  }
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { runInit } from "./init.js";
2
2
  import { runCompare } from "./compare.js";
3
+ import { runClean } from "./clean.js";
3
4
  const HELP = `
4
5
  Norma
5
6
 
@@ -10,6 +11,7 @@ Commands:
10
11
  init Set up Norma in this project
11
12
  compare Compare local screenshots against Figma designs
12
13
  compare --fresh Bypass the Figma cache and force a fresh fetch
14
+ clean Remove local screenshots, diffs, reports, and cache
13
15
  `;
14
16
  async function main() {
15
17
  const command = process.argv[2];
@@ -20,6 +22,9 @@ async function main() {
20
22
  case "compare":
21
23
  await runCompare({ fresh: process.argv.includes("--fresh") });
22
24
  break;
25
+ case "clean":
26
+ await runClean();
27
+ break;
23
28
  default:
24
29
  console.log(HELP);
25
30
  break;
package/dist/init.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createInterface } from "node:readline/promises";
2
- import { mkdir, writeFile, readFile, appendFile, chmod } from "node:fs/promises";
2
+ import { mkdir, writeFile, readFile, appendFile, chmod, copyFile } from "node:fs/promises";
3
3
  import { existsSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fetchFrameList } from "./figma.js";
@@ -47,16 +47,25 @@ async function updateGitignore() {
47
47
  await appendFile(gitignorePath, `${prefix}${missing.join("\n")}\n`);
48
48
  }
49
49
  }
50
- async function installPreCommitHook() {
50
+ export async function installPreCommitHook() {
51
51
  const hooksDir = ".git/hooks";
52
52
  if (!existsSync(hooksDir)) {
53
53
  console.log(" ⚠ No .git/hooks directory found — skipping pre-commit hook install.");
54
- return;
54
+ return { backedUpTo: null };
55
55
  }
56
56
  const hookPath = path.join(hooksDir, "pre-commit");
57
- const hookContent = "#!/bin/sh\nnpx norma-scope compare\n";
57
+ const hookContent = "#!/bin/sh\nnpx norma-scope compare\nexit 0\n";
58
+ let backedUpTo = null;
59
+ if (existsSync(hookPath)) {
60
+ const existing = await readFile(hookPath, "utf-8");
61
+ if (!existing.includes("npx norma-scope compare")) {
62
+ backedUpTo = `${hookPath}.pre-norma-backup`;
63
+ await copyFile(hookPath, backedUpTo);
64
+ }
65
+ }
58
66
  await writeFile(hookPath, hookContent, { mode: 0o755 });
59
67
  await chmod(hookPath, 0o755);
68
+ return { backedUpTo };
60
69
  }
61
70
  export async function runInit() {
62
71
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -77,7 +86,8 @@ export async function runInit() {
77
86
  }
78
87
  console.log("Which frames do you want to check? (comma-separated numbers)\n");
79
88
  frames.forEach((frame, i) => {
80
- console.log(` ${i + 1} ${frame.name}`);
89
+ const dims = frame.width && frame.height ? ` (${frame.width}×${frame.height}px)` : "";
90
+ console.log(` ${i + 1} ${frame.name}${dims}`);
81
91
  });
82
92
  const selection = await rl.question("\n> ");
83
93
  const indices = selection
@@ -106,25 +116,36 @@ export async function runInit() {
106
116
  await writeFile(".env.local", `FIGMA_TOKEN=${trimmedToken}\n`);
107
117
  }
108
118
  await updateGitignore();
109
- await installPreCommitHook();
119
+ const { backedUpTo } = await installPreCommitHook();
110
120
  console.log("\n✓ Config written to .bridge/config.json");
111
121
  console.log("✓ .bridge/screenshots/ folder created");
112
122
  console.log("✓ .bridge/reports/ folder created");
113
123
  console.log("✓ .bridge/diff/ folder created");
114
124
  console.log("✓ .gitignore updated");
115
- console.log("✓ Pre-commit hook installed");
125
+ if (backedUpTo) {
126
+ console.log(`✓ Pre-commit hook installed (your existing hook was backed up to ${backedUpTo})`);
127
+ }
128
+ else {
129
+ console.log("✓ Pre-commit hook installed");
130
+ }
116
131
  console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
117
132
  console.log("You're ready. Here's what to do next:\n");
118
133
  console.log(" Screenshot your implementation for each component");
119
134
  console.log(" and drop the files here: .bridge/screenshots/\n");
120
135
  console.log(" Expected filenames:");
121
- for (const frame of config.frames) {
122
- console.log(` ${frame.screenshot}`);
123
- }
136
+ config.frames.forEach((frame, i) => {
137
+ const source = selectedFrames[i];
138
+ const dims = source.width && source.height ? ` — capture at ${source.width}×${source.height}px` : "";
139
+ console.log(` ${frame.screenshot}${dims}`);
140
+ });
124
141
  console.log("\n Then just git commit as normal.");
125
142
  console.log(" The report generates automatically.");
126
143
  console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
127
144
  }
145
+ catch (err) {
146
+ console.log(`\n✗ Setup failed: ${err.message}`);
147
+ process.exitCode = 1;
148
+ }
128
149
  finally {
129
150
  rl.close();
130
151
  }
package/dist/report.js CHANGED
@@ -14,12 +14,28 @@ function escapeHtml(str) {
14
14
  return str
15
15
  .replace(/&/g, "&amp;")
16
16
  .replace(/</g, "&lt;")
17
- .replace(/>/g, "&gt;");
17
+ .replace(/>/g, "&gt;")
18
+ .replace(/"/g, "&quot;")
19
+ .replace(/'/g, "&#39;");
18
20
  }
19
21
  async function toBase64(filePath) {
20
22
  const buffer = await readFile(filePath);
21
23
  return buffer.toString("base64");
22
24
  }
25
+ function frame(src, alt, dotClass, caption) {
26
+ return `
27
+ <figure class="shot-frame">
28
+ <div class="shot-chrome">
29
+ <span class="shot-dot ${dotClass}"></span>
30
+ <span class="shot-chrome-line short"></span>
31
+ <span class="shot-chrome-line"></span>
32
+ </div>
33
+ <div class="shot-body">
34
+ <img src="${src}" loading="lazy" onclick="openLightbox('${src}')" alt="${alt}" />
35
+ </div>
36
+ <figcaption>${caption}</figcaption>
37
+ </figure>`;
38
+ }
23
39
  export async function generateReport(results, threshold, outputPath) {
24
40
  const { branch, commit } = getGitInfo();
25
41
  const generated = new Date().toLocaleString("en-GB", {
@@ -40,28 +56,17 @@ export async function generateReport(results, threshold, outputPath) {
40
56
  const buildSrc = `data:image/png;base64,${buildB64}`;
41
57
  const figmaSrc = `data:image/png;base64,${figmaB64}`;
42
58
  const diffSrc = `data:image/png;base64,${diffB64}`;
59
+ const label = escapeHtml(r.label);
43
60
  return `
44
- <section class="component ${isFlagged ? "flagged" : "clean"}">
61
+ <section class="component">
45
62
  <div class="component-header">
46
- <div class="component-title">
47
- <span class="status-dot"></span>
48
- <h2>${escapeHtml(r.label)}</h2>
49
- </div>
50
- <span class="component-status">${r.mismatchPercent.toFixed(1)}% diff</span>
63
+ <h2>${label}</h2>
64
+ <span class="component-status ${isFlagged ? "flagged" : "clean"}">${isFlagged ? "⚠ " : ""}${r.mismatchPercent.toFixed(1)}% diff</span>
51
65
  </div>
52
66
  <div class="component-images">
53
- <figure class="image-col">
54
- <img src="${buildSrc}" loading="lazy" onclick="openLightbox('${buildSrc}')" alt="${escapeHtml(r.label)} — your build" />
55
- <figcaption>Your build</figcaption>
56
- </figure>
57
- <figure class="image-col">
58
- <img src="${figmaSrc}" loading="lazy" onclick="openLightbox('${figmaSrc}')" alt="${escapeHtml(r.label)} — Figma design" />
59
- <figcaption>Figma design</figcaption>
60
- </figure>
61
- <figure class="image-col">
62
- <img src="${diffSrc}" loading="lazy" onclick="openLightbox('${diffSrc}')" alt="${escapeHtml(r.label)} — diff overlay" />
63
- <figcaption>Diff overlay</figcaption>
64
- </figure>
67
+ ${frame(buildSrc, `${label} — your build`, "dot-build", "Your build")}
68
+ ${frame(figmaSrc, `${label} — Figma design`, "dot-build", "Figma design")}
69
+ ${frame(diffSrc, `${label} — diff overlay`, "dot-diff", "Diff overlay")}
65
70
  </div>
66
71
  </section>`;
67
72
  }));
@@ -74,139 +79,126 @@ export async function generateReport(results, threshold, outputPath) {
74
79
  <style>
75
80
  :root {
76
81
  --clay: #A8736E;
77
- --clay-dark: #7E5854;
78
- --clay-tint: #F5EBE9;
79
82
  --ink: #1C1B1A;
80
83
  --ink-soft: #6B6664;
81
84
  --line: #E7E1DF;
82
- --bg: #FAF7F6;
85
+ --page-bg: #EDE7E1;
83
86
  --card: #FFFFFF;
84
- --danger: #C2452F;
85
- --danger-tint: #FBEAE6;
87
+ --frame-bg: #F7F5F3;
88
+ --chrome-bg: #F0EDEA;
89
+ --danger: #B6611F;
86
90
  --success: #3E7D52;
87
- --success-tint: #E8F3EB;
88
91
  }
89
92
  * { box-sizing: border-box; }
90
93
  body {
91
94
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif;
92
- background: var(--bg);
95
+ background: var(--page-bg);
93
96
  color: var(--ink);
94
97
  margin: 0;
95
- padding: 0 0 64px;
98
+ padding: 48px 24px;
96
99
  -webkit-font-smoothing: antialiased;
97
100
  }
98
- .topbar {
99
- background: var(--ink);
100
- color: #fff;
101
- padding: 28px 40px;
102
- }
103
- .topbar .wordmark {
104
- font-size: 22px;
105
- font-weight: 700;
106
- color: var(--clay);
107
- letter-spacing: -0.01em;
108
- margin: 0 0 10px;
101
+ .card {
102
+ max-width: 1080px;
103
+ margin: 0 auto;
104
+ background: var(--card);
105
+ border-radius: 24px;
106
+ box-shadow: 0 24px 60px rgba(28,27,26,0.10);
107
+ overflow: hidden;
109
108
  }
110
- .meta-row {
109
+ header {
111
110
  display: flex;
112
- flex-wrap: wrap;
113
- gap: 10px;
114
- }
115
- .meta-pill {
116
- background: rgba(255,255,255,0.08);
117
- border: 1px solid rgba(255,255,255,0.12);
118
- color: rgba(255,255,255,0.85);
119
- font-size: 12px;
120
- padding: 5px 12px;
121
- border-radius: 999px;
122
- font-variant-numeric: tabular-nums;
123
- }
124
- main { max-width: 980px; margin: 0 auto; padding: 36px 40px 0; }
125
- .summary {
126
- display: grid;
127
- grid-template-columns: repeat(3, 1fr);
128
- gap: 16px;
129
- margin: 0 0 36px;
111
+ justify-content: space-between;
112
+ align-items: flex-start;
113
+ padding: 32px 40px;
114
+ border-bottom: 1px solid var(--line);
130
115
  }
131
- .stat-card {
132
- background: var(--card);
133
- border: 1px solid var(--line);
134
- border-radius: 14px;
135
- padding: 20px 22px;
136
- box-shadow: 0 1px 2px rgba(28,27,26,0.04), 0 8px 24px rgba(28,27,26,0.06);
116
+ header h1 { font-size: 26px; font-weight: 700; margin: 0 0 8px; }
117
+ .report-meta {
118
+ font-family: "SF Mono", ui-monospace, Menlo, monospace;
119
+ font-size: 13px;
120
+ color: var(--ink-soft);
137
121
  }
138
- .stat-card .stat-number { font-size: 30px; font-weight: 700; line-height: 1; }
139
- .stat-card .stat-label { font-size: 13px; color: var(--ink-soft); margin-top: 6px; }
140
- .stat-card.attention .stat-number { color: var(--danger); }
141
- .stat-card.clean .stat-number { color: var(--success); }
142
- .component {
143
- background: var(--card);
144
- border: 1px solid var(--line);
145
- border-radius: 14px;
146
- margin-bottom: 18px;
147
- overflow: hidden;
148
- box-shadow: 0 1px 2px rgba(28,27,26,0.04), 0 8px 24px rgba(28,27,26,0.05);
122
+ .wordmark { font-size: 14px; font-weight: 700; color: var(--clay); letter-spacing: -0.01em; }
123
+ .summary-bar {
124
+ display: flex;
125
+ gap: 28px;
126
+ flex-wrap: wrap;
127
+ padding: 20px 40px;
128
+ border-bottom: 1px solid var(--line);
129
+ font-size: 15px;
149
130
  }
131
+ .summary-bar b { font-weight: 700; }
132
+ .summary-bar .attention b { color: var(--danger); }
133
+ .summary-bar .clean b { color: var(--success); }
134
+ .components { padding: 8px 40px 40px; }
135
+ .component { padding: 28px 0; border-bottom: 1px solid var(--line); }
136
+ .component:last-child { border-bottom: none; }
150
137
  .component-header {
151
138
  display: flex;
152
139
  justify-content: space-between;
153
140
  align-items: center;
154
- padding: 16px 20px;
155
- border-bottom: 1px solid var(--line);
156
- }
157
- .component-title { display: flex; align-items: center; gap: 10px; }
158
- .component-title h2 { font-size: 15px; font-weight: 600; margin: 0; }
159
- .status-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
160
- .component.flagged .status-dot { background: var(--danger); }
161
- .component.clean .status-dot { background: var(--success); }
162
- .component-status {
163
- font-size: 12px;
164
- font-weight: 600;
165
- padding: 5px 12px;
166
- border-radius: 999px;
167
- font-variant-numeric: tabular-nums;
141
+ margin-bottom: 18px;
168
142
  }
169
- .component.flagged .component-status { color: var(--danger); background: var(--danger-tint); }
170
- .component.clean .component-status { color: var(--success); background: var(--success-tint); }
143
+ .component-header h2 { font-size: 19px; font-weight: 700; margin: 0; }
144
+ .component-status { font-size: 13px; font-weight: 600; }
145
+ .component-status.flagged { color: var(--danger); }
146
+ .component-status.clean { color: var(--success); }
171
147
  .component-images {
172
148
  display: grid;
173
149
  grid-template-columns: 1fr 1fr 1fr;
174
- gap: 1px;
175
- background: var(--line);
150
+ gap: 20px;
176
151
  }
177
- .image-col {
178
- background: var(--card);
179
- padding: 16px;
180
- margin: 0;
181
- text-align: center;
152
+ .shot-frame { margin: 0; }
153
+ .shot-chrome {
154
+ display: flex;
155
+ align-items: center;
156
+ gap: 6px;
157
+ height: 28px;
158
+ padding: 0 12px;
159
+ background: var(--chrome-bg);
160
+ border: 1px solid var(--line);
161
+ border-bottom: none;
162
+ border-radius: 10px 10px 0 0;
182
163
  }
183
- .image-col img {
164
+ .shot-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
165
+ .dot-build { background: var(--clay); }
166
+ .dot-diff { background: #B9B3AE; }
167
+ .shot-chrome-line { height: 4px; border-radius: 2px; background: #DCD6D1; }
168
+ .shot-chrome-line.short { width: 18px; }
169
+ .shot-chrome-line:not(.short) { width: 40px; margin-left: auto; }
170
+ .shot-body {
171
+ background: var(--frame-bg);
172
+ border: 1px solid var(--line);
173
+ border-top: none;
174
+ border-radius: 0 0 10px 10px;
175
+ padding: 14px;
176
+ }
177
+ .shot-body img {
184
178
  width: 100%;
185
- height: 280px;
179
+ height: 240px;
186
180
  object-fit: contain;
187
181
  object-position: top center;
188
- background: var(--bg);
189
- border-radius: 8px;
190
- border: 1px solid var(--line);
191
182
  cursor: zoom-in;
192
- transition: opacity 0.15s ease;
193
183
  display: block;
184
+ transition: opacity 0.15s ease;
194
185
  }
195
- .image-col img:hover { opacity: 0.85; }
196
- .image-col figcaption {
197
- margin-top: 10px;
186
+ .shot-body img:hover { opacity: 0.85; }
187
+ .shot-frame figcaption {
188
+ margin-top: 12px;
189
+ text-align: center;
198
190
  font-size: 11px;
199
- color: var(--ink-soft);
200
- text-transform: uppercase;
191
+ font-weight: 700;
201
192
  letter-spacing: 0.06em;
202
- font-weight: 600;
193
+ text-transform: uppercase;
194
+ color: var(--ink-soft);
203
195
  }
204
196
  footer {
205
- margin-top: 40px;
206
197
  text-align: center;
207
198
  color: var(--ink-soft);
208
199
  font-size: 12px;
209
200
  line-height: 1.7;
201
+ padding: 28px 40px 8px;
210
202
  }
211
203
  #lightbox {
212
204
  display: none;
@@ -227,41 +219,33 @@ export async function generateReport(results, threshold, outputPath) {
227
219
  box-shadow: 0 20px 60px rgba(0,0,0,0.4);
228
220
  }
229
221
  @media (max-width: 720px) {
230
- .summary { grid-template-columns: 1fr; }
231
222
  .component-images { grid-template-columns: 1fr; }
223
+ header { flex-direction: column; gap: 16px; }
232
224
  }
233
225
  </style>
234
226
  </head>
235
227
  <body>
236
- <div class="topbar">
237
- <p class="wordmark">norma</p>
238
- <div class="meta-row">
239
- <span class="meta-pill">Branch: ${escapeHtml(branch)}</span>
240
- <span class="meta-pill">Commit: ${escapeHtml(commit)}</span>
241
- <span class="meta-pill">${escapeHtml(generated)}</span>
242
- </div>
243
- </div>
244
- <main>
245
- <div class="summary">
246
- <div class="stat-card">
247
- <div class="stat-number">${results.length}</div>
248
- <div class="stat-label">Components checked</div>
249
- </div>
250
- <div class="stat-card attention">
251
- <div class="stat-number">${needsAttention.length}</div>
252
- <div class="stat-label">Need attention</div>
253
- </div>
254
- <div class="stat-card clean">
255
- <div class="stat-number">${clean.length}</div>
256
- <div class="stat-label">Clean</div>
228
+ <div class="card">
229
+ <header>
230
+ <div>
231
+ <h1>Report</h1>
232
+ <p class="report-meta">Branch: ${escapeHtml(branch)} &middot; Commit: ${escapeHtml(commit)} &middot; ${escapeHtml(generated)}</p>
257
233
  </div>
234
+ <span class="wordmark">norma</span>
235
+ </header>
236
+ <div class="summary-bar">
237
+ <span>${results.length} components checked</span>
238
+ <span class="attention"><b>${needsAttention.length}</b> needs attention</span>
239
+ <span class="clean"><b>${clean.length}</b> clean</span>
240
+ </div>
241
+ <div class="components">
242
+ ${rows.join("\n")}
258
243
  </div>
259
- ${rows.join("\n")}
260
244
  <footer>
261
245
  <p>Generated by Norma</p>
262
246
  <p>To fix: update your implementation, re-screenshot, commit again.</p>
263
247
  </footer>
264
- </main>
248
+ </div>
265
249
  <div id="lightbox" onclick="closeLightbox()">
266
250
  <img id="lightbox-img" src="" alt="Full size preview" />
267
251
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "norma-scope",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Compare your implementation screenshots against Figma designs with a zero-friction, local pre-commit report.",
5
5
  "type": "module",
6
6
  "bin": {