diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md
index f3c4253e2..6411e424d 100644
--- a/LOTUS_FEATURES.md
+++ b/LOTUS_FEATURES.md
@@ -175,19 +175,19 @@ Decorative CSS-only overlays that activate automatically on holidays and events.
### Themes
-| Theme | Window | Effect |
-| -------------------- | ------------- | -------------------------------------------------------------------------------------------------- |
-| ๐ New Year | Dec 31โJan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
-| ๐ฎ Lunar New Year | Jan 22โFeb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
-| ๐ Valentine's Day | Feb 10โ15 | โฅ hearts floating upward; soft pink ambient glow |
-| ๐ St. Patrick's Day | Mar 15โ18 | โ 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 |
-| ๐ฑ Earth Day | Apr 20โ23 | ๐ฟ๐ leaf emoji drift; sage green ambient tint; vine accent on left edge |
-| ๐ Autumn | Sep 21โOct 31 | Warm orange/amber leaf shapes rotating and falling |
-| ๐พ Arcade Day | Sep 12 | CRT scanlines; blinking pixel corner decorations; "INSERT COIN" prompt |
-| ๐ Deep Space Week | Oct 4โ10 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient |
-| ๐ Halloween | Oct 15โNov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
-| โ๏ธ Christmas | Dec 10โJan 2 | White dot snowfall in multiple layers at varied speeds |
+| Theme | Window | Effect |
+| -------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| ๐ New Year | Dec 31โJan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
+| ๐ฎ Lunar New Year | Jan 22โFeb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
+| ๐ Valentine's Day | Feb 10โ15 | โฅ hearts floating upward; soft pink ambient glow |
+| ๐ St. Patrick's Day | Mar 15โ18 | โ 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 |
+| ๐ฑ Earth Day | Apr 20โ23 | ๐ฟ๐ leaf emoji drift; sage green ambient tint; vine accent on left edge |
+| ๐ Autumn | Sep 21โOct 31 | Warm orange/amber leaf shapes rotating and falling |
+| ๐พ 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 4โ10 | 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 15โNov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
+| โ๏ธ Christmas | Dec 10โJan 2 | White dot snowfall in multiple layers at varied speeds |
### Implementation
diff --git a/src/app/components/seasonal/SeasonalEffect.tsx b/src/app/components/seasonal/SeasonalEffect.tsx
index 82b4f2e34..caf40d9ff 100644
--- a/src/app/components/seasonal/SeasonalEffect.tsx
+++ b/src/app/components/seasonal/SeasonalEffect.tsx
@@ -84,7 +84,15 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
return (
{buildOverlayContent(theme, true)}
diff --git a/src/app/components/seasonal/themes/Arcade.css.ts b/src/app/components/seasonal/themes/Arcade.css.ts
index 83f77a572..570cdd5e1 100644
--- a/src/app/components/seasonal/themes/Arcade.css.ts
+++ b/src/app/components/seasonal/themes/Arcade.css.ts
@@ -102,12 +102,12 @@ export const animSparkleTwinkle = keyframes({
* Opacity + a hair of scale for a CRT bloom feel.
*/
export const animCoinBlink = keyframes({
- '0%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
- '6%': { opacity: '1', transform: 'translateX(-50%) scale(1.015)' },
- '12%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
- '49%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
- '50%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
- '100%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
+ '0%': { opacity: '0.85', transform: 'scale(1)' },
+ '6%': { opacity: '1', transform: 'scale(1.015)' },
+ '12%': { opacity: '0.85', transform: 'scale(1)' },
+ '49%': { opacity: '0.85', transform: 'scale(1)' },
+ '50%': { opacity: '0', transform: 'scale(1)' },
+ '100%': { opacity: '0', transform: 'scale(1)' },
});
/**
diff --git a/src/app/components/seasonal/themes/Arcade.tsx b/src/app/components/seasonal/themes/Arcade.tsx
index 95f4471dc..c5c1d0652 100644
--- a/src/app/components/seasonal/themes/Arcade.tsx
+++ b/src/app/components/seasonal/themes/Arcade.tsx
@@ -46,6 +46,10 @@ const NEON_CYAN = 'oklch(0.80 0.15 200)';
const GRID_PURPLE = 'oklch(0.45 0.18 300)';
// 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
// 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
@@ -58,7 +62,7 @@ function gridDataUri(): string {
rows.forEach((y) => {
lines.push(
``,
+ `stroke-width='1.2' stroke-opacity='0.9' vector-effect='non-scaling-stroke'/>`,
);
});
// 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
lines.push(
``,
+ `stroke-width='1.2' stroke-opacity='0.8' vector-effect='non-scaling-stroke'/>`,
);
}
const svg =
@@ -105,6 +109,13 @@ const RESTING_SPARKLES: ReadonlyArray<{
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) {
// Deterministic sparkle field, computed ONCE. No per-frame state.
const sparkles = useMemo(() => {
@@ -134,9 +145,9 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
position: 'absolute',
inset: 0,
backgroundImage: [
- 'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.16) 0%, transparent 55%)',
- 'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.20) 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%)',
+ '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.16) 0%, transparent 60%)',
+ 'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.08) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.12) 100%)',
].join(','),
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
half of the screen โ the "floor". The inner plane scrolls upward by
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. */}
{/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits
@@ -197,7 +226,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
height: '34%',
transform: 'translate(-50%, -50%)',
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',
}}
/>
@@ -209,8 +238,10 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
right: '12%',
top: '38%',
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%)`,
- opacity: 0.55,
+ // Bright at the flanks, dropped out through the centre column so the
+ // 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))',
}}
/>
@@ -273,7 +304,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
inset: 0,
overflow: 'hidden',
mixBlendMode: 'multiply',
- opacity: 0.5,
+ opacity: 0.32,
contain: 'layout paint style',
}}
>
@@ -285,7 +316,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
top: '-8px',
bottom: '-8px',
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',
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.
- Static scene shows it steady (no blink). */}
+ {/* 7. Attract-mode HUD: a tiny SCORE readout over a glowing "INSERT COIN"
+ 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. */}
- INSERT COIN
-
-
- {/* 7b. Corner SCORE HUD glyph โ a tiny pixel score that blips, top-left,
- very low opacity so it reads as ambient chrome, not UI. */}
-
- 1UP 00
0000
+
+ 1UP 000000
+
+
+ INSERT COIN
+
{/* 8. CRT vignette + screen-glow. A radial darkening frames the corners,
diff --git a/src/app/components/seasonal/themes/DeepSpace.tsx b/src/app/components/seasonal/themes/DeepSpace.tsx
index e4d2d0212..653072e76 100644
--- a/src/app/components/seasonal/themes/DeepSpace.tsx
+++ b/src/app/components/seasonal/themes/DeepSpace.tsx
@@ -97,8 +97,8 @@ function makeStars(count: number, seedBase: number): Star[] {
export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
// Two parallax depths. Far = dense + faint, Near = sparser + slightly larger.
- const farStars = useMemo(() => makeStars(16, 1000), []);
- const nearStars = useMemo(() => makeStars(12, 2000), []);
+ const farStars = useMemo(() => makeStars(40, 1000), []);
+ const nearStars = useMemo(() => makeStars(22, 2000), []);
const heroStars = useMemo(
() =>
@@ -144,7 +144,7 @@ export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
position: 'absolute',
inset: '-6%',
contain: 'layout paint style',
- backgroundColor: 'oklch(0.2 0.12 300 / 0.16)',
+ backgroundColor: 'oklch(0.2 0.12 300 / 0.12)',
backgroundImage: [
'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%)',