fix(ui): mention color picker, send-animation conflict, DM virtualizer (N69,N10,N22)

- N69: @mention highlight color now uses HexColorPickerPopOut + react-colorful
  HexColorPicker behind a folds Button (color swatch); built-in onRemove
  replaces the separate Reset, dropping the OS-native <input type="color">
- N10: mentionPulseKeyframes animates only box-shadow (dropped the imperceptible
  scale(1.003)) so it no longer fights MsgAppearClass over `transform` on
  self-sent @mention messages
- N22: Direct.tsx virtualizer estimateSize 38 -> 52 (two-line DM row height) to
  avoid the initial-render jump before measureElement corrects each row

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 20:54:32 -04:00
parent 5470e25bb0
commit dfd2c9c49e
4 changed files with 45 additions and 25 deletions
+3 -3
View File
@@ -314,7 +314,7 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
| N7 | Delivery Status | `Message.tsx` | 89148 | `DeliveryStatus` renders Unicode glyphs (`⟳ ✓ ✕`) in a `<span>` with `fontSize: '10px'` instead of folds `<Icon>` components — **FIXED**: replaced with `Icons.Check/Cross/Send` via `<Icon size="100">` | `Icons.Check`, `Icons.Cross`, etc. are used for all other status glyphs; folds `Text` size tokens for all supplementary text |
| N8 | GIF Picker | `GifPicker.tsx` | 83124 | GIF picker container uses fully bespoke inline styles (`borderRadius: '12px'`, `boxShadow: '0 8px 32px rgba(0,0,0,0.4)'`, raw `rgba` border) — two separate style sets for TDS and non-TDS paths | `EmojiBoard` has no caller-applied container styling; folds components handle their own surface internally via design tokens |
| N9 | GIF Button | `RoomInput.tsx` | 10761087 | GIF toolbar button renders `<Text size="T200">` with hand-rolled `fontWeight`/`fontSize`/`letterSpacing` instead of `<Icon>`**WON'T FIX (deliberate)**: folds has no GIF icon, and "GIF" is a widely-recognized text affordance (Slack/Discord/Element all use a text label). Converting to an arbitrary icon would be less clear, not more. | All 8 other toolbar buttons (`Smile`, `Sticker`, `Location`, `Poll`, etc.) use `<Icon src={...} />` exclusively |
| N10 | Send Animation | `Message.tsx` + `Animations.css.ts` | 979998 / 6071 | `MsgAppearClass` and `MentionHighlightPulse` both animate `transform: scale` on the same `MessageBase` DOM node — on self-sent mention messages both classes apply simultaneously and fight over the `transform` property | Pre-existing `highlightAnime` only animates `backgroundColor`; no prior `transform` animation on `MessageBase` |
| N10 | Send Animation | `Message.tsx` + `Animations.css.ts` | 979998 / 6071 | `MsgAppearClass` and `MentionHighlightPulse` both animate `transform: scale` on the same `MessageBase` DOM node — on self-sent mention messages both classes apply simultaneously and fight over the `transform` property **FIXED**: `mentionPulseKeyframes` now animates only `box-shadow` (dropped the imperceptible `scale(1.003)`), so the appear-scale and the mention glow no longer contend for `transform` | Pre-existing `highlightAnime` only animates `backgroundColor`; no prior `transform` animation on `MessageBase` |
| N11 | AvatarDecoration | `AvatarDecoration.tsx` | 5 / 3841 | Fixed 8px inset on all sides regardless of avatar size — at folds size `"200"` (~32px) the decoration bleeds 50% of the avatar diameter, clipping against `overflow: hidden` parent containers in member lists. **Inset issue still OPEN.** _Related regression fixed in `useAvatarDecoration.ts`_: the decoration fetch cached **all** failures (including transient 429/5xx) as "no decoration" permanently for the session, so a single rate-limited burst (member list / timeline mount many avatars at once) would make decorations vanish until a full reload. Now only a genuine 404 is cached; transient errors retry on the next mount. | Folds `Avatar` and `PresenceRingAvatar` do not emit overflow outside their bounding box |
| N12 | MediaGallery Drawer | `MediaGallery.tsx` | 651661 | Drawer uses `position: 'fixed'` with hardcoded `width: '320px'` as inline styles on a `<Box>`**FIXED**: moved positioning/width into co-located `MediaGallery.css.ts` using `toRem(320)` + a `max-width: 750px` full-screen media query (mirrors `MembersDrawer`); border/header now use `config.borderWidth`/`config.space` tokens. Added Escape-to-close on the panel (previously only the lightbox handled Escape). **Full chrome redesign (round 2)** to match native conventions: panel + header switched from `Surface` to `Background` variant (matching `MembersDrawer`/Saved Messages); header now `Text size="H4"` + plain close `IconButton` (dropped the bespoke tooltip-wrapped button); tabs moved to a bordered toolbar strip with the `variant={active?'Primary':'Secondary'} fill={active?'Solid':'Soft'}` pattern from `PolicyListViewer` and now show per-tab counts; the centered "lines + label" month divider replaced with a left-aligned group label (Cinny group-label pattern); thumbnail tiles moved hover/focus styling to CSS `:hover`/`:focus-visible` (no JS hover state) and into `MediaGallery.css.ts`; file rows + grid tokenized. **Docking fix (round 3)** — the core of the finding: the gallery was a `position: fixed` overlay floating over the timeline, mounted from `RoomViewHeader`. It is now a **docked flex sibling** in the room layout row, exactly like `MembersDrawer`: open state lifted to a `mediaGalleryAtom` (mirrors `bookmarksPanelAtom`), rendered in `Room.tsx` with a vertical `Line` separator on desktop and `key={room.roomId}` to reset per room; the CSS is static-width on desktop and only `position: fixed; inset: 0` full-screen on mobile (identical strategy to `MembersDrawer.css`). It now shares the row with the timeline instead of overlapping it. | `MembersDrawer` uses a vanilla-extract class with `width: toRem(266)` and is placed by the layout system, not `position: fixed`. 54px width discrepancy also breaks visual rhythm if both panels could be open |
| N13 | ScheduledMessagesTray | `ScheduledMessagesTray.tsx` | 108126 | Collapsible tray header is `<Box as="button">` with `cursor: 'pointer'` inline style and no folds variant — no hover state, no focus ring — **FIXED**: replaced with folds `<Button variant="Secondary" fill="None" radii="0">` using `before`/`after` icon props (gains design-system hover/focus) | All clickable header/toggle elements in the room view use folds `<Button>` or `<IconButton>` with explicit variants for hover/focus; `<Box as="button">` with no variant is used nowhere else |
@@ -326,7 +326,7 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
| N19 | Presence Labels | `useUserPresence.ts` vs `SettingsTab.tsx` | 5562 / 3642 | `PresenceBadge` tooltip shows "Active / Busy / Away"; `PresencePicker` options read "Online / Idle / Do Not Disturb / Invisible" — a DND user shows tooltip "Busy", not "Do Not Disturb" — **FIXED**: aligned `usePresenceLabel` reader vocabulary to the setter (online→"Online", unavailable→"Idle", offline→"Offline") | Within the same Lotus feature set the user-facing vocabulary is inconsistent between the setter UI and the reader tooltip |
| N20 | Notification Presets | `Notifications.tsx` | 57107 | Gaming/Work/Sleep preset buttons are bare `<button>` elements with Lotus-specific CSS vars (`--border-interactive-normal`, `--bg-surface-low`) not defined in all themes — **FIXED**: converted to folds `<Button variant="Secondary" fill="Soft" radii="300">` (auto height) wrapping the emoji/label/description column; undefined vars removed | Grouped preset/action buttons elsewhere use folds `Chip variant="Primary/Secondary" outlined radii="Pill"` (e.g., Composer Toolbar toggles in `General.tsx:11001113`) |
| N21 | Notification Sound Selects | `SystemNotification.tsx` | 111305 | Message sound, invite sound, and quiet-hours time pickers are bare `<select>`/`<input type="time">` with `colorScheme: 'dark'` workaround | All other dropdowns in settings use the `Button`+`PopOut`+`Menu`+`MenuItem` folds pattern; the native select renders OS-styled on all platforms |
| N22 | DM Preview Virtualizer | `RoomNavItem.tsx` / `Direct.tsx` | 608627 / 232 | DM preview adds a second text row to each DM item, making it taller than 38px, but `useVirtualizer` in `Direct.tsx` still uses `estimateSize: () => 38` — causes layout jump/overlap on initial render | Non-DM rooms in Home.tsx also estimate 38px; DM items with a preview are now a different height, creating two visual densities in the same nav column |
| N22 | DM Preview Virtualizer | `RoomNavItem.tsx` / `Direct.tsx` | 608627 / 232 | DM preview adds a second text row to each DM item, making it taller than 38px, but `useVirtualizer` in `Direct.tsx` still uses `estimateSize: () => 38` — causes layout jump/overlap on initial render **FIXED**: bumped `estimateSize` to 52 (the two-line DM-row height) so the initial estimate matches the common case; `measureElement` still corrects each row exactly | Non-DM rooms in Home.tsx also estimate 38px; DM items with a preview are now a different height, creating two visual densities in the same nav column |
| N23 | RoomServerACL | `RoomServerACL.tsx` | 100115 / 298309 | Server-name text input is a raw `<input type="text">` with inline style object; "Allow IP literal addresses" is a raw `<input type="checkbox">` with `style={{ width: 16, height: 16 }}`**FIXED**: text input → folds `<Input variant={error?'Critical':'Secondary'}>`; checkbox → folds `<Checkbox variant="Primary">` | All other text/boolean controls in room settings use folds `Input` and `Checkbox` components (`RoomAddress.tsx:163`, `RoomAddress.tsx:330`) |
| N24 | PolicyListViewer | `PolicyListViewer.tsx` | 245264 | Room-ID add input is a raw `<input type="text">` with manually replicated folds token values — **FIXED**: replaced with folds `<Input variant={error?'Critical':'Secondary'} size="400" radii="300">` | Native pattern: folds `<Input variant="Secondary" size="300" radii="300">` — no inline style needed |
| N25 | ExportRoomHistory Inputs | `ExportRoomHistory.tsx` | 258292 | Both date range pickers are raw `<input type="date">` with inline styles — **FIXED**: replaced with folds `<Input type="date" variant="Secondary" size="400" radii="300">` | Native pattern: folds `Input` component; `<input type="date">` renders OS-native date picker, unstyled relative to the rest of the settings panel |
@@ -418,7 +418,7 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
| N66 | DateRangeButton — Native `<input type="date">` | `SearchFilters.tsx` | 558589 | "From" and "To" date fields are raw `<input type="date">` with inline style overrides including `fontSize: '0.82rem'`**FIXED**: replaced both with folds `<Input type="date" variant="SurfaceVariant" size="300" radii="300">`; removed now-unused `color` import | `SelectRoomButton` (same file, line 224) and `SelectSenderButton` (line 424) both use folds `<Input size="300" radii="300">`; the date inputs are the only native browser inputs in the search filter row |
| N67 | SeasonalEffect / NightLight Z-Index Order | `SeasonalEffect.tsx` / `App.tsx` | 759 / 6277 | `SeasonalEffect` mounts at `zIndex: 9999`; `NightLightOverlay` at `zIndex: 9998`. Seasonal particles render **above** Night Light so they are never tinted. `SeasonalEffect` also shares `z-index: 9999` with the skip-to-content link in `ClientLayout.tsx`**FIXED**: lowered `SeasonalEffect` overlay to `zIndex: 9997` (below Night Light at 9998 and modals at 9999), so Night Light now tints the particles and dialogs are never obscured | Expected UX: Night Light tints all visible content including effects; requires either a higher Night Light z-index or a lower SeasonalEffect z-index |
| N68 | Syntax Highlighting — `--lt-accent-*` Vars in Non-TDS Themes | `syntaxHighlight.ts` | 313323 | `tokenStyle()` returns `var(--lt-accent-cyan/green/orange/purple, hardcoded-fallback)``--lt-*` vars only exist in TDS mode; fallbacks are Monokai dark colors that have poor contrast on light themes and no relationship to the existing `--prism-*` variables in `ReactPrism.css`**FIXED**: `tokenStyle()` now maps to the `--prism-*` family (keyword/selector/boolean/atrule/comment) which has proper light/dark/TDS palettes; comment uses `--prism-comment` instead of an opacity hack | `ReactPrism.css` uses `--prism-keyword`, `--prism-selector` etc. which switch correctly between light and dark palettes; syntax highlighting should use the same variable family |
| N69 | Mention Highlight — `<input type="color">` Instead of `HexColorPickerPopOut` | `General.tsx` | 644675 | Raw `<input type="color">` with hardcoded pixel dimensions; OS-native color picker chrome renders completely differently from the rest of settings UI | `PowersEditor.tsx:125143` establishes `<HexColorPickerPopOut picker={<HexColorPicker ...>}>` as the codebase's color-picking pattern; Reset button should be `<Button size="300" variant="Secondary" radii="300">` |
| N69 | Mention Highlight — `<input type="color">` Instead of `HexColorPickerPopOut` | `General.tsx` | 644675 | Raw `<input type="color">` with hardcoded pixel dimensions; OS-native color picker chrome renders completely differently from the rest of settings UI **FIXED**: replaced with `<HexColorPickerPopOut>` + `<HexColorPicker>` (react-colorful) behind a folds `<Button>` trigger showing a color swatch; the picker's built-in `onRemove` replaces the separate Reset button | `PowersEditor.tsx:125143` establishes `<HexColorPickerPopOut picker={<HexColorPicker ...>}>` as the codebase's color-picking pattern; Reset button should be `<Button size="300" variant="Secondary" radii="300">` |
| N70 | ChatBgGrid / SeasonalBgGrid — Raw `<button>` Elements | `General.tsx` | 16481689 / 17111742 | Both pickers use raw HTML `<button>` elements with hardcoded `width: toRem(76)`, `height: toRem(50/56)`, `borderRadius: toRem(8)`, `border: 2px solid rgba(...)` — no focus ring via folds, no `variant` prop, no hover state from the design system | Native Cinny theme pickers use folds `<MenuItem>` or `<Chip>` which respond to theme and provide focus/hover states automatically |
---
@@ -110,10 +110,15 @@ export type MessageBaseVariants = RecipeVariants<typeof MessageBase>;
// ── Mention pulse animation ───────────────────────────────────────────────────
// Animates only `box-shadow` — NOT `transform`. A self-sent @mention message
// carries both this class and `MsgAppearClass` (which animates a scale), and two
// animations on the same element cannot share the `transform` property: the
// later one wins and the other is silently dropped. Pulsing the glow alone keeps
// both effects working. (The previous scale(1.003) was imperceptible anyway.)
const mentionPulseKeyframes = keyframes({
'0%': { transform: 'scale(1)', boxShadow: 'none' },
'30%': { transform: 'scale(1.003)', boxShadow: `0 0 8px ${color.Warning.Main}` },
'100%': { transform: 'scale(1)', boxShadow: 'none' },
'0%': { boxShadow: 'none' },
'30%': { boxShadow: `0 0 8px ${color.Warning.Main}` },
'100%': { boxShadow: 'none' },
});
/**
+29 -18
View File
@@ -32,7 +32,9 @@ import {
toRem,
} from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { HexColorPicker } from 'react-colorful';
import FocusTrap from 'focus-trap-react';
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import {
@@ -640,33 +642,42 @@ function Appearance() {
title="@Mention Highlight Color"
description="Color used to highlight messages that mention you. Leave empty to use the theme default."
after={
<Box alignItems="Center" gap="200">
<input
type="color"
value={mentionHighlightColor || '#4caf50'}
onChange={(e) => setMentionHighlightColor(e.target.value)}
style={{
width: '36px',
height: '28px',
cursor: 'pointer',
borderRadius: '4px',
border: 'none',
padding: '2px',
}}
/>
{mentionHighlightColor && (
<HexColorPickerPopOut
picker={
<HexColorPicker
color={mentionHighlightColor || '#4caf50'}
onChange={setMentionHighlightColor}
/>
}
onRemove={mentionHighlightColor ? () => setMentionHighlightColor('') : undefined}
>
{(openPicker, opened) => (
<Button
type="button"
onClick={() => setMentionHighlightColor('')}
aria-pressed={opened}
onClick={openPicker}
size="300"
variant="Secondary"
fill="Soft"
radii="300"
before={
<span
style={{
width: toRem(16),
height: toRem(16),
borderRadius: config.radii.R300,
background: mentionHighlightColor || color.Primary.Main,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
display: 'inline-block',
flexShrink: 0,
}}
/>
}
>
<Text size="B300">Reset</Text>
<Text size="B300">{mentionHighlightColor ? 'Change' : 'Pick'}</Text>
</Button>
)}
</Box>
</HexColorPickerPopOut>
}
/>
</SequenceCard>
+5 -1
View File
@@ -227,7 +227,11 @@ export function Direct() {
const virtualizer = useVirtualizer({
count: filteredDirects.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
// DM rows render a two-line layout (name + message preview), so they are
// taller than the 38px single-line rooms elsewhere. Estimating the common
// two-line height avoids the initial-render jump/overlap before
// `measureElement` corrects each row to its exact size.
estimateSize: () => 52,
overscan: 10,
});