# Widget Design Guide for Agents — lulu-ads result widgets

You are designing a result widget for an MCP tool using the `lulu-ads` SDK
(`register_result_widget`). This document is your design system and your
rules. Follow it exactly; where it conflicts with your instincts, it wins.
Human-readable companion: https://getlulu.dev/playbook/mcp-widget-design-guide.pdf

## The one law

**The tool's data is the hero. Everything else supports it.**
A widget exists to make the answer legible in one glance. If any decoration
competes with the answer, delete the decoration.

## Choose the template first (never start from custom)

| The tool's answer is… | Template |
|---|---|
| One primary value (a temperature, a price, a score, a balance) | `stat-card` |
| Rows to compare (results, rankings, forecasts, options with columns) | `table-card` |
| A verdict (available/taken, passed/failed, ok/warning) | `notice-card` |
| 3–8 rich alternatives to browse (listings, hotels, flights) | `carousel-card` |

Only if none fit, compose a custom body — using the `.lw-*` primitives
ONLY (`.lw-header`, `.lw-value`, `.lw-chip`, `.lw-row`, `.lw-glyph`).
Never write ad-hoc CSS for things the primitives already do.

## Hierarchy rules

1. ONE oversized element per card (the `.lw-value` or the verdict). Never two.
2. Eyebrow above it: small caps, letter-spaced, muted — context, not content.
3. Secondary data goes in chips or rows, never in sentences.
4. Numbers that will be compared get monospace (`tabular-nums`) and right
   alignment.
5. Highlight at most ONE row/item as "best/selected" (orange rail + badge).

## Atmosphere (backgrounds)

Use a condition-driven atmosphere ONLY when the background itself carries
meaning (weather conditions, status severity). Decorative gradients that
mean nothing are noise — use the flat dark card instead. Atmosphere must
never reduce text contrast below comfortable legibility.

## The sponsored strip — inviolable

The frame renders a disclosed SPONSORED strip at the bottom whenever
`structuredContent.sponsored` exists. Rules you must never break:

- Never remove, hide, restyle, cover, or reposition the strip.
- Never imitate it in the body (no fake "sponsored" elements).
- Desktop: dark bar · small orange-gradient SPONSORED chip · advertiser
  logo tile (letter-tile fallback) · text · CTA · "via Lulu Ads".
- Mobile (≤440px): full-width orange gradient band (SPONSORED left,
  "via Lulu Ads" right), then logo | wrapped text columns, CTA beneath.
- Motion: the strip's sheen plays ONCE on render, then rests. Never add
  looping animation, tickers, or rotation anywhere in the widget.

The strip is calm by design: disclosure must be always legible, never
screaming. Your body content should be quieter than the answer and louder
than the strip.

## Native, not spammy

DO: generous padding; one accent color moment; real data in previews;
16px radius; transparent canvas (`color-scheme: light dark` is in the
frame — do not override); text truncation only with full-content
alternatives (wrap on mobile).
DON'T: emoji walls; more than two font sizes in the body; blinking or
looping motion; centered paragraphs; borders around everything; shadows
inside the card; anything that resembles a banner ad.

## Mobile

The iframe viewport equals the card. At ≤440px: values scale down one
step, rows may stack, text wraps instead of truncating (2–3 line clamp).
Test at 360px. If content is cut at 360px, restructure — do not shrink
type below 12px.

## Verification checklist (all mandatory before you declare done)

- [ ] Rendered screenshot on a DARK host background — no white corners,
      contrast comfortable.
- [ ] Rendered screenshot on a LIGHT host background.
- [ ] Screenshot at 360px width — nothing truncated that matters.
- [ ] With `sponsored` present — strip renders, correct layout per width.
- [ ] Without `sponsored` — strip absent entirely, no gap.
- [ ] Every mapped field appears; no field invented, none silently dropped.
- [ ] Show the screenshots to the developer and get approval before
      committing anything.

## Getting the machinery

`pip install lulu-ads` / `npm install lulu-ads` (**≥0.8.5**), then
`register_result_widget(mcp, tool, template=..., mapping=..., endpoint_url=...)`.
Keys and the full growth stack: https://getlulu.dev/growth-kit
(the MCP Growth Kit — playbook + five agent skills, including
`build-result-widget` which operationalizes this guide).

## What 0.8.5 handles for you — one widget, every host

Verified live: the full result widget (table + sponsored strip) renders
on **claude.ai** and **ChatGPT**, with the rendered-impression beacon
confirmed firing from real user sessions on both stacks.

- **Three bridges, zero handshake code for you.** The frame speaks the
  stable MCP Apps (2026-01-26) `ui/initialize` protocol, falls back to
  the draft-era init on older MCP hosts, and speaks ChatGPT's
  `window.openai` bridge natively. Any host that implements either
  standard renders your widget — including hosts that don't exist yet.
- **Registered for both ecosystems automatically.** The SDK declares the
  MCP Apps `resourceUri` AND ChatGPT's `openai/outputTemplate` on your
  tool, plus both CSP dialects (`ui.csp` and `openai/widgetCSP`) on the
  resource — without these, hosts either can't find your template or
  block the beacon image.
- **Rendered-impression beacon.** The sponsored strip fires a 1px beacon
  the moment it actually becomes visible — so impression earnings count
  what a human saw, never mere API output.
- **Host quirks are not your bug.** Claude's Apps runtime intermittently
  fails to start (a documented host-side issue affecting every widget
  builder) — when that happens your ad still ships as a disclosed text
  line and every event still tracks; the widget is the upgrade, not the
  dependency. Tip: after upgrading the SDK, refresh your connector in
  ChatGPT's plugin settings — it caches tool metadata.

— Lulu Ads · disclosed always · data, never directives
