Files
ita-ito-horaires/lib/screen/render.ts
T
vliaudatandClaude Opus 5 ec826b378f fix: stack the next-change line instead of letting it wrap
With the week down the right-hand side the main block is narrower, and
putting the French and English on one row set the two languages
competing for the width. In production that read as "Ouvre demain à" /
"11:00" on one side and the separator stranded on a second line with
"am" — visibly broken.

They are stacked now, French above English, which is already the pattern
in the banner, and each line is fitted to the width available rather
than allowed to wrap.

Found by rendering the real production payload rather than the sample
one: the sample had a short enough sentence to hide it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-21 23:10:57 +02:00

396 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Draws the screen payload as an 800x480 SVG.
*
* This module draws and nothing else. Every string it places has already been
* computed and translated by the view model, so there is no business logic to
* disagree about — which is also why its output can be snapshotted as text
* rather than compared as pixels.
*
* e-ink discipline: no greys, no shadows, no gradients, no hairlines.
* Hierarchy comes from size, weight and solid inversion.
*/
import satori from 'satori';
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 {
BANNER_HEIGHT,
BLACK,
SIDE_BAND_WIDTH,
HEADER_HEIGHT,
PADDING,
RULE,
SANS,
SCREEN_HEIGHT,
SCREEN_WIDTH,
SERIF,
WEEK_HEIGHT,
WHITE,
type WeekPlacement,
} from './layout';
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: SIDE_BAND_WIDTH,
...(placement === 'right'
? { borderLeft: `${RULE}px solid ${BLACK}` }
: { borderRight: `${RULE}px solid ${BLACK}` }),
},
},
...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,
),
),
),
);
}
function header(payload: ScreenPayload, logo: string | null): ElementNode {
return h(
'div',
{
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: HEADER_HEIGHT,
paddingLeft: PADDING,
paddingRight: PADDING,
borderBottom: `${RULE}px solid ${BLACK}`,
},
},
logo
? h('img', {
src: logo,
width: LOGO_WIDTH,
height: LOGO_HEIGHT,
style: { display: 'flex' },
})
: h(
'div',
{ style: { display: 'flex', fontFamily: SERIF, fontSize: 34, letterSpacing: 6 } },
payload.shop_name,
),
h('div', { style: { display: 'flex', fontSize: 22 } }, payload.today.date_fr),
);
}
function main(payload: ScreenPayload, width: number): ElementNode {
const { today } = payload;
return h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
justifyContent: 'center',
paddingLeft: PADDING,
paddingRight: PADDING,
},
},
h(
'div',
{ style: { display: 'flex', fontSize: 92, fontWeight: 700, lineHeight: 1 } },
today.status_fr,
),
today.hours_text
? h(
'div',
{
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,
nextChangeLine(payload, width),
);
}
/**
* "Ferme à 18:30", then the English underneath.
*
* Stacked rather than side by side: the main block is narrower now that the
* week runs down the right, and a row put the two languages in competition
* for the width — the French wrapped and left the separator stranded on the
* next line with half the English. Two lines also match the banner, where
* French above English is already the pattern.
*/
function nextChangeLine(payload: ScreenPayload, width: number): ElementNode | null {
const { next_change_fr: fr, next_change_en: en } = payload.today;
if (!fr && !en) {
return null;
}
const available = width - 2 * PADDING;
return h(
'div',
{ style: { display: 'flex', flexDirection: 'column', marginTop: 12 } },
fr
? h(
'div',
{
style: {
display: 'flex',
fontSize: fitFontSize(fr, { available, preferred: 24, minimum: 18 }),
},
},
fr,
)
: null,
en
? h(
'div',
{
style: {
display: 'flex',
fontSize: fitFontSize(en, { available, preferred: 20, minimum: 15 }),
marginTop: 2,
},
},
en,
)
: null,
);
}
function weekStrip(week: ScreenWeekDay[]): ElementNode {
return h(
'div',
{
style: {
display: 'flex',
height: WEEK_HEIGHT,
borderTop: `${RULE}px solid ${BLACK}`,
},
},
...week.map((day) => weekColumn(day)),
);
}
function weekColumn(day: ScreenWeekDay): ElementNode {
// Today is inverted rather than tinted: a solid block is the only emphasis
// a 1-bit panel renders cleanly.
const inverted = day.is_today;
// A column is only 114px wide. The spaced en dash reads better in the main
// block, but here it is the difference between fitting and clipping.
const lines = day.is_closed
? ['—']
: day.hours_text.split(' · ').map(tightHours);
return h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flexGrow: 1,
flexBasis: 0,
paddingTop: 8,
paddingBottom: 8,
paddingLeft: 6,
paddingRight: 6,
backgroundColor: inverted ? BLACK : WHITE,
color: inverted ? WHITE : BLACK,
},
},
h(
'div',
{ style: { display: 'flex', fontSize: 20, fontWeight: 700, marginBottom: 5 } },
`${day.label_fr}${day.is_exception ? '*' : ''}`,
),
...lines.map((line) =>
h(
'div',
{
style: {
display: 'flex',
fontSize: fitFontSize(line, { available: 102, preferred: 19, minimum: 14 }),
lineHeight: 1.3,
},
},
line,
),
),
);
}
function banner(payload: ScreenPayload): ElementNode | null {
if (!payload.banner) {
return null;
}
const { text_fr: fr, text_en: en } = payload.banner;
return h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
height: BANNER_HEIGHT,
paddingLeft: PADDING,
paddingRight: PADDING,
backgroundColor: BLACK,
color: WHITE,
},
},
h('div', { style: { display: 'flex', fontSize: 25, fontWeight: 600 } }, fr),
en ? h('div', { style: { display: 'flex', fontSize: 19, marginTop: 4 } }, en) : 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,
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(' – ', '–');
}