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}`);