# @arbium/widgets

Skill cards for chat surfaces. Five cards, one shell, nothing fetched at runtime.

## Why this exists

A skill that draws its own card has to emit the whole card: several hundred lines
of markup, written out token by token, every single run. That's slow to produce,
and two skills written a month apart won't look alike.

Chat widget surfaces restrict scripts to a short CDN allowlist. Publishing the
cards as a package moves the markup behind that allowlist, so a skill emits a
payload instead:

```html
<script src="https://cdn.jsdelivr.net/npm/@arbium/widgets@8.2.0/dist/arb.min.js"></script>
<div id="card"></div>
<script>
  ARB.postwork("#card", {
    skill: "Weekly status report",
    subject: "I've drafted the report and the email.",
    steps: [
      { label: "Collect team updates", status: "done", via: ["Slack", "Jira"] },
      { label: "Draft the report", status: "done", via: ["Google Docs"] },
    ],
    outputs: [
      { label: "Status — Week of Aug 3", detail: "New doc · 412 words", via: ["Google Docs"] },
      { label: "Email to leadership", detail: "Draft · 4 recipients · not sent", via: ["Gmail"] },
    ],
    actions: [
      ["Make it weekly", "Schedule this for every Friday at 9am."],
      ["Review & send", "Show me the email draft."],
    ],
  });
</script>
```

A dozen lines instead of four hundred. Every skill renders identically because
they share a package rather than a convention, and a spacing fix lands everywhere
at once.

**The payload names things; it does not style them.** There is no way to pick an
icon, reseed a face or promote a button — those follow
from the card kind, the label and the position. Two skills describing the same
run produce the same card.

## The five cards

| Card       | Where the reader is                    | What they do        |
| ---------- | -------------------------------------- | ------------------- |
| `prework`  | The run is about to start, or part-way | Watches             |
| `postwork` | It finished and made something         | Reviews             |
| `help`     | Asking why this skill exists           | Decides to trust it |
| `feedback` | Wants the skill changed                | Tells it to change  |
| `skills`   | Asking what the team has               | Picks one to run    |

`prework` and `postwork` are the same run twice. They draw the identical
checklist from the identical `steps`, so the second card reads as the first one
resolved rather than as something new to decode.

Three rules that come up often:

- A run that hit a problem is still `prework`, with that step's `status` set to
  `failed`. No separate error card.
- There's no progress card. A visual is a snapshot taken when it renders, so it
  can't follow later work. Render a fresh `prework` with updated statuses.
- There's no percentage, no bar and no status badge, for the same reason. A
  figure that can't update is a figure frozen at a lie; the checklist stays true
  after it stops moving.
- Every card writes in the first person. The face sits outside the panel and the
  panel points back at it, so a card is the skill speaking, not a report about it.

## Shell fields

Every card accepts these, whatever its body:

| Field       | Type                    | Notes                                            |
| ----------- | ----------------------- | ------------------------------------------------ |
| `skill`     | string                  | Which skill this is about. Also seeds the avatar |
| `subject`   | string                  | The line under it                                |
| `run`       | string \| number        | Rendered as "Run 47"                             |
| `state`     | string                  | Accepted and ignored — see below                 |
| `tools`     | string[]                | Accepted and ignored                             |
| `grounding` | `[label, value][]`      | A grid of figures, described below               |
| `actions`   | see below               | Footer controls                                  |
| `footer`    | `{ by, when, notDone }` | `notDone` is the important one                   |
| `theme`     | `"light"` \| `"dark"`   | Forces a theme. Omit to follow the surface       |

`state` no longer renders. There was a status badge in the corner until 8.0.0, and
a card is a snapshot that never re-renders — so a badge reading "Running" is still
reading it an hour later. What a run is doing goes in `subject`, which you write
fresh each time. The field is ignored rather than rejected, so older payloads still
render.

`theme` is the only appearance field, and it exists because a host can theme in a
way the card can't detect. Everything else is decided for you: the face is
generated from `skill` — on every card but the roster, which wears a grid of its
own — so the same run looks the same wherever it renders.

