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
**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.
* **Recommended Fix:** Replace raw selects with the `Menu` + `PopOut` pattern used in the "Message Layout" setting.
* **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.
* **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
**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.
* **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
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;
// Only ring for DMs or private non-space group chats.
// Space voice channels and public rooms fire room-level RTC notifications
// whenever anyone joins — ringing every member is incorrect behaviour.
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
// with no space parent. Persistent voice rooms (call rooms), space channels,
// restricted rooms, and public rooms must never trigger ringing.
if (room.isCallRoom()) return;
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 joinRule = room.getJoinRule();
const isPrivateGroup =
!isSpaceChild &&
(joinRule === JoinRule.Invite ||
joinRule === JoinRule.Knock ||
joinRule === JoinRule.Restricted);
if (!isDirect && !isPrivateGroup) return;
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
if (!isDirect && !isPrivateInviteGroup) return;
const info: IncomingCallInfo = {
room,
+4 -8
View File
@@ -2,14 +2,10 @@ import { CSSProperties } from 'react';
import { ChatBackground } from '../../state/settings';
import {
animRainKeyframe,
animRainGlowKeyframe,
animStarsDriftKeyframe,
animGridPulseKeyframe,
animGridBrightnessKeyframe,
animAuroraKeyframe,
animFirefliesKeyframe,
animFirefliesGlowKeyframe,
animFirefliesBlinkKeyframe,
} from '../../styles/Animations.css';
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
@@ -210,7 +206,7 @@ const DARK: Record<ChatBackground, CSSProperties> = {
].join(','),
backgroundSize: '40px 200px, 12px 200px',
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
@@ -236,7 +232,7 @@ const DARK: Record<ChatBackground, CSSProperties> = {
'linear-gradient(90deg, rgba(0,212,255,0.06) 1px, transparent 1px)',
].join(','),
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
@@ -263,7 +259,7 @@ const DARK: Record<ChatBackground, CSSProperties> = {
].join(','),
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
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(','),
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
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
src={`${DECORATION_CDN}/${slug}.png`}
alt={name}
loading="lazy"
loading="eager"
decoding="async"
style={{
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() {
const themeKind = useSystemThemeKind();
const themeNames = useThemeNames();
@@ -417,42 +493,25 @@ function Appearance() {
title="Seasonal Theme"
description="Decorative overlays that activate automatically on holidays and events, or choose one manually."
after={
<select
<SettingsSelect
value={seasonalThemeOverride ?? 'auto'}
onChange={(e) =>
setSeasonalThemeOverride(
e.target.value as typeof seasonalThemeOverride,
)
}
style={{
background: 'var(--bg-surface-variant)',
color: 'inherit',
border: '1px solid var(--border-interactive)',
borderRadius: '6px',
padding: '4px 8px',
fontSize: '14px',
fontFamily: 'inherit',
cursor: 'pointer',
}}
>
<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>
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' },
]}
/>
}
/>
</SequenceCard>
@@ -556,26 +615,18 @@ function Appearance() {
title="UI Font"
description="Font used throughout the interface."
after={
<select
<SettingsSelect
value={fontFamily ?? 'inter'}
onChange={(e) =>
setFontFamily(e.target.value as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
onChange={(v) =>
setFontFamily(v as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
}
style={{
background: 'var(--bg-surface)',
color: 'inherit',
border: '1px solid var(--border-interactive-normal)',
borderRadius: '6px',
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>
options={[
{ value: 'system', label: 'System Default' },
{ value: 'inter', label: 'Inter (default)' },
{ value: 'jetbrains-mono', label: 'JetBrains Mono' },
{ value: 'fira-code', label: 'Fira Code' },
]}
/>
}
/>
</SequenceCard>
@@ -1263,25 +1314,17 @@ function Calls() {
title="Idle Timeout"
description="How long to wait before auto-muting."
after={
<select
value={afkTimeoutMinutes}
onChange={(e) => setAfkTimeoutMinutes(Number(e.target.value))}
style={{
background: 'var(--bg-surface)',
color: 'inherit',
border: '1px solid var(--border-interactive-normal)',
borderRadius: '6px',
padding: '4px 8px',
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>
<SettingsSelect
value={String(afkTimeoutMinutes ?? 10)}
onChange={(v) => setAfkTimeoutMinutes(Number(v))}
options={[
{ value: '1', label: '1 minute' },
{ value: '5', label: '5 minutes' },
{ value: '10', label: '10 minutes' },
{ value: '20', label: '20 minutes' },
{ value: '30', label: '30 minutes' },
]}
/>
}
/>
)}
@@ -1291,26 +1334,16 @@ function Calls() {
title="Join & Leave Sounds"
description="Play a sound when someone joins or leaves a call you are in."
after={
<select
<SettingsSelect
value={callJoinLeaveSound}
onChange={(e) =>
handleJoinLeaveSoundChange(e.target.value as 'off' | 'chime' | 'soft' | 'retro')
}
style={{
background: 'var(--bg-surface)',
color: 'inherit',
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>
onChange={(v) => handleJoinLeaveSoundChange(v as 'off' | 'chime' | 'soft' | 'retro')}
options={[
{ value: 'off', label: 'Off' },
{ value: 'chime', label: 'Chime' },
{ value: 'soft', label: 'Soft' },
{ value: 'retro', label: 'Retro' },
]}
/>
}
/>
</SequenceCard>