From zworkflow
Render Slack Block Kit JSON to PNG screenshot using Playwright. Use when you need to visually preview Block Kit layouts, compare design variants, or validate UI before deploying. Triggers on "block kit preview", "render block kit", "slack preview", "block kit screenshot", "block kit to png", "visualize blocks".
How this skill is triggered — by the user, by Claude, or both
Slash command
/zworkflow:block-kit-previewThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Converts Slack Block Kit JSON to PNG screenshots using Playwright + a custom HTML renderer.
Converts Slack Block Kit JSON to PNG screenshots using Playwright + a custom HTML renderer. Slack Block Kit Builder requires login, so this bypasses it with a custom CSS renderer.
npm install playwright + npx playwright install chromium)The user may provide Block Kit JSON directly, or point to Block Kit generation logic in the code.
Create the renderer script in the working directory (e.g., /tmp/{userId}/block-kit-preview/).
# Run once only (skip if already installed)
cd /tmp/{userId} && npm init -y --silent 2>/dev/null
npm install playwright 2>/dev/null
npx playwright install chromium 2>/dev/null
Create an .mjs file using the template below. Place the JSON to render in the BLOCK_KIT_JSON variable.
import { chromium } from 'playwright';
import { writeFileSync } from 'fs';
// ── Block Kit JSON ──
const BLOCK_KIT_JSON = {
blocks: [
// ... Block Kit JSON blocks array goes here
]
};
// ── Slack-style mrkdwn parser ──
function parseMrkdwn(text) {
return text
.replace(/\*([^*]+)\*/g, '<strong>$1</strong>')
.replace(/(?<![a-zA-Z0-9])_([^_]+)_(?![a-zA-Z0-9])/g, '<em>$1</em>')
.replace(/~([^~]+)~/g, '<s>$1</s>')
.replace(/`([^`]+)`/g, '<code style="background:#f0f0f3;padding:2px 5px;border-radius:3px;font-size:12px;color:#e01e5a">$1</code>')
.replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, '<a href="$1" style="color:#1264a3;text-decoration:none">$2</a>')
.replace(/<(https?:\/\/[^>]+)>/g, '<a href="$1" style="color:#1264a3;text-decoration:none">$1</a>');
}
// ── Block renderer ──
function renderBlock(block) {
switch (block.type) {
case 'header':
return `<div style="padding:8px 16px;font-size:18px;font-weight:900;line-height:1.4">${block.text.text}</div>`;
case 'divider':
return '<div style="padding:4px 16px"><hr style="border:none;border-top:1px solid #e0e0e0;margin:0"/></div>';
case 'section': {
const textHtml = block.text ? parseMrkdwn(block.text.text) : '';
let fieldsHtml = '';
if (block.fields) {
fieldsHtml = '<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px 16px;margin-top:4px">' +
block.fields.map(f => `<div style="font-size:14px;line-height:1.4">${parseMrkdwn(f.text)}</div>`).join('') +
'</div>';
}
let accessoryHtml = '';
if (block.accessory?.type === 'button') {
const btn = block.accessory;
const style = btn.style === 'danger'
? 'background:#e01e5a;color:#fff;border:none'
: btn.style === 'primary'
? 'background:#007a5a;color:#fff;border:none'
: 'background:#fff;color:#1d1c1d;border:1px solid #e0e0e0';
accessoryHtml = `<button style="${style};padding:6px 14px;border-radius:6px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0">${btn.text.text}</button>`;
}
if (block.accessory?.type === 'image') {
accessoryHtml = `<img src="${block.accessory.image_url}" alt="${block.accessory.alt_text || ''}" style="width:48px;height:48px;border-radius:4px;flex-shrink:0"/>`;
}
return `
<div style="display:flex;align-items:flex-start;justify-content:space-between;padding:6px 16px;gap:12px">
<div style="font-size:15px;line-height:1.5;color:#1d1c1d;flex:1;min-width:0">${textHtml}${fieldsHtml}</div>
${accessoryHtml ? `<div style="flex-shrink:0;padding-top:2px">${accessoryHtml}</div>` : ''}
</div>`;
}
case 'context': {
const els = block.elements.map(el => {
if (el.type === 'mrkdwn') return parseMrkdwn(el.text);
if (el.type === 'plain_text') return el.text;
if (el.type === 'image') return `<img src="${el.image_url}" alt="${el.alt_text || ''}" style="width:16px;height:16px;border-radius:2px;vertical-align:middle;margin-right:4px"/>`;
return '';
}).join(' ');
return `<div style="padding:2px 16px 6px 16px;font-size:12px;line-height:1.4;color:#616061">${els}</div>`;
}
case 'actions': {
const buttons = block.elements.map(el => {
if (el.type === 'button') {
const style = el.style === 'primary'
? 'background:#007a5a;color:#fff;border:none'
: el.style === 'danger'
? 'background:#e01e5a;color:#fff;border:none'
: 'background:#fff;color:#1d1c1d;border:1px solid #e0e0e0';
return `<button style="${style};padding:6px 14px;border-radius:6px;font-size:13px;font-weight:700;cursor:pointer;margin-right:8px">${el.text.text}</button>`;
}
if (el.type === 'static_select') {
return `<select style="padding:6px 14px;border-radius:6px;font-size:13px;border:1px solid #e0e0e0;margin-right:8px"><option>${el.placeholder?.text || 'Select...'}</option></select>`;
}
return '';
}).join('');
return `<div style="padding:6px 16px">${buttons}</div>`;
}
case 'image':
return `<div style="padding:8px 16px"><img src="${block.image_url}" alt="${block.alt_text || ''}" style="max-width:100%;border-radius:4px"/>${block.title ? `<div style="font-size:12px;color:#616061;margin-top:4px">${block.title.text}</div>` : ''}</div>`;
case 'rich_text':
return `<div style="padding:6px 16px;font-size:15px;color:#1d1c1d">[rich_text block]</div>`;
case 'input':
return `<div style="padding:6px 16px"><label style="font-size:14px;font-weight:700">${block.label?.text || 'Input'}</label><input style="width:100%;padding:8px;border:1px solid #e0e0e0;border-radius:4px;margin-top:4px" placeholder="${block.element?.placeholder?.text || ''}"/></div>`;
default:
return `<div style="padding:4px 16px;color:#999">[ unsupported: ${block.type} ]</div>`;
}
}
function renderMessage(json, options = {}) {
const { botName = 'Soma', botTime = '2:30 PM', label = '' } = options;
const blocksHtml = json.blocks.map(renderBlock).join('\n');
return `<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#f8f8f8; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif; padding:40px; }
.message-container { max-width:680px; margin:0 auto; background:#fff; border-radius:8px; border:1px solid #e0e0e0; padding:12px 0; box-shadow:0 1px 3px rgba(0,0,0,0.08); }
.variant-label { padding:8px 16px; font-size:11px; font-weight:800; color:#fff; background:#1d1c1d; margin:-12px 0 12px 0; border-radius:8px 8px 0 0; letter-spacing:0.5px; text-transform:uppercase; }
.bot-header { display:flex; align-items:center; gap:8px; padding:4px 16px 8px 16px; }
.bot-avatar { width:36px; height:36px; border-radius:4px; background:linear-gradient(135deg,#667eea 0%,#764ba2 100%); display:flex; align-items:center; justify-content:center; color:#fff; font-weight:900; font-size:18px; }
.bot-name { font-weight:900; font-size:15px; color:#1d1c1d; }
.bot-time { font-size:12px; color:#616061; margin-left:4px; }
a { pointer-events:none; }
</style></head><body>
<div class="message-container">
${label ? `<div class="variant-label">${label}</div>` : ''}
<div class="bot-header">
<div class="bot-avatar">S</div>
<span class="bot-name">${botName}</span>
<span class="bot-time">${botTime}</span>
</div>
${blocksHtml}
</div></body></html>`;
}
// ── Main ──
(async () => {
const html = renderMessage(BLOCK_KIT_JSON);
const htmlPath = '/tmp/block-kit-preview.html';
writeFileSync(htmlPath, html);
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 800, height: 900 } });
await page.setContent(html, { waitUntil: 'networkidle' });
await page.waitForTimeout(500);
const container = page.locator('.message-container');
const screenshotPath = '/tmp/block-kit-preview.png';
await container.screenshot({ path: screenshotPath });
console.log(`Screenshot saved: ${screenshotPath}`);
await browser.close();
})();
cd /tmp/{userId} && node block-kit-preview.mjs
Open the generated PNG file using the Read tool to visually verify it.
To compare multiple Block Kit JSONs, use a grid layout:
const variants = [
{ json: VARIANT_A, label: 'Option A: Classic' },
{ json: VARIANT_B, label: 'Option B: Compact' },
];
const fullHtml = `<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#f8f8f8; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif; padding:30px; }
.grid { display:grid; grid-template-columns:1fr 1fr; gap:24px; max-width:1500px; margin:0 auto; }
/* ... same styles as single ... */
</style></head><body>
<h1>Block Kit Comparison</h1>
<div class="grid">
${variants.map(v => renderMessage(v.json, { label: v.label })).join('\n')}
</div></body></html>`;
| Block Type | Support Level |
|---|---|
header | Full |
divider | Full |
section (text) | Full (mrkdwn) |
section (fields) | Full (2-column grid) |
section (accessory: button) | Full (primary/danger/default) |
section (accessory: image) | Basic |
context | Full (mrkdwn, plain_text, image) |
actions (buttons) | Full |
actions (static_select) | Placeholder only |
image | Basic |
input | Placeholder only |
rich_text | Not supported |
| Syntax | Example | Supported |
|---|---|---|
| Bold | *text* | Yes |
| Italic | _text_ | Yes |
| Strike | ~text~ | Yes |
| Code | `text` | Yes |
| Link | <url|label> | Yes |
rich_text blocks are not supported/tmp/{userId}/block-kit-preview.png/tmp/{userId}/block-kit-comparison.png/tmp/{userId}/variant-{label}.pngnpx claudepluginhub 2lab-ai/soma-work --plugin zworkflowGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.