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:
@@ -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
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user