# @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](/docs/guides/client-page/) ·
[channels](/docs/guides/channels/) ·
[controller input](/docs/guides/controller/)

## Install

Take the tarball from the [downloads page](/docs/downloads/): `npm install` takes a URL as well as
a name.

```sh
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:

```js
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](/docs/guides/client-page/#custom-client-pages).

**JavaScript**

```js
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'));
```

**TypeScript**

```ts
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'));
```

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:

**JavaScript**

```js
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));
});
```

**TypeScript**

```ts
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));
});
```

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:

```sh
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.

| 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

- [Client pages](/docs/guides/client-page/)
- [The client package](/docs/packages/client/)
- [Controller input](/docs/guides/controller/)

## 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](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:

```sh
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:

```sh
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:

```sh
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](https://console.dev.bitskiff.com)
- Quickstart: [https://docs.dev.bitskiff.com/docs/quickstart/](/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:
```js
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`.
