deco CMS Home Widgets
MCP tools can render a small React app ("MCP-App") as a widget/tile on the deco
home ("What's on your mind" screen). This skill explains how the home sizes those
tiles and how to make a widget that looks right at every size and state.
When to use this skill
- Building or fixing a home widget for an MCP (the small resizable cards on the home).
- A widget's content is being clipped, has no scroll, wastes space, or doesn't
adapt when resized.
- You need to register a new widget resource.
Reference implementation: tanstack-migrator/web/tools/widget-queue/index.tsx
(responsive 2-column queue + suggestions with internal scroll) and
tanstack-migrator/web/tools/widget-active/index.tsx (compact single card).
How the tile works (READ THIS FIRST)
The home controls the tile size, not the MCP. The user resizes each tile with a
grid picker ("Size", columns × rows, up to 4×5). There is no API to declare
a default size from the MCP — assume the widget can be handed any width × height.
The home wraps your widget in a tile that is roughly:
div.relative.flex.h-full.w-full.flex-col.p-3 (larger tiles)
div.flex.min-h-0.w-full.flex-1.flex-col.overflow-hidden (smaller tiles)
Two consequences that drive every rule below:
- The tile is
h-full w-full — your widget should fill it, not hug content.
- The tile is
overflow-hidden — anything taller than the tile is clipped
(no scrollbar) unless you add an internal scroll region.
Observed tile sizes (px)
| cols |
width |
rows |
height |
| 1 |
~282 |
min |
~166 |
| 2 |
~624 |
+1 |
~192 |
| 3 |
~940 |
… |
~292 |
| 4 |
~1256 |
… |
~392, ~492 |
≈ 316px per column, ≈ 100px per row. Widths and heights combine freely
(e.g. 1×2, 2×2, 3×3, 4×5), so design for a range, not fixed dimensions.
Golden rules
Root fills the tile: className="flex h-full min-h-0 flex-col". Never rely on
natural content height — tall content gets clipped by the tile's overflow-hidden.
Scroll inside: put lists in a region with flex-1 min-h-0 overflow-y-auto.
min-h-0 is required or the flex child refuses to shrink and the scroll never engages.
Breakpoints via ResizeObserver, NOT media queries. The tile is not the
viewport — @media/sm: react to the window, not the tile. Measure the tile:
function useWide(ref: React.RefObject<HTMLElement | null>, px: number) {
const [wide, setWide] = useState(false);
useEffect(() => {
const el = ref.current; if (!el) return;
const ro = new ResizeObserver((es) => { for (const e of es) setWide(e.contentRect.width >= px); });
ro.observe(el); return () => ro.disconnect();
}, [ref, px]);
return wide;
}
(Tailwind v4 @container queries also work if you mark the root @container.)
Adapt columns to width. e.g. ≥520px → two columns side by side; below →
stack them in one scroll column. Give empty/low-value sections less room
(grid-cols-[0.8fr_1.2fr]) so the useful content isn't cramped.
Every state fills too: loading / error / empty use
flex flex-1 items-center justify-center so they center in the tile at any size.
Avoid double padding: the tile already applies p-3 at larger sizes. Keep a
single modest padding on your root; don't nest another p-3 inside.
Icons on a dark tile: transparent/dark logos vanish on the dark background.
Put remote favicons on a fixed light background (bg-white object-contain p-0.5)
and fall back through logo → screenshot → colored letter-avatar (never an empty
gray box — some icon endpoints return a blank 200 that won't fire onError).
Registering a widget (decocms/mcps pattern)
Five wiring points (see tanstack-migrator/ for a live example):
- Constants — a resource URI in
server/constants.ts:
export const WIDGET_QUEUE_RESOURCE_URI = "ui://<mcp>/widget-queue";
- Tool — in
server/tools/widgets.ts, a createTool that returns the widget's
data and links the UI via _meta: { ui: { resourceUri: WIDGET_QUEUE_RESOURCE_URI } }
and annotations: { readOnlyHint: true }. Register it in server/tools/index.ts.
- Resource — in
server/resources/index.ts, createMcpAppResource({ uri, name, description, htmlFile: "widget-queue.html" }); add it to the resources: [...]
array in server/main.ts.
- Vite entry — add the HTML to
MCP_APP_ENTRIES in vite.config.ts and to the
build:web script in package.json (MCP_APP_ENTRY=widget-queue … vite build).
- HTML + component —
widget-queue.html (root div + <script src=./web/tools/widget-queue/main.tsx>)
and the React component in web/tools/widget-queue/index.tsx, fetching data with
usePollingTool<T>(TOOL_ID, {}, intervalMs) from web/hooks/use-tool.ts.
Checklist before shipping a widget
1---2name: decocms-home-widgets3description: Build responsive deco CMS home widgets (MCP-App tiles). Covers the resizable tile grid (Size picker up to 4×5), observed pixel sizes, the golden rules for filling + scrolling inside a tile, ResizeObserver-based breakpoints, and how to register a widget (tool _meta.ui.resourceUri → resource → constants → vite entry → html).4---56# deco CMS Home Widgets78MCP tools can render a small React app ("MCP-App") as a **widget/tile** on the deco9home ("What's on your mind" screen). This skill explains how the home sizes those10tiles and how to make a widget that looks right at **every** size and state.1112## When to use this skill1314- Building or fixing a home widget for an MCP (the small resizable cards on the home).15- A widget's content is being clipped, has no scroll, wastes space, or doesn't16 adapt when resized.17- You need to register a new widget resource.1819Reference implementation: `tanstack-migrator/web/tools/widget-queue/index.tsx`20(responsive 2-column queue + suggestions with internal scroll) and21`tanstack-migrator/web/tools/widget-active/index.tsx` (compact single card).2223## How the tile works (READ THIS FIRST)2425The **home controls the tile size**, not the MCP. The user resizes each tile with a26grid picker ("Size", **columns × rows**, up to **4×5**). There is **no API to declare27a default size** from the MCP — assume the widget can be handed any width × height.2829The home wraps your widget in a tile that is roughly:3031```32div.relative.flex.h-full.w-full.flex-col.p-3 (larger tiles)33div.flex.min-h-0.w-full.flex-1.flex-col.overflow-hidden (smaller tiles)34```3536Two consequences that drive every rule below:371. The tile is `h-full w-full` — your widget should **fill it**, not hug content.382. The tile is **`overflow-hidden`** — anything taller than the tile is **clipped**39 (no scrollbar) unless *you* add an internal scroll region.4041### Observed tile sizes (px)4243| cols | width | rows | height |44|------|--------|------|--------|45| 1 | ~282 | min | ~166 |46| 2 | ~624 | +1 | ~192 |47| 3 | ~940 | … | ~292 |48| 4 | ~1256 | … | ~392, ~492 |4950≈ **316px per column**, ≈ **100px per row**. Widths and heights combine freely51(e.g. 1×2, 2×2, 3×3, 4×5), so design for a **range**, not fixed dimensions.5253## Golden rules54551. **Root fills the tile:** `className="flex h-full min-h-0 flex-col"`. Never rely on56 natural content height — tall content gets clipped by the tile's `overflow-hidden`.572. **Scroll inside:** put lists in a region with `flex-1 min-h-0 overflow-y-auto`.58 `min-h-0` is required or the flex child refuses to shrink and the scroll never engages.593. **Breakpoints via `ResizeObserver`, NOT media queries.** The tile is not the60 viewport — `@media`/`sm:` react to the window, not the tile. Measure the tile:6162 ```tsx63 function useWide(ref: React.RefObject<HTMLElement | null>, px: number) {64 const [wide, setWide] = useState(false);65 useEffect(() => {66 const el = ref.current; if (!el) return;67 const ro = new ResizeObserver((es) => { for (const e of es) setWide(e.contentRect.width >= px); });68 ro.observe(el); return () => ro.disconnect();69 }, [ref, px]);70 return wide;71 }72 ```73 (Tailwind v4 `@container` queries also work if you mark the root `@container`.)744. **Adapt columns to width.** e.g. ≥520px → two columns side by side; below →75 stack them in one scroll column. Give empty/low-value sections less room76 (`grid-cols-[0.8fr_1.2fr]`) so the useful content isn't cramped.775. **Every state fills too:** loading / error / empty use78 `flex flex-1 items-center justify-center` so they center in the tile at any size.796. **Avoid double padding:** the tile already applies `p-3` at larger sizes. Keep a80 single modest padding on your root; don't nest another `p-3` inside.817. **Icons on a dark tile:** transparent/dark logos vanish on the dark background.82 Put remote favicons on a fixed light background (`bg-white object-contain p-0.5`)83 and fall back through `logo → screenshot → colored letter-avatar` (never an empty84 gray box — some icon endpoints return a blank 200 that won't fire `onError`).8586## Registering a widget (decocms/mcps pattern)8788Five wiring points (see `tanstack-migrator/` for a live example):89901. **Constants** — a resource URI in `server/constants.ts`:91 `export const WIDGET_QUEUE_RESOURCE_URI = "ui://<mcp>/widget-queue";`922. **Tool** — in `server/tools/widgets.ts`, a `createTool` that returns the widget's93 data and links the UI via `_meta: { ui: { resourceUri: WIDGET_QUEUE_RESOURCE_URI } }`94 and `annotations: { readOnlyHint: true }`. Register it in `server/tools/index.ts`.953. **Resource** — in `server/resources/index.ts`, `createMcpAppResource({ uri, name,96 description, htmlFile: "widget-queue.html" })`; add it to the `resources: [...]`97 array in `server/main.ts`.984. **Vite entry** — add the HTML to `MCP_APP_ENTRIES` in `vite.config.ts` and to the99 `build:web` script in `package.json` (`MCP_APP_ENTRY=widget-queue … vite build`).1005. **HTML + component** — `widget-queue.html` (root div + `<script src=./web/tools/widget-queue/main.tsx>`)101 and the React component in `web/tools/widget-queue/index.tsx`, fetching data with102 `usePollingTool<T>(TOOL_ID, {}, intervalMs)` from `web/hooks/use-tool.ts`.103104## Checklist before shipping a widget105106- [ ] Root is `flex h-full min-h-0 flex-col` (fills the tile).107- [ ] Long lists live in `flex-1 min-h-0 overflow-y-auto` (scroll, not clip).108- [ ] Layout switches on **tile width** via `ResizeObserver`/`@container`, not media queries.109- [ ] loading / error / empty states center with `flex-1`.110- [ ] Looks right at 1×2, 2×2, 3×3, 4×5 and the minimum tile.111- [ ] No double `p-3`; icons readable on the dark background with a real fallback.112- [ ] Widget wired: constants → tool `_meta.ui` → resource → `main.ts` → vite entry → html.