norma-scope 0.1.1 → 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
@@ -1,4 +1,4 @@
1
- <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjgwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDY4MCAyMDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgcm9sZT0iaW1nIiBhcmlhLWxhYmVsPSJOb3JtYSI+CiAgPHRpdGxlPk5vcm1hPC90aXRsZT4KICA8dGV4dCB4PSIzNDAiIHk9IjEzNSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9Ii1hcHBsZS1zeXN0ZW0sICdIZWx2ZXRpY2EgTmV1ZScsIEFyaWFsLCBzYW5zLXNlcmlmIiBmb250LXdlaWdodD0iNzAwIiBmb250LXNpemU9IjEwMCIgZmlsbD0iI0E4NzM2RSIgbGV0dGVyLXNwYWNpbmc9IjIiPm5vcm1hPC90ZXh0Pgo8L3N2Zz4K" alt="Norma" width="200" />
1
+ # Norma
2
2
 
3
3
  Norma compares your implementation screenshots against the original Figma designs and generates a visual diff report — automatically, every time you commit. No servers, no LLM, no blocking: just a report you can open and share.
4
4
 
@@ -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", {
@@ -37,68 +53,211 @@ export async function generateReport(results, threshold, outputPath) {
37
53
  const buildB64 = await toBase64(r.screenshotPath);
38
54
  const figmaB64 = r.figmaPng.toString("base64");
39
55
  const diffB64 = await toBase64(r.diffPath);
56
+ const buildSrc = `data:image/png;base64,${buildB64}`;
57
+ const figmaSrc = `data:image/png;base64,${figmaB64}`;
58
+ const diffSrc = `data:image/png;base64,${diffB64}`;
59
+ const label = escapeHtml(r.label);
40
60
  return `
41
- <div class="component ${isFlagged ? "flagged" : "clean"}">
61
+ <section class="component">
42
62
  <div class="component-header">
43
- <span class="component-label">${escapeHtml(r.label)}</span>
44
- <span class="component-status">${isFlagged ? "⚠" : ""} ${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>
45
65
  </div>
46
66
  <div class="component-images">
47
- <div class="image-col">
48
- <p>Your build</p>
49
- <img src="data:image/png;base64,${buildB64}" />
50
- </div>
51
- <div class="image-col">
52
- <p>Figma design</p>
53
- <img src="data:image/png;base64,${figmaB64}" />
54
- </div>
55
- <div class="image-col">
56
- <p>Diff overlay</p>
57
- <img src="data:image/png;base64,${diffB64}" />
58
- </div>
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")}
59
70
  </div>
60
- </div>`;
71
+ </section>`;
61
72
  }));
62
73
  const html = `<!DOCTYPE html>
63
74
  <html lang="en">
64
75
  <head>
65
76
  <meta charset="UTF-8" />
77
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
66
78
  <title>Norma Report</title>
67
79
  <style>
68
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f7; color: #1d1d1f; margin: 0; padding: 32px; }
69
- header h1 { margin: 0 0 4px; font-size: 22px; }
70
- header p { margin: 2px 0; color: #555; font-size: 13px; }
71
- .summary { display: flex; gap: 24px; margin: 24px 0; padding: 16px; background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); font-size: 14px; }
72
- .summary strong { font-size: 18px; display: block; }
73
- .component { background: #fff; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
74
- .component-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid #eee; }
75
- .component-label { font-weight: 600; }
76
- .component.flagged .component-status { color: #b45309; font-weight: 600; }
77
- .component.clean .component-status { color: #15803d; font-weight: 600; }
78
- .component-images { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1px; background: #eee; }
79
- .image-col { background: #fff; padding: 12px; text-align: center; }
80
- .image-col p { margin: 0 0 8px; font-size: 12px; color: #777; text-transform: uppercase; letter-spacing: 0.05em; }
81
- .image-col img { max-width: 100%; border: 1px solid #ddd; border-radius: 4px; }
82
- footer { margin-top: 32px; text-align: center; color: #888; font-size: 12px; }
80
+ :root {
81
+ --clay: #A8736E;
82
+ --ink: #1C1B1A;
83
+ --ink-soft: #6B6664;
84
+ --line: #E7E1DF;
85
+ --page-bg: #EDE7E1;
86
+ --card: #FFFFFF;
87
+ --frame-bg: #F7F5F3;
88
+ --chrome-bg: #F0EDEA;
89
+ --danger: #B6611F;
90
+ --success: #3E7D52;
91
+ }
92
+ * { box-sizing: border-box; }
93
+ body {
94
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif;
95
+ background: var(--page-bg);
96
+ color: var(--ink);
97
+ margin: 0;
98
+ padding: 48px 24px;
99
+ -webkit-font-smoothing: antialiased;
100
+ }
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;
108
+ }
109
+ header {
110
+ display: flex;
111
+ justify-content: space-between;
112
+ align-items: flex-start;
113
+ padding: 32px 40px;
114
+ border-bottom: 1px solid var(--line);
115
+ }
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);
121
+ }
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;
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; }
137
+ .component-header {
138
+ display: flex;
139
+ justify-content: space-between;
140
+ align-items: center;
141
+ margin-bottom: 18px;
142
+ }
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); }
147
+ .component-images {
148
+ display: grid;
149
+ grid-template-columns: 1fr 1fr 1fr;
150
+ gap: 20px;
151
+ }
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;
163
+ }
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 {
178
+ width: 100%;
179
+ height: 240px;
180
+ object-fit: contain;
181
+ object-position: top center;
182
+ cursor: zoom-in;
183
+ display: block;
184
+ transition: opacity 0.15s ease;
185
+ }
186
+ .shot-body img:hover { opacity: 0.85; }
187
+ .shot-frame figcaption {
188
+ margin-top: 12px;
189
+ text-align: center;
190
+ font-size: 11px;
191
+ font-weight: 700;
192
+ letter-spacing: 0.06em;
193
+ text-transform: uppercase;
194
+ color: var(--ink-soft);
195
+ }
196
+ footer {
197
+ text-align: center;
198
+ color: var(--ink-soft);
199
+ font-size: 12px;
200
+ line-height: 1.7;
201
+ padding: 28px 40px 8px;
202
+ }
203
+ #lightbox {
204
+ display: none;
205
+ position: fixed;
206
+ inset: 0;
207
+ background: rgba(20,18,17,0.88);
208
+ align-items: center;
209
+ justify-content: center;
210
+ padding: 40px;
211
+ z-index: 10;
212
+ cursor: zoom-out;
213
+ }
214
+ #lightbox.open { display: flex; }
215
+ #lightbox img {
216
+ max-width: 100%;
217
+ max-height: 100%;
218
+ border-radius: 8px;
219
+ box-shadow: 0 20px 60px rgba(0,0,0,0.4);
220
+ }
221
+ @media (max-width: 720px) {
222
+ .component-images { grid-template-columns: 1fr; }
223
+ header { flex-direction: column; gap: 16px; }
224
+ }
83
225
  </style>
