Compare commits

..

7 Commits

Author SHA1 Message Date
jared 6ace96f2cf docs(bugs): native-cinny audit fully closed (nits done)
CI / Build & Quality Checks (push) Successful in 10m32s
CI / Trigger Desktop Build (push) Successful in 20s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:15:49 -04:00
jared 2d71f2ce30 refactor(ui): name the global overlay z-index layers (native-cinny nit)
Centralized the global floating-UI stacking values into styles/zIndex.ts
(inCallBanner 9990 < seasonalEffect 9997 < nightLight 9998 < toast 10001;
folds modals sit at 9999 between). Same values, no behavior change — just
removes the magic numbers and documents the layering so future overlays don't
collide. Component-internal small z-index stays local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:10:29 -04:00
jared 2c3dba55e6 fix(ui): use folds Text priority instead of raw opacity (native-cinny nit)
Replaced raw style={{ opacity: N }} de-emphasis on folds <Text> with the
`priority` prop across search, schedule, profile, and tray UI. Left the cases
that aren't Text-priority candidates (an Icon opacity, a Box-row opacity, and a
Text with an explicit color token).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:44:57 -04:00
jared c7a04dcc70 fix(ui): poll checkmark uses folds Icon instead of Unicode glyph (native-cinny nit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:20:52 -04:00
jared 4b14c15518 docs(bugs): timezone select + lightbox done; only native-cinny nits remain
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:49:26 -04:00
jared c68ef346bf fix(ui): MediaGallery lightbox uses folds Overlay + FocusTrap (native-cinny audit 8/N)
The full-screen media viewer was a raw <div role="dialog"> rendered in place
with manual focus. Wrapped it in folds Overlay (portal + backdrop, proper
stacking) and FocusTrap (focus management), keeping its own arrow/Escape key
handling. The light-on-dark chrome (#fff over the forced-black media stage) is
kept — it's a justified, always-dark media-viewer scrim, not theme chrome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:49:24 -04:00
jared c5d7fcc303 fix(ui): timezone picker uses folds SettingsSelect (native-cinny audit 7/N)
Replaced the last raw native <select> (Profile timezone, colorScheme:'dark')
with SettingsSelect. Added an optional `disabled` prop to SettingsSelect for
the saving state. handleSubmit reads the `timezone` state (not the native form
field) so submission is unaffected; the now-unused handleSelectChange was
removed. No raw <select> elements remain in the settings UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:51:13 -04:00
15 changed files with 152 additions and 156 deletions
-10
View File
@@ -72,16 +72,6 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
- **Architecture notes (low priority):** deep `features/` + `hooks/` nesting, many small coupled hooks, possible dead CSS/components, `SpacingVariant` / `DropTarget` recipe simplification.
- **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
### Native-Cinny polish (remaining from the design-law audit)
The "renders-broken-on-stock-themes" cluster (ungated invented CSS vars across
~13 files + the toast rebuild) is fixed; Sentry was removed. Lower-priority
pattern items left:
- **Profile timezone `<select>`** (`settings/account/Profile.tsx`) — still a raw native select (`colorScheme:'dark'`); it's wired to native form submission + a disabled state, so converting to `SettingsSelect` needs care.
- **MediaGallery lightbox** (`room/MediaGallery.tsx`) — raw `<div role="dialog">` + `#fff`/rgba chrome over forced-black media. Should be folds `Overlay`/`Modal`; the over-media light-on-dark scheme is a borderline-justified scrim.
- **Nits:** scattered `opacity:``priority`, the poll `✓` Unicode glyph → folds `Icon`, a few `zIndex` magic numbers.
### Big Projects
- **#5 — Seasonal themes & chat-background redesign.** Current backgrounds are basic CSS; goal is high-fidelity, research-backed, GPU-accelerated designs (layered `oklch`, `backdrop-filter`, `contain:paint`) with WCAG-AA overlay contrast. Treat each as its own design sprint.
+2 -1
View File
@@ -64,6 +64,7 @@ import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
import { useLivekitSupport } from '../hooks/useLivekitSupport';
import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
import { zIndices } from '../styles/zIndex';
const PIP_MIN_W = 200;
const PIP_MIN_H = 112;
@@ -323,7 +324,7 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
position: 'fixed',
top: config.space.S400,
right: config.space.S400,
zIndex: 9990,
zIndex: zIndices.inCallBanner,
width: toRem(300),
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
padding: config.space.S300,
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useCallback, useEffect, useState } from 'react';
import { Box, color, config, Text, toRem } from 'folds';
import { Box, color, config, Icon, Icons, Text, toRem } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
import { RoomEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -339,11 +339,7 @@ export function PollContent({
transition: 'all 0.15s',
}}
>
{selected && isMultiple ? (
<Text as="span" size="T200" style={{ lineHeight: 1 }}>
</Text>
) : null}
{selected && isMultiple ? <Icon size="50" src={Icons.Check} /> : null}
</span>
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
{text}
@@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings';
import { zIndices } from '../../styles/zIndex';
import {
animSeasonFall,
animLeafFall,
@@ -758,7 +759,7 @@ function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: bool
pointerEvents: 'none',
// Below the Night Light overlay (9998) so seasonal particles are tinted
// by it, and below modals (9999) so dialogs are never obscured.
zIndex: 9997,
zIndex: zIndices.seasonalEffect,
overflow: 'hidden',
}}
>
@@ -18,11 +18,13 @@ export function SettingsSelect<T extends string>({
value,
options,
onChange,
disabled,
'aria-label': ariaLabel,
}: {
value: T;
options: SettingsSelectOption<T>[];
onChange: (v: T) => void;
disabled?: boolean;
'aria-label'?: string;
}) {
const [menuCords, setMenuCords] = useState<RectCords>();
@@ -47,6 +49,7 @@ export function SettingsSelect<T extends string>({
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
disabled={disabled}
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={!!menuCords}
@@ -133,7 +133,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
<Text size="T300" truncate>
{room.name}
</Text>
<Text size="T200" style={{ opacity: 0.55 }}>
<Text size="T200" priority="300">
{msgEvents.length > 0
? `${msgEvents.length} messages cached · oldest: ${new Date(oldest!.getTs()).toLocaleDateString()}`
: 'No messages cached yet'}
@@ -153,7 +153,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
</Button>
)}
{!canLoadMore && events.length > 0 && (
<Text size="T200" style={{ opacity: 0.5, flexShrink: 0 }}>
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
Fully cached
</Text>
)}
@@ -656,7 +656,7 @@ export function MessageSearch({
<Icon size="200" src={senderOnlyMode ? Icons.User : Icons.Lock} />
<Text size="H5">{senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'}</Text>
{!senderOnlyMode && (
<Text size="T200" style={{ opacity: 0.55 }}>
<Text size="T200" priority="300">
{`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
</Text>
)}
@@ -280,7 +280,8 @@ export function SearchInput({
<Text
size="T200"
truncate
style={{ opacity: 0.6, fontFamily: 'monospace', fontSize: '0.75em' }}
priority="300"
style={{ fontFamily: 'monospace', fontSize: '0.75em' }}
>
{user.userId}
</Text>
+104 -91
View File
@@ -6,6 +6,8 @@ import {
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
Scroll,
Spinner,
Text,
@@ -15,6 +17,7 @@ import {
config,
} from 'folds';
import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
@@ -250,102 +253,112 @@ function Lightbox({
});
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
role="dialog"
aria-modal
aria-label="Media viewer"
onKeyDown={handleKeyDown}
tabIndex={-1}
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: 'rgba(0,0,0,0.92)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header bar */}
<Box
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderBottom: '1px solid rgba(255,255,255,0.08)',
flexShrink: 0,
<Overlay open backdrop={<OverlayBackdrop />}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: false,
escapeDeactivates: false,
}}
>
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
{item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
{item.sender} · {dateStr}
</Text>
</Box>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
{index + 1} / {items.length}
</Text>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
role="dialog"
aria-modal
aria-label="Media viewer"
onKeyDown={handleKeyDown}
tabIndex={-1}
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: 'rgba(0,0,0,0.92)',
display: 'flex',
flexDirection: 'column',
}}
>
{(ref) => (
<IconButton ref={ref} variant="Surface" aria-label="Close" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
{/* Header bar */}
<Box
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderBottom: '1px solid rgba(255,255,255,0.08)',
flexShrink: 0,
}}
>
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
{item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
{item.sender} · {dateStr}
</Text>
</Box>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
{index + 1} / {items.length}
</Text>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton ref={ref} variant="Surface" aria-label="Close" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
{/* Media area with nav arrows */}
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ overflow: 'hidden', padding: config.space.S400 }}
>
{index > 0 && (
<IconButton
variant="Surface"
aria-label="Previous"
onClick={prev}
style={{ flexShrink: 0, marginRight: config.space.S200 }}
{/* Media area with nav arrows */}
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ overflow: 'hidden', padding: config.space.S400 }}
>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ overflow: 'hidden', height: '100%' }}
>
<LightboxMedia
key={`${item.mxcUrl}-${item.ts}`}
item={item}
useAuthentication={useAuthentication}
/>
</Box>
{index < items.length - 1 && (
<IconButton
variant="Surface"
aria-label="Next"
onClick={next}
style={{ flexShrink: 0, marginLeft: config.space.S200 }}
>
<Icon src={Icons.ArrowRight} />
</IconButton>
)}
</Box>
</div>
{index > 0 && (
<IconButton
variant="Surface"
aria-label="Previous"
onClick={prev}
style={{ flexShrink: 0, marginRight: config.space.S200 }}
>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ overflow: 'hidden', height: '100%' }}
>
<LightboxMedia
key={`${item.mxcUrl}-${item.ts}`}
item={item}
useAuthentication={useAuthentication}
/>
</Box>
{index < items.length - 1 && (
<IconButton
variant="Surface"
aria-label="Next"
onClick={next}
style={{ flexShrink: 0, marginLeft: config.space.S200 }}
>
<Icon src={Icons.ArrowRight} />
</IconButton>
)}
</Box>
</div>
</FocusTrap>
</Overlay>
);
}
@@ -241,7 +241,7 @@ export function ScheduleMessageModal({
<Text size="L400">Send at</Text>
<Box gap="200">
<Box direction="Column" gap="100" style={{ flex: 1 }}>
<Text as="label" htmlFor="schedule-date" size="T200" style={{ opacity: 0.7 }}>
<Text as="label" htmlFor="schedule-date" size="T200" priority="400">
Date
</Text>
<input
@@ -253,7 +253,7 @@ export function ScheduleMessageModal({
/>
</Box>
<Box direction="Column" gap="100" style={{ flex: 1 }}>
<Text as="label" htmlFor="schedule-time" size="T200" style={{ opacity: 0.7 }}>
<Text as="label" htmlFor="schedule-time" size="T200" priority="400">
Time
</Text>
<input
@@ -140,17 +140,17 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
>
<Text
size="T200"
priority="400"
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
opacity: 0.8,
}}
>
{typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'}
</Text>
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}>
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{formatSendAt(msg.sendAt)}
</Text>
<IconButton
+9 -36
View File
@@ -542,7 +542,7 @@ function ProfileStatus() {
</Text>
)}
<Box alignItems="Center" gap="200">
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}>
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
Auto-clear after:
</Text>
<SettingsSelect
@@ -759,10 +759,6 @@ function ProfileTimezone() {
);
const saving = saveState.status === AsyncStatus.Loading;
const handleSelectChange = (evt: React.ChangeEvent<HTMLSelectElement>) => {
setTimezone(evt.currentTarget.value);
};
const handleReset = () => {
setTimezone(savedTimezone);
};
@@ -791,39 +787,16 @@ function ProfileTimezone() {
<Box direction="Column" grow="Yes" gap="100">
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
<Box grow="Yes" direction="Column">
<select
name="timezoneInput"
aria-label="Timezone"
<SettingsSelect
value={timezone}
onChange={handleSelectChange}
options={[
{ value: '', label: '— select timezone —' },
...COMMON_TIMEZONES.map((tz) => ({ value: tz, label: tz })),
]}
onChange={setTimezone}
disabled={saving}
style={{
background: color.SurfaceVariant.Container,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
color: color.SurfaceVariant.OnContainer,
colorScheme: 'dark',
fontSize: '0.875rem',
padding: `${config.space.S200} ${config.space.S300}`,
width: '100%',
cursor: 'pointer',
outline: 'none',
}}
>
<option value=""> select timezone </option>
{COMMON_TIMEZONES.map((tz) => (
<option
key={tz}
value={tz}
style={{
background: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
}}
>
{tz}
</option>
))}
</select>
aria-label="Timezone"
/>
</Box>
{hasChanges && !saving && (
<IconButton
@@ -218,7 +218,7 @@ export function ProfileDecoration() {
>
{DECORATION_CATEGORIES.map((category) => (
<div key={category.id} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Text size="L400" style={{ opacity: 0.7 }}>
<Text size="L400" priority="400">
{category.label}
</Text>
<div
@@ -4,6 +4,7 @@ import { color, config, Icon, IconButton, Icons } from 'folds';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { zIndices } from '../../styles/zIndex';
// Inject the keyframe animation once
const STYLE_ID = 'lotus-toast-keyframes';
@@ -214,7 +215,7 @@ export function LotusToastContainer() {
position: 'fixed',
bottom: '1.5rem',
right: '1.5rem',
zIndex: 10001,
zIndex: zIndices.toast,
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
+2 -1
View File
@@ -27,6 +27,7 @@ import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
import { zIndices } from '../styles/zIndex';
const FONT_MAP: Record<string, string> = {
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
@@ -95,7 +96,7 @@ function NightLightOverlay() {
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 9998,
zIndex: zIndices.nightLight,
backgroundColor: `rgba(255, 140, 0, ${(settings.nightLightOpacity ?? 30) / 100})`,
}}
/>
+16
View File
@@ -0,0 +1,16 @@
/**
* Global overlay stacking layers, centralized so floating Lotus UI doesn't
* collide. (folds `Overlay`/`Dialog` modals resolve to 9999, which sits between
* `nightLight` and `toast`.) Component-internal stacking uses small local
* z-index values and is intentionally not listed here.
*/
export const zIndices = {
/** In-call incoming-call banner — below seasonal/night-light/modals. */
inCallBanner: 9990,
/** Seasonal particle effect — below the night-light tint so particles tint. */
seasonalEffect: 9997,
/** Night Light tint overlay — above effects, below modals. */
nightLight: 9998,
/** Toasts — above everything, including modals. */
toast: 10001,
} as const;