Skip to content
helia-ui
Sections
HELIA HUB

Web apps

Live WebUSB and Bluetooth demos, streaming dashboards and interactive tooling are React and TypeScript applications built with Vite, not Starlight sites. They consume tokens.css, tailwind.css and react/* so they look like the docs that link to them, and they live in their own repositories.

templates/web-app/ is the starter. It ships a working example: a top bar with a connection Badge and a theme toggle, a control Card with a source Select, a sample-rate Slider and a connect Button, a live Recharts trace fed at 50 to 250 Hz, and a status footer with toasts.

The thing you are buildingTemplate
Product documentation, reference, guidestemplates/product-docs
Live hardware, streaming data, dashboardstemplates/web-app

Both are repo-only: neither is in the package’s files, so neither is in the published tarball. Copy them out of AmbiqAI/helia-ui.

  • Vite, React 19 and strict TypeScript. npm run check is tsc --noEmit and npm run lint is ESLint with the type-checked typescript-eslint rules plus eslint-plugin-react-hooks.
  • Tailwind v4 through @tailwindcss/vite, with an entry that imports tokens.css, semantic.css and tailwind.css in the order the Starlight plugin uses, then Tailwind’s Preflight, because an app has no docs shell to inherit a baseline from. tailwind.css and not starlight-tailwind.css: an app has no Starlight shell, so it installs no compat package and supplies none of Starlight’s variables.
  • A theme toggle that owns data-theme on <html>. Light, dark and system, persisted, applied before first paint by a small script in index.html.
  • A Pages deploy. vite.config.ts reads base from BASE_PATH, and .github/workflows/deploy.yml passes the repository name so a project site’s asset URLs carry the prefix.
  • A Playwright smoke test that loads the built app, starts the simulated source, and asserts the chart receives data.

A docs site gets a handful of variables from Starlight’s own props.css. An app has no Starlight, so the template’s entry supplies them: the two system font stacks and --sl-color-text-invert, restated at Starlight’s values.

data-theme matters for the same reason. The tokens flip under :root[data-theme='light'] and the package’s dark: variant keys on [data-theme='dark'], so an app that leaves the attribute unset gets the dark palette from the tokens while every dark: utility evaluates false. That is why the template resolves system to a concrete value rather than clearing the attribute.

A streaming app that ignores these feels broken on the machine the demo runs on, which is not the machine it is written on.

  1. Generate and parse off the main thread. The simulated signal runs in a Web Worker, and a real driver’s packet decode belongs there too. A sample clock that shares the main thread drifts whenever React renders.
  2. Transfer, do not copy. The worker posts Float32Array chunks with the buffer in the transfer list.
  3. Chunks, never samples. A per-sample callback puts one JavaScript call on the main thread per sample, which is where the frame budget goes at the top of the rate range.
  4. Update state at display rate, not sample rate. Chunks go straight into a fixed ring buffer, which is not React state and triggers nothing. One requestAnimationFrame publishes what is there. Everything that lands before the frame fires is already in the buffer, so coalescing loses no samples — only pictures nobody could have seen.
  5. Decimate before the chart, not in it. The buffer returns a fixed number of points chosen against the trace’s pixel width, so render cost stops being a function of the sample rate.
  6. No animation on a live series. isAnimationActive={false}, dot={false}, a fixed axis domain, a linear line type. Recharts will otherwise restart an entry animation on every data change and never finish one.

The smoke test asserts the outcome rather than the mechanism: raising the source from 100 Hz to 250 Hz must multiply the samples received without moving the number of chart updates per second.

One contract sits between the app shell and whatever produces samples. It is the interface a real device driver implements, not a simplification of one.

export interface Source {
readonly id: SourceId;
readonly label: string;
/** Synchronous feature detection. Never prompts, never touches the device. */
support(): SourceSupport;
/** Acquire the transport and start streaming. Rejects with a message fit for a toast. */
connect(sampleRate: number): Promise<void>;
/** Retune while connected. A control transfer or a GATT write on real hardware. */
setSampleRate(sampleRate: number): void;
/** Release the transport. Safe to call when already disconnected. */
disconnect(): Promise<void>;
/** Subscribe. Returns the unsubscribe function. */
onChunk(listener: (chunk: StreamChunk) => void): () => void;
}

A chunk carries the samples and the rate they are produced at, and ownership passes to the listener — a transferable arriving from a worker is already detached on the producer’s side.

The template ships three implementations. SimulatedSource is complete and worker-backed. WebUsbSource and BluetoothSource are stubbed at exactly one point each: everything above it is real, including the filtered chooser, the interface claim or the GATT walk, and the teardown order. What is missing is the decode, because the packet layout belongs to the board’s firmware. Fill in the read loop or the notification handler and nothing outside that file changes.

support() returning a reason rather than a bare boolean is deliberate. WebUSB and Web Bluetooth are Chromium-only and secure-context-only, so “it did not connect” and “this browser cannot connect” are different states and the interface has to tell them apart. Both also gate the device chooser on a live user activation, which an await consumes, so connect() must be called directly from a click handler.

Product status, support, maturity and performance statements are not authored in a demo app any more than in a docs site. They come from the approved export of the handbook’s portfolio.yaml. The sample copy in the template is shape, not copy.