fix: settings dropdowns, background animations, ringing, avatar decorations
CI / Build & Quality Checks (push) Successful in 10m22s
Trigger Desktop Build / trigger (push) Successful in 7s

Settings dropdowns (Bug #3):
- Add reusable SettingsSelect component using Menu+PopOut+FocusTrap — exact
  same pattern as Message Layout, so all dropdowns look consistent
- Replace raw <select> for Seasonal Theme, UI Font, AFK Timeout, and
  Join & Leave Sounds with SettingsSelect

Animated chat backgrounds bleeding onto content (Bug #6 / #7):
- Remove filter:brightness() and opacity animations from chatBackground.ts
  (animRainGlowKeyframe, animGridBrightnessKeyframe, animFirefliesGlowKeyframe,
  animFirefliesBlinkKeyframe). These were applied to the Page element which
  caused ALL descendants (messages, composer) to flash in sync.
  Also created a CSS stacking context on Page that pushed SeasonalEffect
  (position:fixed; z-index:9997) behind the animated background layer.
- Only backgroundPosition / backgroundSize animations remain — safe, do not
  affect descendants, and do not create stacking contexts.
- Remove now-unused animation keyframe imports from chatBackground.ts.

Voice ringing in persistent rooms (Bug #5):
- Narrow the ringing condition from (Invite|Knock|Restricted) to only Invite,
  matching exactly the rooms where the call button is visible.
- Add room.isCallRoom() early-exit so m.join_rule:call rooms never ring.

Avatar decoration images not loading (Bug #8):
- Change loading="lazy" → loading="eager" in DecorationPreviewCell.
  Lazy loading does not reliably trigger for images inside nested overflow
  scroll containers (the settings panel scroll area), so images never loaded.

Docs: LOTUS_BUGS.md updated with root cause and resolution for all 5 new bugs.
Docs: LOTUS_TODO.md adds P5-35/P5-36 (deferred desktop notification/jump list).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 12:20:47 -04:00
parent 2a545b8b3e
commit e9a970a75b
6 changed files with 192 additions and 117 deletions
+39 -5
View File
@@ -84,14 +84,48 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
### 3. Inconsistent Settings Dropdown Styling ### 3. Inconsistent Settings Dropdown Styling
**File:** `src/app/features/settings/general/General.tsx` **File:** `src/app/features/settings/general/General.tsx`
**Status:** UI Consistency **Status:** ✅ RESOLVED (June 2026)
* **Issue:** The dropdowns for "Join & Leave Sounds" and "UI Font" use raw HTML `<select>` elements, which look different from the custom-styled `Menu` used elsewhere. * **Issue:** The dropdowns for "Join & Leave Sounds", "UI Font", and "Seasonal Theme" used raw HTML `<select>` elements, which render differently from the custom-styled `Menu`+`PopOut` used for "Message Layout" and other settings.
* **Recommended Fix:** Replace raw selects with the `Menu` + `PopOut` pattern used in the "Message Layout" setting. * **Fix Applied:** All three raw `<select>` elements replaced with a reusable `SettingsSelect` component using the Menu+PopOut+FocusTrap pattern consistent with the rest of the settings UI.
### 4. No Camera Focus During Screenshare ### 4. No Camera Focus During Screenshare
**File:** `src/app/features/call/CallControls.tsx` **File:** `src/app/features/call/CallControls.tsx`
**Status:** UX Bug **Status:** UX Bug — blocked by Element Call internals
* **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. * **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 "Pin/Focus" toggle on participant tiles that overrides the automatic screenshare spotlight. * **Root Cause:** Element Call's spotlight/layout is controlled internally by EC. Lotus Chat injects the call iframe and cannot easily override EC's participant tile behavior without forking the EC widget.
* **Recommended Fix:** Implement a "Pin/Focus" toggle on participant tiles that overrides the automatic screenshare spotlight — requires EC upstream changes or a custom message bridge.
### 5. Ringing Modal Fires in Persistent Voice Rooms
**File:** `src/app/components/CallEmbedProvider.tsx` (line 337342)
**Status:** ✅ RESOLVED (June 2026)
* **Issue:** Joining a persistent voice room (not a DM or transient group call) showed the incoming call ringing modal and animation.
* **Root Cause:** The `isPrivateGroup` condition included `JoinRule.Restricted` and `JoinRule.Knock` rooms. Lotus Guild voice rooms are `Restricted` join-rule rooms. Their `m.space.parent` state event was being checked but some rooms were set up with only the space-side `m.space.child` relationship, leaving no `m.space.parent` on the room itself — so they passed as `isPrivateGroup` and triggered ringing.
* **Fix Applied:** Narrowed `isPrivateGroup` to only `JoinRule.Invite` to match the exact set of rooms where the call button is shown. Also added `room.isCallRoom()` early-exit so rooms with `m.join_rule.call` type never ring.
### 6. Animated Chat Backgrounds Affect Message Content
**File:** `src/app/features/lotus/chatBackground.ts`
**Status:** ✅ RESOLVED (June 2026)
* **Issue:** Animated backgrounds that use `filter: brightness()` or `opacity` animations (Digital Rain glow, Grid Pulse brightness, Fireflies glow/blink) applied those effects to the entire `<Page>` element, causing all message content and the composer to flash/flicker in sync with the animation.
* **Root Cause:** `filter` and `opacity` CSS properties affect an element AND all its descendants. Applying these as part of the `animation` shorthand on the `Page` container made them "inherited" visually by everything inside the room view.
* **Side Effect:** `filter` animation also created a CSS stacking context on Page, which pushed Seasonal Theme overlays (position:fixed; z-index:9997) behind the Page compositor layer.
* **Fix Applied:** Removed `animRainGlowKeyframe`, `animGridBrightnessKeyframe`, `animFirefliesGlowKeyframe`, and `animFirefliesBlinkKeyframe` from `chatBackground.ts`. Only `backgroundPosition` / `backgroundSize` animations remain — these are safe and do not affect descendants or create stacking contexts.
### 7. Seasonal Themes Display Behind Chat Background
**File:** `src/app/components/seasonal/SeasonalEffect.tsx`
**Status:** ✅ RESOLVED (June 2026) — root cause was Bug #6
* **Issue:** Seasonal theme overlays (position:fixed; z-index:9997) appeared behind animated chat backgrounds.
* **Root Cause:** The `filter` animation on `<Page>` created a CSS stacking context, causing Page's GPU compositing layer to render above the fixed-position seasonal overlay in some browsers. Removing the filter animations (Bug #6 fix) resolves the stacking context issue.
* **Fix Applied:** See Bug #6. No additional changes to SeasonalEffect required.
### 8. Avatar Decoration Images Not Rendering in Settings
**File:** `src/app/features/settings/account/ProfileDecoration.tsx`
**Status:** ✅ RESOLVED (June 2026)
* **Issue:** Under Settings → Account → Avatar Decoration, no decoration images were visible.
* **Root Cause:** The `DecorationPreviewCell` used `loading="lazy"` on decoration images. The browser's lazy-loading algorithm determines image visibility from the viewport, but the decoration grid is inside a nested `overflowY: auto` scroll container inside a settings panel — the browser did not correctly detect these images as near the viewport and never triggered them to load.
* **Fix Applied:** Changed `loading="lazy"` to `loading="eager"` in `DecorationPreviewCell`. The settings panel is user-initiated, so eager loading is appropriate.
+18
View File
@@ -363,6 +363,24 @@ Themes:
--- ---
### [ ] 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.
**Status:** Deferred — `tauri-plugin-notification` has no Rust click/action callback API. Quick-reply would need a custom WinRT toast activator + COM registration, which can't be compile-tested without a Windows build environment.
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
**Complexity:** High (platform-specific native code required).
---
### [ ] P5-36 · Desktop — Windows Jump List (DEFERRED)
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
**Action when unblocked:** Revisit when a Tauri plugin abstracts the Windows Shell `ICustomDestinationList` interface, or when a Windows build environment is available for local iteration.
**Complexity:** High (Windows-only native COM).
---
## Blocked Features ## Blocked Features
These features are confirmed desirable but cannot be built until the listed dependency is resolved. These features are confirmed desirable but cannot be built until the listed dependency is resolved.
+6 -12
View File
@@ -325,21 +325,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
); );
if (!hasCallPermission) return; if (!hasCallPermission) return;
// Only ring for DMs or private non-space group chats. // Only ring in rooms where the call button is visible: DMs or invite-only rooms
// Space voice channels and public rooms fire room-level RTC notifications // with no space parent. Persistent voice rooms (call rooms), space channels,
// whenever anyone joins — ringing every member is incorrect behaviour. // restricted rooms, and public rooms must never trigger ringing.
if (room.isCallRoom()) return;
const isDirect = directs.has(room.roomId); const isDirect = directs.has(room.roomId);
// m.space.parent uses the parent space ID as the state key, so getStateEvent
// (which defaults to stateKey='') always returns undefined. Use getStateEvents
// (no key filter) to detect any space parent relationship.
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0; const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
const joinRule = room.getJoinRule(); const joinRule = room.getJoinRule();
const isPrivateGroup = const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
!isSpaceChild && if (!isDirect && !isPrivateInviteGroup) return;
(joinRule === JoinRule.Invite ||
joinRule === JoinRule.Knock ||
joinRule === JoinRule.Restricted);
if (!isDirect && !isPrivateGroup) return;
const info: IncomingCallInfo = { const info: IncomingCallInfo = {
room, room,
+4 -8
View File
@@ -2,14 +2,10 @@ import { CSSProperties } from 'react';
import { ChatBackground } from '../../state/settings'; import { ChatBackground } from '../../state/settings';
import { import {
animRainKeyframe, animRainKeyframe,
animRainGlowKeyframe,
animStarsDriftKeyframe, animStarsDriftKeyframe,
animGridPulseKeyframe, animGridPulseKeyframe,
animGridBrightnessKeyframe,
animAuroraKeyframe, animAuroraKeyframe,
animFirefliesKeyframe, animFirefliesKeyframe,
animFirefliesGlowKeyframe,
animFirefliesBlinkKeyframe,
} from '../../styles/Animations.css'; } from '../../styles/Animations.css';
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [ export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
@@ -210,7 +206,7 @@ const DARK: Record<ChatBackground, CSSProperties> = {
].join(','), ].join(','),
backgroundSize: '40px 200px, 12px 200px', backgroundSize: '40px 200px, 12px 200px',
backgroundPosition: '0 0, 0 0', backgroundPosition: '0 0, 0 0',
animation: `${animRainKeyframe} 8s linear infinite, ${animRainGlowKeyframe} 2.1s ease-in-out infinite`, animation: `${animRainKeyframe} 8s linear infinite`,
}, },
// Animated: drifting star field — three seamlessly-tiling layers at different speeds // Animated: drifting star field — three seamlessly-tiling layers at different speeds
@@ -236,7 +232,7 @@ const DARK: Record<ChatBackground, CSSProperties> = {
'linear-gradient(90deg, rgba(0,212,255,0.06) 1px, transparent 1px)', 'linear-gradient(90deg, rgba(0,212,255,0.06) 1px, transparent 1px)',
].join(','), ].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px', backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite, ${animGridBrightnessKeyframe} 3.3s ease-in-out infinite`, animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
}, },
// Animated: aurora borealis — four bands each travel an independent path // Animated: aurora borealis — four bands each travel an independent path
@@ -263,7 +259,7 @@ const DARK: Record<ChatBackground, CSSProperties> = {
].join(','), ].join(','),
backgroundSize: '200px 200px, 280px 280px, 160px 160px', backgroundSize: '200px 200px, 280px 280px, 160px 160px',
backgroundPosition: '0 0, 120px 80px, 60px 140px', backgroundPosition: '0 0, 120px 80px, 60px 140px',
animation: `${animFirefliesKeyframe} 30s linear infinite, ${animFirefliesGlowKeyframe} 2.3s ease-in-out infinite, ${animFirefliesBlinkKeyframe} 1.7s ease-in-out infinite`, animation: `${animFirefliesKeyframe} 30s linear infinite`,
}, },
}; };
@@ -481,7 +477,7 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
].join(','), ].join(','),
backgroundSize: '200px 200px, 280px 280px, 160px 160px', backgroundSize: '200px 200px, 280px 280px, 160px 160px',
backgroundPosition: '0 0, 120px 80px, 60px 140px', backgroundPosition: '0 0, 120px 80px, 60px 140px',
animation: `${animFirefliesKeyframe} 30s linear infinite, ${animFirefliesGlowKeyframe} 2.3s ease-in-out infinite, ${animFirefliesBlinkKeyframe} 1.7s ease-in-out infinite`, animation: `${animFirefliesKeyframe} 30s linear infinite`,
}, },
}; };
@@ -60,7 +60,7 @@ function DecorationPreviewCell({
<img <img
src={`${DECORATION_CDN}/${slug}.png`} src={`${DECORATION_CDN}/${slug}.png`}
alt={name} alt={name}
loading="lazy" loading="eager"
decoding="async" decoding="async"
style={{ style={{
position: 'absolute', position: 'absolute',
+124 -91
View File
@@ -154,6 +154,82 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
); );
} }
type SettingsSelectOption<T extends string> = { value: T; label: string };
function SettingsSelect<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: SettingsSelectOption<T>[];
onChange: (v: T) => void;
}) {
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (v: T) => {
onChange(v);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">{selectedLabel}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{options.map((opt) => (
<MenuItem
key={opt.value}
size="300"
variant={opt.value === value ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(opt.value)}
>
<Text size="T300">{opt.label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function SystemThemePreferences() { function SystemThemePreferences() {
const themeKind = useSystemThemeKind(); const themeKind = useSystemThemeKind();
const themeNames = useThemeNames(); const themeNames = useThemeNames();
@@ -417,42 +493,25 @@ function Appearance() {
title="Seasonal Theme" title="Seasonal Theme"
description="Decorative overlays that activate automatically on holidays and events, or choose one manually." description="Decorative overlays that activate automatically on holidays and events, or choose one manually."
after={ after={
<select <SettingsSelect
value={seasonalThemeOverride ?? 'auto'} value={seasonalThemeOverride ?? 'auto'}
onChange={(e) => onChange={(v) => setSeasonalThemeOverride(v as typeof seasonalThemeOverride)}
setSeasonalThemeOverride( options={[
e.target.value as typeof seasonalThemeOverride, { value: 'auto', label: '🗓 Auto (date-based)' },
) { value: 'off', label: 'Off' },
} { value: 'newyear', label: '🎆 New Year' },
style={{ { value: 'lunar', label: '🏮 Lunar New Year' },
background: 'var(--bg-surface-variant)', { value: 'valentines', label: '💖 Valentine\'s Day' },
color: 'inherit', { value: 'stpatricks', label: '🍀 St. Patrick\'s Day' },
border: '1px solid var(--border-interactive)', { value: 'aprilfools', label: '🃏 April Fool\'s Day' },
borderRadius: '6px', { value: 'earthday', label: '🌱 Earth Day' },
padding: '4px 8px', { value: 'autumn', label: '🍂 Autumn' },
fontSize: '14px', { value: 'halloween', label: '🎃 Halloween' },
fontFamily: 'inherit', { value: 'christmas', label: '❄️ Christmas' },
cursor: 'pointer', { value: 'arcade', label: '👾 Retro Arcade Day' },
}} { value: 'deepspace', label: '🚀 Deep Space Week' },
> ]}
<option value="auto">🗓 Auto (date-based)</option> />
<option value="off">Off</option>
<optgroup label="Holidays">
<option value="newyear">🎆 New Year (Dec 31Jan 2)</option>
<option value="lunar">🏮 Lunar New Year (Jan 22Feb 5)</option>
<option value="valentines">💖 Valentine&apos;s Day (Feb 1015)</option>
<option value="stpatricks">🍀 St. Patrick&apos;s Day (Mar 1518)</option>
<option value="aprilfools">🃏 April Fool&apos;s Day (Apr 1)</option>
<option value="earthday">🌱 Earth Day (Apr 22)</option>
<option value="autumn">🍂 Autumn (Sep 21Oct 31)</option>
<option value="halloween">🎃 Halloween (Oct 15Nov 1)</option>
<option value="christmas"> Christmas (Dec 10Jan 2)</option>
</optgroup>
<optgroup label="Events">
<option value="arcade">👾 Retro Arcade Day (Sep 12)</option>
<option value="deepspace">🚀 Deep Space Week (Oct 410)</option>
</optgroup>
</select>
} }
/> />
</SequenceCard> </SequenceCard>
@@ -556,26 +615,18 @@ function Appearance() {
title="UI Font" title="UI Font"
description="Font used throughout the interface." description="Font used throughout the interface."
after={ after={
<select <SettingsSelect
value={fontFamily ?? 'inter'} value={fontFamily ?? 'inter'}
onChange={(e) => onChange={(v) =>
setFontFamily(e.target.value as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code') setFontFamily(v as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
} }
style={{ options={[
background: 'var(--bg-surface)', { value: 'system', label: 'System Default' },
color: 'inherit', { value: 'inter', label: 'Inter (default)' },
border: '1px solid var(--border-interactive-normal)', { value: 'jetbrains-mono', label: 'JetBrains Mono' },
borderRadius: '6px', { value: 'fira-code', label: 'Fira Code' },
padding: '4px 8px', ]}
fontSize: '14px', />
cursor: 'pointer',
}}
>
<option value="system">System Default</option>
<option value="inter">Inter (default)</option>
<option value="jetbrains-mono">JetBrains Mono</option>
<option value="fira-code">Fira Code</option>
</select>
} }
/> />
</SequenceCard> </SequenceCard>
@@ -1263,25 +1314,17 @@ function Calls() {
title="Idle Timeout" title="Idle Timeout"
description="How long to wait before auto-muting." description="How long to wait before auto-muting."
after={ after={
<select <SettingsSelect
value={afkTimeoutMinutes} value={String(afkTimeoutMinutes ?? 10)}
onChange={(e) => setAfkTimeoutMinutes(Number(e.target.value))} onChange={(v) => setAfkTimeoutMinutes(Number(v))}
style={{ options={[
background: 'var(--bg-surface)', { value: '1', label: '1 minute' },
color: 'inherit', { value: '5', label: '5 minutes' },
border: '1px solid var(--border-interactive-normal)', { value: '10', label: '10 minutes' },
borderRadius: '6px', { value: '20', label: '20 minutes' },
padding: '4px 8px', { value: '30', label: '30 minutes' },
fontSize: 'inherit', ]}
cursor: 'pointer', />
}}
>
<option value={1}>1 minute</option>
<option value={5}>5 minutes</option>
<option value={10}>10 minutes</option>
<option value={20}>20 minutes</option>
<option value={30}>30 minutes</option>
</select>
} }
/> />
)} )}
@@ -1291,26 +1334,16 @@ function Calls() {
title="Join & Leave Sounds" title="Join & Leave Sounds"
description="Play a sound when someone joins or leaves a call you are in." description="Play a sound when someone joins or leaves a call you are in."
after={ after={
<select <SettingsSelect
value={callJoinLeaveSound} value={callJoinLeaveSound}
onChange={(e) => onChange={(v) => handleJoinLeaveSoundChange(v as 'off' | 'chime' | 'soft' | 'retro')}
handleJoinLeaveSoundChange(e.target.value as 'off' | 'chime' | 'soft' | 'retro') options={[
} { value: 'off', label: 'Off' },
style={{ { value: 'chime', label: 'Chime' },
background: 'var(--bg-surface)', { value: 'soft', label: 'Soft' },
color: 'inherit', { value: 'retro', label: 'Retro' },
border: '1px solid var(--border-interactive-normal)', ]}
borderRadius: '6px', />
padding: '4px 8px',
fontSize: 'inherit',
cursor: 'pointer',
}}
>
<option value="off">Off</option>
<option value="chime">Chime</option>
<option value="soft">Soft</option>
<option value="retro">Retro</option>
</select>
} }
/> />
</SequenceCard> </SequenceCard>