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:
+2
-1
@@ -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",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 349 B After Width: | Height: | Size: 393 B |
+18
-1
@@ -29,6 +29,21 @@ const LOGO_URL =
|
||||
/** Wide enough to read from the pavement, short enough for a 76px header. */
|
||||
const EINK_WIDTH = 260;
|
||||
|
||||
/**
|
||||
* Where grey becomes ink.
|
||||
*
|
||||
* The wordmark is a Didone: its hairlines are a fraction of its stems, and at
|
||||
* 260px they land below one pixel. Downscaling averages them into grey, and
|
||||
* the panel's default mid-grey cut then threw them away — the diagonal of the
|
||||
* A vanished entirely, leaving a detached serif and something closer to a
|
||||
* lambda. Letting more grey count as ink keeps the thin strokes whole.
|
||||
*
|
||||
* 165 was still not enough: the diagonal came back dotted. 195 holds it solid
|
||||
* without thickening the stems into mud. Re-check with `npm run brand:variants`
|
||||
* if the logo is ever replaced — this number belongs to this drawing.
|
||||
*/
|
||||
const EINK_THRESHOLD = 195;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const directory = path.join(process.cwd(), 'public', 'brand');
|
||||
await mkdir(directory, { recursive: true });
|
||||
@@ -52,7 +67,9 @@ async function main(): Promise<void> {
|
||||
// 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)`);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user