/** * 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 } from './fonts'; import { h, type ElementNode } from './h'; import { LOGO_HEIGHT, LOGO_WIDTH, loadLogoDataUri } from './logo'; import { BANNER_HEIGHT, BLACK, HEADER_HEIGHT, PADDING, RULE, SANS, SCREEN_HEIGHT, SCREEN_WIDTH, SERIF, WEEK_HEIGHT, WHITE, } from './layout'; export function screenTree(payload: ScreenPayload, logo: string | null = null): ElementNode { return h( 'div', { style: { display: 'flex', flexDirection: 'column', width: SCREEN_WIDTH, height: SCREEN_HEIGHT, backgroundColor: WHITE, color: BLACK, fontFamily: SANS, }, }, header(payload, logo), main(payload), weekStrip(payload.week), banner(payload), ); } 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): 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', fontSize: 36, fontWeight: 600, marginTop: 10 } }, today.hours_text, ) : null, nextChangeLine(payload), ); } function nextChangeLine(payload: ScreenPayload): ElementNode | null { const { next_change_fr: fr, next_change_en: en } = payload.today; if (!fr && !en) { return null; } return h( 'div', { style: { display: 'flex', alignItems: 'baseline', marginTop: 12 } }, fr ? h('div', { style: { display: 'flex', fontSize: 24 } }, fr) : null, fr && en ? h('div', { style: { display: 'flex', fontSize: 24, paddingLeft: 10, paddingRight: 10 } }, '·') : null, en ? h('div', { style: { display: 'flex', fontSize: 20 } }, 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((slot) => slot.replace(' – ', '–')); 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: 19, fontWeight: 700, marginBottom: 6 } }, `${day.label_fr}${day.is_exception ? '*' : ''}`, ), ...lines.map((line) => h('div', { style: { display: 'flex', fontSize: 16, lineHeight: 1.35 } }, 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, ); } /** Renders the payload to an SVG string, exactly 800x480. */ export async function renderScreenSvg(payload: ScreenPayload): Promise { const [fonts, logo] = await Promise.all([loadScreenFonts(), loadLogoDataUri()]); return satori(screenTree(payload, logo) as unknown as React.ReactNode, { width: SCREEN_WIDTH, height: SCREEN_HEIGHT, fonts, }); }