@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,607 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve, dirname, basename, join } from 'node:path';
|
|
4
|
+
import { extractInPage } from '../ir/dom.ts';
|
|
5
|
+
import { computeEnergy } from '../motion/energy.ts';
|
|
6
|
+
import type { EnergyCurve, MotionMeasurement, RawIr } from '../types.ts';
|
|
7
|
+
|
|
8
|
+
const MAX_NODES = 4000;
|
|
9
|
+
|
|
10
|
+
// ── Block detection ─────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Thrown by extractIr when the page is behind a bot-challenge or is otherwise
|
|
14
|
+
* unreachable. Callers (CLI, scout) should not retry the same URL — demote to
|
|
15
|
+
* `--image` or advance to the next candidate.
|
|
16
|
+
*/
|
|
17
|
+
export class BlockedPageError extends Error {
|
|
18
|
+
readonly reason: string;
|
|
19
|
+
constructor(reason: string) {
|
|
20
|
+
super(`blocked page: ${reason}`);
|
|
21
|
+
this.name = 'BlockedPageError';
|
|
22
|
+
this.reason = reason;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Challenge-page title patterns that indicate bot/Cloudflare/WAF blocking. */
|
|
27
|
+
const CHALLENGE_TITLE_PATTERNS: RegExp[] = [
|
|
28
|
+
/^just a moment/i,
|
|
29
|
+
/^attention required/i,
|
|
30
|
+
/^access denied/i,
|
|
31
|
+
/^ddos.{0,20}protection/i,
|
|
32
|
+
/^please (verify|wait)/i,
|
|
33
|
+
/^security check/i,
|
|
34
|
+
/^cloudflare/i,
|
|
35
|
+
/^are you (a )?human/i,
|
|
36
|
+
/^checking your browser/i,
|
|
37
|
+
/^one more step/i,
|
|
38
|
+
/^403\b/,
|
|
39
|
+
/^error\s+403\b/i,
|
|
40
|
+
/^(503|502|500)\b/,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Pure heuristic, fully testable without a browser.
|
|
45
|
+
*
|
|
46
|
+
* Returns the block reason string when the signals indicate a bot-challenge or
|
|
47
|
+
* hollow page; returns null when the page looks valid.
|
|
48
|
+
*
|
|
49
|
+
* Rules, in order:
|
|
50
|
+
* 1. HTTP 403 or any 5xx → explicitly blocked or server error
|
|
51
|
+
* 2. Challenge-page title → Cloudflare / WAF interstitial
|
|
52
|
+
* 3. Near-empty body (< 200 visible chars) → hollow IR — nothing to measure
|
|
53
|
+
*/
|
|
54
|
+
export function detectBlockReason(
|
|
55
|
+
title: string,
|
|
56
|
+
bodyTextLength: number,
|
|
57
|
+
httpStatus: number | null,
|
|
58
|
+
): string | null {
|
|
59
|
+
if (httpStatus !== null && (httpStatus === 403 || httpStatus >= 500)) {
|
|
60
|
+
return `HTTP ${httpStatus}`;
|
|
61
|
+
}
|
|
62
|
+
for (const pattern of CHALLENGE_TITLE_PATTERNS) {
|
|
63
|
+
if (pattern.test(title.trim())) {
|
|
64
|
+
return `challenge page: "${title.trim()}"`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (bodyTextLength < 200) {
|
|
68
|
+
return `near-empty body (${bodyTextLength} visible chars)`;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Evaluates the loaded page inside Playwright and throws BlockedPageError when
|
|
75
|
+
* blocked. Called inside withPage after navigation; never throws for probe
|
|
76
|
+
* failures (those are caught locally so capture can continue best-effort).
|
|
77
|
+
*
|
|
78
|
+
* Block detection is only meaningful for live HTTP/HTTPS targets. A file://
|
|
79
|
+
* URL cannot be challenged by Cloudflare and may have minimal body text by
|
|
80
|
+
* design (a fixture page is often all CSS, no prose). Skip all checks for
|
|
81
|
+
* local files so test fixtures are never falsely flagged.
|
|
82
|
+
*/
|
|
83
|
+
async function assertNotBlocked(
|
|
84
|
+
page: import('playwright').Page,
|
|
85
|
+
httpStatus: number | null,
|
|
86
|
+
resolvedUrl: string,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
if (resolvedUrl.startsWith('file:')) return; // local files are never bot-challenged
|
|
89
|
+
const title = await page.title().catch(() => '');
|
|
90
|
+
const bodyTextLength = await page
|
|
91
|
+
.evaluate(() => (document.body?.innerText?.trim() ?? '').length)
|
|
92
|
+
.catch(() => 999); // can't measure → assume valid so capture still runs
|
|
93
|
+
const reason = detectBlockReason(title, bodyTextLength, httpStatus);
|
|
94
|
+
if (reason !== null) throw new BlockedPageError(reason);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface Viewport { width: number; height: number }
|
|
98
|
+
|
|
99
|
+
const FONT_READY_TIMEOUT_MS = 5000;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Wait for the browser's CSS Font Loading set before measuring or capturing. The timeout
|
|
103
|
+
* bounds the readiness wait; `document.fonts.ready` can still resolve after a face fails.
|
|
104
|
+
* Fallback, tofu, and failed faces remain explicit FontFace-inventory and render-review
|
|
105
|
+
* concerns. Readiness cannot identify which physical font painted an individual glyph.
|
|
106
|
+
*/
|
|
107
|
+
export async function waitForDocumentFonts(
|
|
108
|
+
page: Pick<import('playwright').Page, 'evaluate'>,
|
|
109
|
+
timeoutMs = FONT_READY_TIMEOUT_MS,
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
const outcome = await page.evaluate(async (limit) => {
|
|
112
|
+
if (!document.fonts?.ready) return 'unsupported';
|
|
113
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
114
|
+
try {
|
|
115
|
+
return await Promise.race([
|
|
116
|
+
document.fonts.ready.then(() => 'ready' as const),
|
|
117
|
+
new Promise<'timeout'>((resolveTimeout) => {
|
|
118
|
+
timer = setTimeout(() => resolveTimeout('timeout'), limit);
|
|
119
|
+
}),
|
|
120
|
+
]);
|
|
121
|
+
} finally {
|
|
122
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
}, timeoutMs);
|
|
125
|
+
if (outcome === 'timeout') {
|
|
126
|
+
throw new Error(`document.fonts.ready timed out after ${timeoutMs}ms`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function toUrl(target: string): string {
|
|
131
|
+
if (/^https?:\/\//.test(target)) return target;
|
|
132
|
+
const path = resolve(target);
|
|
133
|
+
if (!existsSync(path)) throw new Error(`no such page: ${target}`);
|
|
134
|
+
return pathToFileURL(path).href;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type Browser = import('playwright').Browser;
|
|
138
|
+
|
|
139
|
+
/** Launch one chromium, run fn, close it. Batch capture reuses a single browser across many pages. */
|
|
140
|
+
export async function withBrowser<T>(fn: (browser: Browser) => Promise<T>): Promise<T> {
|
|
141
|
+
const { chromium } = await import('playwright');
|
|
142
|
+
// Always headless — OMD never opens a visible browser window in any situation.
|
|
143
|
+
const browser = await chromium.launch({ headless: true });
|
|
144
|
+
try {
|
|
145
|
+
return await fn(browser);
|
|
146
|
+
} finally {
|
|
147
|
+
await browser.close();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Open one page in an existing browser, navigate, wait for fonts, run fn, close the page. */
|
|
152
|
+
export async function onPage<T>(
|
|
153
|
+
browser: Browser,
|
|
154
|
+
target: string,
|
|
155
|
+
viewport: Viewport,
|
|
156
|
+
fn: (page: import('playwright').Page, httpStatus: number | null, resolvedUrl: string) => Promise<T>,
|
|
157
|
+
): Promise<T> {
|
|
158
|
+
const page = await browser.newPage({ viewport });
|
|
159
|
+
try {
|
|
160
|
+
const resolvedUrl = toUrl(target);
|
|
161
|
+
const response = await page.goto(resolvedUrl, { waitUntil: 'networkidle' });
|
|
162
|
+
await waitForDocumentFonts(page);
|
|
163
|
+
return await fn(page, response?.status() ?? null, resolvedUrl);
|
|
164
|
+
} finally {
|
|
165
|
+
await page.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function withPage<T>(
|
|
170
|
+
target: string,
|
|
171
|
+
viewport: Viewport,
|
|
172
|
+
fn: (page: import('playwright').Page, httpStatus: number | null, resolvedUrl: string) => Promise<T>,
|
|
173
|
+
): Promise<T> {
|
|
174
|
+
return withBrowser((browser) => onPage(browser, target, viewport, fn));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function parseViewport(s = '390x844'): Viewport {
|
|
178
|
+
const [width, height] = s.split('x').map(Number);
|
|
179
|
+
if (!width || !height) throw new Error(`bad viewport: ${s}`);
|
|
180
|
+
return { width, height };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function renderPage(target: string, opts: { viewport: Viewport; out: string; squint?: boolean; fullPage?: boolean }): Promise<string> {
|
|
184
|
+
return withPage(target, opts.viewport, async (page, _httpStatus, _resolvedUrl) => {
|
|
185
|
+
if (opts.squint) {
|
|
186
|
+
// Hierarchy isolation only: this is neither a colour-vision simulation nor a
|
|
187
|
+
// literal recreation of a timed first impression.
|
|
188
|
+
await page.addStyleTag({ content: 'html { filter: grayscale(1) blur(6px) !important; }' });
|
|
189
|
+
}
|
|
190
|
+
await page.screenshot({ path: opts.out, fullPage: opts.fullPage === true });
|
|
191
|
+
return opts.out;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Render the four sketch/craft proofs — fixed and full-page, at desktop and mobile — over ONE
|
|
197
|
+
* browser, two navigations (one per viewport, each yielding its fixed and full-page shot). Writes
|
|
198
|
+
* `<prefix>-desktop.png`, `-mobile.png`, `-desktop-full.png`, `-mobile-full.png`. Byte-identical to
|
|
199
|
+
* four separate `renderPage` calls, at one browser launch instead of four.
|
|
200
|
+
*/
|
|
201
|
+
export async function renderProofs(
|
|
202
|
+
target: string,
|
|
203
|
+
outPrefix: string,
|
|
204
|
+
opts?: { desktop?: Viewport; mobile?: Viewport },
|
|
205
|
+
): Promise<string[]> {
|
|
206
|
+
const desktop = opts?.desktop ?? { width: 1280, height: 900 };
|
|
207
|
+
const mobile = opts?.mobile ?? { width: 390, height: 844 };
|
|
208
|
+
const written: string[] = [];
|
|
209
|
+
await withBrowser(async (browser) => {
|
|
210
|
+
await Promise.all(([['desktop', desktop], ['mobile', mobile]] as const).map(([name, vp]) =>
|
|
211
|
+
onPage(browser, target, vp, async (page) => {
|
|
212
|
+
const fixed = `${outPrefix}-${name}.png`;
|
|
213
|
+
const full = `${outPrefix}-${name}-full.png`;
|
|
214
|
+
await page.screenshot({ path: fixed, fullPage: false });
|
|
215
|
+
await page.screenshot({ path: full, fullPage: true });
|
|
216
|
+
written.push(fixed, full);
|
|
217
|
+
})));
|
|
218
|
+
});
|
|
219
|
+
return written.sort();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Screenshot a single element clipped to its bounding box. Used by
|
|
224
|
+
* `omd ref add --selector … --shot` to pair a component's pixels with its
|
|
225
|
+
* blueprint on one reference record. Throws when the selector matches nothing,
|
|
226
|
+
* the same fail-closed contract as a scoped IR capture.
|
|
227
|
+
*/
|
|
228
|
+
export function renderElement(target: string, opts: { viewport: Viewport; selector: string; out: string }): Promise<string> {
|
|
229
|
+
return withPage(target, opts.viewport, async (page) => {
|
|
230
|
+
const el = await page.$(opts.selector);
|
|
231
|
+
if (!el) throw new Error(`no element matches selector: ${opts.selector}`);
|
|
232
|
+
await el.screenshot({ path: opts.out });
|
|
233
|
+
return opts.out;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Interactive elements: same definition as dom.ts's INTERACTIVE, as a CSS selector. */
|
|
238
|
+
const INTERACTIVE_SELECTOR = "a, button, input, select, textarea, summary, [role='button'], [onclick]";
|
|
239
|
+
|
|
240
|
+
/** Style properties a hover state plausibly changes. Any difference counts as "responsive". */
|
|
241
|
+
const HOVER_KEYS = [
|
|
242
|
+
'backgroundColor', 'color', 'borderColor', 'boxShadow', 'transform', 'opacity', 'textDecorationLine', 'filter',
|
|
243
|
+
] as const;
|
|
244
|
+
|
|
245
|
+
const MAX_PROBED = 25;
|
|
246
|
+
|
|
247
|
+
export interface InteractionMeasurement {
|
|
248
|
+
/** Interactive, visible elements actually probed (capped at 25). */
|
|
249
|
+
probed: number;
|
|
250
|
+
/** Of those, how many changed at least one hover-relevant style on :hover. */
|
|
251
|
+
hoverResponsive: number;
|
|
252
|
+
/** Tab-key stops that landed on a real element (capped at 25 presses). */
|
|
253
|
+
tabStops: number;
|
|
254
|
+
/** Of those stops, how many show a visible focus indicator (outline or box-shadow). */
|
|
255
|
+
focusVisible: number;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function pickHoverStyle(el: Element): Record<string, string> {
|
|
259
|
+
const cs = getComputedStyle(el);
|
|
260
|
+
return {
|
|
261
|
+
backgroundColor: cs.backgroundColor,
|
|
262
|
+
color: cs.color,
|
|
263
|
+
borderColor: cs.borderColor,
|
|
264
|
+
boxShadow: cs.boxShadow,
|
|
265
|
+
transform: cs.transform,
|
|
266
|
+
opacity: cs.opacity,
|
|
267
|
+
textDecorationLine: cs.textDecorationLine,
|
|
268
|
+
filter: cs.filter,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Real Playwright input, not synthetic events: `dispatchEvent` cannot trigger `:hover` or
|
|
274
|
+
* `:focus-visible`, so `handle.hover()` moves the actual mouse and `keyboard.press('Tab')`
|
|
275
|
+
* drives real keyboard focus. A probe failure (a covered element, a page with no
|
|
276
|
+
* interactive elements) must never break capture — every failure point is caught locally
|
|
277
|
+
* and the whole pass returns null rather than throwing.
|
|
278
|
+
*/
|
|
279
|
+
async function probeInteraction(page: import('playwright').Page): Promise<InteractionMeasurement | null> {
|
|
280
|
+
try {
|
|
281
|
+
const handles = await page.$$(INTERACTIVE_SELECTOR);
|
|
282
|
+
const visible: import('playwright').ElementHandle[] = [];
|
|
283
|
+
for (const handle of handles) {
|
|
284
|
+
if (visible.length >= MAX_PROBED) break;
|
|
285
|
+
if (await handle.isVisible().catch(() => false)) visible.push(handle);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let hoverResponsive = 0;
|
|
289
|
+
for (const handle of visible) {
|
|
290
|
+
try {
|
|
291
|
+
const before = await handle.evaluate(pickHoverStyle);
|
|
292
|
+
await handle.hover({ timeout: 2000 });
|
|
293
|
+
const after = await handle.evaluate(pickHoverStyle);
|
|
294
|
+
if (HOVER_KEYS.some((k) => before[k] !== after[k])) hoverResponsive += 1;
|
|
295
|
+
} catch {
|
|
296
|
+
// Not hoverable (covered, detached mid-probe): does not count either way.
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Reset the real cursor so the last-hovered element's :hover does not bleed into
|
|
300
|
+
// the focus-visible pass below.
|
|
301
|
+
await page.mouse.move(0, 0).catch(() => {});
|
|
302
|
+
|
|
303
|
+
await page.evaluate(() => document.body.focus());
|
|
304
|
+
let tabStops = 0;
|
|
305
|
+
let focusVisible = 0;
|
|
306
|
+
for (let i = 0; i < MAX_PROBED; i += 1) {
|
|
307
|
+
await page.keyboard.press('Tab');
|
|
308
|
+
const indicator = await page.evaluate(() => {
|
|
309
|
+
const el = document.activeElement;
|
|
310
|
+
if (!el || el === document.body || el === document.documentElement) return null;
|
|
311
|
+
const cs = getComputedStyle(el);
|
|
312
|
+
const outlined = cs.outlineStyle !== 'none' && cs.outlineWidth !== '0px';
|
|
313
|
+
return outlined || cs.boxShadow !== 'none';
|
|
314
|
+
});
|
|
315
|
+
if (indicator === null) continue;
|
|
316
|
+
tabStops += 1;
|
|
317
|
+
if (indicator) focusVisible += 1;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { probed: visible.length, hoverResponsive, tabStops, focusVisible };
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Motion probe helpers ────────────────────────────────────────────────────
|
|
327
|
+
//
|
|
328
|
+
// These functions run inside the page (injected via page.evaluate stringification).
|
|
329
|
+
// They must stay self-contained: no imports, no closure over module scope, and no
|
|
330
|
+
// syntax that does not survive TypeScript type-stripping to plain JS.
|
|
331
|
+
//
|
|
332
|
+
// PROBE LIMIT: document.getAnimations() sees CSS animations, CSS transitions (WAAPI
|
|
333
|
+
// path), and explicit WAAPI calls including Framer Motion. It does NOT see rAF-driven
|
|
334
|
+
// libraries such as GSAP or Anime.js — those animate via requestAnimationFrame and
|
|
335
|
+
// bypass the Animation API entirely. A GSAP-only page will correctly report zero
|
|
336
|
+
// animations here. This limit is documented on MotionMeasurement and propagated to
|
|
337
|
+
// Invariants.animatedProperties so callers are never surprised.
|
|
338
|
+
|
|
339
|
+
function captureMotionSnapshot(): Array<{ duration: number; easing: string; properties: string[]; playState: string }> {
|
|
340
|
+
const doc = document as unknown as { getAnimations?: (opts?: { subtree: boolean }) => unknown[] };
|
|
341
|
+
const anims: unknown[] = doc.getAnimations?.({ subtree: true }) ?? [];
|
|
342
|
+
const result: Array<{ duration: number; easing: string; properties: string[]; playState: string }> = [];
|
|
343
|
+
for (const anim of anims) {
|
|
344
|
+
const a = anim as { effect?: unknown; playState: string };
|
|
345
|
+
const effect = a.effect;
|
|
346
|
+
if (!effect) continue;
|
|
347
|
+
const eff = effect as { getComputedTiming?: () => { duration?: unknown; easing?: string }; getKeyframes?: () => Record<string, unknown>[] };
|
|
348
|
+
const timing = eff.getComputedTiming?.() ?? {};
|
|
349
|
+
const duration = typeof timing.duration === 'number' ? timing.duration : 0;
|
|
350
|
+
const easing = timing.easing ?? 'linear';
|
|
351
|
+
const properties: string[] = [];
|
|
352
|
+
try {
|
|
353
|
+
if (typeof eff.getKeyframes === 'function') {
|
|
354
|
+
const skip = new Set(['offset', 'easing', 'composite', 'computedOffset']);
|
|
355
|
+
for (const kf of eff.getKeyframes()) {
|
|
356
|
+
for (const k of Object.keys(kf)) {
|
|
357
|
+
if (!skip.has(k) && !properties.includes(k)) properties.push(k);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} catch { /* getKeyframes unsupported in some browser states */ }
|
|
362
|
+
result.push({ duration, easing, properties, playState: a.playState });
|
|
363
|
+
}
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function checkReducedMotionInPage(): boolean {
|
|
368
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
369
|
+
try {
|
|
370
|
+
for (const rule of Array.from(sheet.cssRules)) {
|
|
371
|
+
// CSSMediaRule.type === 4
|
|
372
|
+
if ((rule as unknown as { type: number }).type === 4) {
|
|
373
|
+
const text: string =
|
|
374
|
+
(rule as unknown as { conditionText?: string; media?: { mediaText?: string } }).conditionText
|
|
375
|
+
?? (rule as unknown as { media?: { mediaText?: string } }).media?.mediaText
|
|
376
|
+
?? '';
|
|
377
|
+
if (text.includes('prefers-reduced-motion')) return true;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
} catch { /* cross-origin stylesheet: skip */ }
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Live animation probe. Reads document.getAnimations({subtree:true}) at three timepoints
|
|
387
|
+
* and steps through scroll positions to record per-step choreography data.
|
|
388
|
+
*
|
|
389
|
+
* A probe failure (no Animation API, page error, or Playwright timeout) returns null
|
|
390
|
+
* rather than throwing — capture must never break because motion could not be measured.
|
|
391
|
+
*/
|
|
392
|
+
async function probeMotion(page: import('playwright').Page): Promise<MotionMeasurement | null> {
|
|
393
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
394
|
+
try {
|
|
395
|
+
type Snap = Array<{ duration: number; easing: string; properties: string[]; playState: string }>;
|
|
396
|
+
|
|
397
|
+
// Three-snapshot timeline: capture animation state at load, 500ms, and 1500ms.
|
|
398
|
+
// Entrance animations typically complete within this window; scroll-triggered ones do not.
|
|
399
|
+
const snap0 = await page.evaluate(`(${captureMotionSnapshot.toString()})()`) as Snap;
|
|
400
|
+
await sleep(500);
|
|
401
|
+
const snap500 = await page.evaluate(`(${captureMotionSnapshot.toString()})()`) as Snap;
|
|
402
|
+
await sleep(1000); // 1500ms total
|
|
403
|
+
const snap1500 = await page.evaluate(`(${captureMotionSnapshot.toString()})()`) as Snap;
|
|
404
|
+
|
|
405
|
+
const hasReducedMotion = await page.evaluate(`(${checkReducedMotionInPage.toString()})()`) as boolean;
|
|
406
|
+
|
|
407
|
+
// Scroll choreography: step by 25% viewport height, record animation and element counts.
|
|
408
|
+
const viewportHeight = page.viewportSize()?.height ?? 800;
|
|
409
|
+
const scrollStep = Math.round(viewportHeight * 0.25);
|
|
410
|
+
const scrollChoreography: MotionMeasurement['scrollChoreography'] = [];
|
|
411
|
+
|
|
412
|
+
for (let step = 1; step <= 4; step++) {
|
|
413
|
+
await page.evaluate((y: number) => window.scrollTo({ top: y, behavior: 'instant' }), step * scrollStep);
|
|
414
|
+
await sleep(150); // brief settle for scroll-triggered animations to start
|
|
415
|
+
const stepData = await page.evaluate(() => {
|
|
416
|
+
const doc = document as unknown as { getAnimations?: (opts?: { subtree: boolean }) => Array<{ playState: string }> };
|
|
417
|
+
const anims = doc.getAnimations?.({ subtree: true }) ?? [];
|
|
418
|
+
const fired = anims.filter((a) => a.playState === 'running').length;
|
|
419
|
+
const vH = window.innerHeight;
|
|
420
|
+
const entered = Array.from(document.querySelectorAll('*')).filter((el) => {
|
|
421
|
+
const rect = el.getBoundingClientRect();
|
|
422
|
+
if (rect.top < 0 || rect.bottom > vH || rect.width === 0 || rect.height === 0) return false;
|
|
423
|
+
const cs = getComputedStyle(el);
|
|
424
|
+
const transDur = parseFloat(cs.transitionDuration) || 0;
|
|
425
|
+
const animDur = parseFloat(cs.animationDuration) || 0;
|
|
426
|
+
const hasAnim = cs.animationName !== 'none' && animDur > 0;
|
|
427
|
+
return transDur > 0 || hasAnim;
|
|
428
|
+
}).length;
|
|
429
|
+
return { fired, entered };
|
|
430
|
+
}) as { fired: number; entered: number };
|
|
431
|
+
scrollChoreography.push({ step, fired: stepData.fired, entered: stepData.entered });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Reset scroll so subsequent operations (e.g. screenshot) see the top of the page.
|
|
435
|
+
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
|
|
436
|
+
|
|
437
|
+
const allProps = [...snap0, ...snap500, ...snap1500].flatMap((a) => a.properties);
|
|
438
|
+
const animatedProperties = [...new Set(allProps)].sort();
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
snapshots: [
|
|
442
|
+
{ t: 0, animations: snap0 },
|
|
443
|
+
{ t: 500, animations: snap500 },
|
|
444
|
+
{ t: 1500, animations: snap1500 },
|
|
445
|
+
],
|
|
446
|
+
animatedProperties,
|
|
447
|
+
hasReducedMotion,
|
|
448
|
+
scrollChoreography,
|
|
449
|
+
};
|
|
450
|
+
} catch {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ── Filmstrip ───────────────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Captures 4–6 viewport screenshots at ~300ms intervals from load, then writes:
|
|
459
|
+
* - numbered frame PNGs: `<base>-frame-0.png`, `<base>-frame-1.png`, …
|
|
460
|
+
* - an HTML index at `<base>.html` that lays frames out horizontally
|
|
461
|
+
*
|
|
462
|
+
* The HTML index is the deliverable: `omd-eye` reads it to see what appeared when.
|
|
463
|
+
* No image-compositing dependency is needed — the HTML renders the filmstrip in the
|
|
464
|
+
* browser, and the eye reads the rendered result.
|
|
465
|
+
*
|
|
466
|
+
* Returns the paths of the frame PNGs (not the index).
|
|
467
|
+
*/
|
|
468
|
+
export async function renderFilmstrip(
|
|
469
|
+
target: string,
|
|
470
|
+
opts: { viewport: Viewport; out: string; frames?: number; interval?: number },
|
|
471
|
+
): Promise<string[]> {
|
|
472
|
+
const frameCount = Math.min(Math.max(opts.frames ?? 5, 4), 6);
|
|
473
|
+
const interval = opts.interval ?? 300;
|
|
474
|
+
const base = opts.out.replace(/\.html$/i, '');
|
|
475
|
+
const dir = dirname(resolve(base));
|
|
476
|
+
const name = basename(base);
|
|
477
|
+
|
|
478
|
+
return withPage(target, opts.viewport, async (page, _httpStatus, _resolvedUrl) => {
|
|
479
|
+
mkdirSync(dir, { recursive: true });
|
|
480
|
+
const framePaths: string[] = [];
|
|
481
|
+
|
|
482
|
+
for (let i = 0; i < frameCount; i++) {
|
|
483
|
+
if (i > 0) await new Promise<void>((r) => setTimeout(r, interval));
|
|
484
|
+
const framePath = join(dir, `${name}-frame-${i}.png`);
|
|
485
|
+
// Viewport screenshot (not fullPage) so each frame shows the same viewport region
|
|
486
|
+
// and the temporal sequence is directly comparable.
|
|
487
|
+
await page.screenshot({ path: framePath, fullPage: false });
|
|
488
|
+
framePaths.push(framePath);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// HTML index: frames laid out horizontally so the eye can see the temporal sequence.
|
|
492
|
+
const indexPath = join(dir, `${name}.html`);
|
|
493
|
+
const figures = framePaths.map((p, i) =>
|
|
494
|
+
`<figure><figcaption>t=${i * interval}ms</figcaption>`
|
|
495
|
+
+ `<img src="${basename(p)}" loading="lazy" alt="frame ${i}"></figure>`,
|
|
496
|
+
);
|
|
497
|
+
const html = [
|
|
498
|
+
'<!doctype html><meta charset="utf-8">',
|
|
499
|
+
`<title>Filmstrip — ${name}</title>`,
|
|
500
|
+
'<style>',
|
|
501
|
+
'body{margin:0;background:#111;color:#ddd;font:11px/1.4 system-ui;',
|
|
502
|
+
' display:flex;gap:3px;padding:8px;overflow-x:auto;min-height:100vh}',
|
|
503
|
+
'figure{margin:0;flex-shrink:0;text-align:center}',
|
|
504
|
+
'figcaption{padding:4px 0 6px}',
|
|
505
|
+
'img{display:block;height:320px;width:auto;border:1px solid #333}',
|
|
506
|
+
'</style>',
|
|
507
|
+
...figures,
|
|
508
|
+
].join('\n');
|
|
509
|
+
writeFileSync(indexPath, html);
|
|
510
|
+
|
|
511
|
+
// Energy curve: compute pixel-diff scores between adjacent frames and write alongside
|
|
512
|
+
// the HTML index. This measurement sees ALL motion including GSAP/rAF — closing the
|
|
513
|
+
// getAnimations() blind spot documented on MotionMeasurement. Any decode failure is
|
|
514
|
+
// caught silently so a non-standard PNG format never breaks the filmstrip capture.
|
|
515
|
+
try {
|
|
516
|
+
const frameBuffers = framePaths.map((p) => readFileSync(p));
|
|
517
|
+
const energyCurve = computeEnergy(frameBuffers);
|
|
518
|
+
const energyPath = join(dir, `${name}-energy.json`);
|
|
519
|
+
writeFileSync(energyPath, `${JSON.stringify(energyCurve, null, 2)}\n`);
|
|
520
|
+
} catch {
|
|
521
|
+
// Best-effort: energy is a bonus measurement; a failure must not break capture.
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return framePaths;
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Capture a pixel-diff motion energy curve by taking frames in a live browser session.
|
|
530
|
+
*
|
|
531
|
+
* Unlike renderFilmstrip, this does not write any files — it returns the EnergyCurve
|
|
532
|
+
* directly for embedding in reference records (`omd ref add`). Uses the same
|
|
533
|
+
* Playwright capture path as renderFilmstrip but in a fresh session.
|
|
534
|
+
*
|
|
535
|
+
* Returns null on any failure: a blocked page, unsupported PNG format, or Playwright
|
|
536
|
+
* error must never prevent a reference from being saved.
|
|
537
|
+
*
|
|
538
|
+
* PROBE NOTE: like renderFilmstrip, this sees ALL pixel-level motion including GSAP/rAF,
|
|
539
|
+
* complementing the getAnimations() probe in extractIr which cannot see rAF-driven libs.
|
|
540
|
+
*/
|
|
541
|
+
export async function captureEnergy(
|
|
542
|
+
target: string,
|
|
543
|
+
opts: { viewport: Viewport; frames?: number; interval?: number },
|
|
544
|
+
): Promise<EnergyCurve | null> {
|
|
545
|
+
const frameCount = Math.min(Math.max(opts.frames ?? 4, 2), 6);
|
|
546
|
+
const interval = opts.interval ?? 300;
|
|
547
|
+
try {
|
|
548
|
+
return await withPage(target, opts.viewport, async (page) => {
|
|
549
|
+
const buffers: Buffer[] = [];
|
|
550
|
+
for (let i = 0; i < frameCount; i++) {
|
|
551
|
+
if (i > 0) await new Promise<void>((r) => setTimeout(r, interval));
|
|
552
|
+
buffers.push(await page.screenshot({ fullPage: false }));
|
|
553
|
+
}
|
|
554
|
+
return computeEnergy(buffers);
|
|
555
|
+
});
|
|
556
|
+
} catch {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Page-level IR extraction: block check, DOM extract, interaction/motion probes. */
|
|
562
|
+
async function extractIrCore(
|
|
563
|
+
page: import('playwright').Page,
|
|
564
|
+
httpStatus: number | null,
|
|
565
|
+
resolvedUrl: string,
|
|
566
|
+
selector: string | null,
|
|
567
|
+
): Promise<RawIr> {
|
|
568
|
+
// Fail fast on blocked/challenge pages before attempting DOM extraction.
|
|
569
|
+
await assertNotBlocked(page, httpStatus, resolvedUrl);
|
|
570
|
+
// Node strips the types, so toString() yields runnable JS for the browser.
|
|
571
|
+
const raw = await page.evaluate(
|
|
572
|
+
`(${extractInPage.toString()})(${MAX_NODES}, ${JSON.stringify(selector ?? null)})`,
|
|
573
|
+
) as RawIr;
|
|
574
|
+
const interaction = await probeInteraction(page);
|
|
575
|
+
const motion = await probeMotion(page);
|
|
576
|
+
return { ...raw, meta: { ...(raw.meta ?? {}), interaction, motion } };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function extractIr(target: string, opts: { viewport: Viewport; selector?: string | null }): Promise<RawIr> {
|
|
580
|
+
return withPage(target, opts.viewport, (page, httpStatus, resolvedUrl) =>
|
|
581
|
+
extractIrCore(page, httpStatus, resolvedUrl, opts.selector ?? null));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* One navigation on a shared browser: extract the IR and, optionally, a scoped element screenshot.
|
|
586
|
+
* Doing both on a single page is the per-reference win for batch capture — one launch, one
|
|
587
|
+
* navigation, instead of a separate browser per IR/energy/shot.
|
|
588
|
+
*/
|
|
589
|
+
export async function capturePageForRef(
|
|
590
|
+
browser: Browser,
|
|
591
|
+
target: string,
|
|
592
|
+
viewport: Viewport,
|
|
593
|
+
opts: { selector?: string | null; shotOut?: string },
|
|
594
|
+
): Promise<{ raw: RawIr; shotSaved: boolean }> {
|
|
595
|
+
return onPage(browser, target, viewport, async (page, httpStatus, resolvedUrl) => {
|
|
596
|
+
const raw = await extractIrCore(page, httpStatus, resolvedUrl, opts.selector ?? null);
|
|
597
|
+
let shotSaved = false;
|
|
598
|
+
if (opts.shotOut && opts.selector) {
|
|
599
|
+
const el = await page.$(opts.selector);
|
|
600
|
+
if (el) {
|
|
601
|
+
await el.screenshot({ path: opts.shotOut });
|
|
602
|
+
shotSaved = true;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return { raw, shotSaved };
|
|
606
|
+
});
|
|
607
|
+
}
|