Compare commits

..

12 Commits

Author SHA1 Message Date
jared 79f8fabb1b fix(ui): GIF picker surface tokens + background swatch chrome (N8, N70)
CI / Build & Quality Checks (push) Successful in 10m46s
CI / Trigger Desktop Build (push) Successful in 6s
- N8: GifPicker non-TDS container used undefined var(--bg-surface) + raw
  rgba/12px/boxShadow. Switch to folds tokens (color.Surface.Container,
  config.radii.R400, color.Surface.ContainerLine, color.Other.Shadow).
  TDS branch keeps its --lt-* glow chrome.
- N70: ChatBgGrid/SeasonalBgGrid swatch buttons moved chrome (radius, border,
  hover, keyboard :focus-visible ring, selected via data-selected) into shared
  BgSwatch.css.ts using design tokens; only per-swatch size + live preview
  background stay inline (custom preview tiles, not MenuItem/Chip candidates).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:01:57 -04:00
jared dfd2c9c49e 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>
2026-06-19 20:54:32 -04:00
jared 5470e25bb0 fix(ui): report category dropdown uses folds menu, not native select (N56)
CI / Build & Quality Checks (push) Successful in 10m52s
CI / Trigger Desktop Build (push) Successful in 7s
Extract a shared ReportCategorySelect: folds Button trigger + PopOut +
FocusTrap + Menu + MenuItem (escape + arrow-key nav, like OrderButton),
replacing the OS-styled native <select> in both ReportRoomModal and
ReportUserModal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:41:46 -04:00
jared 374d6dc396 fix(ui): note join/leave sound preview affordance (N82)
Selecting a join/leave sound auto-plays a preview, but nothing communicated
that. Add it to the SettingTile description.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:37:55 -04:00
jared d0715774a8 fix(ui): ScheduleMessageModal + PollCreator use folds Dialog shell (N15, N29)
Both rendered as <Box as="form" role="dialog"> with manually assembled
background/borderRadius(R400)/boxShadow. Switch to <Dialog as="form"
variant="Surface"> so the surface comes from the design system (R300 radius),
matching the other message-action dialogs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:32:16 -04:00
jared 6f544e2b1f fix(ui): report modals use folds Dialog shell (N63)
ReportRoomModal/ReportUserModal rendered as <Box as="form" role="dialog">
with inline background/borderRadius(R400)/boxShadow. Switch both to
<Dialog as="form" variant="Surface"> so the surface (background, R300 radius,
shadow) comes from the design system, matching MessageReportItem and every
other message-action modal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:26:17 -04:00
jared e713d47319 fix(ui): forward-dialog header, notification presets, syntax-highlight tokens
CI / Build & Quality Checks (push) Successful in 10m39s
CI / Trigger Desktop Build (push) Successful in 8s
- N14 ForwardMessageDialog: add folds <Header> with title + close IconButton
  (was closeable only by clicking outside)
- N20 Notification presets: bare <button> with undefined --border-interactive-
  normal / --bg-surface-low vars -> folds <Button variant="Secondary" fill="Soft">
- N68 syntaxHighlight tokenStyle: use the theme-aware --prism-* variable family
  (keyword/selector/boolean/atrule/comment) instead of TDS-only --lt-accent-*
  vars with dark-only Monokai fallbacks; comment uses --prism-comment not opacity

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:22:25 -04:00
jared b361d43088 fix(ui): native inputs/checkboxes, QR fallback, focus + report modal cleanup
- N23 RoomServerACL: raw text input -> folds Input; raw checkbox -> folds Checkbox
- N24 PolicyListViewer: raw room-id input -> folds Input (Critical variant on error)
- N25 ExportRoomHistory: raw <input type="date"> x2 -> folds Input
- N26 RoomShareInvite: QR <img> gets loading="lazy" + onError fallback card
  ("QR code unavailable") instead of a broken-image icon
- N27 GifPicker: FocusTrap returnFocusOnDeactivate:false (matches EmojiBoard)
- N76 Report modals: drop redundant Cancel button (dismiss via header x /
  click-outside, like MessageReportItem)
- N5 ReadReceiptAvatars: hover/focus moved to co-located css :hover/:focus-visible
  (removed JS onMouseEnter/Leave .style mutation)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:12:25 -04:00
jared a33d28a7ae style: apply Prettier formatting to remaining files
Pure formatting reflows (multi-line wrapping of long lines/imports/tables);
no behavior change. Clears the working tree of pending prettier diffs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:41:57 -04:00
jared 4a4dede105 fix(media-gallery): dock as a flex sibling like MembersDrawer (was floating)
CI / Build & Quality Checks (push) Successful in 10m34s
CI / Trigger Desktop Build (push) Successful in 6s
The real reason the gallery didn't look or function like the Members drawer
or Saved Messages: it was a position:fixed overlay floating over the timeline,
mounted from RoomViewHeader. Now it docks into the room layout row exactly like
MembersDrawer.

- new mediaGalleryAtom (mirrors bookmarksPanelAtom) holds the open state
- RoomViewHeader toggles the atom instead of local useState and no longer
  renders the panel
- Room.tsx renders <MediaGallery> as a flex sibling of the timeline with a
  vertical Line separator on desktop and key={room.roomId} to reset per room
- MediaGallery.css: static width on desktop, position:fixed inset:0 full-screen
  only on mobile (identical strategy to MembersDrawer.css); root Box shrink="No"

The panel now shares the row with the timeline instead of overlapping it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:14:50 -04:00
jared b818d3fc5a refactor(media-gallery): redesign chrome to match native folds conventions
CI / Build & Quality Checks (push) Successful in 10m25s
CI / Trigger Desktop Build (push) Successful in 11s
The Media Gallery panel worked but didn't look like a first-party Cinny
drawer. Redesign the chrome to match MembersDrawer / Saved Messages and the
PolicyListViewer tab precedent:

- panel + header: Surface -> Background variant; header uses Text size="H4"
  and a plain close IconButton (dropped the bespoke tooltip-wrapped button)
- tabs: moved into a bordered toolbar strip; adopt the repo's
  variant={active?'Primary':'Secondary'} fill={active?'Solid':'Soft'} pattern
  and show per-tab counts (Images (N) / Videos (N) / Files (N))
- month grouping: replaced the centered "lines + label" divider with a
  left-aligned group label (the Cinny group-label pattern)
- thumbnail tiles: hover/focus border + caption overlay are now CSS-driven
  (:hover / :focus-visible) instead of React state, and live in
  MediaGallery.css.ts; grid + file rows tokenized
- caption overlay also reveals on keyboard focus (a11y)

All styling consolidated into MediaGallery.css.ts; no inline grid/tile styles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 13:07:51 -04:00
jared cf839e7345 fix(ui): avatar-decoration reliability, Saved Messages + Media Gallery redesign
CI / Build & Quality Checks (push) Successful in 10m30s
CI / Trigger Desktop Build (push) Successful in 9s
Avatar decorations: useAvatarDecoration cached ALL profile-field fetch
failures as "no decoration" permanently for the session. The member list
and timeline mount many avatars at once, so one rate-limited (429) burst
would wipe everyone's decoration until a full reload. Now only a genuine
404 (field unset) is cached; transient errors retry on the next mount.

Saved Messages panel — full redesign to match the canonical MembersDrawer:
- co-located BookmarksPanel.css.ts: toRem(266) + max-width:750px full-screen
  media query, replacing the old position:absolute/zIndex:100 mobile "modal"
  that had no backdrop or escape
- variant="Background" header; room avatars on each item (was a generic hash)
- priority tokens replace all raw opacity hacks; 3px borderLeft accent removed
- Escape-to-close; multi-line preview is now a proper folds Button (N38)

Media Gallery (N12): moved fixed positioning + width into MediaGallery.css.ts
using toRem(320) + a full-screen media query; border/header use config tokens;
added Escape-to-close on the panel (previously only the lightbox handled it).

Presence (SettingsTab / useUserPresence):
- N16: wrap presence-dot trigger in TooltipProvider; replace undefined
  --bg-surface with color.Background.Container
