Files
cinny/src/app/features/lotus/chatBackground.ts
T
Lotus CIandClaude Opus 5.5 2700aad209
CI / Build & Quality Checks (pull_request) Successful in 1m57s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Failing after 9m56s
perf(backgrounds): animated chat backgrounds cost ~5% CPU instead of 50 to 70% (#337)
background-position can't run on the compositor, so every frame of the
animated chat backgrounds repainted the whole chat area: 51 to 72% main-thread
busy while idle in a room at 60 fps. The drifts are slow (3 to 22 px/s,
under a pixel per frame), so each animation now uses steps(): about 12
updates/s for fireflies, stars and aurora and about 24/s for rain and
pulse.

Measured headless (20 s idle in a room, CDP TaskDuration):
  none 0.7% → 0.9%; rain 50.7 → 5.9; stars 71.2 → 4.5;
  pulse 64.9 → 5.8; aurora 58.9 → 4.2; fireflies 71.7 → 3.8.

Aurora and pulse used ease-in-out; with steps() their motion is now
even-paced. Pause animations / reduced motion still strip the animation.

Fixes #337

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 19:05:52 -04:00

162 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CSSProperties } from 'react';
import { ChatBackground } from '../../state/settings';
import { blueprint } from './backgrounds/blueprint';
import { stars } from './backgrounds/stars';
import { topographic } from './backgrounds/topographic';
import { herringbone } from './backgrounds/herringbone';
import { crosshatch } from './backgrounds/crosshatch';
import { chevron } from './backgrounds/chevron';
import { polka } from './backgrounds/polka';
import { triangles } from './backgrounds/triangles';
import { plaid } from './backgrounds/plaid';
import { tactical } from './backgrounds/tactical';
import { circuit } from './backgrounds/circuit';
import { hexgrid } from './backgrounds/hexgrid';
import { waves } from './backgrounds/waves';
import { neon } from './backgrounds/neon';
import { animRain } from './backgrounds/animRain';
import { animStars } from './backgrounds/animStars';
import { animPulse } from './backgrounds/animPulse';
import { animAurora } from './backgrounds/animAurora';
import { animFireflies } from './backgrounds/animFireflies';
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'blueprint', label: 'Blueprint' },
{ value: 'carbon', label: 'Carbon' },
{ value: 'stars', label: 'Stars' },
{ value: 'topographic', label: 'Topographic' },
{ value: 'herringbone', label: 'Herringbone' },
{ value: 'crosshatch', label: 'Crosshatch' },
{ value: 'chevron', label: 'Chevron' },
{ value: 'polka', label: 'Polka' },
{ value: 'triangles', label: 'Triangles' },
{ value: 'plaid', label: 'Plaid' },
{ value: 'tactical', label: 'Tactical' },
{ value: 'circuit', label: 'Circuit' },
{ value: 'hexgrid', label: 'Hex Grid' },
{ value: 'waves', label: 'Waves' },
{ value: 'neon', label: 'Neon Grid' },
{ value: 'aurora', label: 'Aurora' },
{ value: 'anim-rain', label: 'Digital Rain' },
{ value: 'anim-stars', label: 'Star Drift' },
{ value: 'anim-pulse', label: 'Grid Pulse' },
{ value: 'anim-aurora', label: 'Aurora Flow' },
{ value: 'anim-fireflies', label: 'Fireflies' },
];
// `none`, `carbon` and `aurora` stay inline: carbon + aurora are the kept user
// favorites, none is the empty layer. Every other background is a premium
// per-pattern module under ./backgrounds/ (each exposes a `dark` + `light`
// variant). Keeping the whole record here lets getChatBg stay the single entry
// point and preserves the Record<ChatBackground, ...> exhaustiveness check.
const DARK: Record<ChatBackground, CSSProperties> = {
none: {},
carbon: {
backgroundColor: '#0e0e0e',
backgroundImage: [
'repeating-linear-gradient(45deg, rgba(255,255,255,0.035) 0, rgba(255,255,255,0.035) 2px, transparent 0, transparent 50%)',
'repeating-linear-gradient(135deg, rgba(255,255,255,0.035) 0, rgba(255,255,255,0.035) 2px, transparent 0, transparent 50%)',
].join(','),
backgroundSize: '8px 8px',
},
aurora: {
backgroundColor: '#030810',
backgroundImage: [
'radial-gradient(ellipse at 20% 30%, rgba(0,255,136,0.08) 0%, transparent 55%)',
'radial-gradient(ellipse at 80% 70%, rgba(0,100,255,0.08) 0%, transparent 55%)',
'radial-gradient(ellipse at 50% 10%, rgba(120,0,255,0.06) 0%, transparent 50%)',
'radial-gradient(ellipse at 60% 90%, rgba(0,212,255,0.06) 0%, transparent 50%)',
].join(','),
},
blueprint: blueprint.dark,
stars: stars.dark,
topographic: topographic.dark,
herringbone: herringbone.dark,
crosshatch: crosshatch.dark,
chevron: chevron.dark,
polka: polka.dark,
triangles: triangles.dark,
plaid: plaid.dark,
tactical: tactical.dark,
circuit: circuit.dark,
hexgrid: hexgrid.dark,
waves: waves.dark,
neon: neon.dark,
'anim-rain': animRain.dark,
'anim-stars': animStars.dark,
'anim-pulse': animPulse.dark,
'anim-aurora': animAurora.dark,
'anim-fireflies': animFireflies.dark,
};
const LIGHT: Record<ChatBackground, CSSProperties> = {
none: {},
carbon: {
backgroundColor: '#efefef',
backgroundImage: [
'repeating-linear-gradient(45deg, rgba(0,0,0,0.04) 0, rgba(0,0,0,0.04) 2px, transparent 0, transparent 50%)',
'repeating-linear-gradient(135deg, rgba(0,0,0,0.04) 0, rgba(0,0,0,0.04) 2px, transparent 0, transparent 50%)',
].join(','),
backgroundSize: '8px 8px',
},
aurora: {
backgroundColor: '#f4faf8',
backgroundImage: [
'radial-gradient(ellipse at 20% 30%, rgba(0,160,80,0.09) 0%, transparent 55%)',
'radial-gradient(ellipse at 80% 70%, rgba(0,80,200,0.09) 0%, transparent 55%)',
'radial-gradient(ellipse at 50% 10%, rgba(100,0,200,0.07) 0%, transparent 50%)',
'radial-gradient(ellipse at 60% 90%, rgba(0,160,200,0.07) 0%, transparent 50%)',
].join(','),
},
blueprint: blueprint.light,
stars: stars.light,
topographic: topographic.light,
herringbone: herringbone.light,
crosshatch: crosshatch.light,
chevron: chevron.light,
polka: polka.light,
triangles: triangles.light,
plaid: plaid.light,
tactical: tactical.light,
circuit: circuit.light,
hexgrid: hexgrid.light,
waves: waves.light,
neon: neon.light,
'anim-rain': animRain.light,
'anim-stars': animStars.light,
'anim-pulse': animPulse.light,
'anim-aurora': animAurora.light,
'anim-fireflies': animFireflies.light,
};
export const getChatBg = (
bg: ChatBackground,
isDark: boolean,
// Whether to strip animation (user "pause animations" setting OR OS
// prefers-reduced-motion). Supplied by the caller — e.g. via useReducedMotion —
// so this function stays pure and SSR-safe (no matchMedia read at call time).
suppressAnimation?: boolean,
): CSSProperties => {
const style = isDark ? DARK[bg] : LIGHT[bg];
if (suppressAnimation && style.animation) {
const { animation: _anim, ...rest } = style;
return rest;
}
// For animated backgrounds, promote the element to its own compositor layer so
// background-position keyframes don't trigger repaints on descendant elements.
// [Gitea #337] background-position can't run on the compositor, so every
// animation frame repaints the whole chat area (45 to 70% main-thread busy
// at 60 fps). The drifts are slow (3–22 px/s), so each animation uses steps():
// ~12 updates/s for the slow ones, ~24/s for rain and pulse: visually the
// same, a fraction of the repaints.
if (style.animation) {
return { ...style, willChange: 'background-position', contain: 'paint' };
}
return style;
};