fix: keep the thin strokes of the wordmark on the panel

The A of ITA had lost its diagonal: at 260px the hairlines of a Didone
fall below a pixel, downscaling averages them into grey, and the
panel's mid-grey cut then discarded them. What reached the shop window
was a detached serif and something closer to a lambda.

Letting more grey count as ink keeps them whole. 165 was not enough —
the diagonal came back dotted — and 195 holds it solid without
thickening the stems into mud. The number belongs to this particular
drawing, so `npm run brand:variants` renders the grid of widths and
thresholds it was chosen from, magnified, for the day the logo changes.

Width stays at 260: the header shares its line with the date, and the
main block is narrower now the week runs down the right-hand side.

The panel will redraw once when it next wakes, since the image and
therefore its content hash have changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
2026-09-21 23:24:53 +02:00
co-authored by Claude Opus 5
parent 13abcf3242
commit 61074d02f9
4 changed files with 111 additions and 2 deletions
+91
View File
@@ -0,0 +1,91 @@
/**
* Renders the wordmark at several widths and thresholds.
*
* npm run brand:variants
*
* The shop logo is a Didone: its thin strokes are a fraction of the thick
* ones. Downscaling averages a hairline into grey, and a threshold then has to
* decide whether that grey is ink. Too strict and the diagonal of the A
* disappears; too loose and the whole wordmark turns to mud. The only way to
* choose is to look at the result thresholded, magnified.
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { encodePng, toBitmap } from '../lib/screen/encode';
const WIDTHS = [260, 320, 400];
const THRESHOLDS = [127, 165, 195];
async function main(): Promise<void> {
const out = path.join(process.cwd(), '.preview', 'logo');
await mkdir(out, { recursive: true });
const source = await readFile(path.join(process.cwd(), 'public', 'brand', 'logo.png'));
const band = await findWordmarkBand(source);
const rows: string[] = [];
for (const width of WIDTHS) {
for (const threshold of THRESHOLDS) {
const raw = await sharp(source)
.extract(band)
.resize({ width })
.flatten({ background: '#ffffff' })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const bitmap = toBitmap(raw.data, raw.info.width, raw.info.height, { threshold });
const name = `w${width}-s${threshold}`;
await writeFile(path.join(out, `${name}.png`), encodePng(bitmap));
// Magnified to the same apparent size, so the variants compare fairly.
await sharp(path.join(out, `${name}.png`))
.resize({ width: 1040, kernel: 'nearest' })
.toFile(path.join(out, `${name}-zoom.png`));
rows.push(`${name} ${raw.info.width}x${raw.info.height}`);
}
}
console.log(rows.join('\n'));
}
async function findWordmarkBand(source: Buffer) {
const { data, info } = await sharp(source)
.flatten({ background: '#ffffff' })
.greyscale()
.raw()
.toBuffer({ resolveWithObject: true });
let top: number | null = null;
let bottom = 0;
for (let y = 0; y < info.height; y += 1) {
let ink = 0;
for (let x = 0; x < info.width; x += 1) {
if ((data[y * info.width + x] ?? 255) < 128) {
ink += 1;
}
}
if (ink > 0) {
if (top === null) {
top = y;
}
bottom = y;
} else if (top !== null) {
break;
}
}
const start = Math.max(0, (top ?? 0) - 2);
return { left: 0, top: start, width: info.width, height: bottom + 2 - start + 1 };
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});