feat: move the week down the right-hand side of the panel

A bottom strip gave each day a 114px column, and that width was what
capped the type size. A side band gives 142px of usable width and a 68px
row, so the hours go from 19pt to 22pt, the day labels from 20 to 23,
and each day fits on one line — "Mar  11:00–18:00" reads at a glance
where a column had to be read downwards.

Right rather than left, for two reasons the renders make plain: on the
left the logo is pushed into the middle and loses its place as a
signature, and the eye meets the week before the day's status. OUVERT is
what has to be read first from the pavement.

The times stay whole. Only the spaces around the dash go, which is
enough to hold 22pt without abbreviating anything: a shop's opening hour
is not a detail to shorten.

The idea came from the shop owner, and testing it surfaced a defect that
had been in production all along. Rendering the worst case the data
model allows — three periods in a day, none on a round hour — showed the
day's hours wrapping, pushing the layout down, and the English line of
the banner falling off the bottom edge. A panel does not scroll and does
not reflow: what does not fit is lost.

Type size is now fitted to the space available, with a floor below which
it will not go, and nothing may leave its box. The width estimate is
deliberately crude: measuring glyphs would mean loading the font in that
module and would still be an estimate, since the renderer applies its
own kerning. Erring small costs a point of type, erring large costs a
broken screen.

Three periods a day will reportedly never happen. The protection stays
anyway — it costs nothing and removes the need to remember.

