@devfellowship/ux-paths-capture
v0.1.1
Published
The DFL UX Paths capture producer: a build-time data-source stamp, a deterministic browser, and the walk that turns a rendered screen into the regions.json that @devfellowship/ux-paths-spec describes.
Downloads
443
Maintainers
Readme
@devfellowship/ux-paths-capture
The producer half of the UX map: it stamps a build with the source file and
line that wrote each element, walks the rendered page, and writes the
regions.json that @devfellowship/ux-paths-spec describes.
Together they let a designer click a region of a screenshot and get an exact file — with no model call, and with no clone of the application's repository on the reader's side.
npm install --save-dev @devfellowship/ux-paths-capture@^0.1.1Why this is a package
Two applications capture themselves this way, and they live in different GitHub organizations, so no token in either one can read the other's source. A copy would have been the third implementation of one contract — the reader half already recorded the second as debt in its own header.
Two producers drift the way two readers already did, and the drift is invisible: both stay green while a click resolves to the wrong file. npm crosses the boundary that a repository cannot.
🚨 The stamp is build-time and capture-only
It must never reach production. Two independent reasons:
- Weight. One attribute per host element, on every element of every screen. It is dead payload for an end user.
- Disclosure. The stamp publishes an application's internal file layout into the DOM, where anyone who opens dev tools reads it. For a closed-source application that is a disclosure control, not a size concern.
The gate is an environment variable that is absent by default. It is not the bundler mode: a capture build is a production-mode build, because the artifact under capture has to be the artifact that ships, minus the stamp — so a test on the mode is true exactly when the stamp is wanted and true for the real release.
Prove the default over the artifact, not over the config, with the bin this package ships:
# the release build — expect ZERO
npx ux-paths-assert-no-stamp dist
# the capture build — expect MORE THAN ZERO
npx ux-paths-assert-no-stamp dist --expect-presentRun both in CI. The second is what keeps the first honest: a grep for a string that is normally absent passes for free, and would keep passing if the detector broke. The bin also self-tests its own detector on every run, and refuses an empty or missing directory.
⚠️ A leak is the attribute BOUND TO A FILE AND LINE, not the words
data-source appearing somewhere. An application that explains its own capture
pipeline puts that name into user-facing copy, and the copy is bundled — the
first version of this detector counted the name alone and failed a perfectly
clean build. A guard that fires on the word for the thing is one an engineer
learns to override, and an overridden disclosure control is worse than none.
The three entries
| import | needs | holds |
|---|---|---|
| @devfellowship/ux-paths-capture | nothing | the stamp transform, the component namer, the region builder, the artifact detector |
| …/vite | vite (optional peer, types only) | the stamp as a Vite plugin |
| …/playwright | @playwright/test (optional peer, types only) | the deterministic browser, the DOM walk, the pair writer |
The split keeps the root importable by something with no bundler and no browser — a CI guard, an MCP tool, a plain Node script.
Stamp a build
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import { sourceStamp } from "@devfellowship/ux-paths-capture/vite";
export default defineConfig({
plugins: [
react(),
// Returns `undefined` when the switch is off, and `undefined` is a valid
// member of `plugins` — so there is one gate, in one place.
sourceStamp({ root: __dirname }),
],
});The default switch is UX_PATHS_SOURCE_STAMP. An application that already has
its own spelling keeps it:
sourceStamp({ root: __dirname, envVar: "MY_APP_SOURCE_STAMP" });The shared thing is the gate, not the string. Renaming a switch that a working capture job already sets would disable that job silently.
Capture a screen
The package owns everything that is the same for every application: the
deterministic browser, the DOM walk, and the geometry. It deliberately does
not own navigation or the settle policy — one app waits on a data-testid,
another shoots until two consecutive images hash identically, a third stubs an
auth gate first. Those are properties of the app, not of the map.
import { chromium } from "@playwright/test";
import {
CAPTURE_CONTEXT_OPTIONS,
CAPTURE_LAUNCH_ARGS,
applyFreezeStylesheet,
installDeterminism,
writeCapturePair,
} from "@devfellowship/ux-paths-capture/playwright";
const browser = await chromium.launch({ args: [...CAPTURE_LAUNCH_ARGS] });
const context = await browser.newContext(CAPTURE_CONTEXT_OPTIONS);
const page = await context.newPage();
await installDeterminism(page); // BEFORE the first navigation
await page.goto("/login");
await applyFreezeStylesheet(page); // AFTER every navigation
// …your own settle policy here…
const png = await page.screenshot({
fullPage: false,
animations: "disabled",
caret: "hide",
scale: "css",
});
await writeCapturePair(page, {
outDir: "capture/login",
screen: "login",
root: process.cwd(), // where a stamped path is resolved from
png,
});That writes screenshot.png and regions.json side by side.
The two files are a PAIR
regions.json describes the geometry of exactly one image, in that image's own
pixel space — CSS pixels, deviceScaleFactor: 1, viewport-clipped. A rect
placed next to a different image is worse than no rect at all.
Viewport-clipped and not fullPage for a concrete reason:
getBoundingClientRect() speaks viewport coordinates, and Chromium resizes
the viewport to take a full-page shot — which moves fixed/sticky elements
and can mount content that was not mounted when the rects were read.
🧊 Pixel stability is a one-host promise
Everything downstream compares two screenshots and asks whether the diff reached zero, so the freezing is part of the contract. But glyph rasterisation belongs to the machine: a different FreeType or fontconfig renders the same text with different anti-aliasing. Two captures on one host are byte-identical; two captures on different hosts are not promised. A comparison across host classes must run in one container to mean anything.
performance.now() is left real on purpose — freezing it stalls React's
scheduler and every requestAnimationFrame loop, trading a cosmetic wobble for
a hang.
What is not attributed, and why that is correct
- Third-party components emit their own DOM from their own compiled JS. The bundler never sees their JSX.
- Pre-compiled workspace packages have already turned their JSX into
jsx()calls before the bundler runs. First-party code, still unattributed. React.createElementanddangerouslySetInnerHTML— no JSX tag, no stamp.
A region that lands on unattributed DOM resolves through its nearest stamped ancestor, which is the file that mounted the third-party component. That is the honest answer, and it is the one a designer can act on.
Reading a map back
resolveRegionAt is re-exported here, but it is defined in
@devfellowship/ux-paths-spec. A viewer should import it from there and
skip this package entirely — the reader half has zero runtime dependencies and
is meant for a browser.
License
MIT
