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

@agent-scope/render

v1.20.0

Published

Render paths for React components: Satori (simple/flexbox) and Playwright BrowserPool (complex)

Readme

@agent-scope/render

Dual-path render engine for React components. Routes simple (flexbox-only) components through a pure Node.js Satori pipeline, and complex components through a warm Playwright BrowserPool. A RenderMatrix generates the full Cartesian product of prop axes; SpriteSheetGenerator composites the results into a single annotated PNG.

Installation

npm install @agent-scope/render
# or
bun add @agent-scope/render

Peer dependencies: react, react-dom.

What it does / when to use

| Use case | Choose | |---|---| | Flexbox-only component, screenshot needed fast (~8 ms) | SatoriRenderer | | CSS grid, absolute positioning, animations, real browser CSS | BrowserPool | | Render all prop combinations in a grid | RenderMatrix | | Composite results into a single labelled PNG | SpriteSheetGenerator | | Layout context stress-testing | contextAxis + RenderMatrix | | Edge-case prop stress-testing | stressAxis + RenderMatrix |


Architecture

                          complexityClass?
                               │
              ┌────────────────┴────────────────┐
           "simple"                          "complex"
              │                                  │
    SatoriRenderer                          BrowserPool
  React → Satori SVG                   Playwright pages
  → resvg-js PNG                       (inject-don't-navigate)
              │                                  │
              └─────────────┬────────────────────┘
                            │
                       RenderResult
                            │
              ┌─────────────┴───────────────┐
         RenderMatrix               SpriteSheetGenerator
    (Cartesian product)               (PNG composite)

Satori path

SatoriRenderer runs entirely in Node.js with no browser overhead:

  1. Load font (Inter .woff by default; custom TTF/OTF/WOFF supported — not woff2, opentype.js limitation)
  2. Wrap element with mock providers (ThemeContext, LocaleContext, generic fallbacks)
  3. Call satori(element, { width, height, fonts }) → SVG string
  4. Call new Resvg(svg).render().asPng() → PNG Buffer

Target: ~8 ms per render at 375×812.

BrowserPool path

BrowserPool maintains N browser instances × M pages per browser. The "inject-don't-navigate" pattern keeps pages warm:

  1. init() launches Chromium instances and pre-loads each page once with a skeleton HTML containing window.__renderComponent, window.__componentRegistry, and window.__registerComponent
  2. render(name, props) calls page.evaluate(() => window.__renderComponent(name, props)) — no page.goto() between renders
  3. Waits for window.__renderReady === true
  4. Screenshots the component bounding box (not the full viewport)
  5. Optionally extracts DOM tree, computed styles, console output, and accessibility info

API Reference

SatoriRenderer

import { SatoriRenderer } from '@agent-scope/render';

const renderer = new SatoriRenderer(config?: RendererConfig);

RendererConfig

interface RendererConfig {
  /** Path to a TTF/OTF/WOFF font file. NOT woff2 — opentype.js limitation.
   *  Defaults to bundled Inter woff (@fontsource/inter). */
  fontPath?: string;
  /** Font family name. Defaults to "Inter". */
  fontFamily?: string;
  /** Default viewport. Defaults to { width: 375, height: 812 }. */
  defaultViewport?: ViewportOptions;
}

Methods

// Pre-load and cache the font (call before the first render for consistent timing).
renderer.preloadFont(): Promise<void>

// Render a React element to PNG.
renderer.render(
  element: React.ReactElement,
  options?: SatoriRenderOptions,
  descriptor?: Pick<ComponentDescriptor, 'requiredContexts'>,
): Promise<RenderResult>

// Shorthand: render at explicit viewport size.
renderer.renderAt(
  element: React.ReactElement,
  width: number,
  height: number,
  descriptor?: Pick<ComponentDescriptor, 'requiredContexts'>,
): Promise<RenderResult>

SatoriRenderOptions

interface SatoriRenderOptions {
  viewport?: { width: number; height: number };
  container?: Partial<ContainerOptions>;
  capture?: Partial<CaptureOptions>;
  environment?: EnvironmentOptions;
}

interface ContainerOptions {
  type: 'centered' | 'flex-row' | 'flex-col' | 'none';
  padding: number;      // pixels, applied to all sides
  background?: string;  // CSS colour string
}

interface CaptureOptions {
  screenshot: boolean;  // default: true
  styles: boolean;      // default: true
  timing: boolean;      // default: true
}

interface EnvironmentOptions {
  viewport?: ViewportOptions;
  theme?: string;   // passed to mock ThemeContext
  locale?: string;  // e.g. "en-US"
}

Example

import React from 'react';
import { SatoriRenderer } from '@agent-scope/render';

const renderer = new SatoriRenderer();
await renderer.preloadFont();

const result = await renderer.render(
  <button style={{ padding: 16, background: '#0070f3', color: '#fff' }}>
    Click me
  </button>,
  { viewport: { width: 375, height: 200 } },
);

console.log(result.width, result.height, result.renderTimeMs);
// → 375 200 7.4

BrowserPool

import { BrowserPool } from '@agent-scope/render';

const pool = new BrowserPool(config?: BrowserPoolConfig);
await pool.init();

BrowserPoolConfig

interface BrowserPoolConfig {
  /**
   * Named size preset. Mutually exclusive with `size`.
   * Defaults to "local".
   *
   * | Preset       | browsers | pagesPerBrowser | max concurrency |
   * |--------------|----------|-----------------|-----------------|
   * | local        | 1        | 5               | 5               |
   * | ci-standard  | 3        | 15              | 45              |
   * | ci-large     | 6        | 20              | 120             |
   */
  preset?: 'local' | 'ci-standard' | 'ci-large';
  /** Custom size config — overrides preset when provided. */
  size?: { browsers: number; pagesPerBrowser: number };
  viewportWidth?: number;   // default: 1440
  viewportHeight?: number;  // default: 900
  /** Max ms to wait for a free slot before acquire() rejects. Default: 30_000. */
  acquireTimeoutMs?: number;
}

Methods

// Launch browsers and pre-load skeleton HTML. Must be called before render().
pool.init(): Promise<void>

// Render a registered component by name.
pool.render(
  componentName: string,
  props?: Record<string, unknown>,
  options?: RenderOptions,
): Promise<RenderResult>

// Acquire a free page slot manually (advanced — prefer render()).
pool.acquire(): Promise<PageSlot>

// Return a slot to the pool (wakes the next queued waiter).
pool.release(slot: PageSlot): void

// Gracefully drain in-flight renders then close all browsers.
pool.close(): Promise<void>

// Introspection
pool.totalSlots: number
pool.freeSlots: number
pool.activeSlots: number
pool.queueDepth: number
pool.isInitialized: boolean
pool.isClosed: boolean

RenderOptions

interface RenderOptions {
  captureDom?: boolean;        // capture DOM tree. Default: false
  captureStyles?: boolean;     // capture computed styles. Default: true
  captureConsole?: boolean;    // capture console output. Default: false
  captureA11y?: boolean;       // capture accessibility snapshot. Default: false
  viewportWidth?: number;      // override pool-level viewport
  viewportHeight?: number;
  timeoutMs?: number;          // wait for window.__renderReady. Default: 10_000
  screenshotPadding?: number;  // px around bounding box crop. Default: 24
  minScreenshotWidth?: number; // Default: 320
  minScreenshotHeight?: number;// Default: 200
}

Example

import { BrowserPool } from '@agent-scope/render';

const pool = new BrowserPool({ preset: 'ci-standard' });
await pool.init();

const result = await pool.render('Button', { label: 'Click me' }, {
  captureDom: true,
  captureConsole: true,
});

// result.dom.elementCount, result.console.errors, etc.
await pool.close();

Pool exhaustion & queuing (from browser-pool.test.ts)

When all slots are busy, acquire() queues callers FIFO and resumes them on release(). With acquireTimeoutMs: 50 and one slot:

const slot = await pool.acquire();
// Second acquire times out after 50 ms when no slot is released:
await pool.acquire(); // throws: "BrowserPool.acquire() timed out after 50ms"
pool.release(slot);

RenderMatrix

Generates the Cartesian product of all axis value combinations and renders each cell.

import { RenderMatrix } from '@agent-scope/render';

const matrix = new RenderMatrix(
  renderer,   // anything implementing MatrixRenderer
  axes,       // MatrixAxis[]
  options?,   // { complexityClass?, concurrency? }
);

const result: MatrixResult = await matrix.render();

MatrixAxis

interface MatrixAxis<T = unknown> {
  name: string;    // axis name, used as prop key
  values: T[];     // values along this axis
}

MatrixRenderer interface

interface MatrixRenderer {
  renderCell(
    props: Record<string, unknown>,
    complexityClass: ComplexityClass,
  ): Promise<RenderResult>;
}

Both SatoriRenderer and BrowserPool can be adapted to this interface.

MatrixResult

interface MatrixResult {
  cells: MatrixCell[];     // flat list, row-major order
  axes: MatrixAxis[];
  axisLabels: string[][];  // [axisIndex][valueIndex] → display label
  stats: MatrixStats;
  rows: number;            // last axis cardinality
  cols: number;            // product of remaining axes
}

interface MatrixCell {
  props: Record<string, unknown>;  // axis name → value for this cell
  result: RenderResult;
  index: number;                   // flat row-major index
  axisIndices: number[];           // per-axis indices
}

interface MatrixStats {
  totalCells: number;
  totalRenderTimeMs: number;
  avgRenderTimeMs: number;
  minRenderTimeMs: number;
  maxRenderTimeMs: number;
  wallClockTimeMs: number;
}

Cell ordering (from matrix.test.ts)

First axis iterates slowest (row-major). For axes [['A','B'], [1,2]]:

index 0 → { x: 'A', y: 1 }
index 1 → { x: 'A', y: 2 }
index 2 → { x: 'B', y: 1 }
index 3 → { x: 'B', y: 2 }

Example: 3×3×2 = 18 cells

const matrix = new RenderMatrix(renderer, [
  { name: 'variant', values: ['primary', 'secondary', 'ghost'] },
  { name: 'size',    values: ['sm', 'md', 'lg'] },
  { name: 'disabled', values: [false, true] },
]);

const result = await matrix.render();
// result.cells.length === 18
// result.stats.avgRenderTimeMs — average per-cell render time

Grid dimensions

rows = last axis cardinality
cols = totalCells / rows

// Single-axis: [{ name: 'v', values: ['a','b','c'] }]
// → rows: 3, cols: 1

// Two axes: [2 values, 3 values]
// → rows: 3, cols: 2, cells: 6

cartesianProduct utility

import { cartesianProduct } from '@agent-scope/render';

cartesianProduct([[1,2], ['a','b']]);
// → [[1,'a'], [1,'b'], [2,'a'], [2,'b']]

SpriteSheetGenerator

Composites a MatrixResult into a single labelled PNG.

import { SpriteSheetGenerator } from '@agent-scope/render';

const gen = new SpriteSheetGenerator(options?: SpriteSheetOptions);
const { png, coordinates, width, height } = await gen.generate(matrixResult);

SpriteSheetOptions

interface SpriteSheetOptions {
  cellPadding?: number;      // px around each cell. Default: 8
  borderWidth?: number;      // border thickness. Default: 1
  background?: string;       // CSS hex. Default: "#f5f5f5"
  borderColor?: string;      // Default: "#cccccc"
  labelHeight?: number;      // top label row height. Default: 32
  labelWidth?: number;       // left label column width. Default: 80
  labelDpi?: number;         // label text DPI. Default: 72
  labelBackground?: string;  // Default: "#e8e8e8"
}

SpriteSheetResult

interface SpriteSheetResult {
  png: Buffer;                   // full composite PNG
  coordinates: CellCoordinateMap; // CellBounds[] indexed by flat cell index
  width: number;                 // total sprite sheet width in pixels
  height: number;                // total sprite sheet height in pixels
}

interface CellBounds {
  x: number;      // left edge in sprite sheet
  y: number;      // top edge in sprite sheet
  width: number;  // content width (excludes padding)
  height: number; // content height (excludes padding)
}

Layout rules (from sprite-sheet.test.ts)

  • Cells in the same column share the same x coordinate
  • Cells in the same row share the same y coordinate
  • Row y values increase monotonically: coords[0].y < coords[1].y < coords[2].y
  • Larger cellPadding → larger total sprite sheet dimensions
const gen = new SpriteSheetGenerator({ cellPadding: 12, background: '#fff' });
const { png, coordinates } = await gen.generate(matrixResult);

// Access cell 3's position:
const { x, y, width, height } = coordinates[3];

Composition Contexts

Ten built-in layout environments for use as RenderMatrix axes.

import { contextAxis, COMPOSITION_CONTEXTS, getContext } from '@agent-scope/render';

Available context IDs

| ID | Description | |---|---| | centered | Centered in viewport (flex, align/justify center) | | flex-row | Flex row with sibling placeholders | | flex-col | Flex column with sibling placeholders | | grid | CSS grid cell (3-column auto-fill) | | sidebar | Narrow 240 px sidebar | | scroll | Scrollable container, fixed 300 px height | | full-width | Stretches to full viewport width | | constrained | Centered, max-width 1024 px | | rtl | dir="rtl", lang="ar" — bidi layout testing | | nested-flex | Three levels of nested flex containers |

Usage

import { RenderMatrix, contextAxis } from '@agent-scope/render';

// All 10 contexts × 2 variants = 20 cells
const matrix = new RenderMatrix(renderer, [
  contextAxis(['centered', 'flex-row', 'sidebar', 'rtl']),
  { name: 'variant', values: ['primary', 'secondary'] },
]);

// Or use all 10:
const allContextMatrix = new RenderMatrix(renderer, [
  contextAxis(),
  { name: 'size', values: ['sm', 'md', 'lg'] },
]);

Helper functions

getContext('rtl')                         // → CompositionContext
getContexts(['rtl', 'centered'])          // → CompositionContext[]
contextAxis(['centered', 'rtl', 'grid'])  // → MatrixAxis<CompositionContext>

Content Stress Presets

Seven categories of edge-case prop values for RenderMatrix axes.

import { stressAxis, getStressPreset, ALL_STRESS_PRESETS } from '@agent-scope/render';

Available categories

| ID | Values | What it exercises | |---|---|---| | text.short | "", " ", "A", "OK", "Hello", "Submit", "Cancel" | Empty, whitespace, single-char | | text.long | 80–200 char strings, long words, 100-char runs | Overflow, text wrapping | | text.unicode | Japanese, Chinese, Korean, diacritics, emoji, zero-width space, RTL override | CJK, emoji, bidi | | text.rtl | Arabic and Hebrew strings, mixed Arabic+LTR | Right-to-left layout | | number.edge | 0, -0, 1, -1, NaN, Infinity, -Infinity, 999999, MAX_SAFE_INTEGER | Numeric extremes | | list.count | [], 1-item, 3-item, 10-item, 100-item, 1000-item arrays | Empty/huge lists | | image.states | null, "", broken URL, 1×1 data URI, 200×200, 4000×4000 | Image error states |

Usage

import { RenderMatrix, stressAxis } from '@agent-scope/render';

// 7 short-text values × 2 disabled states = 14 cells
const matrix = new RenderMatrix(renderer, [
  stressAxis('text.short'),
  { name: 'disabled', values: [false, true] },
]);

// Direct preset access:
const preset = getStressPreset('number.edge');
// preset.values  → [0, -0, 1, -1, NaN, Infinity, ...]
// preset.valueLabels → ['zero', 'neg-zero', 'one', ...]

RenderResult

Shared return type for both render paths.

interface RenderResult {
  screenshot: Buffer;        // PNG as a Buffer
  width: number;             // pixel width of the rendered component
  height: number;            // pixel height
  renderTimeMs: number;      // wall-clock render time in ms
  computedStyles: Record<string, Record<string, string>>;
  // BrowserPool only (when captureXxx options enabled):
  dom?: { tree: DOMNode; elementCount: number; boundingBox: BoundingBox };
  console?: { errors: string[]; warnings: string[]; logs: string[] };
  accessibility?: { role: string; name: string; violations: string[] };
}

The computedStyles key for the BrowserPool path is "[data-reactscope-root] > *" and captures: display, flexDirection, flexWrap, alignItems, justifyContent, gridTemplateColumns, position, width, height, color, backgroundColor, fontSize, fontFamily, fontWeight, borderRadius, boxShadow, opacity, transform, and more.


Structured error handling

import { safeRender, RenderError, detectHeuristicFlags } from '@agent-scope/render';
// or from the sub-path:
import { safeRender } from '@agent-scope/render/errors';

const outcome = await safeRender(
  () => pool.render('Button', props),
  { props, sourceLocation: { file: 'Button.tsx', line: 12, column: 0 } },
);

if (outcome.crashed) {
  console.error(outcome.error.heuristicFlags);
  // e.g. ['MISSING_PROVIDER', 'TYPE_MISMATCH']
} else {
  const png = outcome.result.screenshot;
}

HeuristicFlag values: MISSING_PROVIDER, UNDEFINED_PROP, TYPE_MISMATCH, NETWORK_REQUIRED, ASYNC_NOT_SUSPENDED, HOOK_CALL_VIOLATION, ELEMENT_TYPE_INVALID, HYDRATION_MISMATCH, CIRCULAR_DEPENDENCY, UNKNOWN_ERROR.


Real test payloads

Matrix: 2×2 produces 4 cells with correct prop sets (from matrix.test.ts)

const matrix = new RenderMatrix(renderer, [
  { name: 'variant', values: ['primary', 'secondary'] },
  { name: 'size',    values: ['sm', 'md'] },
]);
const result = await matrix.render();

// result.cells.map(c => c.props):
// [{ variant: 'primary', size: 'sm' }, { variant: 'primary', size: 'md' },
//  { variant: 'secondary', size: 'sm' }, { variant: 'secondary', size: 'md' }]

// result.stats:
// { totalCells: 4, avgRenderTimeMs: 12.5, minRenderTimeMs: 5, maxRenderTimeMs: 20 }

BrowserPool: clipped screenshot dimensions match bounding box (from browser-pool.test.ts)

// Component bounding box: 40×40, viewport: 1440×900
// result.width === 40, result.height === 40  (not 1440×900)

SpriteSheet: coordinate alignment (from sprite-sheet.test.ts)

const matrix = makeMockMatrixResult(3, 2); // 3 rows, 2 cols
const { coordinates } = await gen.generate(matrix);

// Col 0: cells 0,1,2 share x:
coordinates[0].x === coordinates[1].x === coordinates[2].x

// Row 0: cells 0 and 3 share y:
coordinates[0].y === coordinates[3].y

// y increases down rows:
coordinates[0].y < coordinates[1].y < coordinates[2].y

Used by

  • @agent-scope/cli — invokes BrowserPool and SatoriRenderer to render component screenshots during scope render runs
  • @agent-scope/manifest — provides ComplexityClass to route renders between paths