`npm run screen:fonts` renders every arrangement and both alternative
typefaces, including the worst case, because choosing type for a 1-bit
panel is done by looking at it thresholded.

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:07:06 +02:00
co-authored by Claude Opus 5
parent 767c6b9d77
commit 185610ee61
14 changed files with 466 additions and 36 deletions
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { fitFontSize } from './fit';
const options = { available: 500, preferred: 36, minimum: 20 };
describe('fitFontSize', () => {
it('keeps the preferred size when the text is short', () => {
expect(fitFontSize('10:00 – 18:30', options)).toBe(36);
});
it('shrinks a long line rather than letting it wrap', () => {
// Three periods, none on a round hour: the case that overflowed.
const crowded = '09:30 – 12:00 · 13:30 – 16:00 · 17:00 – 19:30';
const size = fitFontSize(crowded, options);
expect(size).toBeLessThan(36);
expect(size * crowded.length * 0.54).toBeLessThanOrEqual(options.available);
});
it('never goes below the floor, however long the text', () => {
expect(fitFontSize('a'.repeat(500), options)).toBe(20);
});
it('keeps the preferred size for empty text', () => {
expect(fitFontSize('', options)).toBe(36);
});
it('returns whole pixels', () => {
expect(Number.isInteger(fitFontSize('10:00 – 13:00 · 14:00', options))).toBe(true);
});
it('gives more room a larger size', () => {
const narrow = fitFontSize('10:00 – 13:00 · 14:00 – 18:30', { ...options, available: 300 });
const wide = fitFontSize('10:00 – 13:00 · 14:00 – 18:30', { ...options, available: 700 });
expect(wide).toBeGreaterThan(narrow);
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Choosing a type size that fits the space available.
*
* The panel cannot scroll and cannot reflow gracefully: text that does not fit
* is text that spills over a neighbour or falls off the bottom edge. The shop
* can legitimately enter three opening periods in a day, none of them on a
* round hour, and write a long message on top — a combination that overflowed
* both the week strip and the bottom banner before this existed.
*
* The width estimate is deliberately crude. Measuring glyphs properly would
* mean loading the font here and would still be an estimate, because the
* renderer applies its own kerning. Erring small costs a point of type; erring
* large costs a broken screen.
*/
/** Average glyph width as a fraction of the type size, for Inter's digits and lowercase. */
const WIDTH_RATIO = 0.54;
export type FitOptions = {
/** The width the text has to live in, in pixels. */
available: number;
/** The size to use when the text is short enough. */
preferred: number;
/** Never go below this: smaller stops being readable from the pavement. */
minimum: number;
};
export function fitFontSize(text: string, { available, preferred, minimum }: FitOptions): number {
if (text.length === 0) {
return preferred;
}
const fitting = available / (text.length * WIDTH_RATIO);
return Math.max(minimum, Math.min(preferred, Math.floor(fitting)));
}
+38 -16
View File
@@ -13,27 +13,49 @@ import { SANS, SERIF } from './layout';
* redraws, so they have to be reproducible. Vendored files make that true; an
* install tree or a CDN would not.
*/
const FILES: { file: string; name: string; weight: Font['weight'] }[] = [
{ file: 'inter-400.woff', name: SANS, weight: 400 },
{ file: 'inter-600.woff', name: SANS, weight: 600 },
{ file: 'inter-700.woff', name: SANS, weight: 700 },
{ file: 'source-serif-600.woff', name: SERIF, weight: 600 },
];
/**
* The sans families the panel can be set in.
*
* More than one exists because choosing a typeface for a 1-bit panel is done
* by looking at it thresholded, not by reading its specimen: anti-aliasing is
* what most screen faces rely on, and there is none here.
*/
export const SANS_FAMILIES = {
inter: ['inter-400.woff', 'inter-600.woff', 'inter-700.woff'],
atkinson: ['atkinson-400.woff', 'atkinson-700.woff', 'atkinson-700.woff'],
plex: ['plex-400.woff', 'plex-600.woff', 'plex-700.woff'],
} as const;
let cached: Font[] | null = null;
export type SansFamily = keyof typeof SANS_FAMILIES;
export async function loadScreenFonts(): Promise<Font[]> {
if (cached) {
return cached;
const WEIGHTS: Font['weight'][] = [400, 600, 700];
const cache = new Map<SansFamily, Font[]>();
export async function loadScreenFonts(family: SansFamily = 'inter'): Promise<Font[]> {
const hit = cache.get(family);
if (hit) {
return hit;
}
const directory = path.join(process.cwd(), 'public', 'fonts');
cached = await Promise.all(
FILES.map(async ({ file, name, weight }) => ({
name,
const files = SANS_FAMILIES[family];
const fonts = await Promise.all([
...files.map(async (file, index) => ({
name: SANS,
data: await readFile(path.join(directory, file)),
weight,
weight: WEIGHTS[index] ?? 400,
style: 'normal' as const,
})),
);
return cached;
(async () => ({
name: SERIF,
data: await readFile(path.join(directory, 'source-serif-600.woff')),
weight: 600 as Font['weight'],
style: 'normal' as const,
}))(),
]);
cache.set(family, fonts);
return fonts;
}
+11
View File
@@ -14,6 +14,17 @@ export const BLACK = '#000000';
export const WHITE = '#ffffff';
export const PADDING = 24;
/**
* Where the week sits.
*
* A bottom band gives each day a 114px column, and that width is what caps
* the type size. A side band gives roughly 230px and a 68px row instead —
* twice the room in both directions, at the cost of narrowing the main block.
*/
export type WeekPlacement = 'bottom' | 'right' | 'left';
export const SIDE_BAND_WIDTH = 232;
export const HEADER_HEIGHT = 76;
export const WEEK_HEIGHT = 112;
export const BANNER_HEIGHT = 84;
+18
View File
@@ -122,6 +122,24 @@ describe('screenTree', () => {
expect(withoutLogo).toContain('ITA ITO');
});
it('keeps the hours whole in the week, tightening only the dash', () => {
const tree = JSON.stringify(screenTree(PAYLOAD));
// A shop's opening hour is not a detail to abbreviate: 11:00 stays 11:00.
expect(tree).toContain('10:00–13:00');
expect(tree).toContain('14:00–18:30');
expect(tree).not.toContain('10–13');
// The main block has the room for the spaced form, and it is the line
// someone actually reads for today.
expect(tree).toContain('10:00 – 13:00 · 14:00 – 18:30');
});
it('draws the week down the right-hand side by default', () => {
const tree = JSON.stringify(screenTree(PAYLOAD));
// The band is the default arrangement; the bottom strip remains available.
expect(tree).toContain('"width":232');
expect(JSON.stringify(screenTree(PAYLOAD, null, 'bottom'))).not.toContain('"width":232');
});
it('omits the hours line on a day with no hours', () => {
const tree = JSON.stringify(
screenTree({ ...PAYLOAD, today: { ...PAYLOAD.today, hours_text: '' } }),
+167 -19
View File
@@ -13,12 +13,14 @@
import satori from 'satori';
import type { ScreenPayload, ScreenWeekDay } from './contract';
import { loadScreenFonts } from './fonts';
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 {
BANNER_HEIGHT,
BLACK,
SIDE_BAND_WIDTH,
HEADER_HEIGHT,
PADDING,
RULE,
@@ -28,26 +30,124 @@ import {
SERIF,
WEEK_HEIGHT,
WHITE,
type WeekPlacement,
} from './layout';
export function screenTree(payload: ScreenPayload, logo: string | null = null): ElementNode {
export function screenTree(
payload: ScreenPayload,
logo: string | null = null,
placement: WeekPlacement = 'right',
): ElementNode {
const root = {
display: 'flex',
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
backgroundColor: WHITE,
color: BLACK,
fontFamily: SANS,
} as const;
if (placement === 'bottom') {
return h(
'div',
{ style: { ...root, flexDirection: 'column' } },
header(payload, logo),
main(payload, SCREEN_WIDTH),
weekStrip(payload.week),
banner(payload),
);
}
const column = h(
'div',
{ style: { display: 'flex', flexDirection: 'column', flexGrow: 1, flexBasis: 0 } },
header(payload, logo),
main(payload, SCREEN_WIDTH - SIDE_BAND_WIDTH),
banner(payload),
);
const band = weekBand(payload.week, placement);
return h(
'div',
{ style: { ...root, flexDirection: 'row' } },
...(placement === 'left' ? [band, column] : [column, band]),
);
}
/**
* The week as a side band: one row per day, the label and the hours on the
* same line. A row is 68px tall and the band 232px wide, so the type can be
* a third larger than the bottom strip allows.
*/
function weekBand(week: ScreenWeekDay[], placement: 'right' | 'left'): ElementNode {
return h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
backgroundColor: WHITE,
color: BLACK,
fontFamily: SANS,
width: SIDE_BAND_WIDTH,
...(placement === 'right'
? { borderLeft: `${RULE}px solid ${BLACK}` }
: { borderRight: `${RULE}px solid ${BLACK}` }),
},
},
header(payload, logo),
main(payload),
weekStrip(payload.week),
banner(payload),
...week.map((day) => weekRow(day)),
);
}
function weekRow(day: ScreenWeekDay): ElementNode {
const inverted = day.is_today;
const lines = day.is_closed ? ['—'] : day.hours_text.split(' · ').map(tightHours);
return h(
'div',
{
style: {
display: 'flex',
alignItems: 'center',
flexGrow: 1,
flexBasis: 0,
paddingLeft: 14,
paddingRight: 14,
backgroundColor: inverted ? BLACK : WHITE,
color: inverted ? WHITE : BLACK,
overflow: 'hidden',
},
},
h(
'div',
{ style: { display: 'flex', width: 62, fontSize: 23, fontWeight: 700 } },
`${day.label_fr}${day.is_exception ? '*' : ''}`,
),
h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
flexGrow: 1,
},
},
...lines.map((line) =>
h(
'div',
{
style: {
display: 'flex',
// A row is 68px tall; three lines have to share it.
fontSize: Math.min(
lines.length >= 3 ? 17 : 22,
fitFontSize(line, { available: 142, preferred: 22, minimum: 14 }),
),
lineHeight: 1.2,
},
},
line,
),
),
),
);
}
@@ -81,7 +181,7 @@ function header(payload: ScreenPayload, logo: string | null): ElementNode {
);
}
function main(payload: ScreenPayload): ElementNode {
function main(payload: ScreenPayload, width: number): ElementNode {
const { today } = payload;
return h(
'div',
@@ -103,7 +203,20 @@ function main(payload: ScreenPayload): ElementNode {
today.hours_text
? h(
'div',
{ style: { display: 'flex', fontSize: 36, fontWeight: 600, marginTop: 10 } },
{
style: {
display: 'flex',
// Three periods with no round hour would otherwise wrap and push
// the banner off the bottom edge.
fontSize: fitFontSize(today.hours_text, {
available: width - 2 * PADDING,
preferred: 36,
minimum: 22,
}),
fontWeight: 600,
marginTop: 10,
},
},
today.hours_text,
)
: null,
@@ -147,7 +260,7 @@ function weekColumn(day: ScreenWeekDay): ElementNode {
// block, but here it is the difference between fitting and clipping.
const lines = day.is_closed
? ['—']
: day.hours_text.split(' · ').map((slot) => slot.replace(' – ', '–'));
: day.hours_text.split(' · ').map(tightHours);
return h(
'div',
@@ -169,11 +282,21 @@ function weekColumn(day: ScreenWeekDay): ElementNode {
},
h(
'div',
{ style: { display: 'flex', fontSize: 19, fontWeight: 700, marginBottom: 6 } },
{ style: { display: 'flex', fontSize: 20, fontWeight: 700, marginBottom: 5 } },
`${day.label_fr}${day.is_exception ? '*' : ''}`,
),
...lines.map((line) =>
h('div', { style: { display: 'flex', fontSize: 16, lineHeight: 1.35 } }, line),
h(
'div',
{
style: {
display: 'flex',
fontSize: fitFontSize(line, { available: 102, preferred: 19, minimum: 14 }),
lineHeight: 1.3,
},
},
line,
),
),
);
}
@@ -202,12 +325,37 @@ function banner(payload: ScreenPayload): ElementNode | null {
);
}
export type RenderOptions = {
/** Which sans family to set the panel in; see lib/screen/fonts.ts. */
family?: SansFamily;
/** Where the week sits; see lib/screen/layout.ts. */
placement?: WeekPlacement;
};
/** Renders the payload to an SVG string, exactly 800x480. */
export async function renderScreenSvg(payload: ScreenPayload): Promise<string> {
const [fonts, logo] = await Promise.all([loadScreenFonts(), loadLogoDataUri()]);
return satori(screenTree(payload, logo) as unknown as React.ReactNode, {
export async function renderScreenSvg(
payload: ScreenPayload,
options: RenderOptions = {},
): Promise<string> {
const [fonts, logo] = await Promise.all([
loadScreenFonts(options.family),
loadLogoDataUri(),
]);
return satori(screenTree(payload, logo, options.placement) as unknown as React.ReactNode, {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
fonts,
});
}
/**
* The week's own spelling of an opening period.
*
* Only the spaces around the dash go: "10:00–18:30" instead of
* "10:00 – 18:30". The times themselves stay whole — a shop's opening hour is
* not a detail to abbreviate — and losing two spaces is enough to hold 22pt
* in a 142px column.
*/
function tightHours(slot: string): string {
return slot.replace(' – ', '–');
}
+33
View File
@@ -29,7 +29,10 @@
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@fontsource/atkinson-hyperlegible": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/silkscreen": "^5.3.0",
"@fontsource/source-serif-4": "^5.3.0",
"@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4.3.3",
@@ -1113,6 +1116,26 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/atkinson-hyperlegible": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/atkinson-hyperlegible/-/atkinson-hyperlegible-5.3.0.tgz",
"integrity": "sha512-Sp8Ve8+rQmENY2Lv02VH7sLpntzTTvnncnJMTbRE7DX6TLxGraN4ykZfzaUWIoCFmaomwkvNiqmlPkrKBuDoSA==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/ibm-plex-sans": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz",
"integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz",
@@ -1123,6 +1146,16 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/silkscreen": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/silkscreen/-/silkscreen-5.3.0.tgz",
"integrity": "sha512-M1NQ3LQH0LFNvyFPd/piHEGMImPPtpLglNnLC6Vdcq9kL0jNIOtTQmksAWCc4enbDnw1OnIpZtjNEfRqYm/QmA==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/source-serif-4": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/source-serif-4/-/source-serif-4-5.3.0.tgz",
+5 -1
View File
@@ -21,7 +21,8 @@
"e2e": "playwright test",
"holidays:sync": "tsx scripts/sync-holidays.ts",
"translate:test": "tsx scripts/test-translation.ts",
"e2e:ui": "playwright test --ui"
"e2e:ui": "playwright test --ui",
"screen:fonts": "tsx scripts/typography-preview.ts"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
@@ -44,7 +45,10 @@
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@fontsource/atkinson-hyperlegible": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/silkscreen": "^5.3.0",
"@fontsource/source-serif-4": "^5.3.0",
"@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4.3.3",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+123
View File
@@ -0,0 +1,123 @@
/**
* Renders the panel in each candidate typeface, at 1 bit, plus a magnified
* crop of the week strip — the smallest text on the screen and the only part
* whose legibility is in question.
*
* npm run screen:fonts
*
* Choosing a face for a 1-bit panel is done by looking at it thresholded. A
* specimen sheet shows anti-aliased outlines the panel will never render.
*/
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import type { ScreenPayload } from '../lib/screen/contract';
import { encodeScreen } from '../lib/screen/encode';
import type { SansFamily } from '../lib/screen/fonts';
import type { WeekPlacement } from '../lib/screen/layout';
import { renderScreenSvg } from '../lib/screen/render';
const PAYLOAD: ScreenPayload = {
schema: 1,
generated_at: '2026-09-22T12:00:00+02:00',
shop_name: 'ITA ITO',
logo_url: '',
today: {
date_fr: 'Mercredi 23 septembre',
date_en: 'Wednesday 23 September',
status: 'OPEN',
status_fr: 'OUVERT',
status_en: 'OPEN',
hours_text: '11:00 – 18:00',
next_change_fr: 'Ferme à 18:00',
next_change_en: 'Closes at 6:00 pm',
is_exception: false,
},
week: [
{ label_fr: 'Lun', label_en: 'Mon', hours_text: '', is_today: false, is_closed: true, is_exception: false },
{ label_fr: 'Mar', label_en: 'Tue', hours_text: '11:00 – 18:00', is_today: false, is_closed: false, is_exception: false },
{ label_fr: 'Mer', label_en: 'Wed', hours_text: '11:00 – 18:00', is_today: true, is_closed: false, is_exception: false },
{ label_fr: 'Jeu', label_en: 'Thu', hours_text: '11:00 – 13:00 · 14:00 – 18:00', is_today: false, is_closed: false, is_exception: false },
{ label_fr: 'Ven', label_en: 'Fri', hours_text: '14:00 – 18:00', is_today: false, is_closed: false, is_exception: true },
{ label_fr: 'Sam', label_en: 'Sat', hours_text: '10:00 – 17:00', is_today: false, is_closed: false, is_exception: false },
{ label_fr: 'Dim', label_en: 'Sun', hours_text: '', is_today: false, is_closed: true, is_exception: false },
],
banner: {
kind: 'MESSAGE',
text_fr: 'Fermeture exceptionnelle jeudi matin',
text_en: 'Exceptionally closed on Thursday morning',
},
};
/**
* The worst case the data model allows: three slots in a day, none of them on
* a round hour, so nothing can be shortened. If a layout survives this it
* survives anything the shop can enter.
*/
const CROWDED: ScreenPayload = {
...PAYLOAD,
today: {
...PAYLOAD.today,
hours_text: '09:30 – 12:00 · 13:30 – 16:00 · 17:00 – 19:30',
next_change_fr: 'Ferme à 12:00',
next_change_en: 'Closes at 12:00 pm',
},
week: PAYLOAD.week.map((day, index) =>
index === 2
? { ...day, hours_text: '09:30 – 12:00 · 13:30 – 16:00 · 17:00 – 19:30' }
: index === 3
? { ...day, hours_text: '10:30 – 18:30' }
: day,
),
};
const VARIANTS: {
key: string;
label: string;
family: SansFamily;
placement: WeekPlacement;
payload?: ScreenPayload;
}[] = [
{ key: 'bas', label: 'Bande en bas (actuelle)', family: 'inter', placement: 'bottom' },
{ key: 'droite', label: 'Bande à droite', family: 'inter', placement: 'right' },
{ key: 'gauche', label: 'Bande à gauche', family: 'inter', placement: 'left' },
{ key: 'limite-droite', label: 'Pire cas, bande à droite', family: 'inter', placement: 'right', payload: CROWDED },
{ key: 'limite-bas', label: 'Pire cas, bande en bas', family: 'inter', placement: 'bottom', payload: CROWDED },
];
async function main(): Promise<void> {
const directory = path.join(process.cwd(), '.preview', 'fonts');
await mkdir(directory, { recursive: true });
for (const { key, label, family, placement, payload } of VARIANTS) {
const png = encodeScreen(
await renderScreenSvg(payload ?? PAYLOAD, { family, placement }),
'png',
);
await writeFile(path.join(directory, `${key}.png`), png);
// Magnified with nearest-neighbour on the week area: the point is to see
// the pixels the panel will actually set, not a smoothed impression.
const crop =
placement === 'bottom'
? { left: 0, top: 284, width: 800, height: 116 }
: placement === 'right'
? { left: 800 - 232, top: 0, width: 232, height: 480 }
: { left: 0, top: 0, width: 232, height: 480 };
await sharp(png)
.extract(crop)
.resize({ width: crop.width * 2, kernel: 'nearest' })
.toFile(path.join(directory, `${key}-zoom.png`));
console.log(`${label.padEnd(26)} -> .preview/fonts/${key}.png (+ -zoom.png)`);
}
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});