Compare commits

...

2 Commits

Author SHA1 Message Date
jared 9bf56d5748 docs(bugs): track remaining native-cinny polish items
CI / Build & Quality Checks (push) Successful in 10m31s
CI / Trigger Desktop Build (push) Successful in 29s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:44:27 -04:00
jared d5ce56930b refactor(ui): extract shared SettingsSelect; replace raw <select> (native-cinny audit 6/N)
Extracted the folds-native dropdown (Button+PopOut+Menu) from General.tsx into a
shared components/settings-select/SettingsSelect.tsx, and used it to replace raw
native <select> elements (which render OS-styled and broke under non-default
themes via colorScheme:'dark'):
- Profile "auto-clear after" select
- PushRuleEditor add-rule mode select (dropped the now-unused handleModeChange)

The form-tied timezone <select> in Profile is left for a follow-up (it's wired
to native form submission + a disabled state and needs more care).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:43:18 -04:00
5 changed files with 117 additions and 125 deletions
+10
View File
@@ -72,6 +72,16 @@ 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.
@@ -0,0 +1,95 @@
import React, { MouseEventHandler, useState } from 'react';
import { Box, Button, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../../utils/keyboard';
export type SettingsSelectOption<T extends string> = {
value: T;
label: string;
disabled?: boolean;
};
/**
* A folds-native dropdown (Button + PopOut + Menu) matching Cinny's select
* pattern — used instead of a raw `<select>`, which renders OS-styled and
* breaks under non-default themes.
*/
export function SettingsSelect<T extends string>({
value,
options,
onChange,
'aria-label': ariaLabel,
}: {
value: T;
options: SettingsSelectOption<T>[];
onChange: (v: T) => void;
'aria-label'?: string;
}) {
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (v: T) => {
onChange(v);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={!!menuCords}
>
<Text size="T300">{selectedLabel}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{options.map((opt) => (
<MenuItem
key={opt.value}
size="300"
variant={opt.value === value ? 'Primary' : 'Surface'}
radii="300"
disabled={opt.disabled}
onClick={() => !opt.disabled && handleSelect(opt.value)}
>
<Text size="T300">{opt.label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
+5 -27
View File
@@ -30,6 +30,7 @@ import {
} from 'folds';
import { Method } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
@@ -544,35 +545,12 @@ function ProfileStatus() {
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}>
Auto-clear after:
</Text>
<select
<SettingsSelect
value={clearAfter}
onChange={(e) => setClearAfter(e.target.value)}
options={CLEAR_AFTER_OPTIONS}
onChange={setClearAfter}
aria-label="Auto-clear status after"
style={{
background: color.SurfaceVariant.Container,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
color: color.SurfaceVariant.OnContainer,
colorScheme: 'dark',
fontSize: '0.82rem',
padding: `${config.space.S100} ${config.space.S200}`,
cursor: 'pointer',
outline: 'none',
}}
>
{CLEAR_AFTER_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
style={{
background: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
}}
>
{opt.label}
</option>
))}
</select>
/>
</Box>
{(presence?.status || statusMsg) && (
<Button
+1 -77
View File
@@ -81,6 +81,7 @@ import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
type ThemeSelectorProps = {
themeNames: Record<string, string>;
@@ -169,83 +170,6 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
);
}
type SettingsSelectOption<T extends string> = { value: T; label: string; disabled?: boolean };
function SettingsSelect<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: SettingsSelectOption<T>[];
onChange: (v: T) => void;
}) {
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (v: T) => {
onChange(v);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">{selectedLabel}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{options.map((opt) => (
<MenuItem
key={opt.value}
size="300"
variant={opt.value === value ? 'Primary' : 'Surface'}
radii="300"
disabled={opt.disabled}
onClick={() => !opt.disabled && handleSelect(opt.value)}
>
<Text size="T300">{opt.label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function SystemThemePreferences() {
const themeKind = useSystemThemeKind();
const themeNames = useThemeNames();
@@ -1,6 +1,7 @@
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { useAccountData } from '../../../hooks/useAccountData';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { SequenceCard } from '../../../components/sequence-card';
@@ -193,10 +194,6 @@ function AddRuleForm({ kind, placeholder, label }: AddRuleFormProps) {
setRuleId(evt.currentTarget.value);
};
const handleModeChange: ChangeEventHandler<HTMLSelectElement> = (evt) => {
setMode(evt.target.value as NotificationMode);
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
<Text size="T200" priority="300">
@@ -217,24 +214,12 @@ function AddRuleForm({ kind, placeholder, label }: AddRuleFormProps) {
/>
</Box>
<Box shrink="No">
<select
<SettingsSelect
value={mode}
onChange={handleModeChange}
style={{
background: 'transparent',
border: '1px solid currentColor',
borderRadius: config.radii.R300,
padding: `${config.space.S100} ${config.space.S200}`,
color: 'inherit',
fontSize: 'inherit',
}}
>
{ADD_MODES.map((m) => (
<option key={m} value={m}>
{MODE_LABELS[m]}
</option>
))}
</select>
options={ADD_MODES.map((m) => ({ value: m, label: MODE_LABELS[m] }))}
onChange={setMode}
aria-label="Notification mode"
/>
</Box>
<Button
size="400"