84
226
  </head>
85
227
  <body>
86
- <header>
87
- <h1>Norma Report</h1>
88
- <p>Branch: ${escapeHtml(branch)}</p>
89
- <p>Commit: ${escapeHtml(commit)}</p>
90
- <p>Generated: ${escapeHtml(generated)}</p>
91
- </header>
92
- <div class="summary">
93
- <div><strong>${results.length}</strong> components checked</div>
94
- <div><strong>${needsAttention.length}</strong> need attention</div>
95
- <div><strong>${clean.length}</strong> 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>
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")}
243
+ </div>
244
+ <footer>
245
+ <p>Generated by Norma</p>
246
+ <p>To fix: update your implementation, re-screenshot, commit again.</p>
247
+ </footer>
248
+ </div>
249
+ <div id="lightbox" onclick="closeLightbox()">
250
+ <img id="lightbox-img" src="" alt="Full size preview" />
96
251
  </div>
97
- ${rows.join("\n")}
98
- <footer>
99
- <p>Generated by Norma</p>
100
- <p>To fix: update your implementation, re-screenshot, commit again.</p>
101
- </footer>
252
+ <script>
253
+ function openLightbox(src) {
254
+ document.getElementById('lightbox-img').src = src;
255
+ document.getElementById('lightbox').classList.add('open');
256
+ }
257
+ function closeLightbox() {
258
+ document.getElementById('lightbox').classList.remove('open');
259
+ }
260
+ </script>
102
261
  </body>
103
262
  </html>`;
104
263
  await writeFile(outputPath, html);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "norma-scope",
3
- "version": "0.1.1",
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": {