@bitskiff/ui-kit
Framework-free custom elements for the client page: joystick, dpad, button, slider, text, readout,
status, banner and a code scanner. They carry no SDK and no framework. Compose them into your
page's markup, then send their events on a channel of your own with
client.channel('fire').send(...).
Importing the package registers nothing. Call an element's own define*()—defineButton(),
defineJoystick(), defineDpad(), defineSlider(), defineText(), defineReadout(),
defineStatus(), defineBanner(), defineCodeScanner()—or defineAll() to register every one.
Registration is guarded either way, so calling one twice is harmless.
This page is the reference. The guides cover the rest: client pages · channels · controller input
Install
Take the tarball from the downloads page: npm install takes a URL as well as
a name.
npm install https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-ui-kit.tgz
To check a download, compare sha256sum bitskiff-ui-kit.tgz with the release's line at https://api.dev.bitskiff.com/v1/releases/<version>/digests, which the api serves apart from the downloads.
These are custom elements, so the package is browser-only. Importing it needs HTMLElement,
which a server does not have: an import evaluated during a server render throws
ReferenceError: HTMLElement is not defined. Import it from a client-side entry—a framework's
client directive, or a dynamic import() inside an effect that runs in the browser—and never
from a module your framework renders on the server.
Hello world
The registration line is the same in both languages, so it is shown once:
import { defineButton } from '@bitskiff/ui-kit';
defineButton();
<bitskiff-button action="fire" label="Fire"></bitskiff-button>
The client joins with your client key, a publishable key made with the console's a client page preset: see client pages.
import { BitskiffClient } from '@bitskiff/client';
const client = new BitskiffClient({ key: 'bsk_pk_...' }); // your client key
const fire = client.channel('fire'); // the channel name is yours
await client.join(location.hash.slice(1)); // your page read the code off its own link
const el: Element | null = document.querySelector('bitskiff-button');
el?.addEventListener('bitskiff-press', () => fire.send('down'));
el?.addEventListener('bitskiff-release', () => fire.send('up'));
import { BitskiffClient } from '@bitskiff/client';
const client = new BitskiffClient({ key: 'bsk_pk_...' }); // your client key
const fire = client.channel('fire'); // the channel name is yours
await client.join(location.hash.slice(1)); // your page read the code off its own link
const el = document.querySelector('bitskiff-button');
el?.addEventListener('bitskiff-press', () => fire.send('down'));
el?.addEventListener('bitskiff-release', () => fire.send('up'));
Every element follows that shape: an attribute-driven custom element that emits bitskiff-* events.
Your page listens for them and turns them into client.channel(name).send(...) calls. This package
never imports @bitskiff/client and never sends anything itself.
Subpaths
Each element also has its own subpath: ./joystick, ./dpad, ./button, ./slider, ./text,
./readout, ./status, ./banner and ./scanner (bitskiff-code-scanner, a camera QR/barcode
reader, whose registrar is defineCodeScanner()). Import one to leave the rest out of your bundle.
Those subpaths and the root are the whole surface. Nothing else in the package is importable, whatever the files inside it are named: a path this list does not carry does not resolve.
Element reference
Every attribute and event, per element. --bitskiff-* theme tokens are custom CSS properties: set
one on an ancestor to theme every element under it; THEME_TOKENS, exported from the package,
lists every token name with its light and dark default, each one a ThemeToken.
Each element is a tag, the class behind it and the registrar that defines it. The package root
exports the nine registrars, defineAll(), the nine classes, THEME_TOKENS and the five value
types below, and nothing else:
| Tag | Class | Registrar |
|---|---|---|
bitskiff-button | BitskiffButtonElement | defineButton() |
bitskiff-dpad | BitskiffDpadElement | defineDpad() |
bitskiff-joystick | BitskiffJoystickElement | defineJoystick() |
bitskiff-slider | BitskiffSliderElement | defineSlider() |
bitskiff-text | BitskiffTextElement | defineText() |
bitskiff-readout | BitskiffReadoutElement | defineReadout() |
bitskiff-status | BitskiffStatusElement | defineStatus() |
bitskiff-banner | BitskiffBannerElement | defineBanner() |
bitskiff-code-scanner | BitskiffCodeScannerElement | defineCodeScanner() |
The value types are Direction (a bitskiff-press detail from the dpad), Transport (the
transport attribute of bitskiff-status), BannerState (the state attribute of
bitskiff-banner), ScannerState (the state attribute of bitskiff-code-scanner) and
ThemeToken (one entry of THEME_TOKENS). Anything
else an element needs—a test double for the scanner, its detector seam, its constants—is on
that element's own subpath rather than on the root, so the root stays the list above.
bitskiff-button
A press-and-release control. Its face is the label attribute or slotted markup.
| Attribute | Type | Default | What it does |
|---|---|---|---|
action | string | '' | The name sent in the event detail below; yours to choose |
label | string | '' | The button's visible text and its accessible name |
variant | string | 'secondary' | Look: secondary, primary or danger |
haptic | boolean | false | Vibrates the device for 10ms on press. Android only: iOS Safari has no Vibration API |
disabled | boolean | false | Disables the button and drops it from tab order |
| Event | Detail | When it fires |
|---|---|---|
bitskiff-press | { action, madeAt } | The button is pressed down |
bitskiff-release | { action, madeAt } | The press is released |
bitskiff-dpad
Four cardinal direction keys; diagonals adds the four diagonal ones, for eight.
| Attribute | Type | Default | What it does |
|---|---|---|---|
diagonals | boolean | false | Shows the four diagonal keys alongside the four cardinal ones |
repeat | number | 0 | Milliseconds between repeated bitskiff-press events while a key stays held. 0 sends no repeats |
label | string | '' | Accessible name prefix for each key |
| Event | Detail | When it fires |
|---|---|---|
bitskiff-press | { direction, madeAt } on the press; repeats carry { direction } alone | A key is pressed, and again every repeat ms while it stays held |
bitskiff-release | { direction, madeAt } | A key is released |
direction is one of up, down, left, right, up-left, up-right, down-left,
down-right; the diagonals only fire while diagonals is set.
bitskiff-joystick
A spring-back stick. x grows to the right and y grows downward, the same orientation as the
DOM's clientX/clientY.
| Attribute | Type | Default | What it does |
|---|---|---|---|
dead-zone | number | 0.12 | Magnitude, 0 to 1, below which the stick reports { x: 0, y: 0 } |
rate | number | 60 | The most bitskiff-move events sent per second, 1 to 120 |
size | number | 160 | Diameter in pixels, 48 or larger |
label | string | '' | Accessible name for the stick's x and y axes |
| Event | Detail | When it fires |
|---|---|---|
bitskiff-move | { x, y } | The stick moves (rate-limited) or is released; x and y are each in [-1, 1]. Releasing sends one final { x: 0, y: 0 } immediately |
bitskiff-slider
| Attribute | Type | Default | What it does |
|---|---|---|---|
min | number | 0 | The lowest value |
max | number | 100 | The highest value |
step | number | 1 | Snaps the value to this grid. 0 disables snapping |
value | number | 0 | The current value; settable |
orientation | string | 'horizontal' | horizontal or vertical |
rate | number | 30 | The most bitskiff-change events sent per second while dragging, 1 to 120 |
label | string | '' | Accessible name |
id | string | '' | The standard DOM id. It rides every bitskiff-change detail, so one handler can tell two sliders apart |
| Event | Detail | When it fires |
|---|---|---|
bitskiff-change | { id, value } | The value changes (rate-limited while dragging), and once more, immediately, when a drag or a key press ends. id is the element's own id attribute |
bitskiff-text
A text field with a submit button. Submitting clears the field.
| Attribute | Type | Default | What it does |
|---|---|---|---|
placeholder | string | '' | Placeholder text for the empty field |
maxlength | number | 64 | The longest text the field accepts, 1 or more |
submit-label | string | 'Send' | The submit button's visible text and accessible name |
label | string | '' | Accessible name for the input field |
id | string | '' | The standard DOM id. It rides every bitskiff-submit detail, so one handler can tell two fields apart |
| Event | Detail | When it fires |
|---|---|---|
bitskiff-submit | { id, text } | The submit button is clicked, or Enter is pressed, while the field is non-empty |
bitskiff-readout
Display only: it shows the value a page binds and adds nothing to it.
| Attribute | Type | Default | What it does |
|---|---|---|---|
value | string | '' | The text shown |
label | string | '' | Accessible name |
color | string | 'fg' | One of fg, muted, accent, ok, warn, danger. An unknown name falls back to fg |
No events: this element is display only.
bitskiff-status
Display only. The pill is colored by transport and also prints the transport name, so no state
here is carried by color alone. The pill is hidden while the path is unknown, which is the
reading before the browser has settled on a candidate pair and the whole of what a Node client ever
reports; unknown takes the neutral tone, never a fault.
| Attribute | Type | Default | What it does |
|---|---|---|---|
transport | string | 'unknown' | One of udp, tcp, turn, unknown. Colors the pill; unknown hides it |
rtt | string | '' | Round-trip time in milliseconds. Shown rounded with a ms suffix; hidden when empty or not a number |
state | string | '' | Written through for a page's own styling. No fixed set here |
transport and rtt come off the client's own snapshot, on state.transports.server.link. In a browser the
path is udp, tcp or turn, read off the selected candidate pair, with rttMs beside it. A
Node client reports unknown and no rttMs. state is yours to set:
import { BitskiffClient, type ClientState } from '@bitskiff/client';
const client = new BitskiffClient({ key: 'bsk_pk_...' }); // your client key
const status = document.querySelector('bitskiff-status');
client.on('state', (s: ClientState) => {
const link = s.transports.server.link;
status?.setAttribute('transport', link?.path ?? 'unknown'); // udp | tcp | turn | unknown
status?.setAttribute('rtt', link?.rttMs === undefined ? '' : String(link.rttMs));
});
import { BitskiffClient } from '@bitskiff/client';
const client = new BitskiffClient({ key: 'bsk_pk_...' }); // your client key
const status = document.querySelector('bitskiff-status');
client.on('state', (s) => {
const link = s.transports.server.link;
status?.setAttribute('transport', link?.path ?? 'unknown'); // udp | tcp | turn | unknown
status?.setAttribute('rtt', link?.rttMs === undefined ? '' : String(link.rttMs));
});
No events: this element is display only.
bitskiff-banner
The words are the page's, in the default slot; the element contributes the state, the tone and the remaining time.
| Attribute | Type | Default | What it does |
|---|---|---|---|
state | string | 'hidden' | One of reconnecting, host-away, away, back, hidden. hidden (or no attribute at all) hides the element; back clears itself to hidden after three seconds |
ms-left | string | '' | Milliseconds remaining, shown as whole seconds. Hidden when empty or not a positive number |
| Event | Detail | When it fires |
|---|---|---|
bitskiff-action | { action: 'Leave' } | The action button is clicked. The button only shows once your page slots something into slot="action" |
bitskiff-code-scanner
Owns the camera and the decode loop. It reports one state and emits bitskiff-found once; call
resume() on the element for the next one.
start() opens the camera and begins decoding; resume() scans again after a found; close()
gives the camera back.
Without wasm-base-url the scanner decodes on Chromium only. The element decodes with the
browser's own BarcodeDetector where there is one, and that API is Chromium's; Safari and Firefox
do not have it.
Everywhere else the element falls back to a WebAssembly decoder, and that decoder is built only when
wasm-base-url names a directory. The attribute's default is the empty string, so a page that sets nothing has no fallback: on an iPhone the element goes to
state="unsupported" and never reads a code.
The file the fallback loads is zxing_reader.wasm, and it is loaded from
<wasm-base-url>/zxing_reader.wasm. Serve it from your own page's origin rather than a CDN: this
is a third-party decoder reading whatever the camera sees, and a directory named for its version
means a new one is never held back by a long-lived cache. Copy it out of the zxing-wasm package
that comes with this one, as part of your build:
mkdir -p public/scanner/3.1.3
cp node_modules/zxing-wasm/dist/reader/zxing_reader.wasm public/scanner/3.1.3/
<bitskiff-code-scanner wasm-base-url="/scanner/3.1.3"></bitskiff-code-scanner>
The file is about 1 MB. It is fetched by the first start() that needs it, never when the page
loads, and never at all on a browser with a detector of its own; the browser caches it after that.
| Attribute | Type | Default | What it does |
|---|---|---|---|
state | string | 'idle' | One of idle, starting, scanning, found, insecure, unsupported, no-camera, denied, failed. Render your own copy for each one |
wasm-base-url | string | '' | The directory your origin serves zxing_reader.wasm from, for the fallback decoder above. Unset, there is no fallback and the element decodes on Chromium only |
label | string | '' | Accessible name for the camera preview |
zoom-out-label | string | 'Zoom out' | Accessible name for the zoom-out control |
zoom-in-label | string | 'Zoom in' | Accessible name for the zoom-in control |
| Event | Detail | When it fires |
|---|---|---|
bitskiff-found | { rawValue } | A code decodes |
Next steps
For coding agents
Bitskiff lets a person control a host—a page, an app, a signage player—from their own device.
- Setting Bitskiff up for a developer? Read and follow https://docs.dev.bitskiff.com/setup.md: it asks the person two questions and runs the rest of the setup under the CLI's stored credential.
- First:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz whoami(Node 22 or newer). If itscredentialrow names a key, a key is stored, and the next lines need no person. - A stale copy: npx keeps one per URL and runs it again after a new publish at the same URL. Delete this CLI's copy, and nothing else in npm's cache, with
node -e "const c=child_process.execSync('npm config get cache',{encoding:'utf8'}).trim(),n=path.join(c,'_npx');for(const d of fs.existsSync(n)?fs.readdirSync(n):[]){let u;try{u=JSON.parse(fs.readFileSync(path.join(n,d,'package.json'),'utf8')).dependencies['@bitskiff/cli']}catch{}if(!u)continue;fs.rmSync(path.join(n,d),{recursive:true,force:true});const h=crypto.createHash('sha256').update('make-fetch-happen:request-cache:'+u).digest('hex');fs.rmSync(path.join(c,'_cacache','index-v5',h.slice(0,2),h.slice(2,4),h.slice(4)),{force:true})}", then run whoami again. - Key stored: the whole setup is one command, run in the project directory, because it leaves
bitskiff.jsonfor your code to read. Pass no--project, and do not take one from whoami'sprojectrow: outside an app directory it can name another app's project.inituses the project the directory'sbitskiff.jsonnames, else makes a new project named for the directory, so a second app gets a second project:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz init
- Serve your own client page? Add
--join-url <url>with the page's absolute URL: init sets the project's join URL, mints a client key for that page's origin that serves this host key, and writes it tobitskiff.jsonasclientKey. The sample client page needs no key. - It writes the host key into
bitskiff.json(key,project,endpoint, and with--join-url,joinUrlandclientKey) and prints no key string. Itsdonestep names the file askeyFileand carries the host key's id ashostKeyId, the<hostKeyId>below. It writes a scaffold (index.html,client.html,README.md) only into an empty directory, or with--scaffold, so in an existing projectbitskiff.jsonis the one file it adds. - A
bitskiff.jsonof the same project is kept, and no key is minted. With nobitskiff.json, it takes the project's newest active host key, and mints a host key only when the project has none or you pass--new-key. One of another project is refused: pass that project's--projectto keep it, or add--forceto replace it, which reuses the new project's host key when it has one. - A host key
initmints has no origin set, so it allowshttp://localhostandhttp://127.0.0.1on any port. A keyinittook keeps the origins it already had; check them withkeysbefore adding yours. For a deployed page, name its origin and keep localhost, because--originreplaces the list:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys update <hostKeyId> --origin <your origin> --origin 'http://localhost:*'
- Prove the project works, with no host code and no device: the CLI is not a host, so the scaffold carries
bitskiff-prove.mjs. Runnpm install && node bitskiff-prove.mjs(theprovevalue ofdone) in the directoryinitwrote the scaffold into. It opens a session on@bitskiff/host-node, joins one client, sends a message each way, then prints one JSON object per step on stdout, each transport's record and the session's usage record among them, and closes. A step that fails prints{"step":"failed","failed":"<step>",...}and exits 1. It reads two keys from the environment:BITSKIFF_SECRET_KEY, a secret key holdingsessions:openandsessions:read, andBITSKIFF_CLIENT_KEY, a client key naming no host key (theclientKeyfrom--join-urlserves the page's host key only).--only secretprints a new key alone: redirect each into a file outside the project, and export the variable from there:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys create --type secret --label prove --permission sessions:open --permission sessions:read --only secret
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys create --type publishable --preset client-page --label prove --only secret
- Refused
invalid_key: the stored key no longer works. Runnpx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz logout, then the steps below. - Refused
forbiddenwithdetails.reasonofmissing_permission: the key lacks the permissiondetails.permissionnames. Runlogout, theninitto sign in again. - Refused
forbiddenwithout it: the key may not use that project. Pass--projectof a project the key may use. - If the key comes from
BITSKIFF_ORG_KEY,logoutdoes not remove it: unset the variable instead. If whoami'sorgrow is then(not set), add--org <orgId>toinit, with the id of the organization the key belongs to. - No key stored (
credentialis(not set)), and only then: the steps below, which wait on a person once. - Run:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz init --format=json(Node 22 or newer; the flag is what puts one JSON object per step on stdout). The CLI already knows the api it was published beside. It leaves the samebitskiff.json, and takes the same--projectand--join-url. - It blocks until a person approves it in the console, for up to ten minutes: run it in the background, or with a timeout at least that long.
- Or add
--no-wait, and it returns at once: it prints one JSON object,{ verificationUri, userCode, expiresAt, next }, and exits 0. Show your developer the URL and the code, then runnextthrough the samenpx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgzin place ofbitskiff: it waits for the approval and finishes the setup. - [HUMAN] tell your developer to open the URL it prints and press Approve. Do not open it yourself: only a signed-in person can approve.
- Working through an MCP client instead of a shell? Add the CLI as a stdio MCP server,
{ "mcpServers": { "bitskiff": { "command": "npx", "args": ["--yes", "https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz", "mcp"] } } }: every verb is a tool under the same stored key,initruns as--no-wait, and a refusal is an error result carrying the samecodeanddetails.reason. - Sign in: https://console.dev.bitskiff.com
- Quickstart: https://docs.dev.bitskiff.com/docs/quickstart/
These are elements for the client page; the client SDK under them is @bitskiff/client and the host
side is @bitskiff/host. One element, wired to a channel:
import { BitskiffClient } from '@bitskiff/client';
import { defineButton } from '@bitskiff/ui-kit';
defineButton(); // now <bitskiff-button action="fire"> is an element
const client = new BitskiffClient({ key: 'bsk_pk_...' }); // your client key
const fire = client.channel('fire'); // a name the host declared
await client.join(location.hash.slice(1));
document.querySelector('bitskiff-button')?.addEventListener('bitskiff-press', () => fire.send('down'));
The .d.ts files in this package are the contract: every element class, property, attribute and
event is declared there, and where a declaration and this page disagree the declaration is right.
Nothing here refuses a call; the refusals a client page meets come from @bitskiff/client, and every
one of them is a BitskiffError carrying code, retryable, retryAfterMs and details.