feat: tighten the wordmark's letter-spacing for the panel

The logotype is drawn with wide spacing — handsome on a website, and a
third of the panel's header width spent on nothing. The spacing is
artwork, not typesetting, so no property adjusts it; but every pixel
column between two letters is blank, and a blank column carries no
shape. Dropping most of them moves the letters together without altering
a single letterform.

At the same 260px the capitals are now a third taller, which the
hairlines needed more than the spacing did: the A of ITA reads at a
glance instead of being rescued by a threshold.

Done at full resolution, before the downscale. Compressing columns that
are already an average of ink and paper would smear exactly the strokes
this logo can least afford to lose.

The rendered size is now read from the PNG's own header rather than kept
as a constant beside it. The spacing is adjustable and every value
changes the height; a number copied into the renderer would have drifted
and squashed the logo with nothing failing to say so.

`npm run brand:variants` renders the spacings side by side, magnified,
since this is a decision to be looked at rather than reasoned about.

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:32:19 +02:00
co-authored by Claude Opus 5
parent 61074d02f9
commit 49f103c0a0
9 changed files with 266 additions and 42 deletions
+28 -2
View File
@@ -21,6 +21,7 @@ import path from 'node:path';
import sharp from 'sharp';
import { tighten } from '../lib/brand/tighten';
import { encodePng, toBitmap } from '../lib/screen/encode';
const LOGO_URL =
@@ -29,6 +30,16 @@ const LOGO_URL =
/** Wide enough to read from the pavement, short enough for a 76px header. */
const EINK_WIDTH = 260;
/**
* How many blank pixel columns may remain between two letters.
*
* The logotype is drawn with wide spacing, which reads well on a website and
* wastes a third of the width on a panel. Compressing the gaps lets the same
* 260px hold letters a third taller — which the hairlines need more than the
* spacing does. Rendered at several values by `npm run brand:variants`.
*/
const EINK_LETTER_GAP = 8;
/**
* Where grey becomes ink.
*
@@ -56,10 +67,25 @@ async function main(): Promise<void> {
await writeFile(path.join(directory, 'logo.png'), original);
const band = await findWordmarkBand(original);
const cropped = await sharp(original)
// Tighten at full resolution, before any downscale: compressing columns that
// are already an average of ink and paper would smear the hairlines.
const full = await sharp(original)
.extract({ left: 0, top: band.top, width: band.width, height: band.height })
.resize({ width: EINK_WIDTH })
.flatten({ background: '#ffffff' })
.greyscale()
.raw()
.toBuffer({ resolveWithObject: true });
const tightened = tighten(
{ data: full.data, width: full.info.width, height: full.info.height },
EINK_LETTER_GAP,
);
const cropped = await sharp(tightened.data, {
raw: { width: tightened.width, height: tightened.height, channels: 1 },
})
.resize({ width: EINK_WIDTH })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
+36 -20
View File
@@ -15,10 +15,11 @@ import path from 'node:path';
import sharp from 'sharp';
import { tighten } from '../lib/brand/tighten';
import { encodePng, toBitmap } from '../lib/screen/encode';
const WIDTHS = [260, 320, 400];
const THRESHOLDS = [127, 165, 195];
/** How many blank pixel columns may remain between two letters. */
const GAPS = [40, 14, 8, 4];
async function main(): Promise<void> {
const out = path.join(process.cwd(), '.preview', 'logo');
@@ -27,29 +28,44 @@ async function main(): Promise<void> {
const source = await readFile(path.join(process.cwd(), 'public', 'brand', 'logo.png'));
const band = await findWordmarkBand(source);
// Full resolution, before any downscale: compressing averaged columns would
// smear the hairlines this logo can least afford to lose.
const full = await sharp(source)
.extract(band)
.flatten({ background: '#ffffff' })
.greyscale()
.raw()
.toBuffer({ resolveWithObject: true });
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 });
for (const gap of GAPS) {
const tightened = tighten(
{ data: full.data, width: full.info.width, height: full.info.height },
gap,
);
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));
const raw = await sharp(tightened.data, {
raw: { width: tightened.width, height: tightened.height, channels: 1 },
})
.resize({ width: 260 })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
// 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`));
const bitmap = toBitmap(raw.data, raw.info.width, raw.info.height, { threshold: 195 });
const name = `gap${String(gap).padStart(2, '0')}`;
await writeFile(path.join(out, `${name}.png`), encodePng(bitmap));
rows.push(`${name} ${raw.info.width}x${raw.info.height}`);
}
// 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`));
// Narrower source at the same final width means larger letters.
rows.push(
`${name} source ${tightened.width}px (-${full.info.width - tightened.width}) -> 260x${raw.info.height}`,
);
}
console.log(rows.join('\n'));