> ## Documentation Index
> Fetch the complete documentation index at: https://docs.voiceos.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Widget Kit

> The dependency-free design system behind every Studio-built card.

The Widget Kit is a single dependency-free module — `widgetKit.ts` — that turns
structured content into polished widget HTML: correct theming, brand accents
with contrast repair, your integration's mark on every card, and the whole
[bridge runtime](/integrations/widgets#the-bridge) implemented for you. It's the
exact system the [Integration Studio](/integrations/build-with-ai) uses, which
is why its output looks native in the notch.

**Getting it:** every Studio-generated folder ships with its own copy, with your
logo baked in. Building by hand, copy `widgetKit.ts` from a generated folder —
or `src/widget/kit.ts` from the SDK — next to your `server.ts`.

## Composed cards: `renderWidget`

Build blocks, render, return:

```ts theme={null}
import { renderWidget, vHeader, vStats, vBars } from "./widgetKit";

const card = renderWidget({
  accent: "#f7931a",                     // the brand's real color
  blocks: [
    vHeader({ title: "Bitcoin", subtitle: "Live price" }),
    vStats([
      { label: "Price", value: "$64,210", tone: "accent" },
      { label: "24h", value: "+2.3%", tone: "good" },
    ]),
    vBars({ items: days.map((d) => ({ label: d.name, value: d.volume })), unit: "B" }),
  ],
});

return {
  content: [{
    type: "text",
    text: JSON.stringify({
      ...data,
      _voiceos_glance: {
        blocks: [{ type: "widget", html: card.html, height: card.height, label: card.label }],
      },
    }),
  }],
};
```

`renderWidget` returns `{ html, height, label }` — and the height is *real*:
every component reports its measured height, so the declared estimate lands
within a few percent of the rendered card.

## The components

Each returns a `Block` for the `blocks` array:

| Component   | Signature (essentials)                                                         | Notes                                                          |
| ----------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------- |
| `vHeader`   | `{ title, subtitle?, trailing?, icon?, mark? }`                                | Start almost every card with one; draws your integration mark. |
| `vList`     | `rows: { title, subtitle?, trailing?, trailingSub?, badge?, image?, href? }[]` | Up to 5 rows, then "+N more". `href` rows open via the bridge. |
| `vStats`    | `{ label, value, tone? }[]`                                                    | Up to 3 big numbers.                                           |
| `vKeyValue` | `{ label, value, tone? }[]`                                                    | Up to 5 pairs.                                                 |
| `vBars`     | `{ items: { label, value }[], highlight?, unit? }`                             | Up to 7 bars.                                                  |
| `vSpark`    | `{ points, start?, end?, tone? }`                                              | Up to 60-point sparkline.                                      |
| `vMeter`    | `{ value, max?, label?, trailing?, tone? }`                                    | Progress toward a goal.                                        |
| `vBadges`   | `(string \| { text, tone? })[]`                                                | Up to 4 chips.                                                 |
| `vHero`     | `{ image?, eyebrow?, title, subtitle? }`                                       | A leading visual moment.                                       |
| `vNote`     | `text`                                                                         | A quiet paragraph (long URLs wrap safely).                     |
| `vEmpty`    | `{ title, hint? }`                                                             | Honest empty states.                                           |
| `vDivider`  | —                                                                              | Hairline rule.                                                 |
| `vField`    | `{ key, label, value?, multiline? }`                                           | An **editable tool argument** — confirmation cards only.       |

Tones are `neutral`, `good`, `bad`, `accent`. Helpers: `esc()` (escape any
interpolated string — always), `clip(value, max)`, and `textEms()` for CJK-aware
width estimates.

## Accent and theme

|            |                                                                                                                                                                                                                                                                               |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Accent** | Pass the brand's real color. The kit validates it and repairs it per surface: a hex that can't hit 3:1 contrast against the dark notch glass (or the light preview surface) is nudged toward legibility while keeping its hue. Near-black brands stay on-brand *and* visible. |
| **Theme**  | Arrives over the bridge as `data-k-theme` on the root. The kit's CSS variables (`--ink-1…4`, `--line`, `--fill-1/2`, `--track`, `--good`, `--bad`, `--accent`) flip automatically.                                                                                            |
| **Corner** | Arrives the same way, as `--k-radius`. Both kit shells already use it; anything *you* paint to the card's edge should too. See [The corner](/integrations/widgets#the-corner).                                                                                                |
| **Motion** | Respects `prefers-reduced-motion`; the entrance animation is opt-in under `no-preference`, so an environment that doesn't animate still paints the card.                                                                                                                      |

<Warning>
  **Never use `prefers-color-scheme`.** It follows the OS while the notch stays
  dark — a light-mode Mac would render a light card on black glass.
</Warning>

**The surface finish is drawn for you.** Every kit card carries a liquid-glass
rim — a 1px inset hairline, brighter along the top, on the same `--k-radius` arc
the host clips at, so it can neither gap nor get shaved. Declare an `accent` and
the card also gets a soft brand wash out of the top-left corner. The integration
mark stays the bare logo; the kit never puts a tile or colored backdrop behind
it, and neither should you.

<Note>
  Don't add your own outer border, glow, or logo tile. The finish that belongs
  on the card is already there.
</Note>

## Bespoke layouts: `renderCustom`

When the composed blocks can't express your card, own the surface:

```ts theme={null}
import { renderCustom, esc, markHtml } from "./widgetKit";

const card = renderCustom({
  accent: "#5865F2",
  height: 236,                      // measure once, then hardcode
  css: `.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } …`,
  body: `
    ${markHtml()}                   <!-- your integration's mark, top-left -->
    <div class="grid">${items.map((i) => `<div>${esc(i.name)}</div>`).join("")}</div>
  `,
});
```

