Bitskiff

@bitskiff/ui-kit

On this page

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.

Shell
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:

JavaScript
import { defineButton } from '@bitskiff/ui-kit';

defineButton();
HTML
<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:

TagClassRegistrar
bitskiff-buttonBitskiffButtonElementdefineButton()
bitskiff-dpadBitskiffDpadElementdefineDpad()
bitskiff-joystickBitskiffJoystickElementdefineJoystick()
bitskiff-sliderBitskiffSliderElementdefineSlider()
bitskiff-textBitskiffTextElementdefineText()
bitskiff-readoutBitskiffReadoutElementdefineReadout()
bitskiff-statusBitskiffStatusElementdefineStatus()
bitskiff-bannerBitskiffBannerElementdefineBanner()
bitskiff-code-scannerBitskiffCodeScannerElementdefineCodeScanner()

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.

AttributeTypeDefaultWhat it does
actionstring''The name sent in the event detail below; yours to choose
labelstring''The button's visible text and its accessible name
variantstring'secondary'Look: secondary, primary or danger
hapticbooleanfalseVibrates the device for 10ms on press. Android only: iOS Safari has no Vibration API
disabledbooleanfalseDisables the button and drops it from tab order
EventDetailWhen 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.

AttributeTypeDefaultWhat it does
diagonalsbooleanfalseShows the four diagonal keys alongside the four cardinal ones
repeatnumber0Milliseconds between repeated bitskiff-press events while a key stays held. 0 sends no repeats
labelstring''Accessible name prefix for each key
EventDetailWhen it fires
bitskiff-press{ direction, madeAt } on the press; repeats carry { direction } aloneA 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.

AttributeTypeDefaultWhat it does
dead-zonenumber0.12Magnitude, 0 to 1, below which the stick reports { x: 0, y: 0 }
ratenumber60The most bitskiff-move events sent per second, 1 to 120
sizenumber160Diameter in pixels, 48 or larger
labelstring''Accessible name for the stick's x and y axes
EventDetailWhen 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

AttributeTypeDefaultWhat it does
minnumber0The lowest value
maxnumber100The highest value
stepnumber1Snaps the value to this grid. 0 disables snapping
valuenumber0The current value; settable
orientationstring'horizontal'horizontal or vertical
ratenumber30The most bitskiff-change events sent per second while dragging, 1 to 120
labelstring''Accessible name
idstring''The standard DOM id. It rides every bitskiff-change detail, so one handler can tell two sliders apart
EventDetailWhen 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.

AttributeTypeDefaultWhat it does
placeholderstring''Placeholder text for the empty field
maxlengthnumber64The longest text the field accepts, 1 or more
submit-labelstring'Send'The submit button's visible text and accessible name
labelstring''Accessible name for the input field
idstring''The standard DOM id. It rides every bitskiff-submit detail, so one handler can tell two fields apart
EventDetailWhen 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.

AttributeTypeDefaultWhat it does
valuestring''The text shown
labelstring''Accessible name
colorstring'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.

AttributeTypeDefaultWhat it does
transportstring'unknown'One of udp, tcp, turn, unknown. Colors the pill; unknown hides it
rttstring''Round-trip time in milliseconds. Shown rounded with a ms suffix; hidden when empty or not a number
statestring''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.

AttributeTypeDefaultWhat it does
statestring'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-leftstring''Milliseconds remaining, shown as whole seconds. Hidden when empty or not a positive number
EventDetailWhen 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:

Shell
mkdir -p public/scanner/3.1.3
cp node_modules/zxing-wasm/dist/reader/zxing_reader.wasm public/scanner/3.1.3/
HTML
<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.

AttributeTypeDefaultWhat it does
statestring'idle'One of idle, starting, scanning, found, insecure, unsupported, no-camera, denied, failed. Render your own copy for each one
wasm-base-urlstring''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
labelstring''Accessible name for the camera preview
zoom-out-labelstring'Zoom out'Accessible name for the zoom-out control
zoom-in-labelstring'Zoom in'Accessible name for the zoom-in control
EventDetailWhen 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 its credential row 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.json for your code to read. Pass no --project, and do not take one from whoami's project row: outside an app directory it can name another app's project. init uses the project the directory's bitskiff.json names, else makes a new project named for the directory, so a second app gets a second project:
Shell
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 to bitskiff.json as clientKey. The sample client page needs no key.
  • It writes the host key into bitskiff.json (key, project, endpoint, and with --join-url, joinUrl and clientKey) and prints no key string. Its done step names the file as keyFile and carries the host key's id as hostKeyId, 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 project bitskiff.json is the one file it adds.
  • A bitskiff.json of the same project is kept, and no key is minted. With no bitskiff.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 --project to keep it, or add --force to replace it, which reuses the new project's host key when it has one.
  • A host key init mints has no origin set, so it allows http://localhost and http://127.0.0.1 on any port. A key init took keeps the origins it already had; check them with keys before adding yours. For a deployed page, name its origin and keep localhost, because --origin replaces the list:
Shell
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. Run npm install && node bitskiff-prove.mjs (the prove value of done) in the directory init wrote 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 holding sessions:open and sessions:read, and BITSKIFF_CLIENT_KEY, a client key naming no host key (the clientKey from --join-url serves the page's host key only). --only secret prints a new key alone: redirect each into a file outside the project, and export the variable from there:
Shell
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. Run npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz logout, then the steps below.
  • Refused forbidden with details.reason of missing_permission: the key lacks the permission details.permission names. Run logout, then init to sign in again.
  • Refused forbidden without it: the key may not use that project. Pass --project of a project the key may use.
  • If the key comes from BITSKIFF_ORG_KEY, logout does not remove it: unset the variable instead. If whoami's org row is then (not set), add --org <orgId> to init, with the id of the organization the key belongs to.
  • No key stored (credential is (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 same bitskiff.json, and takes the same --project and --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 run next through the same npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz in place of bitskiff: 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, init runs as --no-wait, and a refusal is an error result carrying the same code and details.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:

JavaScript
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.