README: real terminal shots (tools/readme-term-shots.mjs) instead of hand-drawn SVGs, a badge row

The architecture and create-terminal SVGs were drawn by hand and their text overflowed. The quick
start now shows the real output of npm create waybetter, rendered the way storebound renders its
terminal shots; the layers stay a table.
This commit is contained in:
CRTkafa 2026-10-15 04:27:29 +08:00
parent 612322a45e
commit 8d45e3b7b3
8 changed files with 135 additions and 159 deletions

View file

@ -13,6 +13,12 @@ GPL-3.0-or-later · <code>npm create waybetter</code></p>
<a href="docs/STRING-PACKS.md">String packs</a> · <a href="docs/STRING-PACKS.md">String packs</a> ·
<a href="https://git.crt.fyi/CRT/waybetter-for-x">wayBetter for X</a></p> <a href="https://git.crt.fyi/CRT/waybetter-for-x">wayBetter for X</a></p>
<p align="center"><a href="https://www.npmjs.com/package/waybetter"><img src="https://img.shields.io/npm/v/waybetter?label=npm&color=cb3837" alt="npm"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-GPL--3.0--or--later-blue" alt="license: GPL-3.0-or-later"></a>
<a href="packages/waybetter/package.json"><img src="https://img.shields.io/badge/runtime%20dependencies-0-brightgreen" alt="runtime dependencies: 0"></a>
<img src="https://img.shields.io/badge/Tauri-2-24c8db" alt="Tauri 2">
<img src="https://img.shields.io/badge/platforms-macOS%20%7C%20Windows%20%7C%20Linux-informational" alt="platforms: macOS, Windows, Linux"></p>
You bring a site and a small adapter that says what the site is. wayBetter gives you the rest: a You bring a site and a small adapter that says what the site is. wayBetter gives you the rest: a
Tauri 2 window with tabs, split panes and every account signed in side by side; a page runtime with Tauri 2 window with tabs, split panes and every account signed in side by side; a page runtime with
a plugin API, a theme engine and string packs; a signed Store with a safe install screen; backups, a plugin API, a theme engine and string packs; a signed Store with a safe install screen; backups,
@ -29,8 +35,6 @@ encrypted sync and signed updates; and the tooling to scaffold, sign, scan and r
## How it fits together ## How it fits together
<p align="center"><img src="assets/readme/architecture.svg" width="880" alt="Architecture: a Tauri 2 window with a trusted shell webview and untrusted site-tab webviews running the page runtime, the Rust host underneath that re-checks every command, the app's adapter on the side and the waybetter tooling"></p>
A wayBetter app is one Tauri 2 window with several webviews. The framework has one piece for each A wayBetter app is one Tauri 2 window with several webviews. The framework has one piece for each
layer and does not need to know which site you wrap. layer and does not need to know which site you wrap.
@ -48,7 +52,7 @@ Details of every module are in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
npm create waybetter app kickapp -- --site kick.com --name "wayBetter for Kick" --yes npm create waybetter app kickapp -- --site kick.com --name "wayBetter for Kick" --yes
``` ```
<p align="center"><img src="assets/readme/create-terminal.svg" width="820" alt="Terminal output: Created a wayBetter app in kickapp (32 files), then the next steps: cd kickapp, add the framework as a git submodule, npm install, npm run build, npm run tauri dev"></p> ![npm create waybetter app: Created a wayBetter app in kickapp (32 files), then the next steps: cd kickapp, add the framework as a git submodule, npm install, npm run build, npm run tauri dev](assets/readme/term/create-app.webp)
The same tool starts a plugin or a theme: The same tool starts a plugin or a theme:
@ -57,6 +61,8 @@ npm create waybetter plugin -- --name "Hide Trends" # add --ts for Ty
npm create waybetter theme -- --name "Midnight Mint" --accent "#3ddc97" npm create waybetter theme -- --name "Midnight Mint" --accent "#3ddc97"
``` ```
![npm create waybetter plugin: a Hide Trends plugin file and how to install it](assets/readme/term/create-plugin.webp)
The app scaffold adds the framework as a git submodule at `waybetter/` by default (`--host-source The app scaffold adds the framework as a git submodule at `waybetter/` by default (`--host-source
path` and `--host-source git` are the alternatives). After `npm run tauri dev`, replace the path` and `--host-source git` are the alternatives). After `npm run tauri dev`, replace the
placeholder selectors in `src/site.ts` and `src-tauri/src/site.rs`. All flags: placeholder selectors in `src/site.ts` and `src-tauri/src/site.rs`. All flags:
@ -241,6 +247,9 @@ bun run test:create # scaffolds each kind into temp folders and checks them a
`test:create` also runs `cargo check` on a generated app (about a minute cold); `test:create` also runs `cargo check` on a generated app (about a minute cold);
`WAYBETTER_SKIP_CARGO_CHECK=1` skips that step. Requires Bun 1.4.2+, Node 24+ and Rust 1.88+. `WAYBETTER_SKIP_CARGO_CHECK=1` skips that step. Requires Bun 1.4.2+, Node 24+ and Rust 1.88+.
The README's terminal images are real output: `node tools/readme-term-shots.mjs` runs the commands and
redraws `assets/readme/term/` (Google Chrome and cwebp).
## Docs ## Docs
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): every module of the host, runtime and shell - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): every module of the host, runtime and shell

