@pygmalionjs/pygmalion 0.6.27 → 0.6.29

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 (29) hide show
  1. package/dist-lib/{FrozenRoutePreview-ziDz92ma.js → FrozenRoutePreview-D9J35QsM.js} +3568 -2799
  2. package/dist-lib/pygmalion.js +11044 -10373
  3. package/dist-lib/testing.js +1 -1
  4. package/dist-lib/types/canvas/ShadowRoutePreview.d.ts +3 -0
  5. package/dist-lib/types/canvas/useFlowSession.d.ts +3 -1
  6. package/dist-lib/types/editor/designImport.d.ts +42 -3
  7. package/dist-lib/types/editor/flowSessionScheduler.d.ts +54 -5
  8. package/dist-lib/types/editor/flowSessions.d.ts +51 -9
  9. package/dist-lib/types/editor/frameInteraction.d.ts +7 -1
  10. package/dist-lib/types/editor/geometryStability.d.ts +122 -0
  11. package/dist-lib/types/editor/heldPseudoStates.d.ts +22 -0
  12. package/dist-lib/types/editor/interactiveSessionSurface.d.ts +32 -0
  13. package/dist-lib/types/editor/interactiveStates.d.ts +27 -14
  14. package/dist-lib/types/editor/previewBootstrap.d.ts +7 -1
  15. package/dist-lib/types/editor/previewReachability.d.ts +7 -0
  16. package/dist-lib/types/editor/projectRuntime.d.ts +5 -0
  17. package/dist-lib/types/editor/routePreview.d.ts +5 -0
  18. package/dist-lib/types/editor/stateSwitchMetrics.d.ts +150 -0
  19. package/dist-lib/types/editor/store.d.ts +70 -3
  20. package/dist-lib/types/editor/storyboardDiscovery.d.ts +1 -0
  21. package/dist-lib/types/editor/variantPrefetch.d.ts +69 -0
  22. package/dist-lib/types/lib.d.ts +67 -1
  23. package/docs/screen-state-contract.md +61 -9
  24. package/node/dev-mirror.mjs +80 -8
  25. package/node/preview-artifact-plugin.mjs +14 -1
  26. package/node/preview-artifact-store.mjs +39 -3
  27. package/node/storyboard-capture-runtime.mjs +90 -0
  28. package/node/vite.mjs +1 -0
  29. package/package.json +1 -1
@@ -6,7 +6,7 @@ how the variation is reproduced, not from how different its pixels look.
6
6
  | Placement | Use when | Pygmalion declaration |
7
7
  | --- | --- | --- |
8
8
  | Frame | The variation is an independent task, journey checkpoint, structural composition, overlay context, or supported viewport that reviewers must navigate to directly. | `DesignScreenCase` |
9
- | Interaction state | The same booted screen reaches a deterministic, reversible visual endpoint through a local gesture. | `ScreenStateAxisDef` with `kind: 'interaction'` |
9
+ | Interaction state | The same booted screen has a deterministic, reversible visual endpoint, reached either directly or through a local gesture. | `ScreenStateAxisDef` with `kind: 'interaction'` and `desiredState` or `steps` |
10
10
  | Condition state | The same screen needs external data, network, storage, permission, media, or timing conditions before it boots. | `ScreenStateAxisDef` with `kind: 'condition'` |
11
11
  | Editable parameter | The variation only samples text length, row count, content, or another freely editable value and has no distinct product endpoint. | Edit mode, component props, list controls, viewport controls, or QA data |
12
12
  | Motion preview | The same screen continuously changes through CSS or Web Animations without becoming a new product state. | Automatically discovered **Motion** controls |
@@ -41,9 +41,10 @@ interaction states. They must not become duplicate frames.
41
41
 
42
42
  The W and H controls in the frame inspector change the frame viewport, not the
43
43
  canvas camera. A committed size is part of the frame fingerprint and its exact
44
- capture recipe. The request carries the route, width, height, conditions, and
45
- interactions that produced that fingerprint, and a returned artifact is seeded
46
- under that requested recipe instead of the authored catalog key.
44
+ capture recipe. The request carries the route, width, height, conditions,
45
+ desired state, and interactions that produced that fingerprint. A returned
46
+ artifact is seeded under that requested recipe instead of the authored catalog
47
+ key.
47
48
 
48
49
  This distinction is observable: resizing a frame may change responsive layout,
49
50
  while changing canvas zoom only changes how large the same frame appears in the
@@ -73,6 +74,15 @@ axis targets the same element and pseudo action, Pygmalion suppresses that
73
74
  automatic duplicate. Click-driven toggles and other application states remain
