<pinbox-toolbar>, published as @autono/pinbox-toolbar. It renders inside a
shadow root, has no runtime dependencies, and ships two build artifacts: an ESM
build for bundlers and a standalone IIFE bundle for a script tag.
Pick one of the four routes below. They all end in the same place — one
<pinbox-toolbar> element on the page, pointed at a hub URL.
Dev-server plugin
Vite. Zero config — finds or starts the hub, injects the toolbar in dev only.
Framework wrapper
React, Vue, Svelte. You control where and when it mounts.
Bundler import
Any bundler.
Pinbox.init(...) behind a dev-only guard.Script tag
No build step. Two tags on the page.
Install
This package deliberately declares no
engines field, unlike the rest of
pinbox. It is browser code — it installs into your toolchain, whatever that is.Script tag
The IIFE bundle isdist/toolbar.iife.js inside the package. Serve it from your
own static path (or an npm CDN that mirrors the package) and call init:
Pinbox, and the API sits flat on it:
Loading the bundle also registers the element, so you can skip
init entirely
and write the tag yourself with hub and token attributes:
ESM (bundler)
Importing the package has a side effect: it registers<pinbox-toolbar>. That
is intentional, and it is why the package sets sideEffects to a glob rather
than false — a bundler that tree-shakes the import away would leave the
element undefined and the toolbar would silently never mount.
Pinbox.init creates the element, configures it, appends it to document.body,
and returns it. Configuration is applied before insertion — the element opens
its connection in connectedCallback, so anything configured after it is in the
DOM arrives too late.
Config
The toolbar never renders a login. If your hub authenticates requests, the
host app already knows who the user is — pass a callback that mints or fetches a
token for them:
Authorization: Bearer header for HTTP requests and, at
WebSocket upgrade, as a pinbox.token.<token> subprotocol — a browser cannot set
headers on an upgrade, so the token rides the subprotocol instead. If getToken
rejects, the toolbar falls back to an empty token rather than failing to mount.
targeting, anchorAttribute, and project are accepted by the type but not
yet wired to behavior. Element targeting is DOM hit-testing today. Don’t build
on those three.Attributes
The element reads two attributes when no config was passed programmatically:configure() (which Pinbox.init does for you) wins over
attributes. Config is read once, on mount — the element does not support live
reconfiguration. To change endpoints, remove the element and create a new one.
Framework wrappers
Each wrapper is a subpath export. They are thin: create the element, configure it, insert it, remove it on unmount. All three exist because refs and directives attach after insertion, which is too late for this element.- React
- Vue
- Svelte
display: contents host div
and mounts the element into it. Config forwards once on mount; pass a key to
remount with a different endpoint.Vite
The Vite plugin is the least work: it finds a running hub or starts one, then injects the toolbar into every dev page.1
Hub discovery
On dev-server start the plugin looks for a healthy hub — an explicit
hub
option if you passed one, otherwise the port in .pinbox/server.json, probed
with GET /health.2
Daemon adoption
If nothing answers, it spawns
pinbox serve detached and polls for up to 10
seconds. The daemon is adopted, never owned: the plugin registers no teardown,
and the daemon manages its own idle exit.3
Injection
A module script is injected into the served HTML. It imports the toolbar,
appends one
<pinbox-toolbar>, and sets its hub and token attributes.
Re-running after HMR reuses the existing element.apply: "serve", which is the single mechanism keeping it out of
production. During vite build it fires no hooks and emits no pinbox references.
Plugin options
disabled: true
returns a plugin with a name and no hooks, so pinbox({ disabled: !dev }) is
legal without juggling holes in the plugins array.
If the hub never comes up, the plugin logs one [pinbox] warning and serves an
empty module. A dead hub degrades the toolbar; it never breaks your dev server.
Supported Vite majors: 5, 6, 7, and 8.
The plugin bakes the local hub’s bearer token into a dev-only module. The hub
binds
127.0.0.1, so a LAN peer who reads it (say, from vite --host) still
cannot reach the hub, and any process on your machine could already read the hub
state file directly. Never serve this from a production build.Next.js
next.config, and under the App Router the page shell is a server component with
no client entry point to extend. Rather than guess at build internals, the
wrapper does the part it can do honestly and leaves the rest to you.
So: wrap the config and mount the component.
withPinbox returns the same config object it was given, never a clone, so it
composes with other withX wrappers in any order. It is a no-op when NODE_ENV
is production or when you pass disabled: true. The hub check is
fire-and-forget so it cannot stall next dev, and it never throws.
It takes the same options as the Vite plugin:
Finding the hub URL
The local hub binds127.0.0.1 on an ephemeral port, so there is no fixed
URL to hardcode. The port is written to .pinbox/server.json in your project:
endpoint at a hub
you deploy yourself. The bearer token and pid are not in the repo; they live
in your XDG state directory with mode 0600.
Using the toolbar
Once it is on the page:
Keystrokes are ignored while a text field has focus. In placing mode, moving the
cursor outlines the deepest element under it; clicking places a draft. Nothing
reaches the hub until you submit the first comment —
Esc or a click away
discards the draft with nothing written.
Screenshots are cropped and encoded to WebP in the browser, uploaded separately,
and the pin carries the returned path, never image bytes. Capture is
best-effort: where the browser cannot do it, the pin ships with its structured
capture alone.
Offline behavior
The toolbar keeps a mirror inlocalStorage, namespaced per endpoint: the event
cursor, the last known pin list, and an outbox of pins drawn while disconnected.
- A dropped socket costs no events. It reconnects with jittered backoff from 1s to 30s and replays from its stored cursor.
- On reconnect, the hub wins on status and the client wins on new pins.
- Storage failures — private mode, a full quota — degrade the mirror and never surface as an error.