Renders carousel slides to PNG via Playwright. Invoked by carousel-b2b-strategy. Takes script + selected images → builds HTML/CSS → renders each slide → runs 12-item checklist → outputs PNGs + carousel-preview.html.
Recursos
2Instal·la
npx skillscat add daniel-ospina/agent-infra/carousel-b2b-design Instal·la des del registre de SkillsCat.
⛔ This skill MUST be read in full — not skimmed. Formal review gates depend on its workflow.
Skipping steps silently bypasses quality checks. Missing gates = undetected breakages.
Carousel B2B Design
Renders branded carousel slides as 1080×1080 PNGs using the El Dato brand design system. Handles both slide types: photo-hero (photo full-bleed + text overlay) and text-slide (text on solid purple).
Input
Reads from docs/carousels/<slug>/:
script.yaml— slide definitions with copy and creative directionselected-images.yaml— Cloudinary URLs for photo slides
Output
Writes to docs/carousels/<slug>/:
carousel.html— self-contained HTML with all slidescarousel-preview.html— browser-viewable sequenceslides/— numbered PNGs (01_portada.png...NN_cta.png)- Uploads to Cloudinary at
eldato/carousels/<slug>/
Design Principles
Feed-scale verification: Text must be readable at ~375px Instagram feed width. Font sizes below 28px on a 1080px canvas become ~10px at feed size — illegible. Review slides at simulated feed size before approval (#4599).
Adaptive gradients: Gradient coverage must be proportional to text density. Less text = lighter scrim. A slide with 2 words should not have the same gradient as a slide with 200 words (#4599).
**155px horizontal safe margin: All text and key content must stay within 155px of left/right edges (xMin=155, xMax=925 in tokens.json
safeZones.universal). This prevents profile grid cropping on Instagram (#4645). Per-type exceptions are documented intokens.jsonsafeZones.perTypeExceptions(.glass=80px,.stat=100px) — these are centered full-bleed accent slides whose content never reaches the crop zone, NOT violations.Self-contained previews only: Every carousel build must produce a base64-embedded HTML preview that works offline — no server dependency for design review (#4599).
Passthrough text mode is the default (#4599): Text auto-splitting on periods (regex
/(?<=\.)\s+(?=[A-ZÁÉÍÓÚÑ])/) is legacy — it creates unwanted visual breaks that fight AI design. The default mode splits ONLY on explicit double-newlines (\n\n). Period-splitting is only used when no double-newlines exist AND no CSS map is active (bare build_carousel.cjs without AI design). CSS-map mode enables full passthrough where the AI controls all layout.Per-slide CSS scoping via
data-slideattributes (#4599): All slide divs carrydata-slide="N"attributes for scoped CSS selectors. Injection MUST useindexOf('>'), never regex — regex-based injection corrupted HTML (#4581).Image status validation (#4574, #4599): Every image in
selected-images.yamlcarries astatusfield (used|not_used|discarded). The build script rejectsdiscardedimages with a hard error and skipsnot_usedimages with a warning. Onlyusedimages are rendered into slides.
Process
Step 0 — Pre-Flight: Art Director Gate 1 (Mandatory)
Before building HTML, run the pre-render quality gate. This gate BLOCKS rendering — do NOT proceed on failure.
Token hygiene: Verify the canonical build script is tokenized:
grep -q "var(--purple)" skills/carousel-b2b-design/scripts/build_carousel.cjs || {
echo "❌ build_carousel.cjs is not tokenized. Use the canonical version."
exit 1
}Safe zone check: Verify text elements survive Instagram cropping:
- Byline Y ≥ 120px (above top UI overlay zone)
- Hook headlines Y: 150–400px (survives 1:1 crop in old profile grid)
- CTA elements above Y: 1100px
Typography limits: Verify heading ≥ 32px, body ≥ 20px, ≤ 2 font families, ≤ 5 font sizes per slide at 1080px square canvas.
On pass: proceed to Step 1. On failure: return specific issues to orchestrator. Do NOT render.
Step 1 — Validate Inputs
Validate script.yaml and selected-images.yaml against schemas. Fail fast on mismatch.
Image status gate (#4574, #4599): Scan selected-images.yaml for image status. Any discarded image must block the build — these images failed quality checks or were user-rejected. not_used images emit a warning and are skipped during rendering. Only used images are rendered into slides.
Step 2 — Build HTML (Canonical Script — Mandatory)
⚠️ CRITICAL: Always use the canonical build script. Never write ad-hoc HTML generation code for carousel rendering.
node skills/carousel-b2b-design/scripts/build_carousel.cjs \
--script docs/carousels/<slug>/script.yaml \
--images docs/carousels/<slug>/selected-images.yaml \
--output docs/carousels/<slug>/carousel.htmlThis script generates self-contained HTML with:
- Base64-embedded fonts (Outfit, InterV) via @font-face
- CSS variables for all brand tokens (
var(--purple),var(--yellow), etc.) - 12 slide types: photo-hero, photo-top, cta, pilar, bento, comparison, stat, glass, cheatsheet, tutorial, quote, text-slide
- Instagram safe zones encoded in margins/padding (universal: X 35–1045, Y 135–945 for 1080×1080 square canvas (chosen over 4:5 because Instagram profile grid crops to 1:1, cutting off text below ~150px)
- "Desliza →" swipe indicator on photo slides
- Proper text shadows on overlay elements for readability
- Gradient overlays with transparent top zone (not full-bleed — images remain visible)
If a new slide type is needed, add it to build_carousel.cjs. Do not write one-off HTML scripts. The canonical build script is the single source of truth for carousel rendering.
Step 3 — Render via Playwright
node scripts/render.cjs \
--input docs/carousels/<slug>/carousel.html \
--output docs/carousels/<slug>/slides/Each .slide div rendered at 1080×1080 via element.screenshot(). device_scale_factor=1.
Step 3.5 — Upload to Cloudinary + Write cloudinary-urls.yaml
After rendering, upload all slide PNGs and write the canonical URL file:
SLUG="<slug>"
DIR="docs/carousels/$SLUG"
# Upload each slide PNG via Cloudinary MCP (mcp__cloudinary__upload-asset)
# Write cloudinary-urls.yaml with all secure_url values
cat > "$DIR/cloudinary-urls.yaml" << YAMLEOF
slides:
YAMLEOF
for f in $(ls "$DIR/slides/"*.png | sort); do
num=$(basename "$f" | grep -o "^[0-9]*")
# Actual upload via MCP: mcp__cloudinary__upload-asset with public_id=eldato/carousels/$SLUG/s$num
echo " $num: https://res.cloudinary.com/djzwqixjt/image/upload/.../s$num.png" >> "$DIR/cloudinary-urls.yaml"
doneThis file is REQUIRED by Gate 2. Without it, the carousel cannot be published.
Image metadata (#5049): When uploading to Cloudinary, tag each image with structured metadata:
# For each slide upload, set Cloudinary metadata via MCP:
# - context: "carousel=<slug>|slide=<N>|type=rendered-slide"
# - tags: "carousel,<slug>,rendered-slide"
# Source images (backgrounds) use type=background-image with additional:
# - context: "carousel=<slug>|slide=<N>|type=background-image|status=<used|unused|rejected>|reason=<rejection reason if rejected>"This enables agent queries like "find all unused founder portraits" or "what background images were rejected for carousel X?"
Step 4 — 12-Item Checklist
Run checklist with fix loop:
- Each slide is 1080×1080
- Max 5 typographic variations per slide
- Gradients use single
%(verify via grep) - Byline legible on photo slides (scrim present)
- Text outside 150px safe zone at bottom (WARN only — carousel-designer has final say)
- No face cropping on photo slides
- Consistent background within slide series
- Yellow emphasis only on keywords, not body text (carousel-designer catches misuse)
🚫 PUNCH AUTO-PROMOTION BUG (fixed): The build script MUST NOT auto-promote the last paragraph to
.punch(96px yellow Outfit). Yellow/punch is ONLY for explicitly declaredemphasiswords in script.yaml. When noemphasisis set, ALL paragraphs render as.setup(white body text). The last sentence is just the conclusion — not inherently a hero punch line.
- Spanish accents render correctly (í, ó, é, ñ)
- No visible AI artifacts in generated images
- File order correct (01→NN)
- Caption + hashtags included
Cycle reporting: "Cycle 1: 4 issues → Cycle 2: 1 → Clean ✓"
155px safe margin check (#4973): After render, verify horizontal padding >= 155px:
grep -q 'text-slide.*padding:.*15[5-9]px\|text-slide.*padding:.*1[6-9][0-9]px\|text-slide.*padding:.*2[0-9][0-9]px' docs/carousels/<slug>/carousel.html || echo "⚠️ text-slide padding < 155px (#4973)" grep -q 'photo-hero.*padding: 0 15[5-9]px\|photo-hero.*padding: 0 1[6-9][0-9]px' docs/carousels/<slug>/carousel.html || echo "⚠️ photo-hero padding < 155px (#4973)"P0 if any slide type uses < 155px horizontal padding. Block delivery.
QA overlap with carousel-designer: Items 2 (typography count), 5 (safe zones), and 8 (yellow emphasis) are relaxed per #4429. The
carousel-designerskill provides visual judgment for these concerns — rule-based gates only catch obviously broken things. Contrast (≥4.5:1) and font size minimums (heading ≥32px, body ≥20px) remain hard gates.Linter pass (mandatory post-render): After checklist passes, run the visual hierarchy linter:
node skills/carousel-b2b-design/scripts/linter.mjs docs/carousels/<slug>/carousel.htmlThis checks contrast ratios (≥4.5:1 WCAG AA), safe zone positions, typography scale, and raw hex values. (Yellow area check removed per #4429 — carousel-designer handles visual judgment.)
Linter P0 failures (contrast, safe zones) block delivery. P1/P2 are warnings.
Step 5 — Output
- Upload PNGs to Cloudinary
- Generate
carousel-preview.html(all slides in sequence) - Post Cloudinary URLs + caption + Download All zip to GitHub issue
- Set
quality_gatein output
Brand Design System
Colors
--purple: #5B3B8C (main brand)
--purple-deep: #3F2766 (gradients)
--purple-d2: #34204F (dark base)
--yellow: #F2C94C (emphasis — stops the scroll)
--white: #FFFFFF
--cream: #EFE9DC (secondary text on purple)
--muted: #C9BBE0 (labels, bylines)Typography
- Outfit (headings): weight 700, tracking -.03em. NO italic (use skewX(-9deg) for emphasis).
- Inter (body): weight 400–600.
Slide Types
Photo-Hero (Type A): Photo full-bleed. Gradient overlay from bottom. Scrim top 200px for byline. Text in bottom third. Byline: [logo] eldato · [yellow dot] · Daniel Ospina · Fundador
Text-Slide (Type B): Solid purple/deep background. Eyebrow in yellow uppercase. Body in white/cream. Emphasis in yellow. Lots of negative space.
Subtitle & Body Text Formatting
Newlines: \n in subtitle/body fields is converted to <br> (line break). Use \n\n (double newline) for paragraph spacing between sections.
Bullet points: Use • (Unicode bullet + space) at the start of a line for list items. Example:
subtitle: 'La gente compra:
• Entretenimiento y trivia.
• Experiencia guiada.
El negocio debe enfocarse en:
• Narrativa fuerte.
• Temática clara.'Design principle: Break dense text into scannable chunks. Whitespace between sections is free readability. Every \n\n costs zero pixels and earns attention.
Inline yellow (optional tool): Words wrapped in emphasis: render in yellow at body size via the .obl class. This is an available tool — no slide is required to use it. Yellow emphasis works best for:
- The key takeaway sentence on a text slide (one per slide max)
- A surprising number or stat worth highlighting
- A conceptual pivot ("But here's what nobody tells you...")
Use when it serves the narrative. Slides without yellow are fine.
Emphasis words: Explicitly declare emphasis: in slide copy for words that should render in yellow Outfit. Never auto-infer emphasis from paragraph position. Without explicit emphasis, all text renders as white body copy.
Canvas Configuration
Default canvas: 1080×1080 (1:1 Instagram square). Configurable via tokens.json:
{ "canvas": { "width": 1080, "height": 1080 } }To change aspect ratio, update tokens.json and the corresponding CSS .slide dimensions in build_carousel.cjs. The build script reads tok.canvas.width and tok.canvas.height for safe zone calculations.
Swipe Indicator Rule
"Desliza →" only on slide 1 (portada). Never on subsequent slides. The swipe indicator teaches the gesture once; repeating it is visual noise. This rule is enforced in buildPhotoHero() via slide.number === 1 check.
gradient (the "n3" approved)
background: linear-gradient(180deg,
rgba(52,32,79,0) 0%,
rgba(52,32,79,0) 32%,
rgba(52,32,79,.40) 40%,
rgba(40,25,66,.72) 48%,
rgba(26,18,40,.92) 55%,
rgba(26,18,40,.96) 100%);Top Scrim (for byline visibility)
background: linear-gradient(180deg, rgba(26,16,40,.5), rgba(26,16,40,0));
height: 200px;Reference
Continue following the workflow as mandated by this skill. Do not skip steps.