Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/skills/release-infographic/EXTEND.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Preferences — release-infographic

Pinned defaults so the skill doesn't re-ask each release. Edit freely.

- **layout:** bento # bento | linear | dashboard
- **language:** English
- **tagline:** auto # "auto" = Claude writes one from the changelog
- **width:** 1200 # PNG width in px; height is automatic
109 changes: 109 additions & 0 deletions .claude/skills/release-infographic/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
name: release-infographic
description: Use when the user wants to generate a visual summary / infographic / release card of what ships in a mint release. Reads CHANGELOG.md, editorializes the changes, and renders a mint-branded PNG. Trigger on "release infographic", "release card", "infografía del release", or before publishing a version.
---

# Release Infographic

Generate a mint-branded PNG summarizing what ships in a release, from `CHANGELOG.md`.

This is a skill (invoked by Claude), NOT an automatic hook. It does not fire on `npm run release`. Claude applies editorial judgment: picking highlights, writing punchy copy, choosing a layout.

## Files in this skill

- `template.html` — mint-branded shell with `{{version}}`, `{{date}}`, `{{tagline}}`, `{{body}}`, `{{stats}}` placeholders.
- `render.mjs` — HTML→PNG renderer. Run: `node <skill>/render.mjs <htmlPath> <outPath> [width]`.
- `EXTEND.md` — optional pinned preferences (read it first if present).

## Workflow

1. **Load preferences.** If `EXTEND.md` exists in this skill dir, read it for `layout`, `language`, `tagline`, `width` defaults.
2. **Resolve the target version.** Default: the topmost released version section in `CHANGELOG.md`. If preparing a release, use the `## [Unreleased]` section. Accept an explicit version if the user names one.
3. **Parse the changelog section** into buckets: Features, Bug Fixes, Performance, Refactoring, Documentation (mirrors `.release-it.json` sections).
4. **Editorialize:**
- Write a one-line `tagline` capturing the release's theme (or use the pinned one).
- Rewrite each changelog item as a short, punchy line (≈ 6–12 words). Wrap identifiers/commands in `<code>…</code>` (e.g. `<code>mint-ds lint</code>`).
- Compute `stats`, e.g. `3 features · 1 fix`.
- Pick a layout preset (see below) based on the release shape.
5. **Build `{{body}}`** using the chosen preset's markup (below).
6. **Fill the template.** Read `template.html`, replace every placeholder, write the filled HTML to `release-assets/v<version>.html`.
7. **Render.** Run `node .claude/skills/release-infographic/render.mjs release-assets/v<version>.html release-assets/v<version>.png <width>`.
8. **Report.** Show the user the PNG path and a one-line summary. Confirm no content was clipped.

## Layout presets (for `{{body}}`)

Pick by release shape. All markup goes inside `{{body}}`.

### `bento` (default) — mixed release with several categories

```html
<div class="bento">
<div class="card wide">
<h3>Features</h3>
<ul>
<li>Short punchy line with <code>identifier</code></li>
</ul>
</div>
<div class="card">
<h3>Bug Fixes</h3>
<ul>
<li>...</li>
</ul>
</div>
</div>
```

Use `card wide` (full-width) for the most important category, plain `card` for the rest.

### `linear` — a few sequential highlights that tell a story

```html
<div class="linear">
<div class="step">
<span class="num">01</span>
<div>First highlight line.</div>
</div>
<div class="step">
<span class="num">02</span>
<div>Second highlight line.</div>
</div>
</div>
```

### `dashboard` — "by the numbers" maintenance release

```html
<div class="tiles">
<div class="tile">
<div class="n">3</div>
<div class="l">Features</div>
</div>
<div class="tile">
<div class="n">1</div>
<div class="l">Fixes</div>
</div>
<div class="tile">
<div class="n">12</div>
<div class="l">Commits</div>
</div>
</div>
<div class="bento">
<div class="card wide">
<h3>Highlights</h3>
<ul>
<li>...</li>
</ul>
</div>
</div>
```

## Output

- PNG: `release-assets/v<version>.png` (versioned in git; auto-excluded from the npm tarball because `package.json#files` is an explicit allowlist).
- HTML record: `release-assets/v<version>.html` (reproducibility — regenerate without re-editorializing).

## Guardrails

