<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. Config that first completes after
insertion (attributes set late, or a late configure() call) still starts the
connection at the end of that tick, but configure-then-insert remains the
recommended order: it is synchronous and has no window where the toolbar is
visible but not yet connected.
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, or at the end of the tick in which
it first becomes complete — and 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, sets
hub and token attributes on one <pinbox-toolbar>, and only then appends
it — the element reads its config on insertion. 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:
The same actions sit on the command bar and, minimized, on the puck’s fan menu.
Keystrokes are ignored while a text field has focus, and modifier chords
(
⌘P, Ctrl+I) always belong to the page. The toolbar sees keys before your
app’s own handlers and consumes only the ones that did something — an Esc
with nothing open is your Esc. Drag the grip on the bar’s left edge to move
it; double-click the grip to put it back.
In placing mode, moving the cursor outlines the deepest element under it;
clicking places a draft. The draft composer offers Ask agent (the default)
or Note: a note is a comment pin, a remark for people that the hub never
routes to an agent. Nothing reaches the hub until you submit — Esc or a click
away discards the draft with nothing written.
Pins that wait on an agent show THINKING, then a quiet WAITING FOR AGENT, then
NO RESPONSE after ten minutes (or sooner when the hub reports no agent session
listening), with Nudge and Resolve on the card and a bulk resolve in the
inbox. Every inbox row can be resolved or unresolved in place, and pins linked
to the same tracker item group under a header with a Resolve-group action.
Screenshots default to a DOM snapshot of the target element: cloned, styles
inlined, drawn to a canvas, no permission prompt. Images inside it render as
neutral boxes. Press S (or the camera button) for tab capture — real
pixels, and Chrome asks to share the tab once per page load. Either way the
image is encoded to WebP in the browser, uploaded separately, and the pin
carries the returned path, never image bytes. Capture is best-effort and
bounded: past four seconds, or 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.