With the move to BYOS there is no TRMNL cloud to interpret a Liquid template, so the application draws the screen itself. The pipeline is satori (flexbox to SVG) then resvg (SVG to pixels) then a hand-written 1-bit encoder. Chromium was the alternative and was rejected: half a gigabyte and a real memory appetite in the runtime image, for a screen of eight blocks. Playwright still handles the E2E tests, in its own pinned image, never in the application one. The payoff is testability. The layout is asserted on the element tree and the geometry on the SVG text, so a rendering regression shows up as a readable diff instead of a pixel comparison. The BMP and PNG encoders are verified field by field against their specifications, including an independent CRC-32 for the PNG: a device rejecting a malformed image is expensive to debug from a shop window. Two properties are pinned because the battery depends on them: the same payload must produce byte-identical output, and changed hours must produce different output. The filename handed to the device is a hash of these bytes, and the firmware skips the redraw when it is unchanged. The fonts are vendored into public/fonts and the logo into public/brand, both committed. Rendering must not depend on an install tree, a CDN or the network, or the bytes drift and the panel wakes for nothing. `npm run screen:preview` writes a real 1-bit image plus a magnified view, which is where clipping and thin strokes give themselves up. That is how the week strip was caught clipping and condensed. `npm run brand` rebuilds the assets: it locates the wordmark band in the shop logo rather than hard-coding offsets a future revision would break, and thresholds it with the panel's own encoder. sharp is a devDependency used only there; nothing at runtime needs it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
214 lines
5.7 KiB
TypeScript
214 lines
5.7 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 } 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<string> {
|
||
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,
|
||
});
|
||
}
|