Fix redundant layout reflows and sanitize SVG color interpolation

Cache getBoundingClientRect() result in OverstimulationBlockOverlay
to avoid four forced reflows on the same element per activation.

Add sanitizeSvgColor() guard in generateAngerRiseEffect() so that
config.color is validated before interpolation into SVG stop-color
attributes, preventing injection if a future caller passes untrusted
color strings.
This commit is contained in:
Chad Curtis
2026-04-23 22:39:27 -05:00
parent 5463206d84
commit 8b0eb97abb
2 changed files with 29 additions and 8 deletions
@@ -40,8 +40,9 @@ export function OverstimulationBlockOverlay({ isBlocked }: Props) {
// Find Blobbi's true visual center via DOM query (after scroll reset)
const el = document.querySelector<HTMLElement>('[data-blobbi-companion]');
const cx = el ? el.getBoundingClientRect().left + el.getBoundingClientRect().width / 2 : window.innerWidth / 2;
const cy = el ? el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2 : window.innerHeight / 2;
const rect = el?.getBoundingClientRect();
const cx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;
const cy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2;
origin.current = { x: cx, y: cy };
root.style.transformOrigin = `${cx}px ${cy}px`;
+26 -6
View File
@@ -34,6 +34,25 @@ import type {
BodyPathInfo,
} from './types';
// ─── SVG Color Sanitization ───────────────────────────────────────────────────
/** Hex color: #rgb, #rrggbb, or #rrggbbaa */
const HEX_RE = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
/** Named CSS color or functional notation (rgb/rgba/hsl/hsla with digits, commas, spaces, dots, %) */
const FUNC_RE = /^(?:rgb|rgba|hsl|hsla)\(\s*[\d.,\s%]+\)$/i;
const NAMED_RE = /^[a-z]{3,24}$/i;
/**
* Validate a color value before interpolating it into SVG markup.
* Returns the color unchanged if it looks safe, otherwise returns a
* harmless fallback. This prevents SVG injection if a future caller
* ever passes user-controlled color strings.
*/
function sanitizeSvgColor(color: string, fallback = '#000'): string {
if (HEX_RE.test(color) || FUNC_RE.test(color) || NAMED_RE.test(color)) return color;
return fallback;
}
// ─── Body Path Detection ──────────────────────────────────────────────────────
/**
@@ -728,6 +747,7 @@ export function generateAngerRiseEffect(
const { pathD, minX, maxX, minY, maxY } = bodyPath;
const bodyHeight = maxY - minY;
const bodyWidth = maxX - minX;
const safeColor = sanitizeSvgColor(config.color);
const suffix = idSuffix ?? Math.random().toString(36).slice(2, 8);
const clipId = `blobbi-anger-clip-${suffix}`;
@@ -759,16 +779,16 @@ export function generateAngerRiseEffect(
<path d="${pathD}" />
</clipPath>
<linearGradient id="${gradientId}" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="${config.color}" stop-opacity="${bottomOpacity}" />
<stop offset="${Math.max(0, lvl - feather)}" stop-color="${config.color}" stop-opacity="${edgeOpacity}" />
<stop offset="${lvl}" stop-color="${config.color}" stop-opacity="0" />
<stop offset="0%" stop-color="${safeColor}" stop-opacity="${bottomOpacity}" />
<stop offset="${Math.max(0, lvl - feather)}" stop-color="${safeColor}" stop-opacity="${edgeOpacity}" />
<stop offset="${lvl}" stop-color="${safeColor}" stop-opacity="0" />
</linearGradient>`
: `
<clipPath id="${clipId}">
<path d="${pathD}" />
</clipPath>
<linearGradient id="${gradientId}" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="${config.color}">
<stop offset="0%" stop-color="${safeColor}">
<animate
attributeName="stop-opacity"
values="0;0.5;0.5"
@@ -777,7 +797,7 @@ export function generateAngerRiseEffect(
fill="freeze"
/>
</stop>
<stop stop-color="${config.color}">
<stop stop-color="${safeColor}">
<animate
attributeName="offset"
values="0;1"
@@ -792,7 +812,7 @@ export function generateAngerRiseEffect(
fill="freeze"
/>
</stop>
<stop stop-color="${config.color}" stop-opacity="0">
<stop stop-color="${safeColor}" stop-opacity="0">
<animate
attributeName="offset"
values="0;1"