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
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
124 lines
6.2 KiB
TypeScript
124 lines
6.2 KiB
TypeScript
import { ChatBgVariants } from './types';
|
||
import { rainFall } from './animRain.css';
|
||
|
||
// anim-rain — "Digital Rain" — a premium take on the Matrix code-rain motif.
|
||
//
|
||
// Concept: sparse vertical columns of falling glyph-streaks. Each streak is a
|
||
// soft vertical gradient that fades from a brighter LEADING glyph (the drop's
|
||
// head) up into a dim trailing tail, punctuated by a scatter of faint monospace
|
||
// glyph marks so it reads as CODE rather than plain stripes. It floats over a
|
||
// near-black base carrying a subtle green phosphor cast and a gentle vignette.
|
||
// Columns are deliberately sparse (only a handful across the 260px-wide tile)
|
||
// so the reading area breathes and text always wins the contrast fight.
|
||
//
|
||
// SEAMLESS TILING + PAN — the streak SVG tile is 260×200. Its content is
|
||
// authored to wrap top↔bottom: each streak's gradient and glyphs are placed so
|
||
// the tile is vertically continuous, and the animation (see animRain.css.ts)
|
||
// pans this first layer down by EXACTLY one tile height (200px) per cycle, so
|
||
// the "fall" loops with no seam. The base / vignette layers are 100% 100% and
|
||
// stay fixed (the keyframe holds them at '0 0').
|
||
//
|
||
// ANIMATION-STRIP SAFETY — getChatBg removes `animation` for reduced-motion /
|
||
// pause-animations users, so the non-animation properties below already read as
|
||
// a finished, gorgeous STATIC rain: a frozen frame of streaks over the base.
|
||
//
|
||
// CSP / Tauri-safe: inline SVG via encodeURIComponent (NOT base64). oklch used
|
||
// throughout; alphas kept low so both themes stay WCAG-AA-friendly for text.
|
||
|
||
// One vertical streak-column, colour-parameterised. Placed at x within a
|
||
// 260-wide tile. `head` is the bright leading-glyph colour, `tail` the dim
|
||
// trailing colour, `glyph` the colour of the riding monospace glyph ticks.
|
||
const streak = (
|
||
x: number,
|
||
headY: number, // y of the leading glyph (drop head)
|
||
len: number, // trailing tail length upward
|
||
head: string,
|
||
tail: string,
|
||
glyph: string,
|
||
): string => {
|
||
const topY = headY - len;
|
||
const id = `g${x}_${headY}`; // unique even when two columns share an x
|
||
// Vertical fade: transparent at the tail top → tail colour → bright head.
|
||
const grad = `
|
||
<linearGradient id='${id}' x1='0' y1='${topY}' x2='0' y2='${headY}' gradientUnits='userSpaceOnUse'>
|
||
<stop offset='0' stop-color='${tail}' stop-opacity='0'/>
|
||
<stop offset='0.55' stop-color='${tail}'/>
|
||
<stop offset='1' stop-color='${head}'/>
|
||
</linearGradient>`;
|
||
// The streak body is a soft, slightly-blurred vertical bar.
|
||
const bar = `<rect x='${x - 3}' y='${topY}' width='6' height='${len}' rx='3' fill='url(#${id})'/>`;
|
||
// A few monospace glyph ticks riding the column (short horizontal dashes).
|
||
const ticks = [0.22, 0.45, 0.68, 0.86]
|
||
.map((f, i) => {
|
||
const gy = Math.round(topY + len * f);
|
||
const gw = i % 2 === 0 ? 5 : 3;
|
||
const op = i === 3 ? '0.9' : '0.5';
|
||
return `<rect x='${x - gw / 2}' y='${gy}' width='${gw}' height='1.4' rx='0.7' fill='${glyph}' fill-opacity='${op}'/>`;
|
||
})
|
||
.join('');
|
||
// The leading glyph: a brighter small square cap at the head.
|
||
const cap = `<rect x='${x - 2.5}' y='${headY - 3}' width='5' height='5' rx='1' fill='${head}'/>`;
|
||
return grad + bar + ticks + cap;
|
||
};
|
||
|
||
// Full 260×200 tile. Columns are wrapped vertically: a column whose head sits
|
||
// low in the tile has its tail running off the top, and a companion column
|
||
// re-enters that space, so panning by one tile height reads as continuous fall.
|
||
const tile = (head: string, tail: string, glyph: string): string => {
|
||
const cols = [
|
||
streak(24, 150, 140, head, tail, glyph),
|
||
streak(78, 60, 120, head, tail, glyph),
|
||
streak(122, 196, 160, head, tail, glyph), // head near bottom → tail wraps up
|
||
streak(122, 40, 160, head, tail, glyph), // partner near top completes the wrap
|
||
streak(178, 110, 100, head, tail, glyph),
|
||
streak(232, 176, 130, head, tail, glyph),
|
||
].join('');
|
||
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='260' height='200' viewBox='0 0 260 200'><defs></defs>${cols}</svg>`;
|
||
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||
};
|
||
|
||
export const animRain: ChatBgVariants = {
|
||
// Dark: phosphor-green streaks on deep near-black with a faint green cast.
|
||
dark: {
|
||
backgroundColor: 'oklch(0.16 0.02 150)',
|
||
backgroundImage: [
|
||
// 1) the falling streak columns (this is the panned layer)
|
||
tile(
|
||
'oklch(0.75 0.14 150 / 0.5)', // head — bright phosphor glyph
|
||
'oklch(0.68 0.12 150 / 0.28)', // tail — dim phosphor
|
||
'oklch(0.82 0.1 150 / 0.5)', // glyph ticks — brightest
|
||
),
|
||
// 2) soft top-down phosphor haze so the rain has atmosphere
|
||
'linear-gradient(180deg, oklch(0.24 0.04 150 / 0.55) 0%, transparent 40%)',
|
||
// 3) subtle green cast pooling toward the bottom
|
||
'radial-gradient(120% 90% at 50% 100%, oklch(0.28 0.05 150 / 0.45) 0%, transparent 60%)',
|
||
// 4) vignette — quiet the corners so the reading column stays clean
|
||
'radial-gradient(140% 140% at 50% 45%, transparent 60%, oklch(0.1 0.02 150 / 0.6) 100%)',
|
||
].join(','),
|
||
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||
animation: `${rainFall} 12s steps(288) infinite`,
|
||
},
|
||
|
||
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
|
||
light: {
|
||
backgroundColor: 'oklch(0.97 0.008 165)',
|
||
backgroundImage: [
|
||
tile(
|
||
'oklch(0.55 0.07 165 / 0.4)', // head — soft teal-grey drop
|
||
'oklch(0.62 0.05 165 / 0.22)', // tail — faint teal-grey
|
||
'oklch(0.5 0.06 165 / 0.42)', // glyph ticks
|
||
),
|
||
// gentle cool wash from the top
|
||
'linear-gradient(180deg, oklch(0.94 0.015 175 / 0.6) 0%, transparent 42%)',
|
||
// faint teal pooling at the bottom edge
|
||
'radial-gradient(120% 90% at 50% 100%, oklch(0.9 0.02 170 / 0.5) 0%, transparent 60%)',
|
||
// soft vignette in cool grey
|
||
'radial-gradient(140% 140% at 50% 45%, transparent 62%, oklch(0.88 0.02 165 / 0.5) 100%)',
|
||
].join(','),
|
||
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||
animation: `${rainFall} 12s steps(288) infinite`,
|
||
},
|
||
};
|