Generate on-brand 16:9 placeholder preview images for DevHub resources (recipes, cookbooks, examples) when a real app screenshot is not available. Use when you need to add, regenerate, or improve a resource's previewImageLightUrl / previewImageDarkUrl. Produces a light and a dark PNG at 1920x1080 that passes `npm run verify:images`, wires the images into `src/lib/recipes/recipes.ts`, and verifies them with agent-browser.
81
77%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
Optimize this skill with Tessl
npx tessl skill review --optimize ./.agents/skills/resource-image-generator/SKILL.mdThis skill produces light/dark placeholder preview images that:
CONTRIBUTING.md, section "Image Requirements").The output is always a pair of PNGs (-light.png + -dark.png) at 1920×1080, saved under:
static/img/guides/<id>-preview-{light,dark}.png — for recipes and cookbooks (UI label: "Guide")static/img/examples/<id>-preview-{light,dark}.png — for examplesPick the directory based on the resource's tier in src/lib/recipes/recipes.ts (recipes, templates, or examples).
Generate one resource at a time. When a new resource is added, run this skill for that one id — the full-bulk batch generator is intentionally not kept in the repo; images are a small, focused per-resource artifact.
The only characters that may appear on the image are glyphs that are part of a real UI (a dot, a bar, a cursor caret). Everything else — titles, taglines, service names, badges with words — is forbidden. If you feel the image needs a word to be understood, the skeleton itself is not doing its job; iterate on the skeleton shape instead.
This keeps images:
previewImageLightUrl / previewImageDarkUrl set.npm run verify:images.FallbackCardArt.Skip this skill when a real app screenshot is available — real screenshots always beat mockups.
Read the resource metadata from src/lib/recipes/recipes.ts:
name, description, tags, services — signal what UI the resource builds or acts on.kind — recipe / template / example.Ask: "If this ran to completion, what would the developer be looking at?" That's the UI to sketch. See the mapping table below.
Create a temp HTML file (e.g. /tmp/devhub-image/<id>-light.html). Use the base template below and drop the appropriate skeleton into .app-frame.
Rules:
<rect>s, circles, and lines to stand in for labels, text, chips, and icons. If you find yourself reaching for <text>, stop and replace it with a bar.data-theme on <html>.Generate the dark variant from the light one by flipping only the <html> tag's data-theme. Do not do a global find/replace — CSS selectors [data-theme="light"] ... / [data-theme="dark"] ... must stay intact.
sed 's/<html lang="en" data-theme="light">/<html lang="en" data-theme="dark">/' \
/tmp/devhub-image/<id>-light.html > /tmp/devhub-image/<id>-dark.html
# recipes + cookbooks → static/img/guides/
# examples → static/img/examples/
OUT_DIR=static/img/guides
node scripts/render-resource-image.mjs \
--input /tmp/devhub-image/<id>-light.html \
--output $OUT_DIR/<id>-preview-light.png
node scripts/render-resource-image.mjs \
--input /tmp/devhub-image/<id>-dark.html \
--output $OUT_DIR/<id>-preview-dark.pngnpm run verify:imagesMust pass. If it fails the error tells you the ratio / size mismatch — almost always a canvas-size typo in the HTML.
recipes.tsAdd the paths to the resource's entry in src/lib/recipes/recipes.ts. Use the URL prefix that matches the folder you saved to:
// recipes + cookbooks → /img/guides/
previewImageLightUrl: "/img/guides/<id>-preview-light.png",
previewImageDarkUrl: "/img/guides/<id>-preview-dark.png",
// examples → /img/examples/
previewImageLightUrl: "/img/examples/<id>-preview-light.png",
previewImageDarkUrl: "/img/examples/<id>-preview-dark.png",For recipes, the preview fields live on the recipes[n] entry. For cookbooks, on the templates[n] createTemplate({ ... }) call. For examples, on the createExample({ ... }) call.
Do NOT set galleryImages from this skill — galleries are for real screenshots of a live app. Placeholders go in the single preview slot only.
Make sure the dev server is running (npm run dev).
# Resources list (card-sized view)
agent-browser open http://localhost:3000/resources
agent-browser eval "window.scrollTo(0, 300); 'done'"
agent-browser screenshot /tmp/devhub-audit/<id>-list.png
# Detail page (full-width view)
agent-browser open http://localhost:3000/resources/<id>
agent-browser screenshot /tmp/devhub-audit/<id>-detail-light.png
# Dark mode
agent-browser eval "document.documentElement.setAttribute('data-theme', 'dark'); 'done'"
agent-browser screenshot /tmp/devhub-audit/<id>-detail-dark.pngRead each screenshot and evaluate against the checklist below. If any answer is "no", iterate on the HTML and re-render.
/resources is ~380px wide. The dominant UI shape (bubbles, rows, sidebar) must still be recognizable at that size, not a mush of tiny rectangles.If any check fails, tweak the HTML and re-render — don't "fix" by changing layout in CSS at runtime.
Once the pair passes both npm run verify:images and the visual checklist, commit:
static/img/examples/<id>-preview-light.pngstatic/img/examples/<id>-preview-dark.pngsrc/lib/recipes/recipes.tsPulled from src/css/custom.css. Do not deviate.
| Token | Hex | Role |
|---|---|---|
--db-navy | #0b2026 | Dark page background, dark body text |
--db-navy-light | #1b3139 | Dark-mode card / app-surface |
--db-lava | #ff3621 | Brand orange (primary accent in light mode) |
--db-lava-light | #ff5542 | Brand orange (primary accent in dark mode) |
--db-oat-medium | #eeede9 | Cream accent (light-mode header/row highlight) |
--db-bg | #f9f7f4 | Light page bg |
--db-card | #ffffff | Light-mode card / app-surface |
| Body fg (light) | #0b2026 | All skeleton bars use this at 18–65% opacity in light mode |
| Body fg (dark) | #f3eee7 | All skeleton bars use this at 22–70% opacity in dark mode |
Copy this into /tmp/devhub-image/<id>-light.html and replace <!-- SKELETON --> with one of the skeletons from the library below. The only value that changes between the -light.html and -dark.html variants is data-theme on <html>.
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<style>
:root {
--db-navy: #0b2026;
--db-navy-light: #1b3139;
--db-lava: #ff3621;
--db-lava-light: #ff5542;
--db-oat-medium: #eeede9;
--db-bg: #f9f7f4;
--db-card: #ffffff;
}
html,
body {
margin: 0;
padding: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
font-family:
"DM Sans",
ui-sans-serif,
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
-webkit-font-smoothing: antialiased;
}
[data-theme="light"] body {
background: var(--db-bg);
}
[data-theme="dark"] body {
background: var(--db-navy);
}
.stage {
width: 1920px;
height: 1080px;
padding: 96px 128px;
box-sizing: border-box;
}
.app-frame {
width: 100%;
height: 100%;
border-radius: 28px;
overflow: hidden;
position: relative;
background: var(--db-card);
box-shadow: 0 24px 64px rgba(11, 32, 38, 0.1);
}
[data-theme="dark"] .app-frame {
background: var(--db-navy-light);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
}
/* Default skeleton color references */
:root {
--skeleton: rgba(11, 32, 38, 0.62);
--skeleton-muted: rgba(11, 32, 38, 0.18);
--skeleton-strong: rgba(11, 32, 38, 0.92);
--surface-soft: var(--db-oat-medium);
--divider: rgba(11, 32, 38, 0.08);
}
[data-theme="dark"] {
--skeleton: rgba(243, 238, 231, 0.68);
--skeleton-muted: rgba(243, 238, 231, 0.22);
--skeleton-strong: rgba(243, 238, 231, 0.95);
--surface-soft: rgba(243, 238, 231, 0.06);
--divider: rgba(243, 238, 231, 0.08);
}
</style>
</head>
<body>
<div class="stage">
<div class="app-frame">
<!-- SKELETON -->
</div>
</div>
</body>
</html>The base is deliberately small. All variation happens inside .app-frame.
Pick the skeleton that matches the dominant UI the resource produces. If several fit, pick the one that matches the primary outcome (what you'd demo first), not the one that mentions the most services.
Two-column layout: narrow sidebar with thread items, main pane with alternating bubbles, bottom input row. One orange outgoing bubble. No glyphs.
<div style="display:grid; grid-template-columns: 240px 1fr; height:100%;">
<!-- Sidebar -->
<div
style="background:var(--surface-soft); padding:32px 20px; border-right:1px solid var(--divider);"
>
<div
style="width:96px; height:12px; border-radius:3px; background:var(--skeleton); opacity:.85; margin-bottom:24px;"
></div>
<div style="display:flex; flex-direction:column; gap:12px;">
<div
style="height:44px; border-radius:10px; background:var(--skeleton-muted);"
></div>
<div
style="height:44px; border-radius:10px; background:var(--skeleton-strong); opacity:.12;"
></div>
<div
style="height:44px; border-radius:10px; background:var(--skeleton-muted);"
></div>
<div
style="height:44px; border-radius:10px; background:var(--skeleton-muted);"
></div>
<div
style="height:44px; border-radius:10px; background:var(--skeleton-muted);"
></div>
</div>
</div>
<!-- Main pane -->
<div
style="padding:36px 80px; display:flex; flex-direction:column; justify-content:space-between;"
>
<div style="display:flex; flex-direction:column; gap:28px;">
<!-- incoming -->
<div
style="align-self:flex-start; max-width:56%; background:var(--surface-soft); padding:22px 26px; border-radius:22px 22px 22px 6px;"
>
<div
style="width:100%; height:10px; border-radius:3px; background:var(--skeleton); opacity:.55; margin-bottom:10px;"
></div>
<div
style="width:82%; height:10px; border-radius:3px; background:var(--skeleton); opacity:.42;"
></div>
</div>
<!-- outgoing (lava) -->
<div
style="align-self:flex-end; max-width:62%; background:var(--db-lava); padding:22px 26px; border-radius:22px 22px 6px 22px;"
>
<div
style="width:100%; height:10px; border-radius:3px; background:#fff; opacity:.92; margin-bottom:10px;"
></div>
<div
style="width:88%; height:10px; border-radius:3px; background:#fff; opacity:.78; margin-bottom:10px;"
></div>
<div
style="width:40%; height:10px; border-radius:3px; background:#fff; opacity:.7;"
></div>
</div>
<!-- incoming streaming (dots) -->
<div
style="align-self:flex-start; max-width:50%; background:var(--surface-soft); padding:22px 26px; border-radius:22px 22px 22px 6px; display:flex; gap:10px;"
>
<div
style="width:10px; height:10px; border-radius:50%; background:var(--skeleton); opacity:.65;"
></div>
<div
style="width:10px; height:10px; border-radius:50%; background:var(--skeleton); opacity:.45;"
></div>
<div
style="width:10px; height:10px; border-radius:50%; background:var(--skeleton); opacity:.28;"
></div>
</div>
</div>
<!-- input row -->
<div
style="display:flex; gap:16px; align-items:center; padding-top:28px; border-top:1px solid var(--divider);"
>
<div
style="flex:1; height:56px; border-radius:28px; background:var(--surface-soft);"
></div>
<div
style="width:56px; height:56px; border-radius:50%; background:var(--db-lava);"
></div>
</div>
</div>
</div>Topbar + rounded table: header row in cream, then 7 data rows with subtle dividers. One row (typically row 2) gets an orange leading badge pill to signal the active / new record. No text.
<div style="display:flex; flex-direction:column; height:100%;">
<!-- Topbar -->
<div
style="height:84px; display:flex; align-items:center; padding:0 40px; gap:28px; border-bottom:1px solid var(--divider);"
>
<div
style="width:28px; height:28px; border-radius:8px; background:var(--db-lava);"
></div>
<div style="display:flex; gap:20px; flex:1;">
<div
style="width:64px; height:10px; border-radius:3px; background:var(--skeleton); opacity:.55;"
></div>
<div
style="width:64px; height:10px; border-radius:3px; background:var(--skeleton); opacity:.28;"
></div>
<div
style="width:64px; height:10px; border-radius:3px; background:var(--skeleton); opacity:.28;"
></div>
</div>
<div
style="width:36px; height:36px; border-radius:50%; background:var(--surface-soft);"
></div>
</div>
<!-- Table -->
<div style="flex:1; padding:40px 60px;">
<div
style="border-radius:16px; overflow:hidden; border:1px solid var(--divider);"
>
<!-- header -->
<div
style="display:grid; grid-template-columns:28px 1.4fr 1fr 0.8fr 0.8fr 0.6fr; gap:24px; padding:22px 28px; background:var(--surface-soft); align-items:center;"
>
<div></div>
<div
style="height:10px; border-radius:3px; background:var(--skeleton); opacity:.52;"
></div>
<div
style="height:10px; border-radius:3px; background:var(--skeleton); opacity:.52;"
></div>
<div
style="height:10px; border-radius:3px; background:var(--skeleton); opacity:.52;"
></div>
<div
style="height:10px; border-radius:3px; background:var(--skeleton); opacity:.52;"
></div>
<div
style="height:10px; border-radius:3px; background:var(--skeleton); opacity:.52;"
></div>
</div>
<!-- rows (7) — second row is the orange accent -->
<div class="row"></div>
</div>
</div>
</div>
<style>
.row {
/* replaced inline — see below */
}
</style>Replace <div class="row"></div> with seven data rows. Each data row is this pattern, with one (the 2nd or 3rd) using the orange badge instead of a muted one:
<div
style="display:grid; grid-template-columns:28px 1.4fr 1fr 0.8fr 0.8fr 0.6fr; gap:24px; padding:22px 28px; align-items:center; border-top:1px solid var(--divider);"
>
<div
style="width:14px; height:14px; border-radius:4px; background:var(--skeleton-muted);"
></div>
<div
style="height:10px; border-radius:3px; background:var(--skeleton); opacity:.48;"
></div>
<div
style="height:10px; border-radius:3px; background:var(--skeleton); opacity:.38;"
></div>
<div
style="height:10px; border-radius:3px; background:var(--skeleton); opacity:.42;"
></div>
<div
style="height:10px; border-radius:3px; background:var(--skeleton); opacity:.42;"
></div>
<div
style="height:20px; border-radius:10px; background:var(--skeleton-muted); justify-self:start; width:52px;"
></div>
</div>For the accent row, replace the last cell's background with var(--db-lava) and the leading checkbox with background:var(--db-lava); opacity:1;.
Topbar + narrow sidebar + content: a row of 4 KPI cards (each a miniature mock), then a full-width chart card with a bar chart. One KPI card and one chart bar carry the lava accent.
<div style="display:grid; grid-template-columns:200px 1fr; height:100%;">
<!-- Sidebar -->
<div
style="background:var(--surface-soft); padding:32px 20px; border-right:1px solid var(--divider); display:flex; flex-direction:column; gap:12px;"
>
<div
style="width:32px; height:32px; border-radius:8px; background:var(--db-lava); margin-bottom:16px;"
></div>
<div
style="height:38px; border-radius:10px; background:var(--skeleton-strong); opacity:.14;"
></div>
<div
style="height:38px; border-radius:10px; background:var(--skeleton-muted);"
></div>
<div
style="height:38px; border-radius:10px; background:var(--skeleton-muted);"
></div>
<div
style="height:38px; border-radius:10px; background:var(--skeleton-muted);"
></div>
<div
style="height:38px; border-radius:10px; background:var(--skeleton-muted);"
></div>
</div>
<!-- Content -->
<div
style="padding:40px 60px; display:flex; flex-direction:column; gap:40px;"
>
<!-- KPI row -->
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:24px;">
<!-- normal KPI card -->
<div
style="background:var(--surface-soft); border-radius:16px; padding:22px 26px;"
>
<div
style="width:60%; height:10px; border-radius:3px; background:var(--skeleton); opacity:.5; margin-bottom:16px;"
></div>
<div
style="width:50%; height:20px; border-radius:5px; background:var(--skeleton); opacity:.78;"
></div>
</div>
<!-- accent KPI card -->
<div
style="background:var(--db-lava); border-radius:16px; padding:22px 26px;"
>
<div
style="width:60%; height:10px; border-radius:3px; background:#fff; opacity:.85; margin-bottom:16px;"
></div>
<div
style="width:50%; height:20px; border-radius:5px; background:#fff; opacity:.95;"
></div>
</div>
<div
style="background:var(--surface-soft); border-radius:16px; padding:22px 26px;"
>
<div
style="width:60%; height:10px; border-radius:3px; background:var(--skeleton); opacity:.5; margin-bottom:16px;"
></div>
<div
style="width:50%; height:20px; border-radius:5px; background:var(--skeleton); opacity:.78;"
></div>
</div>
<div
style="background:var(--surface-soft); border-radius:16px; padding:22px 26px;"
>
<div
style="width:60%; height:10px; border-radius:3px; background:var(--skeleton); opacity:.5; margin-bottom:16px;"
></div>
<div
style="width:50%; height:20px; border-radius:5px; background:var(--skeleton); opacity:.78;"
></div>
</div>
</div>
<!-- Chart card -->
<div
style="flex:1; background:var(--surface-soft); border-radius:16px; padding:32px 40px;"
>
<div
style="display:flex; gap:20px; align-items:end; height:100%; padding-bottom:12px; border-bottom:1px solid var(--divider);"
>
<div
style="flex:1; height:42%; border-radius:8px 8px 0 0; background:var(--skeleton); opacity:.45;"
></div>
<div
style="flex:1; height:62%; border-radius:8px 8px 0 0; background:var(--skeleton); opacity:.55;"
></div>
<div
style="flex:1; height:78%; border-radius:8px 8px 0 0; background:var(--db-lava);"
></div>
<div
style="flex:1; height:56%; border-radius:8px 8px 0 0; background:var(--skeleton); opacity:.48;"
></div>
<div
style="flex:1; height:34%; border-radius:8px 8px 0 0; background:var(--skeleton); opacity:.38;"
></div>
<div
style="flex:1; height:48%; border-radius:8px 8px 0 0; background:var(--skeleton); opacity:.42;"
></div>
<div
style="flex:1; height:70%; border-radius:8px 8px 0 0; background:var(--skeleton); opacity:.55;"
></div>
</div>
</div>
</div>
</div>Centered mac-style window with traffic-light dots, a short terminal prompt line (just a single bar), three lines of "code" (rects), and a running cursor.
<div
style="height:100%; display:flex; align-items:center; justify-content:center;"
>
<div
style="width:66%; aspect-ratio: 16/10; background:var(--db-navy); border-radius:20px; padding:28px 36px; box-shadow:0 16px 40px rgba(0,0,0,.15);"
>
<div style="display:flex; gap:10px; margin-bottom:28px;">
<div
style="width:14px; height:14px; border-radius:50%; background:#ff5e57;"
></div>
<div
style="width:14px; height:14px; border-radius:50%; background:#ffbd2e;"
></div>
<div
style="width:14px; height:14px; border-radius:50%; background:#28c840;"
></div>
</div>
<div style="display:flex; flex-direction:column; gap:16px;">
<div style="display:flex; gap:14px; align-items:center;">
<div
style="width:18px; height:10px; background:var(--db-lava); border-radius:2px;"
></div>
<div
style="width:42%; height:10px; border-radius:3px; background:#fff; opacity:.85;"
></div>
</div>
<div
style="width:72%; height:10px; border-radius:3px; background:#fff; opacity:.55;"
></div>
<div
style="width:58%; height:10px; border-radius:3px; background:#fff; opacity:.45;"
></div>
<div style="display:flex; gap:14px; align-items:center;">
<div
style="width:18px; height:10px; background:var(--db-lava); border-radius:2px;"
></div>
<div
style="width:16px; height:18px; background:#fff; opacity:.85;"
></div>
</div>
</div>
</div>
</div>Horizontal flow of three stages connected by arrows. The final stage (gold / serving) is the lava accent. Each stage is a tall rounded card with skeleton bars inside so it looks like a table / pipeline stage summary.
<div
style="height:100%; display:flex; align-items:center; justify-content:center; gap:48px; padding:0 120px;"
>
<!-- Stage 1 -->
<div
style="flex:1; aspect-ratio: 3/4; background:var(--surface-soft); border-radius:18px; padding:28px 24px; display:flex; flex-direction:column; gap:10px;"
>
<div
style="width:40%; height:10px; border-radius:3px; background:var(--skeleton); opacity:.55; margin-bottom:8px;"
></div>
<div
style="height:8px; border-radius:2px; background:var(--skeleton); opacity:.32;"
></div>
<div
style="height:8px; border-radius:2px; background:var(--skeleton); opacity:.32;"
></div>
<div
style="height:8px; border-radius:2px; background:var(--skeleton); opacity:.32;"
></div>
<div
style="height:8px; border-radius:2px; background:var(--skeleton); opacity:.32;"
></div>
</div>
<!-- Arrow -->
<div
style="width:72px; height:4px; background:var(--skeleton); opacity:.4;"
></div>
<!-- Stage 2 -->
<div
style="flex:1; aspect-ratio: 3/4; background:var(--surface-soft); border-radius:18px; padding:28px 24px; display:flex; flex-direction:column; gap:10px;"
>
<div
style="width:40%; height:10px; border-radius:3px; background:var(--skeleton); opacity:.55; margin-bottom:8px;"
></div>
<div
style="height:8px; border-radius:2px; background:var(--skeleton); opacity:.32;"
></div>
<div
style="height:8px; border-radius:2px; background:var(--skeleton); opacity:.32;"
></div>
<div
style="height:8px; border-radius:2px; background:var(--skeleton); opacity:.32;"
></div>
</div>
<!-- Arrow -->
<div
style="width:72px; height:4px; background:var(--skeleton); opacity:.4;"
></div>
<!-- Stage 3 (accent) -->
<div
style="flex:1; aspect-ratio: 3/4; background:var(--db-lava); border-radius:18px; padding:28px 24px; display:flex; flex-direction:column; gap:10px;"
>
<div
style="width:40%; height:10px; border-radius:3px; background:#fff; opacity:.85; margin-bottom:8px;"
></div>
<div
style="height:8px; border-radius:2px; background:#fff; opacity:.55;"
></div>
<div
style="height:8px; border-radius:2px; background:#fff; opacity:.55;"
></div>
<div
style="height:8px; border-radius:2px; background:#fff; opacity:.55;"
></div>
</div>
</div>A nearly-empty app surface with a single small centered card, centered in the app frame. Convey "this is a blank canvas to start from".
<div
style="height:100%; display:flex; align-items:center; justify-content:center;"
>
<div
style="width:360px; height:200px; background:var(--surface-soft); border-radius:16px; padding:28px; display:flex; flex-direction:column; gap:14px; justify-content:center; align-items:flex-start;"
>
<div
style="width:140px; height:10px; border-radius:3px; background:var(--skeleton); opacity:.6;"
></div>
<div
style="width:220px; height:8px; border-radius:2px; background:var(--skeleton); opacity:.35;"
></div>
<div
style="width:180px; height:8px; border-radius:2px; background:var(--skeleton); opacity:.35;"
></div>
<div
style="width:92px; height:34px; border-radius:10px; background:var(--db-lava); margin-top:12px;"
></div>
</div>
</div>| Resource signal | Skeleton |
|---|---|
| Tags / services mention Chat, AI SDK, Agent, Streaming, Assistant | Chat |
| Tags mention Lakebase, Postgres, Persistence, CRUD, admin tool | Table |
| Tags mention Genie, Analytics, AI/BI, Dashboard, KPIs, SaaS | Dashboard |
| Recipe is about CLI setup, bootstrap, scaffolding, local env | Code / CLI |
| Tags mention Pipelines, CDC, Medallion, Sync, Lakeflow | Pipeline |
| Hello-world / starter / empty-state / "first app" | Minimal |
When multiple fit, pick the one that matches the primary outcome (what the developer would see and demo first), not the one that mentions the most services.
<html>/<body> isn't locked to 1920×1080. Ensure the CSS at the top of the base template is unmodified.<text> or any copy-ish element and replace with a bar.sed on data-theme. Use the scoped sed command in step 3 so CSS selectors stay intact.--skeleton, --skeleton-muted, ...) weren't left on the root — they must switch per theme via [data-theme="dark"] { ... }, which the base template already does.CONTRIBUTING.md, section "Image Requirements"scripts/verify-example-images.mjs (pre-commit)scripts/render-resource-image.mjssrc/css/custom.css6338825
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.