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
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { columnHasInk, keptColumns, tighten, type Greyscale } from './tighten';
/** Builds an image from a picture: '#' is ink, '.' is paper. */
function image(row: string, height = 2): Greyscale {
const width = row.length;
const data = Buffer.alloc(width * height, 255);
for (let y = 0; y < height; y += 1) {
[...row].forEach((cell, x) => {
data[y * width + x] = cell === '#' ? 0 : 255;
});
}
return { data, width, height };
}
describe('columnHasInk', () => {
it('finds ink anywhere down the column', () => {
const grid: Greyscale = { data: Buffer.from([255, 255, 0, 255]), width: 2, height: 2 };
expect(columnHasInk(grid, 0)).toBe(true);
expect(columnHasInk(grid, 1)).toBe(false);
});
});
describe('keptColumns', () => {
it('shortens a gap to the allowance', () => {
expect(keptColumns(image('#.....#'), 2)).toEqual([0, 1, 2, 6]);
});
it('leaves a gap already within the allowance alone', () => {
expect(keptColumns(image('#.#'), 2)).toEqual([0, 1, 2]);
});
it('drops the margins, which are not spacing', () => {
expect(keptColumns(image('..#.#..'), 2)).toEqual([2, 3, 4]);
});
it('handles several gaps independently', () => {
expect(keptColumns(image('#....#....#'), 1)).toEqual([0, 1, 5, 6, 10]);
});
it('returns nothing for a blank image', () => {
expect(keptColumns(image('.....'), 2)).toEqual([]);
});
it('keeps solid ink untouched', () => {
expect(keptColumns(image('####'), 0)).toEqual([0, 1, 2, 3]);
});
});
describe('tighten', () => {
it('narrows the image by the width it removed', () => {
const result = tighten(image('#.....#'), 2);
expect(result.width).toBe(4);
expect(result.height).toBe(2);
});
it('preserves the letterforms, only the space between them', () => {
const result = tighten(image('##....##'), 1);
const row = Array.from({ length: result.width }, (_, x) => (result.data[x] === 0 ? '#' : '.'));
expect(row.join('')).toBe('##.##');
});
it('copies every row, not just the first', () => {
const source = image('#..#', 3);
const result = tighten(source, 1);
for (let y = 0; y < 3; y += 1) {
expect(result.data[y * result.width]).toBe(0);
}
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Tightening the letter-spacing of a wordmark, by image rather than by text.
*
* The logo arrives as artwork: its spacing is drawn in, not typeset, so no
* property can adjust it. But between two letters every pixel column is blank,
* and a blank column carries no shape — dropping some of them moves the
* letters closer without altering a single letterform.
*
* Working on the full-resolution source matters: doing this after the
* downscale would compress columns that are already an average of ink and
* paper, and smear the hairlines this logo can least afford to lose.
*/
export type Greyscale = {
data: Uint8Array | Buffer;
width: number;
height: number;
};
/** Below this a pixel counts as ink; above it, as paper. */
const INK = 128;
export function columnHasInk({ data, width, height }: Greyscale, x: number): boolean {
for (let y = 0; y < height; y += 1) {
if ((data[y * width + x] ?? 255) < INK) {
return true;
}
}
return false;
}
/**
* The columns to keep so that no run of blank ones is longer than `maxGap`.
*
* Blank columns at the very edges are dropped entirely: they are margin, not
* spacing, and the caller sets its own.
*/
export function keptColumns(image: Greyscale, maxGap: number): number[] {
const ink: boolean[] = [];
for (let x = 0; x < image.width; x += 1) {
ink.push(columnHasInk(image, x));
}
const first = ink.indexOf(true);
const last = ink.lastIndexOf(true);
if (first === -1) {
return [];
}
const kept: number[] = [];
let gap = 0;
for (let x = first; x <= last; x += 1) {
if (ink[x]) {
gap = 0;
kept.push(x);
continue;
}
gap += 1;
if (gap <= maxGap) {
kept.push(x);
}
}
return kept;
}
/** Rebuilds the image from the kept columns. */
export function tighten(image: Greyscale, maxGap: number): Greyscale {
const columns = keptColumns(image, maxGap);
const width = columns.length;
const data = Buffer.alloc(width * image.height, 255);
for (let y = 0; y < image.height; y += 1) {
for (let index = 0; index < width; index += 1) {
data[y * width + index] = image.data[y * image.width + (columns[index] ?? 0)] ?? 255;
}
}
return { data, width, height: image.height };
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { encodePng, toBitmap } from './encode';
import { readPngSize } from './logo';
describe('readPngSize', () => {
it('reads the dimensions a PNG declares', () => {
// Built rather than fixtured, so the test cannot drift from the encoder.
const rgba = Buffer.alloc(17 * 5 * 4, 255);
const png = encodePng(toBitmap(rgba, 17, 5));
expect(readPngSize(png)).toEqual({ width: 17, height: 5 });
});
});
+24 -12
View File
@@ -4,29 +4,41 @@ import path from 'node:path';
/**
* The 1-bit wordmark, inlined as a data URI.
*
* It is read from the committed `public/brand/logo-eink.png` and embedded in
* the SVG rather than linked: the renderer must not depend on the network, and
* an image the rasteriser silently failed to fetch would leave a hole in the
* shop window. Regenerate the file with `npm run brand`.
* Read from the committed `public/brand/logo-eink.png` and embedded in the
* SVG rather than linked: the renderer must not depend on the network, and an
* image the rasteriser silently failed to fetch would leave a hole in the shop
* window. Regenerate the file with `npm run brand`.
*
* Its dimensions are read from the file rather than kept as constants beside
* it. The letter-spacing of the wordmark is adjustable, and every adjustment
* changes the height — a number copied here would drift and squash the logo
* without anything failing.
*/
/** Matches the committed asset; see scripts/brand.ts. */
export const LOGO_WIDTH = 260;
export const LOGO_HEIGHT = 36;
export type Logo = { dataUri: string; width: number; height: number };
let cached: string | null | undefined;
let cached: Logo | null | undefined;
export async function loadLogoDataUri(): Promise<string | null> {
export async function loadLogo(): Promise<Logo | null> {
if (cached !== undefined) {
return cached;
}
try {
const file = path.join(process.cwd(), 'public', 'brand', 'logo-eink.png');
cached = `data:image/png;base64,${(await readFile(file)).toString('base64')}`;
const bytes = await readFile(file);
cached = {
dataUri: `data:image/png;base64,${bytes.toString('base64')}`,
...readPngSize(bytes),
};
} catch {
// Not fatal: the header falls back to the shop name set in the settings,
// which is worse-looking but still a working screen.
// Not fatal: the header falls back to the shop name from the settings,
// which is plainer but still a working screen.
cached = null;
}
return cached;
}
/** A PNG states its size in the IHDR chunk, at a fixed offset. */
export function readPngSize(bytes: Buffer): { width: number; height: number } {
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
}
+6 -1
View File
@@ -124,9 +124,14 @@ describe('screenTree', () => {
});
it('shows the wordmark when one is available, and the shop name otherwise', () => {
const withLogo = JSON.stringify(screenTree(PAYLOAD, 'data:image/png;base64,AAAA'));
const withLogo = JSON.stringify(
screenTree(PAYLOAD, { dataUri: 'data:image/png;base64,AAAA', width: 260, height: 49 }),
);
expect(withLogo).toContain('"img"');
expect(withLogo).toContain('data:image/png;base64,AAAA');
// Drawn at the size the file declares, so tightening the letter-spacing
// cannot silently squash it.
expect(withLogo).toContain('"height":49');
// The fallback keeps the panel working if the asset ever goes missing.
const withoutLogo = JSON.stringify(screenTree(PAYLOAD, null));
+7 -7
View File
@@ -16,7 +16,7 @@ import type { ScreenPayload, ScreenWeekDay } from './contract';
import { loadScreenFonts, type SansFamily } from './fonts';
import { fitFontSize } from './fit';
import { h, type ElementNode } from './h';
import { LOGO_HEIGHT, LOGO_WIDTH, loadLogoDataUri } from './logo';
import { loadLogo, type Logo } from './logo';
import {
BANNER_HEIGHT,
BLACK,
@@ -35,7 +35,7 @@ import {
export function screenTree(
payload: ScreenPayload,
logo: string | null = null,
logo: Logo | null = null,
placement: WeekPlacement = 'right',
): ElementNode {
const root = {
@@ -151,7 +151,7 @@ function weekRow(day: ScreenWeekDay): ElementNode {
);
}
function header(payload: ScreenPayload, logo: string | null): ElementNode {
function header(payload: ScreenPayload, logo: Logo | null): ElementNode {
return h(
'div',
{
@@ -167,9 +167,9 @@ function header(payload: ScreenPayload, logo: string | null): ElementNode {
},
logo
? h('img', {
src: logo,
width: LOGO_WIDTH,
height: LOGO_HEIGHT,
src: logo.dataUri,
width: logo.width,
height: logo.height,
style: { display: 'flex' },
})
: h(
@@ -373,7 +373,7 @@ export async function renderScreenSvg(
): Promise<string> {
const [fonts, logo] = await Promise.all([
loadScreenFonts(options.family),
loadLogoDataUri(),
loadLogo(),
]);
return satori(screenTree(payload, logo, options.placement) as unknown as React.ReactNode, {
width: SCREEN_WIDTH,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 B

After

Width:  |  Height:  |  Size: 505 B

+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'));