diff --git a/.claude/skills/release-infographic/EXTEND.md b/.claude/skills/release-infographic/EXTEND.md new file mode 100644 index 0000000..4c722b6 --- /dev/null +++ b/.claude/skills/release-infographic/EXTEND.md @@ -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 diff --git a/.claude/skills/release-infographic/SKILL.md b/.claude/skills/release-infographic/SKILL.md new file mode 100644 index 0000000..a737d6f --- /dev/null +++ b/.claude/skills/release-infographic/SKILL.md @@ -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 /render.mjs [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 `` (e.g. `mint-ds lint`). + - 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.html`. +7. **Render.** Run `node .claude/skills/release-infographic/render.mjs release-assets/v.html release-assets/v.png `. +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 +
+
+

Features

+
    +
  • Short punchy line with identifier
  • +
+
+
+

Bug Fixes

+
    +
  • ...
  • +
+
+
+``` + +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 +
+
+ 01 +
First highlight line.
+
+
+ 02 +
Second highlight line.
+
+
+``` + +### `dashboard` — "by the numbers" maintenance release + +```html +
+
+
3
+
Features
+
+
+
1
+
Fixes
+
+
+
12
+
Commits
+
+
+
+
+

Highlights

+
    +
  • ...
  • +
+
+
+``` + +## Output + +- PNG: `release-assets/v.png` (versioned in git; auto-excluded from the npm tarball because `package.json#files` is an explicit allowlist). +- HTML record: `release-assets/v.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. diff --git a/.claude/skills/release-infographic/render.mjs b/.claude/skills/release-infographic/render.mjs new file mode 100644 index 0000000..5911c1a --- /dev/null +++ b/.claude/skills/release-infographic/render.mjs @@ -0,0 +1,61 @@ +// Deterministic HTML→PNG renderer for the release-infographic skill. +// +// node render.mjs [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 [width]') + process.exit(1) + } + const width = widthArg ? Number(widthArg) : 1200 + if (Number.isNaN(width) || width <= 0) { + console.error('Usage: node render.mjs [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) + }) +} diff --git a/.claude/skills/release-infographic/render.test.mjs b/.claude/skills/release-infographic/render.test.mjs new file mode 100644 index 0000000..17e7646 --- /dev/null +++ b/.claude/skills/release-infographic/render.test.mjs @@ -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, 'hi') + 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, '') + 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, '') + 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) + }) +}) diff --git a/.claude/skills/release-infographic/template.html b/.claude/skills/release-infographic/template.html new file mode 100644 index 0000000..3b27070 --- /dev/null +++ b/.claude/skills/release-infographic/template.html @@ -0,0 +1,195 @@ + + + + + + + +
+
+
mint.
+
+
{{version}}
+
{{date}}
+
+
+

{{tagline}}

+
{{body}}
+
+ github.com/nujovich/mint + {{stats}} +
+
+ + diff --git a/.prettierignore b/.prettierignore index d838da9..980ad78 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,4 @@ examples/ + +# Generated release infographic artifacts (HTML records + rendered PNGs) +release-assets/ diff --git a/package-lock.json b/package-lock.json index f2cabc3..ef41946 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "husky": "^9.1.0", "lint-staged": "^15.5.0", "next": "^15.1.0", + "playwright": "^1.61.1", "prettier": "^3.8.4", "react": "^18.3.0", "react-dom": "^18.3.0", @@ -9023,6 +9024,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 50e54dc..80d7f65 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "husky": "^9.1.0", "lint-staged": "^15.5.0", "next": "^15.1.0", + "playwright": "^1.61.1", "prettier": "^3.8.4", "react": "^18.3.0", "react-dom": "^18.3.0", diff --git a/release-assets/.gitkeep b/release-assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/release-assets/unreleased.html b/release-assets/unreleased.html new file mode 100644 index 0000000..ca732bf --- /dev/null +++ b/release-assets/unreleased.html @@ -0,0 +1,217 @@ + + + + + + + +
+
+
mint.
+
+
Unreleased
+
2026-07-17
+
+
+

+ Static CSS linting learns a new trick: catching gap-decoration hacks before they ship. +

+
+
+
+

Features

+
    +
  • + New mint-ds lint command flags hand-rolled + gap-decoration hacks — borders on children, + ::before/::after pseudo-elements, or + backgrounds beside gap — with zero LLM calls. +
  • +
  • + Suggests the native gap-rule-color, + gap-rule-style, and gap-rule-width + properties, wrapped up in a "Modern CSS Opportunities" + adoption report. +
  • +
+
+
+
+ +
+ + diff --git a/release-assets/unreleased.png b/release-assets/unreleased.png new file mode 100644 index 0000000..6730f19 Binary files /dev/null and b/release-assets/unreleased.png differ diff --git a/vitest.config.ts b/vitest.config.ts index 830588f..9ed709e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,9 @@ -import { defineConfig } from 'vitest/config' +import { defineConfig, configDefaults } from 'vitest/config' export default defineConfig({ test: { environment: 'node', + exclude: [...configDefaults.exclude, '.claude/worktrees/**'], coverage: { provider: 'v8', include: ['lib/**/*.mjs', 'lib/**/*.ts'],