@pygmalionjs/pygmalion 0.7.3 → 0.8.0

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 (133) hide show
  1. package/README.ko.md +17 -2
  2. package/README.md +18 -2
  3. package/dist-lib/CameraLayer-nkNrazn8.js +159 -0
  4. package/dist-lib/pygmalion.js +14562 -16919
  5. package/dist-lib/runtime-DdYnT6k4.js +19705 -0
  6. package/dist-lib/style.css +1 -1
  7. package/dist-lib/testing.js +20 -21
  8. package/dist-lib/types/EditorShell.d.ts +1 -1
  9. package/dist-lib/types/canvas/CameraLayer.d.ts +3 -3
  10. package/dist-lib/types/canvas/Canvas.d.ts +2 -2
  11. package/dist-lib/types/canvas/FrameLabelBanner.d.ts +1 -1
  12. package/dist-lib/types/canvas/FrameView.d.ts +8 -2
  13. package/dist-lib/types/canvas/FrozenRoutePreview.d.ts +1 -1
  14. package/dist-lib/types/canvas/InspectOverlay.d.ts +4 -2
  15. package/dist-lib/types/canvas/LightweightCanvas.d.ts +1 -1
  16. package/dist-lib/types/canvas/SectionBoxes.d.ts +1 -1
  17. package/dist-lib/types/canvas/ShadowRoutePreview.d.ts +2 -2
  18. package/dist-lib/types/canvas/useFlowSession.d.ts +1 -1
  19. package/dist-lib/types/canvas/useFrameLod.d.ts +1 -1
  20. package/dist-lib/types/contract/transport.d.ts +5 -5
  21. package/dist-lib/types/contract/types.d.ts +1 -1
  22. package/dist-lib/types/contract/validate.d.ts +3 -3
  23. package/dist-lib/types/core/context.d.ts +2 -2
  24. package/dist-lib/types/core/debug.d.ts +14 -14
  25. package/dist-lib/types/core/hostBindings.d.ts +2 -2
  26. package/dist-lib/types/core/runtime.d.ts +34 -3
  27. package/dist-lib/types/core/runtimeContext.d.ts +3 -3
  28. package/dist-lib/types/core/schedulers.d.ts +67 -0
  29. package/dist-lib/types/editor/assetEditing.d.ts +2 -2
  30. package/dist-lib/types/editor/automaticMotion.d.ts +0 -11
  31. package/dist-lib/types/editor/automaticPseudoStates.d.ts +8 -2
  32. package/dist-lib/types/editor/captureSupply.d.ts +25 -7
  33. package/dist-lib/types/editor/catalog.d.ts +2 -2
  34. package/dist-lib/types/editor/codegen.d.ts +6 -3
  35. package/dist-lib/types/editor/componentConnections.d.ts +13 -8
  36. package/dist-lib/types/editor/componentInstances.d.ts +2 -2
  37. package/dist-lib/types/editor/declarations.d.ts +107 -0
  38. package/dist-lib/types/editor/designCompiler.d.ts +9 -4
  39. package/dist-lib/types/editor/designImport.d.ts +48 -12
  40. package/dist-lib/types/editor/documentSync.d.ts +28 -25
  41. package/dist-lib/types/editor/domImport.d.ts +2 -2
  42. package/dist-lib/types/editor/fiberMap.d.ts +1 -1
  43. package/dist-lib/types/editor/flowSessionScheduler.d.ts +106 -159
  44. package/dist-lib/types/editor/flowSessions.d.ts +147 -84
  45. package/dist-lib/types/editor/frameInteraction.d.ts +22 -32
  46. package/dist-lib/types/editor/frameLabels.d.ts +11 -5
  47. package/dist-lib/types/editor/frameLanes.d.ts +14 -17
  48. package/dist-lib/types/editor/frameLod.d.ts +17 -0
  49. package/dist-lib/types/editor/frameLodRuntime.d.ts +38 -38
  50. package/dist-lib/types/editor/framePreviewKeys.d.ts +14 -5
  51. package/dist-lib/types/editor/frozenImport.d.ts +13 -14
  52. package/dist-lib/types/editor/heldPseudoStates.d.ts +1 -1
  53. package/dist-lib/types/editor/host.d.ts +10 -55
  54. package/dist-lib/types/editor/implementationSourceMap.d.ts +2 -2
  55. package/dist-lib/types/editor/importFidelity.d.ts +1 -1
  56. package/dist-lib/types/editor/inspect.d.ts +7 -2
  57. package/dist-lib/types/editor/interactiveSessionSurface.d.ts +26 -34
  58. package/dist-lib/types/editor/interactiveStates.d.ts +12 -10
  59. package/dist-lib/types/editor/livePreviewInstances.d.ts +13 -3
  60. package/dist-lib/types/editor/liveScreens.d.ts +25 -16
  61. package/dist-lib/types/editor/mountScheduler.d.ts +8 -11
  62. package/dist-lib/types/editor/nodeSelector.d.ts +1 -1
  63. package/dist-lib/types/editor/pendingSourceChanges.d.ts +19 -0
  64. package/dist-lib/types/editor/previewBootstrap.d.ts +12 -22
  65. package/dist-lib/types/editor/previewCaptureProgress.d.ts +10 -6
  66. package/dist-lib/types/editor/previewEnvironmentControls.d.ts +14 -17
  67. package/dist-lib/types/editor/previewIntent.d.ts +11 -16
  68. package/dist-lib/types/editor/previewSweep.d.ts +7 -3
  69. package/dist-lib/types/editor/previewWarmup.d.ts +19 -7
  70. package/dist-lib/types/editor/projectRuntime.d.ts +2 -2
  71. package/dist-lib/types/editor/registry.d.ts +6 -2
  72. package/dist-lib/types/editor/revisionCatalog.d.ts +19 -19
  73. package/dist-lib/types/editor/revisionCatalogInstall.d.ts +18 -17
  74. package/dist-lib/types/editor/routePreview.d.ts +85 -139
  75. package/dist-lib/types/editor/scenarioCoverage.d.ts +52 -20
  76. package/dist-lib/types/editor/screenCards.d.ts +10 -6
  77. package/dist-lib/types/editor/screenDimensions.d.ts +9 -5
  78. package/dist-lib/types/editor/screenFlows.d.ts +8 -5
  79. package/dist-lib/types/editor/screenInteractions.d.ts +9 -1
  80. package/dist-lib/types/editor/screenLists.d.ts +8 -4
  81. package/dist-lib/types/editor/screenStateGroups.d.ts +1 -1
  82. package/dist-lib/types/editor/sectionHeaders.d.ts +10 -6
  83. package/dist-lib/types/editor/sessionPresets.d.ts +12 -32
  84. package/dist-lib/types/editor/shadowPreview.d.ts +7 -10
  85. package/dist-lib/types/editor/sharedSource.d.ts +4 -2
  86. package/dist-lib/types/editor/sourceChangeSummary.d.ts +1 -1
  87. package/dist-lib/types/editor/sourceJournal.d.ts +2 -2
  88. package/dist-lib/types/editor/stateSwitchMetrics.d.ts +68 -82
  89. package/dist-lib/types/editor/store.d.ts +88 -22
  90. package/dist-lib/types/editor/storyboardAuthored.d.ts +2 -2
  91. package/dist-lib/types/editor/storyboardDiscovery.d.ts +4 -3
  92. package/dist-lib/types/editor/storyboardEnvironment.d.ts +9 -5
  93. package/dist-lib/types/editor/storyboardGraph.d.ts +1 -1
  94. package/dist-lib/types/editor/storyboardGraphRuntime.d.ts +8 -10
  95. package/dist-lib/types/editor/storyboardGraphView.d.ts +1 -1
  96. package/dist-lib/types/editor/storyboardJourney.d.ts +1 -1
  97. package/dist-lib/types/editor/surfaceDemands.d.ts +196 -31
  98. package/dist-lib/types/editor/tokens.d.ts +24 -25
  99. package/dist-lib/types/editor/variantAxes.d.ts +70 -0
  100. package/dist-lib/types/editor/variantPrefetch.d.ts +26 -43
  101. package/dist-lib/types/editor/viewportPresets.d.ts +6 -6
  102. package/dist-lib/types/editor/visualQa.d.ts +2 -2
  103. package/dist-lib/types/lib.d.ts +104 -108
  104. package/dist-lib/types/shell/CatalogAssetPanel.d.ts +1 -1
  105. package/dist-lib/types/shell/CodePanel.d.ts +1 -1
  106. package/dist-lib/types/shell/ComponentStateControls.d.ts +7 -53
  107. package/dist-lib/types/shell/FrameInspector.d.ts +17 -0
  108. package/dist-lib/types/shell/FrameReviewDetails.d.ts +28 -0
  109. package/dist-lib/types/shell/LayerTree.d.ts +1 -1
  110. package/dist-lib/types/shell/StoryboardGraphPanel.d.ts +2 -2
  111. package/dist-lib/types/shell/VariantPanel.d.ts +23 -0
  112. package/dist-lib/types/shell/inspectorControls.d.ts +34 -0
  113. package/dist-lib/types/shell/qaPresentation.d.ts +1 -1
  114. package/dist-lib/types/testing.d.ts +5 -6
  115. package/dist-lib/types/ui/SourceRefControl.d.ts +1 -1
  116. package/docs/coverage-contracts.md +431 -0
  117. package/docs/screen-state-contract.md +14 -16
  118. package/node/component-branches.mjs +7 -13
  119. package/node/design-session.mjs +3 -5
  120. package/node/dev-mirror.mjs +9 -6
  121. package/node/envelope.mjs +43 -0
  122. package/node/impact-frames.mjs +94 -0
  123. package/node/inspect-plugin.mjs +12 -6
  124. package/node/preview-artifact-plugin.mjs +3 -9
  125. package/node/qa-capture-plugin.mjs +3 -6
  126. package/node/source-graph.mjs +1 -0
  127. package/node/storyboard-environment.mjs +33 -6
  128. package/node/vite.mjs +1 -0
  129. package/package.json +7 -2
  130. package/dist-lib/CameraLayer-D2wrEPwG.js +0 -415
  131. package/dist-lib/frameLodRuntime-CU0IYaSu.js +0 -272
  132. package/dist-lib/runtime-DRkTU9eq.js +0 -16195
  133. package/dist-lib/types/core/defaultRuntime.d.ts +0 -3
