Compare commits

...

2 Commits

Author SHA1 Message Date
jared f9edd2023d 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>
2026-06-15 01:14:56 -04:00
jared 30101c83e8 fix(ui): restore direct background application on Page component
The previous fix (7f329e3b) made Page transparent so the body background
would show through. But PageRoot sits between Page and body with an opaque
Background.Container color, so the body background was blocked — it only
showed through the glassmorphism sidebar (which is a sibling of PageRoot,
not a child).

Revert to applying getChatBg() directly to Page via inline style, which
overrides PageRoot's class-level background-color by CSS specificity rules.
SidebarNav continues to mirror the same background to document.body so the
glassmorphism sidebar can blur through it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 01:02:46 -04:00
6 changed files with 378 additions and 412 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 =
+12 -7
View File
@@ -17,6 +17,8 @@ import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollo
import { Page } from '../../components/page';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useTheme, ThemeKind } from '../../hooks/useTheme';
import { getChatBg } from '../lotus/chatBackground';
import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
@@ -59,7 +61,9 @@ export function RoomView({ eventId }: { eventId?: string }) {
const roomViewRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [glassmorphismSidebar] = useSetting(settingsAtom, 'glassmorphismSidebar');
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark;
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
@@ -93,13 +97,14 @@ export function RoomView({ eventId }: { eventId?: string }) {
),
);
// document.body always carries the active background (set by SidebarNav).
// Make Page transparent so the body background shows through the chat area.
// When no background is active, Page keeps its default theme surface color.
// Apply the background directly to Page so it overrides PageRoot's opaque
// Background.Container color. SidebarNav mirrors it onto document.body separately
// so the glassmorphism sidebar can blur through it.
const chatBgStyle = useMemo(() => {
const hasBg = chatBackground !== 'none' || glassmorphismSidebar || lotusTerminal;
return hasBg ? ({ background: 'transparent' } as React.CSSProperties) : {};
}, [chatBackground, glassmorphismSidebar, lotusTerminal]);
if (chatBackground !== 'none') return getChatBg(chatBackground, isDark, pauseAnimations);
if (lotusTerminal) return getChatBg('tactical', isDark, pauseAnimations);
return {};
}, [chatBackground, lotusTerminal, isDark, pauseAnimations]);
return (
<Page ref={roomViewRef} style={chatBgStyle}>
+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');