- Never let an image model render this — text accuracy and exact brand tokens matter. Always use `render.mjs`.
- Keep technical identifiers exact (`mint-ds lint`, `gap-rule-color`, `--no-semantic`). Do not paraphrase them.
- Artifact copy is English by default (repo convention), unless `EXTEND.md` sets another language.
61 changes: 61 additions & 0 deletions .claude/skills/release-infographic/render.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Deterministic HTML→PNG renderer for the release-infographic skill.
//
// node render.mjs <htmlPath> <outPath> [width]
//
// Rendering is done by a headless browser so the mint-branded HTML (exact
// brand tokens + accurate technical text) renders faithfully. Fonts load from
// Google Fonts over the network; offline runs fall back to system fonts. The
// `launch` parameter is injectable so unit tests run without a real browser.

import { readFileSync, writeFileSync } from 'node:fs'
import { argv } from 'node:process'
import { fileURLToPath } from 'node:url'

// Lazily import Playwright only when actually rendering, so tests that inject
// their own `launch` never load the browser package.
async function defaultLaunch() {
const { chromium } = await import('playwright')
return chromium.launch()
}

export async function renderHtmlToPng({
htmlPath,
outPath,
width = 1200,
launch = defaultLaunch,
}) {
const browser = await launch()
try {
const page = await browser.newPage()
// Height is a seed; fullPage screenshot captures the real content height.
await page.setViewportSize({ width, height: 100 })
const html = readFileSync(htmlPath, 'utf8')
await page.setContent(html, { waitUntil: 'networkidle' })
const buffer = await page.screenshot({ fullPage: true, type: 'png' })
writeFileSync(outPath, buffer)
return outPath
} finally {
await browser.close()
}
}

// CLI entry point.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const [, , htmlPath, outPath, widthArg] = argv
if (!htmlPath || !outPath) {
console.error('Usage: node render.mjs <htmlPath> <outPath> [width]')
process.exit(1)
}
const width = widthArg ? Number(widthArg) : 1200
if (Number.isNaN(width) || width <= 0) {
console.error('Usage: node render.mjs <htmlPath> <outPath> [width]')
console.error(`Invalid width: ${widthArg}`)
process.exit(1)
}
renderHtmlToPng({ htmlPath, outPath, width })
.then((p) => console.log(`Wrote ${p}`))
.catch((err) => {
console.error(err)
process.exit(1)
})
}
106 changes: 106 additions & 0 deletions .claude/skills/release-infographic/render.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { renderHtmlToPng } from './render.mjs'

// Minimal valid PNG signature — stand-in for real screenshot bytes.
const FAKE_PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])

function makeFakeBrowser() {
const calls = {
viewport: null,
screenshotOpts: null,
contentSet: false,
closed: false,
}
const page = {
setViewportSize(vp) {
calls.viewport = vp
},
async setContent() {
calls.contentSet = true
},
async screenshot(opts) {
calls.screenshotOpts = opts
return FAKE_PNG
},
}
const browser = {
async newPage() {
return page
},
async close() {
calls.closed = true
},
}
return { browser, calls }
}

describe('renderHtmlToPng', () => {
let dir
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'render-'))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})

it('renders an HTML file to a PNG at the given width via the injected browser', async () => {
const htmlPath = join(dir, 'in.html')
const outPath = join(dir, 'out.png')
writeFileSync(htmlPath, '<html><body>hi</body></html>')
const { browser, calls } = makeFakeBrowser()

const result = await renderHtmlToPng({
htmlPath,
outPath,
width: 1200,
launch: async () => browser,
})

expect(result).toBe(outPath)
expect(calls.viewport.width).toBe(1200)
expect(calls.contentSet).toBe(true)
expect(calls.screenshotOpts.fullPage).toBe(true)
expect(calls.closed).toBe(true)
expect(readFileSync(outPath)).toEqual(FAKE_PNG)
})

it('defaults width to 1200 when not provided', async () => {
const htmlPath = join(dir, 'in.html')
const outPath = join(dir, 'out.png')
writeFileSync(htmlPath, '<html></html>')
const { browser, calls } = makeFakeBrowser()

await renderHtmlToPng({ htmlPath, outPath, launch: async () => browser })

expect(calls.viewport.width).toBe(1200)
})

it('closes the browser even when screenshot fails', async () => {
const htmlPath = join(dir, 'in.html')
const outPath = join(dir, 'out.png')
writeFileSync(htmlPath, '<html></html>')
let closed = false
const browser = {
async newPage() {
return {
setViewportSize() {},
async setContent() {},
async screenshot() {
throw new Error('boom')
},
}
},
async close() {
closed = true
},
}

await expect(
renderHtmlToPng({ htmlPath, outPath, launch: async () => browser })
).rejects.toThrow('boom')
expect(closed).toBe(true)
})
})
Loading
Loading