@pygmalionjs/pygmalion 0.6.1 → 0.6.2

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.
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Annotates visible CSS and Web Animation targets in the running capture page.
3
+ *
4
+ * This function is intentionally self-contained because Playwright serializes
5
+ * it into the browser realm. Keep its metadata contract and discovery behavior
6
+ * aligned with src/editor/automaticMotion.ts.
7
+ */
8
+ export function annotateStoryboardAutomaticMotionTargets() {
9
+ const metadataAttribute = 'data-pygmalion-auto-motion';
10
+ const ledgerAttribute = 'data-pygmalion-auto-motions';
11
+ const declarationAttribute = 'data-pygmalion-motion';
12
+ const motionPseudoPattern =
13
+ /:(focus-visible|focus-within|hover|focus|active)(?![a-z0-9_-])/gi;
14
+ const normalizeText = (value) =>
15
+ String(value ?? '')
16
+ .replace(/\s+/g, ' ')
17
+ .trim();
18
+ const compactLabel = (value) => {
19
+ const normalized = normalizeText(value);
20
+ return normalized.length <= 52
21
+ ? normalized
22
+ : `${normalized.slice(0, 49).trimEnd()}…`;
23
+ };
24
+ const attributeSelector = (name, value) => {
25
+ const escaped = String(value)
26
+ .replaceAll('\\', '\\\\')
27
+ .replaceAll('"', '\\"')
28
+ .replaceAll('\n', '\\a ')
29
+ .replaceAll('\r', '\\d ');
30
+ return `[${name}="${escaped}"]`;
31
+ };
32
+ const splitSelectorList = (selectorText) => {
33
+ const selectors = [];
34
+ let start = 0;
35
+ let round = 0;
36
+ let square = 0;
37
+ let quote = '';
38
+ for (let index = 0; index < selectorText.length; index += 1) {
39
+ const character = selectorText[index];
40
+ if (quote) {
41
+ if (character === '\\') index += 1;
42
+ else if (character === quote) quote = '';
43
+ continue;
44
+ }
45
+ if (character === '"' || character === "'") {
46
+ quote = character;
47
+ continue;
48
+ }
49
+ if (character === '(') round += 1;
50
+ else if (character === ')') round = Math.max(0, round - 1);
51
+ else if (character === '[') square += 1;
52
+ else if (character === ']') square = Math.max(0, square - 1);
53
+ else if (character === ',' && round === 0 && square === 0) {
54
+ selectors.push(selectorText.slice(start, index).trim());
55
+ start = index + 1;
56
+ }
57
+ }
58
+ selectors.push(selectorText.slice(start).trim());
59
+ return selectors.filter(Boolean);
60
+ };
61
+ const visibleElement = (element) => {
62
+ let style;
63
+ try {
64
+ style = getComputedStyle(element);
65
+ } catch {
66
+ return false;
67
+ }
68
+ if (
69
+ style.display === 'none' ||
70
+ style.visibility === 'hidden' ||
71
+ style.visibility === 'collapse'
72
+ ) {
73
+ return false;
74
+ }
75
+ const rect = element.getBoundingClientRect();
76
+ return rect.width > 0 && rect.height > 0;
77
+ };
78
+ const computedStyleCarriesMotion = (style) => {
79
+ const names = String(style.animationName ?? '').trim();
80
+ if (names) {
81
+ return names
82
+ .split(',')
83
+ .some((name) => name.trim().toLowerCase() !== 'none');
84
+ }
85
+ const shorthand = String(style.animation ?? '').trim();
86
+ return Boolean(shorthand && shorthand.toLowerCase() !== 'none');
87
+ };
88
+ const computedMotionCandidate = (element) => {
89
+ try {
90
+ if (computedStyleCarriesMotion(getComputedStyle(element))) return true;
91
+ return ['::before', '::after'].some((pseudo) =>
92
+ computedStyleCarriesMotion(getComputedStyle(element, pseudo)),
93
+ );
94
+ } catch {
95
+ return false;
96
+ }
97
+ };
98
+ const declarationCarriesMotion = (style) => {
99
+ const names = String(style.getPropertyValue('animation-name') ?? '').trim();
100
+ if (
101
+ names &&
102
+ names.split(',').some((name) => name.trim().toLowerCase() !== 'none')
103
+ ) {
104
+ return true;
105
+ }
106
+ const shorthand = String(style.getPropertyValue('animation') ?? '').trim();
107
+ return Boolean(shorthand && shorthand.toLowerCase() !== 'none');
108
+ };
109
+ const motionSubjectSelector = (selector) => {
110
+ const subject = selector
111
+ .replace(/::[a-z0-9_-]+(?:\([^)]*\))?/gi, '')
112
+ .replace(
113
+ /:not\(\s*:(focus-visible|focus-within|hover|focus|active)\s*\)/gi,
114
+ '',
115
+ )
116
+ .replace(motionPseudoPattern, '')
117
+ .replace(/:(?:is|where|not|has)\(\s*(?:,\s*)*\)/gi, '')
118
+ .trim();
119
+ motionPseudoPattern.lastIndex = 0;
120
+ return subject || null;
121
+ };
122
+ const candidates = new Set();
123
+ const elements = [...document.querySelectorAll('*')];
124
+ for (const element of elements) {
125
+ if (!visibleElement(element)) continue;
126
+ if (
127
+ element.hasAttribute(declarationAttribute) ||
128
+ computedMotionCandidate(element)
129
+ ) {
130
+ candidates.add(element);
131
+ }
132
+ }
133
+ const inspectRules = (rules) => {
134
+ for (const rule of [...rules]) {
135
+ if (
136
+ typeof rule.selectorText === 'string' &&
137
+ rule.style &&
138
+ declarationCarriesMotion(rule.style)
139
+ ) {
140
+ for (const selector of splitSelectorList(rule.selectorText)) {
141
+ const subject = motionSubjectSelector(selector);
142
+ if (!subject) continue;
143
+ try {
144
+ for (const element of document.querySelectorAll(subject)) {
145
+ if (visibleElement(element)) candidates.add(element);
146
+ }
147
+ } catch {
148
+ // Continue through selectors the current engine can query.
149
+ }
150
+ }
151
+ }
152
+ if (rule.cssRules) inspectRules(rule.cssRules);
153
+ }
154
+ };
155
+ for (const sheet of [...document.styleSheets]) {
156
+ try {
157
+ inspectRules(sheet.cssRules);
158
+ } catch {
159
+ // Cross-origin stylesheets cannot expose their rules.
160
+ }
161
+ }
162
+ try {
163
+ for (const animation of document.getAnimations?.({ subtree: true }) ?? []) {
164
+ const target = animation.effect?.target;
165
+ if (target?.nodeType === 1 && visibleElement(target)) {
166
+ candidates.add(target);
167
+ }
168
+ }
169
+ } catch {
170
+ // CSS discovery remains available without getAnimations.
171
+ }
172
+ const selectorForElement = (element) => {
173
+ if (element === document.documentElement) {
174
+ return { selector: 'html', index: 0 };
175
+ }
176
+ if (element === document.body) return { selector: 'body', index: 0 };
177
+ for (const name of [
178
+ 'data-testid',
179
+ 'data-pygmalion-own-source',
180
+ 'data-pygmalion-source',
181
+ 'id',
182
+ ]) {
183
+ const value = element.getAttribute(name);
184
+ if (!value) continue;
185
+ const selector = attributeSelector(name, value);
186
+ try {
187
+ const matches = [...document.querySelectorAll(selector)];
188
+ const index = matches.indexOf(element);
189
+ if (index >= 0) return { selector, index };
190
+ } catch {
191
+ // Try the next identity source.
192
+ }
193
+ }
194
+ const segments = [];
195
+ let current = element;
196
+ while (current && current !== document.body) {
197
+ const parent = current.parentElement;
198
+ if (!parent) return null;
199
+ const tag = current.tagName.toLowerCase();
200
+ const siblings = [...parent.children].filter(
201
+ (candidate) => candidate.tagName === current.tagName,
202
+ );
203
+ segments.unshift(
204
+ `${tag}:nth-of-type(${siblings.indexOf(current) + 1})`,
205
+ );
206
+ current = parent;
207
+ }
208
+ return current && segments.length > 0
209
+ ? { selector: `body > ${segments.join(' > ')}`, index: 0 }
210
+ : null;
211
+ };
212
+ const targetLabel = (element) => {
213
+ const descendantLabel = element
214
+ .querySelector('[aria-label]')
215
+ ?.getAttribute('aria-label');
216
+ const ancestorLabel = element.parentElement
217
+ ?.closest('[aria-label]')
218
+ ?.getAttribute('aria-label');
219
+ const sourceName = element
220
+ .getAttribute('data-pygmalion-source-style')
221
+ ?.split('#')
222
+ .at(-1);
223
+ return compactLabel(
224
+ element.getAttribute('aria-label') ||
225
+ element.getAttribute('title') ||
226
+ descendantLabel ||
227
+ ancestorLabel ||
228
+ element.getAttribute('data-testid') ||
229
+ normalizeText(element.textContent) ||
230
+ sourceName ||
231
+ element.getAttribute('role') ||
232
+ element.tagName.toLowerCase(),
233
+ );
234
+ };
235
+ const stableId = (value) => {
236
+ let hash = 0x811c9dc5;
237
+ for (let index = 0; index < value.length; index += 1) {
238
+ hash ^= value.charCodeAt(index);
239
+ hash = Math.imul(hash, 0x01000193);
240
+ }
241
+ return (hash >>> 0).toString(36);
242
+ };
243
+ const order = new Map(elements.map((element, index) => [element, index]));
244
+ const pending = [...candidates]
245
+ .flatMap((element) => {
246
+ const identity = selectorForElement(element);
247
+ return identity
248
+ ? [{ element, identity, baseLabel: targetLabel(element) }]
249
+ : [];
250
+ })
251
+ .sort(
252
+ (left, right) =>
253
+ (order.get(left.element) ?? Number.MAX_SAFE_INTEGER) -
254
+ (order.get(right.element) ?? Number.MAX_SAFE_INTEGER),
255
+ );
256
+ const totals = new Map();
257
+ for (const entry of pending) {
258
+ totals.set(entry.baseLabel, (totals.get(entry.baseLabel) ?? 0) + 1);
259
+ }
260
+ const seen = new Map();
261
+ const targets = pending.map(({ element, identity, baseLabel }) => {
262
+ const ordinal = (seen.get(baseLabel) ?? 0) + 1;
263
+ seen.set(baseLabel, ordinal);
264
+ const total = totals.get(baseLabel) ?? 1;
265
+ const label =
266
+ total > 1 ? `${baseLabel} · ${ordinal}/${total}` : baseLabel;
267
+ const key = `${identity.selector}\u0000${identity.index}`;
268
+ const target = {
269
+ id: `auto-motion:${stableId(key)}`,
270
+ label,
271
+ selector: identity.selector,
272
+ index: identity.index,
273
+ };
274
+ element.setAttribute(metadataAttribute, JSON.stringify(target));
275
+ return target;
276
+ });
277
+ document.body?.setAttribute(ledgerAttribute, JSON.stringify(targets));
278
+ return targets;
279
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export function annotateStoryboardAutomaticPseudoStates() {
9
9
  const metadataAttribute = 'data-pygmalion-auto-pseudo';
10
+ const ledgerAttribute = 'data-pygmalion-auto-pseudos';
10
11
  const eventAttribute = 'data-pygmalion-pseudo-events';
11
12
  const stateOrder = ['hover', 'focus-visible', 'active'];
12
13
  const pseudoPattern =
@@ -277,6 +278,10 @@ export function annotateStoryboardAutomaticPseudoStates() {
277
278
  if (states.size > 0) found.set(element, states);
278
279
  }
279
280
  const selectorForElement = (element) => {
281
+ if (element === document.documentElement) {
282
+ return { selector: 'html', index: 0 };
283
+ }
284
+ if (element === document.body) return { selector: 'body', index: 0 };
280
285
  for (const name of [
281
286
  'data-testid',
282
287
  'data-pygmalion-own-source',
@@ -356,7 +361,7 @@ export function annotateStoryboardAutomaticPseudoStates() {
356
361
  totals.set(entry.baseLabel, (totals.get(entry.baseLabel) ?? 0) + 1);
357
362
  }
358
363
  const seen = new Map();
359
- return pending.map(({ element, identity, baseLabel, states }) => {
364
+ const targets = pending.map(({ element, identity, baseLabel, states }) => {
360
365
  const ordinal = (seen.get(baseLabel) ?? 0) + 1;
361
366
  seen.set(baseLabel, ordinal);
362
367
  const total = totals.get(baseLabel) ?? 1;
@@ -373,4 +378,6 @@ export function annotateStoryboardAutomaticPseudoStates() {
373
378
  element.setAttribute(metadataAttribute, JSON.stringify(target));
374
379
  return target;
375
380
  });
381
+ document.body?.setAttribute(ledgerAttribute, JSON.stringify(targets));
382
+ return targets;
376
383
  }
@@ -37,6 +37,47 @@ export function resolveDevMirrorInventoryOutputRoot(inventory, mirrorAppRoot) {
37
37
  return path.resolve(inventory?.outputRoot ?? mirrorAppRoot);
38
38
  }
39
39
 
40
+ /** A running preview belongs to one immutable app root for its whole process. */
41
+ export function devPreviewNeedsRestart({
42
+ force = false,
43
+ running = false,
44
+ activeAppRoot = null,
45
+ nextAppRoot,
46
+ }) {
47
+ if (force) return true;
48
+ if (!running) return false;
49
+ if (typeof activeAppRoot !== 'string' || !activeAppRoot.trim()) return true;
50
+ return path.resolve(activeAppRoot) !== path.resolve(nextAppRoot);
51
+ }
52
+
53
+ /**
54
+ * A commit is not ready when the proxy child still serves another ref's root.
55
+ * Returning drifted makes the ordinary boot gate request the same resync that
56
+ * repairs a checkout whose HEAD moved underneath the server.
57
+ */
58
+ export function devMirrorPreviewDriftStatus(
59
+ status,
60
+ { running = false, activeAppRoot = null, expectedAppRoot },
61
+ ) {
62
+ if (status?.state !== 'ready' || typeof expectedAppRoot !== 'string') {
63
+ return status;
64
+ }
65
+ const actual =
66
+ running && typeof activeAppRoot === 'string' && activeAppRoot.trim()
67
+ ? path.resolve(activeAppRoot)
68
+ : null;
69
+ const expected = path.resolve(expectedAppRoot);
70
+ if (actual === expected) return status;
71
+ return {
72
+ ...status,
73
+ state: 'drifted',
74
+ runtimeDrift: { expectedAppRoot: expected, actualAppRoot: actual },
75
+ error: actual
76
+ ? `dev screen runtime drifted: expected ${expected}, serving ${actual}`
77
+ : `dev screen runtime stopped: expected ${expected}`,
78
+ };
79
+ }
80
+
40
81
  /**
41
82
  * Directory-safe form of a source ref, used to give each ref its own checkout.
42
83
  */
@@ -877,6 +918,7 @@ export function pygmalionDevMirrorPlugin(options) {
877
918
  let runtimeRequested = false;
878
919
  let previewChild = null;
879
920
  let previewPort = null;
921
+ let previewAppRoot = null;
880
922
  let syncPromise = null;
881
923
  let sharedLockPathPromise = null;
882
924
  // Assigned once the server is configured. A capture asks for the checkout
@@ -962,6 +1004,18 @@ export function pygmalionDevMirrorPlugin(options) {
962
1004
  */
963
1005
  const verifiedStatus = async () => {
964
1006
  if (status.state !== 'ready' || !status.commit) return status;
1007
+ const runtimeStatus = devMirrorPreviewDriftStatus(status, {
1008
+ running:
1009
+ previewChild != null &&
1010
+ previewChild.exitCode == null &&
1011
+ previewPort != null,
1012
+ activeAppRoot: previewAppRoot,
1013
+ expectedAppRoot: mirrorAppRoot,
1014
+ });
1015
+ if (runtimeStatus !== status) {
1016
+ status = runtimeStatus;
1017
+ return status;
1018
+ }
965
1019
  if (Date.now() - lastVerifiedAt < STATUS_VERIFY_TTL_MS) return status;
966
1020
  lastVerifiedAt = Date.now();
967
1021
  const actual = await git(mirrorRoot, 'rev-parse', 'HEAD').catch(() => null);
@@ -973,6 +1027,7 @@ export function pygmalionDevMirrorPlugin(options) {
973
1027
  const child = previewChild;
974
1028
  previewChild = null;
975
1029
  previewPort = null;
1030
+ previewAppRoot = null;
976
1031
  if (!child || child.exitCode != null) return;
977
1032
  child.kill('SIGTERM');
978
1033
  };
@@ -1052,15 +1107,29 @@ export function pygmalionDevMirrorPlugin(options) {
1052
1107
  await run(command, args, { cwd: path.resolve(inventory.cwd ?? editorRoot) });
1053
1108
  };
1054
1109
 
1055
- const startPreview = async (restart, sourceIdentity = ref) => {
1110
+ const startPreview = async (forceRestart, sourceIdentity = ref) => {
1111
+ const running =
1112
+ previewChild != null &&
1113
+ previewChild.exitCode == null &&
1114
+ previewPort != null;
1115
+ const restart = devPreviewNeedsRestart({
1116
+ force: forceRestart,
1117
+ running,
1118
+ activeAppRoot: previewAppRoot,
1119
+ nextAppRoot: mirrorAppRoot,
1120
+ });
1056
1121
  if (restart) await stopPreview();
1057
1122
  if (previewChild && previewChild.exitCode == null && previewPort != null) return;
1058
1123
 
1124
+ // Keep the process identity local. `mirrorAppRoot` is mutable and can point
1125
+ // at another ref by the time readiness or the exit callback runs.
1126
+ const appRoot = mirrorAppRoot;
1127
+ const appViteConfig = viteConfig;
1059
1128
  previewPort = await pickPort(preferredPreviewPort);
1060
1129
  const viteBin = path.resolve(
1061
1130
  options.viteBin ??
1062
1131
  path.join(
1063
- mirrorAppRoot,
1132
+ appRoot,
1064
1133
  dependencies.modulesDirectory ?? 'node_modules',
1065
1134
  'vite',
1066
1135
  'bin',
@@ -1081,32 +1150,34 @@ export function pygmalionDevMirrorPlugin(options) {
1081
1150
  '--strictPort',
1082
1151
  ],
1083
1152
  {
1084
- cwd: mirrorAppRoot,
1153
+ cwd: appRoot,
1085
1154
  env: {
1086
1155
  ...process.env,
1087
1156
  PYGMALION_PREVIEW_MODE: '1',
1088
1157
  // The child outlives a killed parent otherwise — it watches this pid.
1089
1158
  PYGMALION_PREVIEW_PARENT_PID: String(process.pid),
1090
- PYGMALION_APP_ROOT: mirrorAppRoot,
1091
- PYGMALION_VITE_CONFIG: viteConfig,
1159
+ PYGMALION_APP_ROOT: appRoot,
1160
+ PYGMALION_VITE_CONFIG: appViteConfig,
1092
1161
  PYGMALION_PREVIEW_BASE: `${prefix}/`,
1093
1162
  PYGMALION_VITE_CACHE_DIR: resolvePygmalionPreviewViteCacheDir({
1094
- appRoot: mirrorAppRoot,
1163
+ appRoot,
1095
1164
  instance: `mirror:${sourceIdentity}:${prefix}`,
1096
1165
  modulesDirectory: dependencies.modulesDirectory,
1097
1166
  }),
1098
1167
  // Legacy names keep older preview configs working.
1099
- PYGMALION_DEV_FRONTEND_ROOT: mirrorAppRoot,
1168
+ PYGMALION_DEV_FRONTEND_ROOT: appRoot,
1100
1169
  PYGMALION_DEV_BASE: `${prefix}/`,
1101
1170
  },
1102
1171
  stdio: ['ignore', 'inherit', 'inherit'],
1103
1172
  },
1104
1173
  );
1105
1174
  previewChild = child;
1175
+ previewAppRoot = appRoot;
1106
1176
  child.once('exit', (code) => {
1107
1177
  if (previewChild !== child) return;
1108
1178
  previewChild = null;
1109
1179
  previewPort = null;
1180
+ previewAppRoot = null;
1110
1181
  if (status.state === 'ready' && code !== 0) {
1111
1182
  status = {
1112
1183
  ...status,
@@ -1115,7 +1186,7 @@ export function pygmalionDevMirrorPlugin(options) {
1115
1186
  };
1116
1187
  }
1117
1188
  });
1118
- await waitUntilReady(previewPort, mirrorAppRoot, prefix);
1189
+ await waitUntilReady(previewPort, appRoot, prefix);
1119
1190
  };
1120
1191
 
1121
1192
  const syncMirror = async (nextRef) => {
@@ -10,7 +10,7 @@ const AUTO_ID_PATTERN =
10
10
  const TRANSIENT_ATTRIBUTE_PATTERN =
11
11
  /\s(?:data-pygmalion-source|data-pygmalion-slot-count|data-vite-dev-id|data-reactid|data-reactroot|nonce)=(?:"[^"]*"|'[^']*')/gi;
12
12
  const FROZEN_STYLE_PATTERN =
13
- /<style(?:\s[^>]*)?>\s*\*,\*::before,\*::after\{animation:none!important;transition:none!important;caret-color:transparent!important\}html,body\{pointer-events:none!important\}\s*<\/style>/gi;
13
+ /<style(?:\s[^>]*)?>\s*\*,\*::before,\*::after\{(?:animation:none|animation-play-state:paused)!important;transition:none!important;caret-color:transparent!important\}(?:html,body\{pointer-events:none!important\})?\s*<\/style>/gi;
14
14
 
15
15
  function safeSnapshot(snapshot) {
16
16
  if (
@@ -1,11 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { annotateStoryboardAutomaticMotionTargets } from './automatic-motion-runtime.mjs';
2
3
  import { annotateStoryboardAutomaticPseudoStates } from './automatic-pseudo-runtime.mjs';
3
4
 
4
5
  const DEFAULT_VIEWPORT = Object.freeze({ width: 1280, height: 800 });
5
6
  const QA_FAILURE_STAGES = new Set(['interaction', 'assertion']);
6
7
  const STORYBOARD_ENVIRONMENT_QUERY = '__pygmalion_environment';
7
8
  const FROZEN_STYLE =
8
- '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}';
9
+ '*,*::before,*::after{animation-play-state:paused!important;transition:none!important;caret-color:transparent!important}';
9
10
  const DOM_STABLE_ATTRIBUTES = new Set([
10
11
  'type',
11
12
  'name',
@@ -93,6 +94,27 @@ function throwIfAborted(signal) {
93
94
  }
94
95
  }
95
96
 
97
+ function resetStoryboardAnimations() {
98
+ for (const animation of document.getAnimations?.({ subtree: true }) ?? []) {
99
+ try {
100
+ animation.pause();
101
+ animation.currentTime = 0;
102
+ } catch {
103
+ // An animation owned by an unavailable timeline remains CSS-paused.
104
+ }
105
+ }
106
+ }
107
+
108
+ async function freezeStoryboardMotion(page, addStyle = true) {
109
+ if (addStyle) {
110
+ const style = await page.addStyleTag({ content: FROZEN_STYLE });
111
+ await style?.evaluate?.((element) => {
112
+ element.setAttribute('data-pygmalion-preview', 'frozen');
113
+ });
114
+ }
115
+ await page.evaluate(resetStoryboardAnimations);
116
+ }
117
+
96
118
  async function atCaptureStage(stage, task, details = {}) {
97
119
  try {
98
120
  return await task();
@@ -1186,7 +1208,7 @@ export function serializeStoryboardPreviewDocument(
1186
1208
  const frozenStyle = document.createElement('style');
1187
1209
  frozenStyle.setAttribute('data-pygmalion-preview', 'frozen');
1188
1210
  frozenStyle.textContent =
1189
- '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}html,body{pointer-events:none!important}';
1211
+ '*,*::before,*::after{animation-play-state:paused!important;transition:none!important;caret-color:transparent!important}html,body{pointer-events:none!important}';
1190
1212
  head.append(frozenStyle);
1191
1213
 
1192
1214
  return `<!doctype html>${clone.outerHTML}`;
@@ -1243,7 +1265,7 @@ async function collectStableEvidence(
1243
1265
  const evidence = {};
1244
1266
  const errors = [];
1245
1267
  try {
1246
- if (!frozenStyleApplied) await page.addStyleTag({ content: FROZEN_STYLE });
1268
+ if (!frozenStyleApplied) await freezeStoryboardMotion(page);
1247
1269
  if (!alreadyStable) {
1248
1270
  const stable = await waitForStableStoryboardDocument(page, stability);
1249
1271
  if (!stable) {
@@ -1253,9 +1275,10 @@ async function collectStableEvidence(
1253
1275
  } catch (error) {
1254
1276
  errors.push(new StoryboardCaptureStageError('stabilize', error));
1255
1277
  }
1256
- if (includePreviewSnapshot) {
1278
+ if (includePreviewSnapshot || includeDomTree) {
1257
1279
  try {
1258
1280
  await page.evaluate(annotateStoryboardAutomaticPseudoStates);
1281
+ await page.evaluate(annotateStoryboardAutomaticMotionTargets);
1259
1282
  } catch (error) {
1260
1283
  errors.push(new StoryboardCaptureStageError('serialize', error));
1261
1284
  }
@@ -1491,10 +1514,8 @@ export async function captureStoryboardCase({
1491
1514
  );
1492
1515
  }
1493
1516
  await atCaptureStage('stabilize', async () => {
1494
- if (!session?.frozen) {
1495
- await page.addStyleTag({ content: FROZEN_STYLE });
1496
- if (session) session.frozen = true;
1497
- }
1517
+ await freezeStoryboardMotion(page, !session?.frozen);
1518
+ if (session) session.frozen = true;
1498
1519
  frozenStyleApplied = true;
1499
1520
  const stable = await waitForStableStoryboardDocument(page, stability);
1500
1521
  if (!stable) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -34,6 +34,7 @@
34
34
  "dist-lib",
35
35
  "docs/screen-state-contract.md",
36
36
  "node/component-branches.mjs",
37
+ "node/automatic-motion-runtime.mjs",
37
38
  "node/automatic-pseudo-runtime.mjs",
38
39
  "node/design-session.mjs",
39
40
  "node/dev-mirror.mjs",