View file

@ -1,127 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 760" width="1200" height="760" role="img" aria-label="wayBetter architecture: one Tauri 2 window with a trusted shell webview and one webview per site tab, a Rust host underneath, your adapter on the side, and the waybetter command for store, release and build tooling">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0b1020"/>
<stop offset="1" stop-color="#06070c"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0" r="0.7">
<stop offset="0" stop-color="#1d3a8a" stop-opacity="0.55"/>
<stop offset="1" stop-color="#1d3a8a" stop-opacity="0"/>
</radialGradient>
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#3b82f6"/>
<stop offset="0.55" stop-color="#22d3ee"/>
<stop offset="1" stop-color="#a855f7"/>
</linearGradient>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0L10 5L0 10z" fill="#64748b"/>
</marker>
<style>
.t{font-family:ui-sans-serif,"Segoe UI",system-ui,-apple-system,Helvetica,Arial,sans-serif;fill:#e6e9f2}
.m{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace}
.h{font-size:22px;font-weight:700;letter-spacing:-0.01em}
.k{font-size:12px;font-weight:600;letter-spacing:0.14em;fill:#7dd3fc}
.s{font-size:14px;fill:#a3acc2}
.c{font-size:13px;fill:#cbd5e1}
.box{fill:#0f1626;stroke:#24304a;stroke-width:1.2}
.trust{fill:#101b33;stroke:#3b82f6;stroke-width:1.4}
.site{fill:#140f26;stroke:#7c3aed;stroke-width:1.4}
.chip{fill:#17213a;stroke:#2b3a5c}
.ln{stroke:#64748b;stroke-width:1.4;fill:none}
.dash{stroke-dasharray:5 5}
</style>
</defs>
<rect width="1200" height="760" rx="18" fill="url(#bg)"/>
<rect width="1200" height="380" rx="18" fill="url(#glow)"/>
<!-- title -->
<text class="t k" x="48" y="54">— ARCHITECTURE</text>
<text class="t" x="48" y="92" font-size="32" font-weight="760" letter-spacing="-0.02em">One window, three layers, one small adapter.</text>
<rect x="48" y="104" width="560" height="3" rx="1.5" fill="url(#accent)"/>
<!-- the site -->
<rect x="48" y="138" width="760" height="64" rx="12" fill="none" stroke="#334155" stroke-dasharray="6 6"/>
<text class="t h" x="72" y="170" font-size="18">The website you already use</text>
<text class="t s" x="72" y="190">loaded live from its exact https origin, never copied: x.com, kick.com, …</text>
<!-- window -->
<rect x="48" y="226" width="760" height="300" rx="16" class="box"/>
<text class="t k" x="72" y="256">TAURI 2 WINDOW · SYSTEM WEBVIEW</text>
<!-- shell -->
<rect x="72" y="272" width="300" height="234" rx="12" class="trust"/>
<text class="t h" x="92" y="304">Shell</text>
<text class="t m s" x="92" y="326" font-size="12">packages/shell · local, trusted</text>
<g class="t c">
<text x="92" y="356">Tabs, split panes, address bar</text>
<text x="92" y="380">Command palette, accounts, decks</text>
<text x="92" y="404">Settings panel, theme builder</text>
<text x="92" y="428">Store and the install screen</text>
<text x="92" y="452">Workspaces, backup, updates</text>
</g>
<text class="t s" x="92" y="488" font-size="12">the only webview allowed to change anything</text>
<!-- tabs -->
<rect x="392" y="272" width="396" height="234" rx="12" class="site"/>
<text class="t h" x="412" y="304">Site tabs</text>
<text class="t m s" x="412" y="326" font-size="12">one webview + profile per tab · packages/runtime</text>
<g>
<rect x="412" y="342" width="110" height="30" rx="8" class="chip"/><text class="t c" x="425" y="362">Plugin API</text>
<rect x="530" y="342" width="106" height="30" rx="8" class="chip"/><text class="t c" x="543" y="362">Theme engine</text>
<rect x="644" y="342" width="124" height="30" rx="8" class="chip"/><text class="t c" x="657" y="362">String packs</text>
<rect x="412" y="380" width="110" height="30" rx="8" class="chip"/><text class="t c" x="425" y="400">DOM hub</text>
<rect x="530" y="380" width="106" height="30" rx="8" class="chip"/><text class="t c" x="543" y="400">Fetch / XHR hub</text>
<rect x="644" y="380" width="124" height="30" rx="8" class="chip"/><text class="t c" x="657" y="400">Link routing</text>
<rect x="412" y="418" width="110" height="30" rx="8" class="chip"/><text class="t c" x="425" y="438">Accounts</text>
<rect x="530" y="418" width="106" height="30" rx="8" class="chip"/><text class="t c" x="543" y="438">UI kit</text>
<rect x="644" y="418" width="124" height="30" rx="8" class="chip"/><text class="t c" x="657" y="438">Deck columns</text>
</g>
<text class="t s" x="412" y="488" font-size="12">untrusted: may only report about itself</text>
<!-- arrows site -> tabs -->
<path class="ln" d="M590 202 V268" marker-end="url(#arr)"/>
<!-- host -->
<rect x="48" y="550" width="760" height="166" rx="16" class="box"/>
<text class="t k" x="72" y="580">NATIVE HOST · RUST</text>
<text class="t h" x="72" y="612">waybetter-host</text>
<text class="t m s" x="258" y="612" font-size="12">crates/waybetter-host</text>
<g class="t c">
<text x="72" y="644">Every command re-checks its caller (webview label, exact origin)</text>
<text x="72" y="668">Profiles, settings, plugins, themes, fonts, workspaces</text>
<text x="72" y="692">Signed Store (Ed25519), backup, encrypted sync, signed updates</text>
</g>
<path class="ln" d="M222 506 V546" marker-end="url(#arr)" marker-start="url(#arr)"/>
<path class="ln dash" d="M590 506 V546" marker-end="url(#arr)"/>
<text class="t s" x="236" y="532" font-size="12">all commands</text>
<text class="t s" x="604" y="532" font-size="12">a short, reviewed list</text>
<!-- adapter -->
<rect x="836" y="138" width="316" height="300" rx="16" fill="#0f1a1a" stroke="#14b8a6" stroke-width="1.4"/>
<text class="t k" x="860" y="168" style="fill:#5eead4">YOUR APP</text>
<text class="t h" x="860" y="200">The adapter</text>
<text class="t s" x="860" y="222">what only your site knows</text>
<rect x="860" y="242" width="268" height="52" rx="10" class="chip"/>
<text class="t m c" x="876" y="264">site.rs · SiteSpec</text>
<text class="t s" x="876" y="283" font-size="12">hosts, account rules</text>
<rect x="860" y="304" width="268" height="52" rx="10" class="chip"/>
<text class="t m c" x="876" y="326">site.ts · SiteAdapter</text>
<text class="t s" x="876" y="345" font-size="12">selectors, API marks, links</text>
<rect x="860" y="366" width="268" height="52" rx="10" class="chip"/>
<text class="t m c" x="876" y="388">capabilities/*.json</text>
<text class="t s" x="876" y="407" font-size="12">who may call what</text>
<path class="ln dash" d="M836 290 H812" marker-end="url(#arr)"/>
<!-- tooling -->
<rect x="836" y="462" width="316" height="254" rx="16" fill="#151022" stroke="#a855f7" stroke-width="1.4"/>
<text class="t k" x="860" y="492" style="fill:#d8b4fe">NPM · waybetter</text>
<text class="t h" x="860" y="524">The tooling</text>
<g class="t m c">
<text x="860" y="558">npm create waybetter</text>
<text x="860" y="584">waybetter dev --watch</text>
<text x="860" y="610">waybetter store build|sign|verify</text>
<text x="860" y="636">waybetter strings check</text>
<text x="860" y="662">waybetter release</text>
<text x="860" y="688">waybetter build-env</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.4 KiB

View file

@ -1,29 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 400" width="1000" height="400" role="img" aria-label="Terminal: npm create waybetter app kickapp creates a wayBetter app in kickapp (32 files) and prints the next steps">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0d1222"/>
<stop offset="1" stop-color="#080a12"/>
</linearGradient>
<style>
.m{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:15px;fill:#cbd5e1}
.p{fill:#22c55e}.d{fill:#7c8aa5}.o{fill:#e6e9f2;font-weight:600}.a{fill:#7dd3fc}
</style>
</defs>
<rect width="1000" height="400" rx="14" fill="url(#bg)" stroke="#24304a"/>
<rect width="1000" height="36" rx="14" fill="#121a2e"/>
<rect y="22" width="1000" height="14" fill="#121a2e"/>
<circle cx="22" cy="18" r="6" fill="#ef4444" opacity=".8"/><circle cx="42" cy="18" r="6" fill="#f59e0b" opacity=".8"/><circle cx="62" cy="18" r="6" fill="#22c55e" opacity=".8"/>
<text class="m d" x="500" y="23" text-anchor="middle" font-size="13">npm create waybetter</text>
<g class="m">
<text x="28" y="76"><tspan class="p">$</tspan> npm create waybetter app kickapp -- --site kick.com --name "wayBetter for Kick" --yes</text>
<text x="28" y="120" class="o">Created a wayBetter app in kickapp (32 files).</text>
<text x="28" y="160">Next:</text>
<text x="52" y="186"><tspan class="a">cd kickapp</tspan></text>
<text x="52" y="210"><tspan class="a">git init &amp;&amp; git submodule add https://git.crt.fyi/CRT/waybetter.git waybetter</tspan></text>
<text x="52" y="234"><tspan class="a">npm install</tspan></text>
<text x="52" y="258"><tspan class="a">npm run build</tspan></text>
<text x="52" y="282"><tspan class="a">npm run tauri dev</tspan></text>
<text x="52" y="306" class="d">then replace the PLACEHOLDER selectors in src/site.ts and src-tauri/src/site.rs</text>
<text x="28" y="356"><tspan class="p">$</tspan> <tspan class="d">▍</tspan></text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,11 @@
$ npm create waybetter app kickapp -- --site kick.com --name "wayBetter for Kick" --yes
Created a wayBetter app in kickapp (32 files).
Next:
cd kickapp
git init && git submodule add https://git.crt.fyi/CRT/waybetter.git waybetter
npm install
npm run build
npm run tauri dev
then replace the PLACEHOLDER selectors in src/site.ts and src-tauri/src/site.rs

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -0,0 +1,7 @@
$ npm create waybetter plugin -- --name "Hide Trends"
Created a wayBetter plugin in hide-trends (2 files).
Next:
cd hide-trends
copy hide-trends.plugin.js into wayBetter → Plugins → Open folder, then Rescan

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

105
tools/readme-term-shots.mjs Normal file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env node
// Terminal shots for the README: runs real `create-waybetter` commands in a throwaway folder and saves, per shot,
// assets/readme/term/<name>.txt the cleaned output (first line "$ <command>", no ANSI, temp paths made short)
// assets/readme/term/<name>.webp that text drawn as a terminal (dark), rendered by headless Google Chrome and
// encoded with cwebp (the smaller of lossy q80 and lossless)
// Fails when a command exits non-zero, its output lacks its expected marker, or Chrome / cwebp hang.
// node tools/readme-term-shots.mjs all shots
// node tools/readme-term-shots.mjs --text only the .txt files (no browser)
// node tools/readme-term-shots.mjs app only these shots
// Needs Google Chrome (or CHROME=<path>) and cwebp on PATH for the images.
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const CLI = join(ROOT, 'packages', 'create-waybetter', 'bin', 'create-waybetter.js');
const OUT = join(ROOT, 'assets', 'readme', 'term');
const CHROME = process.env.CHROME || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const TIMEOUT_MS = 60_000;
const log = m => console.log(`${new Date().toISOString().slice(11, 19)} [term-shots] ${m}`);
const SHOTS = [
{ name: 'create-app', title: 'npm create waybetter', marker: 'Created a wayBetter app',
shown: 'npm create waybetter app kickapp -- --site kick.com --name "wayBetter for Kick" --yes',
argv: ['app', 'kickapp', '--site', 'kick.com', '--name', 'wayBetter for Kick', '--yes'] },
{ name: 'create-plugin', title: 'npm create waybetter', marker: 'hide-trends',
shown: 'npm create waybetter plugin -- --name "Hide Trends"',
argv: ['plugin', '--name', 'Hide Trends'] },
];
const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
function lineHtml(l) {
if (l.startsWith('$ ')) return `<span class="p">$</span> <span class="cmd">${esc(l.slice(2))}</span>`;
let h = esc(l);
h = h.replace(/\b(error|ERROR|failed)\b/g, m => `<span class="bad">${m}</span>`);
h = h.replace(/^(\s*)(Created|Wrote)\b/, (m, a, b) => `${a}<span class="ok">${b}</span>`);
h = h.replace(/^(\s{2,})(cd |npm |git |then )/, (m, a, b) => `${a}<span class="cmd">${b}</span>`);
return h;
}
const page = (title, lines) => `<!doctype html><html><head><meta charset="utf-8"><style>
:root { --code-bg: #060a14; --code-ink: #e2e8f0; --code-muted: #64748b; --radius: 14px;
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 12px 32px -16px rgba(0, 0, 0, .7);
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; }
html, body { margin: 0; background: #0b1120; }
body { padding: 24px; width: 960px; box-sizing: content-box; }
.term { background: var(--code-bg); color: var(--code-ink); border-radius: var(--radius); box-shadow: var(--shadow); border: 1px solid color-mix(in srgb, var(--code-ink) 10%, transparent); overflow: hidden; }
.term-bar { display: flex; align-items: center; gap: 6px; padding: 10px 14px; border-bottom: 1px solid color-mix(in srgb, var(--code-ink) 10%, transparent); }
.term-bar span { width: 10px; height: 10px; border-radius: 50%; background: #334155; }
.term-bar em { margin-left: 8px; font: 12px var(--mono); color: var(--code-muted); font-style: normal; }
.term pre { margin: 0; padding: 16px 18px 20px; color: var(--code-ink); font: 13.5px/1.75 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
.p { color: #818cf8; } .cmd { color: #a5b4fc; } .c { color: var(--code-muted); }
.bad { color: #fca5a5; font-weight: 600; } .ok { color: #86efac; }
</style></head><body><div class="term"><div class="term-bar"><span></span><span></span><span></span><em>${esc(title)}</em></div><pre><code>${lines.map(lineHtml).join('\n')}</code></pre></div></body></html>`;
function run(cmd, args, opts = {}) {
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: TIMEOUT_MS, ...opts });
if (r.error) throw new Error(`${cmd}: ${r.error.message}`);
return r;
}
function shoot(shot, work) {
const r = run(process.execPath, [CLI, ...shot.argv], { cwd: work, input: '', env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' } });
const out = `${r.stdout}${r.stderr}`.replace(/\x1b\[[0-9;]*m/g, '').replaceAll(work + '/', '').replaceAll(work, '.').trimEnd();
if (r.status !== 0) throw new Error(`${shot.name}: exit ${r.status}\n${out}`);
if (!out.includes(shot.marker)) throw new Error(`${shot.name}: output lacks "${shot.marker}"\n${out}`);
return [`$ ${shot.shown}`, ...out.split('\n')];
}
function render(shot, lines) {
const tmp = mkdtempSync(join(tmpdir(), 'wb-term-'));
try {
const html = join(tmp, 'page.html'), png = join(tmp, 'shot.png');
writeFileSync(html, page(shot.title, lines));
// Height: bar + padding + lines; a little spare, the page background is the frame.
const height = Math.ceil(24 * 2 + 38 + 36 + lines.length * 13.5 * 1.75 + 8);
run(CHROME, ['--headless=new', '--disable-gpu', '--hide-scrollbars', '--force-device-scale-factor=2', `--window-size=1008,${height}`, `--screenshot=${png}`, `file://${html}`]);
if (!existsSync(png)) throw new Error(`${shot.name}: Chrome wrote no screenshot`);
const best = ['q80', 'lossless'].map(mode => {
const file = join(tmp, `${mode}.webp`);
const args = mode === 'q80' ? ['-q', '80', '-m', '6'] : ['-lossless', '-z', '9'];
const c = run('cwebp', ['-quiet', ...args, '-resize', '1600', '0', png, '-o', file]);
if (c.status !== 0) throw new Error(`cwebp ${mode}: ${c.stderr}`);
return { file, bytes: statSync(file).size };
}).reduce((a, b) => (b.bytes < a.bytes ? b : a));
writeFileSync(join(OUT, `${shot.name}.webp`), readFileSync(best.file));
return best.bytes;
} finally { rmSync(tmp, { recursive: true, force: true }); }
}
const args = process.argv.slice(2);
const textOnly = args.includes('--text');
const only = args.filter(a => !a.startsWith('--'));
const shots = only.length ? SHOTS.filter(s => only.includes(s.name)) : SHOTS;
if (only.length && shots.length !== only.length) throw new Error(`unknown shot(s); known: ${SHOTS.map(s => s.name).join(', ')}`);
mkdirSync(OUT, { recursive: true });
const work = mkdtempSync(join(tmpdir(), 'wb-readme-'));
try {
for (const shot of shots) {
const lines = shoot(shot, work);
writeFileSync(join(OUT, `${shot.name}.txt`), lines.join('\n') + '\n');
log(`${shot.name}: ${lines.length} lines${textOnly ? '' : `, ${Math.round(render(shot, lines) / 1024)} KB webp`}`);
}
} finally { rmSync(work, { recursive: true, force: true }); }