generate-calendar-favicons.mjs3.3 KBView on GitHub /**
* Generates the calendar tab icons: one SVG per day of the month, in Cedar's palette.
*
* Why 31 files rather than one icon drawn at runtime — this is how Google Calendar does it, and
* the reason is bookmarks. A favicon built on the fly (canvas -> data: URL) has no stable URL for
* the browser to file under, so bookmarks and the favicon cache treat it inconsistently. A plain
* `/icons/calendar/day-29.svg` is just a URL, and everything downstream handles it normally.
*
* Run: `pnpm --filter @zero/mail favicons:calendar` (no dependencies).
*
* The digit outlines in geist-bold-digits.json are traced from the app's own Geist Bold, so the
* numeral matches the product's type without the icon depending on a font at render time — an
* SVG favicon renders in an isolated context where a `font-family` would resolve to whatever the
* browser felt like.
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = join(here, '..', 'public', 'icons', 'calendar');
const { capTop, capBottom, inkGap, digits } = JSON.parse(
readFileSync(join(here, 'geist-bold-digits.json'), 'utf8'),
);
/** Cedar's surface colour and deep green, sampled from the brand mark. */
const CREAM = '#E5E0DA';
const GREEN = '#063932';
const VIEW = 512;
/** Corner radius of the tile. The corners outside it are transparent, not white. */
const RADIUS = 96;
/**
* Cap height of the numeral. Every day is set at the same height rather than scaled to fill the
* tile, so the icon does not visibly grow and shrink as the month goes on. That makes the widest
* day — "30", the two roundest digits — the one that sets the side margins: at this height it
* clears each edge by ~67 of 512, which is the horizontal breathing room the rounded corners need.
*/
const CAP_HEIGHT = 210;
const round = (n, places = 2) => Number(n.toFixed(places));
/**
* Lays the day out as glyph outlines centred on (cx, cy).
*
* Digits are positioned by their ink edges with a fixed gap rather than by the font's advance
* widths, so a narrow "1" tucks in the way it should — "31" and "11" both sit evenly.
*/
function numeral(day, { cx, cy }) {
const scale = CAP_HEIGHT / (capBottom - capTop);
const glyphs = [...String(day)].map((char) => digits[char]);
const inkWidth = glyphs.reduce((w, g, i) => w + g.width + (i > 0 ? inkGap : 0), 0);
let pen = -inkWidth / 2;
const paths = glyphs.map((g, i) => {
if (i > 0) pen += inkGap;
const x = cx + scale * (pen - g.x0);
const y = cy - CAP_HEIGHT / 2 - scale * capTop;
pen += g.width;
return `<path transform="translate(${round(x)} ${round(y)}) scale(${round(scale, 5)})" d="${g.d}"/>`;
});
return paths.join('\n ');
}
function icon(day) {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${VIEW} ${VIEW}" width="${VIEW}" height="${VIEW}" role="img" aria-label="Cedar Calendar, day ${day}">
<rect width="${VIEW}" height="${VIEW}" rx="${RADIUS}" fill="${CREAM}"/>
<g fill="${GREEN}">
${numeral(day, { cx: VIEW / 2, cy: VIEW / 2 })}
</g>
</svg>
`;
}
mkdirSync(OUT_DIR, { recursive: true });
for (let day = 1; day <= 31; day++) {
writeFileSync(join(OUT_DIR, `day-${day}.svg`), icon(day));
}
console.log(`Wrote 31 day icons to ${OUT_DIR}`);