Same shell — variables, bridge runtime, mark — with zero layout opinions. Two
differences from `renderWidget`:

* You own the padding and the declared `height`.
* It **throws** if the document exceeds the byte cap instead of degrading — an
  error during your test run beats a card the host silently drops.

If your body forgets `markHtml()`, the kit floats the mark over the top-left
anyway. No card ships unbranded.

## Images and fonts

The sandbox has no network, so fetch assets **in the handler** and inline them:

```ts theme={null}
import { inlineImage, inlineImages, inlineFont } from "./widgetKit";

const art = await inlineImage(track.artworkUrl);                // → data: URI or null
const covers = await inlineImages(albums.map((a) => a.cover));  // budgeted set
```

Budgets: \~32 KB per image, \~42 KB for one pre-subsetted WOFF2 font, and half the
total HTML budget across all images. Over-budget assets come back `null` —
design the card to survive a missing image.

## Editable fields and links

The kit's runtime wires interactivity declaratively — you write no bridge code:

* **Fields.** `vField({ key: "body", label: "Message" })` renders an input bound
  to the tool argument `body`. In confirmation cards it hydrates from the
  pending call's `args` and stages the user's edits. In custom markup, any
  element with `data-voiceos-key="body"` gets the same treatment.
* **Links.** `vList` rows with `href`, elements with `data-k-link`, and plain
  `<a href="https://…">` anchors all route through the bridge to the user's
  browser. https only.

## Staying under the caps

The HTML budget is 128 KB. `renderWidget` degrades gracefully when a card
exceeds it — content images are stripped first (never your mark), then trailing
blocks — so a card never silently vanishes. Confirmation-mode cards
(`mode: "confirm"`) also reserve the bottom-right corner for VoiceOS's floating
confirm button.

## The design bar

The same rules the Studio's design critic scores against — worth keeping even
when you're hand-rolling:

* At most **3 type sizes**; `tabular-nums` on every number.
* **Truncate, never wrap** — and `text-overflow: ellipsis` needs
  `overflow: hidden` to work.
* Hairlines and spacing over nested boxes; spend **one accent** deliberately.
* **Fit 420px without scrolling** — show 4–5 rows and a "+N more", not a
  scrollbar.
* Everything renders offline, on first paint, in both themes.

<Card title="Widget confirmations" icon="shield-check" href="/integrations/widget-confirmations">
  The same kit, one extra rule: VoiceOS owns the confirm button.
</Card>
