@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,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
+ }