### `grounding`

```js
grounding: [
  ["Times I saw it", "14×"],
  ["People", "6"],
  ["Watched over", "Jul 7 – Aug 6"],
  ["What it costs you", "~8 hrs/mo"],
];
```

A grid of figures answering "why should I believe this", two columns however many
cells you give it. On `help` it sits mid-card, dividing the work as it is done
today from what the skill does instead; everywhere else it sits above the footer.
Only put real numbers in it: an invented figure is worse than no grid at all,
because the grid only works if a reader can trust it.

### `footer.notDone`

Three words that let a non-technical reader relax. Say what the skill
deliberately left undone: didn't send, didn't write, didn't share. It renders in
bold at the end of the lead sentence rather than in the footer — it is the half a
nervous reader is looking for, and it belongs beside the claim it qualifies.

### `actions`

```js
actions: [
  ["Make it weekly", "Schedule this for every Friday."], // tuple: label, prompt
  { label: "Open it", href: "https://…" }, // link, opens in a new tab
  { label: "Review & send", prompt: "Show me the draft." },
];
```

The last action is the primary one, always. Emphasis follows position, so
ordering the list is how you say which button matters — and a card can't end up
with two primaries or none. An action with no `prompt` and no `href` sends its
own label as the prompt, so every rendered button stays live.

## Bodies

```js
ARB.prework("#el", {
  steps: [
    { label: "Collect team updates", status: "done", via: ["Slack", "Jira"] },
    { label: "Draft the report", status: "pending", via: ["Google Docs"] },
    { label: "Prepare the email", status: "pending", via: ["Gmail"] },
  ],
});

ARB.postwork("#el", {
  steps: [
    { label: "Collect team updates", status: "done", via: ["Slack", "Jira"] },
    { label: "Pull last week's report", status: "failed", via: ["Google Drive"] },
  ],
  outputs: [
    { label: "Status — Week of Aug 3", detail: "New doc · 412 words", via: ["Google Docs"] },
  ],
});

ARB.help("#el", {
  tools: ["Slack", "Jira", "Gmail"],
  today: "Every Friday someone assembles the report by hand.",
  steps: [{ label: "Scroll the channels", detail: "message by message", via: ["Slack"] }],
  does: "Collects the updates and drafts the doc. It never sends.",
  flow: [
    { label: "You ask in chat", by: "you" },
    { label: "Collects the updates from every channel", by: "claude" },
    { label: "Drafts the doc with each update attributed", by: "claude" },
    { label: "You review it and send", by: "you" },
  ],
});

ARB.feedback("#el", {
  targets: ["Collecting", "The draft", "The email", "Something else"],
  placeholder: "e.g. it should be a .docx we can edit",
});

ARB.agents("#el", {
  skill: "Your team's skills",
  skills: [
    { name: "Weekly status report", by: "Ops Enablement", subject: "Drafts it, you send it" },
    ["Invoice matching", "Finance ops"],
    "Expense triage",
  ],
});
```

`ARB.card(el, { ...shell, body: { kind: "postwork", ... } })` is the explicit
form. The four named helpers are sugar that take body fields at the top level,
which is what a skill actually writes.

### help marks the steps it does itself

`flow` nodes are `{ label, by }`, where `by` is `"you"` or `"claude"`. The card
renders a numbered list and marks every row: the skill's own generated face — the
same one on the rail — on the steps it does itself, and a person glyph on yours.
"Which of these am I still doing?" is one glance down a column, with no legend and
no lanes.

The face column is suppressed entirely if any node lacks `by`. A card that guesses
who does the work is worse than one that only lists the steps.

### steps resolve or they don't

`status` on each step is `pending`, `done` or `failed` — there is no in-progress
status, because nothing is mid-flight at the moment a card renders. A pending step
draws its number; the other two draw a tick or a cross.

The body is the checklist and nothing else. No status strip, no badge, no bar —
each was a second indicator restating what the rows already said, and each was
frozen the moment the card rendered.

### feedback is the only interactive card

