@pygmalionjs/pygmalion
v0.6.34
Published
Code-backed DOM design sandbox and visual QA editor
Readme
Pygmalion
Pygmalion is a code-backed visual editor for React applications. It renders real DOM and registered React components on a canvas, keeps shared component instances synchronized, and connects visual edits to reviewable source changes.
Why Pygmalion? The name comes from the myth of a sculptor whose creation came to life.
Behind every design is your React code.
What you can do
- Browse registered screens, components, assets, and design tokens.
- Navigate large screen sets from the sidebar or canvas.
- Edit layout, styles, text, props, and supported component structure.
- Choose between a shared-source edit and a single-screen override.
- See shared edits update every affected frame immediately.
- Inspect the source diff and affected screens before applying a change.
- Compare baseline and changed screenshots with DOM and pixel QA.
- Undo or redo canvas edits, or reset every uncommitted editor change.
Install
Pygmalion is distributed as the public npm package
@pygmalionjs/pygmalion. No registry configuration or authentication token is
required.
npm install --save-dev @pygmalionjs/pygmalionConfigure a host project
Create pygmalion.config.ts in the host application:
import path from "node:path";
import { definePygmalionProject } from "@pygmalionjs/pygmalion/vite";
const appRoot = import.meta.dirname;
const projectRoot = path.resolve(appRoot, "..");
export default definePygmalionProject({
configRoot: appRoot,
projectRoot,
appRoot,
appDirectory: "frontend",
sourceDirectory: "src",
source: { remote: "origin", branch: "dev" },
mirrorRoot: path.resolve(projectRoot, "..", "app-dev-view"),
inventory: {
script: path.join(appRoot, "scripts", "generate-pygmalion-inventory.mjs"),
outputRoot: appRoot,
outputs: [
"src/generated/design-registry.ts",
"src/generated/design-inventory.ts",
],
runtimeInputs: [".env.local"],
},
inspect: {
normalizeValue(property, value) {
return value;
},
},
});inventory.outputs lists generator-owned paths relative to the application root. Pygmalion
restores these paths in its dedicated mirror before switching revisions and checks again after
generation; any change outside the declared outputs stops synchronization.
Git does not report ignored files, so list ignored runtime or configuration
files a generator could affect in inventory.runtimeInputs. Each entry is one
file relative to the application root, with at most 64 files and 16 MiB total.
Pygmalion compares their content directly before and after generation; it does
not recursively scan .git, node_modules, or other unrelated directories.
Add the integration plugins to the host Vite configuration:
import { createPygmalionVitePlugins } from "@pygmalionjs/pygmalion/vite";
import pygmalionConfig from "./pygmalion.config";
export default defineConfig({
plugins: [react(), ...createPygmalionVitePlugins(pygmalionConfig)],
});Frameworks that expose webpack loaders through a JavaScript-only pipeline can
ask the inspect loader to strip TypeScript after source instrumentation. A
Turbopack rule should set transpile: true while keeping the original file
extension, so relative TSX module resolution keeps the host's source identity:
{
'./src/**/*.tsx': {
loaders: [{
loader: require.resolve('@pygmalionjs/pygmalion/webpack-loader'),
options: { root: process.cwd(), sourceDirectory: 'src', transpile: true },
}],
},
}When the mirror preview reloads the host Vite configuration,
PYGMALION_PREVIEW_MODE=1 prevents the integration plugins from starting
recursively.
Mirror and session previews run as their own Vite processes, so the mode you
pass to the editor does not reach them. Set preview.mode when a screen only
exists under a specific env file — a feature flag that has to be off, a mock
profile a recipe depends on:
export default definePygmalionProject({
// ...
preview: { mode: "e2e" },
});Switch what the mirror renders
The mirror follows source.branch unless source.ref pins it. Either can be
repointed at runtime, so comparing two revisions no longer means restarting the
dev server:
const { switchSource, mirror } = usePygmalionProject();
await switchSource("origin/dev");
await switchSource(PYGMALION_WORKTREE_SOURCE_REF); // uncommitted work
mirror.sourceRef; // what it is tracking nowPYGMALION_WORKTREE_SOURCE_REF renders the current working tree by resolving it
to a throwaway commit, leaving your branch, HEAD, and index untouched. It covers
tracked changes only; untracked files raise a warning on the returned status.
Bind screen declarations to the selected revision
An editor host is normally built once, while its mirror can switch among many checkouts. Bundling routed pages into the host therefore lets a new preview run under an old screen catalog. Generate a JSON catalog inside every mirror checkout and let the exact-source endpoint serve it under a read lease:
export default definePygmalionProject({
// ...
inventory: {
script: './scripts/generate-pygmalion-inventory.mjs',
outputs: ['artifacts/pygmalion-revision-catalog.json'],
},
preview: {
catalog: { file: 'artifacts/pygmalion-revision-catalog.json' },
},
});With the default inventory.script invocation, Pygmalion passes
--source-root <path> --out-root <path> --commit <sha>. A custom
inventory.args(context) receives the same exact context.commit when the
generator uses another CLI shape.
The generated file uses the versioned, JSON-only contract exported as
PygmalionRevisionCatalog. Stamp sourceRevision from the inventory
generator's commit context and include routed screens.pages,
screens.assets, tokens, and every screen declaration that can differ by
revision. Put serializable registry metadata in componentRegistry; the editor
binds it by name to the React implementations already bundled by the host and
rejects a catalog whose implementation is unavailable. This keeps props,
defaults, adoption, and code-generation metadata revision-bound without putting
functions in JSON.
Repository-owned generated-code intent belongs in codegenProject so its
frame contracts and prompt provenance switch with the same catalog.
Then opt the editor into the endpoint:
const { appOrigin, previewRevision } = usePygmalionProject();
<PygmalionEditor
previewCacheNamespace="my-product"
previewRevision={previewRevision}
appOrigin={appOrigin ?? undefined}
previewCatalogEndpoint="/__pygmalion-route-preview/artifact/catalog"
/>Pass the runtime's full previewRevision; the editor derives its source SHA
for the catalog request while retaining the session mutation counter for screen
cache invalidation.
The editor validates both namespace and source revision before installation. While another checkout is preparing, it keeps the last complete catalog, preview origin, and interaction declarations together. A late response from an older dev → qa → dev request cannot overwrite the current selection. Invalid, permanently unavailable, or catalogs that produce no installable page fail closed on a dedicated error surface; the editor never relabels the previous canvas as the requested revision. A catalog with no routed screens remains installable when the host contributes non-screen library pages.
When onDesignChange is connected, each payload carries the exact
sourceRevision that owned the edited batch. Its non-enumerable signal is
aborted when that catalog is retired, allowing an in-process host adapter to
cancel work without changing an existing JSON request body.
To offer a choice instead of asking for a ref string, list the revisions and render the shipped control in your toolbar:
const { sourceRefs, switchSource, mirror } = usePygmalionProject();
<SourceRefControl
value={mirror.sourceRef}
currentCommit={mirror.shortCommit}
refs={sourceRefs.refs}
defaultRef={sourceRefs.defaultRef}
worktreeRef={sourceRefs.worktreeRef}
busy={mirror.state === 'syncing'}
refreshLabel="Refresh this branch"
onChange={(ref) => void switchSource(ref)}
>
{/* Optional: your own trigger, so the control reads as part of the toolbar. */}
<span>{mirror.sourceRef ?? 'dev'}@{mirror.shortCommit}</span>
</SourceRefControl>;The menu is a native select layered over that trigger, so keyboard access,
mobile behavior, and placement stay with the platform while the painted trigger
stays yours. Its first item explicitly reloads the selected revision, including
when a branch tip moved while that same branch remained selected. Passing
currentCommit marks the control with [data-update-available] and
[data-latest-commit] when the listed ref is newer. [data-pygmalion-source-ref]
and [data-busy] are also available for styling.
GET /__pygmalion-dev-control/refs backs this list with local branches, remote
tracking branches, the default revision, and the worktree ref. loadSourceRefs()
reloads it after branches change.
Preview artifacts are cached per source SHA, so switching invalidates that cache and the next capture runs again from scratch. A capture already running for the previous revision is discarded instead of published, so it cannot overwrite the artifact the editor moved to.
A design session's worktree is branched from one source SHA. Once it holds
unapplied edits, switchSource refuses rather than leave the canvas on a
revision those edits were never written against — apply or revert them first, or
pass { discardEdits: true }.
Check a frame at the widths you support
A frame resizes by corner drag or by the W/H boxes. Name the widths your application actually guarantees and they become one-click buttons next to those boxes, so moving between them costs nothing to remember:
<PygmalionEditor
viewportPresets={[
{ label: '480', width: 480, height: 800, description: 'Minimum supported width' },
{ label: 'Default', width: 1280, height: 800 },
]}
/>Omit height to change width alone and leave the frame's height as it is. The
button reads as active while the frame sits at that size, and Reset to default
size still returns the frame to the viewport its manifest authored.
If the first declared canvas is a large asset catalog, open the working canvas directly and avoid rendering that catalog during startup:
<PygmalionEditor initialCanvas="Screens" />Screens that never stop moving
A capture waits for the document to hold still. A screen with an elapsed clock, a
level meter, or a marquee never does, so it fails on stabilize no matter how
long it waits. Declare those regions and their mutations stop counting as churn:
captureStoryboardCase({
// ...
stability: {
volatileSelectors: ['[data-testid="elapsed-time"]', '[data-live-meter]'],
},
});The wait itself samples every 100ms and needs three identical samples, up to a
4s ceiling — wide enough for a screen that repaints on a one-second beat.
attempts, requiredStableSamples, intervalMs, and minimumWaitMs are
adjustable for anything slower.
Register screens and scenarios
The host owns application-specific routes, fixtures, authentication, mock data, and screen names. Pygmalion owns preview scheduling, DOM capture, editing, source sessions, and QA.
Use createDesignScreenCollection to classify every discovered scenario as
either:
- a visual screen with a route or registered component fixture; or
- a non-visual behavior with an explicit reason.
A scenario must appear in exactly one category. Invalid references, missing coverage, and duplicate classifications fail before the editor opens.
Screen recipes support click, focus, hover, focus-visible, active,
fill, check, press, scroll, wait, and storage steps. Use deterministic recipes
and assertions so each frame represents a repeatable application state. The
screen state contract defines when a
variation is a frame, an interaction state, a condition state, an editable
parameter, a motion preview, or behavior-only QA. Rendered CSS and Web
Animations targets are automatically available in the right-panel Motion
controls with play, pause, reset, target isolation, and phase scrubbing.
Keep product routes separate from preview entry routes
Authentication and fixture gateways belong to the host application. When a
screen needs one, keep route as the canonical product address and declare the
routed gateway as previewRoute:
{
id: 'screen:account-security',
name: 'Account security',
section: 'Account',
route: '/account/security',
previewRoute: '/__preview__/account/security',
}Pygmalion boots, captures, caches, and interacts through previewRoute, while
inventory, coverage, and source identity continue to use route. The gateway
should be development-only, keep credentials out of URLs and client manifests,
and render or redirect to the real routed screen rather than a separately
assembled component fixture. Changing the gateway invalidates that frame's
preview cache automatically.
Declared screenFlows support the same split. Put the canonical starting
address in route and the host gateway in previewRoute; the flow boots the
gateway and still reports the canonical route to capture preparation.
Keep user journeys explicit instead of relying only on source navigation inference. Paths expand into adjacent graph edges, and source-discovered edges are still merged underneath them:
const storyboard = {
startScreenIds: ['screen:entry'],
paths: [
{
id: 'checkout',
label: 'Checkout',
screenIds: ['screen:entry', 'screen:cart', 'screen:confirmation'],
},
],
};
<PygmalionEditor storyboard={storyboard} />Every id is checked against the resolved screen catalog. Unknown endpoints and
unreachable screens stay visible as graph diagnostics instead of disappearing
from the sidebar. Code-only redirects or fixtures can be classified through
nonVisualScenarioIds with ids created by
createStoryboardRouteScenarioId(route).
The editor shell and frame geometry mount before storyboard discovery finishes. Cached or live route content remains gated until baseline environment and source digests settle, so immediate canvas paint cannot consume a provisional preview identity. Selecting an already visible frame in the sidebar preserves the camera; an offscreen frame is revealed with the smallest pan possible and never changes the user's zoom.
Capture artifact lifecycle
Import the reusable capture primitives from
@pygmalionjs/pygmalion/storyboard. Pygmalion owns isolated browser contexts,
bounded scheduling, inert DOM snapshots, screenshots, QA diagnostics, compact
versioned artifacts, exact revision validation, and atomic artifact
publication. The Vite integration serves only an artifact whose namespace and
source revision match the active editor.
The host application supplies routes, environment presets, mock adapters, interaction recipes, and assertions. A host hook may install domain-specific network fixtures, but it must not fork the browser or artifact lifecycle.
Artifact v3 preserves every registered frame with one explicit outcome:
ready, rendered-with-qa-failure, or capture-error. QA failures can retain
their last stable DOM and screenshot while CI remains red. A failed strict
capture therefore cannot silently leave an older artifact active.
When previewArtifactEndpoint is configured, an embedded artifact with another
namespace or source revision is treated only as a visibly outdated, last-known
preview. It does not count toward data-preview-warm. Each frame exposes its
exact-resolution lifecycle through data-artifact-resolution: checking,
outdated, generating, error, or exact. Transport, validation, and
generation failures stay visible on the frame with a retry action. A current
live snapshot or exact endpoint response clears the stale state.
Editing workflow
For Icons, Atoms, and Components, select an item in Assets to open its
catalog frame and select the source implementation automatically. The trailing
+ action remains insertion-only, so opening an asset never mutates another
frame.
- Open Screens. Preview warm-up starts without requiring frame clicks.
- Select a frame from the sidebar to center it on the canvas.
- Select a DOM layer or registered component.
- Choose Shared component or This screen as the edit scope.
- Change layout, content, props, or supported structure.
- Review the affected frame list and generated source diff.
- Run visual QA, then apply the change to the isolated design session.
- Use Reset changes to discard all unapplied edits and editor history.
Shared edits update detailed active frames and lightweight inactive frames in the same render cycle. A frame does not need to be activated before it reflects a shared change.
Local development
npm install
npm run devValidation commands:
npm test
npm run typecheck
npm run build:lib
npm run lint:languageProject documentation
- Architecture defines the package and host boundary.
- Design compiler and component connections defines the versioned IR, target adapters, diagnostics, and external design mappings.
- Screen state contract defines frame versus same-frame placement and held pseudo-state capture.
- Editor usability defines interaction coverage.
- Product readiness defines quality gates and remaining work.
- Contributing defines the required commit format.
