From 61074d02f90fb9746d95c0c179ca37561c0e1912 Mon Sep 17 00:00:00 2001 From: vl Date: Mon, 21 Sep 2026 23:24:53 +0200 Subject: [PATCH] fix: keep the thin strokes of the wordmark on the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd --- package.json | 3 +- public/brand/logo-eink.png | Bin 349 -> 393 bytes scripts/brand.ts | 19 +++++++- scripts/logo-variants.ts | 91 +++++++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 scripts/logo-variants.ts diff --git a/package.json b/package.json index cb763fd..479f74e 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "holidays:sync": "tsx scripts/sync-holidays.ts", "translate:test": "tsx scripts/test-translation.ts", "e2e:ui": "playwright test --ui", - "screen:fonts": "tsx scripts/typography-preview.ts" + "screen:fonts": "tsx scripts/typography-preview.ts", + "brand:variants": "tsx scripts/logo-variants.ts" }, "dependencies": { "@fontsource-variable/inter": "^5.3.0", diff --git a/public/brand/logo-eink.png b/public/brand/logo-eink.png index 7f56643c8f1dac747aa9e4b3f8982ba15a4c68da..a1294796823dca88a4b94e0e144e408daaad9109 100644 GIT binary patch delta 366 zcmV-!0g?XQ0*M2VB!5szL_t(|+NIR7PQx$|2H>+YWW#TOAH_Tt1L_4aF!D&X#K71` zAWR(_i2cKt_H_V@v)jd?lDlOfR4xjc+cfXEdr6O z)vp+6XaRai;$>XfSp-r91oI_A1Ij&$Xhz{I!kuyq&jV-Cx_>7FL^TOUxK{(CXK%C6 zZdZ$)@(c%WvM`Rd{9zG3s=(+FbQad}+e$>nGeSFy190hz_)S6~Hd*i}_*#JDEFwET z0T)-qIV#xL%-=woJ3Bjzv@sFtrU(tN6#bU?>CIZuLJV8tlwSWSyarr*LmYg&7K^<& z+7h2qxfZK4-Dk&Ued^6ps7k0x*jk_#(lJX>dMwu%REg53ljtf@dR;OE>1|$$l9wdB zorN7m&Rj2tAhGQ=D7TP|0`ZhvmxRv3#UfEg^T%AMIeR77m<>YybcN M07*qoM6N<$f=e)_r~m)} delta 322 zcmV-I0lof-1Kk3UB!47HL_t(|+P%~yfIi|JFRffTJKfl*xMfFhc%+3v4`I>=N(yMOy<_91MW#Fti#`iuCUJ3NO3@ z?BS>ZoJL^~Dk*?y-rz;2fRlSlq8fxF1SMObBTp<|+_MJaTz?qp0gukGP>#|bawbT2 zgcd=w(2g!E0T~Fd2#SSaVYB##jD!ZUWpF07*qoM6N<$f { const directory = path.join(process.cwd(), 'public', 'brand'); await mkdir(directory, { recursive: true }); @@ -52,7 +67,9 @@ async function main(): Promise { // Reuse the panel's own encoder, so the logo is thresholded by exactly the // rule the rest of the screen is. A line drawing gets no dithering: it would // only fuzz the serifs. - const bitmap = toBitmap(cropped.data, cropped.info.width, cropped.info.height); + const bitmap = toBitmap(cropped.data, cropped.info.width, cropped.info.height, { + threshold: EINK_THRESHOLD, + }); await writeFile(path.join(directory, 'logo-eink.png'), encodePng(bitmap)); console.log(`logo.png ${original.length} bytes (original)`); diff --git a/scripts/logo-variants.ts b/scripts/logo-variants.ts new file mode 100644 index 0000000..a45d8b8 --- /dev/null +++ b/scripts/logo-variants.ts @@ -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 { + 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; +});