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="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
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,
@ -29,8 +35,6 @@ encrypted sync and signed updates; and the tooling to scaffold, sign, scan and r
## 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
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
```
<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:
@ -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 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
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:
@ -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);
`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/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 }); }