fix(seasonal): keep Arcade + Deep Space overlays out of the chat text
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Trigger Desktop Build (push) Successful in 7s

Arcade:
- perspective grid is masked through the central timeline column so it
  frames the chat instead of striping messages; lower opacity, thinner
  non-scaling strokes (also fixes the grid vanishing in the 76px swatch)
- neon horizon line is broken through the middle so it never underlines
  a message; softer sun glow
- "1UP" score + "INSERT COIN" moved from top-left/bottom-centre (space
  bar / composer) to a single bottom-right HUD stack; font-size clamps
  to 0 via cqw inside the settings swatch so no more clipped glyphs
- scanlines: 4px pitch at lower opacity (light theme was fully striped);
  ambient wash slightly lighter

Deep Space:
- starfield 28 → 62 stars across the two parallax layers
- base violet wash slightly lighter for light themes

SeasonalPreview wrapper is now a size container (container-type) so
overlays can scale/hide fixed-px details with cqw. LOTUS_FEATURES.md
theme table now describes what both overlays actually render.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 01:29:33 -04:00
co-authored by Claude Fable 5.1
parent 34a3352e21
commit 4bea48959e
5 changed files with 134 additions and 92 deletions
+13 -13
View File
@@ -175,19 +175,19 @@ Decorative CSS-only overlays that activate automatically on holidays and events.
### Themes ### Themes
| Theme | Window | Effect | | Theme | Window | Effect |
| -------------------- | ------------- | -------------------------------------------------------------------------------------------------- | | -------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🎆 New Year | Dec 31Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep | | 🎆 New Year | Dec 31Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
| 🏮 Lunar New Year | Jan 22Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent | | 🏮 Lunar New Year | Jan 22Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
| 💖 Valentine's Day | Feb 1015 | ♥ hearts floating upward; soft pink ambient glow | | 💖 Valentine's Day | Feb 1015 | ♥ hearts floating upward; soft pink ambient glow |
| 🍀 St. Patrick's Day | Mar 1518 | ☘ clovers drifting down; gold metallic shimmer top border | | 🍀 St. Patrick's Day | Mar 1518 | ☘ clovers drifting down; gold metallic shimmer top border |
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark | | 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
| 🌱 Earth Day | Apr 2023 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge | | 🌱 Earth Day | Apr 2023 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
| 🍂 Autumn | Sep 21Oct 31 | Warm orange/amber leaf shapes rotating and falling | | 🍂 Autumn | Sep 21Oct 31 | Warm orange/amber leaf shapes rotating and falling |
| 👾 Arcade Day | Sep 12 | CRT scanlines; blinking pixel corner decorations; "INSERT COIN" prompt | | 👾 Arcade Day | Sep 12 | Synthwave CRT: neon perspective grid framing the timeline (faded through the chat column), broken horizon line, rolling scanlines, pixel sparkles, bottom-right "1UP / INSERT COIN" HUD |
| 🚀 Deep Space Week | Oct 410 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient | | 🚀 Deep Space Week | Oct 410 | Violet void with drifting magenta/cyan nebula clouds, two-depth parallax starfield (~60 twinkling stars + 6 hero gleams), slow galaxy spiral, occasional comet streaks |
| 🎃 Halloween | Oct 15Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint | | 🎃 Halloween | Oct 15Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
| ❄️ Christmas | Dec 10Jan 2 | White dot snowfall in multiple layers at varied speeds | | ❄️ Christmas | Dec 10Jan 2 | White dot snowfall in multiple layers at varied speeds |
### Implementation ### Implementation
@@ -84,7 +84,15 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
return ( return (
<div <div
aria-hidden="true" aria-hidden="true"
style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }} style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
pointerEvents: 'none',
// Size container so overlays can scale/hide fixed-size details (e.g.
// Arcade's HUD text) with `cqw` instead of rendering clipped in a swatch.
containerType: 'inline-size',
}}
> >
{buildOverlayContent(theme, true)} {buildOverlayContent(theme, true)}
</div> </div>
@@ -102,12 +102,12 @@ export const animSparkleTwinkle = keyframes({
* Opacity + a hair of scale for a CRT bloom feel. * Opacity + a hair of scale for a CRT bloom feel.
*/ */
export const animCoinBlink = keyframes({ export const animCoinBlink = keyframes({
'0%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' }, '0%': { opacity: '0.85', transform: 'scale(1)' },
'6%': { opacity: '1', transform: 'translateX(-50%) scale(1.015)' }, '6%': { opacity: '1', transform: 'scale(1.015)' },
'12%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' }, '12%': { opacity: '0.85', transform: 'scale(1)' },
'49%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' }, '49%': { opacity: '0.85', transform: 'scale(1)' },
'50%': { opacity: '0', transform: 'translateX(-50%) scale(1)' }, '50%': { opacity: '0', transform: 'scale(1)' },
'100%': { opacity: '0', transform: 'translateX(-50%) scale(1)' }, '100%': { opacity: '0', transform: 'scale(1)' },
}); });
/** /**
+103 -69
View File
@@ -46,6 +46,10 @@ const NEON_CYAN = 'oklch(0.80 0.15 200)';
const GRID_PURPLE = 'oklch(0.45 0.18 300)'; const GRID_PURPLE = 'oklch(0.45 0.18 300)';
// The receding grid as an inline SVG data-URI (CSP-safe, no external assets). // The receding grid as an inline SVG data-URI (CSP-safe, no external assets).
// Strokes use vector-effect=non-scaling-stroke so a line is ~1px whether the
// tile is stretched across a 2000px plane (preserveAspectRatio=none would
// otherwise fatten the verticals ~4x) or squeezed into the 76px settings
// swatch (where scaled strokes disappeared entirely).
// It is a 1x2 vertical tile of horizontal rule lines + a single set of vertical // It is a 1x2 vertical tile of horizontal rule lines + a single set of vertical
// lines fanning toward a top-center vanishing point. The plane is then placed // lines fanning toward a top-center vanishing point. The plane is then placed
// under a CSS `perspective` rotateX so the lines genuinely recede. Scrolling the // under a CSS `perspective` rotateX so the lines genuinely recede. Scrolling the
@@ -58,7 +62,7 @@ function gridDataUri(): string {
rows.forEach((y) => { rows.forEach((y) => {
lines.push( lines.push(
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` + `<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
`stroke-width='1.4' stroke-opacity='0.9'/>`, `stroke-width='1.2' stroke-opacity='0.9' vector-effect='non-scaling-stroke'/>`,
); );
}); });
// Vertical lines fanning out from the top-center vanishing point. // Vertical lines fanning out from the top-center vanishing point.
@@ -67,7 +71,7 @@ function gridDataUri(): string {
const botX = 300 + i * 95; // wide at the foreground const botX = 300 + i * 95; // wide at the foreground
lines.push( lines.push(
`<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` + `<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` +
`stroke-width='1.4' stroke-opacity='0.8'/>`, `stroke-width='1.2' stroke-opacity='0.8' vector-effect='non-scaling-stroke'/>`,
); );
} }
const svg = const svg =
@@ -105,6 +109,13 @@ const RESTING_SPARKLES: ReadonlyArray<{
const GRID_URI = gridDataUri(); const GRID_URI = gridDataUri();
// HUD text size: 11px on any real viewport, 0px (invisible) inside anything
// narrower than ~330px. `cqw` resolves against the nearest size container —
// the settings swatch (`SeasonalPreview` sets container-type) — and falls back
// to the viewport width when there is no container, i.e. the full-screen
// overlay. clamp(0, 100cqw - 320px, 11px) → 76px swatch: 0px; 1440px app: 11px.
const HUD_FONT_SIZE = 'clamp(0px, calc(100cqw - 320px), 11px)';
export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) { export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic sparkle field, computed ONCE. No per-frame state. // Deterministic sparkle field, computed ONCE. No per-frame state.
const sparkles = useMemo<Sparkle[]>(() => { const sparkles = useMemo<Sparkle[]>(() => {
@@ -134,9 +145,9 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
position: 'absolute', position: 'absolute',
inset: 0, inset: 0,
backgroundImage: [ backgroundImage: [
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.16) 0%, transparent 55%)', 'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.12) 0%, transparent 55%)',
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.20) 0%, transparent 60%)', 'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.16) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.10) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.16) 100%)', 'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.08) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.12) 100%)',
].join(','), ].join(','),
contain: 'layout paint style', contain: 'layout paint style',
}} }}
@@ -147,41 +158,59 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
a vanishing point at the top (the horizon). It lives in the lower a vanishing point at the top (the horizon). It lives in the lower
half of the screen — the "floor". The inner plane scrolls upward by half of the screen — the "floor". The inner plane scrolls upward by
one tile via transform translateY, which reads as the grid flowing one tile via transform translateY, which reads as the grid flowing
toward the viewer. Pure transform; never background-position. */} toward the viewer. Pure transform; never background-position.
Two masks are nested (multiple mask-images on one element union by
default, and `mask-composite: intersect` isn't universal yet): the
outer wrapper fades the lattice through the central column where the
message timeline lives, so it frames the chat instead of striping
the text; the inner box fades it in from the horizon. */}
<div <div
aria-hidden="true" aria-hidden="true"
style={{ style={{
position: 'absolute', position: 'absolute',
left: '-25%', inset: 0,
right: '-25%', maskImage:
bottom: 0, 'linear-gradient(90deg, #000 0%, #000 12%, rgba(0,0,0,0.3) 34%, rgba(0,0,0,0.3) 66%, #000 88%, #000 100%)',
height: '62%', WebkitMaskImage:
overflow: 'hidden', 'linear-gradient(90deg, #000 0%, #000 12%, rgba(0,0,0,0.3) 34%, rgba(0,0,0,0.3) 66%, #000 88%, #000 100%)',
perspective: '280px', opacity: reduced ? 0.4 : 0.46,
perspectiveOrigin: '50% 0%',
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
opacity: reduced ? 0.5 : 0.62,
contain: 'layout paint style', contain: 'layout paint style',
}} }}
> >
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
left: 0, left: '-25%',
right: 0, right: '-25%',
top: 0, bottom: 0,
height: '200%', height: '62%',
transformOrigin: 'top center', overflow: 'hidden',
transform: 'rotateX(74deg)', perspective: '280px',
backgroundImage: GRID_URI, perspectiveOrigin: '50% 0%',
backgroundRepeat: 'repeat-y', maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
backgroundSize: '100% 50%', WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))', contain: 'layout paint style',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
}} }}
/> >
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '200%',
transformOrigin: 'top center',
transform: 'rotateX(74deg)',
backgroundImage: GRID_URI,
backgroundRepeat: 'repeat-y',
backgroundSize: '100% 50%',
filter: 'drop-shadow(0 0 2px oklch(0.55 0.22 320 / 0.55))',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
}}
/>
</div>
</div> </div>
{/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits {/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits
@@ -197,7 +226,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
height: '34%', height: '34%',
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
backgroundImage: backgroundImage:
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)', 'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.16) 0%, oklch(0.65 0.18 330 / 0.08) 40%, transparent 72%)',
contain: 'layout paint style', contain: 'layout paint style',
}} }}
/> />
@@ -209,8 +238,10 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
right: '12%', right: '12%',
top: '38%', top: '38%',
height: '1.5px', height: '1.5px',
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 25%, oklch(0.92 0.10 320 / 0.95) 50%, ${NEON_CYAN} 75%, transparent 100%)`, // Bright at the flanks, dropped out through the centre column so the
opacity: 0.55, // rule frames the timeline rather than underlining a message.
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 14%, oklch(0.92 0.10 320 / 0.95) 22%, transparent 34%, transparent 66%, oklch(0.92 0.10 320 / 0.95) 78%, ${NEON_CYAN} 86%, transparent 100%)`,
opacity: 0.4,
filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))', filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
}} }}
/> />
@@ -273,7 +304,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
inset: 0, inset: 0,
overflow: 'hidden', overflow: 'hidden',
mixBlendMode: 'multiply', mixBlendMode: 'multiply',
opacity: 0.5, opacity: 0.32,
contain: 'layout paint style', contain: 'layout paint style',
}} }}
> >
@@ -285,7 +316,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
top: '-8px', top: '-8px',
bottom: '-8px', bottom: '-8px',
backgroundImage: backgroundImage:
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.55) 0px, oklch(0.10 0.04 300 / 0.55) 1px, transparent 1px, transparent 3px)', 'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.45) 0px, oklch(0.10 0.04 300 / 0.45) 1px, transparent 1px, transparent 4px)',
willChange: reduced ? undefined : 'transform', willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`, animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
}} }}
@@ -309,51 +340,54 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
}} }}
/> />
{/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center. {/* 7. Attract-mode HUD: a tiny SCORE readout over a glowing "INSERT COIN"
Static scene shows it steady (no blink). */} blip, stacked bottom-right. That corner is the one spot that is
clear in every layout (below the members list, or the empty right
end of the read-receipt strip) — top-left collided with the space
bar and bottom-centre sat on the composer. Static scene shows both
steady (no blink). The font-size clamp collapses the text to nothing
when the host is narrower than ~330px, so the 76px settings swatch
never shows clipped glyphs. */}
<div <div
aria-hidden="true" aria-hidden="true"
style={{ style={{
position: 'absolute', position: 'absolute',
bottom: '5%', right: '14px',
left: '50%', bottom: '8px',
transform: 'translateX(-50%)', display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: '3px',
fontFamily: '"Courier New", monospace', fontFamily: '"Courier New", monospace',
fontSize: '12px', fontSize: HUD_FONT_SIZE,
fontWeight: 700, fontWeight: 700,
letterSpacing: '0.32em', lineHeight: 1,
color: NEON_CYAN,
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
userSelect: 'none', userSelect: 'none',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
opacity: reduced ? 0.6 : undefined,
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
}} }}
> >
INSERT COIN <div
</div> style={{
letterSpacing: '0.18em',
{/* 7b. Corner SCORE HUD glyph — a tiny pixel score that blips, top-left, color: NEON_MAGENTA,
very low opacity so it reads as ambient chrome, not UI. */} textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
<div opacity: reduced ? 0.5 : undefined,
aria-hidden="true" animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
style={{ }}
position: 'absolute', >
top: '2.5%', 1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
left: '2%', </div>
fontFamily: '"Courier New", monospace', <div
fontSize: '10px', style={{
fontWeight: 700, letterSpacing: '0.32em',
letterSpacing: '0.18em', color: NEON_CYAN,
color: NEON_MAGENTA, textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)', opacity: reduced ? 0.6 : undefined,
userSelect: 'none', animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
whiteSpace: 'nowrap', }}
opacity: reduced ? 0.5 : undefined, >
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`, INSERT COIN
}} </div>
>
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
</div> </div>
{/* 8. CRT vignette + screen-glow. A radial darkening frames the corners, {/* 8. CRT vignette + screen-glow. A radial darkening frames the corners,
@@ -97,8 +97,8 @@ function makeStars(count: number, seedBase: number): Star[] {
export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) { export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
// Two parallax depths. Far = dense + faint, Near = sparser + slightly larger. // Two parallax depths. Far = dense + faint, Near = sparser + slightly larger.
const farStars = useMemo<Star[]>(() => makeStars(16, 1000), []); const farStars = useMemo<Star[]>(() => makeStars(40, 1000), []);
const nearStars = useMemo<Star[]>(() => makeStars(12, 2000), []); const nearStars = useMemo<Star[]>(() => makeStars(22, 2000), []);
const heroStars = useMemo<HeroStar[]>( const heroStars = useMemo<HeroStar[]>(
() => () =>
@@ -144,7 +144,7 @@ export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
position: 'absolute', position: 'absolute',
inset: '-6%', inset: '-6%',
contain: 'layout paint style', contain: 'layout paint style',
backgroundColor: 'oklch(0.2 0.12 300 / 0.16)', backgroundColor: 'oklch(0.2 0.12 300 / 0.12)',
backgroundImage: [ backgroundImage: [
'radial-gradient(120% 90% at 50% -8%, oklch(0.28 0.13 295 / 0.2) 0%, transparent 60%)', 'radial-gradient(120% 90% at 50% -8%, oklch(0.28 0.13 295 / 0.2) 0%, transparent 60%)',
'radial-gradient(100% 80% at 12% 18%, oklch(0.55 0.2 330 / 0.1) 0%, transparent 55%)', 'radial-gradient(100% 80% at 12% 18%, oklch(0.55 0.2 330 / 0.1) 0%, transparent 55%)',