mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13: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
|
<div
|
||||||
className="pointer-events-none fixed inset-0"
|
className="pointer-events-none fixed inset-0"
|
||||||
style={{
|
style={{
|
||||||
zIndex: -1,
|
zIndex: -2,
|
||||||
opacity: effectiveConfig.opacity,
|
opacity: effectiveConfig.opacity,
|
||||||
filter: effectiveConfig.blur > 0 ? `blur(${effectiveConfig.blur}px)` : undefined,
|
filter: effectiveConfig.blur > 0 ? `blur(${effectiveConfig.blur}px)` : undefined,
|
||||||
contain: 'strict',
|
contain: 'strict',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useEffect, useRef, useMemo } from 'react';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,60 +16,150 @@ const COLORS = [
|
|||||||
'#c4b5fd',
|
'#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) {
|
export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const rows = clampNumber(settings.rows, 4, 30, 15);
|
const rows = clampNumber(settings.rows, 4, 30, 15);
|
||||||
const cols = clampNumber(settings.cols, 4, 30, 15);
|
const cols = clampNumber(settings.cols, 4, 30, 15);
|
||||||
const boxColor = sanitizeColor(settings.boxColor, '#818cf8');
|
const boxColor = sanitizeColor(settings.boxColor, '#818cf8');
|
||||||
|
|
||||||
const cells = useMemo(() => {
|
const cells = useMemo((): CellData[] => {
|
||||||
const result: { color: string; delay: number; duration: number }[] = [];
|
return Array.from({ length: rows * cols }, () => ({
|
||||||
for (let i = 0; i < rows * cols; i++) {
|
rgb: hexToRgb(
|
||||||
result.push({
|
boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor,
|
||||||
color:
|
),
|
||||||
boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor,
|
phase: Math.random() * 8,
|
||||||
delay: Math.random() * 8,
|
period: 3 + Math.random() * 4,
|
||||||
duration: 3 + Math.random() * 4,
|
}));
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [rows, cols, boxColor]);
|
}, [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 (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<canvas
|
||||||
{/* CSS keyframes for box fade — runs on compositor thread, zero main-thread cost */}
|
ref={canvasRef}
|
||||||
<style>{`
|
style={{
|
||||||
@keyframes boxFade {
|
position: 'absolute',
|
||||||
0%, 100% { opacity: 0; }
|
inset: 0,
|
||||||
50% { opacity: 0.15; }
|
width: '100%',
|
||||||
}
|
height: '100%',
|
||||||
`}</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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ img.twemoji {
|
|||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.bento-card {
|
.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) {
|
@media (min-width: 1024px) {
|
||||||
.light .bento-card {
|
.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);
|
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enable backdrop-blur only on desktop */
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.card {
|
.card {
|
||||||
@apply bg-dark-900/50 backdrop-blur-sm;
|
@apply bg-dark-900/60;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,7 +519,8 @@ img.twemoji {
|
|||||||
|
|
||||||
/* Buttons - Dark */
|
/* Buttons - Dark */
|
||||||
.btn {
|
.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 {
|
.btn-primary {
|
||||||
@@ -540,7 +540,8 @@ img.twemoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.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 */
|
/* Highlighted button for onboarding */
|
||||||
@@ -550,7 +551,8 @@ img.twemoji {
|
|||||||
|
|
||||||
/* Inputs - Dark */
|
/* Inputs - Dark */
|
||||||
.input {
|
.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 {
|
.input-error {
|
||||||
@@ -613,7 +615,8 @@ img.twemoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.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 */
|
/* Stat card - Dark */
|
||||||
@@ -627,7 +630,8 @@ img.twemoji {
|
|||||||
|
|
||||||
/* Navigation - Dark */
|
/* Navigation - Dark */
|
||||||
.nav-item {
|
.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 {
|
.nav-item-active {
|
||||||
@@ -654,7 +658,8 @@ img.twemoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav-item {
|
.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 {
|
.bottom-nav-item:hover {
|
||||||
@@ -662,7 +667,8 @@ img.twemoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav-item-active {
|
.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 */
|
/* Divider - Dark */
|
||||||
@@ -697,7 +703,7 @@ img.twemoji {
|
|||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.light .card {
|
.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;
|
border-radius: 0.375rem;
|
||||||
background-color: rgb(var(--color-dark-700));
|
background-color: rgb(var(--color-dark-700));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background-color 0.15s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user