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 +31 -6
- package/dist/cache.js +48 -11
- package/dist/clean.js +31 -0
- package/dist/compare.js +51 -10
- package/dist/diff.js +8 -1
- package/dist/figma.js +38 -12
- package/dist/index.js +5 -0
- package/dist/init.js +31 -10
- package/dist/report.js +206 -47
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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({
|
|
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
|
|
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
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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, "&")
|
|
16
16
|
.replace(/</g, "<")
|
|
17
|
-
.replace(/>/g, ">")
|
|
17
|
+
.replace(/>/g, ">")
|
|
18
|
+
.replace(/"/g, """)
|
|
19
|
+
.replace(/'/g, "'");
|
|
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
|
-
<
|
|
61
|
+
<section class="component">
|
|
42
62
|
<div class="component-header">
|
|
43
|
-
<
|
|
44
|
-
<span class="component-status">${isFlagged ? "⚠" : "
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
<
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
</
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
<div
|
|
95
|
-
|
|
228
|
+
<div class="card">
|
|
229
|
+
<header>
|
|
230
|
+
<div>
|
|
231
|
+
<h1>Report</h1>
|
|
232
|
+
<p class="report-meta">Branch: ${escapeHtml(branch)} · Commit: ${escapeHtml(commit)} · ${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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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);
|