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
396 lines
10 KiB
TypeScript
396 lines
10 KiB
TypeScript
/**
|
||
* 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(' – ', '–');
|
||
}
|