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. 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); } } } 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..35c3e4c 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 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. | + +`--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; 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/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/config/resolve.ts b/src/config/resolve.ts index 6e77834..d3b6e83 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -14,6 +14,8 @@ import { WEB_ONLY_KEYS, } from './tty.js'; import type { + HeroLayout, + Mode, ResolvedBackground, ResolvedConfig, ResolvedFrame, @@ -49,6 +51,13 @@ 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; +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'] { if (!isObj(value)) { @@ -145,28 +154,78 @@ 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; } -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 }), @@ -269,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[]; @@ -284,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]; @@ -300,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, @@ -316,6 +391,8 @@ function resolveHero( }; } +const NUMBER_WORDS: Record = { 3: 'three', 4: 'four' }; + function slugify(name: string): string { return name .toLowerCase() @@ -421,7 +498,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 2a48b46..d4d285d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -305,15 +305,59 @@ 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'; +/** + * 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; @@ -327,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 }` @@ -425,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[]; /** @@ -530,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; @@ -627,13 +695,17 @@ 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 { style: FrameStyle; theme: 'light' | 'dark'; title: string | false; + address: string; background: ResolvedBackground; padding: number; radius: number; @@ -658,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/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 d57d388..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 { @@ -53,6 +79,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,15 +103,47 @@ 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`; +/** 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, @@ -77,6 +151,8 @@ export function windowMarkup({ scale: s, imageSrc, title, + address, + barColor, extraStyle = '', }: { frame: ResolvedFrame; @@ -84,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]; @@ -91,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 ` @@ -153,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/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. */ 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/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 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}`); diff --git a/src/readme.ts b/src/readme.ts index 1c82dac..54b2272 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 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. + */ +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,33 @@ 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, + // 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 = ` ${cell.media}`; + const text = `

${cell.title}

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

${cell.caption}

`}`; + return ['', ' ', ...(index % 2 === 0 ? [image, text] : [text, image]), ' ', '
']; + }), + 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}`, '

']), +}; 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/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; 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); + }); +}); diff --git a/test/readme-layouts.test.ts b/test/readme-layouts.test.ts new file mode 100644 index 0000000..a539387 --- /dev/null +++ b/test/readme-layouts.test.ts @@ -0,0 +1,112 @@ +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'); + }); +});