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

@tilewise/workbench-client

v0.3.0

Published

Connects a React prototype to the workbench's viewer: its screens, its controls and the protocol between them.

Readme

@tilewise/workbench-client

Connects a React prototype to the workbench's Proto viewer. The viewer shows the prototype in a frame, draws a device round it, lists its screens and states, its features and its components, shows each one's spec, and draws a form of controls beside it for the page on screen. This package holds everything the prototype needs for that; the prototype only declares what it has.

pnpm add @tilewise/workbench-client

React 19 is a peer dependency, so the app's own React is the one used.

Controls

A control is one value a reviewer can change from the viewer. There are five types, and the viewer draws one fixed row for each:

| Type | For | Its own fields | Value | | --- | --- | --- | --- | | variant | Another version of a screen or of one part of it | options, each { value, label } | One of the values | | boolean | Show or hide one thing | None | true or false | | number | A count between a min and a max | min, max, step | A number in range | | color | A color from the app's tokens, never a free hex | options, each { token, label } | One of the tokens | | text | One line of text, to try other wording | maxLength | A string |

Every control also has an id (unique across the app, never renamed), a label, a default, and an optional note, shown quietly under its row.

Declaring

Every component lives in a folder named after it, beside its test. A screen with a spec or controls, and every feature and component the viewer lists, holds its part there too, a .workbench.ts file:

// HomeScreen/HomeScreen.workbench.ts
import { defineScreen } from '@tilewise/workbench-client';

export const home = defineScreen({
  screen: 'home',
  spec: 'HomeScreen.md',
  controls: [
    {
      id: 'homepage',
      type: 'variant',
      label: 'Page',
      options: [
        { value: 'today', label: 'Original design' },
        { value: 'feed', label: 'Feed (old)' },
      ],
      default: 'today',
    },
    { id: 'bottom', type: 'boolean', label: 'Bottom section', default: true },
  ],
});

| Level | Declared with | Shows in the form | | --- | --- | --- | | Global | defineApp, beside the app's root component | On every page | | Screen | defineScreen({ screen, spec?, controls? }) | On that screen | | Feature | defineFeature({ feature, label, spec?, controls? }) | On any screen while the feature is drawn there, and with it alone | | Component | defineComponent({ component, label, spec?, controls? }) | With it alone |

Each returns one handle per control, under its id: home.homepage. The values' types come from the declaration, so home.homepage reads as 'today' | 'feed' with no as const.

A feature is one part of the app the viewer lists on its own: a page's block, a card, a sheet. A component is one of its building blocks: a button, a badge. Each is declared even with no controls, so the viewer lists it. feature and component are ids, unique among their kind and never renamed, as the viewer's links and approvals use them. A folder may declare several: one for each spec in it.

Specs

spec names a Markdown file beside the .workbench.ts: what the part is and how it behaves. The viewer shows it beside the part, with its images: ![…](today-page.webp) is the file beside the spec. It comes from the build, so an old commit shows its own spec.

Examples

A feature or a component is drawn alone from its examples, in a <name>.examples.tsx beside it: each the part with the props and the data it needs, under an id, in the order the viewer lists them.

// BagCard/BagCard.examples.tsx
import { defineExamples } from '@tilewise/workbench-client';
import { BagCard } from './BagCard';
import { bagCard } from './BagCard.workbench';

export const examples = defineExamples(bagCard, {
  full: { label: 'Ten items', render: () => <BagCard bag="b1" /> },
  empty: { label: 'Empty', render: () => <BagCard bag="b9" /> },
});

The app has exactly one defineApp, beside its root component:

// App/App.workbench.ts
import { defineApp } from '@tilewise/workbench-client';

export const app = defineApp({
  app: 'aitems',
  screens: allScreens(), // every screen and its states, each state with its jump
  useNavigation, // a hook: { go(state), at: { screen, state } }
  parts: import.meta.glob(['/src/**/*.workbench.ts', '!/src/App/App.workbench.ts'], {
    eager: true,
  }),
  specs: import.meta.glob('/src/**/*.md', { query: '?raw', import: 'default', eager: true }),
  assets: import.meta.glob('/src/**/*.{png,jpg,jpeg,gif,svg,webp}', {
    query: '?url',
    import: 'default',
    eager: true,
  }),
  examples: import.meta.glob('/src/**/*.examples.tsx', { eager: true }),
  controls: [{ id: 'seeall', type: 'variant', label: 'See all', options: [/* … */], default: 'ink' }],
});

