mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
fix: rewrite BackgroundBoxes from 225 DOM divs to single canvas element
Root cause: 225 individually animated DOM elements forced Chrome's compositor to create separate paint regions per element. Any hover state change on page content triggered re-compositing of all 225 regions, causing visible flickering on all pages. Canvas fix: single <canvas> element renders the entire grid effect via requestAnimationFrame. Canvas content is pixel-based and cannot be affected by DOM hover state changes on other elements. Additional fixes: - Fix z-index collision: portal z-index -1 → -2 (was same as body::before noise) - Replace all transition-all with specific properties on .btn, .btn-icon, .input, .nav-item, .bottom-nav-item, checkbox - Remove backdrop-blur-sm from desktop .bento-card and .card (forced Chrome to re-sample animated background pixels on every hover)
This commit is contained in:
@@ -92,7 +92,7 @@ export function BackgroundRenderer() {
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
zIndex: -2,
|
||||
opacity: effectiveConfig.opacity,
|
||||
filter: effectiveConfig.blur > 0 ? `blur(${effectiveConfig.blur}px)` : undefined,
|
||||
contain: 'strict',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
|
||||
interface Props {
|
||||
@@ -16,60 +16,150 @@ const COLORS = [
|
||||
'#c4b5fd',
|
||||
];
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const v = parseInt(hex.slice(1), 16);
|
||||
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
|
||||
}
|
||||
|
||||
interface CellData {
|
||||
rgb: [number, number, number];
|
||||
phase: number;
|
||||
period: number;
|
||||
}
|
||||
|
||||
// Pre-computed transform constants for skewX(-48deg) skewY(14deg) scale(0.675)
|
||||
const SKEW_X_TAN = Math.tan((-48 * Math.PI) / 180);
|
||||
const SKEW_Y_TAN = Math.tan((14 * Math.PI) / 180);
|
||||
const GRID_SCALE = 0.675;
|
||||
|
||||
/**
|
||||
* Animated boxes background rendered on a single <canvas> element.
|
||||
*
|
||||
* Previous implementation used 225 DOM <div> elements with CSS @keyframes.
|
||||
* Chrome's compositor created a separate paint region per div; any hover
|
||||
* state change on page content forced Chrome to re-composite all 225 regions,
|
||||
* causing visible flickering. A single canvas bypasses the DOM style/layout/paint
|
||||
* pipeline entirely — hover interactions on other elements cannot trigger
|
||||
* repaints of canvas content.
|
||||
*/
|
||||
export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const rows = clampNumber(settings.rows, 4, 30, 15);
|
||||
const cols = clampNumber(settings.cols, 4, 30, 15);
|
||||
const boxColor = sanitizeColor(settings.boxColor, '#818cf8');
|
||||
|
||||
const cells = useMemo(() => {
|
||||
const result: { color: string; delay: number; duration: number }[] = [];
|
||||
for (let i = 0; i < rows * cols; i++) {
|
||||
result.push({
|
||||
color:
|
||||
boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor,
|
||||
delay: Math.random() * 8,
|
||||
duration: 3 + Math.random() * 4,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
const cells = useMemo((): CellData[] => {
|
||||
return Array.from({ length: rows * cols }, () => ({
|
||||
rgb: hexToRgb(
|
||||
boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor,
|
||||
),
|
||||
phase: Math.random() * 8,
|
||||
period: 3 + Math.random() * 4,
|
||||
}));
|
||||
}, [rows, cols, boxColor]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let animId = 0;
|
||||
|
||||
const resize = () => {
|
||||
const dpr = devicePixelRatio || 1;
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const draw = (now: number) => {
|
||||
const t = now * 0.001;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.save();
|
||||
|
||||
// Transform origin: center of viewport
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
ctx.translate(cx, cy);
|
||||
|
||||
// Replicate CSS: skewX(-48deg) skewY(14deg) scale(0.675)
|
||||
ctx.transform(1, 0, SKEW_X_TAN, 1, 0, 0);
|
||||
ctx.transform(1, SKEW_Y_TAN, 0, 1, 0, 0);
|
||||
ctx.scale(GRID_SCALE, GRID_SCALE);
|
||||
|
||||
ctx.translate(-cx, -cy);
|
||||
|
||||
// Grid: 300% of viewport, offset by -100% (matches the original CSS layout)
|
||||
const gw = w * 3;
|
||||
const gh = h * 3;
|
||||
const ox = -w;
|
||||
const oy = -h;
|
||||
const cellW = gw / cols;
|
||||
const cellH = gh / rows;
|
||||
|
||||
// Draw colored cells
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const cell = cells[i];
|
||||
const cycleT = ((t + cell.phase) % cell.period) / cell.period;
|
||||
const alpha = 0.15 * Math.sin(cycleT * Math.PI);
|
||||
if (alpha < 0.005) continue;
|
||||
|
||||
const col = i % cols;
|
||||
const row = (i - col) / cols;
|
||||
const [r, g, b] = cell.rgb;
|
||||
|
||||
ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`;
|
||||
ctx.fillRect(ox + col * cellW, oy + row * cellH, cellW, cellH);
|
||||
}
|
||||
|
||||
// Draw grid lines as a single batch (much cheaper than 225 individual strokeRects)
|
||||
ctx.strokeStyle = 'rgba(51,65,85,0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
|
||||
for (let r = 0; r <= rows; r++) {
|
||||
const y = oy + r * cellH;
|
||||
ctx.moveTo(ox, y);
|
||||
ctx.lineTo(ox + gw, y);
|
||||
}
|
||||
for (let c = 0; c <= cols; c++) {
|
||||
const x = ox + c * cellW;
|
||||
ctx.moveTo(x, oy);
|
||||
ctx.lineTo(x, oy + gh);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
animId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
animId = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [cells, rows, cols]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{/* CSS keyframes for box fade — runs on compositor thread, zero main-thread cost */}
|
||||
<style>{`
|
||||
@keyframes boxFade {
|
||||
0%, 100% { opacity: 0; }
|
||||
50% { opacity: 0.15; }
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-100%',
|
||||
left: '-100%',
|
||||
width: '300%',
|
||||
height: '300%',
|
||||
display: 'grid',
|
||||
gridTemplateRows: `repeat(${rows}, 1fr)`,
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
transform: 'skewX(-48deg) skewY(14deg) scale(0.675) translateZ(0)',
|
||||
transformOrigin: 'center center',
|
||||
contain: 'strict',
|
||||
}}
|
||||
>
|
||||
{cells.map((cell, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border border-slate-700/50"
|
||||
style={{
|
||||
backgroundColor: cell.color,
|
||||
animation: `boxFade ${cell.duration}s ${cell.delay}s ease-in-out infinite`,
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -360,7 +360,7 @@ img.twemoji {
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.bento-card {
|
||||
@apply bg-dark-900/50 backdrop-blur-sm;
|
||||
@apply bg-dark-900/60;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ img.twemoji {
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.light .bento-card {
|
||||
@apply bg-champagne-50/80 backdrop-blur-sm;
|
||||
@apply bg-champagne-50/85;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,10 +487,9 @@ img.twemoji {
|
||||
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Enable backdrop-blur only on desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.card {
|
||||
@apply bg-dark-900/50 backdrop-blur-sm;
|
||||
@apply bg-dark-900/60;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,7 +519,8 @@ img.twemoji {
|
||||
|
||||
/* Buttons - Dark */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 ease-smooth focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-900 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50;
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium duration-200 ease-smooth focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-900 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50;
|
||||
transition-property: color, background-color, border-color, box-shadow, transform, opacity;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -540,7 +540,8 @@ img.twemoji {
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
@apply rounded-lg p-2 text-dark-400 transition-all duration-200 hover:bg-dark-800 hover:text-dark-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 active:scale-95;
|
||||
@apply rounded-lg p-2 text-dark-400 duration-200 hover:bg-dark-800 hover:text-dark-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 active:scale-95;
|
||||
transition-property: color, background-color, transform;
|
||||
}
|
||||
|
||||
/* Highlighted button for onboarding */
|
||||
@@ -550,7 +551,8 @@ img.twemoji {
|
||||
|
||||
/* Inputs - Dark */
|
||||
.input {
|
||||
@apply w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-100 placeholder-dark-500 transition-all duration-200 focus:border-accent-500/50 focus:outline-none focus:ring-2 focus:ring-accent-500/20;
|
||||
@apply w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-100 placeholder-dark-500 duration-200 focus:border-accent-500/50 focus:outline-none focus:ring-2 focus:ring-accent-500/20;
|
||||
transition-property: color, background-color, border-color, box-shadow;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
@@ -613,7 +615,8 @@ img.twemoji {
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
@apply h-full rounded-full transition-all duration-500 ease-smooth;
|
||||
@apply h-full rounded-full duration-500 ease-smooth;
|
||||
transition-property: width, background-color;
|
||||
}
|
||||
|
||||
/* Stat card - Dark */
|
||||
@@ -627,7 +630,8 @@ img.twemoji {
|
||||
|
||||
/* Navigation - Dark */
|
||||
.nav-item {
|
||||
@apply flex items-center gap-3 rounded-xl px-4 py-3 text-dark-400 transition-all duration-200 hover:bg-dark-800/50 hover:text-dark-100;
|
||||
@apply flex items-center gap-3 rounded-xl px-4 py-3 text-dark-400 duration-200 hover:bg-dark-800/50 hover:text-dark-100;
|
||||
transition-property: color, background-color;
|
||||
}
|
||||
|
||||
.nav-item-active {
|
||||
@@ -654,7 +658,8 @@ img.twemoji {
|
||||
}
|
||||
|
||||
.bottom-nav-item {
|
||||
@apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl px-3 py-2.5 text-dark-500 transition-all duration-200;
|
||||
@apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl px-3 py-2.5 text-dark-500 duration-200;
|
||||
transition-property: color, background-color;
|
||||
}
|
||||
|
||||
.bottom-nav-item:hover {
|
||||
@@ -662,7 +667,8 @@ img.twemoji {
|
||||
}
|
||||
|
||||
.bottom-nav-item-active {
|
||||
@apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl bg-accent-500/15 px-3 py-2.5 text-accent-400 transition-all duration-200;
|
||||
@apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl bg-accent-500/15 px-3 py-2.5 text-accent-400 duration-200;
|
||||
transition-property: color, background-color;
|
||||
}
|
||||
|
||||
/* Divider - Dark */
|
||||
@@ -697,7 +703,7 @@ img.twemoji {
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.light .card {
|
||||
@apply bg-champagne-50/80 backdrop-blur-sm;
|
||||
@apply bg-champagne-50/85;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1252,7 +1258,9 @@ input[type='checkbox'] {
|
||||
border-radius: 0.375rem;
|
||||
background-color: rgb(var(--color-dark-700));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user