From c78ef66eeaacb9f81ed701376b929f70ff2bf0c9 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 15:38:11 +0200 Subject: [PATCH 01/12] refactor(capture): share the check for a nav that is visited instead of clicked --- src/capture.ts | 4 ++-- src/paths.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/capture.ts b/src/capture.ts index b663e5f..f6c2771 100644 --- a/src/capture.ts +++ b/src/capture.ts @@ -7,7 +7,7 @@ import { isTtyConfig } from './config/resolve.js'; import type { CdpTarget, ResolvedConfig, ResolvedWebConfig, ResolvedWebShot, UrlTarget } from './config/types.js'; import { ShowcaseError } from './errors.js'; import { log } from './log.js'; -import { navUrl, outputPath, select } from './paths.js'; +import { isGotoNav, navUrl, outputPath, select } from './paths.js'; import { answers, startCommand, waitForUrl, type StartedProcess } from './process.js'; import { trimTrailing } from './text.js'; @@ -307,7 +307,7 @@ async function navigate(session: Session, page: Page, shot: ResolvedWebShot): Pr let goto: string | undefined; let click: string | undefined; if (typeof nav === 'string') { - if (/^https?:\/\//.test(nav) || (nav.startsWith('/') && !nav.startsWith('//'))) goto = nav; + if (isGotoNav(nav)) goto = nav; else click = nav; } else if ('goto' in nav) { goto = nav.goto; diff --git a/src/paths.ts b/src/paths.ts index 585d459..a477e29 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -42,6 +42,11 @@ export function select(config: ResolvedConfig, only?: string[], langs?: string[] }; } +/** A string `nav` is visited when it is an http(s) URL or a path starting with one `/`; anything else is clicked. */ +export function isGotoNav(nav: string): boolean { + return /^https?:\/\//.test(nav) || (nav.startsWith('/') && !nav.startsWith('//')); +} + /** * Where a `goto` nav goes in url mode. A nav that starts with `/` (or `\`, or either after leading spaces or control * characters, as the url parser reads it) resolves like a relative link from the app's base directory: the target url's path, with or without a trailing slash, except that a last segment with a dot From 23905daf8cc2a97833622822e8348c50d76afb54 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 15:38:12 +0200 Subject: [PATCH 02/12] feat(frame): mesh, dot grid and grain backgrounds --- src/config/resolve.ts | 35 ++++++++++++++++++++++++++++++++- src/config/types.ts | 45 +++++++++++++++++++++++++++++++++++++++++-- src/frame/template.ts | 28 +++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/src/config/resolve.ts b/src/config/resolve.ts index 6e77834..0b406e1 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -49,6 +49,8 @@ export const DEFAULT_CDP_URL = 'http://127.0.0.1:9222'; export const GALLERY_FILE = 'showcase.gallery.json'; const DEFAULT_BACKGROUND: ResolvedBackground = { type: 'gradient', from: '#0f766e', to: '#1e1b4b', angle: 135 }; +const DEFAULT_DOT = 'rgba(255,255,255,0.14)'; +const BACKGROUND_TYPES = ['solid', 'gradient', 'transparent', 'mesh', 'dots', 'noise'] as const; function resolveWebTarget(issues: Issues, value: unknown, rootDir: string): ResolvedWebConfig['target'] { if (!isObj(value)) { @@ -145,8 +147,39 @@ function resolveBackground( checkKeys(issues, path, value, ['type']); return { type: 'transparent' }; } + if (value.type === 'mesh') { + checkKeys(issues, path, value, ['type', 'colors']); + const { colors } = value; + if (!Array.isArray(colors) || colors.length < 2 || colors.length > 5) { + issues.add(`${path}.colors`, `must be an array of two to five CSS colors, got ${describe(colors)}`); + return fallback; + } + return { type: 'mesh', colors: colors.map((entry, index) => color(issues, `${path}.colors[${String(index)}]`, entry)) }; + } + if (value.type === 'dots') { + checkKeys(issues, path, value, ['type', 'color', 'dot', 'spacing']); + return { + type: 'dots', + color: color(issues, `${path}.color`, value.color), + dot: value.dot === undefined ? DEFAULT_DOT : color(issues, `${path}.dot`, value.dot), + spacing: num(issues, `${path}.spacing`, value.spacing, 24, { min: 8, max: 96 }), + }; + } + if (value.type === 'noise') { + checkKeys(issues, path, value, ['type', 'from', 'to', 'angle', 'amount']); + return { + type: 'noise', + from: color(issues, `${path}.from`, value.from), + to: color(issues, `${path}.to`, value.to), + angle: num(issues, `${path}.angle`, value.angle, 135, { min: -360, max: 360 }), + amount: num(issues, `${path}.amount`, value.amount, 0.2, { min: 0, max: 1 }), + }; + } } - issues.add(path, `must be a color string or { type: "solid" | "gradient" | "transparent" }, got ${describe(value)}`); + issues.add( + path, + `must be a color string or { type: ${BACKGROUND_TYPES.map(type => `"${type}"`).join(' | ')} }, got ${describe(value)}`, + ); return fallback; } diff --git a/src/config/types.ts b/src/config/types.ts index 2a48b46..34760ae 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -305,7 +305,45 @@ export type Background = angle?: number; } /** No background: the space around the window stays transparent. */ - | { type: 'transparent' }; + | { type: 'transparent' } + /** A mesh gradient: the first color underneath, each other color glowing from its own corner. */ + | { + type: 'mesh'; + /** Two to five CSS colors: the base, then the top left, top right, bottom right and bottom left glows. */ + colors: string[]; + } + /** A subtle grid of dots on one CSS color. */ + | { + type: 'dots'; + /** The color under the dots. */ + color: string; + /** + * The dot color. Pick a dark one on a light `color`. + * @default `'rgba(255,255,255,0.14)'` + */ + dot?: string; + /** + * Distance between dots in CSS pixels, 8 to 96. + * @default `24` + */ + spacing?: number; + } + /** A linear gradient with a film grain on top, the same on every run. */ + | { + type: 'noise'; + from: string; + to: string; + /** + * Angle in degrees, -360 to 360. + * @default `135` + */ + angle?: number; + /** + * How strong the grain is, 0 to 1. + * @default `0.2` + */ + amount?: number; + }; /** The window chrome around a capture. */ export type FrameStyle = 'window' | 'minimal' | 'none'; @@ -627,7 +665,10 @@ export type ResolvedShot = ResolvedWebShot | ResolvedTtyShot; export type ResolvedBackground = | { type: 'solid'; color: string } | { type: 'gradient'; from: string; to: string; angle: number } - | { type: 'transparent' }; + | { type: 'transparent' } + | { type: 'mesh'; colors: string[] } + | { type: 'dots'; color: string; dot: string; spacing: number } + | { type: 'noise'; from: string; to: string; angle: number; amount: number }; /** `frame` with every default filled in. */ export interface ResolvedFrame { diff --git a/src/frame/template.ts b/src/frame/template.ts index d57d388..6cec2e4 100644 --- a/src/frame/template.ts +++ b/src/frame/template.ts @@ -53,6 +53,22 @@ export function escapeHtml(text: string): string { return text.replace(/[&<>"']/g, char => `&#${String(char.charCodeAt(0))};`); } +/** Where each mesh color after the first glows from: top left, top right, bottom right, bottom left. */ +const MESH_CORNERS = ['0% 0%', '100% 0%', '100% 100%', '0% 100%']; + +/** + * A tile of grey film grain. `feTurbulence` with a fixed seed draws the same pixels on every run. The SVG uses double + * quotes only, which `encodeURIComponent` escapes, so the URL is safe inside `url('...')` in a style attribute. + */ +function grain(amount: number): string { + const svg = + '' + + '' + + '' + + ``; + return `url('data:image/svg+xml,${encodeURIComponent(svg)}') 0 0 / 240px 240px`; +} + export function backgroundCss(background: ResolvedBackground): string { switch (background.type) { case 'solid': @@ -61,11 +77,23 @@ export function backgroundCss(background: ResolvedBackground): string { return `linear-gradient(${String(background.angle)}deg, ${background.from}, ${background.to})`; case 'transparent': return 'transparent'; + case 'mesh': { + const [base, ...glows] = background.colors; + const layers = glows.map((glow, index) => `radial-gradient(at ${MESH_CORNERS[index] ?? '50% 50%'}, ${glow} 0%, transparent 62%)`); + return [...layers, base].join(', '); + } + case 'dots': { + const tile = px(background.spacing); + return `radial-gradient(circle, ${background.dot} 1.25px, transparent 1.75px) 0 0 / ${tile} ${tile}, ${background.color}`; + } + case 'noise': + return `${grain(background.amount)}, linear-gradient(${String(background.angle)}deg, ${background.from}, ${background.to})`; } } const px = (value: number): string => `${String(Math.round(value * 1000) / 1000)}px`; + /** * One framed window as self-contained markup (inline styles only), so a page can hold several at different * scales. `extraStyle` goes on the outer element, for positioning and transforms. Without `imageSrc` the screenshot From 0712f3dc517d2c56c703421280284e4ce8cf1367 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 15:38:25 +0200 Subject: [PATCH 03/12] feat(frame): browser, windows and terminal styles, with an address bar template --- src/config/resolve.ts | 33 +++++++-- src/config/types.ts | 20 +++++- src/frame/index.ts | 3 +- src/frame/render.ts | 44 ++++++++++-- src/frame/template.ts | 160 ++++++++++++++++++++++++++++++++++++------ src/portfolio.ts | 3 +- 6 files changed, 226 insertions(+), 37 deletions(-) diff --git a/src/config/resolve.ts b/src/config/resolve.ts index 0b406e1..b4e4500 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -14,6 +14,7 @@ import { WEB_ONLY_KEYS, } from './tty.js'; import type { + Mode, ResolvedBackground, ResolvedConfig, ResolvedFrame, @@ -51,6 +52,8 @@ export const GALLERY_FILE = 'showcase.gallery.json'; const DEFAULT_BACKGROUND: ResolvedBackground = { type: 'gradient', from: '#0f766e', to: '#1e1b4b', angle: 135 }; const DEFAULT_DOT = 'rgba(255,255,255,0.14)'; const BACKGROUND_TYPES = ['solid', 'gradient', 'transparent', 'mesh', 'dots', 'noise'] as const; +export const FRAME_STYLES = ['window', 'minimal', 'none', 'browser', 'windows', 'terminal'] as const; +const ADDRESS_TOKENS = ['url', 'name', 'title', 'id', 'lang']; function resolveWebTarget(issues: Issues, value: unknown, rootDir: string): ResolvedWebConfig['target'] { if (!isObj(value)) { @@ -183,23 +186,42 @@ function resolveBackground( return fallback; } -function resolveFrame(issues: Issues, value: unknown): ResolvedFrame { +function resolveFrame(issues: Issues, value: unknown, mode: Mode): ResolvedFrame { const frame = value === undefined ? {} : value; if (!isObj(frame)) { issues.add('frame', `must be an object, got ${describe(frame)}`); - return resolveFrame(issues, {}); + return resolveFrame(issues, {}, mode); } - checkKeys(issues, 'frame', frame, ['style', 'theme', 'title', 'background', 'padding', 'radius', 'shadow', 'quality', 'maxWidth']); + checkKeys(issues, 'frame', frame, [ + 'style', + 'theme', + 'title', + 'address', + 'background', + 'padding', + 'radius', + 'shadow', + 'quality', + 'maxWidth', + ]); let title: string | false = '{name}'; if (frame.title === false) { title = false; } else if (frame.title !== undefined) { title = str(issues, 'frame.title', frame.title) ?? '{name}'; } + const style = oneOf(issues, 'frame.style', frame.style, FRAME_STYLES, 'window'); + const address = pathTemplate(issues, 'frame.address', frame.address, '{url}', { allowed: ADDRESS_TOKENS, required: [] }); + if (style === 'browser' && mode === 'tty') { + issues.add('frame.style', '"browser" needs a page with an address; a terminal app has none (use "terminal" or "window")'); + } else if (style === 'browser' && mode === 'cdp' && address.includes('{url}')) { + issues.add('frame.address', 'cannot use {url} in cdp mode, where the page is not known when framing: write the address'); + } return { - style: oneOf(issues, 'frame.style', frame.style, ['window', 'minimal', 'none'] as const, 'window'), + style, theme: oneOf(issues, 'frame.theme', frame.theme, ['light', 'dark'] as const, 'dark'), title, + address, background: resolveBackground(issues, frame.background), padding: num(issues, 'frame.padding', frame.padding, 72, { min: 0, integer: true }), radius: num(issues, 'frame.radius', frame.radius, 14, { min: 0, integer: true }), @@ -454,7 +476,8 @@ export function resolveConfig(input: unknown, root: string, source?: string): Re issues.add('timeouts', `must be an object, got ${describe(timeouts)}`); } - const frame = resolveFrame(issues, input.frame); + const mode: Mode = tty ? 'tty' : isObj(input.target) && input.target.mode === 'cdp' ? 'cdp' : 'url'; + const frame = resolveFrame(issues, input.frame, mode); const common = { name, slug, diff --git a/src/config/types.ts b/src/config/types.ts index 34760ae..02c9fec 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -345,13 +345,19 @@ export type Background = amount?: number; }; -/** The window chrome around a capture. */ -export type FrameStyle = 'window' | 'minimal' | 'none'; +/** + * The window chrome around a capture. `window` reads as macOS, `windows` as Windows 11, `browser` as a browser + * with an address bar, and `terminal` as a terminal emulator. + */ +export type FrameStyle = 'window' | 'minimal' | 'none' | 'browser' | 'windows' | 'terminal'; /** How a capture is framed for the README, the portfolio, the hero and clips. */ export interface FrameOptions { /** - * `window`: title bar with traffic lights. `minimal`: thin bar. `none`: just the rounded screenshot. + * `window`: title bar with traffic lights. `minimal`: thin bar. `none`: just the rounded screenshot. `browser`: a + * browser toolbar with the `address` in its address bar (url and cdp mode). `windows`: a Windows title bar, the + * title on the left and the caption buttons on the right. `terminal`: a terminal emulator's bar, a tab with the + * title in a monospace font; in tty mode it takes the terminal's background, so bar and screen read as one. * @default `'window'` */ style?: FrameStyle; @@ -365,6 +371,13 @@ export interface FrameOptions { * @default `'{name}'` */ title?: string | false; + /** + * Address bar text for `style: 'browser'`. Tokens: `{url}`, `{name}`, `{title}`, `{id}`, `{lang}`. `{url}` is the + * page the shot visits without `http://` or `https://`: the target url, resolved with the shot's `nav` when that is + * a path or URL. In cdp mode the page is not known when framing, so write the text without `{url}`. + * @default `'{url}'` + */ + address?: string; /** * What the window sits on. * @default `{ type: 'gradient', from: '#0f766e', to: '#1e1b4b', angle: 135 }` @@ -675,6 +688,7 @@ export interface ResolvedFrame { style: FrameStyle; theme: 'light' | 'dark'; title: string | false; + address: string; background: ResolvedBackground; padding: number; radius: number; diff --git a/src/frame/index.ts b/src/frame/index.ts index 22d31ac..2ab259c 100644 --- a/src/frame/index.ts +++ b/src/frame/index.ts @@ -3,7 +3,7 @@ import type { ResolvedConfig } from '../config/types.js'; import { ShowcaseError } from '../errors.js'; import { log } from '../log.js'; import { outputPath, select } from '../paths.js'; -import { formatOf, frameTitle, logWritten, readRaw, renderFrame, writeImage } from './render.js'; +import { formatOf, frameAddress, frameTitle, logWritten, readRaw, renderFrame, writeImage } from './render.js'; import { readmeLayout } from './template.js'; export interface FrameRunOptions { @@ -44,6 +44,7 @@ export async function frame(config: ResolvedConfig, options: FrameRunOptions = { raw, layout, title: frameTitle(config, shot, lang), + address: frameAddress(config, shot, lang), deviceScaleFactor: config.deviceScaleFactor, }); const path = outputPath(config, readme, lang, shot.id); diff --git a/src/frame/render.ts b/src/frame/render.ts index 953dac7..2511151 100644 --- a/src/frame/render.ts +++ b/src/frame/render.ts @@ -3,10 +3,11 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, extname, relative } from 'node:path'; import type { Browser } from 'playwright'; import sharp from 'sharp'; +import { isTtyConfig } from '../config/resolve.js'; import type { ResolvedConfig, ResolvedShot } from '../config/types.js'; import { ShowcaseError } from '../errors.js'; import { log } from '../log.js'; -import { outputPath } from '../paths.js'; +import { isGotoNav, navUrl, outputPath } from '../paths.js'; import { fillTemplate } from '../template.js'; import { frameHtml, readmeLayout, type FrameLayout } from './template.js'; @@ -42,11 +43,44 @@ export function frameTitle(config: ResolvedConfig, shot: ResolvedShot, lang: str return fillTemplate(config.frame.title, { name: config.name, title: shot.title, id: shot.id, lang }); } +/** + * The address bar text of the `browser` style, undefined for the others. `{url}` is the page the shot visits: the + * target url, resolved with the shot's `nav` when that is a path or a URL (a click can go anywhere, so it keeps the + * target url), without its scheme. + */ +export function frameAddress(config: ResolvedConfig, shot: ResolvedShot, lang: string): string | undefined { + if (config.frame.style !== 'browser') return undefined; + let url = ''; + if (config.target.mode === 'url') { + const nav = 'nav' in shot ? shot.nav : undefined; + const target = typeof nav === 'object' && 'goto' in nav ? nav.goto : typeof nav === 'string' && isGotoNav(nav) ? nav : undefined; + url = target === undefined ? config.target.url : navUrl(target, config.target.url); + } + return fillTemplate(config.frame.address, { + url: url.replace(/^https?:\/\//, ''), + name: config.name, + title: shot.title, + id: shot.id, + lang, + }); +} + +/** The bar color of the `terminal` style in tty mode: the terminal's background, so bar and screen read as one. */ +export function frameBarColor(config: ResolvedConfig): string | undefined { + return config.frame.style === 'terminal' && isTtyConfig(config) ? config.terminal.theme.background : undefined; +} + /** Render one framed image to a PNG buffer at `layout.canvas * deviceScaleFactor` pixels. */ export async function renderFrame( browser: Browser, config: ResolvedConfig, - { raw, layout, title, deviceScaleFactor }: { raw: RawImage; layout: FrameLayout; title: string | undefined; deviceScaleFactor: number }, + { + raw, + layout, + title, + address, + deviceScaleFactor, + }: { raw: RawImage; layout: FrameLayout; title: string | undefined; address?: string | undefined; deviceScaleFactor: number }, ): Promise { const context = await browser.newContext({ viewport: { width: Math.round(layout.canvas.width), height: Math.round(layout.canvas.height) }, @@ -55,7 +89,8 @@ export async function renderFrame( try { const page = await context.newPage(); const imageSrc = `data:image/png;base64,${raw.data.toString('base64')}`; - await page.setContent(frameHtml({ frame: config.frame, layout, imageSrc, title }), { waitUntil: 'load' }); + const html = frameHtml({ frame: config.frame, layout, imageSrc, title, address, barColor: frameBarColor(config) }); + await page.setContent(html, { waitUntil: 'load' }); await page.evaluate(async () => { await document.fonts.ready; await Promise.all([...document.images].map(image => image.decode())); @@ -134,7 +169,8 @@ export async function renderFrameHole( }); try { const page = await context.newPage(); - await page.setContent(frameHtml({ frame: config.frame, layout, imageSrc: undefined, title }), { waitUntil: 'load' }); + const html = frameHtml({ frame: config.frame, layout, imageSrc: undefined, title, barColor: frameBarColor(config) }); + await page.setContent(html, { waitUntil: 'load' }); // Measured in the page, so the hole always matches the layout the template really produced. const hole = await page.evaluate(async () => { await document.fonts.ready; diff --git a/src/frame/template.ts b/src/frame/template.ts index 6cec2e4..a786741 100644 --- a/src/frame/template.ts +++ b/src/frame/template.ts @@ -1,13 +1,39 @@ import type { ResolvedBackground, ResolvedFrame } from '../config/types.js'; /** Title bar height per style, in CSS pixels at scale 1. */ -export const BAR_HEIGHT: Record = { window: 40, minimal: 28, none: 0 }; +export const BAR_HEIGHT: Record = { + window: 40, + minimal: 28, + none: 0, + browser: 44, + windows: 32, + terminal: 34, +}; const THEMES = { - dark: { bar: '#1f2430', border: 'rgba(255,255,255,0.06)', title: 'rgba(255,255,255,0.62)', outline: 'rgba(255,255,255,0.10)' }, - light: { bar: '#eceef2', border: 'rgba(0,0,0,0.08)', title: 'rgba(0,0,0,0.55)', outline: 'rgba(0,0,0,0.12)' }, + dark: { + bar: '#1f2430', + border: 'rgba(255,255,255,0.06)', + title: 'rgba(255,255,255,0.62)', + outline: 'rgba(255,255,255,0.10)', + field: 'rgba(255,255,255,0.08)', + windows: '#202020', + terminal: '#16181d', + }, + light: { + bar: '#eceef2', + border: 'rgba(0,0,0,0.08)', + title: 'rgba(0,0,0,0.55)', + outline: 'rgba(0,0,0,0.12)', + field: '#ffffff', + windows: '#f3f3f3', + terminal: '#e7e9ee', + }, } as const; +const SANS = "system-ui,-apple-system,'Segoe UI',Roboto,sans-serif"; +const MONO = "ui-monospace,SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace"; + const LIGHTS = ['#ff5f57', '#febc2e', '#28c840']; export interface FrameLayout { @@ -93,11 +119,31 @@ export function backgroundCss(background: ResolvedBackground): string { const px = (value: number): string => `${String(Math.round(value * 1000) / 1000)}px`; +/** A small line icon as inline SVG, `size` CSS pixels square, drawn in a 10 unit box. */ +function icon(path: string, size: number, color: string, width = 1): string { + return ( + `` + + `` + ); +} + +const ICONS = { + back: 'M6.5 1.5 3 5l3.5 3.5', + forward: 'M3.5 1.5 7 5 3.5 8.5', + reload: 'M8.2 3.6A3.6 3.6 0 1 0 8.6 5.6M8.4 1.4v2.4H6', + lock: 'M2.6 4.6h4.8v4H2.6zM3.6 4.6V3.2a1.4 1.4 0 0 1 2.8 0v1.4', + minimize: 'M2 5h6', + maximize: 'M2.2 2.2h5.6v5.6H2.2z', + close: 'M2.2 2.2l5.6 5.6M7.8 2.2 2.2 7.8', + prompt: 'M1.5 2.5 4.5 5l-3 2.5M5.5 8h3', +}; /** * One framed window as self-contained markup (inline styles only), so a page can hold several at different * scales. `extraStyle` goes on the outer element, for positioning and transforms. Without `imageSrc` the screenshot - * is an empty `#hole` with nothing painted behind it, for clips that composite their frames in later. + * is an empty `#hole` with nothing painted behind it, for clips that composite their frames in later. `address` is the + * address bar text of the `browser` style, and `barColor` replaces the `terminal` style's bar color (tty mode passes + * the terminal background). */ export function windowMarkup({ frame, @@ -105,6 +151,8 @@ export function windowMarkup({ scale: s, imageSrc, title, + address, + barColor, extraStyle = '', }: { frame: ResolvedFrame; @@ -112,6 +160,8 @@ export function windowMarkup({ scale: number; imageSrc: string | undefined; title: string | undefined; + address?: string | undefined; + barColor?: string | undefined; extraStyle?: string; }): string { const theme = THEMES[frame.theme]; @@ -119,23 +169,9 @@ export function windowMarkup({ const shadow = frame.shadow ? `0 ${px(24 * s)} ${px(64 * s)} rgba(0,0,0,0.35), 0 ${px(8 * s)} ${px(24 * s)} rgba(0,0,0,0.22)` : 'none'; - const lights = - frame.style === 'window' - ? `
${LIGHTS.map( - color => ``, - ).join('')}
` - : ''; - const titleMarkup = title - ? `
` + - `${escapeHtml(title)}
` - : ''; - const bar = - frame.style === 'none' - ? '' - : `
` + - `${lights}${titleMarkup}
`; + const barBackground = + frame.style === 'windows' ? theme.windows : frame.style === 'terminal' ? (barColor ?? theme.terminal) : theme.bar; + const bar = frame.style === 'none' ? '' : barMarkup(frame, { s, theme, hairline, title, address, background: barBackground }); // The outline sits on top of the screenshot so light apps keep an edge on light backgrounds. const outline = `
` : ``; return ( `
` + + `border-radius:${px(frame.radius * s)};background:${imageSrc === undefined ? 'transparent' : barBackground};box-shadow:${shadow};${extraStyle}">` + `${bar}${media}` + `${outline}
` ); } +/** The title bar of every style but `none`. */ +function barMarkup( + frame: ResolvedFrame, + { + s, + theme, + hairline, + title, + address, + background, + }: { + s: number; + theme: (typeof THEMES)[keyof typeof THEMES]; + hairline: string; + title: string | undefined; + address: string | undefined; + background: string; + }, +): string { + const style = frame.style; + const lights = (size: number): string => + `
${LIGHTS.map( + color => ``, + ).join('')}
`; + const centered = (inner: string, font: string): string => + `
${inner}
`; + const border = style === 'terminal' ? '' : `;border-bottom:${hairline} solid ${theme.border}`; + let inner: string; + if (style === 'window' || style === 'minimal') { + inner = + (style === 'window' ? lights(12) : '') + (title ? centered(escapeHtml(title), `500 ${px(13 * s)}/1 ${SANS}`) : ''); + } else if (style === 'browser') { + const arrows = + `
` + + [ICONS.back, ICONS.forward, ICONS.reload].map(path => icon(path, 14 * s, theme.title, 1.1)).join('') + + '
'; + const field = + `
` + + `${icon(ICONS.lock, 11 * s, theme.title, 1.1)}` + + `${escapeHtml(address ?? '')}
`; + inner = lights(12) + arrows + centered(field, `400 ${px(12.5 * s)}/1 ${SANS}`); + } else if (style === 'windows') { + const buttons = + `
` + + [ICONS.minimize, ICONS.maximize, ICONS.close] + .map( + path => + `
` + + `${icon(path, 10 * s, theme.title, 0.9)}
`, + ) + .join('') + + '
'; + const label = title + ? `
${escapeHtml(title)}
` + : ''; + inner = label + buttons; + } else { + // terminal: a tab with a prompt icon and the title, on a bar that shares the screen's color. + const tab = + `
${icon(ICONS.prompt, 11 * s, theme.title, 1.2)}` + + `${title ? `${escapeHtml(title)}` : ''}
`; + inner = lights(11) + centered(tab, `500 ${px(12 * s)}/1 ${MONO}`); + } + return ( + `
${inner}
` + ); +} + /** A minimal HTML page: fixed-size body, the frame background, `body` markup centered. */ export function page(canvas: { width: number; height: number }, background: string, body: string, css = ''): string { return ` @@ -181,13 +291,17 @@ export function frameHtml({ layout, imageSrc, title, + address, + barColor, }: { frame: ResolvedFrame; layout: FrameLayout; imageSrc: string | undefined; title: string | undefined; + address?: string | undefined; + barColor?: string | undefined; }): string { - const window = windowMarkup({ frame, image: layout.image, scale: layout.scale, imageSrc, title }); + const window = windowMarkup({ frame, image: layout.image, scale: layout.scale, imageSrc, title, address, barColor }); if (imageSrc !== undefined) return page(layout.canvas, backgroundCss(frame.background), window); const backdrop = `
`; return page(layout.canvas, 'transparent', backdrop + window); diff --git a/src/portfolio.ts b/src/portfolio.ts index f68d0f6..af81def 100644 --- a/src/portfolio.ts +++ b/src/portfolio.ts @@ -4,7 +4,7 @@ import { dirname, join, relative, resolve } from 'node:path'; import { launchBrowser } from './browser.js'; import type { ResolvedConfig, ResolvedPortfolio } from './config/types.js'; import { ShowcaseError } from './errors.js'; -import { frameTitle, logWritten, readRaw, renderFrame, writeImage } from './frame/render.js'; +import { frameAddress, frameTitle, logWritten, readRaw, renderFrame, writeImage } from './frame/render.js'; import { containLayout } from './frame/template.js'; import { log } from './log.js'; import { select } from './paths.js'; @@ -69,6 +69,7 @@ export async function exportPortfolio(config: ResolvedConfig, options: { only?: raw, layout, title: frameTitle(config, shot, lang), + address: frameAddress(config, shot, lang), deviceScaleFactor: 1, }); const path = join(dir, `${shot.id}.${extension}`); From 84492a8f4af1d7a02b346145c45d807ca0103a47 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 15:38:25 +0200 Subject: [PATCH 04/12] feat(hero): spotlight, split, row, mosaic and centered layouts --- src/config/resolve.ts | 30 ++++++- src/config/types.ts | 24 +++++- src/hero.ts | 194 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 205 insertions(+), 43 deletions(-) diff --git a/src/config/resolve.ts b/src/config/resolve.ts index b4e4500..d3b6e83 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -14,6 +14,7 @@ import { WEB_ONLY_KEYS, } from './tty.js'; import type { + HeroLayout, Mode, ResolvedBackground, ResolvedConfig, @@ -53,6 +54,9 @@ const DEFAULT_BACKGROUND: ResolvedBackground = { type: 'gradient', from: '#0f766 const DEFAULT_DOT = 'rgba(255,255,255,0.14)'; const BACKGROUND_TYPES = ['solid', 'gradient', 'transparent', 'mesh', 'dots', 'noise'] as const; export const FRAME_STYLES = ['window', 'minimal', 'none', 'browser', 'windows', 'terminal'] as const; +export const HERO_LAYOUTS = ['stack', 'spotlight', 'split', 'row', 'mosaic', 'centered'] as const; +/** The most shots each hero layout shows; it shows the first ones when `hero.shots` is not set. */ +export const HERO_MAX_SHOTS: Record = { stack: 3, spotlight: 1, split: 1, row: 4, mosaic: 4, centered: 1 }; const ADDRESS_TOKENS = ['url', 'name', 'title', 'id', 'lang']; function resolveWebTarget(issues: Issues, value: unknown, rootDir: string): ResolvedWebConfig['target'] { @@ -324,14 +328,27 @@ function resolveHero( issues.add('hero', `must be an object, got ${describe(hero)}`); return resolveHero(issues, {}, { shots, langs, frame }); } - checkKeys(issues, 'hero', hero, ['tagline', 'logo', 'shots', 'lang', 'output', 'size', 'background', 'theme', 'quality']); + checkKeys(issues, 'hero', hero, [ + 'layout', + 'tagline', + 'logo', + 'shots', + 'lang', + 'output', + 'size', + 'background', + 'theme', + 'quality', + ]); - let heroShots = shots.slice(0, 3).map(shot => shot.id); + const layout = oneOf(issues, 'hero.layout', hero.layout, HERO_LAYOUTS, 'stack'); + const max = HERO_MAX_SHOTS[layout]; + let heroShots = shots.slice(0, max).map(shot => shot.id); if (hero.shots !== undefined) { const valid = Array.isArray(hero.shots) && hero.shots.length >= 1 && - hero.shots.length <= 3 && + hero.shots.length <= max && hero.shots.every(id => typeof id === 'string'); if (valid) { heroShots = hero.shots as string[]; @@ -339,7 +356,9 @@ function resolveHero( if (!shots.some(shot => shot.id === id)) issues.add('hero.shots', `"${id}" is not a shot id`); } } else { - issues.add('hero.shots', `must be an array of one to three shot ids, got ${describe(hero.shots)}`); + const count = max === 1 ? 'one shot id' : `one to ${NUMBER_WORDS[max] ?? String(max)} shot ids`; + const why = layout === 'stack' ? '' : ` (layout "${layout}")`; + issues.add('hero.shots', `must be an array of ${count}${why}, got ${describe(hero.shots)}`); } } let size: [number, number] = [1280, 640]; @@ -355,6 +374,7 @@ function resolveHero( if (hero.lang !== undefined && !langs.includes(lang)) issues.add('hero.lang', `"${lang}" is not in langs`); return { + layout, tagline: str(issues, 'hero.tagline', hero.tagline), logo: str(issues, 'hero.logo', hero.logo), shots: heroShots, @@ -371,6 +391,8 @@ function resolveHero( }; } +const NUMBER_WORDS: Record = { 3: 'three', 4: 'four' }; + function slugify(name: string): string { return name .toLowerCase() diff --git a/src/config/types.ts b/src/config/types.ts index 02c9fec..d4d285d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -476,15 +476,32 @@ export interface Outputs { clips?: string; } +/** + * How the hero banner is composed. + * + * - `stack`: text on the left, one to three windows stacked and tilted on the right. + * - `spotlight`: text on the left, one large straight window running off the right and bottom edges. + * - `split`: text on the left, one window turned in perspective on the right. + * - `row`: text centered at the top, one to four windows side by side under it. + * - `mosaic`: text on the left, a tilted wall of one to four windows, repeated, filling the right. + * - `centered`: text first, centered, with one window rising from the bottom edge. + */ +export type HeroLayout = 'stack' | 'spotlight' | 'split' | 'row' | 'mosaic' | 'centered'; + /** The README banner that `showcase hero` renders. */ export interface HeroOptions { + /** + * How the banner is composed: `stack`, `spotlight`, `split`, `row`, `mosaic` or `centered`. + * @default `'stack'` + */ + layout?: HeroLayout; /** Line under the name. */ tagline?: string; /** Logo image (PNG, SVG, WebP or JPEG), relative to the config root. */ logo?: string; /** - * One to three shot ids to stack, back to front. - * @default the first three shots + * Shot ids to show, back to front: one to three for `stack`, one to four for `row` and `mosaic`, one for the others. + * @default as many of the first shots as the layout shows */ shots?: string[]; /** @@ -581,7 +598,7 @@ export interface CommonConfig { frame?: FrameOptions; /** Where the files go. */ outputs?: Outputs; - /** Banner image for the top of a README: logo, name, tagline and a stack of framed shots. */ + /** Banner image for the top of a README: logo, name, tagline and framed shots, in one of several layouts. */ hero?: HeroOptions; /** The Chromium that captures web apps, renders terminal screens and draws frames. */ browser?: BrowserOptions; @@ -713,6 +730,7 @@ export interface ResolvedPortfolio { /** `hero` with every default filled in. */ export interface ResolvedHero { + layout: HeroLayout; tagline: string | undefined; logo: string | undefined; shots: string[]; diff --git a/src/hero.ts b/src/hero.ts index 2f72701..d9f22f6 100644 --- a/src/hero.ts +++ b/src/hero.ts @@ -2,9 +2,18 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { extname, resolve } from 'node:path'; import { launchBrowser } from './browser.js'; -import type { ResolvedConfig, ResolvedShot } from './config/types.js'; +import type { HeroLayout, ResolvedConfig, ResolvedShot } from './config/types.js'; import { ShowcaseError } from './errors.js'; -import { formatOf, frameTitle, logWritten, readRaw, writeImage, type RawImage } from './frame/render.js'; +import { + formatOf, + frameAddress, + frameBarColor, + frameTitle, + logWritten, + readRaw, + writeImage, + type RawImage, +} from './frame/render.js'; import { backgroundCss, escapeHtml, page, windowMarkup } from './frame/template.js'; import { fillTemplate } from './template.js'; @@ -39,47 +48,160 @@ async function logoSrc(config: ResolvedConfig): Promise { return `data:${mime};base64,${(await readFile(path)).toString('base64')}`; } -/** The hero page: text on the left, framed shots stacked and tilted on the right. */ -export function heroHtml( - config: ResolvedConfig, - { windows, logo }: { windows: { shot: ResolvedShot; raw: RawImage }[]; logo: string | undefined }, -): string { - const [width, height] = config.hero.size; - const dark = config.hero.theme === 'dark'; - const stack = STACKS[windows.length] ?? []; - const windowWidth = width * 0.5; - const markup = windows - .map(({ shot, raw }, index) => { - const place = stack[index]!; - const scale = windowWidth / raw.cssWidth; - return windowMarkup({ - frame: config.frame, - image: { width: windowWidth, height: raw.cssHeight * scale }, - // Chrome a little larger than true scale reads better at banner size. - scale: Math.max(scale, 0.6), - imageSrc: `data:image/png;base64,${raw.data.toString('base64')}`, - title: frameTitle(config, shot, config.hero.lang), - extraStyle: `position:absolute;left:${String(place.left * width)}px;top:${String(place.top * height)}px;transform:rotate(${String(place.rotate)}deg);transform-origin:center`, - }); - }) - .join(''); - const unit = height / 640; - const text = `
+type HeroWindow = { shot: ResolvedShot; raw: RawImage }; + +interface Composition { + config: ResolvedConfig; + windows: HeroWindow[]; + width: number; + height: number; + /** One banner pixel at the default 640 px height, so text and gaps scale with `size`. */ + unit: number; + dark: boolean; +} + +/** One framed window `width` CSS pixels wide. Chrome a little larger than true scale reads better at banner size. */ +function heroWindow({ config }: Composition, { shot, raw }: HeroWindow, width: number, extraStyle: string): string { + const scale = width / raw.cssWidth; + return windowMarkup({ + frame: config.frame, + image: { width, height: raw.cssHeight * scale }, + scale: Math.max(scale, 0.6), + imageSrc: `data:image/png;base64,${raw.data.toString('base64')}`, + title: frameTitle(config, shot, config.hero.lang), + address: frameAddress(config, shot, config.hero.lang), + barColor: frameBarColor(config), + extraStyle, + }); +} + +const n = (value: number): string => String(value); + +/** Logo, name and tagline: left-aligned in a column, or centered across the banner. */ +function textBlock({ config, width, unit, dark }: Composition, place: { left: number; column: number } | { top: number; centered: true }, logo: string | undefined): { markup: string; css: string } { + const markup = `
${logo ? `` : ''}
${escapeHtml(config.name)}
${config.hero.tagline ? `
${escapeHtml(config.hero.tagline)}
` : ''}
`; - const css = ` + const color = dark ? '#ffffff' : '#0f172a'; + const muted = dark ? 'rgba(255,255,255,0.78)' : 'rgba(15,23,42,0.72)'; + if ('centered' in place) { + return { + markup, + css: ` + .text { + position: absolute; left: ${n(72 * unit)}px; right: ${n(72 * unit)}px; top: ${n(place.top)}px; z-index: 2; text-align: center; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + color: ${color}; + } + .logo { display: block; width: ${n(72 * unit)}px; height: ${n(72 * unit)}px; object-fit: contain; margin: 0 auto ${n(18 * unit)}px; } + .name { font-size: ${n(54 * unit)}px; font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; } + .tagline { margin-top: ${n(12 * unit)}px; font-size: ${n(21 * unit)}px; line-height: 1.4; color: ${muted}; }`, + }; + } + return { + markup, + css: ` .text { - position: absolute; left: ${String(72 * unit)}px; top: 50%; transform: translateY(-50%); - width: ${String(width * 0.36)}px; z-index: 2; + position: absolute; left: ${n(place.left)}px; top: 50%; transform: translateY(-50%); + width: ${n(width * place.column)}px; z-index: 2; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - color: ${dark ? '#ffffff' : '#0f172a'}; + color: ${color}; } - .logo { display: block; width: ${String(96 * unit)}px; height: ${String(96 * unit)}px; object-fit: contain; margin-bottom: ${String(24 * unit)}px; } - .name { font-size: ${String(60 * unit)}px; font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; } - .tagline { margin-top: ${String(16 * unit)}px; font-size: ${String(22 * unit)}px; line-height: 1.4; color: ${dark ? 'rgba(255,255,255,0.78)' : 'rgba(15,23,42,0.72)'}; }`; - return page({ width, height }, backgroundCss(config.hero.background), `${markup}${text}`, css); + .logo { display: block; width: ${n(96 * unit)}px; height: ${n(96 * unit)}px; object-fit: contain; margin-bottom: ${n(24 * unit)}px; } + .name { font-size: ${n(60 * unit)}px; font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; } + .tagline { margin-top: ${n(16 * unit)}px; font-size: ${n(22 * unit)}px; line-height: 1.4; color: ${muted}; }`, + }; +} + +/** The windows of each layout, as markup placed on the canvas. Text is added by `heroHtml`. */ +const LAYOUTS: Record string> = { + stack: c => { + const stack = STACKS[c.windows.length] ?? []; + return c.windows + .map((window, index) => { + const place = stack[index]!; + return heroWindow( + c, + window, + c.width * 0.5, + `position:absolute;left:${String(place.left * c.width)}px;top:${String(place.top * c.height)}px;transform:rotate(${String(place.rotate)}deg);transform-origin:center`, + ); + }) + .join(''); + }, + // One straight window, larger than the canvas has room for, running off the right and bottom edges. + spotlight: c => + heroWindow(c, c.windows[0]!, c.width * 0.68, `position:absolute;left:${n(c.width * 0.43)}px;top:${n(c.height * 0.2)}px`), + // One window turned towards the text, in a perspective that scales with the banner. + split: c => { + const window = c.windows[0]!; + const width = c.width * 0.5; + const height = (window.raw.cssHeight * width) / window.raw.cssWidth; + return ( + `
` + + heroWindow(c, window, width, 'transform:rotateY(22deg) rotateX(6deg);transform-origin:60% 50%') + + '
' + ); + }, + // One to four windows side by side, centered under the text; tall captures run off the bottom edge. + row: c => { + const count = c.windows.length; + const gap = 28 * c.unit; + const width = Math.min((c.width - 2 * 64 * c.unit - gap * (count - 1)) / count, c.width * 0.56); + const left = (c.width - (width * count + gap * (count - 1))) / 2; + return c.windows + .map((window, index) => + heroWindow(c, window, width, `position:absolute;left:${n(left + index * (width + gap))}px;top:${n(c.height * 0.5)}px`), + ) + .join(''); + }, + // A wall of windows, repeated to fill it, laid flat and turned; it fades out towards the text. + mosaic: c => { + const columns = 3; + const rows = 4; + const width = c.width * 0.3; + const gap = 36 * c.unit; + const tiles = []; + for (let index = 0; index < columns * rows; index++) { + const window = c.windows[index % c.windows.length]!; + tiles.push(heroWindow(c, window, width, '')); + } + // Centered on a point right of the text, turned about its own center, whatever its height comes to. + const wall = + `
${tiles.join('')}
`; + const mask = 'linear-gradient(90deg,transparent 30%,#000 52%)'; + return `
${wall}
`; + }, + // One window rising from the bottom edge, leaning back, under centered text. + centered: c => { + const window = c.windows[0]!; + const width = c.width * 0.62; + return ( + `
` + + heroWindow(c, window, width, 'transform:rotateX(16deg);transform-origin:50% 0') + + '
' + ); + }, +}; + +/** The hero page: text and framed shots, arranged by `hero.layout`. */ +export function heroHtml( + config: ResolvedConfig, + { windows, logo }: { windows: HeroWindow[]; logo: string | undefined }, +): string { + const [width, height] = config.hero.size; + const composition: Composition = { config, windows, width, height, unit: height / 640, dark: config.hero.theme === 'dark' }; + const { layout } = config.hero; + const place = + layout === 'row' || layout === 'centered' + ? { top: height * (layout === 'row' ? 0.08 : 0.1), centered: true as const } + : { left: 72 * composition.unit, column: layout === 'spotlight' || layout === 'mosaic' ? 0.34 : 0.36 }; + const text = textBlock(composition, place, logo); + return page({ width, height }, backgroundCss(config.hero.background), `${LAYOUTS[layout](composition)}${text.markup}`, text.css); } /** Render the README hero banner to `hero.output` at exactly `hero.size` pixels. */ From c9dce56d2da42afa25bc9c8de5ef1e5e38e63c63 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 15:38:25 +0200 Subject: [PATCH 05/12] feat(readme): rows, featured, details and list layouts behind --layout --- src/cli.ts | 8 +++-- src/index.ts | 2 +- src/readme.ts | 85 +++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 780a88c..4da3ea0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,7 +13,7 @@ import { generateIcons, ICON_PRESETS, type IconPreset } from './icons.js'; import { init } from './init.js'; import { log } from './log.js'; import { exportPortfolio } from './portfolio.js'; -import { readmeSnippet } from './readme.js'; +import { readmeSnippet, type ReadmeLayout } from './readme.js'; import { record, splitIds } from './record.js'; const HELP = `showcase: capture, frame and export showcase images of your app @@ -38,6 +38,8 @@ Options: readme, record, all) --langs Comma-separated languages (capture, frame, record, all) --lang Language for readme (default: the first in langs) + --layout Layout for readme: table, rows, featured, details or list + (default table) --cols Images per row for readme (default 2) --base Directory the README is in, for relative image paths (readme) --source Square source image, 1024px or larger (icons) @@ -75,6 +77,7 @@ async function main(argv: string[]): Promise { only: { type: 'string' }, langs: { type: 'string' }, lang: { type: 'string' }, + layout: { type: 'string' }, cols: { type: 'string' }, base: { type: 'string' }, source: { type: 'string' }, @@ -137,7 +140,8 @@ async function main(argv: string[]): Promise { }, readme: async config => { const cols = values.cols === undefined ? undefined : Number(values.cols); - console.log(readmeSnippet(config, { lang: values.lang, cols, base: values.base, only: list(values.only) })); + const layout = values.layout as ReadmeLayout | undefined; + console.log(readmeSnippet(config, { lang: values.lang, layout, cols, base: values.base, only: list(values.only) })); }, all: async config => { const { shots: only, clips } = splitIds(config, list(values.only)); diff --git a/src/index.ts b/src/index.ts index 4695cb2..7337ced 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ export { hero, heroHtml } from './hero.js'; export { encodeIcns, encodeIco, generateIcons, ICON_PRESETS, type IconPreset } from './icons.js'; export { init, starterConfig } from './init.js'; export { exportPortfolio, GALLERY_FILE, type GalleryItem, type PortfolioResult } from './portfolio.js'; -export { readmeSnippet, type ReadmeOptions } from './readme.js'; +export { readmeSnippet, type ReadmeLayout, type ReadmeOptions } from './readme.js'; export { record, type RecordedClip, type RecordedFile, type RecordOptions } from './record.js'; export type * from './config/types.js'; export type * from './tty/types.js'; diff --git a/src/readme.ts b/src/readme.ts index 1c82dac..666c9a0 100644 --- a/src/readme.ts +++ b/src/readme.ts @@ -8,9 +8,24 @@ import { log } from './log.js'; import { outputPath, select } from './paths.js'; import { clipPath, selectClips, splitIds } from './record.js'; +/** + * How `showcase readme` lays out the images. Every layout uses only HTML that GitHub keeps in a README. + * + * - `table`: a table, a row of images and a row of `` captions under it, `cols` per row. + * - `rows`: one row per image, the image on one side and its title and caption on the other, alternating sides. + * - `featured`: the first image full width with its caption, then the others in a table, `cols` per row. + * - `details`: one collapsible `
` per image, the caption as its summary; the first starts open. + * - `list`: every image full width, one under the other, each with its caption. + */ +export type ReadmeLayout = 'table' | 'rows' | 'featured' | 'details' | 'list'; + +export const README_LAYOUTS: readonly ReadmeLayout[] = ['table', 'rows', 'featured', 'details', 'list']; + export interface ReadmeOptions { lang?: string; - /** Images per row. Default 2. */ + /** How the images are laid out. Default `'table'`. */ + layout?: ReadmeLayout; + /** Images per row in the `table` layout, thumbnails per row in `featured`. Default 2. */ cols?: number; /** Directory the README lives in; image paths are relative to it. Default: the config root. */ base?: string; @@ -23,10 +38,17 @@ export interface ReadmeOptions { * when there is one, or only a link for an MP4-only clip. */ export function readmeSnippet(config: ResolvedConfig, options: ReadmeOptions = {}): string { + const layout = options.layout ?? 'table'; + if (!README_LAYOUTS.includes(layout)) { + throw new ShowcaseError(`--layout must be one of ${README_LAYOUTS.join(', ')}, got ${String(options.layout)}`); + } const cols = options.cols ?? 2; if (!Number.isInteger(cols) || cols < 1 || cols > 6) { throw new ShowcaseError(`--cols must be a whole number from 1 to 6, got ${String(options.cols)}`); } + if (options.cols !== undefined && layout !== 'table' && layout !== 'featured') { + throw new ShowcaseError(`--cols only applies to the table and featured layouts, not ${layout}`); + } const template = config.outputs.readme; const clips = isTtyConfig(config) ? config.clips : []; if (template === false && clips.length === 0) { @@ -42,18 +64,17 @@ export function readmeSnippet(config: ResolvedConfig, options: ReadmeOptions = { ); } const base = resolve(config.root, options.base ?? '.'); - const width = `${String(Math.floor(100 / cols))}%`; const src = (path: string, command: string): string => { if (!existsSync(path)) log.warn(`warning: ${relative(process.cwd(), path)} does not exist yet (run \`showcase ${command}\`).`); return escapeHtml(relative(base, path).split(sep).join('/')); }; - /** Cell contents as HTML. */ - const cells: { media: string; caption: string }[] = []; + const cells: Cell[] = []; if (template !== false && only.shots?.length !== 0) { for (const shot of select(config, only.shots, [lang]).shots) { cells.push({ media: `${escapeHtml(shot.alt)}`, + title: escapeHtml(shot.title), caption: escapeHtml(shot.caption ?? shot.title), }); } @@ -63,18 +84,39 @@ export function readmeSnippet(config: ResolvedConfig, options: ReadmeOptions = { const image = clip.formats.find(format => format !== 'mp4'); const video = clip.formats.includes('mp4') ? src(clipPath(config, lang, clip.id, 'mp4'), 'record') : undefined; const caption = escapeHtml(clip.caption ?? clip.title); + const title = escapeHtml(clip.title); if (image) { cells.push({ media: `${escapeHtml(clip.alt)}`, + title, caption: video ? `${caption} (MP4)` : caption, }); } else { // GitHub does not play a repository MP4 inline, so an MP4-only clip is a link. - cells.push({ media: `${escapeHtml(clip.alt)} (MP4)`, caption }); + cells.push({ media: `${escapeHtml(clip.alt)} (MP4)`, title, caption, link: true }); } } } + return LAYOUTS[layout](cells, cols).join('\n'); +} + +/** One image (or clip link) of the snippet, every field already HTML. */ +interface Cell { + media: string; + title: string; + caption: string; + /** An MP4-only clip: `media` is a link, not an image. */ + link?: boolean; +} +/** `media` stretched to the full width of what holds it; a link stays a link. */ +function fullWidth(cell: Cell): string { + return cell.link ? cell.media : cell.media.replace('` captions in the next, `cols` per row. */ +function table(cells: Cell[], cols: number): string[] { + const width = `${String(Math.floor(100 / cols))}%`; const lines = ['']; for (let start = 0; start < cells.length; start += cols) { const row = cells.slice(start, start + cols); @@ -89,5 +131,36 @@ export function readmeSnippet(config: ResolvedConfig, options: ReadmeOptions = { lines.push(' '); } lines.push('
'); - return lines.join('\n'); + return lines; } + +// GitHub keeps no CSS in a README, only a short list of tags and attributes: tables with `width` and `align`, +// `
`, ``, `

`, `

`, ``, `
`. Each layout is built from those. +const LAYOUTS: Record string[]> = { + table, + rows: cells => { + const lines = ['']; + cells.forEach((cell, index) => { + const image = ` `; + const text = ` `; + lines.push(' ', ...(index % 2 === 0 ? [image, text] : [text, image]), ' '); + }); + lines.push('
${cell.media}

${cell.title}

${cell.caption === cell.title ? '' : `

${cell.caption}

`}
'); + return lines; + }, + featured: (cells, cols) => { + const [first, ...rest] = cells; + if (!first) return []; + const lines = ['

', ` ${fullWidth(first)}`, `
${first.caption}`, '

']; + return rest.length > 0 ? [...lines, ...table(rest, cols)] : lines; + }, + details: cells => + cells.flatMap((cell, index) => [ + index === 0 ? '
' : '
', + ` ${cell.title}${cell.caption === cell.title ? '' : `: ${cell.caption}`}`, + `

${fullWidth(cell)}

`, + '
', + ]), + list: cells => + cells.flatMap(cell => ['

', ` ${fullWidth(cell)}`, `
${cell.caption}`, '

']), +}; From b45e9636999fb0f0f6e51b0097d901730b766656 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 15:38:25 +0200 Subject: [PATCH 06/12] test(config): validate hero layouts, frame styles, the address and the new backgrounds --- test/config.test.ts | 99 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/test/config.test.ts b/test/config.test.ts index 58a1a05..13d47f1 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -84,7 +84,7 @@ describe('resolveConfig', () => { 'shots[0].id: may only contain letters, digits, "-" and "_", got "a b"', expect.stringMatching(/^shots\[1\]\.nav: must be a selector/), 'shots[2].id: duplicates another shot id "x"', - 'frame.style: must be one of "window", "minimal", "none", got "fancy"', + 'frame.style: must be one of "window", "minimal", "none", "browser", "windows", "terminal", got "fancy"', 'frame.background.color: is not a CSS color: "red; } body { display:none"', 'outputs.raw: must end in .png', 'outputs.readme: must contain {id}, or every file overwrites the last', @@ -132,6 +132,103 @@ describe('resolveConfig', () => { ]); }); + it('defaults the new layout keys to the looks that existed before them', () => { + const config = resolveConfig(minimal, '/work/app'); + expect(config.hero.layout).toBe('stack'); + expect(config.hero.shots).toEqual(['home']); + expect(config.frame.address).toBe('{url}'); + }); + + it('accepts every hero layout, and takes as many of the first shots as the layout shows', () => { + const shots = ['a', 'b', 'c', 'd', 'e'].map(id => ({ id })); + const heroShots = (layout: string) => resolveConfig({ ...minimal, shots, hero: { layout } }, '/').hero.shots; + expect(heroShots('stack')).toEqual(['a', 'b', 'c']); + expect(heroShots('row')).toEqual(['a', 'b', 'c', 'd']); + expect(heroShots('mosaic')).toEqual(['a', 'b', 'c', 'd']); + for (const layout of ['spotlight', 'split', 'centered']) expect(heroShots(layout)).toEqual(['a']); + }); + + it('rejects an unknown hero layout, and more shots than the layout shows', () => { + const shots = ['a', 'b', 'c', 'd', 'e'].map(id => ({ id })); + expect(issuesOf({ ...minimal, hero: { layout: 'carousel' } })).toEqual([ + 'hero.layout: must be one of "stack", "spotlight", "split", "row", "mosaic", "centered", got "carousel"', + ]); + expect(issuesOf({ ...minimal, shots, hero: { layout: 'spotlight', shots: ['a', 'b'] } })).toEqual([ + 'hero.shots: must be an array of one shot id (layout "spotlight"), got an array', + ]); + expect(issuesOf({ ...minimal, shots, hero: { layout: 'row', shots: ['a', 'b', 'c', 'd', 'e'] } })).toEqual([ + 'hero.shots: must be an array of one to four shot ids (layout "row"), got an array', + ]); + expect(issuesOf({ ...minimal, shots, hero: { shots: ['a', 'b', 'c', 'd'] } })).toEqual([ + 'hero.shots: must be an array of one to three shot ids, got an array', + ]); + }); + + it('accepts the new frame styles and an address template', () => { + for (const style of ['browser', 'windows', 'terminal']) { + expect(resolveConfig({ ...minimal, frame: { style } }, '/').frame.style).toBe(style); + } + const frame = resolveConfig({ ...minimal, frame: { style: 'browser', address: 'demo.app/{id}?lang={lang}' } }, '/').frame; + expect(frame.address).toBe('demo.app/{id}?lang={lang}'); + }); + + it('rejects the browser style where there is no page, and unknown address tokens', () => { + const tty = { name: 'Demo', target: { mode: 'tty', command: 'demo' }, shots: [{ id: 'home' }] }; + expect(issuesOf({ ...tty, frame: { style: 'browser' } })).toEqual([ + 'frame.style: "browser" needs a page with an address; a terminal app has none (use "terminal" or "window")', + ]); + const cdp = { ...minimal, target: { mode: 'cdp' } }; + expect(issuesOf({ ...cdp, frame: { style: 'browser' } })).toEqual([ + 'frame.address: cannot use {url} in cdp mode, where the page is not known when framing: write the address', + ]); + expect(resolveConfig({ ...cdp, frame: { style: 'browser', address: 'demo.app' } }, '/').frame.address).toBe('demo.app'); + expect(issuesOf({ ...minimal, frame: { address: '{host}/{id}' } })).toEqual([ + 'frame.address: unknown token {host} (allowed: {url}, {name}, {title}, {id}, {lang})', + ]); + }); + + it('resolves the mesh, dots and noise backgrounds with their defaults', () => { + const background = (value: unknown) => resolveConfig({ ...minimal, frame: { background: value } }, '/').frame.background; + expect(background({ type: 'mesh', colors: ['#000', 'red', 'oklch(70% 0.1 200)'] })).toEqual({ + type: 'mesh', + colors: ['#000', 'red', 'oklch(70% 0.1 200)'], + }); + expect(background({ type: 'dots', color: '#101010' })).toEqual({ + type: 'dots', + color: '#101010', + dot: 'rgba(255,255,255,0.14)', + spacing: 24, + }); + expect(background({ type: 'noise', from: '#111', to: '#222' })).toEqual({ + type: 'noise', + from: '#111', + to: '#222', + angle: 135, + amount: 0.2, + }); + const hero = resolveConfig({ ...minimal, hero: { background: { type: 'dots', color: '#fff', dot: '#0003', spacing: 12 } } }, '/'); + expect(hero.hero.background).toEqual({ type: 'dots', color: '#fff', dot: '#0003', spacing: 12 }); + }); + + it('rejects bad mesh, dots and noise backgrounds with the reason', () => { + expect(issuesOf({ ...minimal, frame: { background: { type: 'mesh', colors: ['#000'] } } })).toEqual([ + 'frame.background.colors: must be an array of two to five CSS colors, got an array', + ]); + expect(issuesOf({ ...minimal, frame: { background: { type: 'mesh', colors: ['#000', 'url(x.png)'] } } })).toEqual([ + 'frame.background.colors[1]: is not a CSS color: "url(x.png)"', + ]); + expect(issuesOf({ ...minimal, frame: { background: { type: 'dots', color: '#000', spacing: 4, size: 2 } } })).toEqual([ + 'frame.background.size: unknown key (expected one of: type, color, dot, spacing)', + 'frame.background.spacing: must be a number between 8 and 96, got number 4', + ]); + expect(issuesOf({ ...minimal, frame: { background: { type: 'noise', from: '#000', to: '#fff', amount: 2 } } })).toEqual([ + 'frame.background.amount: must be a number between 0 and 1, got number 2', + ]); + expect(issuesOf({ ...minimal, frame: { background: { type: 'stripes' } } })).toEqual([ + 'frame.background: must be a color string or { type: "solid" | "gradient" | "transparent" | "mesh" | "dots" | "noise" }, got an object', + ]); + }); + it('drops the g and y flags from a RegExp pageMatch, whose lastIndex would make matching flaky', () => { const config = resolveConfig({ ...minimal, target: { mode: 'cdp', pageMatch: /localhost/giy } }, '/'); const pageMatch = (config.target as { pageMatch: RegExp }).pageMatch; From 5ce738e8b9590ab9ea035849e3089bb5f270378d Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 15:38:25 +0200 Subject: [PATCH 07/12] test(hero): render every hero layout, frame style and background --- test/layouts.test.ts | 212 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 test/layouts.test.ts diff --git a/test/layouts.test.ts b/test/layouts.test.ts new file mode 100644 index 0000000..57bbaa1 --- /dev/null +++ b/test/layouts.test.ts @@ -0,0 +1,212 @@ +import { readFileSync } from 'node:fs'; +import sharp from 'sharp'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { capture } from '../src/capture.js'; +import { HERO_LAYOUTS, resolveConfig } from '../src/config/resolve.js'; +import type { HeroLayout, ResolvedConfig, ShowcaseConfig, TtyConfig } from '../src/config/types.js'; +import { frame } from '../src/frame/index.js'; +import { frameAddress, frameBarColor } from '../src/frame/render.js'; +import { hero } from '../src/hero.js'; +import { LIGHT_THEME } from '../src/tty/theme.js'; +import { fixtureInput, serveFixture, tempDir, type FixtureServer } from './helpers.js'; + +type Rgb = [number, number, number]; + +async function raw(path: string): Promise<{ data: Buffer; width: number; height: number }> { + const { data, info } = await sharp(path).removeAlpha().raw().toBuffer({ resolveWithObject: true }); + return { data, width: info.width, height: info.height }; +} + +async function pixel(path: string, x: number, y: number): Promise { + const { data, width } = await raw(path); + const offset = (y * width + x) * 3; + return [data[offset]!, data[offset + 1]!, data[offset + 2]!]; +} + +/** Share of the pixels in a box that differ from `background` by more than a lossy encoder would. */ +async function covered(path: string, box: { x: number; y: number; w: number; h: number }, background: Rgb): Promise { + const { data, width } = await raw(path); + let hits = 0; + for (let y = box.y; y < box.y + box.h; y++) { + for (let x = box.x; x < box.x + box.w; x++) { + const offset = (y * width + x) * 3; + if (background.some((channel, index) => Math.abs(data[offset + index]! - channel) > 16)) hits++; + } + } + return hits / (box.w * box.h); +} + +const near = (actual: number[], expected: number[], tolerance = 8): boolean => + actual.every((channel, index) => Math.abs(channel - expected[index]!) <= tolerance); + +let server: FixtureServer; +let root: string; + +function config(overrides: Pick): ResolvedConfig { + return resolveConfig(fixtureInput({ target: { mode: 'url', url: server.url }, ...overrides }), root); +} + +beforeAll(async () => { + server = await serveFixture(); + root = tempDir(); + await capture(config({})); +}); + +afterAll(async () => { + await server.close(); +}); + +const BG: Rgb = [0x12, 0x34, 0x56]; + +/** + * Where each layout puts windows and where it leaves the background, in banner pixels (1280x640). The boxes avoid + * the text on the left or at the top. + */ +const PLACES: Record = { + // Three windows at 0.42 to 0.56 of the width, tilted: the right half, low, is covered. + stack: { windows: { x: 900, y: 380, w: 200, h: 100 }, empty: { x: 560, y: 590, w: 60, h: 40 } }, + // One window from x 550, y 128, running off the right and bottom edges. + spotlight: { windows: { x: 1150, y: 560, w: 120, h: 70 }, empty: { x: 560, y: 20, w: 700, h: 80 } }, + // One window 640 wide from x 576, 400 high, centered: nothing below it. + split: { windows: { x: 800, y: 260, w: 200, h: 120 }, empty: { x: 560, y: 580, w: 700, h: 50 } }, + // Three windows 365 wide from x 64 at y 320, under the centered text. + row: { windows: { x: 100, y: 400, w: 1080, h: 60 }, empty: { x: 20, y: 20, w: 200, h: 200 } }, + // A wall on the right; the mask hides it on the left. + mosaic: { windows: { x: 900, y: 200, w: 300, h: 250 }, empty: { x: 20, y: 500, w: 300, h: 120 } }, + // One window 794 wide from x 243 at y 320, leaning back: the bottom corners stay empty. + centered: { windows: { x: 500, y: 450, w: 280, h: 150 }, empty: { x: 20, y: 400, w: 150, h: 220 } }, +}; + +describe('hero layouts', () => { + it.each(HERO_LAYOUTS)('%s puts the windows where it says and leaves the rest to the background', async layout => { + const result = await hero( + config({ + hero: { layout, output: `hero-${layout}.png`, background: '#123456' }, + frame: { shadow: false }, + }), + ); + const meta = await sharp(result.path).metadata(); + expect([meta.width, meta.height, meta.format]).toEqual([1280, 640, 'png']); + const place = PLACES[layout]; + expect(await covered(result.path, place.windows, BG)).toBeGreaterThan(0.8); + expect(await covered(result.path, place.empty, BG)).toBeLessThan(0.01); + }); + + it('renders the same bytes on a second run', async () => { + const settings = { layout: 'mosaic' as const, background: { type: 'noise' as const, from: '#123456', to: '#345678' } }; + const first = readFileSync((await hero(config({ hero: { ...settings, output: 'a.png' } }))).path); + const second = readFileSync((await hero(config({ hero: { ...settings, output: 'b.png' } }))).path); + expect(first.equals(second)).toBe(true); + }); +}); + +describe('frame styles', () => { + const framed = async (style: 'browser' | 'windows' | 'terminal', theme: 'light' | 'dark' = 'dark') => { + const [file] = await frame( + config({ + frame: { style, theme, background: '#ff0000', shadow: false, address: 'demo.app/{id}' }, + outputs: { readme: `styles/${style}-${theme}-{id}.png` }, + }), + { only: ['home'] }, + ); + return file!; + }; + + it.each([ + // (640 + 2 * 72) x (400 + bar + 2 * 72) CSS pixels at DPR 2, with bars of 44, 32 and 34. + ['browser', 1176], + ['windows', 1152], + ['terminal', 1156], + ] as const)('%s adds its bar height', async (style, height) => { + const file = await framed(style); + expect([file.width, file.height]).toEqual([1568, height]); + }); + + it('draws the Windows bar in its own colors, light and dark', async () => { + // A point in the bar between the title and the caption buttons, at DPR 2. + const point = [(72 + 400) * 2, (72 + 16) * 2] as const; + expect(await pixel((await framed('windows')).path, ...point)).toEqual([0x20, 0x20, 0x20]); + expect(await pixel((await framed('windows', 'light')).path, ...point)).toEqual([0xf3, 0xf3, 0xf3]); + }); + + it('draws the address field in the middle of the browser bar', async () => { + const { path } = await framed('browser'); + const bar = await pixel(path, (72 + 120) * 2, (72 + 36) * 2); + // Inside the field, left of the address text: the bar color with 8% white over it. + const field = await pixel(path, (72 + 190) * 2, (72 + 22) * 2); + expect(near(bar, [0x1f, 0x24, 0x30])).toBe(true); + expect(near(field, [0x2f, 0x34, 0x3f])).toBe(true); + }); + + it('draws the terminal bar without a line between bar and screen', async () => { + const { path } = await framed('terminal'); + expect(near(await pixel(path, (72 + 100) * 2, (72 + 17) * 2), [0x16, 0x18, 0x1d])).toBe(true); + // The last device pixel row of the 34 px bar, where the other styles draw their hairline. + expect(near(await pixel(path, (72 + 100) * 2, (72 + 34) * 2 - 1), [0x16, 0x18, 0x1d], 4)).toBe(true); + }); + + it('fills the address from the shot: the url a path nav visits, the target url for a click', () => { + const resolved = config({ frame: { style: 'browser' } }); + const host = server.url.replace('http://', ''); + const [home, , about] = resolved.shots; + expect(frameAddress(resolved, home!, 'en')).toBe(host); + expect(frameAddress(resolved, about!, 'en')).toBe(`${host}about`); + const custom = config({ frame: { style: 'browser', address: '{name} {title} {id} {lang}' } }); + expect(frameAddress(custom, about!, 'pl')).toBe('Fixture App About about pl'); + expect(frameAddress(config({}), home!, 'en')).toBeUndefined(); + }); + + it('gives the terminal bar the terminal background in tty mode only', () => { + const tty = resolveConfig( + { + name: 'Rumi', + target: { mode: 'tty', command: 'rumi' }, + shots: [{ id: 'home' }], + terminal: { theme: 'light' }, + frame: { style: 'terminal' }, + } satisfies TtyConfig, + root, + ); + expect(frameBarColor(tty)).toBe(LIGHT_THEME.background); + expect(frameBarColor({ ...tty, frame: { ...tty.frame, style: 'window' } })).toBeUndefined(); + expect(frameBarColor(config({ frame: { style: 'terminal' } }))).toBeUndefined(); + }); +}); + +describe('backgrounds', () => { + const framed = async (name: string, background: NonNullable['background']) => { + const [file] = await frame(config({ frame: { background, shadow: false }, outputs: { readme: `bg/${name}-{id}.png` } }), { + only: ['home'], + }); + return file!.path; + }; + + it('mesh: each color glows from its corner over the first', async () => { + const path = await framed('mesh', { type: 'mesh', colors: ['#000000', '#ff0000', '#00ff00', '#0000ff', '#ffff00'] }); + const [width, height] = [1568, 1168]; + expect(near(await pixel(path, 2, 2), [255, 0, 0], 12)).toBe(true); + expect(near(await pixel(path, width - 3, 2), [0, 255, 0], 12)).toBe(true); + expect(near(await pixel(path, width - 3, height - 3), [0, 0, 255], 12)).toBe(true); + expect(near(await pixel(path, 2, height - 3), [255, 255, 0], 12)).toBe(true); + }); + + it('dots: a dot in the middle of every tile, the color between them', async () => { + const path = await framed('dots', { type: 'dots', color: '#000000', dot: '#ffffff', spacing: 24 }); + // The first tile is 24 CSS pixels square from the corner: its center is at 12, 12, or 24, 24 at DPR 2. + expect((await pixel(path, 24, 24)).every(channel => channel > 200)).toBe(true); + expect(await pixel(path, 0, 0)).toEqual([0, 0, 0]); + expect(await pixel(path, 48 + 24, 24)).toEqual(await pixel(path, 24, 24)); + }); + + it('noise: grain the gradient does not have, from amount 0 to 1', async () => { + const spread = async (amount: number) => { + const path = await framed(`noise-${String(amount)}`, { type: 'noise', from: '#404040', to: '#404040', amount }); + const { data, width } = await raw(path); + const values = []; + for (let x = 0; x < 120; x++) values.push(data[(10 * width + x) * 3]!); + return Math.max(...values) - Math.min(...values); + }; + expect(await spread(0)).toBe(0); + expect(await spread(1)).toBeGreaterThan(40); + }); +}); From 9370ad89499cb5849e34d9e0f123a37accce0d75 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 15:38:25 +0200 Subject: [PATCH 08/12] test(readme): pin the output of every readme layout and the --layout flag --- test/cli.test.ts | 11 ++++ test/readme-layouts.test.ts | 108 ++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 test/readme-layouts.test.ts diff --git a/test/cli.test.ts b/test/cli.test.ts index 3ba5ef3..54a026d 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -114,6 +114,17 @@ describe('showcase CLI', () => { `); + + const list = await run(['readme', '--layout', 'list', '--only', 'about'], dir); + expect(list.code).toBe(0); + expect(list.stdout).toBe(`

+ Fixture App: About +
About page +

+`); + const wrong = await run(['readme', '--layout', 'grid'], dir); + expect(wrong.code).toBe(1); + expect(wrong.stderr).toContain('--layout must be one of table, rows, featured, details, list, got grid'); }); it('captures only what --only and --langs select', async () => { diff --git a/test/readme-layouts.test.ts b/test/readme-layouts.test.ts new file mode 100644 index 0000000..7fec09b --- /dev/null +++ b/test/readme-layouts.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resolveConfig } from '../src/config/resolve.js'; +import type { TtyConfig } from '../src/config/types.js'; +import { log } from '../src/log.js'; +import { readmeSnippet, type ReadmeLayout } from '../src/readme.js'; + +const config = resolveConfig( + { + name: 'Rumi', + target: { mode: 'tty', command: 'rumi' }, + shots: [ + { id: 'queue', title: 'Queue', caption: 'The queue & its details.' }, + { id: 'log', title: 'Log' }, + ], + clips: [{ id: 'video', title: 'Video', steps: [{ keys: 'v' }], formats: ['mp4'] }], + } satisfies TtyConfig, + '/work/rumi', +); + +const snippet = (layout: ReadmeLayout, cols?: number): string => readmeSnippet(config, { layout, cols }); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('readme layouts', () => { + it('keeps the table as the default', () => { + vi.spyOn(log, 'warn').mockImplementation(() => {}); + expect(snippet('table')).toBe(readmeSnippet(config)); + expect(readmeSnippet(config)).toMatch(/^\n {2}\n {4}
{ + vi.spyOn(log, 'warn').mockImplementation(() => {}); + expect(snippet('rows')).toBe(` + + + + + + + + + + + + +
Rumi: Queue

Queue

The queue & its details.

Log

Rumi: Log
Rumi: Video (MP4)

Video

`); + }); + + it('featured: the first image full width, the rest in a table of --cols', () => { + vi.spyOn(log, 'warn').mockImplementation(() => {}); + expect(snippet('featured', 3)).toBe(`

+ Rumi: Queue +
The queue & its details. +

+ + + + + + + + + +
Rumi: LogRumi: Video (MP4)
LogVideo
`); + }); + + it('details: one collapsible section per image, the first open', () => { + vi.spyOn(log, 'warn').mockImplementation(() => {}); + expect(snippet('details')).toBe(`
+ Queue: The queue & its details. +

Rumi: Queue

+
+
+ Log +

Rumi: Log

+
+
+ Video +

Rumi: Video (MP4)

+
`); + }); + + it('list: every image full width with its caption', () => { + vi.spyOn(log, 'warn').mockImplementation(() => {}); + expect(snippet('list')).toBe(`

+ Rumi: Queue +
The queue & its details. +

+

+ Rumi: Log +
Log +

+

+ Rumi: Video (MP4) +
Video +

`); + }); + + it('refuses an unknown layout, and --cols where it means nothing', () => { + vi.spyOn(log, 'warn').mockImplementation(() => {}); + expect(() => readmeSnippet(config, { layout: 'grid' as ReadmeLayout })).toThrow( + '--layout must be one of table, rows, featured, details, list, got grid', + ); + expect(() => snippet('rows', 2)).toThrow('--cols only applies to the table and featured layouts, not rows'); + }); +}); From 52a2c0fc82a379a0755194a92638d3bff2b8f707 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 15:38:25 +0200 Subject: [PATCH 09/12] test(site): check the defaults of every background form, keyed by form type --- site/scripts/reference/config.test.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/site/scripts/reference/config.test.ts b/site/scripts/reference/config.test.ts index 8f35ee8..ffc69f1 100644 --- a/site/scripts/reference/config.test.ts +++ b/site/scripts/reference/config.test.ts @@ -61,9 +61,19 @@ const NORMALIZED: Record unknown> = { 'terminal.theme': (literal) => (literal === 'dark' ? lib.DARK_THEME : lib.LIGHT_THEME), }; -/** Form fields with a default, and a value of that form without the field, to see the default filled in. */ +/** + * Form fields with a default, keyed by path and the form's `type` (two forms can share a field name), and a value of + * that form without the field, to see the default filled in. + */ +const GRADIENT = { type: 'gradient', from: '#000000', to: '#ffffff' }; +const NOISE = { type: 'noise', from: '#000000', to: '#ffffff' }; +const DOTS = { type: 'dots', color: '#000000' }; const FORM_SAMPLES: Record> = { - 'frame.background.angle': { type: 'gradient', from: '#000000', to: '#ffffff' }, + 'frame.background.angle in gradient': GRADIENT, + 'frame.background.angle in noise': NOISE, + 'frame.background.amount in noise': NOISE, + 'frame.background.dot in dots': DOTS, + 'frame.background.spacing in dots': DOTS, }; /** Every default written as prose (it depends on other keys or on files), exactly. */ @@ -130,11 +140,12 @@ describe('(a) documented defaults', () => { for (const field of form.fields ?? []) { if (field.default?.kind !== 'literal') continue; const path = `${table.path}.${field.key}`; - const sample = FORM_SAMPLES[path]; - expect({ path, sample: sample !== undefined }).toEqual({ path, sample: true }); + const key = `${path} in ${/type: '(\w+)'/.exec(form.type)?.[1] ?? form.type}`; + const sample = FORM_SAMPLES[key]; + expect({ key, sample: sample !== undefined }).toEqual({ key, sample: true }); const config = setAt(fixture('url'), segmentsOf(table.path, table.array), sample); expect(valueAt(lib.resolveConfig(config, FIXTURE_ROOT), path)).toEqual(parseLiteral(field.default.text)); - seen.push(path); + seen.push(key); } } } From 87d8bed88207cb605304ca19d79d1da6ccbe2499 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 15:38:25 +0200 Subject: [PATCH 10/12] docs(site): describe the hero layouts, frame styles, backgrounds and readme layouts --- site/src/content/docs/guides/frames.mdx | 47 +++++++++++++++++-- site/src/content/docs/guides/hero.mdx | 41 ++++++++++++---- site/src/content/docs/guides/readme-table.mdx | 41 +++++++++++++++- 3 files changed, 116 insertions(+), 13 deletions(-) diff --git a/site/src/content/docs/guides/frames.mdx b/site/src/content/docs/guides/frames.mdx index fe2cdc8..664b6e7 100644 --- a/site/src/content/docs/guides/frames.mdx +++ b/site/src/content/docs/guides/frames.mdx @@ -1,6 +1,6 @@ --- title: Frames -description: Turn raw captures into README images inside a window frame, on a solid, gradient or transparent background, as WebP or PNG. +description: Turn raw captures into README images inside a window, browser or terminal frame, on a solid, gradient, mesh, dotted, grainy or transparent background, as WebP or PNG. --- import { Aside } from '@astrojs/starlight/components'; @@ -37,30 +37,71 @@ config reference under [`frame`](/showcase-kit/reference/config/#frame) and | `'window'` (default) | 40 px, with the three traffic lights and the title centered. | | `'minimal'` | A thin 28 px bar with the title, no lights. | | `'none'` | No bar and no title: just the rounded screenshot. | +| `'browser'` | A 44 px browser toolbar: traffic lights, back, forward and reload, and an address bar showing `address`. | +| `'windows'` | A 32 px Windows title bar: the title on the left, minimize, maximize and close on the right. | +| `'terminal'` | A 34 px terminal bar: traffic lights and a tab with a prompt icon and the title in a monospace font. | `theme` (`'dark'` by default, or `'light'`) sets the title bar's colors. Pick the one that matches the app, so the bar reads as part of the window. +### The browser address bar + +`address` is the text in the `browser` style's address bar, `'{url}'` by default. `{url}` is the page +the shot visits, without `http://` or `https://`: the target url, resolved with the shot's `nav` when +that is a path or a URL. A shot reached by a click keeps the target url, so most apps will want their +own text, such as their public address: + +```js +frame: { + style: 'browser', + address: 'nightjar.app/{id}', +}, +``` + +`address` also takes `{name}`, `{title}`, `{id}` and `{lang}`. The style has no title text, so `title` +does not show. In cdp mode the page is not known when the frame is drawn, so an `address` with `{url}` +is an error there; write the address instead. A terminal app has no address, so tty mode refuses the +`browser` style and suggests `terminal` or `window`. + +### The terminal style + +`terminal` works for web apps and terminal apps. In tty mode its bar takes the terminal theme's +background and drops the line under the bar, so the bar and the screen read as one terminal window. In +url and cdp mode the bar is a dark (or light) grey. + `title` is the title bar text, `'{name}'` by default. It takes the tokens `{name}` (the config's `name`), `{title}` (the shot's title), `{id}` and `{lang}`, so `title: '{name}: {title}'` gives "My App: Settings". `title: false` hides it and keeps the bar. ## Backgrounds -`background` takes four forms: +`background` takes these forms: ```js background: '#1e1b4b', // any CSS color: a hex, a name, rgb(), hsl(), oklch(), color-mix() ... background: { type: 'solid', color: '#1e1b4b' }, background: { type: 'gradient', from: '#0f766e', to: '#1e1b4b', angle: 135 }, // angle defaults to 135 background: { type: 'transparent' }, +background: { type: 'mesh', colors: ['#0b1026', '#6d28d9', '#0e7490', '#1e3a8a', '#be185d'] }, +background: { type: 'dots', color: '#0d1224', dot: 'rgba(255,255,255,0.14)', spacing: 24 }, +background: { type: 'noise', from: '#2a1f6b', to: '#0b3b4c', angle: 135, amount: 0.2 }, ``` +- **mesh** takes two to five colors: the first is the base, and the others glow from the top left, top + right, bottom right and bottom left corners, in that order. +- **dots** draws a grid of small dots on `color`. `dot` is the dot color (a faint white by default: pick + a dark one on a light `color`) and `spacing` the distance between dots, 8 to 96 CSS pixels (default 24). +- **noise** is a gradient like `gradient`, with a film grain on top. `amount` (0 to 1, default 0.2) sets + how strong the grain is. The grain comes from a fixed seed, so it is the same on every run. + +The hero's `background` takes the same forms. + Colors are checked when the config loads: only color syntax is accepted, so `url()` and other CSS functions are rejected. A transparent background keeps the alpha channel in both WebP and PNG, so the shadow falls on whatever page the image sits on. -The default is the teal to indigo gradient above. +The default is the teal to indigo gradient above. For [clips](/showcase-kit/guides/clips/), a solid +background keeps the file small: gradients, meshes, dots and grain do not compress. ## Spacing, corners and shadow diff --git a/site/src/content/docs/guides/hero.mdx b/site/src/content/docs/guides/hero.mdx index 61ca1ab..48f8e0b 100644 --- a/site/src/content/docs/guides/hero.mdx +++ b/site/src/content/docs/guides/hero.mdx @@ -1,12 +1,13 @@ --- title: Hero banner -description: Render a banner for the top of a README, and a GitHub social preview, from your logo, name, tagline and up to three framed shots. +description: Render a banner for the top of a README, and a GitHub social preview, from your logo, name, tagline and framed shots, in one of six layouts. --- import { Aside } from '@astrojs/starlight/components'; -`showcase hero` composes a banner from the raw captures: your logo, name and tagline on the left, and -one to three framed shots stacked and tilted on the right. The default size, 1280x640, is GitHub's +`showcase hero` composes a banner from the raw captures: your logo, name and tagline, and framed shots +arranged by `layout`. The default, `stack`, puts the text on the left and one to three shots stacked and +tilted on the right. The default size, 1280x640, is GitHub's social preview size, so the same file works at the top of the README and as the repository's preview image. The `hero` block configures it; every key is optional. @@ -35,11 +36,11 @@ npx showcase hero # writes assets/showcase/hero.webp ## Composition - **Text.** The logo (fitted into a 96 px square at the default height), then `name`, then `tagline`, - vertically centered on the left. -- **Windows.** `shots` lists one to three shot ids, back to front, and defaults to the first three - shots. Each window is half the banner's width, tilted a few degrees, and framed with your `frame` - settings (style, theme, title, radius, shadow). The positions are fractions of the canvas, so a - different `size` keeps the composition. + vertically centered on the left. The `row` and `centered` layouts center it at the top instead. +- **Windows.** `shots` lists the shot ids to show, back to front, and defaults to as many of the first + shots as the layout shows. In `stack`, each window is half the banner's width and tilted a few + degrees. Every window is framed with your `frame` settings (style, theme, title, radius, shadow). The + positions are fractions of the canvas, so a different `size` keeps the composition. - **Background.** `background` takes the same forms as `frame.background` and defaults to it. - **Text color.** `theme` defaults to `frame.theme`: `'dark'` gives light text, `'light'` gives dark text. Match it to the background, not the app. @@ -47,6 +48,30 @@ npx showcase hero # writes assets/showcase/hero.webp The banner is rendered at twice its size and scaled down to exactly `size`, for sharper text and edges on the tilted windows. +## Layouts + +`layout` picks the composition. Every layout keeps `size`, `background`, `theme` and the frame look. + +| `layout` | Shots | What it looks like | +| --- | --- | --- | +| `'stack'` (default) | 1 to 3 | Text on the left, the windows stacked and tilted on the right. | +| `'spotlight'` | 1 | Text on the left, one large straight window running off the right and bottom edges. | +| `'split'` | 1 | Text on the left, one window turned in perspective towards it. | +| `'row'` | 1 to 4 | Text centered at the top, the windows side by side under it. | +| `'mosaic'` | 1 to 4 | Text on the left, a tilted wall of the windows (repeated to fill it), fading out towards the text. | +| `'centered'` | 1 | Text centered at the top, one window rising from the bottom edge and leaning back. | + +```js +hero: { + layout: 'spotlight', + shots: ['player'], +}, +``` + +A `shots` list longer than the layout shows is an error that names the layout, so switching from +`stack` to a one-window layout means picking the one shot to show. Without `shots`, each layout takes the +first shots of the config. + ## Logo, language and output - `logo` is a PNG, SVG, WebP or JPEG file, relative to the config's directory. A missing file or another diff --git a/site/src/content/docs/guides/readme-table.mdx b/site/src/content/docs/guides/readme-table.mdx index 3ddd1a0..10ffa4c 100644 --- a/site/src/content/docs/guides/readme-table.mdx +++ b/site/src/content/docs/guides/readme-table.mdx @@ -44,7 +44,8 @@ So a shot with a `title` and no `caption` gets its title as the caption, as `set | Option | Default | What it does | | --- | --- | --- | -| `--cols ` | 2 | Images per row, 1 to 6. Each cell is `100 / n` percent wide, rounded down. | +| `--layout ` | `table` | How the images are laid out: `table`, `rows`, `featured`, `details` or `list` (see [Layouts](#layouts)). | +| `--cols ` | 2 | Images per row, 1 to 6, in the `table` layout, and thumbnails per row in `featured`. Each cell is `100 / n` percent wide, rounded down. | | `--lang ` | the first of `langs` | Which language's images to list. It must be one of `langs`. | | `--base ` | the config's directory | The directory your README is in. Image paths are written relative to it. | | `--only ` | every shot and clip | Comma-separated shot and clip ids. The table keeps config order; an unknown id is an error. | @@ -65,10 +66,46 @@ run (`showcase frame`, or `showcase record` for a clip). The [CLI reference](/showcase-kit/reference/cli/#readme) has the command with every option. +## Layouts + +`--layout` picks another arrangement of the same images, captions and alt text. GitHub removes CSS and +most HTML attributes from a README, so every layout is built only from what it keeps: tables with +`width` and `align`, `

`, `

`, ``, `
`, `
` and ``. Each one was +checked through GitHub's own Markdown renderer. + +| `--layout` | What it prints | +| --- | --- | +| `table` (default) | The table above. | +| `rows` | One table row per image: the image in a 60% cell, and the title (as `

`) and caption in a 40% cell, alternating sides. | +| `featured` | The first image full width with its caption under it, then the others in a table of `--cols` per row. | +| `details` | One collapsible `
` per image, with the title and caption as its summary; the first starts open. | +| `list` | Every image full width, one under the other, each with its caption. | + +`--cols` only means something for `table` and `featured`; with another layout it is an error. + +```sh +npx showcase readme --layout featured --cols 3 +``` + +```html title="output" +

+ My App: Home +
The home screen. +

+ + + + + + + +
My App: Settings
Settings
+``` + ## Clips in the table In a terminal app config with [`clips`](/showcase-kit/guides/clips/), the clips follow the shots in the -same table: +same table (or in the same layout): - a clip with a WebP or GIF gets an `` of the first of those in its `formats`, which GitHub plays inline; From dcbeac868e2713422a5ea9e7f9249258599003b9 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 15:38:25 +0200 Subject: [PATCH 11/12] chore(changeset): add a minor changeset for the layout proposal --- .changeset/more-layouts.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/more-layouts.md diff --git a/.changeset/more-layouts.md b/.changeset/more-layouts.md new file mode 100644 index 0000000..4bce6f9 --- /dev/null +++ b/.changeset/more-layouts.md @@ -0,0 +1,10 @@ +--- +"@noctcore/showcase-kit": minor +--- + +More looks for the hero, the frames and the README snippet. Every new option defaults to the look you have today, so an existing config renders the same images byte for byte. + +- **Hero layouts**: `hero.layout` picks the composition: `stack` (the default, as before), `spotlight` (one large straight window running off the edges), `split` (one window in perspective), `row` (up to four windows under centered text), `mosaic` (a tilted wall of windows) and `centered` (text first, one window rising from the bottom). `hero.shots` takes as many shots as the layout shows and defaults to that many of the first shots. +- **Frame styles**: `frame.style` also takes `browser` (a toolbar with an address bar, whose text is the new `frame.address`, `'{url}'` by default), `windows` (a Windows title bar) and `terminal` (a terminal tab; in tty mode the bar takes the terminal background). tty mode refuses `browser`, and cdp mode needs an `address` without `{url}`. +- **Backgrounds**: `frame.background` and `hero.background` also take `{ type: 'mesh', colors }`, `{ type: 'dots', color, dot, spacing }` and `{ type: 'noise', from, to, angle, amount }`, a gradient with a film grain that is the same on every run. +- **README layouts**: `showcase readme --layout ` prints `table` (the default, as before), `rows`, `featured`, `details` or `list`, all built from HTML that GitHub keeps in a README. `--cols` applies to `table` and `featured`. The library's `readmeSnippet` takes the same `layout` option. From 7d6e31b2c07839b27d3c100df647078915797013 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 15:41:47 +0200 Subject: [PATCH 12/12] fix(readme): give each rows entry its own table so alternating cells keep their widths --- site/src/content/docs/guides/readme-table.mdx | 2 +- src/readme.ts | 15 ++++++--------- test/readme-layouts.test.ts | 6 +++++- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/site/src/content/docs/guides/readme-table.mdx b/site/src/content/docs/guides/readme-table.mdx index 10ffa4c..35c3e4c 100644 --- a/site/src/content/docs/guides/readme-table.mdx +++ b/site/src/content/docs/guides/readme-table.mdx @@ -76,7 +76,7 @@ checked through GitHub's own Markdown renderer. | `--layout` | What it prints | | --- | --- | | `table` (default) | The table above. | -| `rows` | One table row per image: the image in a 60% cell, and the title (as `

`) and caption in a 40% cell, alternating sides. | +| `rows` | One small table per image: the image in a 60% cell, and the title (as `

`) and caption in a 40% cell, alternating sides. A table each, because the columns of one table are shared and alternating cells would squeeze the images. | | `featured` | The first image full width with its caption under it, then the others in a table of `--cols` per row. | | `details` | One collapsible `
` per image, with the title and caption as its summary; the first starts open. | | `list` | Every image full width, one under the other, each with its caption. | diff --git a/src/readme.ts b/src/readme.ts index 666c9a0..54b2272 100644 --- a/src/readme.ts +++ b/src/readme.ts @@ -12,7 +12,7 @@ import { clipPath, selectClips, splitIds } from './record.js'; * How `showcase readme` lays out the images. Every layout uses only HTML that GitHub keeps in a README. * * - `table`: a table, a row of images and a row of `` captions under it, `cols` per row. - * - `rows`: one row per image, the image on one side and its title and caption on the other, alternating sides. + * - `rows`: one small table per image, the image on one side and its title and caption on the other, alternating sides. * - `featured`: the first image full width with its caption, then the others in a table, `cols` per row. * - `details`: one collapsible `
` per image, the caption as its summary; the first starts open. * - `list`: every image full width, one under the other, each with its caption. @@ -138,16 +138,13 @@ function table(cells: Cell[], cols: number): string[] { // `
`, ``, `

`, `

`, ``, `
`. Each layout is built from those. const LAYOUTS: Record string[]> = { table, - rows: cells => { - const lines = ['']; - cells.forEach((cell, index) => { + // One table per row: in a single table the columns are shared, so alternating 60% and 40% cells would fight. + rows: cells => + cells.flatMap((cell, index) => { const image = ` `; const text = ` `; - lines.push(' ', ...(index % 2 === 0 ? [image, text] : [text, image]), ' '); - }); - lines.push('
${cell.media}

${cell.title}

${cell.caption === cell.title ? '' : `

${cell.caption}

`}
'); - return lines; - }, + return ['', ' ', ...(index % 2 === 0 ? [image, text] : [text, image]), ' ', '
']; + }), featured: (cells, cols) => { const [first, ...rest] = cells; if (!first) return []; diff --git a/test/readme-layouts.test.ts b/test/readme-layouts.test.ts index 7fec09b..a539387 100644 --- a/test/readme-layouts.test.ts +++ b/test/readme-layouts.test.ts @@ -30,17 +30,21 @@ describe('readme layouts', () => { expect(readmeSnippet(config)).toMatch(/^\n {2}\n {4}
{ + it('rows: image and text side by side, alternating, one table each, the caption only when it adds to the title', () => { vi.spyOn(log, 'warn').mockImplementation(() => {}); expect(snippet('rows')).toBe(` +
Rumi: Queue

Queue

The queue & its details.

+ +

Log

Rumi: Log
+
Rumi: Video (MP4)

Video