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