commit 7daa86515342c77a155a31d73011626fd700eaee
parent 1847e6c78a0aee7409678f6182bbd85d9983cc02
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 03:06:38 -0400
common: normalizeSocialSvg themes only a single-colour icon (review should-fix) — at most one solid colour across root and children outside <mask> (spellings folded: #fff = white = rgb(255,255,255)) becomes currentColor; two or more (YouTube's mark, a badge) keep every colour as pasted; animation tags (fill=freeze/remove) passed over; a self-closing <mask/> no longer swallows what follows; +5 unit tests (13)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 123 insertions(+), 36 deletions(-)
diff --git a/common/lib/normalizeSocialSvg.test.ts b/common/lib/normalizeSocialSvg.test.ts
@@ -7,8 +7,10 @@ import { normalizeSocialSvg } from "./settingsSchema";
// child that paints itself kept its colour: the operator's x.com icon — X's
// official file, `fill="none"` on the root and `fill="white"` on its one path —
// was white on the light footer (1.11:1; release 10, slice O's screenshots).
-// Every element's fill colour now becomes currentColor (the footer link's
-// colour, which follows the theme); no paint, gradients and mask values stay.
+// A SINGLE-colour icon's fills — root and children — now become currentColor
+// (the footer link's colour, which follows the theme); no paint, gradients and
+// mask values stay. An icon of two or more colours draws its shape with them
+// (YouTube's mark, a badge) and keeps every colour as pasted (review, O1).
// The two icons in the operator's settings.json, verbatim (2026-09-28).
const X_COM = `<svg aria-hidden="true" viewBox="0 0 1200 1227" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -22,6 +24,13 @@ const KIWI_FARMS = `<svg aria-hidden="true" fill="currentColor" version="1.1" id
const FIXTURE = `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24"><path d="M1 1h2"/></svg>`;
const BARE = `<svg viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/></svg>`;
+// YouTube's official mark (Wikimedia's "YouTube full-color icon"): a red
+// rounded rectangle and a white play triangle. Two colours ARE its shape.
+const YOUTUBE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 159 110"><path d="m154 17.5c-1.82-6.73-7.07-12-13.8-13.8-9.04-3.49-96.6-5.2-122 0.1-6.73 1.82-12 7.07-13.8 13.8-4.08 17.9-4.39 56.6 0.1 74.9 1.82 6.73 7.07 12 13.8 13.8 17.9 4.12 103 4.7 122 0 6.73-1.82 12-7.07 13.8-13.8 4.35-19.5 4.66-55.8-0.1-75z" fill="#f00"/><path d="m105 55-40.8-23.4v46.8z" fill="#fff"/></svg>`;
+
+// The common "badge": a dark disc with a white glyph cut into it.
+const BADGE = `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="#1d1d1d"/><path fill="#ffffff" d="M7 7h10v10H7z"/></svg>`;
+
// Every fill attribute / style declaration, in document order.
const fills = (svg: string) =>
[...svg.matchAll(/\sfill\s*=\s*"([^"]*)"|[;"']\s*fill\s*:\s*([^;"']*)/g)].map(
@@ -69,20 +78,42 @@ test("a bare icon still gains the root's fill and aria-hidden, as before", () =>
);
});
-test("every colour spelling is themed, on the root and in a style; other fill-* are not", () => {
+test("one colour in every spelling is themed, on the root and in a style; other fill-* are not", () => {
const out = normalized(
- `<svg viewBox="0 0 8 8" fill="#FFFFFF"><path fill='#000' fill-rule="evenodd" d="M0 0"/>` +
- `<rect style="opacity:.5; fill: rgb(255,255,255) ;fill-opacity:.4" fill-opacity="0.5"/>` +
- `<circle fill="black"/><ellipse fill="url(#g) #fff"/><g fill="inherit"><path fill="transparent"/></g></svg>`,
+ `<svg viewBox="0 0 8 8" fill="#FFFFFF"><path fill='#fff' fill-rule="evenodd" d="M0 0"/>` +
+ `<rect style="opacity:.5; fill: rgb(255, 255, 255) ;fill-opacity:.4" fill-opacity="0.5"/>` +
+ `<circle fill="White"/><ellipse fill="url(#g) #000"/><g fill="inherit"><path fill="transparent"/></g></svg>`,
);
assert.equal(
out,
`<svg aria-hidden="true" viewBox="0 0 8 8" fill="currentColor"><path fill='currentColor' fill-rule="evenodd" d="M0 0"/>` +
`<rect style="opacity:.5; fill: currentColor;fill-opacity:.4" fill-opacity="0.5"/>` +
- `<circle fill="currentColor"/><ellipse fill="url(#g) #fff"/><g fill="inherit"><path fill="transparent"/></g></svg>`,
+ `<circle fill="currentColor"/><ellipse fill="url(#g) #000"/><g fill="inherit"><path fill="transparent"/></g></svg>`,
+ );
+});
+
+test("YouTube's mark keeps its red and its white: two colours are its shape", () => {
+ const out = normalized(YOUTUBE);
+ assert.deepEqual(fills(out), ["currentColor", "#f00", "#fff"]);
+ // Only the root gains what it always did (a fill for any unpainted child,
+ // aria-hidden); the paths are as pasted.
+ assert.equal(
+ out,
+ YOUTUBE.replace("<svg ", '<svg aria-hidden="true" fill="currentColor" '),
);
});
+test("a dark disc with a white glyph keeps both colours", () => {
+ const out = normalized(BADGE);
+ assert.deepEqual(fills(out), ["currentColor", "#1d1d1d", "#ffffff"]);
+ assert.equal(out, BADGE.replace("<svg ", '<svg aria-hidden="true" fill="currentColor" '));
+});
+
+test("a root colour and a different child colour are two colours: nothing is themed", () => {
+ const raw = `<svg aria-hidden="true" viewBox="0 0 8 8" fill="#fff"><path fill="#000" d="M0 0"/><path d="M1 1"/></svg>`;
+ assert.equal(normalized(raw), raw);
+});
+
test("a mask's fills are how much shows through, not a colour: kept", () => {
const raw =
`<svg viewBox="0 0 8 8"><mask id="m"><rect fill="white" width="8" height="8"/><circle fill="black" r="2"/></mask>` +
@@ -92,8 +123,30 @@ test("a mask's fills are how much shows through, not a colour: kept", () => {
assert.ok(out.includes(`<path mask="url(#m)" fill="currentColor" d="M0 0"/>`));
});
+test("an animation's fill is timing, not paint: never touched, never counted", () => {
+ const raw =
+ `<svg viewBox="0 0 8 8"><path fill="#fff" d="M0 0">` +
+ `<animate attributeName="opacity" from="0" to="1" dur="1s" fill="freeze"/>` +
+ `<animateTransform attributeName="transform" type="rotate" dur="1s" fill="remove"/></path>` +
+ `<set attributeName="opacity" to="1" fill="freeze"/></svg>`;
+ const out = normalized(raw);
+ assert.ok(out.includes(`<path fill="currentColor" d="M0 0">`), "the one colour is themed");
+ assert.ok(out.includes(`dur="1s" fill="freeze"/>`));
+ assert.ok(out.includes(`type="rotate" dur="1s" fill="remove"/>`));
+ assert.ok(out.includes(`<set attributeName="opacity" to="1" fill="freeze"/>`));
+});
+
+test("an empty self-closing <mask/> does not swallow what follows it", () => {
+ const raw =
+ `<svg viewBox="0 0 8 8"><mask id="a"/><path fill="#fff" d="M0 0"/>` +
+ `<mask id="b"><rect fill="white" width="8" height="8"/></mask></svg>`;
+ const out = normalized(raw);
+ assert.ok(out.includes(`<mask id="a"/><path fill="currentColor" d="M0 0"/>`));
+ assert.ok(out.includes(`<mask id="b"><rect fill="white" width="8" height="8"/></mask>`));
+});
+
test("idempotent: a stored icon normalizes to itself", () => {
- for (const raw of [X_COM, KIWI_FARMS, FIXTURE, BARE]) {
+ for (const raw of [X_COM, KIWI_FARMS, FIXTURE, BARE, YOUTUBE, BADGE]) {
const once = normalized(raw);
assert.equal(normalized(once), once);
}
diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts
@@ -1294,52 +1294,78 @@ export function parseSocialLinks(input: unknown): SocialLink[] {
return out;
}
-// A paint that is not a colour to swap for the footer's: no paint (`none`,
-// `transparent`), a paint server (a gradient or pattern, `url(#…)`, with or
-// without a fallback), or what the element takes from its parent already.
+// A paint that is not a colour: no paint (`none`, `transparent`), a paint
+// server (a gradient or pattern, `url(#…)`, with or without a fallback), or
+// what the element takes from its parent already. Never counted, never swapped.
const KEPT_PAINT = /^(?:none|transparent|currentcolor|inherit)$|^url\(/i;
-// Any other paint — white, black, #fff, rgb(…), a named colour — was drawn for
-// ONE background and vanishes on another (the official x.com icon is a
-// `<path fill="white">`: 1.11:1 on the light footer). It becomes
-// `currentColor`, the footer link's colour, which follows the theme.
-function themedPaint(value: string): string {
- return KEPT_PAINT.test(value.trim()) ? value : "currentColor";
+// One solid colour however it is spelled, so `#FFF`, `#ffffff`, `white` and
+// `rgb(255, 255, 255)` count as ONE colour of an icon, not four.
+function paintKey(value: string): string {
+ const v = value.trim().toLowerCase().replace(/\s+/g, "");
+ if (v === "white") return "#ffffff";
+ if (v === "black") return "#000000";
+ const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(v);
+ if (short) return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
+ const rgb = /^rgb\((\d{1,3}),(\d{1,3}),(\d{1,3})\)$/.exec(v);
+ if (rgb) {
+ return `#${rgb
+ .slice(1, 4)
+ .map((n) => Math.min(255, Number(n)).toString(16).padStart(2, "0"))
+ .join("")}`;
+ }
+ return v;
}
-// Every fill of one tag: its `fill` attribute and any `fill:` declaration in
-// its `style` (which beats the attribute). `fill-rule`, `fill-opacity`,
-// strokes and gradient stops are not fills and are left alone.
-function themeTagFills(tag: string): string {
+// Every fill of one tag, mapped: its `fill` attribute and any `fill:`
+// declaration in its `style` (which beats the attribute). `fill-rule`,
+// `fill-opacity`, strokes and gradient stops are not fills and are left alone.
+function mapTagFills(tag: string, paint: (value: string) => string): string {
return tag
.replace(
/(\sfill\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi,
(_m, pre: string, dq?: string, sq?: string) =>
- dq !== undefined
- ? `${pre}"${themedPaint(dq)}"`
- : `${pre}'${themedPaint(sq ?? "")}'`,
+ dq !== undefined ? `${pre}"${paint(dq)}"` : `${pre}'${paint(sq ?? "")}'`,
)
.replace(
/(\sstyle\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi,
(_m, pre: string, dq?: string, sq?: string) => {
const css = (dq ?? sq ?? "").replace(
/(^|;)(\s*fill\s*:\s*)([^;]*)/gi,
- (_d, lead: string, prop: string, v: string) =>
- `${lead}${prop}${themedPaint(v)}`,
+ (_d, lead: string, prop: string, v: string) => `${lead}${prop}${paint(v)}`,
);
return dq !== undefined ? `${pre}"${css}"` : `${pre}'${css}'`;
},
);
}
+// The children, tag by tag. Passed over whole, never read or changed:
+// - a <mask> (its white and black say how much shows through, not what
+// colour) — self-closing first, so an empty `<mask …/>` cannot swallow
+// everything up to the next `</mask>`;
+// - an animation tag, whose `fill="freeze"` / `fill="remove"` is timing.
+const CHILD_TAG = /<mask\b[^>]*\/>|<mask\b[\s\S]*?<\/mask\s*>|<[a-zA-Z][^>]*>/gi;
+const PASSED_OVER = /^<(?:mask|animate\w*|set)\b/i;
+
+function mapChildFills(body: string, paint: (value: string) => string): string {
+ return body.replace(CHILD_TAG, (m) => (PASSED_OVER.test(m) ? m : mapTagFills(m, paint)));
+}
+
// Normalize an admin-provided SVG snippet for inline use in the export
// footer. Returns null on anything that looks unsafe or unrenderable.
// Steps: trim, allowlist-check, strip width/height, force fill="currentColor"
// + aria-hidden on the root <svg>. Requires a viewBox so the icon scales.
-// Every element's fill colour — the root's and its children's — becomes
-// `currentColor` (themedPaint above), so a white or black icon shows on every
-// theme; `none`, gradients and the fills inside a <mask> (there white and black
-// say how much shows through, not what colour) are kept. Idempotent.
+//
+// A SINGLE-COLOUR icon follows the theme: when its fills — the root's and its
+// children's together, outside a <mask> — hold at most one solid colour, each
+// becomes `currentColor`, the footer link's colour. Drawn for one background,
+// such an icon vanishes on another (X's official logo is a `<path
+// fill="white">`: 1.11:1 on the light footer). An icon of TWO or more colours
+// draws its shape with them (YouTube's mark is a red rounded rectangle with a
+// white play triangle; flattened, it is a blank rectangle), carries its own
+// contrast, and keeps every colour as pasted. Paints that are not colours
+// (`none`, `url(…)`, `inherit`) are never counted or changed. Idempotent: a
+// themed icon has no solid colour left.
export function normalizeSocialSvg(raw: string): string | null {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
@@ -1354,24 +1380,32 @@ export function normalizeSocialSvg(raw: string): string | null {
const openEnd = trimmed.indexOf(">");
if (openEnd < 0) return null;
let opening = trimmed.slice(0, openEnd);
- const rest = trimmed.slice(openEnd);
+ let body = trimmed.slice(openEnd);
if (!/\sviewBox\s*=\s*"/i.test(opening)) return null;
opening = opening.replace(/\s(width|height)\s*=\s*"[^"]*"/gi, "");
opening = opening.replace(/\s(width|height)\s*=\s*'[^']*'/gi, "");
- opening = themeTagFills(opening);
+ const colours = new Set<string>();
+ const count = (v: string) => {
+ if (!KEPT_PAINT.test(v.trim())) colours.add(paintKey(v));
+ return v;
+ };
+ mapTagFills(opening, count);
+ mapChildFills(body, count);
+ if (colours.size <= 1) {
+ const themed = (v: string) => (KEPT_PAINT.test(v.trim()) ? v : "currentColor");
+ opening = mapTagFills(opening, themed);
+ body = mapChildFills(body, themed);
+ }
+
if (!/\sfill\s*=/i.test(opening)) {
opening = opening.replace(/^<svg/i, '<svg fill="currentColor"');
}
if (!/\saria-hidden\s*=/i.test(opening)) {
opening = opening.replace(/^<svg/i, '<svg aria-hidden="true"');
}
- // The children, tag by tag; a <mask> is passed over whole.
- const body = rest.replace(/<mask\b[\s\S]*?<\/mask\s*>|<[a-zA-Z][^>]*>/gi, (m) =>
- /^<mask\b/i.test(m) ? m : themeTagFills(m),
- );
return opening + body;
}