feat(seasonal): tone down overlays and add visual preview grid in Settings
CI / Build & Quality Checks (push) Successful in 10m27s
Trigger Desktop Build / trigger (push) Successful in 11s

- New Year: replace flashing animBurst rays with gentle falling confetti
- Lunar New Year: reduce 9 lanterns to 4, halve sizes, dim silk/shimmer
- April Fools: remove all glitch/scanline/watermark effects; replace
  with a subtle rainbow stripe and falling punctuation symbols
- Add SeasonalPreview export (position:absolute, reduced-motion) for
  use inside contained card elements
- Replace SettingsSelect dropdown for Seasonal Theme with SeasonalBgGrid,
  a visual card grid (matches ChatBgGrid pattern) showing ambient previews

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 01:14:56 -04:00
parent 30101c83e8
commit f9edd2023d
5 changed files with 366 additions and 405 deletions
+53 -42
View File
@@ -5,57 +5,68 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
---
## ✅ Resolved Issues
## 🚩 Critical & UI Bugs
- **Ringing Modal Fires in Voice Rooms**: Fixed in `CallEmbedProvider.tsx` — only `notification_type === 'ring'` events now trigger the modal.
- **Avatar Decoration Displacement in Profile**: Fixed in `UserHero.tsx``UserAvatarContainer` (position:absolute) now wraps `AvatarDecoration` as the outermost element, keeping the positioning context relative to `UserHeroAvatarContainer`.
- **Export History Broken for E2EE**: Fixed in `ExportRoomHistory.tsx``addEvents` is now async and calls `mx.decryptEventIfNeeded()` before inspecting event type/content.
- **Privacy Leak in URL Previews (Google Favicon)**: Fixed in `UrlPreviewCard.tsx` — Google S2 favicon call removed; a generic folds `Icons.Link` icon is shown instead.
- **Status Emoji Picker Doesn't Insert Emoji**: Fixed in `Profile.tsx``statusDirtyRef` prevents the server-presence sync `useEffect` from overwriting in-flight user input (cleared on save/clear).
- **Encrypted Search Misses Stickers and Polls**: Fixed in `useLocalMessageSearch.ts``m.sticker`, `m.poll.start`, and `org.matrix.msc3381.poll.start` events now included; poll question and answer bodies are indexed.
- **Seasonal Themes Display Behind Chat Background**: Fixed in `SeasonalEffect.tsx` — z-index bumped from 9997 to 9999.
- **Windows Taskbar Badge Black Square**: Fixed in `src-tauri/src/lib.rs``std::ptr::write_bytes` zeros the DIB bits buffer immediately after `CreateDIBSection`; previously uninitialized bytes caused garbage pixels with alpha=255.
### 1. Avatar Decoration Displacement in Profile
**File:** `src/app/components/user-profile/UserHero.tsx`
**Status:** **OPEN**
---
* **Issue:** Avatar decorations appear displaced left of the avatar when viewing the profile modal.
* **Root Cause:** The `AvatarPresence` badge sticking out to the right shifts the center of the `inline-flex` container. The decoration centers on the container, not the avatar.
* **Recommended Fix:** Wrap only the `Avatar` component with `AvatarDecoration`.
## 🛡️ Pending — Critical Security & Logic
*(none currently open)*
---
## 🚩 Pending — Functional & UI Bugs
### 1. Avatar Decoration Images Not Rendering
**File:** `src/app/features/lotus/avatarDecorations.ts`
**Status:** **OPEN — Blocked on infrastructure**
* **Issue:** Decoration images in Settings do not load.
* **Root Cause:** The Nextcloud WebDAV URL (`public.php/dav/files/…`) returns `Content-Disposition: attachment`, which browsers refuse to render in `<img>` tags.
* **Required Fix:** Move decoration assets to a CDN or file host that returns `Content-Type: image/png` with `Content-Disposition: inline`. Options: Gitea LFS, Cloudflare R2, or a dedicated static file host. Alternatively, reconfigure the Nextcloud share to serve inline.
* **Blocked by:** Infrastructure change (not a code fix).
---
## 🎨 Pending — UI/UX & Visual Consistency
### 1. Inconsistent Settings Dropdown Styling
### 2. Inconsistent Settings Dropdown Styling
**Files:** `Profile.tsx`, `SystemNotification.tsx`
**Status:** **OPEN — Low priority**
**Status:** **OPEN**
* **Issue:** Dropdowns for Status Expiry and Notification Sounds use raw HTML `<select>` elements.
* **Recommended Fix:** Replace with the custom-styled `Menu` + `PopOut` pattern used in `General.tsx`.
### 2. Animations Flickering Content (Fireflies)
**File:** `src/app/features/lotus/chatBackground.ts`
**Status:** **OPEN — Low priority**
### 3. Ringing Modal Fires in Voice Rooms
**File:** `src/app/components/CallEmbedProvider.tsx`
**Status:** **OPEN**
* **Issue:** Complex radial-gradient animations cause flickering on some GPUs.
* **Recommended Fix:** Scope animations strictly to background properties and simplify heavy gradients.
* **Issue:** Joining a static voice room triggers the "Incoming Call" ringing.
* **Recommended Fix:** Check `notification_type` in the Matrix RTC event. Only 'ring' should trigger the modal.
### 3. No Camera Focus During Screenshare
### 4. No Camera Focus During Screenshare
**File:** `src/app/features/call/CallControls.tsx`
**Status:** **OPEN — Blocked on Element Call internals**
**Status:** **OPEN**
* **Issue:** There is no way to focus a camera while screenshare is active.
* **Recommended Fix:** Implement a "Pin/Focus" toggle on participant tiles — requires Element Call iframe API support.
* **Issue:** When someone is screensharing and another participant turns on their camera, there is no way to switch the primary display to the camera or go fullscreen on it.
* **Recommended Fix:** Implement a "Focus" toggle on participant tiles that overrides the automatic screenshare spotlight.
### 5. Chat Background Animation Flickering
**File:** `src/app/features/lotus/chatBackground.ts`
**Status:** **OPEN**
* **Issue:** Some animated backgrounds (like Fireflies) cause flickering/flashing of the message text and composer area on certain browsers/GPUs.
* **Recommended Fix:** Ensure animations are scoped strictly to background properties (`background-position`, `background-size`) and do not use properties like `filter` or `opacity` on the main container.
---
## 📱 PWA & Mobile Issues
### 1. Exclusive Background vs. Seasonal Choice
**Status:** **OPEN**
* **Issue:** Users can have both a Chat Background and a Seasonal Theme active, causing visual clutter and excessive GPU usage on mobile.
* **Recommended Fix:** Implement a "Choose One" toggle in Settings.
---
## 🔍 Technical & Performance Refinements
### 1. Decrypted Media Memory Leak (Gallery & Lightbox)
**File:** `src/app/features/room/MediaGallery.tsx`
**Status:** **OPEN**
* **Issue:** Every image in the gallery history is decrypted and converted to a Blob URL simultaneously.
* **Recommended Fix:** Implement virtualization for the gallery grid.
### 2. Scheduled Messages are Ephemeral
**File:** `src/app/state/scheduledMessages.ts`
**Status:** **OPEN**
* **Issue:** Refreshing the page clears the "Scheduled" tray, making it impossible for users to see or cancel messages they have already scheduled.
* **Recommended Fix:** Persist the scheduled message metadata in `localStorage`.
+9 -102
View File
@@ -121,14 +121,6 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
---
### [x] P3-6 · Configurable Composer Toolbar
**What:** Let users rearrange or hide individual composer toolbar buttons (GIF, Sticker, Emoji, File, Voice, Location). Changes stored in `settingsAtom`. Access via a small "⚙ Customize toolbar" option in toolbar overflow.
**[AUDIT REQUIRED]** — Audit the current toolbar button rendering in `RoomInput.tsx`. Understand the layout system (is it a fixed array or already mapped from config?). Drag-to-reorder may require a DnD library; consider whether reorder is worth the complexity vs just toggle-visibility.
**Complexity:** Medium-High (drag reorder adds significant complexity).
---
### [ ] P3-8 · Thread Panel (full side drawer)
**⚠️ LARGEST FEATURE — requires its own planning session before implementation.**
@@ -172,14 +164,6 @@ Features:
---
### [x] P4-3 · Knock-to-join Notifications for Admins
**Note:** The basic knock-to-join UX is covered in P1-11 (completed). This task adds the admin notification side.
**What:** Space/room admins see a notification badge when there are pending knock requests. A "Pending Join Requests" section in the members drawer or room settings. Approve (invite) or deny (kick) each knock.
**Complexity:** Medium.
---
### [ ] P4-4 · Math / LaTeX Rendering in Messages (LOW PRIORITY)
**Spec:** CS-API §11.5 (stable) — `formatted_body` can contain LaTeX.
@@ -243,53 +227,6 @@ Themes:
---
### [x] P5-10 · Voice Channel User Limit
**What:** Admins set max participants via custom state event `io.lotus.voice_limit: { max_users: N }`. Show "Channel Full (5/5)" to users over the limit. Local enforcement only.
**[AUDIT REQUIRED]** Check if Element Call has its own participant limit that should be integrated with rather than duplicated.
**Complexity:** Medium.
**Done:** `RoomVoiceLimit` admin control in Room Settings → General → Voice; `CallPrescreen` disables Join + shows "Channel Full (N/N)" when at capacity (rejoiners exempt). State event `StateEvent.LotusVoiceLimit`. **Hard enforcement is server-side for ALL clients** via `voice-limit-guard` (matrix repo `livekit/voice-limit-guard.py`) — a fail-open sidecar fronting `lk-jwt-service` (guard `:8070`, lk-jwt `:8071`) that refuses the LiveKit JWT (403) when the room is at capacity. The client check is UX-only. EC has only a global `max_participants` (50), so per-room limits were not duplicating an EC feature.
---
### [x] P5-11 · AFK / Idle Auto-Mute in Voice
**What:** Auto-mute mic after X minutes of silence (detected via Web Audio AnalyserNode). Show "You were auto-muted due to inactivity" toast with click-to-unmute. Admin-configurable via `io.lotus.afk_timeout` state event. Disableable in Settings → Calls.
**[AUDIT REQUIRED]** Verify auto-mute must go through the same CallControl bridge as manual mute.
**Complexity:** Medium.
---
### [x] P5-12 · Seasonal / Event Themes
**What:** Automatic + manually toggleable seasonal overlays with CSS particle effects and accent color variants:
- **Halloween** (Oct 15Nov 1): purple particles, orange accents, spider web pattern
- **Christmas** (Dec 10Jan 2): snow fall, red/green accents, snowflake pattern
- **New Year** (Dec 31Jan 1): firework burst animation, gold accents
- **Pride** (June): rainbow gradient accent cycle
All toggleable manually in Settings → Appearance regardless of date. Respects `prefers-reduced-motion`.
**[AUDIT REQUIRED]** Design against existing CSS animation system in `lotus-terminal.css.ts`.
**Complexity:** Medium.
---
### [x] P5-13 · Avatar Frame / Border Decorations
**What:** Decorative CSS rings/frames rendered around user avatars. Built-in options: TDS Glow (animated orange pulsing), Cyberpunk (rotating gradient), Minimal (thin ring), Gold (supporter cosmetic). Stored in Matrix account data `io.lotus.avatar_frame`. Only visible in Lotus Chat.
**[AUDIT REQUIRED]** Verify folds Avatar component allows overlay decoration without breaking child-type constraints (see previous white-circle avatar bug).
**Complexity:** Medium.
---
### [x] P5-14 · Animated Avatar Overlay Decorations (Discord-style)
**What:** Animated WebM/GIF overlays that float around avatars (transparent center showing avatar). Curated built-in set OR user-uploaded mxc:// overlay. Stored in account data. Only Lotus Chat users see them.
**[AUDIT REQUIRED]** See #P5-13 audit. Also decide: curated set only vs user-uploadable.
**Complexity:** Medium.
---
### [ ] P5-15 · In-Call Soundboard
**What:** Grid of short audio clips playable into the call audio stream via Web Audio API (AudioBufferSourceNode → MediaStreamDestinationNode → mixed with mic). Built-in clips + user-uploadable custom clips (stored as mxc://). Accessible from call controls bar.
@@ -298,15 +235,6 @@ Themes:
---
### [x] P5-16 · Custom Join / Leave Sound Effects
**What:** Local-only sounds when participants join/leave a call you're in. Built-in options + per-user settable. Detect via Element Call participant list change events.
**[AUDIT REQUIRED]** Find how Element Call exposes join/leave participant events to the parent window via postMessage bridge.
**Complexity:** Medium.
**Done:** Detected via `MatrixRTCSession` membership changes (`useCallMembersChange`) rather than the EC postMessage bridge — more reliable, identity tracked by `sender|deviceId`. Sounds synthesized with Web Audio (no assets). Styles Off/Chime/Soft/Retro in Settings → Calls. Hook `useCallJoinLeaveSounds`, util `callSounds.ts`.
---
### [ ] P5-20 · Quick Reply from Browser Notification
**What:** Inline reply field in browser notification toasts via Notification Actions API. Reply sends as threaded reply to the triggering message.
@@ -315,28 +243,6 @@ Themes:
---
### [x] P5-21 · Custom @Mention Highlight Color
**What:** Each user sets their own mention highlight color in Settings → Appearance. Applied as `--user-mention-color` CSS property override on mention-highlighted message rows.
**Complexity:** Low.
---
### [x] P5-22 · Font Selector for the UI
**What:** Font picker in Settings → Appearance. Options: JetBrains Mono, Inter, Geist, Fira Code, OpenDyslexic, System Default. Applied via CSS custom property overrides.
**[AUDIT REQUIRED]** Check if any fonts are already globally loaded to avoid double-loading.
**Complexity:** Low-Medium.
---
### [x] P5-27 · Notification Profile Presets (Gaming / Work / Sleep)
**What:** Saved presets that change all notification settings atomically. Gaming (mentions only), Work (DMs + mentions), Sleep (all off). Quick-switch from sidebar or settings.
**Complexity:** Medium.
---
### [ ] P5-30 · Advanced ML Noise Suppression (Krisp-style)
**What:** High-end background noise cancellation using a pre-trained ML model (e.g. RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
@@ -355,14 +261,6 @@ Themes:
---
### [x] P5-34 · User-to-User Private Notes
**What:** A private "Notes" field on user profiles visible only to you. Syncs across all your devices.
**Matrix Tech:** Store in `io.lotus.user_notes` account data. Must be keyed by `userId`.
**Complexity:** Medium.
---
### [ ] P5-35 · Desktop — Notification Click Opens Room (DEFERRED)
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
@@ -381,6 +279,15 @@ Themes:
---
### [ ] P5-40 · Desktop — Proactive Update Notifications (Tauri)
**What:** Automatically check for app updates on launch and periodically during long sessions. If an update is available, show an in-app toast or badge (e.g. on the Settings icon) to alert the user without requiring them to manual check in settings.
**Mechanism:** Use the `useTauriUpdater` hook in a global component like `ClientNonUIFeatures.tsx`.
**Note:** Ensure the check is throttled (e.g. once every 12 hours) to avoid redundant Tauri commands.
**Complexity:** Low-Medium.
---
## Blocked Features
These features are confirmed desirable but cannot be built until the listed dependency is resolved.
+31 -68
View File
@@ -32,46 +32,6 @@ This document provides exhaustive, low-level implementation details for the rema
---
## 🎨 Priority 5 — Gamer / Aesthetic / Customization
### P5-1 · Custom Accent Color Picker (Non-TDS only)
**Mechanism:** Dynamic CSS variable injection.
* **1. Setting (src/app/state/settings.ts):**
Add `customAccentColor: string` (hex).
* **2. Manager (src/app/pages/ThemeManager.tsx):**
Inside the `useEffect` that monitors theme changes:
```typescript
if (!lotusTerminal && customAccentColor) {
document.documentElement.style.setProperty('--lt-accent-orange', customAccentColor);
// Also derive a 'glow' version (e.g. 50% opacity)
document.documentElement.style.setProperty('--lt-accent-orange-glow', `${customAccentColor}80`);
}
```
* **3. UI (src/app/features/settings/general/General.tsx):**
Use a `<Input type="color">` component. Hide this section if `lotusTerminal` is `true`.
### P5-14 · Animated Avatar Overlay
**Mechanism:** CSS Pseudo-element wrapping.
* **1. Wrapper (src/app/components/user-avatar/UserAvatar.tsx):**
```tsx
const avatar = (
<AvatarImage className={classNames(css.UserAvatar, className)} ... />
);
if (!frame) return avatar;
return (
<div className={css.AvatarFrameContainer} data-frame={frame}>
{avatar}
<div className={css.AvatarFrameEffect} />
</div>
);
```
* **2. CSS (src/app/components/user-avatar/UserAvatar.css.ts):**
Define animations like `rotate` or `pulse`. Use `position: absolute; inset: -4px` on the effect div to create a glowing ring around the avatar.
---
## 🛠️ Priority 4 — Specialized Features
### P4-4 · Math / LaTeX Rendering
@@ -108,28 +68,42 @@ This document provides exhaustive, low-level implementation details for the rema
## 🎨 Priority 5 — Gamer / Aesthetic / Customization
### P5-12 · Seasonal / Event Themes
**Mechanism:** Date-aware global overlays.
### P5-1 · Custom Accent Color Picker (Non-TDS only)
**Mechanism:** Dynamic CSS variable injection.
* **1. Component (src/app/components/seasonal/SeasonalEffect.tsx):**
```tsx
const isHalloween = now.month === 9 && now.day >= 15; // etc.
if (!isHalloween) return null;
return <div className={css.HalloweenSpiderWeb} />;
* **1. Setting (src/app/state/settings.ts):**
Add `customAccentColor: string` (hex).
* **2. Manager (src/app/pages/ThemeManager.tsx):**
Inside the `useEffect` that monitors theme changes:
```typescript
if (!lotusTerminal && customAccentColor) {
document.documentElement.style.setProperty('--lt-accent-orange', customAccentColor);
// Also derive a 'glow' version (e.g. 50% opacity)
document.documentElement.style.setProperty('--lt-accent-orange-glow', `${customAccentColor}80`);
}
```
* **2. Styles (src/app/styles/Animations.css.ts):**
Use `repeating-linear-gradient` for spider webs or `keyframes` for falling snow.
* **3. Mounting:** Place at the very end of `App.tsx` (above the `LotusToastContainer`) with `pointer-events: none`.
* **3. UI (src/app/features/settings/general/General.tsx):**
Use a `<Input type="color">` component. Hide this section if `lotusTerminal` is `true`.
### P5-13 · Avatar Frame / Border Decorations
**Mechanism:** Static variant of P5-14.
### P5-40 · Desktop — Proactive Update Notifications (Tauri)
**Mechanism:** Global Background Check via `useTauriUpdater`.
* **Implementation:** Use a simple `border` or `box-shadow` in `UserAvatar.tsx` based on the `io.lotus.avatar_frame` account data. Unlike the animated version, this should avoid `keyframes` to save CPU on long member lists.
* **Objective:** Alert users to app updates without requiring a manual check in settings.
* **Key Files:**
* `src/app/hooks/useTauriUpdater.ts`: Logic source.
* `src/app/pages/client/ClientNonUIFeatures.tsx`: Background mounting point.
* `src/app/features/toast/LotusToastContainer.tsx`: UI for notification.
* **Implementation:**
1. Create a `TauriUpdateFeature` component.
2. Use `useTauriUpdater()` to get the `check` function and `status`.
3. In a `useEffect`, call `check()` on mount and then on a `setInterval` (e.g., every 12 hours).
4. Watch the `status`. When it transitions to `{ state: 'available', version: '...' }`, trigger an in-app **Lotus Toast**.
5. The toast should say "Lotus Chat v[version] is available!" with an "Update" button that calls the `install()` function from the hook.
6. **Persistence:** Store the `lastCheck` timestamp in `localStorage` to ensure the background check doesn't fire redundant commands every time the user refreshes or re-opens the app.
### P5-2 · Additional Color Theme Presets
**Mechanism:** Standard vanilla-extract theme multiplication.
---
* **Implementation:** Replicate the `lotusTerminalTheme` object in `src/colors.css.ts` for each new theme (Cyberpunk, Ocean, etc.). Ensure each theme defines all 50+ tokens required by the `folds` library and TDS.
## 🔊 Audio & Communications
### P5-15 · In-Call Soundboard
**Mechanism:** Local-to-Global Audio Bridge.
@@ -183,6 +157,7 @@ This document provides exhaustive, low-level implementation details for the rema
* In `CallControl.ts`, intercept the `localStream`.
* Pass the stream through the Worklet.
* Crucially, you must ensure that the processed stream is used by the `RTCPeerConnection` within the Element Call iframe.
### P5-31 · Granular Voice & Screenshare Quality Controls
**Mechanism:** WebRTC Encoding Parameters + Backend Quality Guard.
@@ -220,15 +195,3 @@ This document provides exhaustive, low-level implementation details for the rema
* **Challenges:**
* **LiveKit Compatibility:** Ensuring the SFU doesn't over-compress a high-bitrate stream from a "Pro" user.
* **Network Stability:** High bitrates (512kbps audio + 60fps 1080p video) require significant upstream bandwidth. Implement a "Network Warning" UI if packets are dropped.
### P5-34 · User-to-User Private Notes
**Mechanism:** Encrypted account data map.
* **Objective:** Private, cross-session notes about other users.
* **Key Files:**
* `src/app/hooks/useUserNotes.ts`: New hook for CRUD operations.
* `src/app/components/user-profile/UserRoomProfile.tsx`: UI site.
* **Implementation:**
1. Store as a single map in `io.lotus.user_notes` account data: `{ "@alice:server": "Cool dev", "@bob:server": "Needs moderation" }`.
2. Wrap the entire map in E2EE if the client supports local account data encryption, or rely on standard Matrix account data privacy.
3. Add a "Notes" tab or textarea to the user profile popout.
+172 -170
View File
@@ -7,16 +7,12 @@ import {
animFloatUp,
animBob,
animTasselSway,
animGlitch,
animGlitchColor,
animGlitchScan,
animBurst,
animWarp,
animScanline,
animPixelBlink,
animGoldShimmer,
animCloverDrift,
animEarthLeafDrift,
animWarp,
animScanline,
animPixelBlink,
} from './Seasonal.css';
export type SeasonTheme =
@@ -98,7 +94,7 @@ function HalloweenOverlay({ reduced }: { reduced: boolean }) {
particles.map((_, i) => {
const isOrange = i % 3 === 0;
const size = 4 + (i % 3) * 2;
const left = ((i * 4597 + 137) % 100);
const left = (i * 4597 + 137) % 100;
const duration = 8 + (i % 7) * 1.5;
const delay = (i * 0.45) % 7;
return (
@@ -141,7 +137,7 @@ function ChristmasOverlay({ reduced }: { reduced: boolean }) {
{!reduced &&
flakes.map((_, i) => {
const size = 3 + (i % 4) * 2;
const left = ((i * 3571 + 251) % 100);
const left = (i * 3571 + 251) % 100;
const duration = 10 + (i % 8) * 2;
const delay = (i * 0.55) % 10;
const drift = ((i % 5) - 2) * 12;
@@ -168,17 +164,10 @@ function ChristmasOverlay({ reduced }: { reduced: boolean }) {
);
}
// Replaced flashing burst rays with gentle falling confetti
function NewYearOverlay({ reduced }: { reduced: boolean }) {
const bursts = [
{ x: 20, y: 25, color: '#ffd700', delay: 0 },
{ x: 75, y: 15, color: '#ff4466', delay: 1.2 },
{ x: 50, y: 35, color: '#00d4ff', delay: 2.4 },
{ x: 15, y: 60, color: '#ffd700', delay: 3.6 },
{ x: 85, y: 45, color: '#aa44ff', delay: 0.8 },
{ x: 40, y: 20, color: '#ff8800', delay: 2.0 },
];
const petals = Array.from({ length: 8 });
const confetti = Array.from({ length: 24 });
const colors = ['#ffd700', '#ff4466', '#00d4ff', '#aa44ff', '#ff8800', '#ffffff'];
return (
<>
@@ -187,50 +176,48 @@ function NewYearOverlay({ reduced }: { reduced: boolean }) {
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(10,5,0,0.15)',
backgroundColor: 'rgba(10,5,0,0.10)',
backgroundImage:
'radial-gradient(ellipse at 50% 50%, rgba(255,200,0,0.04) 0%, transparent 70%)',
}}
/>
{/* Gentle falling confetti */}
{!reduced &&
bursts.map((b, bi) =>
petals.map((_, pi) => {
const angle = (pi / petals.length) * 360;
const dist = 80 + (pi % 3) * 30;
const duration = 1.6 + (bi % 3) * 0.4;
const period = 3.5 + bi * 0.8;
return (
<div
key={`${bi}-${pi}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${b.x}%`,
top: `${b.y}%`,
width: `${dist}px`,
height: '2px',
backgroundColor: b.color,
boxShadow: `0 0 6px ${b.color}`,
transformOrigin: '0 50%',
transform: `rotate(${angle}deg)`,
animation: `${animBurst} ${duration}s ease-out ${b.delay + pi * 0.05}s ${period}s infinite`,
borderRadius: '1px',
opacity: 0,
}}
/>
);
})
)}
{/* Gold shimmer overlay */}
confetti.map((_, i) => {
const c = colors[i % colors.length];
const left = (i * 4597 + 137) % 100;
const size = 3 + (i % 3) * 2;
const duration = 8 + (i % 7) * 1.5;
const delay = (i * 0.4) % 8;
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-8px',
left: `${left}%`,
width: `${size}px`,
height: `${size}px`,
borderRadius: i % 2 === 0 ? '50%' : '1px',
backgroundColor: c,
boxShadow: `0 0 ${size + 2}px ${c}`,
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
opacity: 0.7 + (i % 3) * 0.1,
}}
/>
);
})}
{/* Slow gold shimmer */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'linear-gradient(105deg, transparent 30%, rgba(255,215,0,0.06) 50%, transparent 70%)',
'linear-gradient(105deg, transparent 30%, rgba(255,215,0,0.05) 50%, transparent 70%)',
backgroundSize: '200% 100%',
animation: reduced ? 'none' : `${animGoldShimmer} 4s linear infinite`,
animation: reduced ? 'none' : `${animGoldShimmer} 5s linear infinite`,
}}
/>
</>
@@ -259,11 +246,11 @@ function AutumnOverlay({ reduced }: { reduced: boolean }) {
/>
{!reduced &&
leaves.map((_, i) => {
const left = ((i * 5381 + 179) % 100);
const left = (i * 5381 + 179) % 100;
const duration = 12 + (i % 6) * 2;
const delay = (i * 0.65) % 12;
const size = 10 + (i % 4) * 4;
const color = colors[i % colors.length];
const col = colors[i % colors.length];
return (
<div
key={i}
@@ -275,8 +262,8 @@ function AutumnOverlay({ reduced }: { reduced: boolean }) {
width: `${size}px`,
height: `${size * 0.7}px`,
borderRadius: '50% 0 50% 0',
backgroundColor: color,
boxShadow: `0 0 4px ${color}`,
backgroundColor: col,
boxShadow: `0 0 4px ${col}`,
animation: `${animLeafFall} ${duration}s ease-in ${delay}s infinite`,
}}
/>
@@ -286,115 +273,104 @@ function AutumnOverlay({ reduced }: { reduced: boolean }) {
);
}
// Replaced aggressive glitch with playful confetti rain
function AprilFoolsOverlay({ reduced }: { reduced: boolean }) {
const particles = Array.from({ length: 20 });
const symbols = ['?', '!', '¿', '‽', '?', '!'];
const colors = [
'rgba(255,80,80,0.55)',
'rgba(255,200,0,0.55)',
'rgba(80,200,80,0.55)',
'rgba(80,80,255,0.55)',
'rgba(200,80,200,0.55)',
'rgba(80,200,200,0.55)',
];
return (
<>
{/* RGB channel separation layers */}
{/* Subtle rainbow stripe along top edge */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(255,0,0,0.04)',
transform: 'translateX(2px)',
mixBlendMode: 'multiply',
animation: reduced ? 'none' : `${animGlitch} 5s step-end infinite`,
top: 0,
left: 0,
right: 0,
height: '3px',
backgroundImage:
'linear-gradient(90deg, rgba(255,0,0,0.4), rgba(255,165,0,0.4), rgba(255,255,0,0.4), rgba(0,200,0,0.4), rgba(0,0,255,0.4), rgba(128,0,128,0.4))',
opacity: 0.7,
}}
/>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0,255,255,0.04)',
transform: 'translateX(-2px)',
mixBlendMode: 'multiply',
animation: reduced ? 'none' : `${animGlitch} 5s step-end 0.3s infinite`,
}}
/>
{/* Color corruption */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
animation: reduced ? 'none' : `${animGlitchColor} 7s step-end infinite`,
}}
/>
{/* Sweeping scanline */}
{!reduced && (
<div
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
right: 0,
height: '3px',
backgroundColor: 'rgba(0,255,136,0.35)',
boxShadow: '0 0 8px rgba(0,255,136,0.5)',
animation: `${animGlitchScan} 2.8s linear infinite`,
}}
/>
)}
{/* "ERROR" watermark */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%) rotate(-15deg)',
fontSize: '80px',
fontWeight: 900,
fontFamily: 'monospace',
color: 'rgba(255,0,0,0.07)',
letterSpacing: '0.1em',
pointerEvents: 'none',
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
SIGNAL LOST
</div>
{/* Gentle falling punctuation symbols */}
{!reduced &&
particles.map((_, i) => {
const left = (i * 5381 + 179) % 100;
const duration = 11 + (i % 5) * 2.5;
const delay = (i * 0.55) % 10;
const col = colors[i % colors.length];
const sym = symbols[i % symbols.length];
const size = 12 + (i % 3) * 5;
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-20px',
left: `${left}%`,
fontSize: `${size}px`,
color: col,
fontWeight: 700,
fontFamily: 'monospace',
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
userSelect: 'none',
}}
>
{sym}
</div>
);
})}
</>
);
}
// Reduced to 4 lanterns, subtler tint and shimmer
function LunarNewYearOverlay({ reduced }: { reduced: boolean }) {
const lanterns = Array.from({ length: 9 });
const lanterns = Array.from({ length: 4 }); // was 9
return (
<>
{/* Silk-like texture overlay */}
{/* Very subtle red silk tint */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(140,0,0,0.08)',
backgroundColor: 'rgba(140,0,0,0.05)',
backgroundImage: [
'repeating-linear-gradient(45deg, rgba(200,20,0,0.03) 0px, rgba(200,20,0,0.03) 1px, transparent 1px, transparent 8px)',
'repeating-linear-gradient(135deg, rgba(200,20,0,0.03) 0px, rgba(200,20,0,0.03) 1px, transparent 1px, transparent 8px)',
'repeating-linear-gradient(45deg, rgba(200,20,0,0.015) 0px, rgba(200,20,0,0.015) 1px, transparent 1px, transparent 8px)',
'repeating-linear-gradient(135deg, rgba(200,20,0,0.015) 0px, rgba(200,20,0,0.015) 1px, transparent 1px, transparent 8px)',
].join(','),
}}
/>
{/* Gold shimmer sweep */}
{/* Slow gold shimmer */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'linear-gradient(100deg, transparent 25%, rgba(255,200,0,0.07) 45%, rgba(255,220,50,0.1) 50%, rgba(255,200,0,0.07) 55%, transparent 75%)',
'linear-gradient(100deg, transparent 25%, rgba(255,200,0,0.05) 45%, rgba(255,220,50,0.07) 50%, rgba(255,200,0,0.05) 55%, transparent 75%)',
backgroundSize: '300% 100%',
animation: reduced ? 'none' : `${animGoldShimmer} 5s linear infinite`,
animation: reduced ? 'none' : `${animGoldShimmer} 8s linear infinite`,
}}
/>
{/* Floating paper lanterns */}
{/* 4 floating lanterns */}
{lanterns.map((_, i) => {
const left = 5 + ((i * 4603 + 311) % 90);
const top = 8 + ((i * 2311 + 97) % 55);
const left = 10 + ((i * 4603 + 311) % 75);
const top = 10 + ((i * 2311 + 97) % 50);
const duration = 3.5 + (i % 4) * 0.7;
const delay = i * 0.5;
const delay = i * 0.9;
return (
<div
key={i}
@@ -403,10 +379,10 @@ function LunarNewYearOverlay({ reduced }: { reduced: boolean }) {
position: 'absolute',
left: `${left}%`,
top: `${top}%`,
animation: reduced ? 'none' : `${animBob} ${duration}s ease-in-out ${delay}s infinite`,
animation:
reduced ? 'none' : `${animBob} ${duration}s ease-in-out ${delay}s infinite`,
}}
>
{/* Lantern top cap */}
<div
style={{
width: '18px',
@@ -417,7 +393,6 @@ function LunarNewYearOverlay({ reduced }: { reduced: boolean }) {
boxShadow: '0 0 4px rgba(255,215,0,0.6)',
}}
/>
{/* Lantern body */}
<div
style={{
width: '24px',
@@ -429,7 +404,6 @@ function LunarNewYearOverlay({ reduced }: { reduced: boolean }) {
margin: '1px auto',
}}
/>
{/* Lantern bottom cap */}
<div
style={{
width: '18px',
@@ -439,14 +413,16 @@ function LunarNewYearOverlay({ reduced }: { reduced: boolean }) {
margin: '0 auto',
}}
/>
{/* Tassel */}
<div
style={{
width: '2px',
height: '14px',
backgroundColor: '#ffd700',
margin: '0 auto',
animation: reduced ? 'none' : `${animTasselSway} ${duration * 0.6}s ease-in-out ${delay}s infinite`,
animation:
reduced
? 'none'
: `${animTasselSway} ${duration * 0.6}s ease-in-out ${delay}s infinite`,
transformOrigin: 'top center',
}}
/>
@@ -482,7 +458,7 @@ function ValentinesOverlay({ reduced }: { reduced: boolean }) {
const duration = 9 + (i % 6) * 1.8;
const delay = (i * 0.6) % 9;
const size = 14 + (i % 4) * 5;
const color = colors[i % colors.length];
const col = colors[i % colors.length];
return (
<div
key={i}
@@ -492,7 +468,7 @@ function ValentinesOverlay({ reduced }: { reduced: boolean }) {
bottom: '-20px',
left: `${left}%`,
fontSize: `${size}px`,
color,
color: col,
filter: 'drop-shadow(0 0 4px rgba(255,100,140,0.4))',
animation: `${animFloatUp} ${duration}s ease-in ${delay}s infinite`,
userSelect: 'none',
@@ -521,7 +497,6 @@ function StPatricksOverlay({ reduced }: { reduced: boolean }) {
].join(','),
}}
/>
{/* Moving metallic gold shimmer on the accent border at top */}
<div
aria-hidden="true"
style={{
@@ -538,7 +513,7 @@ function StPatricksOverlay({ reduced }: { reduced: boolean }) {
/>
{!reduced &&
clovers.map((_, i) => {
const left = ((i * 4129 + 223) % 100);
const left = (i * 4129 + 223) % 100;
const duration = 14 + (i % 6) * 2;
const delay = (i * 0.7) % 12;
const size = 14 + (i % 3) * 6;
@@ -581,7 +556,6 @@ function EarthDayOverlay({ reduced }: { reduced: boolean }) {
].join(','),
}}
/>
{/* Vine line along the left edge */}
<div
aria-hidden="true"
style={{
@@ -626,7 +600,6 @@ function DeepSpaceOverlay({ reduced }: { reduced: boolean }) {
const stars = Array.from({ length: 24 });
return (
<>
{/* Deep space ambient */}
<div
aria-hidden="true"
style={{
@@ -640,7 +613,6 @@ function DeepSpaceOverlay({ reduced }: { reduced: boolean }) {
].join(','),
}}
/>
{/* Warp streak particles emanating from center */}
{!reduced &&
stars.map((_, i) => {
const angle = (i / stars.length) * 360;
@@ -648,7 +620,11 @@ function DeepSpaceOverlay({ reduced }: { reduced: boolean }) {
const delay = (i * 0.18) % 2.5;
const period = 3 + (i % 4) * 0.5;
const size = 1 + (i % 3);
const colors = ['rgba(200,180,255,0.9)', 'rgba(150,200,255,0.8)', 'rgba(255,255,255,0.7)'];
const starColors = [
'rgba(200,180,255,0.9)',
'rgba(150,200,255,0.8)',
'rgba(255,255,255,0.7)',
];
return (
<div
key={i}
@@ -659,10 +635,10 @@ function DeepSpaceOverlay({ reduced }: { reduced: boolean }) {
top: '50%',
width: `${80 + i * 6}px`,
height: `${size}px`,
backgroundColor: colors[i % colors.length],
backgroundColor: starColors[i % starColors.length],
transformOrigin: '0 50%',
transform: `rotate(${angle}deg)`,
boxShadow: `0 0 ${size * 2}px ${colors[i % colors.length]}`,
boxShadow: `0 0 ${size * 2}px ${starColors[i % starColors.length]}`,
animation: `${animWarp} ${duration}s ease-out ${delay}s ${period}s infinite`,
opacity: 0,
}}
@@ -676,7 +652,6 @@ function DeepSpaceOverlay({ reduced }: { reduced: boolean }) {
function ArcadeOverlay({ reduced }: { reduced: boolean }) {
return (
<>
{/* CRT scanlines */}
<div
aria-hidden="true"
style={{
@@ -687,7 +662,6 @@ function ArcadeOverlay({ reduced }: { reduced: boolean }) {
animation: reduced ? 'none' : `${animScanline} 3s ease-in-out infinite`,
}}
/>
{/* Pixel corner decorations */}
{(['0,0', '0,auto', 'auto,0', 'auto,auto'] as const).map((corner, i) => {
const [t, b] = corner.split(',');
return (
@@ -712,7 +686,6 @@ function ArcadeOverlay({ reduced }: { reduced: boolean }) {
</div>
);
})}
{/* "INSERT COIN" prompt */}
<div
aria-hidden="true"
style={{
@@ -731,7 +704,6 @@ function ArcadeOverlay({ reduced }: { reduced: boolean }) {
>
INSERT COIN
</div>
{/* Vignette */}
<div
aria-hidden="true"
style={{
@@ -745,29 +717,40 @@ function ArcadeOverlay({ reduced }: { reduced: boolean }) {
);
}
// ─── Wrapper ──────────────────────────────────────────────────────────────────
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ──
function SeasonalOverlay({
theme,
reduced,
}: {
theme: SeasonTheme;
reduced: boolean;
}) {
const overlayMap: Record<SeasonTheme, React.ReactNode> = {
halloween: <HalloweenOverlay reduced={reduced} />,
christmas: <ChristmasOverlay reduced={reduced} />,
newyear: <NewYearOverlay reduced={reduced} />,
autumn: <AutumnOverlay reduced={reduced} />,
aprilfools: <AprilFoolsOverlay reduced={reduced} />,
lunar: <LunarNewYearOverlay reduced={reduced} />,
valentines: <ValentinesOverlay reduced={reduced} />,
stpatricks: <StPatricksOverlay reduced={reduced} />,
earthday: <EarthDayOverlay reduced={reduced} />,
deepspace: <DeepSpaceOverlay reduced={reduced} />,
arcade: <ArcadeOverlay reduced={reduced} />,
};
function buildOverlayContent(theme: SeasonTheme, reduced: boolean): React.ReactNode {
switch (theme) {
case 'halloween':
return <HalloweenOverlay reduced={reduced} />;
case 'christmas':
return <ChristmasOverlay reduced={reduced} />;
case 'newyear':
return <NewYearOverlay reduced={reduced} />;
case 'autumn':
return <AutumnOverlay reduced={reduced} />;
case 'aprilfools':
return <AprilFoolsOverlay reduced={reduced} />;
case 'lunar':
return <LunarNewYearOverlay reduced={reduced} />;
case 'valentines':
return <ValentinesOverlay reduced={reduced} />;
case 'stpatricks':
return <StPatricksOverlay reduced={reduced} />;
case 'earthday':
return <EarthDayOverlay reduced={reduced} />;
case 'deepspace':
return <DeepSpaceOverlay reduced={reduced} />;
case 'arcade':
return <ArcadeOverlay reduced={reduced} />;
default:
return null;
}
}
// ─── Full-screen overlay (fixed position, used in App) ────────────────────────
function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: boolean }) {
return (
<div
aria-hidden="true"
@@ -779,11 +762,30 @@ function SeasonalOverlay({
overflow: 'hidden',
}}
>
{overlayMap[theme]}
{buildOverlayContent(theme, reduced)}
</div>
);
}
// ─── Preview overlay (absolute position, contained in a card) ─────────────────
/**
* Renders the ambient (reduced-motion) version of a seasonal overlay inside
* a parent container. The parent must have `position: relative; overflow: hidden`.
*/
export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
return (
<div
aria-hidden="true"
style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}
>
{buildOverlayContent(theme, true)}
</div>
);
}
// ─── Main exported component ──────────────────────────────────────────────────
export function SeasonalEffect() {
const settings = useAtomValue(settingsAtom);
const reduced =
+101 -23
View File
@@ -42,8 +42,13 @@ import {
DateFormat,
MessageLayout,
MessageSpacing,
Settings,
settingsAtom,
} from '../../../state/settings';
import {
SeasonalPreview,
SeasonTheme,
} from '../../../components/seasonal/SeasonalEffect';
import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent';
@@ -488,32 +493,22 @@ function Appearance() {
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="200"
>
<SettingTile
title="Seasonal Theme"
description="Decorative overlays that activate automatically on holidays and events, or choose one manually."
after={
<SettingsSelect
value={seasonalThemeOverride ?? 'auto'}
onChange={(v) => setSeasonalThemeOverride(v as typeof seasonalThemeOverride)}
options={[
{ value: 'auto', label: '🗓 Auto (date-based)' },
{ value: 'off', label: 'Off' },
{ value: 'newyear', label: '🎆 New Year' },
{ value: 'lunar', label: '🏮 Lunar New Year' },
{ value: 'valentines', label: '💖 Valentine\'s Day' },
{ value: 'stpatricks', label: '🍀 St. Patrick\'s Day' },
{ value: 'aprilfools', label: '🃏 April Fool\'s Day' },
{ value: 'earthday', label: '🌱 Earth Day' },
{ value: 'autumn', label: '🍂 Autumn' },
{ value: 'halloween', label: '🎃 Halloween' },
{ value: 'christmas', label: '❄️ Christmas' },
{ value: 'arcade', label: '👾 Retro Arcade Day' },
{ value: 'deepspace', label: '🚀 Deep Space Week' },
]}
/>
}
description="Decorative overlays for holidays and events. Preview below — click to select."
/>
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
<SeasonalBgGrid
value={seasonalThemeOverride ?? 'auto'}
onChange={(v) => setSeasonalThemeOverride(v)}
/>
</Box>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
@@ -1351,6 +1346,89 @@ function Calls() {
);
}
const SEASONAL_OPTIONS: { value: Settings['seasonalThemeOverride']; label: string; emoji: string }[] =
[
{ value: 'auto', label: 'Auto', emoji: '🗓' },
{ value: 'off', label: 'Off', emoji: '×' },
{ value: 'newyear', label: 'New Year', emoji: '🎆' },
{ value: 'lunar', label: 'Lunar New Year', emoji: '🏮' },
{ value: 'valentines', label: "Valentine's", emoji: '💖' },
{ value: 'stpatricks', label: "St. Patrick's", emoji: '🍀' },
{ value: 'aprilfools', label: 'April Fools', emoji: '?' },
{ value: 'earthday', label: 'Earth Day', emoji: '🌱' },
{ value: 'autumn', label: 'Autumn', emoji: '🍂' },
{ value: 'halloween', label: 'Halloween', emoji: '🎃' },
{ value: 'christmas', label: 'Christmas', emoji: '❄️' },
{ value: 'arcade', label: 'Arcade Day', emoji: '👾' },
{ value: 'deepspace', label: 'Deep Space', emoji: '🚀' },
];
function SeasonalBgGrid({
value,
onChange,
}: {
value: Settings['seasonalThemeOverride'];
onChange: (v: Settings['seasonalThemeOverride']) => void;
}) {
return (
<Box wrap="Wrap" gap="200">
{SEASONAL_OPTIONS.map((opt) => {
const selected = value === opt.value;
const isSpecial = opt.value === 'auto' || opt.value === 'off';
return (
<Box key={opt.value} direction="Column" gap="100" style={{ alignItems: 'center' }}>
<button
type="button"
aria-label={opt.label}
aria-pressed={selected}
onClick={() => onChange(opt.value)}
style={{
position: 'relative',
display: 'block',
width: toRem(76),
height: toRem(56),
borderRadius: toRem(8),
cursor: 'pointer',
border: selected
? `2px solid ${color.Critical.Main}`
: '2px solid rgba(128,128,128,0.25)',
padding: 0,
overflow: 'hidden',
backgroundColor: '#030508',
}}
>
{!isSpecial && <SeasonalPreview theme={opt.value as SeasonTheme} />}
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage:
opt.value === 'auto'
? 'linear-gradient(135deg, rgba(255,100,0,0.2), rgba(255,200,0,0.2), rgba(0,200,100,0.2), rgba(0,100,255,0.2))'
: undefined,
pointerEvents: 'none',
}}
>
{isSpecial && (
<span style={{ fontSize: '22px', opacity: opt.value === 'off' ? 0.4 : 1 }}>
{opt.emoji}
</span>
)}
</div>
</button>
<Text size="T200" style={selected ? { color: color.Critical.Main } : undefined}>
{opt.label}
</Text>
</Box>
);
})}
</Box>
);
}
function ChatBgGrid() {
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');