diff --git a/lib/__tests__/favicon-badge.test.ts b/lib/__tests__/favicon-badge.test.ts
index 699f4b365..ae4fda2a8 100644
--- a/lib/__tests__/favicon-badge.test.ts
+++ b/lib/__tests__/favicon-badge.test.ts
@@ -3,21 +3,36 @@ import { formatBadgeCount, renderBadgedFavicon } from '@/lib/favicon-badge';
const BASE_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 the renderer appends, identified by its fill. */
-function band(svg: string): { x: number; y: number; w: number; h: number; rx: number } {
- const match =
- /]*\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(
+ `]*\\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(/]*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('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(/]*fill="#ffffff"/);
- expect(svg).toMatch(/]*fill="#000000"/);
+ expect(svg).toMatch(new RegExp(`]*fill="${RING}"`));
+ expect(svg).toMatch(new RegExp(`]*fill="${FILL}"`));
+ expect(svg).toMatch(/]*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 = {
- '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 = {
+ '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(/]*font-weight="500"/);
- expect(svg).toMatch(/]*style="[^"]*font-weight:\s*500/);
+ expect(svg).toMatch(/]*font-weight="600"/);
+ expect(svg).toMatch(/]*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 = ``;
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 = ``;
const svg = decode(renderBadgedFavicon(STYLED, 3)!);
- expect(svg).toMatch(/]*style="[^"]*fill:\s*#ffffff/);
- expect(svg).toMatch(/]*style="[^"]*fill:\s*#000000/);
+ expect(svg).toMatch(new RegExp(`]*style="[^"]*fill:\\s*${RING}`));
+ expect(svg).toMatch(new RegExp(`]*style="[^"]*fill:\\s*${FILL}`));
+ expect(svg).toMatch(/]*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', () => {
diff --git a/lib/favicon-badge.ts b/lib/favicon-badge.ts
index d717fd0ed..99132ff62 100644
--- a/lib/favicon-badge.ts
+++ b/lib/favicon-badge.ts
@@ -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 `` — 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);