@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-clientReact 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:
 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)andresetControl(handle)change it. A test sets the values it needs withsetControlbefore 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 andotherfor the rest | |boolean(value, { on, off })| Both | |number(value, cases)| Any exact numbers, andother| |text(value, cases)| Any exact lines, andother| |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
useControlreports 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 callsuseFeature(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, asdata-<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-bodywhile 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
.envis 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:5173in 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.jsondeclares (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.
