@devfellowship/ux-paths-capture
v0.4.0
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.
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.4.0Why 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.
Universal screens (react-native on the web) — 0.4.0
A screen built from View / Text / Pressable of react-native, rendered by
react-native-web, has no lowercase tag, so the host-element rule stamps nothing.
From 0.4.0 an element whose tag is bound by an import from react-native (or
react-native-web) gets dataSet={{ source: "<file>:<line>" }}, which
react-native-web renders as the same data-source="<file>:<line>" attribute. No
reader changes. ux-paths-assert-no-stamp detects the dataSet form in a
bundle too, because there the attribute name is built at runtime.
🚨 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 canvas-ink reading, 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.
The boundary is "app-specific", not "anything to do with settling":
readCanvases and settleCanvases are in the package because an empty
<canvas> is a fact about the platform, read identically everywhere. They
measure and report; they never refuse. See below.
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 canvas = await settleCanvases(page); // null when the screen has no canvas
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.
📐 Capture at another viewport — VIEWPORT_PRESETS (0.3.0)
A capture is a photograph of a screen at one viewport, and until 0.3.0 that viewport was a constant. It is now a parameter.
import {
VIEWPORT_PRESETS,
captureContextOptions,
captureDirName,
resolveViewportSpec,
screenshotMetaFor,
} from "@devfellowship/ux-paths-capture/playwright";
const { name, preset, custom } = resolveViewportSpec(process.env.VIEWPORT ?? "desktop");
const context = await browser.newContext(captureContextOptions(preset));
// …capture as above…
await writeCapturePair(page, { outDir: captureDirName("login", preset), /* … */ });| preset | size | flags |
|---|---|---|
| desktop (default) | 1280x800 | — |
| mobile | 390x844 | isMobile, hasTouch |
Both are deviceScaleFactor: 1.
captureContextOptions("desktop") is deep-equal to CAPTURE_CONTEXT_OPTIONS,
and a unit test asserts it. CAPTURE_VIEWPORT and CAPTURE_CONTEXT_OPTIONS
stay exported and stay 1280x800 — nothing a consumer already imports moved.
🚨 isMobile is the field that matters, and it is invisible in the image. A
390x844 context without it is a narrow desktop, not a phone: every
@media (hover: hover) and pointer: fine branch still takes the desktop path,
so the capture photographs a layout no phone user is ever shown — and it does so
deterministically, which is what makes the mistake durable. That is why
resolveViewportSpec("390x844") returns the mobile preset, flags included,
rather than a bare size. A WxH matching no preset comes back with
custom: true so the caller can say so out loud.
🚨 deviceScaleFactor stays 1, not 3. Playwright's own iPhone 13 preset is
DPR 3. Adopting it would be a new determinism claim (nothing here is measured at
3) and a new region-projection claim (scale: 'css' shares one coordinate space
with getBoundingClientRect() only at DPR 1). The price is that a mobile image
is 390x844 CSS pixels rather than 1170x2532 device pixels: the true mobile
layout at 1x, with the region map still true.
Where the pair lands. captureDirName writes the default viewport to
<screen_id>/ exactly as before, and every other viewport to a flat
<screen_id>@<WxH>/. The file names inside never change, and regions.json
records the real viewport, so a pair is self-describing even out of its
directory. Nested <screen_id>/<WxH>/ was rejected: it breaks every reader that
treats the directory name as the screen id.
screenshotMetaFor(preset) returns the { platform, orientation, viewport }
triple for a screens[].screenshots[] entry, so a producer of an observed-flows
document does not hard-type web/landscape again.
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.
🕒 new Date() is frozen. Date.now() runs on a virtual clock
Changed in 0.2.0, and it changes behaviour for every consumer.
Before 0.2.0 both were frozen. That made every elapsed-time measurement return
zero, which silently disables lodash.debounce, lodash.throttle and anything
else that decides by subtracting two clock readings — Date.now() - last >= wait
is 0 >= 1, false, forever. kapsule (under react-force-graph-2d) schedules
its digest that way, so a force graph never laid out and a blank canvas was
published as the screen, with a stable digest and an exit code of 0.
So now:
| clock | behaviour |
|---|---|
| new Date() | frozen at the epoch — this is what reaches the DOM |
| Date.now() | epoch + virtual elapsed, and virtual elapsed advances only when a setTimeout / setInterval callback fires, to that timer's due time |
A timer armed with setTimeout(fn, 300) therefore observes exactly 300 ms when
it runs, on an idle host and a loaded one alike. What that trades:
Date.now()andnew Date().getTime()no longer agree, by the virtual elapsed time.- Determinism became conditional. A frozen constant is identical on every run regardless of what the page does; a virtual clock is identical only while the page schedules the same timers in the same order. Byte-stability is now a measurement, not a property of this file — take it on your own capture.
- A raw
Date.now()value printed on screen can move. A formatted date cannot: those come fromnew Date().
installDeterminism(page, { clock: "frozen" }) restores the old behaviour
exactly, for a capture that needs the two clocks to agree and has nothing in its
render path that measures elapsed time.
🎨 A still page is not a painted page
settleCanvases(page) waits until every <canvas> carries INK — more than one
distinct pixel value in its own back-buffer — and until that ink stops changing.
Both facts are read out of the canvas with getImageData, not waited out.
It exists because two identical BLANK frames hash the same, so an empty plot
passes a shoot-until-stable check on the first attempt. It returns null on a
screen with no canvas, and { painted: false, reason } when the budget runs out.
It reports; it does not refuse. writeCapturePair never consults it. An
empty canvas is a defect in one app and the correct first frame of a blank
drawing surface in another, and this package cannot tell them apart — so the
decision to withhold the image is the consumer's, and it should make it: a hole
in a map is visible, a confident blank picture is not.
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