parts, specs, assets and examples load every part, spec, image and example as the app starts. Give them by their paths from the app's folder, starting /src/: a part's path says where its folder is, so the viewer finds its spec and matches it to a pull request's files. As it starts, the app throws on two controls with one id, two features or two components with one id, a screen id that isn't in screens, a spec that isn't in specs, examples for a part that isn't in parts or two sets for one, a default that isn't one of the options or is outside min and max, and a second defineApp.

Values

A value comes from ?<id>=<value> in the address first (and is stored, so a link can hand a variant round), then from control:<id> in the browser's storage, then from the default. Every value is checked against its declaration: one that doesn't fit falls back to the default, a number is clamped and text is cut at maxLength. Only a picked value is stored, so a new default reaches everyone who left that control alone. Another tab of the app follows a change through storage.

In components

// HomeScreen/HomeScreen.tsx
import { boolean, ScreenScope, useControl, variant } from '@tilewise/workbench-client';
import { home } from './HomeScreen.workbench';

export function HomeScreen() {
  const page = useControl(home.homepage);
  const bottom = useControl(home.bottom);

  return (
    <ScreenScope screen="home">
      <StatusRow />
      {variant(page, { today: <TodayPage />, feed: null })}
      {boolean(bottom, { on: <BelowFeed />, off: null })}
    </ScreenScope>
  );
}
  • useControl(handle) reads a value and draws again when it changes; setControl(handle, value) and resetControl(handle) change it. A test sets the values it needs with setControl before it renders.

  • A component never compares a value to choose what to draw. It hands the value to the helper for its type, so the type checker sees every case:

    | Helper | Cases | | --- | --- | | variant(value, cases) | Every option, or some and other for the rest | | boolean(value, { on, off }) | Both | | number(value, cases) | Any exact numbers, and other | | text(value, cases) | Any exact lines, and other | | color(token) | None: color('lime') is 'var(--lime)' |

    A case is something to draw, or a function that gets the value. other's function gets it typed without the options listed beside it. The helpers take values, not handles, so they work anywhere.

  • A feature's useControl reports the feature as drawn on the screen of the nearest <ScreenScope>, until it unmounts, so the form shows a section for it. A feature that reads none of its controls calls useFeature(part) instead. Wrap each screen's content in one. A control that decides whether a feature is mounted at all lives with the parent that mounts it.

  • Global values are written to the root as html[data-<id>], and a number or color also as --tweak-<id>, for pure-CSS tweaks. A screen's or block's value, a feature's or a component's goes on the component's own root, as data-<id>, for its CSS.

  • Mark the part a control changes data-control="<id> …": after the viewer sets or resets it, the screen scrolls there.

  • Mark each screen's scrolling body data-workbench-body while it is the open one: on Full page, the app reports how tall the open screen is laid out whole from it.

The bridge

Mount <Bridge /> and <Examples /> once at the app's root. In the viewer's frame the bridge says hello with the manifest, sends where the app is, the features drawn on each screen and every value as they change, and does what the viewer asks. When the viewer shows a feature or a component, <Examples /> draws its examples over the app, one under the other, on the page's background; ?show=feature:<id> and ?example=<id> do the same from a link. It lays the app out for the device the viewer draws round it: --safe-top, --safe-bottom and html[data-platform]; until a device arrives, the app's own CSS holds the first phone's. When the viewer folds a foldable it asks for a picture of the screen, to show on the half that swings: the bridge takes it with modern-screenshot, loaded only then. Outside a frame it only opens the state ?at= names. docs/protocol.md has the protocol.

Local builds

// vite.config.ts
import { workbenchLocal } from '@tilewise/workbench-client/vite';

export default defineConfig({ plugins: [react(), workbenchLocal({ base: 'develop' })] });

With it, the app's dev server also answers the viewer's Local source: it lists this machine's branches and commits against base (the repository's default branch unless set), builds any commit in a git worktree into the git-ignored .builds/<sha>/, and checks a branch out, refused while anything is uncommitted. Builds are served under /__workbench/b/<sha>/, so the app's router takes basepath: import.meta.env.BASE_URL.

It also pulls a branch, fast-forward only (/__workbench/pull): git fetch, then the branch moves to its branch on GitHub, never merging or rebasing. The checked-out branch moves its files too, refused while anything is uncommitted, and installs at the repository's top when the lockfile changed, then restarts the dev server. Any other branch moves alone, refused when another worktree has it checked out. A branch with commits GitHub doesn't have, or with no branch there, is refused, with the reason.

