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.
Which template
Section titled “Which template”| The thing you are building | Template |
|---|---|
| Product documentation, reference, guides | templates/product-docs |
| Live hardware, streaming data, dashboards | templates/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.
What the starter wires up
Section titled “What the starter wires up”- Vite, React 19 and strict TypeScript.
npm run checkistsc --noEmitandnpm run lintis ESLint with the type-checked typescript-eslint rules pluseslint-plugin-react-hooks. - Tailwind v4 through
@tailwindcss/vite, with an entry that importstokens.css,semantic.cssandtailwind.cssin the order the Starlight plugin uses, then Tailwind’s Preflight, because an app has no docs shell to inherit a baseline from.tailwind.cssand notstarlight-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-themeon<html>. Light, dark and system, persisted, applied before first paint by a small script inindex.html. - A Pages deploy.
vite.config.tsreadsbasefromBASE_PATH, and.github/workflows/deploy.ymlpasses 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.
Outside Starlight
Section titled “Outside Starlight”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.
The responsiveness rules
Section titled “The responsiveness rules”A streaming app that ignores these feels broken on the machine the demo runs on, which is not the machine it is written on.
- 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.
- Transfer, do not copy. The worker posts
Float32Arraychunks with the buffer in the transfer list. - 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.
- 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
requestAnimationFramepublishes 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. - 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.
- 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.
The Source interface
Section titled “The Source interface”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.
Claims
Section titled “Claims”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.