74
75
  declared axes unless they already have their own review frame.
75
76
 
77
+ In view-only mode, `requires.testId` is matched against the frozen preview's
78
+ lightweight **reachable** test-id index as well as an imported layer tree. A
79
+ test id under `display: none`, hidden visibility, zero opacity, or an inert
80
+ ancestor does not qualify merely because React kept that branch mounted. This
81
+ keeps right-panel state controls available without turning selection into a
82
+ layer import or live boot, while hiding axes that the captured frame cannot
83
+ actually reach. Legacy snapshots without a capture-time index are measured
84
+ after their inert DOM mounts.
85
+
76
86
  ## Automatic motion coverage
77
87
 
78
88
  Pygmalion discovers visible CSS animation owners, animated pseudo elements,
@@ -140,6 +150,11 @@ export const screenStateAxes: readonly ScreenStateAxisDef[] = [
140
150
  },
141
151
  ],
142
152
  },
153
+ {
154
+ id: 'collapsed',
155
+ label: 'Collapsed',
156
+ desiredState: 'collapsed',
157
+ },
143
158
  ],
144
159
  },
145
160
  {
@@ -167,11 +182,48 @@ export const screenStateAxes: readonly ScreenStateAxisDef[] = [
167
182
  ```
168
183
 
169
184
  An interaction option cannot declare `environment`. Every non-base condition
170
- option must declare one. An interaction option ending in `fill` is rejected
171
- unless a later targeted `wait` proves that the input reached a distinct visual
172
- result. This keeps empty strings and maximum-length samples out of the state
173
- panel while still allowing a search input to reproduce a real empty-result
174
- state.
185
+ option must declare one. A direct `desiredState` value cannot be combined with
186
+ steps or an environment on the same option. An interaction option ending in
187
+ `fill` is rejected unless a later targeted `wait` proves that the input reached
188
+ a distinct visual result. This keeps empty strings and maximum-length samples
189
+ out of the state panel while still allowing a search input to reproduce a real
190
+ empty-result state.
191
+
192
+ ## Direct desired state
193
+
194
+ Use direct desired state for a locally controlled endpoint whose final visual
195
+ state matters more than the gestures used to reach it. Pygmalion builds a
196
+ complete map from selected axes, such as `{ sidebar: 'collapsed' }`, and calls
197
+ the host's `executeDesiredState` adapter after mount and before any remaining
198
+ interaction steps. The map participates in capture and preview identity.
199
+
200
+ The adapter must replace its whole preview state, not merge it. Pygmalion calls
201
+ it with `{}` as well, so a warm iframe reused for another frame cannot retain a
202
+ previous override. A typical React host exposes the map through an external
203
+ store and lets each component read only its own axis. The adapter is preview
204
+ infrastructure: it must not dispatch the application's production store or
205
+ write persistent product state. When a reviewer uses the real control, the
206
+ component should release its preview override and continue from the currently
207
+ displayed value.
208
+
209
+ The right-panel selection starts delivery in the same input task. Once one
210
+ option has a live document, sibling direct-state options share that document:
211
+ Pygmalion skips boot, route entry, gesture replay, assertions, and geometry
212
+ settling, then sends the replacement map to the host adapter. The React commit
213
+ is the presentation boundary; snapshot serialization may continue afterward
214
+ without blocking the visible state. A live direct-state document is pinned
215
+ against normal warm-pool eviction and cannot be claimed through gesture-prefix
216
+ matching, because those signatures do not describe host-owned state.
217
+ Sibling delivery calls the live adapter directly rather than joining the replay
218
+ runner's FIFO. Frames that expose a direct-state axis also reserve their
219
+ activation-warmed document from unrelated gesture prefetch and align it
220
+ invisibly behind the frozen frame. The first selection can therefore reveal the
221
+ already-positioned React document after its adapter commit; speculative work
222
+ cannot reintroduce a queue between the panel click and that commit.
223
+
224
+ Keep gesture steps when the transition itself is under review, when effects
225
+ outside local render state establish the endpoint, or when the host has no safe
226
+ preview-only adapter for that state.
175
227
 
176
228
  ## Scroll positions
177
229
 
@@ -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) => {
@@ -1201,6 +1272,7 @@ export function pygmalionDevMirrorPlugin(options) {
1201
1272
  pygmalion: {
1202
1273
  acquireLease: (label) => acquireDevMirrorLease({ repoRoot, mirrorRoot, label }),
1203
1274
  activeMirrorRoot: () => mirrorRoot,
1275
+ activeAppRoot: () => mirrorAppRoot,
1204
1276
  // A capture needs the checkout as much as a live frame does, but it asks
1205
1277
  // at another plugin's endpoint. Without these two the demand never
1206
1278
  // reached the starter: the frame had no origin to request, so nothing
@@ -170,6 +170,16 @@ function requestedFrames(params) {
170
170
  if (typeof fingerprint !== 'string' || !fingerprint.trim()) return null;
171
171
  if (fingerprint.length > MAX_IDENTITY_LENGTH) return null;
172
172
  }
173
+ const recipeFingerprint = item.recipeFingerprint;
174
+ if (recipeFingerprint !== undefined) {
175
+ if (
176
+ typeof recipeFingerprint !== 'string' ||
177
+ !recipeFingerprint.trim() ||
178
+ recipeFingerprint.length > MAX_IDENTITY_LENGTH
179
+ ) {
180
+ return null;
181
+ }
182
+ }
173
183
  // The recipe the fingerprint stands for. A fingerprint is a hash — a
174
184
  // generator handed one alone can only re-capture what the host declared for
175
185
  // that id, which is a different screen wearing the requested name. Carried
@@ -184,6 +194,7 @@ function requestedFrames(params) {
184
194
  wanted.push({
185
195
  id,
186
196
  ...(fingerprint === undefined ? {} : { fingerprint }),
197
+ ...(recipeFingerprint === undefined ? {} : { recipeFingerprint }),
187
198
  ...(recipe === undefined ? {} : { recipe }),
188
199
  });
189
200
  }
@@ -256,6 +267,7 @@ function exactArtifact(artifact, namespace, sourceRevision) {
256
267
  */
257
268
  export function pygmalionPreviewArtifactPlugin({
258
269
  root = process.cwd(),
270
+ sourceRoot,
259
271
  artifactFile = DEFAULT_PYGMALION_PREVIEW_ARTIFACT_FILE,
260
272
  endpoint = PYGMALION_PREVIEW_ARTIFACT_ENDPOINT,
261
273
  disabled = () => false,
@@ -305,7 +317,7 @@ export function pygmalionPreviewArtifactPlugin({
305
317
  artifactFile: resolvedArtifact,
306
318
  // With the source root, a published frame records the files it actually
307
319
  // rendered, so freshness stops depending on which revision captured it.
308
- sourceRoot: resolvedRoot,
320
+ sourceRoot: sourceRoot ?? resolvedRoot,
309
321
  ...(artifactStoreMaxBytes == null
310
322
  ? {}
311
323
  : { maxBytes: artifactStoreMaxBytes }),
@@ -644,6 +656,7 @@ export function pygmalionPreviewArtifactPlugin({
644
656
  await artifactStore.publishArtifact(generated, {
645
657
  sourceRevision,
646
658
  recordRevision: false,
659
+ frameRequests: remaining,
647
660
  });
648
661
  // Retention is allowed to decline a newly generated object when older
649
662
  // entries have earned higher read recency. That cache decision must not
@@ -75,6 +75,8 @@ function validManifestEntry(entry, kind) {
75
75
  typeof entry.id === 'string' &&
76
76
  entry.id.length > 0 &&
77
77
  (entry.fingerprint == null || typeof entry.fingerprint === 'string') &&
78
+ (entry.recipeFingerprint == null ||
79
+ typeof entry.recipeFingerprint === 'string') &&
78
80
  (entry.sourceRevision == null || typeof entry.sourceRevision === 'string')
79
81
  );
80
82
  }
@@ -269,6 +271,13 @@ export function createPreviewArtifactStore({
269
271
  }
270
272
  const legacyFile = path.resolve(artifactFile);
271
273
  const root = path.resolve(storeDirectory);
274
+ const currentSourceRoot = () => {
275
+ const candidate =
276
+ typeof sourceRoot === 'function' ? sourceRoot() : sourceRoot;
277
+ return typeof candidate === 'string' && candidate
278
+ ? path.resolve(candidate)
279
+ : undefined;
280
+ };
272
281
  let legacyCache = null;
273
282
  const objectCache = new Map();
274
283
  let objectCacheBytes = 0;
@@ -451,7 +460,10 @@ export function createPreviewArtifactStore({
451
460
  const classifyFrameEntry = async (entry) => {
452
461
  const observed = normalizeObservedDependencies(entry.sourceFiles);
453
462
  if (!observed) return 'unknown';
454
- return (await observedDependenciesUnchanged({ recorded: observed, sourceRoot }))
463
+ return (await observedDependenciesUnchanged({
464
+ recorded: observed,
465
+ sourceRoot: currentSourceRoot(),
466
+ }))
455
467
  ? 'valid'
456
468
  : 'invalid';
457
469
  };
@@ -626,6 +638,7 @@ export function createPreviewArtifactStore({
626
638
  recordRevision = true,
627
639
  sourceRevision = artifact?.sourceRevision,
628
640
  materializeLegacy = true,
641
+ frameRequests = [],
629
642
  } = {},
630
643
  ) => {
631
644
  const validation = validateRoutePreviewArtifactBundle(artifact);
@@ -680,6 +693,9 @@ export function createPreviewArtifactStore({
680
693
  }
681
694
  }
682
695
  if (artifact.version === 3) {
696
+ const requestsById = new Map(
697
+ frameRequests.map((request) => [request.id, request]),
698
+ );
683
699
  let frames = manifest.frames;
684
700
  for (const [id, frame] of Object.entries(artifact.frames)) {
685
701
  const selected = selectRoutePreviewArtifactFrames(artifact, [{ id }]);
@@ -690,7 +706,7 @@ export function createPreviewArtifactStore({
690
706
  normalizeObservedDependencies(frame.sourceFiles) ??
691
707
  (await recordObservedDependencies({
692
708
  snapshot: selected.bundle?.frames?.[id]?.snapshot ?? frame.snapshot,
693
- sourceRoot,
709
+ sourceRoot: currentSourceRoot(),
694
710
  ...(observedAlwaysInclude
695
711
  ? { alwaysInclude: observedAlwaysInclude }
696
712
  : {}),
@@ -699,6 +715,12 @@ export function createPreviewArtifactStore({
699
715
  id,
700
716
  fingerprint:
701
717
  typeof frame.fingerprint === 'string' ? frame.fingerprint : null,
718
+ ...(typeof requestsById.get(id)?.recipeFingerprint === 'string'
719
+ ? {
720
+ recipeFingerprint:
721
+ requestsById.get(id).recipeFingerprint,
722
+ }
723
+ : {}),
702
724
  sourceRevision: sourceRevision ?? null,
703
725
  object,
704
726
  generation,
@@ -893,9 +915,23 @@ export function createPreviewArtifactStore({
893
915
  if (request.fingerprint != null && entry.fingerprint === request.fingerprint) {
894
916
  return true;
895
917
  }
918
+ // Source hashes cannot prove that viewport, environment, or interaction
919
+ // inputs stayed the same. They may bridge a changed source fingerprint only
920
+ // when the independently recorded capture recipe still matches.
921
+ if (
922
+ request.fingerprint != null &&
923
+ (request.recipeFingerprint == null ||
924
+ entry.recipeFingerprint == null ||
925
+ entry.recipeFingerprint !== request.recipeFingerprint)
926
+ ) {
927
+ return false;
928
+ }
896
929
  const observed = normalizeObservedDependencies(entry.sourceFiles);
897
930
  if (observed) {
898
- return observedDependenciesUnchanged({ recorded: observed, sourceRoot });
931
+ return observedDependenciesUnchanged({
932
+ recorded: observed,
933
+ sourceRoot: currentSourceRoot(),
934
+ });
899
935
  }
900
936
  // Nothing content-based to go on: fall back to the old rule so a frame is
901
937
  // never treated as fresher than it was before.
@@ -752,6 +752,42 @@ async function executeStoryboardPreset(page, screenCase, route) {
752
752
  }, request);
753
753
  }
754
754
 
755
+ /**
756
+ * Replaces host-owned post-mount UI state on the running application page.
757
+ * Empty state is still delivered when an adapter exists, because a warm page
758
+ * may carry the previous case's override. Only a non-empty request requires
759
+ * the host hook, preserving compatibility for hosts that do not use this path.
760
+ */
761
+ export async function executeStoryboardDesiredState(page, screenCase, route) {
762
+ const state =
763
+ screenCase.desiredState && typeof screenCase.desiredState === 'object'
764
+ ? screenCase.desiredState
765
+ : {};
766
+ const request = {
767
+ pageId: screenCase.id,
768
+ route,
769
+ state,
770
+ };
771
+ const required = Object.keys(state).length > 0;
772
+ await page.evaluate(async ({ payload, required }) => {
773
+ const apply = window.__PYGMALION_APPLY_DESIRED_STATE__;
774
+ if (typeof apply !== 'function') {
775
+ if (required) {
776
+ throw new Error(
777
+ 'There is a screen desired state, but the host adapter is not installed.',
778
+ );
779
+ }
780
+ return;
781
+ }
782
+ const result = await apply(payload);
783
+ if (result && result.complete === false) {
784
+ throw new Error(
785
+ result.message ?? 'The host refused to apply the screen desired state.',
786
+ );
787
+ }
788
+ }, { payload: request, required });
789
+ }
790
+
755
791
  export function collectStoryboardDomTree() {
756
792
  const visit = (node) => {
757
793
  if (node.nodeType === Node.TEXT_NODE) {
@@ -1020,6 +1056,56 @@ export function serializeStoryboardPreviewDocument(
1020
1056
  const clone = document.documentElement.cloneNode(true);
1021
1057
  if (!(clone instanceof HTMLElement)) return null;
1022
1058
 
1059
+ // A frozen snapshot preserves branches that are mounted but visually absent
1060
+ // (for example, two workspace panels cross-faded with opacity). Presence
1061
+ // alone therefore cannot tell the inspector which controls this captured
1062
+ // frame can actually reach. Record a compact, capture-time visibility index
1063
+ // on the body; older snapshots without it are measured again after mount.
1064
+ try {
1065
+ const reachableTestIds = new Set();
1066
+ const hasRenderedBox = (element) => {
1067
+ const rect = element.getBoundingClientRect();
1068
+ if (rect.width > 0 && rect.height > 0) return true;
1069
+ return [...element.querySelectorAll('*')].some((descendant) => {
1070
+ const descendantRect = descendant.getBoundingClientRect();
1071
+ return descendantRect.width > 0 && descendantRect.height > 0;
1072
+ });
1073
+ };
1074
+ const isReachable = (element) => {
1075
+ for (
1076
+ let current = element;
1077
+ current;
1078
+ current = current.parentElement
1079
+ ) {
1080
+ const style = window.getComputedStyle(current);
1081
+ const opacity = Number.parseFloat(style.opacity || '1');
1082
+ if (
1083
+ current.hasAttribute('hidden') ||
1084
+ current.hasAttribute('inert') ||
1085
+ style.display === 'none' ||
1086
+ style.visibility === 'hidden' ||
1087
+ style.visibility === 'collapse' ||
1088
+ (Number.isFinite(opacity) && opacity <= 0)
1089
+ ) {
1090
+ return false;
1091
+ }
1092
+ }
1093
+ return hasRenderedBox(element);
1094
+ };
1095
+ for (const element of document.documentElement.querySelectorAll('*')) {
1096
+ const testId = element.getAttribute('data-testid');
1097
+ if (testId && isReachable(element)) reachableTestIds.add(testId);
1098
+ }
1099
+ const cloneBody = clone.querySelector('body');
1100
+ cloneBody?.setAttribute(
1101
+ 'data-pygmalion-reachable-testids',
1102
+ encodeURIComponent(JSON.stringify([...reachableTestIds].sort())),
1103
+ );
1104
+ } catch {
1105
+ // Reachability metadata is additive. A constrained capture realm may not
1106
+ // implement layout; the mounted-preview fallback handles that snapshot.
1107
+ }
1108
+
1023
1109
  const sourceInputs = [...document.querySelectorAll('input')];
1024
1110
  const cloneInputs = [...clone.querySelectorAll('input')];
1025
1111
  sourceInputs.forEach((source, index) => {
@@ -1564,6 +1650,10 @@ export async function captureStoryboardCase({
1564
1650
  signal,
1565
1651
  });
1566
1652
  });
1653
+ await atCaptureStage('state', async () => {
1654
+ throwIfAborted(signal);
1655
+ await executeStoryboardDesiredState(page, screenCase, route);
1656
+ });
1567
1657
  for (const interaction of screenCase.interactions ?? []) {
1568
1658
  throwIfAborted(signal);
1569
1659
  await atCaptureStage(
package/node/vite.mjs CHANGED
@@ -263,6 +263,7 @@ export function createPygmalionVitePlugins(config) {
263
263
  endpoint: project.preview.artifactEndpoint,
264
264
  generateArtifact,
265
265
  acquireLease: (label) => devMirror?.acquireLease(label) ?? null,
266
+ sourceRoot: () => devMirror?.activeAppRoot?.() ?? project.appRoot,
266
267
  requestRuntime: () => devMirror?.requestRuntime?.(),
267
268
  // No mirror plugin means no checkout to prepare, so generation is
268
269
  // always allowed to proceed as it did before.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.6.27",
3
+ "version": "0.6.29",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {