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 palette

SKILL.md

GitHub

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.

Only the names in these two tables reach the sandboxed frame; a parent theme override cannot add more. Two common names are aliased for you: --surface resolves to --card and --surface-secondary to --bg-elevated. Any other name (--surface-raised, --panel, ...) is undefined inside the frame, so var(--panel, #1e1e1e) always paints its dark literal and is unreadable on a light theme.

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

<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. Put <mcwidget> on its own line after a blank line, never glued to a closing ``` fence.

Every widget is already an artifact

Each finalized <mcwidget> without slug= is auto-registered on the backend, keyed idempotently by message timestamp and a fingerprint of the widget body (two identical bodies in one message are one artifact), 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.:
    <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:
    <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.
  • Your widget's own CSS is NOT sanitized: the CSP above is the only control on it. website/src/lib/cssSanitize.ts filters only the theme variable VALUES the dashboard injects into the frame, so do not rely on it to strip anything you write.

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.

<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.

Animation

Move only when the motion itself carries information. The test: freeze one frame. If the meaning survives, keep it still.

Content Animate? Example
Change over time Yes a CI queue growing
Steps in order Yes one request walking the stack
Things flowing Yes messages entering a queue
Before / after Yes old vs new layout
Space, 3D Yes an architecture diagram turning
The user asked for animation Yes a playful scene
Lookups, tables No a PR list
Code, prose No a diff, a report
Something watched all day No a status panel

Every animation:

  • Has a play / pause control, unless it runs under ~5 seconds and stops.
  • Honours matchMedia('(prefers-reduced-motion: reduce)'): show an equal still version instead, such as every step labelled side by side.
  • Stops when it is done. Only small decorative loops may run forever.
  • On Canvas, uses requestAnimationFrame and sizes the canvas to devicePixelRatio, so it stays sharp and pauses in a hidden tab.
  • Carries every value it needs in the HTML: the frame blocks runtime fetches (see Rules).

How to move is the living-ui skill's job. Once the test above says move, load it: it covers how motion feels, where the eye goes, where words sit, keyboard and screen-reader use, and how to check the result before you show it. It never picks a colour, a font or a shape.

If the user wants to take it elsewhere (a slide, Slack, an email), a widget is the wrong container: render a video or GIF file instead.

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:

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

When clicked, the dashboard PRE-FILLS the chat composer with a message of the form [UI] approve: {"id":"123"}; it never sends it on its own. The user reviews the text and presses send, and only then does the agent receive it as a normal message and respond with text, a new widget, or both. Do not tell the user a click "submits" anything, and do not wait on a click as if it were a reply: nothing arrives until they send.

Form inputs (<input>, <select>, <textarea>) carrying a name (or id, or data-field) are auto-collected on click and merged into the payload as formData. Use this for creation forms: render pre-filled elements, the user adjusts values, clicks the action button, then sends the pre-filled message, 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.