feat: redesign the unread favicon badge as a compact keyline badge

This commit is contained in:
Linus Rath
2026-08-20 16:49:31 +02:00
parent 161e7ec92a
commit 6a3bb3f459
2 changed files with 225 additions and 147 deletions
+131 -80
View File
@@ -3,21 +3,36 @@ import { formatBadgeCount, renderBadgedFavicon } from '@/lib/favicon-badge';
const BASE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clip1"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clip1)"><rect width="1000" height="1000" fill="#123456"/></g></svg>`;
// The renderer's published colours: the white keyline halo under a deep-rose
// fill rect, with white digits on top.
const RING = '#ffffff';
const FILL = '#af1d3f';
function decode(dataUrl: string): string {
return decodeURIComponent(dataUrl.replace('data:image/svg+xml,', ''));
}
/** The badge band: the last <rect> the renderer appends, identified by its fill. */
function band(svg: string): { x: number; y: number; w: number; h: number; rx: number } {
const match =
/<rect[^>]*\bx="(-?[\d.]+)"[^>]*\by="(-?[\d.]+)"[^>]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"[^>]*\brx="([\d.]+)"[^>]*fill="#ffffff"/.exec(
svg,
);
type Rect = { x: number; y: number; w: number; h: number; rx: number };
function rectByFill(svg: string, fill: string): Rect {
const match = new RegExp(
`<rect[^>]*\\bx="(-?[\\d.]+)"[^>]*\\by="(-?[\\d.]+)"[^>]*\\bwidth="([\\d.]+)"[^>]*\\bheight="([\\d.]+)"[^>]*\\brx="([\\d.]+)"[^>]*fill="${fill}"`,
).exec(svg);
expect(match).not.toBeNull();
const [, x, y, w, h, rx] = match!.map(Number);
return { x, y, w, h, rx };
}
/** The badge fill rect: the deep-rose rounded rect the digits sit on. */
function badge(svg: string): Rect {
return rectByFill(svg, FILL);
}
/** The keyline halo: the white rect drawn underneath, inflated by the ring width. */
function halo(svg: string): Rect {
return rectByFill(svg, RING);
}
function fontSize(svg: string): number {
return Number(/<text[^>]*font-size="([\d.]+)"/.exec(svg)![1]);
}
@@ -84,7 +99,7 @@ describe('renderBadgedFavicon', () => {
expect(url!.startsWith('data:image/svg+xml,')).toBe(true);
});
it('draws a badge band and the count text', () => {
it('draws a badge and the count text', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('<rect');
expect(svg).toContain('>3<');
@@ -109,10 +124,38 @@ describe('renderBadgedFavicon', () => {
expect(svg).not.toContain('1000pt');
});
it('draws a white band with black digits, so the count stays legible over any base icon', () => {
it('draws a deep-rose badge with white digits inside a white keyline halo', () => {
// The keyline pair is the contrast guarantee: against dark or saturated
// artwork the white ring separates the badge; against pale artwork the ring
// vanishes but the deep fill is the edge. Digit contrast (white on the
// fill) is fixed and independent of the artwork. Neither half may be
// dropped: a bare coloured badge was invisible on Bulwark's own red icon,
// and the flat white band that replaced it read as a sticker over the logo.
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toMatch(/<rect[^>]*fill="#ffffff"/);
expect(svg).toMatch(/<text[^>]*fill="#000000"/);
expect(svg).toMatch(new RegExp(`<rect[^>]*fill="${RING}"`));
expect(svg).toMatch(new RegExp(`<rect[^>]*fill="${FILL}"`));
expect(svg).toMatch(/<text[^>]*fill="#ffffff"/);
});
it('draws the halo before the fill rect, so the ring sits underneath', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg.indexOf(`fill="${RING}"`)).toBeLessThan(svg.indexOf(`fill="${FILL}"`));
});
it('inflates the halo by the ring width on every side of the fill rect', () => {
// The ring is a rect underneath, not a stroke: a stroke straddles the edge,
// halving the visible keyline and shrinking the fill.
const RING_W = 0.0625 * 1000;
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
const b = badge(svg);
const g = halo(svg);
expect(g.x).toBeCloseTo(b.x - RING_W, 5);
expect(g.y).toBeCloseTo(b.y - RING_W, 5);
expect(g.w).toBeCloseTo(b.w + 2 * RING_W, 5);
expect(g.h).toBeCloseTo(b.h + 2 * RING_W, 5);
expect(g.rx).toBeCloseTo(b.rx + RING_W, 5);
}
});
it('shrinks the font as the label grows so three glyphs still fit', () => {
@@ -127,44 +170,46 @@ describe('renderBadgedFavicon', () => {
expect(renderBadgedFavicon(bad, 3)).toBeNull();
});
it('sizes the band to the label, and only "99+" fills the full icon width', () => {
// The box is only as wide as its digits need — "5" must not squat on as much
// white as "99+". It is never wider than the icon, and three glyphs, whose
// font is budgeted against the full span, grow to exactly fill it.
const w = (count: number) => band(decode(renderBadgedFavicon(BASE_SVG, count)!)).w;
it('sizes the badge to the label, and only "99+" fills the full icon width', () => {
// The badge is only as wide as its digits need — "5" must not cover as much
// of the icon as "99+". The halo is never wider than the icon, and three
// glyphs, whose font is budgeted against the full span, grow to exactly
// fill it.
const w = (count: number) => halo(decode(renderBadgedFavicon(BASE_SVG, count)!)).w;
expect(w(7)).toBeLessThan(w(47));
expect(w(47)).toBeLessThan(w(250));
expect(w(250)).toBeCloseTo(1000, 5);
});
it('matches the geometry measured from Gmail\'s 16px favicon', () => {
// Ground truth, measured pixel-by-pixel off Gmail's tab icon and scaled to a
// 0 0 1000 1000 viewBox: band 10/16 of the icon (0.625), flush to the bottom
// edge, corners rounded by 0.1h, width fitted to the label, anchored right.
// Gmail's own single-digit badge sits hard right in a box about a third of
// the icon wide, so the box grows leftwards from the corner.
// w = label.length * 0.6 * font + 2 * 0.04 * 1000, x = 1000 - w.
const expected: Record<string, { x: number; w: number; font: number }> = {
'5': { x: 554, w: 446, font: 610 }, // textW = 1 * 0.6 * 610 = 366
'15': { x: 188, w: 812, font: 610 }, // textW = 2 * 0.6 * 610 = 732
'250': { x: 0, w: 1000, font: 920 / 1.8 }, // "99+": font = (1000 - 80) / (3 * 0.6)
it('renders the published geometry at 1, 2, and 3 glyphs', () => {
// Scaled to a 0 0 1000 1000 viewBox: ring 62.5, fill height 480, padding 45
// per side, font capped at 460 and budgeted against the full span:
// font = min(460, (1000 - 125 - 90) / (len * 0.6)),
// w = len * 0.6 * font + 90 (capped at 875), halo flush bottom-right.
const expected: Record<string, { haloX: number; haloW: number; font: number }> = {
'5': { haloX: 509, haloW: 491, font: 460 }, // textW = 1 * 0.6 * 460 = 276
'15': { haloX: 233, haloW: 767, font: 460 }, // textW = 2 * 0.6 * 460 = 552
'250': { haloX: 0, haloW: 1000, font: 785 / 1.8 }, // "99+" shrinks, halo fills the span
};
for (const [count, want] of Object.entries(expected)) {
const svg = decode(renderBadgedFavicon(BASE_SVG, Number(count))!);
const { x, y, w, h, rx } = band(svg);
expect(x).toBeCloseTo(want.x, 5);
expect(w).toBeCloseTo(want.w, 5);
const g = halo(svg);
expect(g.x).toBeCloseTo(want.haloX, 5);
expect(g.w).toBeCloseTo(want.haloW, 5);
expect(fontSize(svg)).toBeCloseTo(want.font, 5);
expect(y).toBeCloseTo(375, 5);
expect(h).toBeCloseTo(625, 5);
expect(rx).toBeCloseTo(62.5, 5);
expect(g.y).toBeCloseTo(1000 - 480 - 125, 5);
expect(g.h).toBeCloseTo(480 + 125, 5);
const b = badge(svg);
expect(b.h).toBeCloseTo(480, 5);
expect(b.rx).toBeCloseTo(0.3 * 480, 5);
}
});
it('anchors the band to the right edge, including on a negative-origin viewBox', () => {
// Corner-anchored, not centred: the box grows leftwards from the bottom-right
// corner, so its right edge sits on minX + span whatever the label. Centring
// was rejected — at a single digit it lands under the middle of the mark.
it('anchors the badge to the right edge, including on a negative-origin viewBox', () => {
// Corner-anchored, not centred: the badge grows leftwards from the
// bottom-right corner, so the halo's right edge sits on minX + span
// whatever the label. Centring was rejected — at a single digit it lands
// under the middle of the mark.
const cases: [string, number, number][] = [
// [base svg, minX, span]
[BASE_SVG, 0, 1000],
@@ -172,7 +217,7 @@ describe('renderBadgedFavicon', () => {
];
for (const [svgSource, minX, span] of cases) {
for (const count of [7, 47, 250]) {
const { x, w } = band(decode(renderBadgedFavicon(svgSource, count)!));
const { x, w } = halo(decode(renderBadgedFavicon(svgSource, count)!));
expect(x + w).toBeCloseTo(minX + span, 5);
expect(x).toBeGreaterThanOrEqual(minX);
}
@@ -181,9 +226,9 @@ describe('renderBadgedFavicon', () => {
it('renders 1- and 2-digit labels at the max font size, and shrinks only for "99+"', () => {
// The font is budgeted against the full icon span, not against the fitted
// box, so one or two glyphs always land at FONT_MAX; only three force a
// shrink — and their box then grows to fill the icon.
const FONT_MAX = 0.61 * 1000;
// badge, so one or two glyphs always land at FONT_MAX; only three force a
// shrink — and their badge then grows to fill the icon.
const FONT_MAX = 0.46 * 1000;
const one = decode(renderBadgedFavicon(BASE_SVG, 7)!);
const two = decode(renderBadgedFavicon(BASE_SVG, 47)!);
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
@@ -192,31 +237,33 @@ describe('renderBadgedFavicon', () => {
expect(fontSize(three)).toBeLessThan(FONT_MAX);
});
it('rounds the band corners slightly — neither an oval nor a hard square', () => {
// rx = h / 2 was the pill: at one digit it read as a circle, at two an oval,
// and "99+" was a smudge. rx = 0 is the other failure: Gmail's corners carry
// a visible ~1px round at 16px. Guard against a silent revert to either.
it('rounds the badge corners — neither an oval nor a hard square', () => {
// rx = h / 2 was the pill: at one digit it read as a circle, at two an
// oval, and "99+" was a smudge — round ends squander exactly the horizontal
// space three glyphs need. rx = 0 was the slab. Guard against a silent
// revert to either.
for (const count of [7, 47, 250]) {
const { h, rx } = band(decode(renderBadgedFavicon(BASE_SVG, count)!));
expect(rx).toBeCloseTo(0.1 * h, 5);
const { h, rx } = badge(decode(renderBadgedFavicon(BASE_SVG, count)!));
expect(rx).toBeCloseTo(0.3 * h, 5);
expect(rx).toBeGreaterThan(0);
expect(rx).toBeLessThan(h / 2);
}
});
it('draws the digits at font-weight 500, in both the attribute and the style', () => {
// 700 read visibly heavier than Gmail's equivalent badge.
it('draws the digits at font-weight 600, in both the attribute and the style', () => {
// 600, not the 500 the black-on-white band used: light-on-dark digits lose
// stroke weight to anti-aliasing at 16px.
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toMatch(/<text[^>]*font-weight="500"/);
expect(svg).toMatch(/<text[^>]*style="[^"]*font-weight:\s*500/);
expect(svg).toMatch(/<text[^>]*font-weight="600"/);
expect(svg).toMatch(/<text[^>]*style="[^"]*font-weight:\s*600/);
});
it('keeps the badge band entirely inside the viewBox for 1, 2, and 3-glyph labels', () => {
// The band is flush to the bottom and, at three glyphs, to the left and
// right edges too — but it must never overflow any of them.
it('keeps the badge entirely inside the viewBox for 1, 2, and 3-glyph labels', () => {
// The halo is flush to the bottom and right edges and, at three glyphs, to
// the left edge too — but it must never overflow any of them.
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
const { x, y, w, h } = band(svg);
const { x, y, w, h } = halo(svg);
expect(x).toBeGreaterThanOrEqual(0);
expect(y).toBeGreaterThanOrEqual(0);
expect(x + w).toBeLessThanOrEqual(1000);
@@ -226,34 +273,34 @@ describe('renderBadgedFavicon', () => {
// A previous version of this test used /\bx="([\d.]+)"/, which cannot match a
// negative number: dropping `minX +` from the anchoring passed it. Anchor
// against a viewBox whose origin is negative, where the band's own x is
// against a viewBox whose origin is negative, where the halo's own x is
// legitimately negative, so the offset is genuinely pinned.
it('anchors the band to the viewBox origin, including a negative origin', () => {
it('anchors the badge to the viewBox origin, including a negative origin', () => {
const NEGATIVE_ORIGIN = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-40 -40 240 240"><rect x="-40" y="-40" width="240" height="240" fill="#123456"/></svg>`;
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(NEGATIVE_ORIGIN, count)!);
const { x, y, w, h } = band(svg);
const { x, y, w, h } = halo(svg);
expect(x).toBeGreaterThanOrEqual(-40);
expect(y).toBeGreaterThanOrEqual(-40);
expect(x + w).toBeLessThanOrEqual(200);
expect(y + h).toBeLessThanOrEqual(200);
// Anchored to the bottom-right: in a viewBox running from -40 to 200, the
// band's bottom edge and its right edge both sit well past the midpoint.
// halo's bottom edge and its right edge both sit well past the midpoint.
expect(x + w).toBeGreaterThan(80);
expect(y + h).toBeGreaterThan(80);
}
});
it('fits the label inside the band, with padding, for every label length', () => {
// The core band invariant: textW + 2 * pad <= w, where the glyph advance and
// padding are the renderer's own published constants. PAD_FACTOR is a
// fraction of the icon span, not of the fitted box, so the padding is the
it('fits the label inside the badge, with padding, for every label length', () => {
// The core badge invariant: textW + 2 * pad <= w, where the glyph advance
// and padding are the renderer's own published constants. PAD_FACTOR is a
// fraction of the icon span, not of the fitted badge, so the padding is the
// same at every label length.
const GLYPH_ADV = 0.6;
const PAD_FACTOR = 0.04;
const PAD_FACTOR = 0.045;
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
const { w } = band(svg);
const { w } = badge(svg);
const label = count > 99 ? '99+' : String(count);
const textW = label.length * GLYPH_ADV * fontSize(svg);
const pad = PAD_FACTOR * 1000;
@@ -280,14 +327,16 @@ describe('renderBadgedFavicon', () => {
expect(renderBadgedFavicon(noNs, 3)).toBeNull();
});
it('beats a stylesheet in the base SVG, keeping the badge white-on-black', () => {
it('beats a stylesheet in the base SVG, keeping the badge colours fixed', () => {
// Presentation attributes lose to any CSS rule in the document. A branded
// base carrying `rect { fill: #db2d54 }` would otherwise paint the band red
// and the digits red — exactly what the white band exists to prevent.
// base carrying `rect { fill: #db2d54 }` would otherwise repaint the halo,
// the fill and the digits in the artwork's own colour — exactly what the
// keyline exists to prevent.
const STYLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><style>rect{fill:#db2d54}text{fill:#db2d54}</style><rect width="1000" height="1000"/></svg>`;
const svg = decode(renderBadgedFavicon(STYLED, 3)!);
expect(svg).toMatch(/<rect[^>]*style="[^"]*fill:\s*#ffffff/);
expect(svg).toMatch(/<text[^>]*style="[^"]*fill:\s*#000000/);
expect(svg).toMatch(new RegExp(`<rect[^>]*style="[^"]*fill:\\s*${RING}`));
expect(svg).toMatch(new RegExp(`<rect[^>]*style="[^"]*fill:\\s*${FILL}`));
expect(svg).toMatch(/<text[^>]*style="[^"]*fill:\s*#ffffff/);
});
it('strips scripts, foreignObject and event handlers from the base SVG', () => {
@@ -321,18 +370,20 @@ describe('renderBadgedFavicon', () => {
expect(vb.minX).toBe(0);
expect(vb.minY).toBe(-40); // centred: (100 - 20) / 2 above and below
const { x, y, w, h } = band(svg);
// Sized against the square side (100), not the 20-unit short axis.
expect(h).toBeCloseTo(0.625 * 100, 5);
// Two glyphs at FONT_MAX (61) plus padding: 2 * 0.6 * 61 + 2 * 4 = 81.2,
// anchored to the right of the squared span.
expect(w).toBeCloseTo(81.2, 5);
expect(x + w).toBeCloseTo(100, 5);
const g = halo(svg);
// Sized against the square side (100), not the 20-unit short axis: fill
// height 0.48 * 100 plus a 6.25-unit ring each side.
expect(g.h).toBeCloseTo(48 + 12.5, 5);
// Two glyphs at FONT_MAX (46) plus padding and ring:
// 2 * 0.6 * 46 + 2 * 4.5 + 2 * 6.25 = 76.7, anchored to the right of the
// squared span.
expect(g.w).toBeCloseTo(76.7, 5);
expect(g.x + g.w).toBeCloseTo(100, 5);
// Still in bounds of the normalised viewBox.
expect(x).toBeGreaterThanOrEqual(vb.minX);
expect(y).toBeGreaterThanOrEqual(vb.minY);
expect(x + w).toBeLessThanOrEqual(vb.minX + vb.width);
expect(y + h).toBeLessThanOrEqual(vb.minY + vb.height);
expect(g.x).toBeGreaterThanOrEqual(vb.minX);
expect(g.y).toBeGreaterThanOrEqual(vb.minY);
expect(g.x + g.w).toBeLessThanOrEqual(vb.minX + vb.width);
expect(g.y + g.h).toBeLessThanOrEqual(vb.minY + vb.height);
});
it('leaves a square viewBox untouched', () => {
+94 -67
View File
@@ -1,43 +1,48 @@
const SVG_NS = 'http://www.w3.org/2000/svg';
// A neutral white band with black digits, rather than the conventional red
// badge. The band guarantees contrast for the count whatever the base icon
// looks like, which matters because `faviconUrl` is admin-overridable and may
// be any artwork. A coloured badge cannot make that guarantee: Bulwark's own
// icon is rgb(219,45,84), so a red badge sat red-on-red.
const BADGE_FILL = '#ffffff';
const BADGE_TEXT_FILL = '#000000';
// A conventional notification badge — deep-rose fill, white digits — wearing a
// thin white keyline ring. Two designs preceded it, and the ring is what
// reconciles their failures:
//
// - A bare red pill: invisible on Bulwark's own icon, which is itself
// rgb(219,45,84). `faviconUrl` is admin-overridable, so no fill colour can
// ever be guaranteed to differ from the artwork behind it.
// - A flat white band with black digits (Gmail-measured geometry): contrast
// guaranteed, but it read as a white sticker slapped across the logo — a
// sharp-cornered slab covering most of the icon, with nothing marking it as
// a *badge*.
//
// The keyline is the guarantee the white band bought, without the slab: against
// dark or saturated artwork the white ring separates the badge; against white
// or pale artwork the ring vanishes but the deep fill is the edge. The pair
// covers every base icon, so neither half may be dropped. Digit contrast is
// fixed (white on the fill) and independent of the artwork entirely.
//
// The fill is the Bulwark brand red darkened to ~40% lightness: dark enough to
// read as a distinct badge on the brand icon itself (with the ring between
// them), and ~6.9:1 against the white digits.
const BADGE_FILL = '#af1d3f';
const BADGE_RING = '#ffffff';
const BADGE_TEXT_FILL = '#ffffff';
const BADGE_FONT = "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
// The badge is a Gmail-style band across the bottom of the icon, sized as a
// fraction of the icon's own coordinate space so it lands correctly whatever
// viewBox the base declares.
// Geometry, as fractions of the icon's own coordinate span so it lands
// correctly whatever viewBox the base declares. Reference pixel sizes are for
// the 16px tab rendering, which is what the fractions were tuned at.
//
// The fractions below are not invented: they are measured, pixel-by-pixel, off
// Gmail's real 16x16 tab favicon, which is the badge users actually compare this
// one against. Gmail's band is 10 of 16 px tall (0.625 of the icon span), its
// digits have a cap height of 7 of 16 px (0.44, i.e. a font-size of ~0.61 span),
// it is flush — edge to edge, and to the bottom, with no inset margin — and its
// corners carry a slight round, about 1px at 16px, which is roughly 0.1 of the
// band height. Not square, and emphatically not h/2.
//
// The box is sized to the label and centred, as Gmail's is: "5" must not squat
// on as much white as "99+" does.
//
// What keeps a three-glyph label legible is not the width — it is the small
// corner radius, plus budgeting the font against the FULL span rather than
// against the fitted box. The rounded-end pill that preceded this failed for the
// first reason: round ends (rx = h/2) squander their horizontal extent on the
// curve, which is exactly the space three glyphs need, so at 16px "99+" was an
// illegible smudge — and at one digit the same pill read as a plain circle. Do
// not reinstate rx = h/2. Because the font is budgeted against the full span,
// "99+" shrinks to the size that would fit edge to edge, and its box then grows
// to fill the icon width anyway; "9" and "47" render at the cap in a box that
// hugs them.
const BAND_HEIGHT = 0.625; // band height, as a fraction of the icon span
const FONT_MAX = 0.61; // font-size cap, as a fraction of the icon span
const PAD_FACTOR = 0.04; // horizontal padding, as a fraction of the icon span, each side
const CORNER_FACTOR = 0.1; // corner radius, as a fraction of band height
// What keeps a three-glyph label legible is the modest corner radius plus
// budgeting the font against the FULL span rather than against the fitted box.
// Round ends (rx = h/2) squander their horizontal extent on the curve, which is
// exactly the space three glyphs need — at 16px "99+" was an illegible smudge,
// and at one digit the same pill read as a plain circle. Do not reinstate
// rx = h/2. Because the font is budgeted against the full span, "99+" shrinks
// to the size that fits edge to edge and its badge grows to fill the icon
// width; "9" and "47" render at the cap in a badge that hugs them.
const RING_FACTOR = 0.0625; // keyline width, as a fraction of the icon span (~1px at 16)
const BADGE_HEIGHT = 0.48; // fill-rect height, as a fraction of the icon span (~7.7px at 16)
const FONT_MAX = 0.46; // font-size cap, as a fraction of the icon span
const PAD_FACTOR = 0.045; // horizontal padding inside the fill rect, per side, as a fraction of the span
const CORNER_FACTOR = 0.3; // fill-rect corner radius, as a fraction of the fill-rect height
const GLYPH_ADV = 0.6; // advance width per glyph, in em, for the sans badge font
// Counts above this render as "99+". Gmail caps at 20, and matching it was
@@ -135,42 +140,62 @@ export function renderBadgedFavicon(baseSvgSource: string, count: number): strin
root.setAttribute('viewBox', `${minX} ${minY} ${side} ${side}`);
const span = side;
const h = BAND_HEIGHT * span;
const ring = RING_FACTOR * span;
const h = BADGE_HEIGHT * span;
const fontMax = FONT_MAX * span;
const pad = PAD_FACTOR * span;
// The font first, budgeted against the FULL span: the largest size that
// would still leave the padding intact if the box ran edge to edge. That is
// the cap for one or two glyphs and a modest shrink for "99+".
const font = Math.min(fontMax, (span - 2 * pad) / (label.length * GLYPH_ADV));
// The box then hugs the label — never wider than the icon, anchored to the
// bottom-right corner. A three-glyph label, whose font was budgeted against
// the whole span, fills that span exactly; shorter labels get a narrower
// box, leaving the left of the base mark uncovered so the artwork stays
// recognisable. Gmail's own badge does the same: measured off its 16px
// favicon, a single digit sits hard right in a box about a third of the
// icon wide. Centring was tried and rejected — at one digit the box lands
// under the middle of the mark and bites a hole out of it.
// would still leave the ring and padding intact if the badge ran edge to
// edge. That is the cap for one or two glyphs and a modest shrink for "99+".
const font = Math.min(fontMax, (span - 2 * ring - 2 * pad) / (label.length * GLYPH_ADV));
// The badge then hugs the label — never wider than the icon, anchored to
// the bottom-right corner. A three-glyph label, whose font was budgeted
// against the whole span, fills that span exactly; shorter labels get a
// narrower badge, leaving the left of the base mark uncovered so the
// artwork stays recognisable. Centring was tried and rejected — at one
// digit the badge lands under the middle of the mark and bites a hole out
// of it.
const textW = label.length * GLYPH_ADV * font;
const w = Math.min(span, textW + 2 * pad);
const x = minX + span - w;
const y = minY + span - h;
const w = Math.min(span - 2 * ring, textW + 2 * pad);
// The ring is drawn as a white rect underneath the fill rect, inflated by
// the ring width on every side, not as a stroke: a stroke straddles the
// edge, halving the visible keyline and shrinking the fill. The halo is
// flush to the icon's bottom-right corner; the fill rect sits inset within
// it, so the ring shows on all four sides.
const haloW = w + 2 * ring;
const haloH = h + 2 * ring;
const haloX = minX + span - haloW;
const haloY = minY + span - haloH;
const x = haloX + ring;
const y = haloY + ring;
const rx = CORNER_FACTOR * h;
const bandRect = doc.createElementNS(SVG_NS, 'rect');
bandRect.setAttribute('x', String(x));
bandRect.setAttribute('y', String(y));
bandRect.setAttribute('width', String(w));
bandRect.setAttribute('height', String(h));
bandRect.setAttribute('rx', String(rx));
bandRect.setAttribute('ry', String(rx));
// Presentation attributes lose to any CSS rule in the same document, and a
// branded base is free to carry `<style>rect{fill:#db2d54}</style>` — which
// would paint the badge red-on-red, the exact failure the white band exists
// to prevent. A style attribute outranks a stylesheet rule, so set both: the
// attribute as the guarantee, the presentation attribute as the fallback.
bandRect.setAttribute('fill', BADGE_FILL);
bandRect.setAttribute('style', `fill:${BADGE_FILL}`);
// would repaint the badge in the artwork's own colour, the exact failure
// the keyline exists to prevent. A style attribute outranks a stylesheet
// rule, so set both: the attribute as the fallback, the style as the
// guarantee.
const haloRect = doc.createElementNS(SVG_NS, 'rect');
haloRect.setAttribute('x', String(haloX));
haloRect.setAttribute('y', String(haloY));
haloRect.setAttribute('width', String(haloW));
haloRect.setAttribute('height', String(haloH));
haloRect.setAttribute('rx', String(rx + ring));
haloRect.setAttribute('ry', String(rx + ring));
haloRect.setAttribute('fill', BADGE_RING);
haloRect.setAttribute('style', `fill:${BADGE_RING}`);
const fillRect = doc.createElementNS(SVG_NS, 'rect');
fillRect.setAttribute('x', String(x));
fillRect.setAttribute('y', String(y));
fillRect.setAttribute('width', String(w));
fillRect.setAttribute('height', String(h));
fillRect.setAttribute('rx', String(rx));
fillRect.setAttribute('ry', String(rx));
fillRect.setAttribute('fill', BADGE_FILL);
fillRect.setAttribute('style', `fill:${BADGE_FILL}`);
const text = doc.createElementNS(SVG_NS, 'text');
text.setAttribute('x', String(x + w / 2));
@@ -178,18 +203,20 @@ export function renderBadgedFavicon(baseSvgSource: string, count: number): strin
text.setAttribute('text-anchor', 'middle');
text.setAttribute('dominant-baseline', 'central');
text.setAttribute('font-family', BADGE_FONT);
// 500, not 700: at true 16px a bold count read visibly heavier than the
// equivalent badge in Gmail's tab, which is the thing users compare it to.
text.setAttribute('font-weight', '500');
// 600, not the 500 the black-on-white band used: light-on-dark digits lose
// stroke weight to anti-aliasing at 16px, so they need the extra grade to
// read as crisply as dark-on-light ones did.
text.setAttribute('font-weight', '600');
text.setAttribute('font-size', String(font));
text.setAttribute('fill', BADGE_TEXT_FILL);
text.setAttribute(
'style',
`fill:${BADGE_TEXT_FILL};font-family:${BADGE_FONT};font-weight:500;font-size:${font}px`,
`fill:${BADGE_TEXT_FILL};font-family:${BADGE_FONT};font-weight:600;font-size:${font}px`,
);
text.textContent = label;
root.appendChild(bandRect);
root.appendChild(haloRect);
root.appendChild(fillRect);
root.appendChild(text);
const serialised = new XMLSerializer().serializeToString(doc);