The logotype is drawn with wide spacing — handsome on a website, and a third of the panel's header width spent on nothing. The spacing is artwork, not typesetting, so no property adjusts it; but every pixel column between two letters is blank, and a blank column carries no shape. Dropping most of them moves the letters together without altering a single letterform. At the same 260px the capitals are now a third taller, which the hairlines needed more than the spacing did: the A of ITA reads at a glance instead of being rescued by a threshold. Done at full resolution, before the downscale. Compressing columns that are already an average of ink and paper would smear exactly the strokes this logo can least afford to lose. The rendered size is now read from the PNG's own header rather than kept as a constant beside it. The spacing is adjustable and every value changes the height; a number copied into the renderer would have drifted and squashed the logo with nothing failing to say so. `npm run brand:variants` renders the spacings side by side, magnified, since this is a decision to be looked at rather than reasoned about. 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 { loadLogo, type Logo } 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: Logo | 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: Logo | 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.dataUri,
|
||
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),
|
||
loadLogo(),
|
||
]);
|
||
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(' – ', '–');
|
||
}
|