@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.
- package/LICENSE +21 -0
- package/README.ko.md +282 -0
- package/README.md +282 -0
- package/adapters/build.ts +184 -0
- package/adapters/claude.ts +111 -0
- package/adapters/codex.ts +68 -0
- package/adapters/tokens.ts +21 -0
- package/adapters/tool-map.json +7 -0
- package/bin/omd-install.ts +76 -0
- package/bin/omd.ts +1774 -0
- package/core/asset-sourcing/index.ts +187 -0
- package/core/coach/index.ts +98 -0
- package/core/composition/app-shell-workbench.md +168 -0
- package/core/composition/asymmetric-diagonal-grid.md +178 -0
- package/core/composition/bento-grid.md +223 -0
- package/core/composition/editorial-index-labels.md +173 -0
- package/core/composition/form-wizard-stepper.md +132 -0
- package/core/composition/master-detail-flow.md +129 -0
- package/core/composition/section-inversion.md +182 -0
- package/core/composition/sidebar-margin-annotation.md +184 -0
- package/core/composition/split-screen-hero.md +224 -0
- package/core/composition/sticky-sidebar-scroll.md +219 -0
- package/core/composition/typographic-hero.md +100 -0
- package/core/composition-contract/index.ts +233 -0
- package/core/composition-contract/visual-richness.ts +101 -0
- package/core/config/index.ts +27 -0
- package/core/copy/index.ts +296 -0
- package/core/craft/finish-pass.md +356 -0
- package/core/craft/index.ts +33 -0
- package/core/design/index.ts +581 -0
- package/core/design/interaction-states.ts +189 -0
- package/core/eval-harness/index.ts +225 -0
- package/core/evidence/final.ts +444 -0
- package/core/evidence/task.ts +459 -0
- package/core/figma/client.ts +312 -0
- package/core/figma/diff.ts +261 -0
- package/core/figma/responsive.ts +310 -0
- package/core/figma/system.ts +466 -0
- package/core/figma/types.ts +185 -0
- package/core/frame/check-ux.ts +180 -0
- package/core/frame/index.ts +39 -0
- package/core/frame/write.ts +168 -0
- package/core/graphics/css-illustration-primitives.md +189 -0
- package/core/graphics/duotone-image-presets.md +173 -0
- package/core/graphics/gradient-mesh.md +178 -0
- package/core/graphics/noise-grain-texture.md +150 -0
- package/core/graphics/placeholder-policy.md +185 -0
- package/core/graphics/svg-geometric-patterns.md +145 -0
- package/core/history/index.ts +39 -0
- package/core/install/detect.ts +32 -0
- package/core/install/install.ts +321 -0
- package/core/install/patch-claude.ts +109 -0
- package/core/install/patch-codex.ts +82 -0
- package/core/interaction/index.ts +101 -0
- package/core/interaction/recipes/signature-lighting.md +203 -0
- package/core/ir/dom.ts +352 -0
- package/core/ir/normalize.ts +177 -0
- package/core/motion/easing.md +151 -0
- package/core/motion/energy.ts +188 -0
- package/core/motion/recipes/image-hover-distortion.md +198 -0
- package/core/motion/recipes/magnetic-hover.md +229 -0
- package/core/motion/recipes/marquee.md +202 -0
- package/core/motion/recipes/number-counter.md +242 -0
- package/core/motion/recipes/page-loader.md +240 -0
- package/core/motion/recipes/parallax.md +233 -0
- package/core/motion/recipes/scroll-reveal.md +210 -0
- package/core/motion/recipes/section-color-inversion.md +217 -0
- package/core/motion/recipes/split-text-entrance.md +222 -0
- package/core/motion/recipes/stagger-orchestrator.md +227 -0
- package/core/motion/recipes/sticky-scene-transition.md +246 -0
- package/core/motion/recipes/view-transitions.md +239 -0
- package/core/probe/index.ts +186 -0
- package/core/probe/schema.json +46 -0
- package/core/protocol/composition-contract.md +192 -0
- package/core/protocol/copy-deck.md +96 -0
- package/core/protocol/human-design-loop.md +447 -0
- package/core/protocol/slop-review.md +71 -0
- package/core/ref/batch.ts +94 -0
- package/core/ref/blueprint.ts +199 -0
- package/core/ref/distance.ts +225 -0
- package/core/ref/invariants.ts +162 -0
- package/core/ref/signal.ts +37 -0
- package/core/ref/store.ts +122 -0
- package/core/render/index.ts +607 -0
- package/core/rules/attribution.ts +159 -0
- package/core/rules/builtin/contrast.yaml +8 -0
- package/core/rules/builtin/focus.yaml +8 -0
- package/core/rules/builtin/hit-area.yaml +8 -0
- package/core/rules/builtin/ko.yaml +6 -0
- package/core/rules/builtin/motion.yaml +80 -0
- package/core/rules/builtin/slop.yaml +365 -0
- package/core/rules/builtin/spacing.yaml +9 -0
- package/core/rules/builtin/system.yaml +42 -0
- package/core/rules/builtin/token.yaml +8 -0
- package/core/rules/builtin/ux.yaml +125 -0
- package/core/rules/engine.ts +88 -0
- package/core/rules/leakage.ts +72 -0
- package/core/rules/motion-spec.ts +204 -0
- package/core/site/index.ts +100 -0
- package/core/slop/index.ts +427 -0
- package/core/slop/text-slop.ts +220 -0
- package/core/source-seal/index.ts +174 -0
- package/core/stack/index.ts +53 -0
- package/core/target/index.ts +183 -0
- package/core/theory/color.md +285 -0
- package/core/theory/components.md +272 -0
- package/core/theory/craft.md +270 -0
- package/core/theory/expressive.md +213 -0
- package/core/theory/imagegen.md +105 -0
- package/core/theory/layout.md +159 -0
- package/core/theory/motion.md +283 -0
- package/core/theory/typography.md +134 -0
- package/core/theory/ux.md +580 -0
- package/core/theory/voice.md +595 -0
- package/core/types.ts +684 -0
- package/package.json +40 -0
- package/src/agents/composer.agent.yaml +174 -0
- package/src/agents/eye.agent.yaml +208 -0
- package/src/agents/framer.agent.yaml +52 -0
- package/src/agents/glance.agent.yaml +19 -0
- package/src/agents/hand.agent.yaml +200 -0
- package/src/agents/scout.agent.yaml +71 -0
- package/src/agents/sketch.agent.yaml +64 -0
- package/src/agents/typesetter.agent.yaml +52 -0
- package/src/agents/writer.agent.yaml +63 -0
- package/src/skills/omd-coach/SKILL.md +40 -0
- package/src/skills/omd-critique/SKILL.md +77 -0
- package/src/skills/omd-figma/SKILL.md +256 -0
- package/src/skills/omd-humanize/SKILL.md +160 -0
- package/src/skills/omd-scout/SKILL.md +77 -0
- package/src/skills/omd-ultradesign/SKILL.md +521 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const SOURCE_SEAL_SCHEMA_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
8
|
+
'.astro', '.cjs', '.css', '.gif', '.html', '.ico', '.jpeg', '.jpg', '.js', '.json',
|
|
9
|
+
'.jsx', '.less', '.mjs', '.otf', '.png', '.sass', '.scss', '.svg', '.svelte', '.ts',
|
|
10
|
+
'.tsx', '.ttf', '.vue', '.wasm', '.webp', '.woff', '.woff2',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const EXCLUDED_DIRECTORIES = new Set([
|
|
14
|
+
'.cache', '.git', '.next', '.nuxt', '.omd', '.output', '.svelte-kit',
|
|
15
|
+
'__tests__', 'build', 'cache', 'coverage', 'dist', 'e2e', 'generated',
|
|
16
|
+
'node_modules', 'out', 'temp', 'test', 'tests', 'tmp', 'vendor',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const EXCLUDED_FILES = new Set([
|
|
20
|
+
'bun.lock', 'bun.lockb', 'npm-shrinkwrap.json', 'package-lock.json', 'pnpm-lock.yaml',
|
|
21
|
+
'yarn.lock',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export interface SourceSealArtifact {
|
|
25
|
+
schemaVersion: 1;
|
|
26
|
+
sealedAt: string;
|
|
27
|
+
inputs: {
|
|
28
|
+
copyDeckSha256: string;
|
|
29
|
+
typeProofSha256: string;
|
|
30
|
+
compositionSha256: string;
|
|
31
|
+
};
|
|
32
|
+
sources: Array<{ path: string; sha256: string }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SourceSealFinding {
|
|
36
|
+
id: 'SOURCE-SEAL-MISSING' | 'SOURCE-SEAL-STALE';
|
|
37
|
+
path: string;
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const hashBytes = (bytes: Buffer | string): string => createHash('sha256').update(bytes).digest('hex');
|
|
42
|
+
const slash = (path: string): string => path.split(sep).join('/');
|
|
43
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
44
|
+
|
|
45
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
46
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isSafeSourcePath(value: unknown): value is string {
|
|
50
|
+
if (typeof value !== 'string' || value.length === 0 || value.includes('\\') || value.includes('\0')) return false;
|
|
51
|
+
if (value.startsWith('/') || /^[A-Za-z]:\//.test(value)) return false;
|
|
52
|
+
return value.split('/').every((segment) => segment.length > 0 && segment !== '.' && segment !== '..');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isSourceSealArtifact(value: unknown): value is SourceSealArtifact {
|
|
56
|
+
if (!isRecord(value) || value.schemaVersion !== SOURCE_SEAL_SCHEMA_VERSION || typeof value.sealedAt !== 'string') return false;
|
|
57
|
+
if (!isRecord(value.inputs) || !Array.isArray(value.sources)) return false;
|
|
58
|
+
for (const key of ['copyDeckSha256', 'typeProofSha256', 'compositionSha256'] as const) {
|
|
59
|
+
if (typeof value.inputs[key] !== 'string' || !SHA256_PATTERN.test(value.inputs[key])) return false;
|
|
60
|
+
}
|
|
61
|
+
return value.sources.every((item) => (
|
|
62
|
+
isRecord(item)
|
|
63
|
+
&& isSafeSourcePath(item.path)
|
|
64
|
+
&& typeof item.sha256 === 'string'
|
|
65
|
+
&& SHA256_PATTERN.test(item.sha256)
|
|
66
|
+
));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function listProductionSourceFiles(rootInput: string): string[] {
|
|
70
|
+
const root = resolve(rootInput);
|
|
71
|
+
const files: string[] = [];
|
|
72
|
+
const walk = (directory: string): void => {
|
|
73
|
+
const entries = readdirSync(directory, { withFileTypes: true })
|
|
74
|
+
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (entry.isSymbolicLink()) continue;
|
|
77
|
+
const absolute = join(directory, entry.name);
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
if (entry.name.startsWith('.') || EXCLUDED_DIRECTORIES.has(entry.name)) continue;
|
|
80
|
+
walk(absolute);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!entry.isFile() || EXCLUDED_FILES.has(entry.name.toLowerCase())) continue;
|
|
84
|
+
if (!SOURCE_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
|
|
85
|
+
files.push(slash(relative(root, absolute)));
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
walk(root);
|
|
89
|
+
return files.sort();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function inputHashes(root: string): SourceSealArtifact['inputs'] {
|
|
93
|
+
const omd = join(root, '.omd');
|
|
94
|
+
const required = [
|
|
95
|
+
['copyDeckSha256', 'copy-deck.md'],
|
|
96
|
+
['typeProofSha256', 'type-proof.md'],
|
|
97
|
+
['compositionSha256', 'composition.md'],
|
|
98
|
+
] as const;
|
|
99
|
+
const values: Partial<SourceSealArtifact['inputs']> = {};
|
|
100
|
+
for (const [key, filename] of required) {
|
|
101
|
+
const path = join(omd, filename);
|
|
102
|
+
if (!existsSync(path)) throw new Error(`cannot seal source: missing .omd/${filename}`);
|
|
103
|
+
values[key] = hashBytes(readFileSync(path));
|
|
104
|
+
}
|
|
105
|
+
return values as SourceSealArtifact['inputs'];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createSourceSeal(rootInput: string, sealedAt = new Date().toISOString()): SourceSealArtifact {
|
|
109
|
+
const root = resolve(rootInput);
|
|
110
|
+
const stat = lstatSync(root);
|
|
111
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`source root must be a non-symlink directory: ${root}`);
|
|
112
|
+
return {
|
|
113
|
+
schemaVersion: SOURCE_SEAL_SCHEMA_VERSION,
|
|
114
|
+
sealedAt,
|
|
115
|
+
inputs: inputHashes(root),
|
|
116
|
+
sources: listProductionSourceFiles(root).map((path) => ({
|
|
117
|
+
path,
|
|
118
|
+
sha256: hashBytes(readFileSync(join(root, path))),
|
|
119
|
+
})),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function writeSourceSeal(rootInput: string): string {
|
|
124
|
+
const root = resolve(rootInput);
|
|
125
|
+
const path = join(root, '.omd', 'source-seal.json');
|
|
126
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
127
|
+
writeFileSync(path, `${JSON.stringify(createSourceSeal(root), null, 2)}\n`);
|
|
128
|
+
return path;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function validateSourceSeal(rootInput: string): SourceSealFinding[] {
|
|
132
|
+
const root = resolve(rootInput);
|
|
133
|
+
const path = join(root, '.omd', 'source-seal.json');
|
|
134
|
+
if (!existsSync(path)) {
|
|
135
|
+
return [{ id: 'SOURCE-SEAL-MISSING', path: '.omd/source-seal.json', message: 'source seal is missing' }];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let parsed: unknown;
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
|
|
141
|
+
} catch {
|
|
142
|
+
return [{ id: 'SOURCE-SEAL-STALE', path: '.omd/source-seal.json', message: 'source seal is not valid JSON' }];
|
|
143
|
+
}
|
|
144
|
+
if (!isSourceSealArtifact(parsed)) {
|
|
145
|
+
return [{ id: 'SOURCE-SEAL-STALE', path: '.omd/source-seal.json', message: 'source seal schema is invalid' }];
|
|
146
|
+
}
|
|
147
|
+
const sealed = parsed;
|
|
148
|
+
|
|
149
|
+
let current: SourceSealArtifact;
|
|
150
|
+
try {
|
|
151
|
+
current = createSourceSeal(root, sealed.sealedAt);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return [{ id: 'SOURCE-SEAL-STALE', path: '.omd/source-seal.json', message: error instanceof Error ? error.message : String(error) }];
|
|
154
|
+
}
|
|
155
|
+
const findings: SourceSealFinding[] = [];
|
|
156
|
+
for (const [key, filename] of [
|
|
157
|
+
['copyDeckSha256', 'copy-deck.md'],
|
|
158
|
+
['typeProofSha256', 'type-proof.md'],
|
|
159
|
+
['compositionSha256', 'composition.md'],
|
|
160
|
+
] as const) {
|
|
161
|
+
if (sealed.inputs[key] !== current.inputs[key]) {
|
|
162
|
+
findings.push({ id: 'SOURCE-SEAL-STALE', path: `.omd/${filename}`, message: `${filename} changed after source seal` });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const sealedSources = new Map(sealed.sources.map((item) => [item.path, item.sha256]));
|
|
167
|
+
const currentSources = new Map(current.sources.map((item) => [item.path, item.sha256]));
|
|
168
|
+
for (const sourcePath of [...new Set([...sealedSources.keys(), ...currentSources.keys()])].sort()) {
|
|
169
|
+
if (sealedSources.get(sourcePath) !== currentSources.get(sourcePath)) {
|
|
170
|
+
findings.push({ id: 'SOURCE-SEAL-STALE', path: sourcePath, message: `${sourcePath} was added, removed, or changed after source seal` });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return findings.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
174
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { discoverEvidence } from '../design/index.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic stack routing. The agents repeatedly misread a folder — treating a truly blank
|
|
5
|
+
* greenfield, or their own leftover `index.html`, as "an existing vanilla-HTML project" and dropping
|
|
6
|
+
* to plain HTML. This computes the stack from folder evidence so the model does not have to judge it:
|
|
7
|
+
* the tool measures, the model builds exactly what it names. The only lawful override to plain HTML on
|
|
8
|
+
* a greenfield is a verbatim explicit user request, recorded via `omd decision`.
|
|
9
|
+
*/
|
|
10
|
+
export interface StackDecision {
|
|
11
|
+
stack: 'react-vite-typescript' | 'existing' | 'plain-html-css-js';
|
|
12
|
+
framework: string | null;
|
|
13
|
+
greenfield: boolean;
|
|
14
|
+
/** Whether a plain-HTML choice is even permitted here (only on a blank greenfield, with an explicit request). */
|
|
15
|
+
htmlOverrideAllowed: boolean;
|
|
16
|
+
reason: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function computeStack(cwd: string): StackDecision {
|
|
20
|
+
const ev = discoverEvidence(cwd);
|
|
21
|
+
|
|
22
|
+
if (ev.hasPackageJson && ev.framework) {
|
|
23
|
+
return {
|
|
24
|
+
stack: 'existing',
|
|
25
|
+
framework: ev.framework,
|
|
26
|
+
greenfield: false,
|
|
27
|
+
htmlOverrideAllowed: false,
|
|
28
|
+
reason: `existing ${ev.framework} project (package.json) — build in it and add no unnecessary dependencies`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (ev.hasPackageJson || ev.appEvidencePaths.length > 0) {
|
|
33
|
+
const marker = ev.hasPackageJson ? 'package.json' : ev.appEvidencePaths[0]!;
|
|
34
|
+
return {
|
|
35
|
+
stack: 'existing',
|
|
36
|
+
framework: ev.framework,
|
|
37
|
+
greenfield: false,
|
|
38
|
+
htmlOverrideAllowed: false,
|
|
39
|
+
reason: `existing toolchain (${marker}) — preserve and investigate it, never cover it with a React scaffold`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
stack: 'react-vite-typescript',
|
|
45
|
+
framework: null,
|
|
46
|
+
greenfield: true,
|
|
47
|
+
htmlOverrideAllowed: true,
|
|
48
|
+
reason:
|
|
49
|
+
'truly blank greenfield (no package.json, no build config) — default to React + Vite + TypeScript. '
|
|
50
|
+
+ 'A loose leftover index.html/.css/.js is NOT an existing stack, and one static page is no reason to drop '
|
|
51
|
+
+ 'to plain HTML. Emit plain HTML/CSS/JS only on a verbatim explicit user request, recorded via omd decision.',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual target loop — RE-C (일반 시각 타깃 루프).
|
|
3
|
+
*
|
|
4
|
+
* Generalises the Figma pixel-diff loop to work against ANY image reference —
|
|
5
|
+
* a mockup, a screenshot, a live-URL render — so "make it look like this picture"
|
|
6
|
+
* becomes a measured convergence loop.
|
|
7
|
+
*
|
|
8
|
+
* ── WHY THIS EXISTS ─────────────────────────────────────────────────────────────
|
|
9
|
+
*
|
|
10
|
+
* An LLM judges spatial layout poorly by eye. Telling a model "make the hero taller"
|
|
11
|
+
* or "the padding looks off" requires the model to estimate pixel distances from
|
|
12
|
+
* a render it has never measured. The pixel diff replaces that eyeballing with
|
|
13
|
+
* numbers: a score (0–1), a pass/fail decision at a threshold, and a 6×6 grid
|
|
14
|
+
* that names the worst-mismatch cells so each iteration targets a specific region
|
|
15
|
+
* rather than re-guessing the whole layout.
|
|
16
|
+
*
|
|
17
|
+
* ── REUSE ───────────────────────────────────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* This module reuses two existing in-repo primitives:
|
|
20
|
+
*
|
|
21
|
+
* - `compareImages` from `core/figma/diff.ts` — diff math + grid logic
|
|
22
|
+
* - `formatDiffReport` from `core/figma/diff.ts` — human-readable grid report
|
|
23
|
+
* - `decodePng` from `core/motion/energy.ts` — PNG decoder (no new dep)
|
|
24
|
+
*
|
|
25
|
+
* The `DiffResult` / `DiffCell` types are re-exported so callers never depend on
|
|
26
|
+
* the figma module path directly.
|
|
27
|
+
*
|
|
28
|
+
* ── JSON CONTRACT ────────────────────────────────────────────────────────────────
|
|
29
|
+
*
|
|
30
|
+
* `compareAgainstTarget` returns the same `DiffResult` shape as `compareImages`
|
|
31
|
+
* so `--json` output and loop drivers work identically for both figma diff and
|
|
32
|
+
* target diff:
|
|
33
|
+
*
|
|
34
|
+
* {
|
|
35
|
+
* score: number, // 0–1, fraction of "same" pixels
|
|
36
|
+
* threshold: number,
|
|
37
|
+
* pass: boolean,
|
|
38
|
+
* cells: [{ row, col, x, y, width, height, mismatch }],
|
|
39
|
+
* dimMismatch?: { refWidth, refHeight, buildWidth, buildHeight,
|
|
40
|
+
* overlapWidth, overlapHeight }
|
|
41
|
+
* }
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
45
|
+
import { join } from 'node:path';
|
|
46
|
+
import { decodePng } from '../motion/energy.ts';
|
|
47
|
+
import { compareImages, formatDiffReport } from '../figma/diff.ts';
|
|
48
|
+
|
|
49
|
+
// ── Re-exports (stable contract) ─────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export type { DiffResult, DiffCell } from '../figma/diff.ts';
|
|
52
|
+
export { compareImages, formatDiffReport } from '../figma/diff.ts';
|
|
53
|
+
|
|
54
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export interface TargetViewport {
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One entry in the target manifest. */
|
|
62
|
+
export interface TargetEntry {
|
|
63
|
+
/** Human-readable name used with `--target <name>`. */
|
|
64
|
+
name: string;
|
|
65
|
+
/** Original source: a file path or a URL. */
|
|
66
|
+
source: string;
|
|
67
|
+
/** Absolute path to the stored PNG inside `.omd/target/`. */
|
|
68
|
+
path: string;
|
|
69
|
+
/** Dimensions decoded from the target PNG — used as the render viewport. */
|
|
70
|
+
viewport: TargetViewport;
|
|
71
|
+
registeredAt: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface TargetManifest {
|
|
75
|
+
targets: TargetEntry[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function targetDir(cwd: string): string {
|
|
81
|
+
return join(cwd, '.omd', 'target');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function manifestPath(cwd: string): string {
|
|
85
|
+
return join(targetDir(cwd), 'manifest.json');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function loadManifest(cwd: string): TargetManifest {
|
|
89
|
+
const p = manifestPath(cwd);
|
|
90
|
+
if (!existsSync(p)) return { targets: [] };
|
|
91
|
+
return JSON.parse(readFileSync(p, 'utf8')) as TargetManifest;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function saveManifest(cwd: string, manifest: TargetManifest): void {
|
|
95
|
+
mkdirSync(targetDir(cwd), { recursive: true });
|
|
96
|
+
writeFileSync(manifestPath(cwd), JSON.stringify(manifest, null, 2));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Sanitise a target name into a safe file basename. */
|
|
100
|
+
export function safeName(name: string): string {
|
|
101
|
+
return name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Register a PNG buffer as a named visual target.
|
|
108
|
+
*
|
|
109
|
+
* Decodes the buffer to read its dimensions (the intended render viewport),
|
|
110
|
+
* writes the PNG to `.omd/target/<safeName>.png`, and upserts the manifest.
|
|
111
|
+
*
|
|
112
|
+
* @param cwd Project working directory (`.omd/` is created here).
|
|
113
|
+
* @param name Human-readable target name (used with `--target <name>`).
|
|
114
|
+
* @param source Original source identifier (URL or file path) — stored for reference.
|
|
115
|
+
* @param buf Valid PNG buffer.
|
|
116
|
+
*/
|
|
117
|
+
export function registerTarget(
|
|
118
|
+
cwd: string,
|
|
119
|
+
name: string,
|
|
120
|
+
source: string,
|
|
121
|
+
buf: Buffer,
|
|
122
|
+
): TargetEntry {
|
|
123
|
+
const dir = targetDir(cwd);
|
|
124
|
+
mkdirSync(dir, { recursive: true });
|
|
125
|
+
|
|
126
|
+
const pngPath = join(dir, `${safeName(name)}.png`);
|
|
127
|
+
writeFileSync(pngPath, buf);
|
|
128
|
+
|
|
129
|
+
// Decode to get dimensions — decodePng from core/motion/energy.ts, no new dep.
|
|
130
|
+
const { width, height } = decodePng(buf);
|
|
131
|
+
|
|
132
|
+
const entry: TargetEntry = {
|
|
133
|
+
name,
|
|
134
|
+
source,
|
|
135
|
+
path: pngPath,
|
|
136
|
+
viewport: { width, height },
|
|
137
|
+
registeredAt: new Date().toISOString(),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const manifest = loadManifest(cwd);
|
|
141
|
+
const idx = manifest.targets.findIndex((t) => t.name === name);
|
|
142
|
+
if (idx >= 0) {
|
|
143
|
+
manifest.targets[idx] = entry;
|
|
144
|
+
} else {
|
|
145
|
+
manifest.targets.push(entry);
|
|
146
|
+
}
|
|
147
|
+
saveManifest(cwd, manifest);
|
|
148
|
+
|
|
149
|
+
return entry;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Return all registered targets for the project at `cwd`. */
|
|
153
|
+
export function listTargets(cwd: string): TargetEntry[] {
|
|
154
|
+
return loadManifest(cwd).targets;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Look up one registered target by name.
|
|
159
|
+
* Returns `undefined` when no target with that name has been registered.
|
|
160
|
+
*/
|
|
161
|
+
export function findTarget(cwd: string, name: string): TargetEntry | undefined {
|
|
162
|
+
return loadManifest(cwd).targets.find((t) => t.name === name);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Compare a build PNG buffer against a target PNG buffer.
|
|
167
|
+
*
|
|
168
|
+
* Pure wrapper around `compareImages` from `core/figma/diff.ts` — same
|
|
169
|
+
* algorithm, same JSON contract, same tolerance constants. The `DiffResult`
|
|
170
|
+
* returned here is bitwise-identical to what `omd figma diff` would produce
|
|
171
|
+
* for the same pair of images.
|
|
172
|
+
*
|
|
173
|
+
* @param targetBuf Reference PNG (the registered visual target).
|
|
174
|
+
* @param buildBuf Build render PNG (the "actual" to be improved).
|
|
175
|
+
* @param threshold Pass/fail threshold (0–1, default 0.97).
|
|
176
|
+
*/
|
|
177
|
+
export function compareAgainstTarget(
|
|
178
|
+
targetBuf: Buffer,
|
|
179
|
+
buildBuf: Buffer,
|
|
180
|
+
threshold = 0.97,
|
|
181
|
+
): ReturnType<typeof compareImages> {
|
|
182
|
+
return compareImages(targetBuf, buildBuf, threshold);
|
|
183
|
+
}
|