@3xhaust/oh-my-design 0.16.1

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.
Files changed (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +282 -0
  3. package/README.md +282 -0
  4. package/adapters/build.ts +184 -0
  5. package/adapters/claude.ts +111 -0
  6. package/adapters/codex.ts +68 -0
  7. package/adapters/tokens.ts +21 -0
  8. package/adapters/tool-map.json +7 -0
  9. package/bin/omd-install.ts +76 -0
  10. package/bin/omd.ts +1774 -0
  11. package/core/asset-sourcing/index.ts +187 -0
  12. package/core/coach/index.ts +98 -0
  13. package/core/composition/app-shell-workbench.md +168 -0
  14. package/core/composition/asymmetric-diagonal-grid.md +178 -0
  15. package/core/composition/bento-grid.md +223 -0
  16. package/core/composition/editorial-index-labels.md +173 -0
  17. package/core/composition/form-wizard-stepper.md +132 -0
  18. package/core/composition/master-detail-flow.md +129 -0
  19. package/core/composition/section-inversion.md +182 -0
  20. package/core/composition/sidebar-margin-annotation.md +184 -0
  21. package/core/composition/split-screen-hero.md +224 -0
  22. package/core/composition/sticky-sidebar-scroll.md +219 -0
  23. package/core/composition/typographic-hero.md +100 -0
  24. package/core/composition-contract/index.ts +233 -0
  25. package/core/composition-contract/visual-richness.ts +101 -0
  26. package/core/config/index.ts +27 -0
  27. package/core/copy/index.ts +296 -0
  28. package/core/craft/finish-pass.md +356 -0
  29. package/core/craft/index.ts +33 -0
  30. package/core/design/index.ts +581 -0
  31. package/core/design/interaction-states.ts +189 -0
  32. package/core/eval-harness/index.ts +225 -0
  33. package/core/evidence/final.ts +444 -0
  34. package/core/evidence/task.ts +459 -0
  35. package/core/figma/client.ts +312 -0
  36. package/core/figma/diff.ts +261 -0
  37. package/core/figma/responsive.ts +310 -0
  38. package/core/figma/system.ts +466 -0
  39. package/core/figma/types.ts +185 -0
  40. package/core/frame/check-ux.ts +180 -0
  41. package/core/frame/index.ts +39 -0
  42. package/core/frame/write.ts +168 -0
  43. package/core/graphics/css-illustration-primitives.md +189 -0
  44. package/core/graphics/duotone-image-presets.md +173 -0
  45. package/core/graphics/gradient-mesh.md +178 -0
  46. package/core/graphics/noise-grain-texture.md +150 -0
  47. package/core/graphics/placeholder-policy.md +185 -0
  48. package/core/graphics/svg-geometric-patterns.md +145 -0
  49. package/core/history/index.ts +39 -0
  50. package/core/install/detect.ts +32 -0
  51. package/core/install/install.ts +321 -0
  52. package/core/install/patch-claude.ts +109 -0
  53. package/core/install/patch-codex.ts +82 -0
  54. package/core/interaction/index.ts +101 -0
  55. package/core/interaction/recipes/signature-lighting.md +203 -0
  56. package/core/ir/dom.ts +352 -0
  57. package/core/ir/normalize.ts +177 -0
  58. package/core/motion/easing.md +151 -0
  59. package/core/motion/energy.ts +188 -0
  60. package/core/motion/recipes/image-hover-distortion.md +198 -0
  61. package/core/motion/recipes/magnetic-hover.md +229 -0
  62. package/core/motion/recipes/marquee.md +202 -0
  63. package/core/motion/recipes/number-counter.md +242 -0
  64. package/core/motion/recipes/page-loader.md +240 -0
  65. package/core/motion/recipes/parallax.md +233 -0
  66. package/core/motion/recipes/scroll-reveal.md +210 -0
  67. package/core/motion/recipes/section-color-inversion.md +217 -0
  68. package/core/motion/recipes/split-text-entrance.md +222 -0
  69. package/core/motion/recipes/stagger-orchestrator.md +227 -0
  70. package/core/motion/recipes/sticky-scene-transition.md +246 -0
  71. package/core/motion/recipes/view-transitions.md +239 -0
  72. package/core/probe/index.ts +186 -0
  73. package/core/probe/schema.json +46 -0
  74. package/core/protocol/composition-contract.md +192 -0
  75. package/core/protocol/copy-deck.md +96 -0
  76. package/core/protocol/human-design-loop.md +447 -0
  77. package/core/protocol/slop-review.md +71 -0
  78. package/core/ref/batch.ts +94 -0
  79. package/core/ref/blueprint.ts +199 -0
  80. package/core/ref/distance.ts +225 -0
  81. package/core/ref/invariants.ts +162 -0
  82. package/core/ref/signal.ts +37 -0
  83. package/core/ref/store.ts +122 -0
  84. package/core/render/index.ts +607 -0
  85. package/core/rules/attribution.ts +159 -0
  86. package/core/rules/builtin/contrast.yaml +8 -0
  87. package/core/rules/builtin/focus.yaml +8 -0
  88. package/core/rules/builtin/hit-area.yaml +8 -0
  89. package/core/rules/builtin/ko.yaml +6 -0
  90. package/core/rules/builtin/motion.yaml +80 -0
  91. package/core/rules/builtin/slop.yaml +365 -0
  92. package/core/rules/builtin/spacing.yaml +9 -0
  93. package/core/rules/builtin/system.yaml +42 -0
  94. package/core/rules/builtin/token.yaml +8 -0
  95. package/core/rules/builtin/ux.yaml +125 -0
  96. package/core/rules/engine.ts +88 -0
  97. package/core/rules/leakage.ts +72 -0
  98. package/core/rules/motion-spec.ts +204 -0
  99. package/core/site/index.ts +100 -0
  100. package/core/slop/index.ts +427 -0
  101. package/core/slop/text-slop.ts +220 -0
  102. package/core/source-seal/index.ts +174 -0
  103. package/core/stack/index.ts +53 -0
  104. package/core/target/index.ts +183 -0
  105. package/core/theory/color.md +285 -0
  106. package/core/theory/components.md +272 -0
  107. package/core/theory/craft.md +270 -0
  108. package/core/theory/expressive.md +213 -0
  109. package/core/theory/imagegen.md +105 -0
  110. package/core/theory/layout.md +159 -0
  111. package/core/theory/motion.md +283 -0
  112. package/core/theory/typography.md +134 -0
  113. package/core/theory/ux.md +580 -0
  114. package/core/theory/voice.md +595 -0
  115. package/core/types.ts +684 -0
  116. package/package.json +40 -0
  117. package/src/agents/composer.agent.yaml +174 -0
  118. package/src/agents/eye.agent.yaml +208 -0
  119. package/src/agents/framer.agent.yaml +52 -0
  120. package/src/agents/glance.agent.yaml +19 -0
  121. package/src/agents/hand.agent.yaml +200 -0
  122. package/src/agents/scout.agent.yaml +71 -0
  123. package/src/agents/sketch.agent.yaml +64 -0
  124. package/src/agents/typesetter.agent.yaml +52 -0
  125. package/src/agents/writer.agent.yaml +63 -0
  126. package/src/skills/omd-coach/SKILL.md +40 -0
  127. package/src/skills/omd-critique/SKILL.md +77 -0
  128. package/src/skills/omd-figma/SKILL.md +256 -0
  129. package/src/skills/omd-humanize/SKILL.md +160 -0
  130. package/src/skills/omd-scout/SKILL.md +77 -0
  131. package/src/skills/omd-ultradesign/SKILL.md +521 -0
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Figma REST API client — F1 implementation.
3
+ *
4
+ * Two layers, kept strictly separate for testability:
5
+ * Pure layer — parseFileKey, figmaColorToHex, normalizeNode, normalizeFile
6
+ * Fetch layer — fetchAndNormalize (uses global fetch, Node 22+)
7
+ *
8
+ * Endpoints used:
9
+ * GET /v1/files/:key?depth=2 enumerate pages + top-level frames
10
+ * GET /v1/files/:key/nodes?ids=... per-frame full subtrees (batched ≤50)
11
+ */
12
+ import type {
13
+ FigmaFileResponse,
14
+ FigmaNodesResponse,
15
+ FigmaNode,
16
+ FigmaPaint,
17
+ FigmaEffect,
18
+ SnapshotNode,
19
+ SnapshotFill,
20
+ SnapshotEffect,
21
+ SnapshotPage,
22
+ FigmaSnapshot,
23
+ } from './types.ts';
24
+
25
+ // ── URL parsing ───────────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Extract the Figma file key from any figma.com URL.
29
+ *
30
+ * Handles:
31
+ * https://www.figma.com/file/ABCDEF123/My-Design
32
+ * https://www.figma.com/design/ABCDEF123/My-Design?node-id=1%3A2
33
+ * figma.com/file/ABCDEF123
34
+ *
35
+ * Throws if the URL does not contain a recognisable /file/:key or /design/:key
36
+ * path segment.
37
+ */
38
+ export function parseFileKey(url: string): string {
39
+ let href = url.trim();
40
+ if (!href.startsWith('http://') && !href.startsWith('https://')) {
41
+ href = `https://${href}`;
42
+ }
43
+
44
+ let pathname: string;
45
+ try {
46
+ pathname = new URL(href).pathname;
47
+ } catch {
48
+ throw new Error(`Invalid Figma URL: ${url}`);
49
+ }
50
+
51
+ const match = pathname.match(/\/(file|design)\/([A-Za-z0-9_-]+)/);
52
+ if (!match || !match[2]) {
53
+ throw new Error(`Could not extract file key from Figma URL: ${url}`);
54
+ }
55
+ return match[2];
56
+ }
57
+
58
+ // ── Color utilities ───────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Convert a Figma RGBA color (channel values in [0, 1]) to a #RRGGBB hex string.
62
+ * Alpha is dropped — fills use opacity at the paint level.
63
+ */
64
+ export function figmaColorToHex(c: { r: number; g: number; b: number }): string {
65
+ const r = Math.round(c.r * 255).toString(16).padStart(2, '0');
66
+ const g = Math.round(c.g * 255).toString(16).padStart(2, '0');
67
+ const b = Math.round(c.b * 255).toString(16).padStart(2, '0');
68
+ return `#${r}${g}${b}`;
69
+ }
70
+
71
+ // ── Node normalization (pure) ─────────────────────────────────────────────────
72
+
73
+ function normalizeFill(paint: FigmaPaint): SnapshotFill | null {
74
+ if (paint.visible === false) return null;
75
+ if (paint.type === 'SOLID' && paint.color) {
76
+ return { type: 'solid', hex: figmaColorToHex(paint.color) };
77
+ }
78
+ if (paint.type.startsWith('GRADIENT') && paint.gradientStops) {
79
+ const stops = paint.gradientStops
80
+ .map((s) => `${figmaColorToHex(s.color)} ${Math.round(s.position * 100)}%`)
81
+ .join(', ');
82
+ const gradType = paint.type === 'GRADIENT_RADIAL' ? 'radial-gradient' : 'linear-gradient';
83
+ return { type: paint.type.toLowerCase(), gradient: `${gradType}(${stops})` };
84
+ }
85
+ return { type: paint.type.toLowerCase() };
86
+ }
87
+
88
+ function normalizeEffect(effect: FigmaEffect): SnapshotEffect | null {
89
+ if (effect.visible === false) return null;
90
+ const base: SnapshotEffect = { type: effect.type.toLowerCase() };
91
+ if (effect.color !== undefined) base.color = figmaColorToHex(effect.color);
92
+ if (effect.offset !== undefined) {
93
+ base.offsetX = effect.offset.x;
94
+ base.offsetY = effect.offset.y;
95
+ }
96
+ if (effect.radius !== undefined) base.radius = effect.radius;
97
+ return base;
98
+ }
99
+
100
+ /**
101
+ * Normalize a single Figma API node into a SnapshotNode.
102
+ * Pure — no I/O.
103
+ *
104
+ * @param node Raw node from the Figma API.
105
+ * @param compSetLookup Map of componentNodeId → componentSetId, built from
106
+ * the file-level `components` map. Pass an empty object
107
+ * when not available.
108
+ */
109
+ export function normalizeNode(
110
+ node: FigmaNode,
111
+ compSetLookup: Readonly<Record<string, string>> = {},
112
+ ): SnapshotNode {
113
+ const fills: SnapshotFill[] = (node.fills ?? [])
114
+ .map(normalizeFill)
115
+ .filter((f): f is SnapshotFill => f !== null);
116
+
117
+ const effects: SnapshotEffect[] = (node.effects ?? [])
118
+ .map(normalizeEffect)
119
+ .filter((e): e is SnapshotEffect => e !== null);
120
+
121
+ const result: SnapshotNode = {
122
+ id: node.id,
123
+ name: node.name,
124
+ type: node.type,
125
+ fills,
126
+ effects,
127
+ };
128
+
129
+ if (node.absoluteBoundingBox !== undefined) {
130
+ result.absoluteBoundingBox = node.absoluteBoundingBox;
131
+ }
132
+ if (node.cornerRadius !== undefined) result.cornerRadius = node.cornerRadius;
133
+
134
+ // Typography (TEXT nodes carry a `style` object)
135
+ if (node.style?.fontFamily) {
136
+ const s = node.style;
137
+ let lineHeight = 1.2;
138
+ if (s.lineHeightPx !== undefined && s.fontSize !== undefined && s.fontSize > 0) {
139
+ lineHeight = Math.round((s.lineHeightPx / s.fontSize) * 100) / 100;
140
+ }
141
+ result.typography = {
142
+ fontFamily: s.fontFamily!,
143
+ fontSize: s.fontSize ?? 16,
144
+ fontWeight: s.fontWeight ?? 400,
145
+ lineHeight,
146
+ };
147
+ }
148
+
149
+ // Auto-layout
150
+ if (node.layoutMode !== undefined && node.layoutMode !== 'NONE') {
151
+ result.layoutMode = node.layoutMode as 'HORIZONTAL' | 'VERTICAL';
152
+ if (node.itemSpacing !== undefined) result.itemSpacing = node.itemSpacing;
153
+ result.padding = [
154
+ node.paddingTop ?? 0,
155
+ node.paddingRight ?? 0,
156
+ node.paddingBottom ?? 0,
157
+ node.paddingLeft ?? 0,
158
+ ];
159
+ }
160
+
161
+ // Component set membership
162
+ const setId = compSetLookup[node.id];
163
+ if (setId !== undefined) result.componentSetId = setId;
164
+
165
+ return result;
166
+ }
167
+
168
+ /**
169
+ * Recursively walk a frame node and collect it plus all descendants as
170
+ * SnapshotNodes. Including the root itself ensures the frame's own fills,
171
+ * effects, and layout properties are available for token extraction.
172
+ */
173
+ function collectNodes(
174
+ node: FigmaNode,
175
+ compSetLookup: Readonly<Record<string, string>>,
176
+ ): SnapshotNode[] {
177
+ const out: SnapshotNode[] = [];
178
+ function walk(n: FigmaNode): void {
179
+ out.push(normalizeNode(n, compSetLookup));
180
+ for (const child of n.children ?? []) walk(child);
181
+ }
182
+ walk(node);
183
+ return out;
184
+ }
185
+
186
+ /**
187
+ * Normalize a full Figma file API response into a FigmaSnapshot.
188
+ * Pure — no I/O.
189
+ *
190
+ * @param fileKey The Figma file key.
191
+ * @param response Full or depth-limited file response.
192
+ * @param extraNodes Map of frame/node IDs to their fully-fetched FigmaNode,
193
+ * used to replace the shallow stubs in `response`. Optional.
194
+ */
195
+ export function normalizeFile(
196
+ fileKey: string,
197
+ response: FigmaFileResponse,
198
+ extraNodes: Readonly<Record<string, FigmaNode>> = {},
199
+ ): FigmaSnapshot {
200
+ // Build compSetLookup: componentNodeId → componentSetId
201
+ const compSetLookup: Record<string, string> = {};
202
+ for (const [id, comp] of Object.entries(response.components ?? {})) {
203
+ if (comp.componentSetId !== undefined) compSetLookup[id] = comp.componentSetId;
204
+ }
205
+
206
+ // Build componentSets index (seed from file-level metadata)
207
+ const componentSets: FigmaSnapshot['componentSets'] = {};
208
+ for (const [id, cs] of Object.entries(response.componentSets ?? {})) {
209
+ componentSets[id] = { id, name: cs.name, variants: [] };
210
+ }
211
+ // Populate variants from the components map
212
+ for (const [id, comp] of Object.entries(response.components ?? {})) {
213
+ if (comp.componentSetId !== undefined) {
214
+ const entry = componentSets[comp.componentSetId];
215
+ if (entry !== undefined) {
216
+ entry.variants.push({ componentId: id, name: comp.name });
217
+ }
218
+ }
219
+ }
220
+
221
+ // Walk document: DOCUMENT → CANVAS (page) → top-level frames
222
+ const pages: SnapshotPage[] = [];
223
+ for (const page of response.document.children ?? []) {
224
+ if (page.type !== 'CANVAS') continue;
225
+ const frames = [];
226
+ for (const child of page.children ?? []) {
227
+ const frameTypes = new Set(['FRAME', 'COMPONENT', 'COMPONENT_SET', 'GROUP', 'SECTION']);
228
+ if (!frameTypes.has(child.type)) continue;
229
+ // Prefer the full subtree from extraNodes if available
230
+ const nodeData = extraNodes[child.id] ?? child;
231
+ frames.push({
232
+ id: child.id,
233
+ name: nodeData.name,
234
+ nodes: collectNodes(nodeData, compSetLookup),
235
+ });
236
+ }
237
+ pages.push({ id: page.id, name: page.name, frames });
238
+ }
239
+
240
+ return {
241
+ fileKey,
242
+ fileName: response.name,
243
+ capturedAt: new Date().toISOString(),
244
+ pages,
245
+ componentSets,
246
+ };
247
+ }
248
+
249
+ // ── Fetch layer ───────────────────────────────────────────────────────────────
250
+
251
+ const FIGMA_BASE = 'https://api.figma.com';
252
+
253
+ async function figmaFetch(path: string, token: string): Promise<unknown> {
254
+ const url = `${FIGMA_BASE}${path}`;
255
+ let res = await fetch(url, { headers: { 'X-Figma-Token': token } });
256
+
257
+ // Respect rate-limit: retry once after Retry-After seconds
258
+ if (res.status === 429) {
259
+ const retryAfter = parseInt(res.headers.get('Retry-After') ?? '10', 10);
260
+ await new Promise<void>((resolve) => setTimeout(resolve, (retryAfter || 10) * 1000));
261
+ res = await fetch(url, { headers: { 'X-Figma-Token': token } });
262
+ }
263
+
264
+ if (!res.ok) {
265
+ const body = await res.text().catch(() => '');
266
+ throw new Error(`Figma API ${res.status} for ${path}: ${body.slice(0, 300)}`);
267
+ }
268
+
269
+ return res.json();
270
+ }
271
+
272
+ /**
273
+ * Fetch a Figma file and return a normalized snapshot.
274
+ *
275
+ * Strategy:
276
+ * 1. GET /v1/files/:key?depth=2 — get pages + top-level frame list without
277
+ * full subtrees (avoids huge payloads on large files).
278
+ * 2. GET /v1/files/:key/nodes?ids=... — fetch full subtrees per frame,
279
+ * in batches of ≤50 IDs.
280
+ * Then normalizeFile merges both into a FigmaSnapshot.
281
+ */
282
+ export async function fetchAndNormalize(fileKey: string, token: string): Promise<FigmaSnapshot> {
283
+ // Step 1: shallow fetch to enumerate pages and frame IDs
284
+ const shallow = (await figmaFetch(`/v1/files/${fileKey}?depth=2`, token)) as FigmaFileResponse;
285
+
286
+ // Collect top-level frame IDs from all pages
287
+ const frameIds: string[] = [];
288
+ const frameTypes = new Set(['FRAME', 'COMPONENT', 'COMPONENT_SET', 'GROUP', 'SECTION']);
289
+ for (const page of shallow.document.children ?? []) {
290
+ if (page.type !== 'CANVAS') continue;
291
+ for (const child of page.children ?? []) {
292
+ if (frameTypes.has(child.type)) frameIds.push(child.id);
293
+ }
294
+ }
295
+
296
+ // Step 2: fetch full subtrees in batches of ≤50
297
+ const extraNodes: Record<string, FigmaNode> = {};
298
+ const BATCH = 50;
299
+ for (let i = 0; i < frameIds.length; i += BATCH) {
300
+ const batch = frameIds.slice(i, i + BATCH);
301
+ const ids = batch.map(encodeURIComponent).join(',');
302
+ const nodesRes = (await figmaFetch(
303
+ `/v1/files/${fileKey}/nodes?ids=${ids}`,
304
+ token,
305
+ )) as FigmaNodesResponse;
306
+ for (const [id, data] of Object.entries(nodesRes.nodes)) {
307
+ if (data?.document !== undefined) extraNodes[id] = data.document;
308
+ }
309
+ }
310
+
311
+ return normalizeFile(fileKey, shallow, extraNodes);
312
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Pixel fidelity comparison for `omd figma diff` (F3).
3
+ *
4
+ * ── ANTIALIASING / FONT-RENDERING HONESTY ────────────────────────────────────
5
+ *
6
+ * A 100 % pixel match between a Figma export and a browser render is not
7
+ * achievable in practice. Three sources guarantee divergence even on a correct
8
+ * implementation:
9
+ *
10
+ * 1. Font rasterization — Figma uses its own renderer; browsers use
11
+ * FreeType / Skia / Core Text depending on platform. Sub-pixel positions
12
+ * diverge by ±1–5 channel counts even for an identical font and size.
13
+ * 2. Antialiasing — shape and curve edges are blended differently; pixels
14
+ * along edges typically differ by 1–15 counts per channel.
15
+ * 3. Color profile — Figma exports sRGB; browsers may apply display-P3 or a
16
+ * system profile transform, producing small global shifts.
17
+ *
18
+ * ── TOLERANCE DESIGN ─────────────────────────────────────────────────────────
19
+ *
20
+ * Per-channel delta ≤ 12 counts as "same pixel" (scale 0–255, i.e. ≈ 4.7 %).
21
+ *
22
+ * Rationale: empirical testing on correct browser↔Figma pairs shows that
23
+ * antialiasing-edge pixels diverge by 4–12 counts per channel. Real design
24
+ * errors — missing element, wrong color, wrong size — produce deltas of 30–255.
25
+ * The 12-count band keeps false positives below ~1 % of all pixels on typical
26
+ * design-system pages while reliably flagging layout and color regressions.
27
+ *
28
+ * The default similarity threshold (0.97 = 97 % similar pixels) gives 3 %
29
+ * headroom for platform-level AA + font divergence on a correctly implemented
30
+ * page. Use `--threshold` to tighten for pixel-accurate icon sections or loosen
31
+ * for text-heavy pages.
32
+ *
33
+ * ── JSON CONTRACT ─────────────────────────────────────────────────────────────
34
+ *
35
+ * `compareImages` returns a `DiffResult` whose shape is the stable contract for
36
+ * `--json` output and loop drivers:
37
+ *
38
+ * {
39
+ * score: number, // 0–1, fraction of "same" pixels
40
+ * threshold: number, // value used for pass/fail
41
+ * pass: boolean,
42
+ * cells: [ // 6×6 grid, row-major
43
+ * { row, col, x, y, width, height, mismatch } // mismatch 0–1
44
+ * ],
45
+ * dimMismatch?: { // only present when dimensions differ
46
+ * refWidth, refHeight, buildWidth, buildHeight,
47
+ * overlapWidth, overlapHeight
48
+ * }
49
+ * }
50
+ */
51
+
52
+ import { decodePng } from '../motion/energy.ts';
53
+
54
+ // ── Public types ──────────────────────────────────────────────────────────────
55
+
56
+ /** One cell in the diff grid. Coordinates are in reference-image pixel space. */
57
+ export interface DiffCell {
58
+ row: number;
59
+ col: number;
60
+ /** Left edge (pixels, reference coordinate space). */
61
+ x: number;
62
+ /** Top edge (pixels). */
63
+ y: number;
64
+ width: number;
65
+ height: number;
66
+ /** Fraction of pixels in this cell beyond the per-channel tolerance. 0–1. */
67
+ mismatch: number;
68
+ }
69
+
70
+ /** Stable JSON contract for `--json` output and loop drivers. */
71
+ export interface DiffResult {
72
+ /** Fraction of pixels that are "same" (1 = identical, 0 = completely different). */
73
+ score: number;
74
+ /** The threshold used for the pass/fail decision (0–1). */
75
+ threshold: number;
76
+ /** true when score >= threshold. */
77
+ pass: boolean;
78
+ /** Per-cell mismatch fractions, 6×6 grid in row-major order. */
79
+ cells: DiffCell[];
80
+ /** Present only when reference and build have different dimensions. */
81
+ dimMismatch?: {
82
+ refWidth: number;
83
+ refHeight: number;
84
+ buildWidth: number;
85
+ buildHeight: number;
86
+ /** Dimensions of the region actually compared (overlap). */
87
+ overlapWidth: number;
88
+ overlapHeight: number;
89
+ };
90
+ }
91
+
92
+ // ── Constants ─────────────────────────────────────────────────────────────────
93
+
94
+ /** Per-channel delta at or below this value counts as "same pixel". See module doc. */
95
+ export const CHANNEL_TOLERANCE = 12;
96
+
97
+ /** Number of columns in the diff grid. */
98
+ export const GRID_COLS = 6;
99
+
100
+ /** Number of rows in the diff grid. */
101
+ export const GRID_ROWS = 6;
102
+
103
+ // ── Core comparison logic (pure, unit-tested) ─────────────────────────────────
104
+
105
+ const round4 = (v: number): number => Math.round(v * 10000) / 10000;
106
+
107
+ /**
108
+ * Compare two PNG buffers pixel-by-pixel and return a DiffResult.
109
+ *
110
+ * Pure and deterministic — given the same PNG buffers, threshold, and tolerance
111
+ * it always returns the same result. Safe to call in unit tests with synthetic
112
+ * buffers (see test/figma-diff.test.ts for examples using makePng helpers that
113
+ * mirror test/motion-energy.test.ts).
114
+ *
115
+ * Reuses `decodePng` from core/motion/energy.ts — no second PNG decoder.
116
+ *
117
+ * When the two images have different dimensions, the overlapping top-left region
118
+ * is compared and a `dimMismatch` descriptor is included in the result. This is
119
+ * treated as a comparison warning, not an error, so the score is still meaningful
120
+ * (comparing whatever pixels are present in both).
121
+ *
122
+ * @param refBuf Reference PNG (Figma export / "expected").
123
+ * @param buildBuf Build render PNG ("actual").
124
+ * @param threshold Pass/fail threshold (0–1, default 0.97).
125
+ * @param tolerance Per-channel delta considered "same" (0–255, default 12).
126
+ */
127
+ export function compareImages(
128
+ refBuf: Buffer,
129
+ buildBuf: Buffer,
130
+ threshold = 0.97,
131
+ tolerance = CHANNEL_TOLERANCE,
132
+ ): DiffResult {
133
+ const ref = decodePng(refBuf);
134
+ const build = decodePng(buildBuf);
135
+
136
+ // Comparison region: overlap of both images
137
+ const compareW = Math.min(ref.width, build.width);
138
+ const compareH = Math.min(ref.height, build.height);
139
+
140
+ const dimMismatch =
141
+ ref.width !== build.width || ref.height !== build.height
142
+ ? {
143
+ refWidth: ref.width,
144
+ refHeight: ref.height,
145
+ buildWidth: build.width,
146
+ buildHeight: build.height,
147
+ overlapWidth: compareW,
148
+ overlapHeight: compareH,
149
+ }
150
+ : undefined;
151
+
152
+ // Cell grid over the comparison region.
153
+ // cellW/cellH define nominal cell size; the last column/row absorbs remainder.
154
+ const cellW = Math.max(1, Math.floor(compareW / GRID_COLS));
155
+ const cellH = Math.max(1, Math.floor(compareH / GRID_ROWS));
156
+
157
+ const cellCount = GRID_ROWS * GRID_COLS;
158
+ const cellTotal = new Array<number>(cellCount).fill(0);
159
+ const cellChanged = new Array<number>(cellCount).fill(0);
160
+
161
+ let totalPixels = 0;
162
+ let totalSame = 0;
163
+
164
+ for (let y = 0; y < compareH; y++) {
165
+ for (let x = 0; x < compareW; x++) {
166
+ const ri = (y * ref.width + x) * ref.channels;
167
+ const bi = (y * build.width + x) * build.channels;
168
+
169
+ // Max per-channel RGB delta; alpha channel (index 3) is ignored.
170
+ const rgbCh = Math.min(3, ref.channels, build.channels);
171
+ let maxDiff = 0;
172
+ for (let ch = 0; ch < rgbCh; ch++) {
173
+ const diff = Math.abs((ref.pixels[ri + ch]! & 0xff) - (build.pixels[bi + ch]! & 0xff));
174
+ if (diff > maxDiff) maxDiff = diff;
175
+ }
176
+
177
+ const same = maxDiff <= tolerance;
178
+ if (same) totalSame++;
179
+ totalPixels++;
180
+
181
+ // Clamp to last cell so remainder pixels stay inside the grid boundary.
182
+ const col = Math.min(Math.floor(x / cellW), GRID_COLS - 1);
183
+ const row = Math.min(Math.floor(y / cellH), GRID_ROWS - 1);
184
+ const ci = row * GRID_COLS + col;
185
+ cellTotal[ci]!++;
186
+ if (!same) cellChanged[ci]!++;
187
+ }
188
+ }
189
+
190
+ const score = totalPixels === 0 ? 1 : round4(totalSame / totalPixels);
191
+
192
+ const cells: DiffCell[] = [];
193
+ for (let row = 0; row < GRID_ROWS; row++) {
194
+ for (let col = 0; col < GRID_COLS; col++) {
195
+ const ci = row * GRID_COLS + col;
196
+ const total = cellTotal[ci]!;
197
+ const changed = cellChanged[ci]!;
198
+ const mismatch = total === 0 ? 0 : round4(changed / total);
199
+
200
+ // Compute cell bounds, letting the last row/col absorb the remainder.
201
+ const cellX = col * cellW;
202
+ const cellY = row * cellH;
203
+ const width = col === GRID_COLS - 1 ? compareW - cellX : cellW;
204
+ const height = row === GRID_ROWS - 1 ? compareH - cellY : cellH;
205
+
206
+ cells.push({ row, col, x: cellX, y: cellY, width, height, mismatch });
207
+ }
208
+ }
209
+
210
+ return {
211
+ score,
212
+ threshold,
213
+ pass: score >= threshold,
214
+ cells,
215
+ ...(dimMismatch !== undefined ? { dimMismatch } : {}),
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Format a DiffResult as a human-readable report.
221
+ *
222
+ * Lists cells with mismatch > 5 %, sorted worst-first, annotated with
223
+ * page-coordinate ranges so the implementer knows WHERE to look.
224
+ * All coordinate ranges are in reference-image pixel space.
225
+ */
226
+ export function formatDiffReport(result: DiffResult): string {
227
+ const lines: string[] = [];
228
+ const pct = (v: number): string => `${(v * 100).toFixed(1)}%`;
229
+
230
+ lines.push(
231
+ `similarity: ${pct(result.score)} threshold: ${pct(result.threshold)} ${result.pass ? 'PASS' : 'FAIL'}`,
232
+ );
233
+
234
+ if (result.dimMismatch !== undefined) {
235
+ const d = result.dimMismatch;
236
+ lines.push(
237
+ `dimension mismatch: reference ${d.refWidth}×${d.refHeight}`
238
+ + ` build ${d.buildWidth}×${d.buildHeight}`
239
+ + ` comparing overlap ${d.overlapWidth}×${d.overlapHeight}`,
240
+ );
241
+ }
242
+
243
+ const BAD_THRESHOLD = 0.05;
244
+ const badCells = result.cells.filter((c) => c.mismatch > BAD_THRESHOLD);
245
+
246
+ if (badCells.length === 0) {
247
+ lines.push('no cells above 5% mismatch');
248
+ return lines.join('\n');
249
+ }
250
+
251
+ badCells.sort((a, b) => b.mismatch - a.mismatch);
252
+ lines.push(`\nbad cells (>${pct(BAD_THRESHOLD)} mismatch):`);
253
+
254
+ for (const c of badCells) {
255
+ const xEnd = c.x + c.width;
256
+ const yEnd = c.y + c.height;
257
+ lines.push(` r${c.row}c${c.col} x ${c.x}–${xEnd} y ${c.y}–${yEnd} ${pct(c.mismatch)} mismatch`);
258
+ }
259
+
260
+ return lines.join('\n');
261
+ }