Both chip rows are optional and the free text is the gate, so nobody hits a
taxonomy on the way to saying what went wrong. On send it composes one prompt
carrying the skill, both chips and the note, then swaps to a confirmation.

## Naming tools

`via` on a step and `tools` on a `help` card are **recorded, not drawn**. 5.1.0
removed the tile that used to sit beside each row, and 8.0.0 declined to bring it
back: the design this version follows loads brand icons from a favicon service,
and the chat surfaces these render in block remote images without reporting it.
An icon that silently fails is a hole where information should be.

Keep passing them — they are the honest record of what a step touched, and a
future card may show them as text. Name the tool the way the reader says it
("Outlook", not "Microsoft 365 mail").

## Size

The bundle is **56 KB raw, 21 KB gzipped**, and makes no network requests of
its own. No framework, no stylesheet request, no webfont, no remote images.
`npm run build` prints both figures on every build.

The largest single thing in it is the avatar art: the tree characters, compiled
in as vector parts and composed per seed, about 30 KB of the raw bytes. The chat
surfaces these render in do not allow remote images, and a blocked one fails
silently — a hole where a face should be, with nothing in the console — so the
bytes buy a mark that always arrives. They are vector, so they cost the same at
any size.

The size matters in one place beyond load time: `rendering.md` has a model
inline the whole bundle as a fallback when the CDN fetch fails, which is ~30k
tokens. That is why it is one retry and not a routine.

## `ARB.configure()`

```js
ARB.configure({
  sendPrompt: (text) => {
    /* put text in the composer */
  },
  fonts: true, // load Geist + Geist Mono from jsdelivr
});
```

`sendPrompt` is the one thing the package can't supply for itself. It
auto-detects, in order: `window.claude.sendPrompt`, then a bare
`window.sendPrompt`. Hosts mount this differently and one observed widget surface
uses the bare global, so both are tried.

Without it, action buttons still render, but clicking one logs an error and shows
a visible notice in the card. A dead button with no explanation is the worse
outcome.

## Theming

Arbium's palette is baked in, and the package handles its own light and dark, so
cards look the same in a chat, a docs page, or a web console.

Which of the two it picks follows, in order: an explicit `theme` on the card, a
`data-theme` on any ancestor, the brightness of whatever the host paints behind
the card, then `prefers-color-scheme`. That third step exists because plenty of
hosts theme through their own CSS variables and never set `data-theme` anywhere
the stylesheet can read.

That check runs once, at render. A host that flips theme afterwards won't be
followed; pass `theme` explicitly and re-render if that matters.

Tokens are scoped to `.arb-root` rather than `:root`, so mounting a card can't
disturb variables the host page already uses.

## Host requirements

The allowlist is enforced by CSP and set per host, so confirm it before assuming
a card will load. One measured widget surface permits exactly:

```
cdn.jsdelivr.net   unpkg.com   cdnjs.cloudflare.com   esm.sh
fonts.googleapis.com   fonts.gstatic.com
```

Every other origin is blocked. Nothing here degrades gracefully if the script
itself is blocked.

## Behaviour worth knowing

**Nothing branches on a vendor name.** There is no table of known apps anywhere in
the package, so a tool nobody anticipated takes exactly the same path through the
code as a familiar one.

**Payloads are forgiving.** A missing list omits its section, a malformed entry is
dropped. An unknown `kind` still renders the shell and logs.

**Data is never markup.** Everything is built with `createElement` and
`textContent`, so a subject line containing HTML renders as text.

**Pin an exact version** — `@arbium/widgets@8.2.0`, never `@6` or `@latest`. jsdelivr
serves range URLs with `max-age=604800`, so a browser that fetched `@5` once holds that
build for seven days, and purging the CDN does not reach it. An exact version is a
distinct URL, so a new release is always fetched.

## Development

```bash
npm install
npm run build   # arb.min.js (IIFE, global ARB), arb.mjs (ESM)
npm run size    # bundle size report
```

One build guard worth knowing: anything in the bundle that isn't `src/` or an
allowlisted dependency fails the build, which is what holds the "nothing fetched
at runtime" promise.