It also reads workbench.json, at the repository's top, where the repository declares its setup: the fake API comes from it (below), and /__workbench/setup hands the checkout's to the viewer, as { name, viewers } (404 without the file, 422 when it isn't a setup), for a workbench that can't read it from GitHub.

It also reads the repository's files, as the workbench reads a pushed commit's from GitHub, so Live and a commit not pushed yet have them too: /__workbench/files/<ref>?under=<folder> lists the files under a folder (every one without it), as { files: [{ path, size }] }, and /__workbench/files/<ref>/<path> is one of them as it is, by its type, so an image shows as one. <ref> is live or a commit's full sha; anything else is refused (400).

  • Live: the working tree's files that git names, tracked, and untracked but not ignored, read from disk, so an ignored .env is never read. A tracked file deleted since isn't there, and a link is followed only within the repository.
  • A commit: its files as git keeps them, on this computer, pushed or not.

Paths are from the repository's top, whatever the dev server's root, separated by /. One that is absolute, has a .. or a backslash, or leads out of the repository is refused (400); a file or a commit that isn't there is 404.

Only the viewer may ask: from its origin (WORKBENCH_URL, or a viewer on localhost) with the dev server's token. The viewer has it from the link the dev server logs, which pnpm dev opens, or by connecting to the dev server's port: /__workbench/connect hands the token, and the repository it serves, to a page of those origins alone, as the browser names a page's origin and no site can claim another's. Without WORKBENCH_URL the dev server opens no viewer, and only a viewer on localhost can connect. An image or a link can't send the token, so one file also takes it in its address, ?token=: a request that names another origin is still refused, and a list takes it only in the header.

https

The dev server serves https, so Safari can use Local too. Safari blocks a secure page, as the viewer is, from reaching http://localhost, which Chrome and Firefox allow. The certificate comes from Vite's own @vitejs/plugin-basic-ssl. It is made for localhost, one for every project on the computer, and kept in ~/.workbench/https for 825 days, Apple's limit.

Each computer trusts it once, as the dev server's log says at each start:

  • Mac (Safari, and Chrome with it): open https://localhost:5173 in Safari, choose Show Details › visit this website, and enter your password.
  • Windows (Chrome and Edge): certutil -user -addstore TrustedPeople "%USERPROFILE%\.workbench\https\localhost.crt". TrustedPeople trusts this certificate alone, never one it signs.

https: false in the options, or WORKBENCH_HTTPS=0, serves http. WORKBENCH_HTTPS=1 serves https whatever the options say. An app that sets its own server.https keeps it. Under Vitest it is http, unless https is set.

An app in a workspace

The app may live in a folder of a pnpm workspace, such as apps/app/, with its dev server's root there. Each commit is built from that folder, or from the top for a commit from before the app moved there. A workspace commit installs its own packages (pnpm install --frozen-lockfile --prefer-offline), so its packages link to that commit's, never the checkout's. The folder is the dev server's root, whatever workbench.json says, and a local build installs and builds as above, without Proto's install and build.

A fake API

Declare it in workbench.json, at the repository's top, as Proto's api:

{
  "name": "Aitems",
  "viewers": {
    "proto": { "folder": "apps/app", "api": "../fake-api/src/index.ts" }
  }
}

api is the fake API's module, from Proto's folder: the app's folder from the top, or the top when it is '' or left out. Its default export is a fetch handler: a function from Request to Response, or an object with fetch, as a Hono app is. The app asks it at /api, and the handler is asked without that path, so /api/bags/b1 reaches it as /bags/b1. With no file, a file that isn't a setup, or no api in it, there is no fake API and /api is the app's own.

  • Live: the dev server answers it at /api, from its source. Vite loads it again when it changes, and the file is read on each request, so declaring the API or moving it needs no restart.
  • Builds: each build bundles the fake API its commit's own workbench.json declares (the checkout's, for a commit with no file of its own), with every package inside it, into .builds/<sha>/__api/, and answers it under the build, at /__workbench/b/<sha>/api/…. An old build asks the API it was built with, whatever the checkout's has become. The app asks `${import.meta.env.BASE_URL}api/…`, which is right in both.
  • Each build's API runs in a worker of its own: loaded on its first request and stopped after 30 minutes with nothing asked, so a visit's data lasts until then. The worker gets no environment variables, and the bundle itself is never served.

The api option overrides the file, for Live and for a commit with no file of its own:

workbenchLocal({ base: 'develop', api: { entry: '../fake-api/src/index.ts', path: '/api' } });

entry is the module from the app's folder, the dev server's root; path is where the app asks it, /api unless set.