npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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.0

Why 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:

  1. Weight. One attribute per host element, on every element of every screen. It is dead payload for an end user.
  2. 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-present

Run 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() and new 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 from new 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.createElement and dangerouslySetInnerHTML — 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