- N17: add escapeDeactivates + isKeyForward/isKeyBackward to the FocusTrap
- N19: align reader labels (usePresenceLabel) to the setter vocabulary
  (Online/Idle/Offline) so a chosen status matches the tooltip others see

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:21:29 -04:00
38 changed files with 944 additions and 746 deletions
+71 -72
View File
@@ -307,63 +307,63 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
### 🟠 Moderate — Interaction Pattern or Visual Deviations
| # | Area | File | Lines | Issue | Native Pattern |
| :-- | :------------------------- | :---------------------------------------- | :---------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N5 | Read Receipts | `ReadReceiptAvatars.tsx` | 62137 | Trigger button is raw `<button>` with `onMouseEnter`/`onMouseLeave` JS style mutation for hover state | All interactive elements use `useHover` from `react-aria` and folds variant system for hover; direct `.style` mutation used nowhere else on buttons |
| N6 | Read Receipts | `ReadReceiptAvatars.tsx` & `Message.tsx` | 3256 / 268283 | Two code paths open `EventReaders`: avatar-pill path uses `useModalStyle(360)` for mobile fullscreen; context-menu path (`MessageReadReceiptItem`) does not — on mobile the context menu path opens a fixed-size non-fullscreen modal for the same content | All modals that share a layout variant use `useModalStyle` consistently; `MessageReadReceiptItem` was not updated when `useModalStyle` was added |
| 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` |
| 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 | 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>` | `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 |
| N14 | ForwardMessageDialog | `ForwardMessageDialog.tsx` | 137154 | Dialog uses `<Modal>` but has no `<Header>` component and no close `<IconButton>` — only way to close is clicking outside | Every other modal using `<Modal>` or `<Box role="dialog">` includes a `<Header>` with a close `<IconButton>` in the top-right (EditHistoryModal, LeaveRoomPrompt, ScheduleMessageModal, RemindMeDialog, etc.) |
| N15 | ScheduleMessageModal | `ScheduleMessageModal.tsx` | 180193 | Modal root is `<Box as="form" role="dialog">` with manually assembled `borderRadius: config.radii.R400`/`boxShadow` | `ForwardMessageDialog` uses folds `<Modal size="400">` with `R500` radius; the R400 vs R500 mismatch is visible when both dialogs appear in the same session |
| N16 | Presence Picker | `SettingsTab.tsx` | 118144 | Presence trigger dot is raw `<button>` with `position: absolute; bottom: 2; right: 2` inline and no folds focus ring; no tooltip | Every other sidebar icon button uses folds `IconButton` with `SidebarItemTooltip` and `TooltipProvider` |
| N17 | Presence Picker | `SettingsTab.tsx` | 8086 | `PresencePicker` `FocusTrap` missing `escapeDeactivates: stopPropagation` and `isKeyForward`/`isKeyBackward` | Every other `PopOut`+`FocusTrap`+`Menu` combo supplies both (theme selector `General.tsx:143160`, `SettingsSelect`, sort menus) — without it Escape bubbles past the trap and arrow-key navigation is absent |
| N18 | Profile Selects | `Profile.tsx` | 547575 / 816848 | `ProfileStatus` auto-clear and `ProfileTimezone` selectors are native `<select>` elements with hardcoded `colorScheme: 'dark'` — will render in dark mode on light themes | General.tsx uses folds `SettingsSelect<T>` (`Button`+`PopOut`+`Menu`) for all dropdowns; `colorScheme: 'dark'` breaks light/custom theme appearance |
| 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" | 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 | 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 |
| 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 }}` | 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 | 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 | Native pattern: folds `Input` component; `<input type="date">` renders OS-native date picker, unstyled relative to the rest of the settings panel |
| N26 | RoomShareInvite QR | `RoomShareInvite.tsx` | 6673 | QR code `<img>` has no `onError` handler and no loading state — broken-image placeholder shown when the external API is unreachable | Cinny avatar components and MediaGallery use `onError` handlers; this is the only settings element making a request to a third-party server with no graceful degradation |
| # | Area | File | Lines | Issue | Native Pattern |
| :-- | :------------------------- | :---------------------------------------- | :---------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N5 | Read Receipts | `ReadReceiptAvatars.tsx` | 62137 | Trigger button is raw `<button>` with `onMouseEnter`/`onMouseLeave` JS style mutation for hover state **FIXED**: hover/focus emphasis moved to co-located `ReadReceiptAvatars.css.ts` (`:hover`/`:focus-visible`), no JS `.style` mutation | All interactive elements use `useHover` from `react-aria` and folds variant system for hover; direct `.style` mutation used nowhere else on buttons |
| N6 | Read Receipts | `ReadReceiptAvatars.tsx` & `Message.tsx` | 3256 / 268283 | Two code paths open `EventReaders`: avatar-pill path uses `useModalStyle(360)` for mobile fullscreen; context-menu path (`MessageReadReceiptItem`) does not — on mobile the context menu path opens a fixed-size non-fullscreen modal for the same content | All modals that share a layout variant use `useModalStyle` consistently; `MessageReadReceiptItem` was not updated when `useModalStyle` was added |
| 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 **FIXED**: non-TDS path now uses folds tokens (`color.Surface.Container`, `config.radii.R400`, `color.Surface.ContainerLine`, `color.Other.Shadow`), dropping the undefined `var(--bg-surface)`; the TDS branch keeps its `--lt-*` glow chrome (valid TDS styling) | `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 **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 |
| N14 | ForwardMessageDialog | `ForwardMessageDialog.tsx` | 137154 | Dialog uses `<Modal>` but has no `<Header>` component and no close `<IconButton>` — only way to close is clicking outside **FIXED**: added a folds `<Header variant="Surface" size="500">` with the title + close `<IconButton radii="300">`, matching every other modal | Every other modal using `<Modal>` or `<Box role="dialog">` includes a `<Header>` with a close `<IconButton>` in the top-right (EditHistoryModal, LeaveRoomPrompt, ScheduleMessageModal, RemindMeDialog, etc.) |
| N15 | ScheduleMessageModal | `ScheduleMessageModal.tsx` | 180193 | Modal root is `<Box as="form" role="dialog">` with manually assembled `borderRadius: config.radii.R400`/`boxShadow`**FIXED**: shell is now `<Dialog as="form" variant="Surface">`; removed inline surface styles | `ForwardMessageDialog` uses folds `<Modal size="400">` with `R500` radius; the R400 vs R500 mismatch is visible when both dialogs appear in the same session |
| N16 | Presence Picker | `SettingsTab.tsx` | 118144 | Presence trigger dot is raw `<button>` with `position: absolute; bottom: 2; right: 2` inline and no folds focus ring; no tooltip **FIXED**: wrapped the trigger in a folds `TooltipProvider` (shows "Status: …"); replaced the undefined `var(--bg-surface)` with `color.Background.Container`. Kept the absolute-positioned `<button>` (it overlays the avatar corner; a full `IconButton` would be too large for the dot). | Every other sidebar icon button uses folds `IconButton` with `SidebarItemTooltip` and `TooltipProvider` |
| N17 | Presence Picker | `SettingsTab.tsx` | 8086 | `PresencePicker` `FocusTrap` missing `escapeDeactivates: stopPropagation` and `isKeyForward`/`isKeyBackward`**FIXED**: added all three options, matching the theme selector / sort menus | Every other `PopOut`+`FocusTrap`+`Menu` combo supplies both (theme selector `General.tsx:143160`, `SettingsSelect`, sort menus) — without it Escape bubbles past the trap and arrow-key navigation is absent |
| N18 | Profile Selects | `Profile.tsx` | 547575 / 816848 | `ProfileStatus` auto-clear and `ProfileTimezone` selectors are native `<select>` elements with hardcoded `colorScheme: 'dark'` — will render in dark mode on light themes | General.tsx uses folds `SettingsSelect<T>` (`Button`+`PopOut`+`Menu`) for all dropdowns; `colorScheme: 'dark'` breaks light/custom theme appearance |
| 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 **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 |
| N26 | RoomShareInvite QR | `RoomShareInvite.tsx` | 6673 | QR code `<img>` has no `onError` handler and no loading state — broken-image placeholder shown when the external API is unreachable **FIXED**: added `loading="lazy"` + `onError` that swaps to a folds "QR code unavailable" placeholder card | Cinny avatar components and MediaGallery use `onError` handlers; this is the only settings element making a request to a third-party server with no graceful degradation |
---
### 🟡 Minor — Cosmetic / Token Discipline
| # | Area | File | Lines | Issue | Native Pattern |
| :------ | :--------------------------------- | :------------------------------------- | :---------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N27 | GIF Picker | `GifPicker.tsx` | 103110 | `FocusTrap` omits `returnFocusOnDeactivate: false` — focus returns to GIF button on dismiss instead of staying in the editor | `EmojiBoard` in `RoomInput.tsx:978` explicitly sets `returnFocusOnDeactivate={false}`; GIF picker dismiss behaviour is inconsistent with emoji picker |
| N28 | Character Counter | `RoomInput.tsx` | 11591174 | Composer character counter rendered with `color: 'var(--tc-surface-low)'` and raw pixel padding — a CSS variable not used anywhere else in the codebase — **FIXED**: removed undefined var and raw opacity; now `<Text priority="300">` with `config.space.S100` padding | Use `color.*` folds tokens or `priority="300"` on a `Text` component |
| N29 | PollCreator Modal | `PollCreator.tsx` | 103116 | Modal root is `<Box as="form" role="dialog" aria-modal="true">` with manually assembled surface styles instead of folds `<Dialog variant="Surface">` | `MessageDeleteItem` and `MessageReportItem` in `Message.tsx:506,635` use `<Dialog variant="Surface">` inside `OverlayCenter > FocusTrap` |
| N30 | Playback Speed Chip | `AudioContent.tsx` | 163189 | Speed chip uses `variant="SurfaceVariant" radii="Pill"` while adjacent Play/Pause chip uses `variant="Secondary" radii="300"` — mismatched shape and variant within the same `leftControl` row — **FIXED**: changed speed chip to `variant="Secondary" radii="300"` | Controls grouped in the same row should share variant and radii |
| N31 | Collapsible Message Toggle | `MsgTypeRenderers.tsx` | 97105 | "Read more ↓" / "Show less ↑" uses `<Button size="300" variant="Secondary" fill="None">` — visually a padded form button — **FIXED**: replaced with the native flush inline-button pattern (`background:none;border:none;padding:0`) + `<Text size="T200">` tinted `color.Primary.Main`, matching `(edited)` in FallbackContent | Inline text toggles in message content (e.g. `(edited)` in `FallbackContent.tsx:74`) use bare `<button>` with `background: none; border: none; padding: 0` to stay flush with text |
| N32 | ReadReceiptAvatars Pill | `ReadReceiptAvatars.tsx` | 95103 | Pill border is `'1px solid rgba(0,212,255,0.30)'` hardcoded raw rgba string; `borderRadius: '999px'` not a folds radii token; padding in raw pixels — **FIXED**: replaced with `config.borderWidth.B300`, `config.radii.Pill`, `config.space.S100/S200` | Use `color.*` folds tokens and `config.radii.Pill` / `config.space.S*` |
| ~~N33~~ | ~~ReadReceiptAvatars Class~~ | ~~`ReadReceiptAvatars.tsx`~~ | ~~67~~ | ~~`className="receipt-pill-btn"` references a class never defined~~**FIXED**: removed dead className | All custom CSS goes through co-located vanilla-extract `*.css.ts` files |
| N34 | EventReaders Header Size | `EventReaders.tsx` | 70 | `Header size="600"` (56px tall) while all peer message-action modals use `size="500"` (48px) — **FIXED**: changed to `size="500"` | `EditHistoryModal`, `LeaveRoomPrompt`, `MessageDeleteItem`, `MessageReportItem` all use `size="500"`; `size="600"` is reserved for full-page panel headers |
| N35 | EventReaders Close Button | `EventReaders.tsx` | 96 | Close `IconButton` missing explicit `radii="300"` prop — **FIXED**: added `radii="300"` | Every peer modal close button explicitly sets `radii="300"` (EditHistoryModal:184, LeaveRoomPrompt:75, MessageDeleteItem:517) |
| N36 | EventReaders Header Border | `EventReaders.tsx` | 7277 | Lotus-mode header sets `borderBottom: '1px solid var(--lt-border-color)'` as a CSS shorthand string — **FIXED**: changed to `borderBottomWidth: config.borderWidth.B300` | Native modals use `borderBottomWidth: config.borderWidth.B300` to avoid overriding the border-color set by the folds variant system |
| N37 | EventReaders Timestamp | `EventReaders.tsx` | 143151 | Lotus path sets `fontSize: '0.72rem'` inline — a raw relative unit between folds `T200` and `T100` scale steps — **FIXED**: removed raw `fontSize`, added `priority="300"` | Use folds `Text size="T200" priority="300"` for subdued secondary text |
| N38 | BookmarksPanel Header | `BookmarksPanel.tsx` | 155196 | Header uses `variant="Surface"` and close button uses `size="300" radii="300"`; also has a SurfaceVariant search bar strip with no equivalent in any native drawer | `MembersDrawer` header uses `variant="Background"` and default-size close button; the extra search+count strip creates a structurally different component family |
| N39 | Forward Menu Icon | `Message.tsx` | 1150 | Forward context menu item's `after` icon has no `size="100"` prop — **FIXED**: added `size="100"` to the `ArrowRight` icon | Every other after-icon in the same menu block explicitly uses `size="100"` (Reply, Reaction, Edit, Remind Me, Bookmark); missing size causes the Forward icon to render larger |
| N40 | ProfileDecoration Remove Button | `ProfileDecoration.tsx` | 185 | "Remove" link is a raw `<button>` with `background: 'none'; color: 'var(--tc-surface-low-contrast)'` — an undefined CSS variable — **FIXED**: replaced with `<Button variant="Critical" fill="None" size="300" radii="300">` | Use folds `<Button variant="Critical" fill="None">` or a `Text`-styled inline link |
| N41 | PresenceBadge / UserNotes Saving | `UserRoomProfile.tsx` | 240244 | "Saving…" indicator is `<Text opacity={0.5}>` without a spinner — **FIXED**: now shows a folds `<Spinner variant="Success" fill="Solid" size="100">` beside the "Saving…" text | Every other save operation in `Profile.tsx` shows a folds `<Spinner variant="Success" fill="Solid" size="300">` alongside the save button |
| N42 | Character Counter Convention | `UserRoomProfile.tsx` vs `Profile.tsx` | 243 / 479490 | `UserPrivateNotes` shows remaining count `"N left"`, appears only under 100; `ProfileStatus` shows `"current / 64"` always with color progression | Two Lotus features in the same settings flow use different counter conventions; neither matches a pre-existing Cinny pattern |
| N43 | Night Light Slider | `General.tsx` | 554565 | Night Light intensity slider is a raw `<input type="range">` with no `accentColor` token — renders in browser-default blue on all themes — **FIXED**: added `accentColor: color.Primary.Main`; the intensity label `opacity` hack also replaced with `priority="300"` | The Gate Threshold slider at `General.tsx:1456` at minimum sets `accentColor: 'var(--accent-orange)'`; the Night Light slider does neither |
| N44 | Mention Highlight & Boot Button | `General.tsx` | 597677 | `<input type="color">` for mention highlight uses raw pixel dimensions (`width: '36px'`, `height: '28px'`, `borderRadius: '4px'`); Reset and Boot buttons are bare `<button>` with Lotus CSS vars — **PARTIALLY FIXED**: the mention-highlight Reset button (renders on all themes) is now a folds `<Button variant="Secondary" fill="Soft">`, removing the undefined `--border-interactive-normal` var. The Boot button is **deliberately kept** as-is: it only renders when `lotusTerminal` is active, i.e. exactly when the `--accent-orange*` TDS vars are defined. The `<input type="color">` itself is tracked separately as N69. | Adjacent settings controls use folds `IconButton`/`Button`; there is no other `<input type="color">` in the Cinny settings UI |
| N45 | SettingsSelect vs SelectTheme | `General.tsx` | 126 vs 197 | `SettingsSelect` trigger uses `variant="Secondary"` while `SelectTheme` uses `variant="Primary" outlined fill="Soft"` for the same `Button`+`PopOut` dropdown pattern — adjacent rows in the same Appearance section have different visual weight — **FIXED**: `SelectTheme` trigger changed to `variant="Secondary"` to match `SettingsSelect` | Dropdown triggers should share the same variant within the same settings section |
| N46 | RoomInsights SectionHeader | `RoomInsights.tsx` | 2437 | `SectionHeader` adds `textTransform: 'uppercase'`, `letterSpacing: '0.06em'`, `opacity: 0.6` to `Text size="L400"`**FIXED**: simplified to `<Text size="L400" priority="300">` | Every other settings panel uses bare `<Text size="L400">Label</Text>` with no transforms (`General.tsx:5272`, `ExportRoomHistory.tsx:220,246`) |
| N47 | RoomInsights Chart Radii | `RoomInsights.tsx` | 350356 / 415436 | Bar chart uses `borderRadius: 3` and histogram bars use `borderRadius: '2px 2px 0 0'` as raw pixel integers — **FIXED**: replaced with `config.radii.R300` | All other rounded corners use `config.radii.*` tokens |
| N48 | RoomInsights Font Size | `RoomInsights.tsx` | 448 | Hour-axis labels set `style={{ fontSize: 9 }}` as a raw pixel integer — overrides the folds `Text size="T200"` applied on the same element — **FIXED**: removed raw `style={{ fontSize: 9 }}` | Use only folds `Text` size props; never override with raw `fontSize` |
| N49 | RoomInsights Emoji Icons | `RoomInsights.tsx` | 4165 / 292295 | `StatTile` uses literal Unicode emoji (`🖼️ 🎬 🎵 📎`) in `<Text size="H4">` as icons — **FIXED**: `StatTile` now takes an `icon: IconSrc` and renders `<Icon>` using `Icons.Photo/VideoCamera/Headphone/File` | All other iconographic elements use `<Icon src={Icons.*} />` from folds — emoji rendering varies between Windows/macOS/Linux and cannot be tinted by the theme |
| N50 | RoomInsights Warning Banner | `RoomInsights.tsx` | 168192 | Disclaimer banner uses raw `<Box style={{ border: color.Warning.Main, background: color.Warning.Container }}>`**FIXED**: replaced with `<SequenceCard variant="SurfaceVariant">` with `<Icon>` colored via `color.Warning.Main` | Settings panel informational cards use `<SequenceCard variant="SurfaceVariant">` throughout RoomServerACL, ExportRoomHistory, PolicyListViewer |
| N51 | ExportRoomHistory Progress | `ExportRoomHistory.tsx` | 311314 | Export progress shows as a plain `Text` string ("Exporting… N messages") — **WON'T FIX (deliberate)**: unlike `BackupRestore` (which has a known total to drive a determinate `ProgressBar`), export has no known total — it counts messages as they stream. The operation already shows a folds `Spinner` in the button plus a live count, which is the correct affordance for an indeterminate task. | `BackupRestore.tsx:72,90` uses a folds `<ProgressBar variant="Secondary" size="300">` for the same kind of long async operation |
| N52 | MessageQuickReactions Empty Return | `Message.tsx` | 160 | `if (recentEmojis.length === 0) return <span />;` — injects an invisible DOM node into the hover action bar flex container — **FIXED**: changed to `return null` | Universal convention for empty renders in Cinny is `return null`; 144+ instances across the codebase; the empty `<span>` can affect flex spacing |
| # | Area | File | Lines | Issue | Native Pattern |
| :------ | :--------------------------------- | :------------------------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N27 | GIF Picker | `GifPicker.tsx` | 103110 | `FocusTrap` omits `returnFocusOnDeactivate: false` — focus returns to GIF button on dismiss instead of staying in the editor **FIXED**: added `returnFocusOnDeactivate: false` (matches EmojiBoard) | `EmojiBoard` in `RoomInput.tsx:978` explicitly sets `returnFocusOnDeactivate={false}`; GIF picker dismiss behaviour is inconsistent with emoji picker |
| N28 | Character Counter | `RoomInput.tsx` | 11591174 | Composer character counter rendered with `color: 'var(--tc-surface-low)'` and raw pixel padding — a CSS variable not used anywhere else in the codebase — **FIXED**: removed undefined var and raw opacity; now `<Text priority="300">` with `config.space.S100` padding | Use `color.*` folds tokens or `priority="300"` on a `Text` component |
| N29 | PollCreator Modal | `PollCreator.tsx` | 103116 | Modal root is `<Box as="form" role="dialog" aria-modal="true">` with manually assembled surface styles instead of folds `<Dialog variant="Surface">` **FIXED**: shell is now `<Dialog as="form" variant="Surface">`; removed inline surface styles | `MessageDeleteItem` and `MessageReportItem` in `Message.tsx:506,635` use `<Dialog variant="Surface">` inside `OverlayCenter > FocusTrap` |
| N30 | Playback Speed Chip | `AudioContent.tsx` | 163189 | Speed chip uses `variant="SurfaceVariant" radii="Pill"` while adjacent Play/Pause chip uses `variant="Secondary" radii="300"` — mismatched shape and variant within the same `leftControl` row — **FIXED**: changed speed chip to `variant="Secondary" radii="300"` | Controls grouped in the same row should share variant and radii |
| N31 | Collapsible Message Toggle | `MsgTypeRenderers.tsx` | 97105 | "Read more ↓" / "Show less ↑" uses `<Button size="300" variant="Secondary" fill="None">` — visually a padded form button — **FIXED**: replaced with the native flush inline-button pattern (`background:none;border:none;padding:0`) + `<Text size="T200">` tinted `color.Primary.Main`, matching `(edited)` in FallbackContent | Inline text toggles in message content (e.g. `(edited)` in `FallbackContent.tsx:74`) use bare `<button>` with `background: none; border: none; padding: 0` to stay flush with text |
| N32 | ReadReceiptAvatars Pill | `ReadReceiptAvatars.tsx` | 95103 | Pill border is `'1px solid rgba(0,212,255,0.30)'` hardcoded raw rgba string; `borderRadius: '999px'` not a folds radii token; padding in raw pixels — **FIXED**: replaced with `config.borderWidth.B300`, `config.radii.Pill`, `config.space.S100/S200` | Use `color.*` folds tokens and `config.radii.Pill` / `config.space.S*` |
| ~~N33~~ | ~~ReadReceiptAvatars Class~~ | ~~`ReadReceiptAvatars.tsx`~~ | ~~67~~ | ~~`className="receipt-pill-btn"` references a class never defined~~**FIXED**: removed dead className | All custom CSS goes through co-located vanilla-extract `*.css.ts` files |
| N34 | EventReaders Header Size | `EventReaders.tsx` | 70 | `Header size="600"` (56px tall) while all peer message-action modals use `size="500"` (48px) — **FIXED**: changed to `size="500"` | `EditHistoryModal`, `LeaveRoomPrompt`, `MessageDeleteItem`, `MessageReportItem` all use `size="500"`; `size="600"` is reserved for full-page panel headers |
| N35 | EventReaders Close Button | `EventReaders.tsx` | 96 | Close `IconButton` missing explicit `radii="300"` prop — **FIXED**: added `radii="300"` | Every peer modal close button explicitly sets `radii="300"` (EditHistoryModal:184, LeaveRoomPrompt:75, MessageDeleteItem:517) |
| N36 | EventReaders Header Border | `EventReaders.tsx` | 7277 | Lotus-mode header sets `borderBottom: '1px solid var(--lt-border-color)'` as a CSS shorthand string — **FIXED**: changed to `borderBottomWidth: config.borderWidth.B300` | Native modals use `borderBottomWidth: config.borderWidth.B300` to avoid overriding the border-color set by the folds variant system |
| N37 | EventReaders Timestamp | `EventReaders.tsx` | 143151 | Lotus path sets `fontSize: '0.72rem'` inline — a raw relative unit between folds `T200` and `T100` scale steps — **FIXED**: removed raw `fontSize`, added `priority="300"` | Use folds `Text size="T200" priority="300"` for subdued secondary text |
| N38 | BookmarksPanel Header | `BookmarksPanel.tsx` | 155196 | Header uses `variant="Surface"` and close button uses `size="300" radii="300"`; also has a SurfaceVariant search bar strip with no equivalent in any native drawer **FIXED (full redesign)**: rebuilt the whole "Saved Messages" panel to match the canonical `MembersDrawer` — co-located `BookmarksPanel.css.ts` (`toRem(266)` + `max-width:750px` full-screen media query, replacing the old `position:absolute; zIndex:100` mobile "modal" that had no backdrop/escape), `variant="Background"` header, room **avatars** on each item (was a generic hash icon), `priority` tokens replacing all raw `opacity` hacks, the `borderLeft:3px` accent removed, and Escape-to-close added. | `MembersDrawer` header uses `variant="Background"` and default-size close button; the extra search+count strip creates a structurally different component family |
| N39 | Forward Menu Icon | `Message.tsx` | 1150 | Forward context menu item's `after` icon has no `size="100"` prop — **FIXED**: added `size="100"` to the `ArrowRight` icon | Every other after-icon in the same menu block explicitly uses `size="100"` (Reply, Reaction, Edit, Remind Me, Bookmark); missing size causes the Forward icon to render larger |
| N40 | ProfileDecoration Remove Button | `ProfileDecoration.tsx` | 185 | "Remove" link is a raw `<button>` with `background: 'none'; color: 'var(--tc-surface-low-contrast)'` — an undefined CSS variable — **FIXED**: replaced with `<Button variant="Critical" fill="None" size="300" radii="300">` | Use folds `<Button variant="Critical" fill="None">` or a `Text`-styled inline link |
| N41 | PresenceBadge / UserNotes Saving | `UserRoomProfile.tsx` | 240244 | "Saving…" indicator is `<Text opacity={0.5}>` without a spinner — **FIXED**: now shows a folds `<Spinner variant="Success" fill="Solid" size="100">` beside the "Saving…" text | Every other save operation in `Profile.tsx` shows a folds `<Spinner variant="Success" fill="Solid" size="300">` alongside the save button |
| N42 | Character Counter Convention | `UserRoomProfile.tsx` vs `Profile.tsx` | 243 / 479490 | `UserPrivateNotes` shows remaining count `"N left"`, appears only under 100; `ProfileStatus` shows `"current / 64"` always with color progression | Two Lotus features in the same settings flow use different counter conventions; neither matches a pre-existing Cinny pattern |
| N43 | Night Light Slider | `General.tsx` | 554565 | Night Light intensity slider is a raw `<input type="range">` with no `accentColor` token — renders in browser-default blue on all themes — **FIXED**: added `accentColor: color.Primary.Main`; the intensity label `opacity` hack also replaced with `priority="300"` | The Gate Threshold slider at `General.tsx:1456` at minimum sets `accentColor: 'var(--accent-orange)'`; the Night Light slider does neither |
| N44 | Mention Highlight & Boot Button | `General.tsx` | 597677 | `<input type="color">` for mention highlight uses raw pixel dimensions (`width: '36px'`, `height: '28px'`, `borderRadius: '4px'`); Reset and Boot buttons are bare `<button>` with Lotus CSS vars — **PARTIALLY FIXED**: the mention-highlight Reset button (renders on all themes) is now a folds `<Button variant="Secondary" fill="Soft">`, removing the undefined `--border-interactive-normal` var. The Boot button is **deliberately kept** as-is: it only renders when `lotusTerminal` is active, i.e. exactly when the `--accent-orange*` TDS vars are defined. The `<input type="color">` itself is tracked separately as N69. | Adjacent settings controls use folds `IconButton`/`Button`; there is no other `<input type="color">` in the Cinny settings UI |
| N45 | SettingsSelect vs SelectTheme | `General.tsx` | 126 vs 197 | `SettingsSelect` trigger uses `variant="Secondary"` while `SelectTheme` uses `variant="Primary" outlined fill="Soft"` for the same `Button`+`PopOut` dropdown pattern — adjacent rows in the same Appearance section have different visual weight — **FIXED**: `SelectTheme` trigger changed to `variant="Secondary"` to match `SettingsSelect` | Dropdown triggers should share the same variant within the same settings section |
| N46 | RoomInsights SectionHeader | `RoomInsights.tsx` | 2437 | `SectionHeader` adds `textTransform: 'uppercase'`, `letterSpacing: '0.06em'`, `opacity: 0.6` to `Text size="L400"`**FIXED**: simplified to `<Text size="L400" priority="300">` | Every other settings panel uses bare `<Text size="L400">Label</Text>` with no transforms (`General.tsx:5272`, `ExportRoomHistory.tsx:220,246`) |
| N47 | RoomInsights Chart Radii | `RoomInsights.tsx` | 350356 / 415436 | Bar chart uses `borderRadius: 3` and histogram bars use `borderRadius: '2px 2px 0 0'` as raw pixel integers — **FIXED**: replaced with `config.radii.R300` | All other rounded corners use `config.radii.*` tokens |
| N48 | RoomInsights Font Size | `RoomInsights.tsx` | 448 | Hour-axis labels set `style={{ fontSize: 9 }}` as a raw pixel integer — overrides the folds `Text size="T200"` applied on the same element — **FIXED**: removed raw `style={{ fontSize: 9 }}` | Use only folds `Text` size props; never override with raw `fontSize` |
| N49 | RoomInsights Emoji Icons | `RoomInsights.tsx` | 4165 / 292295 | `StatTile` uses literal Unicode emoji (`🖼️ 🎬 🎵 📎`) in `<Text size="H4">` as icons — **FIXED**: `StatTile` now takes an `icon: IconSrc` and renders `<Icon>` using `Icons.Photo/VideoCamera/Headphone/File` | All other iconographic elements use `<Icon src={Icons.*} />` from folds — emoji rendering varies between Windows/macOS/Linux and cannot be tinted by the theme |
| N50 | RoomInsights Warning Banner | `RoomInsights.tsx` | 168192 | Disclaimer banner uses raw `<Box style={{ border: color.Warning.Main, background: color.Warning.Container }}>`**FIXED**: replaced with `<SequenceCard variant="SurfaceVariant">` with `<Icon>` colored via `color.Warning.Main` | Settings panel informational cards use `<SequenceCard variant="SurfaceVariant">` throughout RoomServerACL, ExportRoomHistory, PolicyListViewer |
| N51 | ExportRoomHistory Progress | `ExportRoomHistory.tsx` | 311314 | Export progress shows as a plain `Text` string ("Exporting… N messages") — **WON'T FIX (deliberate)**: unlike `BackupRestore` (which has a known total to drive a determinate `ProgressBar`), export has no known total — it counts messages as they stream. The operation already shows a folds `Spinner` in the button plus a live count, which is the correct affordance for an indeterminate task. | `BackupRestore.tsx:72,90` uses a folds `<ProgressBar variant="Secondary" size="300">` for the same kind of long async operation |
| N52 | MessageQuickReactions Empty Return | `Message.tsx` | 160 | `if (recentEmojis.length === 0) return <span />;` — injects an invisible DOM node into the hover action bar flex container — **FIXED**: changed to `return null` | Universal convention for empty renders in Cinny is `return null`; 144+ instances across the codebase; the empty `<span>` can affect flex spacing |
---
@@ -397,30 +397,29 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
**N56 — Report Modal Category Dropdown: Native `<select>` Instead of folds `Chip`+`PopOut`+`Menu`**
- **File:** `src/app/features/room/ReportRoomModal.tsx` lines 138163; `src/app/features/room/ReportUserModal.tsx` lines 144169
- **Status:** **OPEN**
- **Status:** **FIXED** — extracted a shared `ReportCategorySelect` component (`src/app/features/room/ReportCategorySelect.tsx`) using the folds `Button` trigger + `PopOut` + `FocusTrap` + `Menu` + `MenuItem` pattern (with `escapeDeactivates`/arrow-key nav, matching `OrderButton`); both modals now use it instead of the native `<select>`.
- **Issue:** Both report modals render the "Category" field as `<Box as="select">` with hand-rolled inline styles (padding, border, background, color, fontSize, fontFamily). No other selector in the message-action modal context uses `<select>` — the established pattern for all dropdowns in both message modals and search filters is `Chip onClick → setMenuAnchor → PopOut → FocusTrap → Menu → MenuItem` (reference: `OrderButton` in `SearchFilters.tsx` lines 63114).
- **Fix:** Replace native `<select>` with `<Chip>` trigger + `<PopOut>` + `<Menu>` + `<MenuItem>` pattern.
---
#### 🟠 Additional Moderate Findings
| # | Area | File | Lines | Issue | Native Pattern |
| :-- | :--------------------------------------------------------------------------- | :-------------------------------------------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| N57 | PiP Fullscreen Button | `CallEmbedProvider.tsx` | 929951 | PiP fullscreen toggle is a raw `<button>` with `background: 'rgba(0,0,0,0.65)'`, `color: '#fff'`, `fontSize: '13px'`, Unicode ⛶/⊡ glyph — no focus ring, no tooltip — **FIXED (token discipline)**: `borderRadius`/`padding`/gap replaced with `config.radii.R300` + `config.space.*` tokens (also on the "Return to call" label). The dark scrim and `#fff` text are **deliberately kept** for legibility over arbitrary video; the glyph stays because folds has no fullscreen icon. `aria-label`/`title` tooltip already present. | `Controls.tsx` fullscreen button uses `<IconButton variant="Surface" fill="Soft" radii="400" size="400" outlined>` with `<TooltipProvider>`; hardcoded `#fff` fails on light themes |
| N58 | Screenshare Confirm Popup | `CallControls.tsx` | 303360 | "Share your screen?" popup is a raw `<Box>` with `--bg-surface`/`--bg-surface-border` vars (undefined outside TDS), `borderRadius: '0.75rem'`, `boxShadow: '0 8px 32px rgba(...)'`, no `FocusTrap` | Cinny's confirmation dialogs use folds `<Menu>` + `<FocusTrap>` + `<PopOut>`; the non-FocusTrap popup is not keyboard-accessible |
| N59 | ML Noise Suppression Panel | `General.tsx` | 13031487 | Sub-panel uses `var(--border-color)`, `var(--bg-card)`, `var(--bg-input)` (undefined in folds default theme), raw `<details>`/`<summary>` (UA-styled), `accentColor: 'var(--accent-orange)'` (TDS-only) | All other settings sub-sections use `<SettingTile>` rows inside `<SequenceCard>`; no other settings component uses `<details>` |
| N60 | Knock Badge on Members Button | `RoomViewHeader.tsx` | 744782 | Knock count badge wrapped in extra `<div style={{ position: 'relative' }}>` with hardcoded `fontSize: '9px'`, `minWidth: '14px'`, `height: '14px'`, `padding: '0 3px'` overriding folds `size="200"`**FIXED**: removed wrapper div, put `position: 'relative'` directly on the `IconButton`, `<Badge size="400">` with `toRem(3)` insets and `<Text size="L400">` — now matches the Pinned Messages badge pattern exactly | Pinned Messages badge (same header, lines 651677) uses `position: 'relative'` directly on `<IconButton>` + `toRem()` for inset; no extra wrapper div |
| N61 | Knock Member Rows | `MembersDrawer.tsx` | 441487 | Knock requester rows use raw `<Box>` with manually duplicated padding; no `<MenuItem>` wrapper → no hover/focus/active states — **WON'T FIX (deliberate)**: unlike a `MemberItem` (a clickable navigation row), a knock row contains two action buttons (Approve / Deny) and is **not itself clickable**. Wrapping it in `<MenuItem>` (a `<button>`) would nest interactive controls inside a button — invalid HTML/ARIA. The row has no interactive state to express. | Every joined/invited member uses `<MemberItem>` which wraps `<MenuItem variant="Background" radii="400">` with baked-in spacing and all interactive states |
| N62 | Unverified Device Banner | `RoomInput.tsx` | 860883 | Warning callout above composer uses inline `background: color.Warning.Container`, `borderLeft: '3px solid color.Warning.Main'` — a custom left-border accent pattern not present anywhere else in the folds system — **FIXED**: replaced the `borderLeft: '3px'` accent with a standard full `border` using `color.Warning.ContainerLine` + `config.borderWidth.B300`; removed the `opacity` hacks (folds `OnContainer` already meets contrast) | Warning indicators in the same codebase use `<Chip variant="Warning">` or `<Badge variant="Warning">`; the 3px left-border card pattern has no folds equivalent |
| N63 | Report Modals — Box Instead of Dialog | `ReportRoomModal.tsx` / `ReportUserModal.tsx` | 97110 / 103116 | Both modals render as `<Box as="form" role="dialog">` with inline `background`/`borderRadius`/`boxShadow`; use `config.radii.R400` (rounder) vs native `Dialog` which uses `R300` | Native `MessageReportItem` at `Message.tsx:634` and all other Cinny message-action modals use `<Dialog variant="Surface">` |
| N64 | EditHistoryModal — `<Modal>` vs `<Dialog>` | `EditHistoryModal.tsx` | 166 | Uses `<Modal variant="Surface" size="500">` while sibling message-action modals (`DeleteMessageItem:505`, `MessageReportItem:634`) all use `<Dialog variant="Surface">` — different widths and internal padding | `<Dialog variant="Surface">` is the established modal shell for all message-triggered dialogs |
| N65 | EditHistoryModal — No "Load More" | `EditHistoryModal.tsx` | 253259 | When `hasMore` is true the modal shows passive `<Text>"Showing the 50 most recent edits"</Text>` with no action; older edits are inaccessible — **FIXED**: implemented real pagination — edits accumulate across `next_batch` fetches (de-duped by event id, re-sorted by ts), with a folds `<Button>Load more</Button>` (spinner while loading) replacing the passive text | `RoomActivityLog.tsx:425` and `MessageSearch.tsx:129` both render a folds `<Button size="300" variant="Secondary">Load more</Button>` to fetch the next page |
| 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` | `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">` |
| 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 |
| # | Area | File | Lines | Issue | Native Pattern |
| :-- | :--------------------------------------------------------------------------- | :-------------------------------------------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| N57 | PiP Fullscreen Button | `CallEmbedProvider.tsx` | 929951 | PiP fullscreen toggle is a raw `<button>` with `background: 'rgba(0,0,0,0.65)'`, `color: '#fff'`, `fontSize: '13px'`, Unicode ⛶/⊡ glyph — no focus ring, no tooltip — **FIXED (token discipline)**: `borderRadius`/`padding`/gap replaced with `config.radii.R300` + `config.space.*` tokens (also on the "Return to call" label). The dark scrim and `#fff` text are **deliberately kept** for legibility over arbitrary video; the glyph stays because folds has no fullscreen icon. `aria-label`/`title` tooltip already present. | `Controls.tsx` fullscreen button uses `<IconButton variant="Surface" fill="Soft" radii="400" size="400" outlined>` with `<TooltipProvider>`; hardcoded `#fff` fails on light themes |
| N58 | Screenshare Confirm Popup | `CallControls.tsx` | 303360 | "Share your screen?" popup is a raw `<Box>` with `--bg-surface`/`--bg-surface-border` vars (undefined outside TDS), `borderRadius: '0.75rem'`, `boxShadow: '0 8px 32px rgba(...)'`, no `FocusTrap` | Cinny's confirmation dialogs use folds `<Menu>` + `<FocusTrap>` + `<PopOut>`; the non-FocusTrap popup is not keyboard-accessible |
| N59 | ML Noise Suppression Panel | `General.tsx` | 13031487 | Sub-panel uses `var(--border-color)`, `var(--bg-card)`, `var(--bg-input)` (undefined in folds default theme), raw `<details>`/`<summary>` (UA-styled), `accentColor: 'var(--accent-orange)'` (TDS-only) | All other settings sub-sections use `<SettingTile>` rows inside `<SequenceCard>`; no other settings component uses `<details>` |
| N60 | Knock Badge on Members Button | `RoomViewHeader.tsx` | 744782 | Knock count badge wrapped in extra `<div style={{ position: 'relative' }}>` with hardcoded `fontSize: '9px'`, `minWidth: '14px'`, `height: '14px'`, `padding: '0 3px'` overriding folds `size="200"`**FIXED**: removed wrapper div, put `position: 'relative'` directly on the `IconButton`, `<Badge size="400">` with `toRem(3)` insets and `<Text size="L400">` — now matches the Pinned Messages badge pattern exactly | Pinned Messages badge (same header, lines 651677) uses `position: 'relative'` directly on `<IconButton>` + `toRem()` for inset; no extra wrapper div |
| N61 | Knock Member Rows | `MembersDrawer.tsx` | 441487 | Knock requester rows use raw `<Box>` with manually duplicated padding; no `<MenuItem>` wrapper → no hover/focus/active states — **WON'T FIX (deliberate)**: unlike a `MemberItem` (a clickable navigation row), a knock row contains two action buttons (Approve / Deny) and is **not itself clickable**. Wrapping it in `<MenuItem>` (a `<button>`) would nest interactive controls inside a button — invalid HTML/ARIA. The row has no interactive state to express. | Every joined/invited member uses `<MemberItem>` which wraps `<MenuItem variant="Background" radii="400">` with baked-in spacing and all interactive states |
| N62 | Unverified Device Banner | `RoomInput.tsx` | 860883 | Warning callout above composer uses inline `background: color.Warning.Container`, `borderLeft: '3px solid color.Warning.Main'` — a custom left-border accent pattern not present anywhere else in the folds system — **FIXED**: replaced the `borderLeft: '3px'` accent with a standard full `border` using `color.Warning.ContainerLine` + `config.borderWidth.B300`; removed the `opacity` hacks (folds `OnContainer` already meets contrast) | Warning indicators in the same codebase use `<Chip variant="Warning">` or `<Badge variant="Warning">`; the 3px left-border card pattern has no folds equivalent |
| N63 | Report Modals — Box Instead of Dialog | `ReportRoomModal.tsx` / `ReportUserModal.tsx` | 97110 / 103116 | Both modals render as `<Box as="form" role="dialog">` with inline `background`/`borderRadius`/`boxShadow`; use `config.radii.R400` (rounder) vs native `Dialog` which uses `R300` **FIXED**: both shells are now `<Dialog as="form" variant="Surface">`; removed inline surface styles (Dialog provides background/radius/shadow) | Native `MessageReportItem` at `Message.tsx:634` and all other Cinny message-action modals use `<Dialog variant="Surface">` |
| N64 | EditHistoryModal — `<Modal>` vs `<Dialog>` | `EditHistoryModal.tsx` | 166 | Uses `<Modal variant="Surface" size="500">` while sibling message-action modals (`DeleteMessageItem:505`, `MessageReportItem:634`) all use `<Dialog variant="Surface">` — different widths and internal padding | `<Dialog variant="Surface">` is the established modal shell for all message-triggered dialogs |
| N65 | EditHistoryModal — No "Load More" | `EditHistoryModal.tsx` | 253259 | When `hasMore` is true the modal shows passive `<Text>"Showing the 50 most recent edits"</Text>` with no action; older edits are inaccessible — **FIXED**: implemented real pagination — edits accumulate across `next_batch` fetches (de-duped by event id, re-sorted by ts), with a folds `<Button>Load more</Button>` (spinner while loading) replacing the passive text | `RoomActivityLog.tsx:425` and `MessageSearch.tsx:129` both render a folds `<Button size="300" variant="Secondary">Load more</Button>` to fetch the next page |
| 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 **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 **FIXED**: chrome (radius, border, hover, **keyboard `:focus-visible` ring**, selected state via `data-selected`) moved to a shared `BgSwatch.css.ts` using `config`/`color` tokens; only the per-swatch size + live preview background remain inline (these are inherently custom preview tiles, not folds `MenuItem`/`Chip` candidates) | Native Cinny theme pickers use folds `<MenuItem>` or `<Chip>` which respond to theme and provide focus/hover states automatically |
---
@@ -433,11 +432,11 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
| N73 | Pending Requests Header | `MembersDrawer.tsx` | 415422 | "Pending Requests" section header is bare `<Text>` with inline padding instead of `className={css.MembersGroupLabel}`**FIXED**: now uses `className={css.MembersGroupLabel}` like every other section header | Power-level group labels at lines 506519 use `className={css.MembersGroupLabel}` for all other section headers in the same virtualizer list |
| N74 | Emoji Prefix Span | `RoomNavItem.tsx` | 730736 | Emoji prefix rendered as raw `<span style={{ fontSize: '1.15em', lineHeight: 1 }}>` inside a `<Text>` node — **FIXED**: removed the emoji-splitting span; the room name (including any leading emoji) now renders directly inside `<Text>` | All other nav item text uses folds `<Text size="Inherit">` or similar — no raw `<span>` with em-based font-size override exists elsewhere in the sidebar |
| N75 | Room Name Override / Star Indicators | `RoomNavItem.tsx` | 741757 | Pencil and star indicator icons are embedded inside the name `<Box as="span">`, giving them the same visual baseline as the room name text — **WON'T FIX (deliberate)**: an inline favorite-star / local-name marker adjacent to the name is a deliberate, common design (cf. Element/Slack pinned-name markers). Moving them to the far right would collide with the unread/notification indicators already there and risks layout regressions. Low value, real regression risk. | Native sidebar status indicators (unread count, notification mode icon) are placed to the far right of the item, never inside the name text span group |
| N76 | Report Modals — Extra Cancel Button | `ReportRoomModal.tsx` / `ReportUserModal.tsx` | 189191 / 195197 | Both custom report modals include a "Cancel" `<Button>` in the footer row | Native `MessageReportItem` (`Message.tsx:675691`) has no Cancel button — dismissal is via `×` header button or click-outside only |
| N76 | Report Modals — Extra Cancel Button | `ReportRoomModal.tsx` / `ReportUserModal.tsx` | 189191 / 195197 | Both custom report modals include a "Cancel" `<Button>` in the footer row **FIXED**: removed the Cancel button; dismissal is via the header `×` / click-outside, matching `MessageReportItem` | Native `MessageReportItem` (`Message.tsx:675691`) has no Cancel button — dismissal is via `×` header button or click-outside only |
| N77 | Search Filter Inline Lambdas | `SearchFilters.tsx` | 480, 625 | `SelectSenderButton` and `DateRangeButton` trigger chips use inline `onClick` arrow functions — **WON'T FIX (deliberate)**: purely a code-style nit with zero user-facing or behavioural impact. Inline arrow handlers are idiomatic React and used throughout this very file; extracting them yields no functional benefit. | `OrderButton` (line 58) and `SelectRoomButton` (line 195) both extract a named `const handleOpenMenu: MouseEventHandler<HTMLButtonElement>` handler — bypassing the type annotation in the inline form |
| N78 | HasLink Chip Active Color | `SearchFilters.tsx` | 755 | `HasLink` active state uses `variant="Primary"` (blue); all boolean scope-toggle chips in the same bar use `variant="Success"` (green) with `outlined`**FIXED**: changed to `variant={containsUrl ? 'Success' : 'SurfaceVariant'} outlined={!!containsUrl}` | `variant="Success" outlined` is the established active-state pattern for boolean toggles in the filter bar |
| N79 | Server Notice Chip Radii | `RoomViewHeader.tsx` | 570 | `<Chip size="400" radii="Pill">``Pill` radii on a room-type label — **FIXED**: changed to `radii="300"` | Room/space type labels in lobby (`RoomItem.tsx:83`, `SpaceItem.tsx:63`) use `radii="300"`; `radii="Pill"` is for filter/tag chips only |
| N80 | Server Support Contact Layout | `About.tsx` | 172239 | Homeserver support contacts rendered as raw `<Box direction="Column">` with `<Text as="a">` pairs — custom label/link layout | All other `<SequenceCard>` content in `About.tsx` and `General.tsx` uses `<SettingTile title="..." description="..." after={...}>` as the content unit |
| N80 | Server Support Contact Layout | `About.tsx` | 172239 | Homeserver support contacts rendered as raw `<Box direction="Column">` with `<Text as="a">` pairs — custom label/link layout **WON'T FIX (deliberate)**: a contact is `role → {matrix_id?, email?, …}` (one-to-many links per role), which doesn't map onto `SettingTile`'s single `title`/`description`/`after` slots without contortion. The current layout already uses folds `Box`/`Text`/`SequenceCard` + tokens and `Text as="a"` (a valid folds pattern); no undefined vars or raw HTML chrome. | All other `<SequenceCard>` content in `About.tsx` and `General.tsx` uses `<SettingTile title="..." description="..." after={...}>` as the content unit |
| N81 | Background Picker Grid — No Responsive Layout | `General.tsx` | 17071742 | Fixed `width: toRem(76)` flex-wrap cells with no `minWidth` floor or CSS grid `auto-fill` — SeasonalBgGrid's 13 items produce a visually lopsided orphan last row at any viewport width | Cinny's native grids use `grid-template-columns: repeat(auto-fill, minmax(N, 1fr))` or equivalent for responsive fill |
| N82 | Join/Leave Sounds Auto-Preview | `General.tsx` | 15921609 | Selecting a sound in the dropdown immediately plays a preview, but no UI affordance (button label, description text, or "▶ Preview" button) communicates this to the user | Settings tiles with side effects on selection (theme picker, chat background) show a live visual preview or a dedicated control explaining the side effect |
+28 -26
View File
@@ -62,32 +62,32 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
### Confirmed facts
| Finding | Impact |
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| Finding | Impact |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| **MSC flags ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` · `msc3401_matrix_rtc` | All safe to use now |
| **MSC flags OFF:** `msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
| **MSC3266** room summary: flag `msc3266_enabled: true` set but `GET /v1/rooms/{id}/summary` still returns 404 (M_UNRECOGNIZED) | Room Preview BLOCKED — endpoint not implemented in Synapse 1.155 |
| **MSC3892** relation redaction: not in flags | Reaction Redaction feature BLOCKED |
| **MSC4260** report user: `POST /_matrix/client/v3/users/{userId}/report` returns **200** ✅ | **Report User UNBLOCKED** — endpoint live since Synapse 1.133; ready to build |
| **MSC4151** report room: HTTP 405 on GET = endpoint exists (POST only) | Report Room live ✅ |
| `folds AvatarImage` does NOT accept children | Add frame/overlay inside `UserAvatar.tsx` itself — optional `frameName` prop |
| No in-app toast system exists (was) | Built `ToastProvider` + Jotai queue; at `App.tsx:65` |
| `useUnverifiedDeviceCount()` hook exists | `src/app/hooks/useDeviceVerificationStatus.ts:65-106` |
| Voice player: `AudioContent.tsx:44-223` | Playback rate on hidden `<audio>` at line 217 |
| `CallControl.setMicrophone(bool)` at `CallControl.ts:206-212` | For AFK auto-mute |
| `CallControl.toggleSound()` at `CallControl.ts:230-251` | Push-to-deafen — just wire a hotkey to this |
| matrix-js-sdk has NO arbitrary profile field methods | Use `mx.http.authedRequest()` for MSC4133 |
| Sanitizer (`sanitize.ts`) allows table, div, span, a, code, hr | LFG HTML card is safe locally; test on Element/FluffyChat |
| Sanitizer STRIPS `<math>`/MathML tags | Math/LaTeX task must also modify sanitizer |
| Service worker EXISTS at `src/sw.ts` | Quick-reply task: add `notificationclick` handler |
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` |
| `KeywordMessages.tsx` already has custom keyword push rules | Full push rule editor: only non-keyword rule types need new UI |
| `getMatrixToRoom()` in `matrix-to.ts` generates invite URLs | Invite link: just add QR code to room settings |
| Cindy CANNOT inject audio into EC call stream | In-call soundboard must be redesigned as local-only |
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
| MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
| **MSC flags OFF:** `msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
| **MSC3266** room summary: flag `msc3266_enabled: true` set but `GET /v1/rooms/{id}/summary` still returns 404 (M_UNRECOGNIZED) | Room Preview BLOCKED — endpoint not implemented in Synapse 1.155 |
| **MSC3892** relation redaction: not in flags | Reaction Redaction feature BLOCKED |
| **MSC4260** report user: `POST /_matrix/client/v3/users/{userId}/report` returns **200** | **Report User UNBLOCKED** — endpoint live since Synapse 1.133; ready to build |
| **MSC4151** report room: HTTP 405 on GET = endpoint exists (POST only) | Report Room live ✅ |
| `folds AvatarImage` does NOT accept children | Add frame/overlay inside `UserAvatar.tsx` itself — optional `frameName` prop |
| No in-app toast system exists (was) | Built `ToastProvider` + Jotai queue; at `App.tsx:65` |
| `useUnverifiedDeviceCount()` hook exists | `src/app/hooks/useDeviceVerificationStatus.ts:65-106` |
| Voice player: `AudioContent.tsx:44-223` | Playback rate on hidden `<audio>` at line 217 |
| `CallControl.setMicrophone(bool)` at `CallControl.ts:206-212` | For AFK auto-mute |
| `CallControl.toggleSound()` at `CallControl.ts:230-251` | Push-to-deafen — just wire a hotkey to this |
| matrix-js-sdk has NO arbitrary profile field methods | Use `mx.http.authedRequest()` for MSC4133 |
| Sanitizer (`sanitize.ts`) allows table, div, span, a, code, hr | LFG HTML card is safe locally; test on Element/FluffyChat |
| Sanitizer STRIPS `<math>`/MathML tags | Math/LaTeX task must also modify sanitizer |
| Service worker EXISTS at `src/sw.ts` | Quick-reply task: add `notificationclick` handler |
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` |
| `KeywordMessages.tsx` already has custom keyword push rules | Full push rule editor: only non-keyword rule types need new UI |
| `getMatrixToRoom()` in `matrix-to.ts` generates invite URLs | Invite link: just add QR code to room settings |
| Cindy CANNOT inject audio into EC call stream | In-call soundboard must be redesigned as local-only |
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
| MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
---
@@ -197,12 +197,14 @@ Features:
**What:** Improve search filter UX in `SearchFilters.tsx`.
**Completed 2026-06-18:**
- ✅ `SelectSenderButton` — picker UI for sender filter (previously required typing `from:@user` by hand)
- ✅ `DateRangeButton` — quick-pick presets: Today / Last week / Last month / Last year
- ✅ `Has link` chip — `contains_url: true` filter, wired to Matrix API and URL param
**UNTESTED** — needs verification at chat.lotusguild.org.
**UNTESTED** — needs verification at chat.lotusguild.org.
**Remaining for parity with Discord/Slack:**
- [ ] `has:image` / `has:file` / `has:video` — msgtype filters (require client-side post-filtering, no server API)
- [ ] Pinned messages filter
- [ ] Saved searches / search history
+6 -5
View File
@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
import FocusTrap from 'focus-trap-react';
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
import { IGif } from '@giphy/js-types';
import { Box } from 'folds';
import { Box, color, config } from 'folds';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
@@ -91,11 +91,11 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
width: `${PICKER_WIDTH}px`,
}
: {
background: 'var(--bg-surface)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '12px',
background: color.Surface.Container,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R400,
overflow: 'hidden',
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
boxShadow: color.Other.Shadow,
width: `${PICKER_WIDTH}px`,
};
@@ -103,6 +103,7 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: requestClose,
clickOutsideDeactivates: true,
allowOutsideClick: true,
+6 -1
View File
@@ -237,7 +237,12 @@ export function RenderMessageContent({
title={body}
src={src}
loading="lazy"
style={{ objectFit: 'cover', objectPosition: 'center top', width: '100%', height: '100%' }}
style={{
objectFit: 'cover',
objectPosition: 'center top',
width: '100%',
height: '100%',
}}
/>
)}
/>
+6 -1
View File
@@ -103,7 +103,12 @@ export const Reply = as<'div', ReplyProps>(
return (
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
{threadRootId && (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} aria-label="View thread" />
<ThreadIndicator
as="button"
data-event-id={threadRootId}
onClick={onClick}
aria-label="View thread"
/>
)}
<ReplyLayout
as="button"
@@ -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' },
});
/**
@@ -0,0 +1,24 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
// Hover/focus emphasis driven by CSS rather than JS style mutation, matching
// how every other interactive element in the app handles hover state.
export const ReceiptTrigger = style({
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
marginLeft: 'auto',
marginTop: config.space.S100,
display: 'flex',
alignItems: 'center',
gap: config.space.S100,
opacity: config.opacity.P500,
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
selectors: {
'&:hover, &:focus-visible': {
opacity: 1,
transform: 'scale(1.04)',
},
},
});
@@ -23,6 +23,7 @@ import { StackedAvatar } from '../stacked-avatar';
import { EventReaders } from '../event-readers';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
import * as css from './ReadReceiptAvatars.css';
const MAX_DISPLAY = 5;
@@ -74,27 +75,7 @@ export function ReadReceiptAvatars({
onClick={() => setOpen(true)}
title={tooltipNames}
aria-label={tooltipNames}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
marginLeft: 'auto',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '4px',
opacity: 0.85,
transition: 'opacity 0.15s, transform 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1';
e.currentTarget.style.transform = 'scale(1.04)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.85';
e.currentTarget.style.transform = 'scale(1)';
}}
className={css.ReceiptTrigger}
>
{/* Pill wrapper ensures visibility on any wallpaper/background */}
<span
@@ -0,0 +1,42 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
// Mirrors MembersDrawer: a 266px side panel on desktop that becomes a
// full-screen fixed panel on narrow viewports — the app's canonical drawer.
export const BookmarksPanel = style({
width: toRem(266),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
inset: 0,
width: '100%',
zIndex: 500,
},
},
});
export const BookmarksHeader = style({
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
});
export const BookmarksToolbar = style({
flexShrink: 0,
padding: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
});
export const BookmarksContent = style({
padding: config.space.S200,
});
export const BookmarkPreview = style({
width: '100%',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
textAlign: 'left',
});
+132 -169
View File
@@ -1,5 +1,6 @@
import React, { ChangeEvent, useState } from 'react';
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
import {
Avatar,
Box,
Button,
Header,
@@ -12,9 +13,17 @@ import {
color,
config,
} from 'folds';
import classNames from 'classnames';
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { RoomAvatar } from '../../components/room-avatar';
import { getRoomAvatarUrl } from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { stopPropagation } from '../../utils/keyboard';
import * as css from './BookmarksPanel.css';
function formatTimeAgo(ts: number): string {
const diff = Date.now() - ts;
@@ -37,187 +46,150 @@ type BookmarkItemProps = {
function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
const mx = useMatrixClient();
const room = mx.getRoom(bookmark.roomId);
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(bookmark.roomId) ?? undefined;
const displayRoomName = room?.name ?? bookmark.roomName;
const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined;
return (
<Box
direction="Column"
gap="200"
style={{
padding: `${config.space.S300} ${config.space.S300}`,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
transition: 'background 0.1s',
padding: config.space.S300,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
}}
>
{/* Room name row */}
<Box alignItems="Center" gap="100">
<Icon src={Icons.Hash} size="50" style={{ opacity: 0.5, flexShrink: 0 }} />
<Text
size="T200"
style={{
color: color.Primary.Main,
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
{/* Room identity + remove */}
<Box alignItems="Center" gap="200">
<Avatar size="200" radii="300">
<RoomAvatar
roomId={bookmark.roomId}
src={avatarUrl}
alt={displayRoomName}
renderFallback={() => <Text size="H6">{nameInitials(displayRoomName)}</Text>}
/>
</Avatar>
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
<Text size="T200" truncate style={{ fontWeight: config.fontWeight.W600 }}>
{displayRoomName}
</Text>
<Text size="T200" priority="300">
{formatTimeAgo(bookmark.savedAt)}
</Text>
</Box>
<IconButton
size="300"
variant="SurfaceVariant"
fill="None"
radii="300"
onClick={() => onRemove(bookmark.eventId)}
aria-label="Remove saved message"
>
{displayRoomName}
</Text>
<Icon size="100" src={Icons.Delete} />
</IconButton>
</Box>
{/* Message preview */}
<Box
style={{
background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300,
borderLeft: `3px solid ${color.Primary.Main}`,
padding: `${config.space.S100} ${config.space.S200}`,
}}
{/* Message preview — clicking jumps to the message */}
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
onClick={() => onJump(bookmark.roomId, bookmark.eventId)}
aria-label="Jump to saved message"
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
>
<Text
size="T300"
style={{
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
opacity: 0.9,
}}
>
<Text className={css.BookmarkPreview} size="T200" priority="400">
{bookmark.previewText || '(no preview)'}
</Text>
</Box>
{/* Footer row */}
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="T200" style={{ opacity: 0.5 }}>
{formatTimeAgo(bookmark.savedAt)}
</Text>
<Box gap="100" shrink="No">
<Button
size="300"
variant="Primary"
fill="Soft"
radii="300"
onClick={() => onJump(bookmark.roomId, bookmark.eventId)}
before={<Icon size="100" src={Icons.ArrowRight} />}
>
<Text size="T300">Jump</Text>
</Button>
<IconButton
size="300"
variant="Surface"
fill="None"
radii="300"
onClick={() => onRemove(bookmark.eventId)}
aria-label="Remove bookmark"
>
<Icon size="100" src={Icons.Delete} />
</IconButton>
</Box>
</Box>
</Button>
</Box>
);
}
type BookmarksPanelProps = {
onClose: () => void;
isMobile?: boolean;
};
export function BookmarksPanel({ onClose, isMobile }: BookmarksPanelProps) {
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
const { bookmarks, removeBookmark } = useBookmarks();
const { navigateRoom } = useRoomNavigate();
const [filter, setFilter] = useState('');
const handleJump = (roomId: string, eventId: string) => {
navigateRoom(roomId, eventId);
onClose();
};
// Escape closes the panel (parity with the app's other overlays/drawers).
useEffect(() => {
const handleKeyDown = (evt: KeyboardEvent) => {
if (evt.key === 'Escape') {
stopPropagation(evt);
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const handleJump = useCallback(
(roomId: string, eventId: string) => {
navigateRoom(roomId, eventId);
onClose();
},
[navigateRoom, onClose],
);
const handleFilterChange = (e: ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
};
const query = filter.trim().toLowerCase();
const filtered: Bookmark[] =
filter.trim().length === 0
query.length === 0
? bookmarks
: bookmarks.filter((bk) => {
const q = filter.toLowerCase();
return bk.previewText.toLowerCase().includes(q) || bk.roomName.toLowerCase().includes(q);
});
: bookmarks.filter(
(bk) =>
bk.previewText.toLowerCase().includes(query) ||
bk.roomName.toLowerCase().includes(query),
);
return (
<Box
className={classNames(css.BookmarksPanel, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
style={{
width: isMobile ? '100%' : '266px',
height: '100%',
position: isMobile ? 'absolute' : 'static',
top: isMobile ? 0 : 'auto',
left: isMobile ? 0 : 'auto',
zIndex: isMobile ? 100 : 'auto',
flexShrink: 0,
borderLeft: isMobile ? 'none' : `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header */}
<Header
style={{
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
}}
variant="Surface"
size="600"
>
<Header className={css.BookmarksHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Star} size="200" />
<Box grow="Yes">
<Text size="H5">Saved Messages</Text>
<Text size="H4" truncate>
Saved Messages
</Text>
</Box>
<IconButton
size="300"
variant="Surface"
radii="300"
aria-label="Close saved messages"
onClick={onClose}
>
<Icon src={Icons.Cross} size="100" />
<IconButton size="300" radii="300" aria-label="Close saved messages" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
{/* Search */}
<Box
style={{
flexShrink: 0,
padding: config.space.S200,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
background: color.SurfaceVariant.Container,
}}
>
<Box className={css.BookmarksToolbar} direction="Column" gap="100">
<Input
variant="Surface"
variant="SurfaceVariant"
size="400"
radii="400"
radii="300"
placeholder="Search saved messages…"
value={filter}
onChange={handleFilterChange}
before={<Icon size="200" src={Icons.Search} />}
before={<Icon size="100" src={Icons.Search} />}
after={
filter.length > 0 ? (
<IconButton
size="300"
variant="Surface"
variant="SurfaceVariant"
fill="None"
radii="300"
aria-label="Clear search"
onClick={() => setFilter('')}
@@ -227,56 +199,47 @@ export function BookmarksPanel({ onClose, isMobile }: BookmarksPanelProps) {
) : undefined
}
/>
</Box>
{/* Count badge */}
{bookmarks.length > 0 && (
<Box
style={{
flexShrink: 0,
padding: `${config.space.S100} ${config.space.S300}`,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
background: color.SurfaceVariant.Container,
}}
>
<Text size="T200" style={{ opacity: 0.6 }}>
{bookmarks.length > 0 && (
<Text size="T200" priority="300">
{filtered.length === bookmarks.length
? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}`
: `${filtered.length} of ${bookmarks.length} messages`}
</Text>
</Box>
)}
)}
</Box>
{/* List */}
<Scroll variant="Background" size="300" style={{ flexGrow: 1, minHeight: 0 }}>
{filtered.length === 0 ? (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
style={{ padding: config.space.S600, textAlign: 'center' }}
>
<Icon size="600" src={Icons.Star} style={{ opacity: 0.3 }} />
<Text size="T300" priority="300" align="Center">
{bookmarks.length === 0
? 'No saved messages yet.\nRight-click any message to bookmark it.'
: 'No bookmarks match your search.'}
</Text>
</Box>
) : (
<Box direction="Column">
{filtered.map((bk) => (
<BookmarkItem
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
))}
</Box>
)}
</Scroll>
<Box grow="Yes" style={{ minHeight: 0 }}>
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
{filtered.length === 0 ? (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
style={{ padding: config.space.S700, textAlign: 'center' }}
>
<Icon size="600" src={Icons.Star} style={{ opacity: config.opacity.Disabled }} />
<Text size="T300" priority="300" align="Center">
{bookmarks.length === 0
? 'No saved messages yet. Right-click any message to save it.'
: 'No saved messages match your search.'}
</Text>
</Box>
) : (
<Box className={css.BookmarksContent} direction="Column" gap="200">
{filtered.map((bk) => (
<BookmarkItem
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
))}
</Box>
)}
</Scroll>
</Box>
</Box>
);
}
+6 -1
View File
@@ -63,7 +63,12 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
</Box>
{memberVisible && (
<Box shrink="No">
<MemberGlance room={room} members={callMembers} speakers={speakers} callEmbed={callEmbed} />
<MemberGlance
room={room}
members={callMembers}
speakers={speakers}
callEmbed={callEmbed}
/>
</Box>
)}
</Box>
+1 -11
View File
@@ -1,14 +1,4 @@
import {
Box,
config,
Icon,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
} from 'folds';
import { Box, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import React, { useState } from 'react';
import FocusTrap from 'focus-trap-react';
@@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react';
import { Box, Button, config, Icon, Icons, Text } from 'folds';
import { Box, Button, color, config, Icon, Icons, Text } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
@@ -12,6 +12,7 @@ export function RoomShareInvite() {
const mx = useMatrixClient();
const room = useRoom();
const [copied, setCopied] = useState(false);
const [qrError, setQrError] = useState(false);
const domain = mx.getDomain() ?? undefined;
const inviteUrl = getMatrixToRoom(room.roomId, domain ? [domain] : undefined);
@@ -63,13 +64,35 @@ export function RoomShareInvite() {
</Box>
</Box>
<Box justifyContent="Center">
<img
src={qrSrc}
alt="QR code for room invite link"
width={160}
height={160}
style={{ display: 'block', borderRadius: config.radii.R300 }}
/>
{qrError ? (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="100"
style={{
width: 160,
height: 160,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
}}
>
<Icon size="400" src={Icons.Warning} />
<Text size="T200" priority="300" align="Center">
QR code unavailable
</Text>
</Box>
) : (
<img
src={qrSrc}
alt="QR code for room invite link"
width={160}
height={160}
loading="lazy"
onError={() => setQrError(true)}
style={{ display: 'block', borderRadius: config.radii.R300 }}
/>
)}
</Box>
</Box>
</CutoutCard>
@@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, config, color } from 'folds';
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text } from 'folds';
import { EventType } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -255,40 +255,24 @@ ${msgRows}
<Box gap="400" wrap="Wrap">
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
<Text size="T300">From</Text>
<input
<Input
type="date"
variant="Secondary"
size="400"
radii="300"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
style={{
background: color.Surface.Container,
color: color.Surface.OnContainer,
border: `1px solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R300,
padding: `${config.space.S200} ${config.space.S300}`,
fontSize: 'inherit',
fontFamily: 'inherit',
width: '100%',
boxSizing: 'border-box',
}}
/>
</Box>
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
<Text size="T300">To</Text>
<input
<Input
type="date"
variant="Secondary"
size="400"
radii="300"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
style={{
background: color.Surface.Container,
color: color.Surface.OnContainer,
border: `1px solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R300,
padding: `${config.space.S200} ${config.space.S300}`,
fontSize: 'inherit',
fontFamily: 'inherit',
width: '100%',
boxSizing: 'border-box',
}}
/>
</Box>
</Box>
@@ -1,5 +1,17 @@
import React, { useCallback, useRef, useState } from 'react';
import { Badge, Box, Button, Icon, IconButton, Icons, Scroll, Text, color, config } from 'folds';
import {
Badge,
Box,
Button,
Icon,
IconButton,
Icons,
Input,
Scroll,
Text,
color,
config,
} from 'folds';
import { EventTimeline, MatrixEvent, Room } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -242,7 +254,7 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
gap="300"
>
<Box gap="200" alignItems="Center">
<input
<Input
ref={inputRef}
value={roomIdInput}
onChange={(e) => setRoomIdInput(e.target.value)}
@@ -250,17 +262,10 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
if (e.key === 'Enter') handleLoad();
}}
placeholder="!roomId:server or #alias:server"
style={{
flexGrow: 1,
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: `1px solid ${error ? color.Critical.Main : color.Surface.ContainerLine}`,
background: color.Surface.Container,
color: color.Surface.OnContainer,
fontSize: 'inherit',
fontFamily: 'inherit',
outline: 'none',
}}
variant={error ? 'Critical' : 'Secondary'}
size="400"
radii="300"
style={{ flexGrow: 1 }}
/>
<Button
onClick={handleLoad}
@@ -1,5 +1,18 @@
import React, { useCallback, useRef, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, color, config } from 'folds';
import {
Box,
Button,
Checkbox,
Icon,
IconButton,
Icons,
Input,
Scroll,
Spinner,
Text,
color,
config,
} from 'folds';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
@@ -97,22 +110,14 @@ function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProp
{canEdit && (
<Box direction="Column" gap="100">
<input
<Input
ref={inputRef}
type="text"
placeholder="e.g. *.example.com or badserver.org"
onKeyDown={handleKeyDown}
style={{
background: color.Surface.Container,
color: color.Surface.OnContainer,
border: `1px solid ${error ? color.Critical.Main : color.Surface.ContainerLine}`,
borderRadius: config.radii.R300,
padding: `${config.space.S200} ${config.space.S300}`,
fontSize: 'inherit',
fontFamily: 'inherit',
width: '100%',
boxSizing: 'border-box',
}}
variant={error ? 'Critical' : 'Secondary'}
size="400"
radii="300"
/>
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
@@ -295,18 +300,13 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
gap="200"
>
<Box alignItems="Center" gap="300">
<input
type="checkbox"
<Checkbox
id="allow-ip-literals"
checked={allowIpLiterals}
disabled={!canEdit}
onChange={(e) => setAllowIpLiterals(e.target.checked)}
style={{
width: 16,
height: 16,
flexShrink: 0,
cursor: canEdit ? 'pointer' : 'default',
}}
onClick={() => setAllowIpLiterals(!allowIpLiterals)}
size="300"
variant="Primary"
/>
<Box direction="Column" gap="0">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+122
View File
@@ -0,0 +1,122 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// Right-side drawer DOCKED into the room layout row (a flex sibling of the
// timeline), exactly like MembersDrawer — not a floating overlay. 320px is a
// little wider than the 266px member/bookmark drawers because it hosts a media
// grid. On narrow viewports it becomes a full-screen fixed panel, matching the
// app's other drawers.
export const MediaGalleryDrawer = style({
width: toRem(320),
overflow: 'hidden',
'@media': {
'(max-width: 750px)': {
position: 'fixed',
inset: 0,
width: '100%',
zIndex: 500,
},
},
});
export const MediaGalleryHeader = style({
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
});
export const MediaGalleryTabs = style({
flexShrink: 0,
padding: config.space.S200,
gap: config.space.S100,
borderBottomWidth: config.borderWidth.B300,
borderBottomStyle: 'solid',
borderBottomColor: color.Background.ContainerLine,
});
export const MediaGalleryContent = style({
padding: config.space.S200,
});
export const MediaGalleryGroup = style({
marginBottom: config.space.S300,
});
export const MediaGalleryGroupLabel = style({
padding: `${config.space.S200} ${config.space.S100}`,
});
export const MediaGalleryGrid = style({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: config.space.S100,
});
export const GalleryTile = style({
position: 'relative',
aspectRatio: '1',
overflow: 'hidden',
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
borderWidth: config.borderWidth.B300,
borderStyle: 'solid',
borderColor: color.SurfaceVariant.ContainerLine,
cursor: 'pointer',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'border-color 100ms ease-in-out',
selectors: {
'&:hover, &:focus-visible': {
borderColor: color.Primary.Main,
},
},
});
export const GalleryTileImg = style({
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center top',
display: 'block',
});
// Dark scrim is intentional: it overlays arbitrary media, so a theme surface
// token would not guarantee legibility of the sender/date caption.
export const GalleryTileOverlay = style({
position: 'absolute',
inset: 0,
background: 'linear-gradient(to top, rgba(0,0,0,0.72) 0%, transparent 55%)',
opacity: 0,
transition: 'opacity 100ms ease-in-out',
pointerEvents: 'none',
selectors: {
[`${GalleryTile}:hover &, ${GalleryTile}:focus-visible &`]: {
opacity: 1,
},
},
});
export const GalleryTileCaption = style({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: `${config.space.S100} ${config.space.S200}`,
});
export const GalleryVideoBadge = style({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: toRem(28),
height: toRem(28),
borderRadius: '50%',
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
});
+74 -155
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
@@ -15,13 +15,15 @@ import {
config,
} from 'folds';
import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard';
import * as css from './MediaGallery.css';
type GalleryTab = 'image' | 'video' | 'file';
@@ -381,7 +383,6 @@ function GalleryTile({
mimeType,
nearViewport,
);
const [hovered, setHovered] = useState(false);
const relDate = formatRelativeDate(ts);
return (
@@ -390,22 +391,7 @@ function GalleryTile({
type="button"
aria-label={body || (isVideo ? 'Video' : 'Image')}
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
position: 'relative',
aspectRatio: '1',
overflow: 'hidden',
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
border: `1px solid ${hovered ? color.Primary.Main : color.SurfaceVariant.ContainerLine}`,
cursor: 'pointer',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'border-color 0.15s',
}}
className={css.GalleryTile}
>
{media.status === 'loading' && <Spinner size="200" />}
{media.status === 'error' && (
@@ -419,71 +405,30 @@ function GalleryTile({
<Text
size="T200"
truncate
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.5 }}
priority="300"
style={{ maxWidth: '100%', textAlign: 'center' }}
>
{body}
</Text>
</Box>
)}
{media.status === 'ok' && (
<img
src={media.url}
alt={body}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center top',
display: 'block',
}}
/>
)}
{media.status === 'ok' && <img src={media.url} alt={body} className={css.GalleryTileImg} />}
{/* Video play badge */}
{isVideo && media.status === 'ok' && (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 28,
height: 28,
borderRadius: '50%',
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<div className={css.GalleryVideoBadge}>
<Icon src={Icons.Play} size="200" />
</div>
)}
{/* Hover overlay */}
{hovered && media.status === 'ok' && (
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(to top, rgba(0,0,0,0.72) 0%, transparent 55%)',
pointerEvents: 'none',
}}
>
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: `${config.space.S100} ${config.space.S200}`,
}}
>
{/* Hover/focus caption overlay (CSS-driven) */}
{media.status === 'ok' && (
<div className={css.GalleryTileOverlay}>
<div className={css.GalleryTileCaption}>
<Text size="T200" truncate style={{ color: '#fff', display: 'block', lineHeight: 1.3 }}>
{sender}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.65)', opacity: 0.8 }}>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.65)' }}>
{relDate}
</Text>
</div>
@@ -495,30 +440,16 @@ function GalleryTile({
// ── Month separator ───────────────────────────────────────────────────────────
function MonthSeparator({ label }: { label: string }) {
return (
<Box
alignItems="Center"
gap="200"
style={{ padding: `${config.space.S100} 0`, gridColumn: '1 / -1' }}
>
<div style={{ flex: 1, height: 1, background: color.Surface.ContainerLine }} />
<Text size="T200" priority="300" style={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
{label}
</Text>
<div style={{ flex: 1, height: 1, background: color.Surface.ContainerLine }} />
</Box>
);
}
// ── Tab button ────────────────────────────────────────────────────────────────
function TabButton({
label,
count,
active,
onClick,
}: {
label: string;
count: number;
active: boolean;
onClick: () => void;
}) {
@@ -526,11 +457,14 @@ function TabButton({
<Button
size="300"
variant={active ? 'Primary' : 'Secondary'}
fill={active ? 'Soft' : 'None'}
fill={active ? 'Solid' : 'Soft'}
radii="300"
onClick={onClick}
>
<Text size="B300">{label}</Text>
<Text size="B300">
{label}
{count > 0 ? ` (${count})` : ''}
</Text>
</Button>
);
}
@@ -545,8 +479,6 @@ type MediaGalleryProps = {
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const [tab, setTab] = useState<GalleryTab>('image');
const [loading, setLoading] = useState(false);
@@ -561,6 +493,20 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
setLightboxIndex(null); // stale index would open wrong item in new tab's lightboxItems
}, []);
// Escape closes the drawer — but only when the lightbox isn't open, since the
// lightbox has its own Escape handler that should take precedence.
useEffect(() => {
if (lightboxIndex !== null) return undefined;
const handleKeyDown = (evt: KeyboardEvent) => {
if (evt.key === 'Escape') {
stopPropagation(evt);
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [lightboxIndex, onClose]);
const msgtype = TAB_MSGTYPES[tab];
const getFilteredEvents = useCallback(
@@ -643,6 +589,25 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
};
});
// Per-tab counts for the tab labels (single pass over loaded timeline)
const tabCounts = useMemo(() => {
const counts: Record<GalleryTab, number> = { image: 0, video: 0, file: 0 };
room
.getLiveTimeline()
.getEvents()
.forEach((ev) => {
if (ev.isRedacted() || ev.getType() !== EventType.RoomMessage) return;
const mt = ev.getContent().msgtype;
if (mt === MsgType.Image) counts.image += 1;
else if (mt === MsgType.Video) counts.video += 1;
else if (mt === MsgType.File) counts.file += 1;
});
return counts;
// `events` is intentional: it changes when more history is paginated in, so
// the counts stay in sync with the loaded window (it isn't read directly).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, events]);
// Group image/video events by month for the grid
type MonthGroup = { label: string; events: MatrixEvent[] };
const monthGroups: MonthGroup[] = [];
@@ -659,71 +624,32 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
return (
<>
<Box
className={ContainerColor({ variant: 'Surface' })}
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: isMobile ? '100%' : '320px',
zIndex: 500,
borderLeft: isMobile ? 'none' : `1px solid ${color.Surface.ContainerLine}`,
overflow: 'hidden',
}}
>
{/* Header */}
<Header
variant="Surface"
size="600"
style={{
flexShrink: 0,
paddingRight: config.space.S200,
paddingLeft: config.space.S300,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
}}
>
<Header variant="Background" size="600" className={css.MediaGalleryHeader}>
<Box grow="Yes" alignItems="Center" gap="200">
<Icon size="200" src={Icons.Photo} />
<Box grow="Yes">
<Text size="H5" truncate>
<Text size="H4" truncate>
Media Gallery
</Text>
</Box>
{events.length > 0 && (
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
{events.length}
</Text>
)}
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton ref={ref} variant="Background" aria-label="Close" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
<IconButton size="300" radii="300" aria-label="Close media gallery" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
{/* Tabs */}
<Box
shrink="No"
gap="100"
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
>
<Box className={css.MediaGalleryTabs} shrink="No">
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
<TabButton
key={t}
label={TAB_LABELS[t]}
count={tabCounts[t]}
active={tab === t}
onClick={() => handleTabChange(t)}
/>
@@ -733,7 +659,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
{/* Content */}
<Box grow="Yes" style={{ position: 'relative', overflow: 'hidden' }}>
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
<Box direction="Column" style={{ padding: config.space.S200, gap: 0 }}>
<Box className={css.MediaGalleryContent} direction="Column">
{/* ── Image / video grid ── */}
{(tab === 'image' || tab === 'video') && (
<>
@@ -757,20 +683,14 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
{(() => {
let flatIdx = 0;
return monthGroups.map((group) => (
<Box
key={group.label}
direction="Column"
style={{ marginBottom: config.space.S200 }}
>
{/* Month header — only shown when there are multiple groups */}
{monthGroups.length > 1 && <MonthSeparator label={group.label} />}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: config.space.S100,
}}
>
<Box key={group.label} direction="Column" className={css.MediaGalleryGroup}>
{/* Month label — only shown when there are multiple groups */}
{monthGroups.length > 1 && (
<Text className={css.MediaGalleryGroupLabel} size="L400" priority="300">
{group.label}
</Text>
)}
<div className={css.MediaGalleryGrid}>
{group.events.map((mEvent) => {
const c = mEvent.getContent();
const isEnc = !!c.file;
@@ -848,8 +768,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
background: color.SurfaceVariant.Container,
}}
>
<Icon size="300" src={Icons.File} />
@@ -867,7 +786,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
</Text>
</Box>
<IconButton
variant="Background"
variant="SurfaceVariant"
size="300"
radii="300"
aria-label={`Download ${body}`}
+5 -9
View File
@@ -3,6 +3,7 @@ import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Dialog,
Header,
Icon,
IconButton,
@@ -100,19 +101,14 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
escapeDeactivates: stopPropagation,
}}
>
<Box
<Dialog
as="form"
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="poll-creator-title"
onSubmit={handleSubmit}
direction="Column"
style={{
background: color.Surface.Container,
borderRadius: config.radii.R400,
boxShadow: color.Other.Shadow,
...modalStyle,
}}
style={modalStyle}
>
{/* Header */}
<Header
@@ -256,7 +252,7 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
</Button>
</Box>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
@@ -0,0 +1,85 @@
import React, { MouseEventHandler, useState } from 'react';
import { Box, Button, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config } from 'folds';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../../utils/keyboard';
type ReportCategorySelectProps = {
id?: string;
value: string;
labels: Record<string, string>;
onChange: (value: string) => void;
};
/**
* Category dropdown for the report modals folds `Button` + `PopOut` + `Menu`
* pattern (matching `OrderButton` in SearchFilters), replacing the OS-styled
* native `<select>` that looked foreign inside the modal.
*/
export function ReportCategorySelect({ id, value, labels, onChange }: ReportCategorySelectProps) {
const [anchor, setAnchor] = useState<RectCords>();
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
setAnchor(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (key: string) => {
onChange(key);
setAnchor(undefined);
};
return (
<PopOut
anchor={anchor}
position="Bottom"
align="Start"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
}}
>
<Menu style={{ padding: config.space.S100 }}>
<Box direction="Column" gap="100">
{Object.keys(labels).map((key) => (
<MenuItem
key={key}
size="300"
variant={key === value ? 'Primary' : 'Surface'}
radii="300"
aria-pressed={key === value}
onClick={() => handleSelect(key)}
>
<Text size="T300">{labels[key]}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
>
<Button
id={id}
type="button"
variant="Secondary"
fill="Soft"
size="400"
radii="300"
outlined
onClick={handleOpen}
aria-haspopup="listbox"
aria-expanded={!!anchor}
after={<Icon size="100" src={Icons.ChevronBottom} />}
style={{ width: '100%', justifyContent: 'space-between' }}
>
<Text size="T300">{labels[value] ?? value}</Text>
</Button>
</PopOut>
);
}
+10 -35
View File
@@ -5,6 +5,7 @@ import {
Text,
Input,
Button,
Dialog,
IconButton,
Icon,
Icons,
@@ -17,6 +18,7 @@ import {
Spinner,
} from 'folds';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ReportCategorySelect } from './ReportCategorySelect';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
@@ -94,19 +96,14 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
escapeDeactivates: stopPropagation,
}}
>
<Box
<Dialog
as="form"
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="report-room-dialog-title"
onSubmit={handleSubmit}
direction="Column"
style={{
background: color.Surface.Container,
borderRadius: config.radii.R400,
boxShadow: color.Other.Shadow,
...modalStyle,
}}
style={modalStyle}
>
<Header
style={{
@@ -135,31 +132,12 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
<Text as="label" htmlFor="report-category" size="L400">
Category
</Text>
<Box
as="select"
<ReportCategorySelect
id="report-category"
aria-label="Report category"
value={category}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setCategory(e.target.value as ReportCategory)
}
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
color: color.Surface.OnContainer,
fontSize: 'inherit',
fontFamily: 'inherit',
width: '100%',
}}
>
{(Object.keys(CATEGORY_LABELS) as ReportCategory[]).map((key) => (
<option key={key} value={key}>
{CATEGORY_LABELS[key]}
</option>
))}
</Box>
labels={CATEGORY_LABELS}
onChange={(v) => setCategory(v as ReportCategory)}
/>
</Box>
<Box direction="Column" gap="100">
@@ -186,9 +164,6 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
</Box>
<Box gap="200" justifyContent="End">
<Button type="button" variant="Secondary" fill="None" radii="300" onClick={onClose}>
<Text size="B400">Cancel</Text>
</Button>
<Button
type="submit"
variant="Critical"
@@ -209,7 +184,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
</Button>
</Box>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
+10 -35
View File
@@ -5,6 +5,7 @@ import {
Text,
Input,
Button,
Dialog,
IconButton,
Icon,
Icons,
@@ -18,6 +19,7 @@ import {
} from 'folds';
import { Method } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ReportCategorySelect } from './ReportCategorySelect';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
@@ -100,19 +102,14 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
escapeDeactivates: stopPropagation,
}}
>
<Box
<Dialog
as="form"
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="report-user-dialog-title"
onSubmit={handleSubmit}
direction="Column"
style={{
background: color.Surface.Container,
borderRadius: config.radii.R400,
boxShadow: color.Other.Shadow,
...modalStyle,
}}
style={modalStyle}
>
<Header
style={{
@@ -141,31 +138,12 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
<Text as="label" htmlFor="report-user-category" size="L400">
Category
</Text>
<Box
as="select"
<ReportCategorySelect
id="report-user-category"
aria-label="Report category"
value={category}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setCategory(e.target.value as ReportCategory)
}
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
color: color.Surface.OnContainer,
fontSize: 'inherit',
fontFamily: 'inherit',
width: '100%',
}}
>
{(Object.keys(CATEGORY_LABELS) as ReportCategory[]).map((key) => (
<option key={key} value={key}>
{CATEGORY_LABELS[key]}
</option>
))}
</Box>
labels={CATEGORY_LABELS}
onChange={(v) => setCategory(v as ReportCategory)}
/>
</Box>
<Box direction="Column" gap="100">
@@ -192,9 +170,6 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
</Box>
<Box gap="200" justifyContent="End">
<Button type="button" variant="Secondary" fill="None" radii="300" onClick={onClose}>
<Text size="B400">Cancel</Text>
</Button>
<Button
type="submit"
variant="Critical"
@@ -215,7 +190,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
</Button>
</Box>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
+13 -1
View File
@@ -2,9 +2,11 @@ import React, { useCallback } from 'react';
import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
import { useAtomValue } from 'jotai';
import { useAtomValue, useSetAtom } from 'jotai';
import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer';
import { MediaGallery } from './MediaGallery';
import { mediaGalleryAtom } from '../../state/mediaGallery';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -31,6 +33,8 @@ export function Room() {
const callEmbed = useCallEmbed();
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const galleryOpen = useAtomValue(mediaGalleryAtom);
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room);
@@ -78,6 +82,14 @@ export function Room() {
<CallChatView />
</>
)}
{!callView && galleryOpen && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
</>
)}
{!callView && isDrawer && (
<>
{screenSize === ScreenSize.Desktop && (
+3 -4
View File
@@ -73,7 +73,7 @@ import { RoomSettingsPage } from '../../state/roomSettings';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { MediaGallery } from './MediaGallery';
import { mediaGalleryAtom } from '../../state/mediaGallery';
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
@@ -488,7 +488,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
: undefined;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [galleryOpen, setGalleryOpen] = useState(false);
const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom);
const pendingKnocks = usePendingKnocks(room);
const handleSearchClick = () => {
@@ -820,7 +820,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
room={room}
requestClose={() => setMenuAnchor(undefined)}
galleryOpen={galleryOpen}
onToggleGallery={() => setGalleryOpen((v) => !v)}
onToggleGallery={() => setGalleryOpen((v: boolean) => !v)}
/>
</FocusTrap>
}
@@ -828,7 +828,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</Box>
</Box>
</PageHeader>
{galleryOpen && <MediaGallery room={room} onClose={() => setGalleryOpen(false)} />}
</>
);
}
@@ -3,6 +3,7 @@ import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Dialog,
Header,
Icon,
IconButton,
@@ -177,19 +178,14 @@ export function ScheduleMessageModal({
escapeDeactivates: stopPropagation,
}}
>
<Box
<Dialog
as="form"
variant="Surface"
role="dialog"
aria-modal="true"
aria-labelledby="schedule-message-title"
onSubmit={handleSubmit}
direction="Column"
style={{
background: color.Surface.Container,
borderRadius: config.radii.R400,
boxShadow: color.Other.Shadow,
...modalStyle,
}}
style={modalStyle}
>
{/* Header */}
<Header
@@ -341,7 +337,7 @@ export function ScheduleMessageModal({
<Text size="B400">Schedule</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
@@ -4,6 +4,10 @@ import {
Avatar,
Box,
config,
Header,
Icon,
IconButton,
Icons,
Input,
Line,
MenuItem,
@@ -134,14 +138,22 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
...modalStyle,
}}
>
<Box
direction="Column"
gap="200"
shrink="No"
style={{ padding: config.space.S400, paddingBottom: config.space.S200 }}
<Header
variant="Surface"
size="500"
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
>
<Text size="H5">Forward message</Text>
{!sentTo && (
<Box grow="Yes">
<Text as="h2" size="H4" truncate>
Forward message
</Text>
</Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
{!sentTo && (
<Box shrink="No" style={{ padding: `${config.space.S200} ${config.space.S400}` }}>
<Input
variant="Background"
size="400"
@@ -151,8 +163,8 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
value={query}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
/>
)}
</Box>
</Box>
)}
<Line size="300" />
{sentTo ? (
<Box
@@ -0,0 +1,31 @@
import { style } from '@vanilla-extract/css';
import { color, config } from 'folds';
// Shared base for the chat-background / seasonal-theme preview swatches. These
// are inherently custom tiles (they render a live background preview), so they
// can't be a folds MenuItem/Chip — but the chrome (radius, border, hover, and a
// proper keyboard focus ring) now comes from design tokens instead of raw
// rgba/px inline styles. Size + the preview background stay inline per-swatch.
export const BgSwatch = style({
display: 'block',
padding: 0,
cursor: 'pointer',
overflow: 'hidden',
borderRadius: config.radii.R300,
borderWidth: config.borderWidth.B400,
borderStyle: 'solid',
borderColor: color.SurfaceVariant.ContainerLine,
transition: 'border-color 100ms ease-in-out',
selectors: {
'&:hover': {
borderColor: color.Primary.ContainerLine,
},
'&:focus-visible': {
outline: `${config.borderWidth.B400} solid ${color.Primary.Main}`,
outlineOffset: config.space.S100,
},
'&[data-selected="true"]': {
borderColor: color.Primary.Main,
},
},
});
+35 -36
View File
@@ -32,7 +32,10 @@ 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 { BgSwatch as BgSwatchStyle } from './BgSwatch.css';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import {
@@ -640,33 +643,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>
@@ -1587,7 +1599,7 @@ function Calls() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
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. Selecting an option plays a preview."
after={
<SettingsSelect
value={callJoinLeaveSound}
@@ -1644,19 +1656,13 @@ function SeasonalBgGrid({
type="button"
aria-label={opt.label}
aria-pressed={selected}
data-selected={selected}
className={BgSwatchStyle}
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.Primary.Main}`
: '2px solid rgba(128,128,128,0.25)',
padding: 0,
overflow: 'hidden',
backgroundColor: '#030508',
}}
>
@@ -1707,22 +1713,15 @@ function ChatBgGrid() {
type="button"
aria-label={opt.label}
aria-pressed={chatBackground === opt.value}
data-selected={chatBackground === opt.value}
className={BgSwatchStyle}
onClick={() => {
setChatBackground(opt.value as ChatBackground);
if (opt.value !== 'none') setSeasonalThemeOverride('off');
}}
style={{
display: 'block',
width: toRem(76),
height: toRem(50),
borderRadius: toRem(8),
cursor: 'pointer',
border:
chatBackground === opt.value
? `2px solid ${color.Primary.Main}`
: '2px solid rgba(128,128,128,0.25)',
padding: 0,
overflow: 'hidden',
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations),
}}
/>
@@ -1,6 +1,6 @@
import React from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { Box, Text, IconButton, Icon, Icons, Scroll, config, toRem } from 'folds';
import { Box, Button, Text, IconButton, Icon, Icons, Scroll, config, toRem } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SystemNotification } from './SystemNotification';
import { AllMessagesNotifications } from './AllMessages';
@@ -68,37 +68,34 @@ function NotificationPresets() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<Box gap="300" style={{ padding: config.space.S300, flexWrap: 'wrap' }}>
{PRESETS.map((preset) => (
<button
<Button
key={preset.label}
type="button"
onClick={() => applyPreset(preset.patch)}
title={preset.description}
variant="Secondary"
fill="Soft"
radii="300"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: toRem(4),
padding: `${toRem(8)} ${toRem(16)}`,
borderRadius: config.radii.R300,
border: '1px solid var(--border-interactive-normal)',
background: 'var(--bg-surface-low)',
color: 'inherit',
cursor: 'pointer',
height: 'unset',
minWidth: toRem(80),
padding: `${config.space.S200} ${config.space.S400}`,
}}
>
<span style={{ fontSize: toRem(24) }}>{preset.emoji}</span>
<Text size="T300" style={{ fontWeight: 600 }}>
{preset.label}
</Text>
<Text
size="T200"
priority="300"
style={{ textAlign: 'center', maxWidth: toRem(120) }}
>
{preset.description}
</Text>
</button>
<Box direction="Column" alignItems="Center" gap="100">
<span style={{ fontSize: toRem(24) }}>{preset.emoji}</span>
<Text size="T300" style={{ fontWeight: config.fontWeight.W600 }}>
{preset.label}
</Text>
<Text
size="T200"
priority="300"
style={{ textAlign: 'center', maxWidth: toRem(120) }}
>
{preset.description}
</Text>
</Box>
</Button>
))}
</Box>
</SequenceCard>
+13 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Method } from 'matrix-js-sdk';
import { MatrixError, Method } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
@@ -32,8 +32,18 @@ function fetchDecoration(
cache.set(userId, val);
return val;
})
.catch(() => {
cache.set(userId, null);
.catch((err: unknown) => {
// A 404 (M_NOT_FOUND) means the field is genuinely unset → cache "no
// decoration". A transient failure (429 rate-limit, 5xx, network) must
// NOT be cached: doing so permanently hides the user's decoration for the
// whole session. This matters most for the member list and timeline, which
// mount many avatars at once and can trip homeserver rate limits — a
// single 429 in that burst would otherwise wipe the decoration until a
// full reload. Leaving the cache unset lets the next mount retry.
const status = err instanceof MatrixError ? err.httpStatus : undefined;
if (status === 404) {
cache.set(userId, null);
}
return null;
})
.finally(() => {
+7 -3
View File
@@ -53,10 +53,14 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
export const usePresenceLabel = (): Record<Presence, string> =>
useMemo(
// Keep this vocabulary aligned with the status setter (PresencePicker in
// SettingsTab.tsx): online -> "Online", unavailable -> "Idle". Previously
// these read "Active"/"Busy"/"Away", so a user who set "Idle" showed as
// "Busy" to others.
() => ({
[Presence.Online]: 'Active',
[Presence.Unavailable]: 'Busy',
[Presence.Offline]: 'Away',
[Presence.Online]: 'Online',
[Presence.Unavailable]: 'Idle',
[Presence.Offline]: 'Offline',
}),
[],
);
+1 -4
View File
@@ -49,10 +49,7 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) {
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<BookmarksPanel
onClose={() => setBookmarksOpen(false)}
isMobile={screenSize !== ScreenSize.Desktop}
/>
<BookmarksPanel onClose={() => setBookmarksOpen(false)} />
</>
)}
</Box>
+1 -5
View File
@@ -15,11 +15,7 @@ import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { usePreviousValue } from '../../hooks/usePreviousValue';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import {
getDirectRoomPath,
getHomeRoomPath,
getInboxInvitesPath,
} from '../pathUtils';
import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pathUtils';
import { mDirectAtom } from '../../state/mDirectList';
import {
getMemberDisplayName,
+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,
});
+46 -27
View File
@@ -9,12 +9,15 @@ import {
PopOut,
RectCords,
Text,
Tooltip,
TooltipProvider,
color,
config,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar';
import { stopPropagation } from '../../../utils/keyboard';
import { UserAvatar } from '../../../components/user-avatar';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
@@ -82,12 +85,16 @@ function PresencePicker() {
initialFocus: false,
onDeactivate: closeMenu,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
}}
>
<Menu variant="Surface" style={{ padding: config.space.S100, minWidth: toRem(210) }}>
<Box direction="Column" gap="100">
<Box style={{ padding: `${config.space.S100} ${config.space.S200}` }}>
<Text size="L400" style={{ opacity: 0.6 }}>
<Text size="L400" priority="300">
Set Status
</Text>
</Box>
@@ -115,33 +122,45 @@ function PresencePicker() {
}
>
{/* Presence dot sits in the bottom-right corner of SidebarItem (which is position:relative) */}
<button
type="button"
aria-label={`Status: ${currentOption.label}. Click to change.`}
onClick={(e) => {
e.stopPropagation();
setMenuAnchor(e.currentTarget.getBoundingClientRect());
}}
style={{
position: 'absolute',
bottom: 2,
right: 2,
background: 'var(--bg-surface)',
border: 'none',
borderRadius: '50%',
padding: 2,
lineHeight: 0,
cursor: 'pointer',
zIndex: 1,
}}
<TooltipProvider
position="Right"
tooltip={
<Tooltip>
<Text size="T200">{`Status: ${currentOption.label}`}</Text>
</Tooltip>
}
>
<Badge
size="200"
variant={presenceVariant(presenceStatus)}
fill={presenceStatus === 'invisible' ? 'Soft' : 'Solid'}
radii="Pill"
/>
</button>
{(triggerRef) => (
<button
type="button"
ref={triggerRef}
aria-label={`Status: ${currentOption.label}. Click to change.`}
onClick={(e) => {
e.stopPropagation();
setMenuAnchor(e.currentTarget.getBoundingClientRect());
}}
style={{
position: 'absolute',
bottom: 2,
right: 2,
background: color.Background.Container,
border: 'none',
borderRadius: '50%',
padding: 2,
lineHeight: 0,
cursor: 'pointer',
zIndex: 1,
}}
>
<Badge
size="200"
variant={presenceVariant(presenceStatus)}
fill={presenceStatus === 'invisible' ? 'Soft' : 'Solid'}
radii="Pill"
/>
</button>
)}
</TooltipProvider>
</PopOut>
);
}
+8 -2
View File
@@ -390,7 +390,10 @@ export class CallEmbed {
if (this.call === null) return;
const raw = ev.getEffectiveEvent();
this.call.feedStateUpdate(raw as IRoomEvent).catch((e) => {
console.error('Error sending state update to widget:', e instanceof Error ? e.message : 'unknown error');
console.error(
'Error sending state update to widget:',
e instanceof Error ? e.message : 'unknown error',
);
});
}
@@ -496,7 +499,10 @@ export class CallEmbed {
} else {
const raw = ev.getEffectiveEvent();
this.call.feedEvent(raw as IRoomEvent).catch((e) => {
console.error('Error sending event to widget:', e instanceof Error ? e.message : 'unknown error');
console.error(
'Error sending event to widget:',
e instanceof Error ? e.message : 'unknown error',
);
});
}
}
+3
View File
@@ -0,0 +1,3 @@
import { atom } from 'jotai';
export const mediaGalleryAtom = atom<boolean>(false);
+13 -6
View File
@@ -306,19 +306,26 @@ export function tokenize(code: string, lang: string): SyntaxToken[] {
// ── Inline style helpers ────────────────────────────────────────────────────
/** Returns the React inline-style object for a given SyntaxToken type. */
/**
* Returns the React inline-style object for a given SyntaxToken type.
*
* Uses the `--prism-*` variable family (defined in `ReactPrism.css`), which has
* proper light / dark / TDS palettes unlike the raw `--lt-accent-*` vars,
* which only exist in TDS mode and otherwise fall back to dark-only Monokai
* colors with poor contrast on light themes.
*/
export function tokenStyle(type: SyntaxToken['type']): CSSProperties {
switch (type) {
case 'kw':
return { color: 'var(--lt-accent-cyan, #66d9ef)' };
return { color: 'var(--prism-keyword)' };
case 'str':
return { color: 'var(--lt-accent-green, #a6e22e)' };
return { color: 'var(--prism-selector)' };
case 'num':
return { color: 'var(--lt-accent-orange, #fd971f)' };
return { color: 'var(--prism-boolean)' };
case 'cmt':
return { opacity: 0.5, fontStyle: 'italic' as const };
return { color: 'var(--prism-comment)', fontStyle: 'italic' as const };
case 'fn':
return { color: 'var(--lt-accent-purple, #ae81ff)' };
return { color: 'var(--prism-atrule)' };
default:
return {};
}