# Widgets

> Render rich HTML inline via `<mcwidget>` tags with theme-aware styling. Load when emitting an mcwidget so the iframe inherits the dashboard theme instead of clashing with light / dark / custom palettes.

- Skill: `kirodotdev/widgets` (Agent Skill)
- Install (CLI): `npx skillmds@latest add kirodotdev/widgets`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kirodotdev/widgets/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: kirodotdev (https://skillmd.com/u/kirodotdev)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/kirodotdev/widgets

---


# Inline widgets (`<mcwidget>`)

You can embed rich HTML in assistant messages using
`<mcwidget title="Title">HTML</mcwidget>`. The dashboard renders each tag as
a sandboxed iframe with Tailwind CSS preloaded. Use this for styled visual
content plain markdown cannot express: charts, color-coded tables, styled
cards, visual summaries, simple interactive probes.

Tailwind is preloaded and ready without any setup — it is served from the
dashboard's own origin, not from `cdn.tailwindcss.com`, which the iframe CSP
does NOT allow. Other libraries (Chart.js, D3, etc.) need a
`<script src="…">` tag pulling from one of the CSP-allowed CDNs listed under
Rules below.

## When to use

- **Use** when styled HTML genuinely helps comprehension — Chart.js graphs,
  color-coded comparison tables, status cards, before/after previews, small
  interactive probes.
- **Don't use** for content markdown already handles well — plain prose,
  bullet lists, fenced code, simple tables. Widget iframes cost more to render
  and add more context than markdown; default to markdown.
- **Save to file** for anything large. `<mcwidget>` bodies fit comfortably
  up to a few KB; beyond that, write an HTML file and return the absolute
  path so the dashboard shows a thumbnail / link instead.

## Theme-aware styling (critical)

The widget iframe inherits the dashboard's active theme through CSS
custom properties injected into each srcdoc. Use these variables instead
of hardcoded colors so widgets render correctly on every theme (light,
dark, and user-defined custom palettes).

Core palette (use these first):

| Variable        | Role                            |
|-----------------|---------------------------------|
| `var(--bg)`     | Page background                 |
| `var(--text)`   | Foreground text                 |
| `var(--card)`   | Card / panel background         |
| `var(--card-fg)`| Foreground text on `--card`     |
| `var(--border)` | Borders and dividers            |
| `var(--accent)` | Primary accent / links          |
| `var(--muted)`  | Muted / secondary text          |
| `var(--ok)`     | Success state                   |
| `var(--warn)`   | Warning state                   |
| `var(--danger)` | Error / destructive state       |
| `var(--info)`   | Info / neutral callout (blue)   |

Extended palette (also available):

| Variable                | Role                                                |
|-------------------------|-----------------------------------------------------|
| `var(--bg-elevated)`    | Raised surface above `--bg` (headers, modals)       |
| `var(--bg-hover)`       | Hover state for interactive backgrounds             |
| `var(--text-strong)`    | High-contrast text emphasis                         |
| `var(--muted-strong)`   | Stronger muted text (still secondary, more legible) |
| `var(--border-strong)`  | High-contrast border for emphasis                   |
| `var(--accent-hover)`   | Hover state for accent elements                     |
| `var(--accent-subtle)`  | Tinted background using accent (badges, highlights) |
| `var(--ok-subtle)`      | Tinted ok background (success banners)              |
| `var(--warn-subtle)`    | Tinted warn background (warning banners)            |
| `var(--danger-subtle)`  | Tinted danger background (error banners)            |

With Tailwind, use arbitrary values: `bg-[var(--card)]`,
`text-[var(--card-fg)]`, `border-[var(--border)]`. Avoid `bg-gray-900`,
`text-white`, `bg-white`, etc — they clash the moment the user switches
themes.

### The theme contract

These rules apply to **every artifact-bound HTML document**, not just
inline widgets — a page generated by a script and pushed via
`artifact_save` / `artifact_update` or the `kirocrew artifact` CLI
renders in the same themed iframe:

- **Never set one half of a foreground/background pair.** The iframe
  injects `body{background:var(--bg);color:var(--text)}` defaults, so a
  lone hardcoded `color:#111` lands on a dark canvas in dark mode (and a
  lone light `background` swallows themed light text). Set both halves,
  or neither.
- **Hardcode nothing; fall back instead.** Content that must also render
  standalone (outside the dashboard, where no theme vars exist) keeps a
  literal fallback inside the var: `color:var(--text,#111);
  background:var(--bg,#fff)`. Inside the dashboard the theme wins;
  standalone, the fallback reproduces the intended palette.
- **The renderer has a last-resort fallback, not a licence.** When a widget
  carries a hardcoded light background (`bg-white`, `bg-<hue>-50|100|200`, a
  light `#hex`) and references no `var(--…)` at all, a dark dashboard renders
  it on a neutral light canvas with dark text instead of white-on-white.
  That is a readability floor for content that already slipped through: it
  cannot honor the user's palette, it turns off the moment a single theme var
  appears, and it does nothing for a dark hardcoded palette. Write to the
  contract above; never design for the fallback.
- `artifact_save` / `artifact_update` attach a `⚠️` hint when widget/html
  content carries literal colors (`#hex` / `rgb()` / `hsl()`) and no
  `var(--…)` reference. Treat that hint as a defect to fix in the same
  turn — **unless the user explicitly asked for a fixed palette** (brand
  colors, a print-faithful mock): the heuristic cannot distinguish
  deliberate branding, so honor the user's choice and say the hint was
  intentionally not applied.

## Format

```html
<mcwidget title="Deploy status">
<div class="p-4 rounded-lg" style="background:var(--card);color:var(--text);border:1px solid var(--border)">
  <div class="text-sm font-semibold" style="color:var(--ok)">✅ Green across all stages</div>
  <div class="text-xs" style="color:var(--muted)">last build 08:42 UTC</div>
  <div class="text-xs">
    <a href="https://github.com/example-org/example-repo/pull/1234"
       target="_blank" rel="noopener noreferrer"
       style="color:var(--accent)">#1234</a>
  </div>
</div>
</mcwidget>
```

The opening tag takes two attributes: `title=` and `slug=`. A `slug=` binds the
impression to an artifact that already exists and suppresses registration; omit
it for new content.

## Every widget is already an artifact

Each finalized `<mcwidget>` without `slug=` is auto-registered on the backend,
keyed idempotently by message timestamp and widget index, even if never viewed.
Incognito and temporary sessions never register widgets. Unpinned auto-registered
widgets are pruned oldest-first past 200; the user's star keeps one out of the sweep.

Do **not** call `artifact_save` on emitted content: it creates a duplicate and
returns a warning. `slug=` only binds an impression; it skips registration and
neither reads nor writes the artifact. To revise one, call `artifact_get`, then
`artifact_update` to persist a new version, and re-emit with
`<mcwidget title="…" slug="<known-slug>">`.

Rules:

- One `<mcwidget>` per visual payload — don't nest widgets.
- Keep the body self-contained: Tailwind classes, inline `style=`, or a
  short `<style>` block are all fine. Inline `<script>` is permitted for
  small visualization code; third-party `<script src="…">` must target one
  of the CSP-allowed CDNs (`cdn.jsdelivr.net`, `cdnjs.cloudflare.com`).
- For Chart.js, pull the library from jsdelivr and instantiate against a
  `<canvas>` inside the widget body, e.g.:
  ```html
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  <canvas id="c"></canvas>
  <script>new Chart(document.getElementById('c'), { /* config */ })</script>
  ```
- **Give a responsive canvas a definite height, or leave the aspect ratio
  alone.** The Chart.js defaults (`responsive: true`,
  `maintainAspectRatio: true`) are safe exactly as written above. Only set
  `maintainAspectRatio: false` when the canvas's parent has an explicit pixel
  height:
  ```html
  <div style="height:180px"><canvas id="c"></canvas></div>
  ```
  Without one, the canvas and its parent size each other and the self-sizing
  iframe re-feeds the loop, so the chart never settles and the widget renders
  blank. Any library that sizes a canvas from its container — Chart.js,
  ECharts, Plotly's responsive mode — carries the same hazard; inline SVG with
  a fixed `viewBox` has no feedback path at all.
- The iframe CSP is `default-src 'none'`, which forbids more than script
  sources: there are **no network calls** (`connect-src 'none'`, so `fetch`,
  XHR and websockets fail), **no remote images or web fonts** (`img-src data:
  blob:`, `font-src data:` — use inline SVG or a data URI), **no form
  submission** (`form-action 'none'` — use the `data-action` event path
  below), and **no `eval`** (`'unsafe-eval'` is not granted, so a library that
  compiles at runtime is dead on arrival). Pass every value the widget needs
  in its HTML; a widget cannot fetch its own data. A silently blank widget is
  usually one of these.
- The dashboard sanitizes CSS via `src/lib/cssSanitize.ts` (shared with
  `WidgetFrame.tsx`) — a small allowlist of properties plus a denylist of
  dangerous functions (`expression()`, `javascript:`, `url(` with external
  schemes). Write clean CSS and you'll be fine.

## Links

**Always open links in a new tab.** Every `<a>` inside an `<mcwidget>` MUST
carry `target="_blank"` AND `rel="noopener noreferrer"`. The widget iframe
is sandboxed; without `target="_blank"`, navigation either fails silently
or replaces the iframe content with the link target — both broken UX. The
`rel` attribute is non-negotiable for security: it blocks reverse-tabnabbing
and prevents the destination from accessing `window.opener`.

Style links with `style="color:var(--accent)"` (or Tailwind
`text-[var(--accent)]`) for theme-correct contrast. Add `hover:underline`
or `underline` based on density — long copy benefits from underline,
chips / badges look cleaner without.

```html
<a href="https://github.com/example-org/example-repo/pull/1234"
   target="_blank" rel="noopener noreferrer"
   style="color:var(--accent)">#1234</a>
```

**Render identifiers and bare URLs as links** to their known canonical `https://`
targets, keeping the identifier as the label. If a canonical URL is unknown, leave
the identifier as plain text rather than guessing.

For chat messages, paste the full URL the user shares; never reconstruct.

When the visible label can be made shorter than the URL (e.g. a long doc
title), use a meaningful label (`<a href="…">Migration design doc</a>`)
rather than dumping the raw URL.

## Interactive widgets

Widgets can send events back to the agent. Add `data-action` and an
optional `data-payload` (JSON string) attribute to any clickable element:

```html
<button data-action="approve" data-payload='{"id":"123"}'>Approve</button>
```

When clicked, the dashboard auto-submits a user message of the form
`[UI] approve: {"id":"123"}`. The agent receives it as a normal message
and can respond with text, a new widget, or both.

Form inputs with `name` attributes are auto-collected on click and
merged into the payload as `formData`. Use this for creation forms:
render pre-filled `<input>` / `<select>` elements, the user adjusts
values, clicks submit, and the agent receives every field.

Styling for interactive controls (consistent with the dashboard chrome):

- Buttons: `text-xs py-1.5 px-3.5 rounded-md` + theme-var background.
- Labels: `text-[11px]` + `text-[var(--muted)]`.
- Inputs: `text-sm px-2.5 py-2 rounded-md` + `bg-[var(--bg)]` +
  `border-[var(--border)]`.
- Zero hardcoded hex colors. Use the theme variables above for every
  background, foreground, and border.

## Density config

`cfg.dashboard.widget_density` (`more` / `less`, default `more`) controls
the wording of the short pointer that lives in the system prompt. `less`
biases against widgets by default; `more` encourages them. Users can flip
this in dashboard settings. Either way, this skill holds the full rules —
the main prompt only discovers that `<mcwidget>` exists.