@@ -1,5 +1,5 @@
1
- import type { StoryboardGraphViewModel } from '../editor/storyboardGraphView';
2
- import type { StoryboardCompositionModel } from '../editor/storyboardComposition';
1
+ import type { StoryboardGraphViewModel } from '../editor/storyboardGraphView.js';
2
+ import type { StoryboardCompositionModel } from '../editor/storyboardComposition.js';
3
3
  type StoryboardPanelView = 'journey' | 'stateboard';
4
4
  export interface StoryboardGraphPanelProps {
5
5
  model: StoryboardGraphViewModel;
@@ -0,0 +1,23 @@
1
+ import type { EditorRuntime } from '../core/runtime.js';
2
+ import type { PageModel } from '../editor/store.js';
3
+ import { type VariantAxis } from '../editor/variantAxes.js';
4
+ import type { ScreenStateAxisDef } from '../editor/interactiveStates.js';
5
+ export declare function useVariantAxes(page: PageModel): {
6
+ axes: VariantAxis[];
7
+ pseudoTargets: ScreenStateAxisDef[];
8
+ shownPseudo: ScreenStateAxisDef | undefined;
9
+ };
10
+ /** Applies one option of one axis through whatever declared the axis. */
11
+ export declare function chooseVariantOption(runtime: Pick<EditorRuntime, 'store' | 'declarations' | 'schedulers'>, page: PageModel, axis: VariantAxis, optionId: string, liveOrigin: string): void;
12
+ export declare const AxisRow: import("react").FunctionComponent<{
13
+ axis: VariantAxis;
14
+ page: PageModel;
15
+ }>;
16
+ /** Chips for every axis away from its base; a chip restores that base. */
17
+ export declare const VariantChips: import("react").FunctionComponent<{
18
+ page: PageModel;
19
+ }>;
20
+ /** Every axis of the screen, grouped, one row each. */
21
+ export declare const VariantMatrix: import("react").FunctionComponent<{
22
+ page: PageModel;
23
+ }>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * What the panel shows instead of edit fields while the editor is in
3
+ * view-only.
4
+ *
5
+ * A greyed-out number box still says "you may type here, just not now",
6
+ * which is not what this mode means. A read-out says the object's shape and
7
+ * stops. The code panel remains the real inspector for anything deeper.
8
+ */
9
+ export declare function ReadRow({ label, value }: {
10
+ label: string;
11
+ value: string;
12
+ }): import("react").JSX.Element;
13
+ export declare function sizeSummary(mode: string, value: number | undefined): string;
14
+ export declare function NumBox({ label, value, onChange, disabled, suffix, }: {
15
+ label: string;
16
+ value: number;
17
+ onChange: (v: number) => void;
18
+ disabled?: boolean;
19
+ suffix?: string;
20
+ }): import("react").JSX.Element;
21
+ /**
22
+ * Frame size commits on blur instead of rebuilding the responsive preview for
23
+ * every digit typed. This is a viewport dimension, not a canvas zoom value.
24
+ */
25
+ export declare function FrameSizeBox({ axis, value, onCommit, }: {
26
+ axis: 'width' | 'height';
27
+ value: number;
28
+ onCommit: (value: number) => void;
29
+ }): import("react").JSX.Element;
30
+ export declare function ColorRow({ value, onChange, onRemove, }: {
31
+ value: string;
32
+ onChange: (v: string) => void;
33
+ onRemove?: () => void;
34
+ }): import("react").JSX.Element;
@@ -1,4 +1,4 @@
1
- import type { InspectQaFrameResult, InspectQaResult } from '../editor/visualQa';
1
+ import type { InspectQaFrameResult, InspectQaResult } from '../editor/visualQa.js';
2
2
  export interface InspectQaSummary {
3
3
  capturedFrames: number;
4
4
  failedFrames: number;
@@ -3,10 +3,9 @@
3
3
  * Host repositories can verify frozen preview behavior without importing this
4
4
  * repository's source tree or depending on private internal file paths.
5
5
  */
6
- export { FrozenRoutePreviewView } from './canvas/FrozenRoutePreview';
7
- export { EditorStore, NodeModel, serializeNode } from './editor/store';
8
- export { setComponentRegistry } from './editor/registry';
9
- export { createEditorRuntime } from './core/runtime';
10
- export type { EditorRuntime } from './core/runtime';
11
- export { EditorRuntimeProvider } from './core/context';
6
+ export { FrozenRoutePreviewView } from './canvas/FrozenRoutePreview.js';
7
+ export { EditorStore, NodeModel, serializeNode } from './editor/store.js';
8
+ export { createEditorRuntime } from './core/runtime.js';
9
+ export type { EditorRuntime } from './core/runtime.js';
10
+ export { EditorRuntimeProvider } from './core/context.js';
12
11
  export { createMemoryTransport, type MemoryTransport, type MemoryTransportCall, type MemoryTransportHandlers, } from './contract/memoryTransport.js';
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
- import type { PygmalionSourceRef } from '../editor/projectRuntime';
2
+ import type { PygmalionSourceRef } from '../editor/projectRuntime.js';
3
3
  export interface SourceRefControlProps {
4
4
  /** Revision the mirror follows now. */
5
5
  value: string | null;
@@ -0,0 +1,431 @@
1
+ # Coverage contracts
2
+
3
+ A host that ships a storyboard wants two questions answered before a change
4
+ merges:
5
+
6
+ 1. **Representation.** Is every source route the application can render shown
7
+ somewhere the editor can open — a route preview or a registered component
8
+ fixture — or excluded for a stated reason?
9
+ 2. **Classification.** Is every user scenario verified exactly once, and by
10
+ which declaration?
11
+
12
+ They are different questions with different answers. A preview URL proves that
13
+ a route renders; it proves nothing about a user scenario or an interaction. A
14
+ scenario claim proves where a scenario is verified; it says nothing about
15
+ routes nobody declared. The package answers each with its own functions, and a
16
+ green answer to one never stands in for the other.
17
+
18
+ Everything here runs in plain Node: no DOM, no Vite server, no editor mount,
19
+ no network. The functions are pure; they take declarations and return
20
+ diagnostics with structured identities. `scripts/smoke-consumer/smoke.ts` is a
21
+ complete consumer that imports them from the packed package and runs the
22
+ positive and negative cases below.
23
+
24
+ ## Who owns what
25
+
26
+ | The host owns | The package validates |
27
+ | --- | --- |
28
+ | Source discovery and router-specific normalization: which files are routes, what their canonical pattern is, what their stable id is | That every discovered route is represented, by which screen, and how |
29
+ | Screen cases, fixtures, and the mapping from a fixture to the routes it stands for | That every mapping names a route discovery reports and a component the registry has |
30
+ | Verdicts for routes no screen represents: covered by a named declaration, or excluded for a reason | That every verdict names a surface that exists and a declaration that exists, exactly once |
31
+ | The scenario inventory and the claims that classify it | That every scenario is classified exactly once against declarations that exist |
32
+ | Product scope, migration baselines, and what the CI gate fails on | Counts by outcome, with exclusions and todo items apart from coverage |
33
+
34
+ The package never reads a router, a file system, or a running application in
35
+ this path. When a guarantee needs product knowledge, it belongs in the host;
36
+ the section [What stays in the host](#what-stays-in-the-host) lists those.
37
+
38
+ ## Identify a route by its pattern, not by a preview URL
39
+
40
+ Discovery hands the audit `DiscoveredRoute` values:
41
+
42
+ ```ts
43
+ interface DiscoveredRoute {
44
+ /** The canonical pattern, in whatever syntax the host normalized to. */
45
+ path?: string;
46
+ /** The host's stable identity for the route — usually its source path. */
47
+ id?: string;
48
+ }
49
+ ```
50
+
51
+ `path` is the route as the router declares it, not an address someone visited:
52
+ `/items/:id`, or `/items/[id]` if the host keeps the framework's syntax. `id`
53
+ is the join key back to the source. A candidate for an unrepresented route is
54
+ identified as `route:<normalized path>` and carries the `id` as `sourceId`, so
55
+ a diagnostic can be matched to a route name or a source file without parsing
56
+ its message.
57
+
58
+ The package matches a discovered pattern against a screen's concrete `route`
59
+ in one way: segment for segment, where a `:param` or `*` segment accepts any
60
+ nonempty concrete segment. There is no prefix matching and no other parameter
61
+ syntax. A host whose router writes `[id]` either normalizes to `:id` before
62
+ calling the audit, or maps the route explicitly as the next section shows.
63
+ Router-specific discovery and normalization stay in the host on purpose; the
64
+ package does not carry a parser for any framework.
65
+
66
+ ## Represent a route: preview, mapping, or fixture
67
+
68
+ A declared screen says what it represents through three fields, all of which a
69
+ `DesignScreenCase` already has:
70
+
71
+ ```ts
72
+ interface DeclaredScreenRoute {
73
+ id: string;
74
+ /** The concrete canonical address the screen boots. */
75
+ route?: string;
76
+ /** The registered component the screen renders instead of navigating. */
77
+ componentName?: string;
78
+ /** Source routes the screen explicitly represents, by discovered id or path. */
79
+ coverageRoutes?: readonly string[];
80
+ }
81
+ ```
82
+
83
+ `auditRouteCoverage` compares the two lists:
84
+
85
+ ```ts
86
+ import { auditRouteCoverage } from '@pygmalionjs/pygmalion';
87
+
88
+ const audit = auditRouteCoverage({
89
+ routes, // DiscoveredRoute[] from the host's discovery
90
+ declaredScreens, // DesignScreenCase[] or result.routeMappings
91
+ registeredComponents, // the component registry, or its names
92
+ });
93
+ audit.candidates; // SurfaceCandidate[] — routes nothing represents
94
+ audit.represented; // RouteRepresentation[] — every (route, screen) pair that holds
95
+ audit.diagnostics; // RouteCoverageDiagnostic[] — mappings that name something missing
96
+ ```
97
+
98
+ A screen represents a route in one of three ways, recorded as `via` on each
99
+ `RouteRepresentation`:
100
+
101
+ | `via` | How | Example |
102
+ | --- | --- | --- |
103
+ | `preview` | The screen's concrete `route` satisfies the discovered pattern | `route: '/records/main'` represents `/records/:section` |
104
+ | `mapping` | The screen lists the route in `coverageRoutes` and boots a route of its own | `route: '/items/fixture-1', coverageRoutes: ['/items/[id]']` |
105
+ | `fixture` | The screen lists the route in `coverageRoutes` and renders a registered component | `componentName: 'GuardedFixture', coverageRoutes: ['/guarded']` |
106
+
107
+ An explicit entry matches exactly: it equals the discovered route's `id`, or
108
+ it normalizes to the discovered route's path. No parameter is substituted and
109
+ no prefix is expanded. That is what keeps a fixture from covering more than the
110
+ host said, and a concrete preview from covering a pattern by resemblance.
111
+
112
+ The two opt-in checks are on by supplying their input. With
113
+ `registeredComponents`, a screen whose `componentName` is not registered
114
+ represents nothing, and the audit reports it. Without it, a fixture mapping is
115
+ taken at its word, which is the 0.7 behaviour plus explicit mappings.
116
+
117
+ ### Diagnostics
118
+
119
+ Invalid mappings are reported apart from missing representation. The
120
+ candidates list is "what owes a verdict"; the diagnostics list is "what the
121
+ host wrote that no longer holds". A stale mapping produces both: the
122
+ diagnostic on the screen, and the route back among the candidates, because a
123
+ mapping that names something missing must never keep covering.
124
+
125
+ | `code` | Meaning | Fields |
126
+ | --- | --- | --- |
127
+ | `unknown-route` | A `coverageRoutes` entry matches no discovered route; it was removed or renamed | `screenId`, `route`, `componentName?` |
128
+ | `unregistered-component` | The screen's `componentName` is not among `registeredComponents` | `screenId`, `componentName` |
129
+ | `empty-mapping` | A `coverageRoutes` entry is blank | `screenId`, `route` |
130
+
131
+ `enumerateRouteCandidates(input)` returns `audit.candidates` alone, for a
132
+ caller that only needs the holes.
133
+
134
+ ### Examples
135
+
136
+ A static route with a preview:
137
+
138
+ ```ts
139
+ auditRouteCoverage({
140
+ routes: [{ id: 'app/settings/page', path: '/settings' }],
141
+ declaredScreens: [{ id: 'screen:settings', route: '/settings' }],
142
+ });
143
+ // candidates: [] represented: [{ screenId: 'screen:settings', via: 'preview', … }]
144
+ ```
145
+
146
+ A dynamic route, in the framework's own syntax, represented by the concrete
147
+ preview that instantiates it:
148
+
149
+ ```ts
150
+ auditRouteCoverage({
151
+ routes: [{ id: 'app/items/[id]/page', path: '/items/[id]' }],
152
+ declaredScreens: [
153
+ {
154
+ id: 'screen:item-detail',
155
+ route: '/items/fixture-1',
156
+ coverageRoutes: ['app/items/[id]/page'],
157
+ },
158
+ ],
159
+ });
160
+ // candidates: [] represented: [{ via: 'mapping', sourceId: 'app/items/[id]/page', … }]
161
+ ```
162
+
163
+ Without the `coverageRoutes` entry the same input leaves `route:/items/[id]`
164
+ among the candidates: a concrete address does not satisfy a bracket pattern,
165
+ because the package does not parse that syntax. The route stays missing until
166
+ the host supplies the mapping, a registered fixture, or a normalized pattern
167
+ (`path: '/items/:id'`, which the concrete preview then satisfies).
168
+
169
+ A guarded route, represented by a registered component fixture and no
170
+ navigation URL:
171
+
172
+ ```ts
173
+ auditRouteCoverage({
174
+ routes: [{ id: 'app/guarded/page', path: '/guarded' }],
175
+ declaredScreens: [
176
+ { id: 'screen:guarded', componentName: 'GuardedFixture', coverageRoutes: ['/guarded'] },
177
+ ],
178
+ registeredComponents: registry,
179
+ });
180
+ // candidates: [] represented: [{ via: 'fixture', componentName: 'GuardedFixture', … }]
181
+ ```
182
+
183
+ Remove `GuardedFixture` from the registry and the same call reports
184
+ `unregistered-component` on `screen:guarded` and `route:/guarded` among the
185
+ candidates.
186
+
187
+ A multi-screen journey — every step is its own source route and its own
188
+ screen; the journey itself is declared to the storyboard as a `screenFlows`
189
+ path, which is a separate, host-owned declaration:
190
+
191
+ ```ts
192
+ auditRouteCoverage({
193
+ routes: [
194
+ { id: 'app/checkout/page', path: '/checkout' },
195
+ { id: 'app/checkout/review/page', path: '/checkout/review' },
196
+ { id: 'app/checkout/done/page', path: '/checkout/done' },
197
+ ],
198
+ declaredScreens: [
199
+ { id: 'screen:checkout', route: '/checkout' },
200
+ { id: 'screen:checkout-review', route: '/checkout/review' },
201
+ { id: 'screen:checkout-done', componentName: 'CheckoutDoneFixture', coverageRoutes: ['/checkout/done'] },
202
+ ],
203
+ registeredComponents: registry,
204
+ });
205
+ ```
206
+
207
+ A step reached from another step without a navigation (a confirmation dialog
208
+ on the review screen) is a screen state or an interactive-state axis of the
209
+ review screen, not a route; the [screen state contract](./screen-state-contract.md)
210
+ decides which.
211
+
212
+ ### Audit before the collection strips the mapping
213
+
214
+ `createDesignScreenCollection` removes `coverageRoutes` from the pages it
215
+ returns, because the editor does not need it. The result keeps the route facts
216
+ beside them as `routeMappings`, one `DeclaredScreenRoute` per case, so the
217
+ audit runs on exactly the declarations the editor will show:
218
+
219
+ ```ts
220
+ const result = createDesignScreenCollection({ cases, scenarios, behaviorScenarios });
221
+ const audit = auditRouteCoverage({
222
+ routes,
223
+ declaredScreens: result.routeMappings,
224
+ registeredComponents: registry,
225
+ });
226
+ ```
227
+
228
+ The collection throws when scenario classification fails, so a gate that wants
229
+ the route audit even then passes the original `cases` instead; they satisfy
230
+ `DeclaredScreenRoute` as written.
231
+
232
+ ## Answer the remaining routes: verdicts
233
+
234
+ A route nothing represents still exists; the host says what happens to it. A
235
+ `SurfaceClassification` names the candidate and gives one of two verdicts:
236
+
237
+ ```ts
238
+ const classifications: SurfaceClassification[] = [
239
+ { id: 'route:/labs', verdict: 'covered', by: 'screen:labs' },
240
+ { id: 'route:/legacy', verdict: 'excluded', reason: 'redirects to /settings on every entry' },
241
+ ];
242
+ ```
243
+
244
+ `validateSurfaceDemands(candidates, classifications, options?)` checks them.
245
+ Every failure is an error; a warning would let a surface go unreviewed while
246
+ the build stayed green.
247
+
248
+ | `code` | Meaning |
249
+ | --- | --- |
250
+ | `unclassified` | A candidate has no verdict |
251
+ | `stale-verdict` | A verdict names a surface no enumerator reports |
252
+ | `duplicate-verdict` | Two verdicts answer one surface |
253
+ | `duplicate-candidate` | An enumerator reported one surface twice |
254
+ | `exclusion-reason` | An exclusion's reason is shorter than `MIN_SURFACE_REASON_LENGTH` |
255
+ | `empty-target` | A covered verdict names nothing |
256
+ | `unknown-target` | A covered verdict names a declaration that is not among `knownTargets` |
257
+
258
+ `unknown-target` is the check a deleted screen used to slip past: a verdict
259
+ saying `by: 'screen:settings'` kept covering `/settings` after the screen was
260
+ gone, because the target only had to be nonempty. Pass the declarations and it
261
+ becomes an error:
262
+
263
+ ```ts
264
+ import { declaredCoverageTargets, validateSurfaceDemands } from '@pygmalionjs/pygmalion';
265
+
266
+ const knownTargets = declaredCoverageTargets({
267
+ screens: cases, // named by their own id, e.g. 'screen:settings'
268
+ components: registry, // named 'component:<name>'
269
+ targets: axes.map((axis) => `axis:${axis.id}`), // anything else, verbatim
270
+ });
271
+ const diagnostics = validateSurfaceDemands(audit.candidates, classifications, { knownTargets });
272
+ ```
273
+
274
+ A screen is named by its own id — the same string a scenario claim's
275
+ `screenId` uses. A host with a different vocabulary for targets builds the set
276
+ itself and hands it to `knownTargets` directly.
277
+
278
+ ## Classify scenarios exactly once
279
+
280
+ `createDesignScreenCollection` takes the scenario inventory and the cases that
281
+ demonstrate it and throws before the editor opens when any scenario is
282
+ classified zero times or twice, or when a case names a scenario the inventory
283
+ lacks. Its `coverage` counts screens and behaviors apart and lists what is
284
+ missing. The invariant is exact-once classification of the scenarios the host
285
+ supplied; the function does not discover scenarios, and it does not learn
286
+ about routes.
287
+
288
+ `validateScenarioCoverage(claims, context)` is the same invariant for the
289
+ claims ledger, where a scenario may be verified by a frame, a screen-state
290
+ sibling, a component prop, a viewport preset, a catalog sheet, an interactive
291
+ axis, a card slot, or a list length, or admitted as `behavior` or `todo` with
292
+ a reason. Every diagnostic carries a `code`, the `scenarioId`, the claim's
293
+ `kind`, its `screenId` when it named one, and the `target` the context lacks:
294
+
295
+ | `code` | Meaning |
296
+ | --- | --- |
297
+ | `unknown-scenario` | The claim names a scenario the inventory lacks |
298
+ | `unclaimed` | A scenario has no claim |
299
+ | `duplicate-claim` | A scenario has more than one claim |
300
+ | `insufficient-reason` | A `behavior` or `todo` reason is below the floor |
301
+ | `unknown-screen` | The screen or catalog sheet is not declared |
302
+ | `not-a-state-group` | A `screen-state` claim names a screen with no sibling states |
303
+ | `unknown-component`, `unknown-prop`, `unknown-value` | The registry lacks the component, the prop, or the value |
304
+ | `unknown-preset` | The viewport preset is not declared |
305
+ | `unknown-axis`, `unknown-option` | The interactive axis or option, or the card option, is not declared |
306
+ | `unknown-card`, `unknown-slot` | The card or slot is not declared |
307
+ | `unknown-list`, `count-out-of-range` | The list is not declared, or the count is outside its range |
308
+ | `base-option` | Warning: the claimed option is the axis base, which reproduces the capture |
309
+
310
+ ### Case and step identity
311
+
312
+ Exact-once classification depends on the granularity of scenario ids. The
313
+ rule: one scenario id per state a reviewer should be able to select again,
314
+ classified by exactly one declaration.
315
+
316
+ - A journey of three screens is three scenario ids (or one id per step), each
317
+ classified by its own case. One scenario id spanning three screens cannot be
318
+ classified exactly once.
319
+ - A screen-state variant is a case of its own in the same `scenario` group,
320
+ distinguished by `state`. It carries its own scenario id, or the group's
321
+ canonical case carries the set in `scenarioIds`.
322
+ - A case demonstrates every id in its `scenarioIds`; the canonical
323
+ `scenarioId` is only where a reviewer is sent first.
324
+ - A viewport variant of a case is not a new scenario. Expand with
325
+ `expandDesignScreenCasesViewports`, which derives variant ids from the case
326
+ id and keeps the scenario ids with the authored case.
327
+ - An interaction step inside a recipe is how a case is reached, not a
328
+ scenario. A step a reviewer must be able to select on its own is a
329
+ screen-state axis option or a case.
330
+ - A route candidate id (`route:/checkout`) and a scenario id (`US-checkout`)
331
+ are different namespaces. A covered verdict names the screen that
332
+ represents the route; a claim names the screen that verifies the scenario.
333
+ The same screen may do both.
334
+
335
+ ## A preview is not a scenario
336
+
337
+ A route the audit reports as represented has a screen that renders it. That
338
+ is all it says. Whether the screen demonstrates a user scenario is the claims
339
+ ledger's question, answered by a claim naming that screen; whether an
340
+ interaction is covered is a screen-state axis or an interactive-state claim,
341
+ never inferred from a URL. A gate that reports "all routes represented" has
342
+ not reported scenario coverage.
343
+
344
+ ## Counts a report shows
345
+
346
+ Two summaries turn diagnostics into numbers without re-deriving them:
347
+
348
+ ```ts
349
+ summarizeSurfaceDemands(candidates, classifications, { knownTargets });
350
+ // { total, covered, excluded, invalid, unclassified, excludedIds, invalidIds, unclassifiedIds }
351
+
352
+ summarizeScenarioCoverage(claims, context);
353
+ // { total, covered, behavior, todo, invalid, unclaimed, behaviorIds, todoIds, invalidIds, unclaimedIds, unknownIds }
354
+ ```
355
+
356
+ `covered` counts only what validated clean. An exclusion is in `excluded`,
357
+ never in `covered`. A `todo` is in `todo`. A verdict or claim with an error is
358
+ in `invalid`. A surface or scenario nobody answered is in `unclassified` or
359
+ `unclaimed`. Nothing moves between buckets, so a report that shows all of
360
+ them shows the whole picture, and a report that shows only `covered` is
361
+ visibly incomplete.
362
+
363
+ ## Join diagnostics to source
364
+
365
+ | Diagnostic | Identity fields |
366
+ | --- | --- |
367
+ | `SurfaceDemandDiagnostic` | `code`, `kind` (`route`, `control`, `request`), `candidateId`, `sourceId?`, `frameId?`, `target?` |
368
+ | `RouteCoverageDiagnostic` | `code`, `screenId`, `route?`, `componentName?` |
369
+ | `ScenarioCoverageDiagnostic` | `code`, `scenarioId`, `kind?`, `screenId?`, `target?` |
370
+ | `RouteRepresentation` | `candidateId`, `path`, `sourceId?`, `screenId`, `via`, `componentName?` |
371
+
372
+ `message` is for people. A gate joins on the fields: `sourceId` back to the
373
+ route's source path, `screenId` to the case and its `sourcePath`,
374
+ `scenarioId` to the scenario's `sourcePath`, `target` to the declaration that
375
+ is missing.
376
+
377
+ ## What stays in the host
378
+
379
+ These guarantees need product knowledge, so the package does not provide
380
+ them; a host adopting the gate implements them itself:
381
+
382
+ - **Discovery and normalization.** Which files are routes, what their canonical
383
+ pattern is, and a stable `id` for each. The package matches patterns in one
384
+ syntax and parses no framework's.
385
+ - **Scope.** Which discovered routes are in the product at all. A route that is
386
+ out of scope gets an `excluded` verdict with a reason; the package does not
387
+ decide eligibility.
388
+ - **Fixture fidelity.** That a registered component fixture renders what the
389
+ route renders. The package checks that the component is registered, not
390
+ what it draws.
391
+ - **The inventory.** Scenario ids and their granularity, per the rules above.
392
+ - **Baselines.** A migration window is a host-held set of candidate ids that
393
+ are allowed to stay unclassified for now, subtracted from the diagnostics
394
+ before failing the build. The package does not downgrade severities.
395
+ - **CI policy.** Which diagnostics fail the build, on which Node version. The
396
+ package declares `engines.node`; the gate should run on the host's
397
+ supported version and on the declared minimum.
398
+
399
+ ## A headless gate, end to end
400
+
401
+ ```ts
402
+ import {
403
+ auditRouteCoverage,
404
+ createDesignScreenCollection,
405
+ declaredCoverageTargets,
406
+ summarizeScenarioCoverage,
407
+ summarizeSurfaceDemands,
408
+ validateScenarioCoverage,
409
+ validateSurfaceDemands,
410
+ } from '@pygmalionjs/pygmalion';
411
+
412
+ const routes = discoverRoutes(); // host: DiscoveredRoute[]
413
+ const result = createDesignScreenCollection({ cases, scenarios, behaviorScenarios });
414
+
415
+ const audit = auditRouteCoverage({
416
+ routes,
417
+ declaredScreens: result.routeMappings,
418
+ registeredComponents: registry,
419
+ });
420
+ const surfaces = validateSurfaceDemands(audit.candidates, classifications, {
421
+ knownTargets: declaredCoverageTargets({ screens: cases, components: registry }),
422
+ });
423
+ const ledger = validateScenarioCoverage(claims, context);
424
+
425
+ const failures = [...audit.diagnostics, ...surfaces, ...ledger].filter(
426
+ (diagnostic) => diagnostic.severity === 'error' && !baseline.has(keyOf(diagnostic)),
427
+ );
428
+ console.table(summarizeSurfaceDemands(audit.candidates, classifications));
429
+ console.table(summarizeScenarioCoverage(claims, context));
430
+ process.exit(failures.length === 0 ? 0 : 1);
431
+ ```
@@ -9,7 +9,7 @@ how the variation is reproduced, not from how different its pixels look.
9
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
- | Motion preview | The same screen continuously changes through CSS or Web Animations without becoming a new product state. | Automatically discovered **Motion** controls |
12
+ | Motion preview | The same screen continuously changes through CSS or Web Animations without becoming a new product state. | Nothing to declare: a live frame animates on its own; a capture holds the settled baseline |
13
13
  | Behavior | The action has no stable visual endpoint to preserve. | `DesignBehaviorScenario` or an automated interaction test |
14
14
 
15
15
  The practical test is: after reproducing the variation, is there a stable state
@@ -57,9 +57,10 @@ Pygmalion discovers pseudo-state surfaces while it serializes a screen. Every
57
57
  rendered target matched by `:hover`, `:focus`, `:focus-visible`,
58
58
  `:focus-within`, or `:active` is stamped into the inert DOM preview. React
59
59
  hover and focus handlers are discovered from the rendered element props; the
60
- inspect transform also marks native JSX event surfaces. The right panel then
61
- lists those targets under **Interaction states**, with Default, Hover, Focus,
62
- and Active controls as applicable.
60
+ inspect transform also marks native JSX event surfaces. The right panel's
61
+ variant matrix shows the chosen target as an **Elements** row with Default,
62
+ Hover, Focus, and Pressed options as applicable; a filterable picker chooses
63
+ the target, and picking an element with Inspect on chooses it from the canvas.
63
64
 
64
65
  Discovery does not apply a semantic or size threshold. A plain button whose
65
66
  only hover effect is a color, border, shadow, or opacity change belongs in the
@@ -87,20 +88,17 @@ after their inert DOM mounts.
87
88
 
88
89
  Pygmalion discovers visible CSS animation owners, animated pseudo elements,
89
90
  matching animation rules, and Web Animations API targets while it serializes a
90
- screen. It stamps every target into the inert preview and lists them under
91
- **Motion**. Reviewers can play all motion together, isolate one target, pause
92
- it, reset it to the deterministic visual baseline, or scrub through one cycle
93
- with the Phase control.
91
+ screen, and stamps every target into the inert preview. The stamps are what
92
+ the capture runtime settles: finite animations at their terminal state,
93
+ repeating animations at the start of their cycle. This keeps entrance motion
94
+ from hiding the base UI while screenshots and visual comparisons remain
95
+ repeatable, and a mounted frozen preview holds the same baseline.
94
96
 
95
97
  Motion discovery has no semantic or size threshold. Small progress dots,
96
- ordinary status indicators, shimmer bars, and full-surface animation receive
97
- the same control. The default preview holds finite animations at their terminal
98
- state and repeating animations at the start of their cycle. This keeps entrance
99
- motion from hiding the base UI while screenshots and visual comparisons remain
100
- repeatable. Play, Pause, or Phase explicitly enters the motion timeline at the
101
- selected phase; Reset restores the visual baseline. Using a Motion control
102
- temporarily switches an imported frame back to its source DOM preview,
103
- including while the editor is in Editing mode.
98
+ ordinary status indicators, shimmer bars, and full-surface animation are
99
+ settled the same way. There are no playback controls in the editor: a live
100
+ frame runs the real application, so its motion plays as it does in the
101
+ product, and a capture is by definition still.
104
102
 
105
103
  An animation does not need duplicate frames merely to show several points in
106
104
  its cycle. Preserve a separate frame only when the motion ends in an independent
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { PYGMALION_ENDPOINTS } from './contract.mjs';
3
3
  import path from 'node:path';
4
+ import { sendJson } from './envelope.mjs';
4
5
 
5
6
  // Component branch scanner — reads a component source file and extracts the
6
7
  // conditional render branches a designer would call "cases": `cond && <el>`
@@ -85,24 +86,17 @@ export function componentBranchesMiddleware(root) {
85
86
  return next();
86
87
  }
87
88
  if (!sourcePath) {
88
- res.statusCode = 400;
89
- res.setHeader('content-type', 'application/json');
90
- res.end(JSON.stringify({ error: 'source query parameter is required' }));
89
+ sendJson(res, 400, { error: 'invalid_source', message: 'source query parameter is required' });
91
90
  return;
92
91
  }
93
92
  try {
94
93
  const manifest = await scanComponentBranchesFile(root, sourcePath);
95
- res.statusCode = 200;
96
- res.setHeader('content-type', 'application/json');
97
- res.end(JSON.stringify(manifest));
94
+ sendJson(res, 200, manifest);
98
95
  } catch (error) {
99
- res.statusCode = 404;
100
- res.setHeader('content-type', 'application/json');
101
- res.end(
102
- JSON.stringify({
103
- error: error instanceof Error ? error.message : String(error),
104
- }),
105
- );
96
+ sendJson(res, 404, {
97
+ error: 'component_branches_unavailable',
98
+ message: error instanceof Error ? error.message : String(error),
99
+ });
106
100
  }
107
101
  };
108
102
  }
@@ -13,6 +13,7 @@ import { writeStyleEdit, writeTextEdit } from './inspect-plugin.mjs';
13
13
  import { createUnifiedDiff } from './source-diff.mjs';
14
14
  import { writeSourceOperations } from './source-operations.mjs';
15
15
  import { resolvePygmalionPreviewViteCacheDir } from './preview-vite-cache.mjs';
16
+ import { sendJson as sendJsonEnvelope } from './envelope.mjs';
16
17
 
17
18
  const execFileAsync = promisify(execFile);
18
19
 
@@ -24,11 +25,8 @@ const READY_TIMEOUT_MS = 60_000;
24
25
  const SESSION_ID_RE = /^[A-Za-z0-9_-]{8,80}$/;
25
26
  const SHA_RE = /^[0-9a-f]{40}$/;
26
27
 
27
- function json(res, statusCode, body) {
28
- res.statusCode = statusCode;
29
- res.setHeader('content-type', 'application/json; charset=utf-8');
30
- res.end(JSON.stringify(body));
31
- }
28
+ // One envelope for every endpoint; see envelope.mjs.
29
+ const json = sendJsonEnvelope;
32
30
 
33
31
  async function readJson(req) {
34
32
  const chunks = [];