feat: show unread count badge on favicon
Closes #560. Composes the active inbox's unread count over the base favicon as an SVG badge, served as a percent-encoded data: URL, so new mail is visible on a tab that is not focused — including when the browser collapses tabs to icon-only, where a title-based count disappears entirely. The base icon is read from the rendered <link rel="icon"> rather than from config, so admin and per-domain branding overrides are inherited for free: the count is drawn on whatever logo the deployment actually serves. Keeping the badge in SVG rather than rasterising to a canvas also means the browser can rasterise it at whatever size it asks for, so a HiDPI tab is not served a 16px bitmap. Notes on the approach: - The badge link is an *additional* icon link that we append and mark as ours; we never remove or mutate a link we did not create. Next's metadata icons are rendered by React, which keeps a fiber pointing at that DOM node, so removing it would leave React holding a detached node and throw "Cannot read properties of null (reading 'removeChild')" on the next commit that deletes the fiber. Appending instead means the last-declared icon wins, and non-SVG fallback links survive with their type/sizes intact. (The usual recipe for this feature — assign canvas.toDataURL() to the existing link's href — does both of the things that break here.) - Every change of state is an *insertion* of a fresh link of ours, never a mutation or a removal, because that is the only signal a browser reliably re-reads the favicon on. Firefox ignores an in-place href change, and it equally ignores a removal — so clearing the badge by deleting our link left a stale count painted on the tab until a hard reload. Clearing it instead inserts a new link of ours carrying the original base href. - Holding last place has to be defended: on a client-side navigation React re-hoists its metadata icon link into <head>, landing after ours, and the base icon silently wins again. A MutationObserver on <head> moves our own link back to the end whenever a foreign icon link appears — moving only our node, never anyone else's. It no-ops once ours is last again, so a move cannot feed itself. - The badge is a full-width band across the foot of the icon, drawn to the metrics measured from Gmail's own 16px favicon: band height 0.625 of the icon, digit cap height 0.44, flush to the edges, corners rounded by about a pixel. Full width is what keeps a three-glyph label legible — rounded ends waste exactly the horizontal space it needs. Neutral white with black digits rather than the conventional red: faviconUrl is admin-overridable and Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red. - The base SVG may be admin-uploaded, and the branding route deliberately serves it under a sandboxing CSP because SVG can carry script. Re-emitting it as a same-origin data: URL would un-fence that, so script, foreignObject and every on* handler are stripped before serialising. - Mounted in the root layout, not on the mail route: the badge belongs to the tab, so mounting it on the page would clear it on every hop to settings, calendar or contacts.
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
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>`;
|
||||
|
||||
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,
|
||||
);
|
||||
expect(match).not.toBeNull();
|
||||
const [, x, y, w, h, rx] = match!.map(Number);
|
||||
return { x, y, w, h, rx };
|
||||
}
|
||||
|
||||
function fontSize(svg: string): number {
|
||||
return Number(/<text[^>]*font-size="([\d.]+)"/.exec(svg)![1]);
|
||||
}
|
||||
|
||||
function viewBoxOf(svg: string): { minX: number; minY: number; width: number; height: number } {
|
||||
const [minX, minY, width, height] = /viewBox="([^"]+)"/
|
||||
.exec(svg)![1]
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return { minX, minY, width, height };
|
||||
}
|
||||
|
||||
describe('formatBadgeCount', () => {
|
||||
it('returns an empty string for zero and below', () => {
|
||||
expect(formatBadgeCount(0)).toBe('');
|
||||
expect(formatBadgeCount(-3)).toBe('');
|
||||
});
|
||||
|
||||
it('returns the count verbatim from 1 to 99', () => {
|
||||
expect(formatBadgeCount(1)).toBe('1');
|
||||
expect(formatBadgeCount(9)).toBe('9');
|
||||
expect(formatBadgeCount(47)).toBe('47');
|
||||
expect(formatBadgeCount(99)).toBe('99');
|
||||
});
|
||||
|
||||
it('caps at 99+ above 99', () => {
|
||||
// Gmail caps at 20; matching it was tried and reverted. A lower cap means a
|
||||
// typical inbox needs three glyphs almost always, and three glyphs do not
|
||||
// fit at the full font size — so "99+" rendered permanently smaller than a
|
||||
// real two-digit count would have.
|
||||
expect(formatBadgeCount(100)).toBe('99+');
|
||||
expect(formatBadgeCount(133)).toBe('99+');
|
||||
expect(formatBadgeCount(1000)).toBe('99+');
|
||||
});
|
||||
|
||||
it('returns an empty string for non-finite input', () => {
|
||||
expect(formatBadgeCount(Number.NaN)).toBe('');
|
||||
expect(formatBadgeCount(Number.POSITIVE_INFINITY)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderBadgedFavicon', () => {
|
||||
it('returns null when the count is zero', () => {
|
||||
expect(renderBadgedFavicon(BASE_SVG, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the source is not SVG', () => {
|
||||
expect(renderBadgedFavicon('this is not svg', 3)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the root element is not <svg>', () => {
|
||||
expect(renderBadgedFavicon('<html><body/></html>', 3)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the root has no viewBox', () => {
|
||||
const noViewBox = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"/>`;
|
||||
expect(renderBadgedFavicon(noViewBox, 3)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a percent-encoded svg data URL', () => {
|
||||
const url = renderBadgedFavicon(BASE_SVG, 3);
|
||||
expect(url).not.toBeNull();
|
||||
expect(url!.startsWith('data:image/svg+xml,')).toBe(true);
|
||||
});
|
||||
|
||||
it('draws a badge band and the count text', () => {
|
||||
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
||||
expect(svg).toContain('<rect');
|
||||
expect(svg).toContain('>3<');
|
||||
});
|
||||
|
||||
it('renders 99+ for large counts', () => {
|
||||
const svg = decode(renderBadgedFavicon(BASE_SVG, 250)!);
|
||||
expect(svg).toContain('>99+<');
|
||||
});
|
||||
|
||||
it('preserves the base artwork and its clipPath id', () => {
|
||||
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
||||
expect(svg).toContain('id="_clip1"');
|
||||
expect(svg).toContain('#123456');
|
||||
});
|
||||
|
||||
it('overrides pt-unit width and height with unitless 16 and keeps the viewBox', () => {
|
||||
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
||||
expect(svg).toContain('width="16"');
|
||||
expect(svg).toContain('height="16"');
|
||||
expect(svg).toContain('viewBox="0 0 1000 1000"');
|
||||
expect(svg).not.toContain('1000pt');
|
||||
});
|
||||
|
||||
it('draws a white band with black digits, so the count stays legible over any base icon', () => {
|
||||
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
||||
expect(svg).toMatch(/<rect[^>]*fill="#ffffff"/);
|
||||
expect(svg).toMatch(/<text[^>]*fill="#000000"/);
|
||||
});
|
||||
|
||||
it('shrinks the font as the label grows so three glyphs still fit', () => {
|
||||
const one = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
||||
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
|
||||
expect(fontSize(three)).toBeLessThan(fontSize(one));
|
||||
});
|
||||
|
||||
it('returns null rather than throwing when the source contains a lone surrogate', () => {
|
||||
const bad = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><title>abc\uD800def</title></svg>`;
|
||||
expect(() => renderBadgedFavicon(bad, 3)).not.toThrow();
|
||||
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;
|
||||
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)
|
||||
};
|
||||
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);
|
||||
expect(fontSize(svg)).toBeCloseTo(want.font, 5);
|
||||
expect(y).toBeCloseTo(375, 5);
|
||||
expect(h).toBeCloseTo(625, 5);
|
||||
expect(rx).toBeCloseTo(62.5, 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.
|
||||
const cases: [string, number, number][] = [
|
||||
// [base svg, minX, span]
|
||||
[BASE_SVG, 0, 1000],
|
||||
[`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 24 24"><rect x="-4" y="-4" width="24" height="24" fill="#123456"/></svg>`, -4, 24],
|
||||
];
|
||||
for (const [svgSource, minX, span] of cases) {
|
||||
for (const count of [7, 47, 250]) {
|
||||
const { x, w } = band(decode(renderBadgedFavicon(svgSource, count)!));
|
||||
expect(x + w).toBeCloseTo(minX + span, 5);
|
||||
expect(x).toBeGreaterThanOrEqual(minX);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
const one = decode(renderBadgedFavicon(BASE_SVG, 7)!);
|
||||
const two = decode(renderBadgedFavicon(BASE_SVG, 47)!);
|
||||
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
|
||||
expect(fontSize(one)).toBeCloseTo(FONT_MAX, 5);
|
||||
expect(fontSize(two)).toBeCloseTo(FONT_MAX, 5);
|
||||
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.
|
||||
for (const count of [7, 47, 250]) {
|
||||
const { h, rx } = band(decode(renderBadgedFavicon(BASE_SVG, count)!));
|
||||
expect(rx).toBeCloseTo(0.1 * 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.
|
||||
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
||||
expect(svg).toMatch(/<text[^>]*font-weight="500"/);
|
||||
expect(svg).toMatch(/<text[^>]*style="[^"]*font-weight:\s*500/);
|
||||
});
|
||||
|
||||
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.
|
||||
for (const count of [7, 47, 250]) {
|
||||
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
|
||||
const { x, y, w, h } = band(svg);
|
||||
expect(x).toBeGreaterThanOrEqual(0);
|
||||
expect(y).toBeGreaterThanOrEqual(0);
|
||||
expect(x + w).toBeLessThanOrEqual(1000);
|
||||
expect(y + h).toBeLessThanOrEqual(1000);
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
// legitimately negative, so the offset is genuinely pinned.
|
||||
it('anchors the band 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);
|
||||
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.
|
||||
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
|
||||
// same at every label length.
|
||||
const GLYPH_ADV = 0.6;
|
||||
const PAD_FACTOR = 0.04;
|
||||
for (const count of [7, 47, 250]) {
|
||||
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
|
||||
const { w } = band(svg);
|
||||
const label = count > 99 ? '99+' : String(count);
|
||||
const textW = label.length * GLYPH_ADV * fontSize(svg);
|
||||
const pad = PAD_FACTOR * 1000;
|
||||
expect(textW + 2 * pad).toBeLessThanOrEqual(w + 1e-6);
|
||||
}
|
||||
});
|
||||
|
||||
it('percent-encodes the payload, so a "#" in a fill cannot truncate the data URL', () => {
|
||||
const url = renderBadgedFavicon(BASE_SVG, 3)!;
|
||||
// encodeURI leaves "#" bare, which the browser reads as a fragment
|
||||
// delimiter: everything after the first colour would be silently dropped.
|
||||
expect(url).toContain('%23');
|
||||
expect(url).not.toContain('#');
|
||||
});
|
||||
|
||||
it('returns an empty label, and no badge, for a fractional count below one', () => {
|
||||
expect(formatBadgeCount(0.5)).toBe('');
|
||||
expect(renderBadgedFavicon(BASE_SVG, 0.5)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the root svg has no SVG namespace', () => {
|
||||
// Non-null but unrenderable: a data URL built from this would show nothing.
|
||||
const noNs = `<svg viewBox="0 0 100 100"><rect width="100" height="100"/></svg>`;
|
||||
expect(renderBadgedFavicon(noNs, 3)).toBeNull();
|
||||
});
|
||||
|
||||
it('beats a stylesheet in the base SVG, keeping the badge white-on-black', () => {
|
||||
// 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.
|
||||
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/);
|
||||
});
|
||||
|
||||
it('strips scripts, foreignObject and event handlers from the base SVG', () => {
|
||||
// The base may be an admin-uploaded file, which upstream serves under a
|
||||
// sandboxing CSP precisely because SVG can carry script. Re-emitting it as a
|
||||
// same-origin data: URL would un-fence it, so sanitise before serialising.
|
||||
const HOSTILE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" onload="alert(1)"><script>alert(2)</script><foreignObject width="100" height="100"><body xmlns="http://www.w3.org/1999/xhtml">hi</body></foreignObject><rect width="100" height="100" onclick="alert(3)" ONMOUSEOVER="alert(4)" fill="#123456"/></svg>`;
|
||||
const url = renderBadgedFavicon(HOSTILE, 3)!;
|
||||
expect(url).not.toBeNull();
|
||||
const svg = decode(url);
|
||||
expect(svg).not.toContain('<script');
|
||||
expect(svg).not.toContain('foreignObject');
|
||||
expect(svg.toLowerCase()).not.toContain('onload');
|
||||
expect(svg.toLowerCase()).not.toContain('onclick');
|
||||
expect(svg.toLowerCase()).not.toContain('onmouseover');
|
||||
expect(svg).not.toContain('alert');
|
||||
// The legitimate artwork survives.
|
||||
expect(svg).toContain('#123456');
|
||||
});
|
||||
|
||||
it('normalises a non-square viewBox to a square, so the badge stays legible', () => {
|
||||
// A 100x20 wordmark: span = min(w, h) = 20 previously produced a ~2px-tall
|
||||
// smudge on a 16px icon. Squaring the viewBox first sizes the badge against
|
||||
// the rendered box instead.
|
||||
const WORDMARK = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><rect width="100" height="20" fill="#123456"/></svg>`;
|
||||
const svg = decode(renderBadgedFavicon(WORDMARK, 42)!);
|
||||
|
||||
const vb = viewBoxOf(svg);
|
||||
expect(vb.width).toBe(100);
|
||||
expect(vb.height).toBe(100);
|
||||
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);
|
||||
// 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);
|
||||
});
|
||||
|
||||
it('leaves a square viewBox untouched', () => {
|
||||
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
||||
expect(svg).toContain('viewBox="0 0 1000 1000"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
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';
|
||||
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.
|
||||
//
|
||||
// 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
|
||||
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
|
||||
// tried and reverted: the cap decides how often the label needs three glyphs,
|
||||
// and three glyphs do not fit at the full font size. Capping at 20 meant a
|
||||
// typical inbox showed "20+" at 84% of the cap size essentially always, where
|
||||
// capping at 99 shows a real two-digit count at full size. Bigger digits and a
|
||||
// number you can act on beat parity with Gmail's ceiling.
|
||||
const BADGE_MAX = 99;
|
||||
|
||||
/**
|
||||
* Formats an unread count for display in the badge.
|
||||
* Returns an empty string when there is nothing to show.
|
||||
*/
|
||||
export function formatBadgeCount(count: number): string {
|
||||
// `< 1`, not `<= 0`: a fractional count such as 0.5 would otherwise floor to
|
||||
// 0 and draw a "0" badge, since String(0) is truthy.
|
||||
if (!Number.isFinite(count) || count < 1) return '';
|
||||
const whole = Math.floor(count);
|
||||
return whole > BADGE_MAX ? `${BADGE_MAX}+` : String(whole);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips anything active from the base SVG.
|
||||
*
|
||||
* The base may be an admin-uploaded file, which the branding route deliberately
|
||||
* serves under a sandboxing CSP because SVG can carry script (see
|
||||
* app/api/admin/branding/[filename]/route.ts). Re-emitting it verbatim as a
|
||||
* same-origin `data:` URL inside our own document would un-fence exactly what
|
||||
* that CSP fences, so remove script, foreignObject and every on* handler first.
|
||||
*/
|
||||
function sanitiseSvg(doc: Document): void {
|
||||
doc.querySelectorAll('script, foreignObject').forEach((el) => el.remove());
|
||||
|
||||
doc.querySelectorAll('*').forEach((el) => {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (attr.name.toLowerCase().startsWith('on')) {
|
||||
el.removeAttributeNS(attr.namespaceURI, attr.localName);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes an unread badge over an SVG favicon and returns it as a data URL.
|
||||
*
|
||||
* Returns null — meaning "leave the favicon alone" — when the count is zero,
|
||||
* or when the source is not usable SVG. Never throws.
|
||||
*/
|
||||
export function renderBadgedFavicon(baseSvgSource: string, count: number): string | null {
|
||||
const label = formatBadgeCount(count);
|
||||
if (!label) return null;
|
||||
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(baseSvgSource, 'image/svg+xml');
|
||||
|
||||
if (doc.querySelector('parsererror')) return null;
|
||||
|
||||
const root = doc.documentElement;
|
||||
// The namespace, not just the tag name: an <svg> with no xmlns parses fine
|
||||
// but renders as nothing, so it would yield a non-null, blank data URL.
|
||||
if (!root || root.localName !== 'svg' || root.namespaceURI !== SVG_NS) return null;
|
||||
|
||||
const viewBox = root.getAttribute('viewBox');
|
||||
if (!viewBox) return null;
|
||||
|
||||
const [rawMinX, rawMinY, rawWidth, rawHeight] = viewBox.trim().split(/[\s,]+/).map(Number);
|
||||
if (
|
||||
![rawMinX, rawMinY, rawWidth, rawHeight].every(Number.isFinite) ||
|
||||
rawWidth <= 0 ||
|
||||
rawHeight <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sanitiseSvg(doc);
|
||||
|
||||
// The base declares "1000pt"; point units in a favicon are unreliable.
|
||||
// Unitless 16 with the viewBox retained lets the browser rasterise cleanly
|
||||
// at any size it asks for.
|
||||
root.setAttribute('width', '16');
|
||||
root.setAttribute('height', '16');
|
||||
|
||||
// Normalise the viewBox to a square, centred on the original, before doing
|
||||
// any badge maths. Sizing the badge off min(width, height) double-penalised
|
||||
// a non-square base: a 100x20 wordmark produced a ~2px-tall smudge on a
|
||||
// 16px icon. Squaring first sizes the badge against the box the icon is
|
||||
// actually painted into. It is a no-op for a square viewBox (Bulwark's own
|
||||
// is 0 0 1000 1000). Caveat: a base that pairs a non-square viewBox with
|
||||
// preserveAspectRatio="none" will now letterbox rather than stretch — an
|
||||
// acceptable, arguably better, trade for a favicon, which is always square.
|
||||
const side = Math.max(rawWidth, rawHeight);
|
||||
const minX = rawMinX - (side - rawWidth) / 2;
|
||||
const minY = rawMinY - (side - rawHeight) / 2;
|
||||
root.setAttribute('viewBox', `${minX} ${minY} ${side} ${side}`);
|
||||
|
||||
const span = side;
|
||||
const h = BAND_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.
|
||||
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 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}`);
|
||||
|
||||
const text = doc.createElementNS(SVG_NS, 'text');
|
||||
text.setAttribute('x', String(x + w / 2));
|
||||
text.setAttribute('y', String(y + h / 2));
|
||||
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');
|
||||
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`,
|
||||
);
|
||||
text.textContent = label;
|
||||
|
||||
root.appendChild(bandRect);
|
||||
root.appendChild(text);
|
||||
|
||||
const serialised = new XMLSerializer().serializeToString(doc);
|
||||
|
||||
// Percent-encoding rather than base64: btoa throws on any character outside
|
||||
// Latin-1, which a branded SVG may well contain. encodeURIComponent itself
|
||||
// throws on an unpaired surrogate, so this whole tail is guarded. It must be
|
||||
// encodeURIComponent, not encodeURI: the latter leaves "#" bare, and a bare
|
||||
// "#" in a colour truncates the data URL at the first fill.
|
||||
return `data:image/svg+xml,${encodeURIComponent(serialised)}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user