feat: add extra-compact density option and fix font size scaling

- Add 'extra-compact' density level that hides avatars and preview
  text, showing only sender and subject for maximum information density
- Add visual density preview in appearance settings showing a 3-row
  email list mockup that reflects each density option
- Fix global font size setting only changing line height by moving
  font-size from body to :root so rem-based Tailwind classes scale
- Apply extra-compact behavior to email list items, thread list items,
  thread email items, and thread conversation view (EmailCard)
- Update email list virtualizer size estimates for extra-compact
- Add extra-compact translations for all 8 locales
This commit is contained in:
Linus Rath
2026-03-14 18:59:00 +01:00
parent 838de8e5da
commit d5404de224
16 changed files with 128 additions and 40 deletions
+2 -1
View File
@@ -3,6 +3,8 @@
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
:root { :root {
font-size: var(--font-size-base);
--color-border: #e2e8f0; --color-border: #e2e8f0;
--color-input: #e2e8f0; --color-input: #e2e8f0;
--color-ring: #94a3b8; --color-ring: #94a3b8;
@@ -79,7 +81,6 @@ body {
system-ui, system-ui,
-apple-system, -apple-system,
sans-serif; sans-serif;
font-size: var(--font-size-base);
font-feature-settings: font-feature-settings:
"rlig" 1, "rlig" 1,
"calt" 1; "calt" 1;
+10 -7
View File
@@ -24,6 +24,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
const t = useTranslations('email_viewer'); const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore(); const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { identities } = useAuthStore(); const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
@@ -116,12 +117,14 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
)} )}
{/* Avatar */} {/* Avatar */}
<Avatar {density !== 'extra-compact' && (
name={sender?.name} <Avatar
email={sender?.email} name={sender?.name}
size="md" email={sender?.email}
className="flex-shrink-0 shadow-sm" size="md"
/> className="flex-shrink-0 shadow-sm"
/>
)}
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -183,7 +186,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
</div> </div>
{/* Third Line: Preview (controlled by showPreview setting) */} {/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && ( {showPreview && density !== 'extra-compact' && (
<p className={cn( <p className={cn(
"text-sm leading-relaxed line-clamp-2", "text-sm leading-relaxed line-clamp-2",
isUnread isUnread
+2 -2
View File
@@ -100,8 +100,8 @@ export function EmailList({
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const estimateSize = useCallback(() => { const estimateSize = useCallback(() => {
const base = { compact: 60, regular: 84, comfortable: 104 }[density]; const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
return showPreview ? base + 36 : base; return (showPreview && density !== 'extra-compact') ? base + 36 : base;
}, [density, showPreview]); }, [density, showPreview]);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
+10 -7
View File
@@ -221,6 +221,7 @@ function EmailCard({
}: EmailCardProps) { }: EmailCardProps) {
const t = useTranslations(); const t = useTranslations();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const density = useSettingsStore((state) => state.density);
const sender = email.from?.[0]; const sender = email.from?.[0];
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
@@ -428,12 +429,14 @@ function EmailCard({
)} )}
style={{ gap: 'var(--density-item-gap)', padding: 'var(--density-card-p)' }} style={{ gap: 'var(--density-item-gap)', padding: 'var(--density-card-p)' }}
> >
<Avatar {density !== 'extra-compact' && (
name={sender?.name} <Avatar
email={sender?.email} name={sender?.name}
size="md" email={sender?.email}
className="flex-shrink-0" size="md"
/> className="flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
<span className={cn( <span className={cn(
@@ -452,7 +455,7 @@ function EmailCard({
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{formatDate(email.receivedAt)} {formatDate(email.receivedAt)}
</div> </div>
{!isExpanded && ( {!isExpanded && density !== 'extra-compact' && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2"> <p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{email.preview || "No preview available"} {email.preview || "No preview available"}
</p> </p>
+10 -6
View File
@@ -7,6 +7,7 @@ import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react"; import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
import { useEmailDrag } from "@/hooks/use-email-drag"; import { useEmailDrag } from "@/hooks/use-email-drag";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
interface ThreadEmailItemProps { interface ThreadEmailItemProps {
email: Email; email: Email;
@@ -27,6 +28,7 @@ export function ThreadEmailItem({
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const sender = email.from?.[0]; const sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const density = useSettingsStore((state) => state.density);
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
const { dragHandlers, isDragging } = useEmailDrag({ const { dragHandlers, isDragging } = useEmailDrag({
@@ -104,12 +106,14 @@ export function ThreadEmailItem({
)} )}
{/* Small Avatar */} {/* Small Avatar */}
<Avatar {density !== 'extra-compact' && (
name={sender?.name} <Avatar
email={sender?.email} name={sender?.name}
size="sm" email={sender?.email}
className="flex-shrink-0" size="sm"
/> className="flex-shrink-0"
/>
)}
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
+20 -14
View File
@@ -42,6 +42,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const sender = email.from?.[0]; const sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density);
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
// Resolve color and keyword definition from keyword definitions if not passed directly // Resolve color and keyword definition from keyword definitions if not passed directly
@@ -128,12 +129,14 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div> </div>
)} )}
<Avatar {density !== 'extra-compact' && (
name={sender?.name} <Avatar
email={sender?.email} name={sender?.name}
size="md" email={sender?.email}
className="flex-shrink-0 shadow-sm" size="md"
/> className="flex-shrink-0 shadow-sm"
/>
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1"> <div className="flex items-center justify-between gap-2 mb-1">
@@ -185,7 +188,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{email.subject || "(no subject)"} {email.subject || "(no subject)"}
</div> </div>
{showPreview && ( {showPreview && density !== 'extra-compact' && (
<p className={cn( <p className={cn(
"text-sm leading-relaxed line-clamp-2", "text-sm leading-relaxed line-clamp-2",
isUnread isUnread
@@ -216,6 +219,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
}, ref) { }, ref) {
const t = useTranslations('threads'); const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread; const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
@@ -377,12 +381,14 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div> </div>
)} )}
<Avatar {density !== 'extra-compact' && (
name={latestEmail.from?.[0]?.name} <Avatar
email={latestEmail.from?.[0]?.email} name={latestEmail.from?.[0]?.name}
size="md" email={latestEmail.from?.[0]?.email}
className="flex-shrink-0 shadow-sm" size="md"
/> className="flex-shrink-0 shadow-sm"
/>
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1"> <div className="flex items-center justify-between gap-2 mb-1">
@@ -446,7 +452,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{latestEmail.subject || "(no subject)"} {latestEmail.subject || "(no subject)"}
</div> </div>
{showPreview && ( {showPreview && density !== 'extra-compact' && (
<p className={cn( <p className={cn(
"text-sm leading-relaxed line-clamp-2", "text-sm leading-relaxed line-clamp-2",
hasUnread hasUnread
+57 -2
View File
@@ -2,9 +2,62 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useThemeStore } from '@/stores/theme-store'; import { useThemeStore } from '@/stores/theme-store';
import { useSettingsStore, type ToolbarPosition } from '@/stores/settings-store'; import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/settings-store';
import { LanguageSwitcher } from '@/components/ui/language-switcher'; import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
compact: { py: 'py-1', gap: 'gap-2', showAvatar: true, showPreview: false },
regular: { py: 'py-2.5', gap: 'gap-3', showAvatar: true, showPreview: true },
comfortable: { py: 'py-4', gap: 'gap-4', showAvatar: true, showPreview: true },
};
function DensityPreview({ density }: { density: Density }) {
const cfg = DENSITY_PREVIEW[density];
const rows = [
{ unread: true, sender: 'Alice Johnson', subject: 'Project update — Q1 roadmap', preview: 'Here are the latest numbers from…' },
{ unread: false, sender: 'Bob Smith', subject: 'Re: Meeting notes', preview: 'Thanks, will review and get back...' },
{ unread: true, sender: 'Carol Lee', subject: 'Invoice #4092', preview: 'Please find attached the invoice…' },
];
return (
<div className="mt-3 rounded-lg border border-border overflow-hidden bg-background text-xs select-none">
{rows.map((row, i) => (
<div
key={i}
className={cn(
"flex items-center px-3 border-b border-border last:border-b-0",
cfg.py,
cfg.gap
)}
>
{cfg.showAvatar && (
<div className={cn(
"flex-shrink-0 rounded-full bg-muted",
density === 'comfortable' ? "w-8 h-8" : "w-6 h-6"
)} />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className={cn("truncate", row.unread ? "font-semibold text-foreground" : "text-muted-foreground")}>
{row.sender}
</span>
<span className="text-[10px] text-muted-foreground flex-shrink-0 tabular-nums">12:00</span>
</div>
<div className={cn("truncate", row.unread ? "font-medium text-foreground" : "text-foreground/80")}>
{row.subject}
</div>
{cfg.showPreview && (
<div className="truncate text-muted-foreground/70">{row.preview}</div>
)}
</div>
</div>
))}
</div>
);
}
export function AppearanceSettings() { export function AppearanceSettings() {
const t = useTranslations('settings.appearance'); const t = useTranslations('settings.appearance');
@@ -49,14 +102,16 @@ export function AppearanceSettings() {
<RadioGroup <RadioGroup
value={density} value={density}
onChange={(value) => onChange={(value) =>
updateSetting('density', value as 'compact' | 'regular' | 'comfortable') updateSetting('density', value as Density)
} }
options={[ options={[
{ value: 'extra-compact', label: t('list_density.extra_compact') },
{ value: 'compact', label: t('list_density.compact') }, { value: 'compact', label: t('list_density.compact') },
{ value: 'regular', label: t('list_density.regular') }, { value: 'regular', label: t('list_density.regular') },
{ value: 'comfortable', label: t('list_density.comfortable') }, { value: 'comfortable', label: t('list_density.comfortable') },
]} ]}
/> />
<DensityPreview density={density} />
</SettingItem> </SettingItem>
{/* Toolbar Position */} {/* Toolbar Position */}
+1
View File
@@ -512,6 +512,7 @@
"list_density": { "list_density": {
"label": "Dichte", "label": "Dichte",
"description": "Abstände in der Benutzeroberfläche steuern", "description": "Abstände in der Benutzeroberfläche steuern",
"extra_compact": "Sehr kompakt",
"compact": "Kompakt", "compact": "Kompakt",
"regular": "Normal", "regular": "Normal",
"comfortable": "Komfortabel" "comfortable": "Komfortabel"
+1
View File
@@ -512,6 +512,7 @@
"list_density": { "list_density": {
"label": "Density", "label": "Density",
"description": "Control spacing and padding across the UI", "description": "Control spacing and padding across the UI",
"extra_compact": "Extra Compact",
"compact": "Compact", "compact": "Compact",
"regular": "Regular", "regular": "Regular",
"comfortable": "Comfortable" "comfortable": "Comfortable"
+1
View File
@@ -512,6 +512,7 @@
"list_density": { "list_density": {
"label": "Densidad", "label": "Densidad",
"description": "Controle el espaciado en toda la interfaz", "description": "Controle el espaciado en toda la interfaz",
"extra_compact": "Extra compacto",
"compact": "Compacto", "compact": "Compacto",
"regular": "Regular", "regular": "Regular",
"comfortable": "Cómodo" "comfortable": "Cómodo"
+1
View File
@@ -512,6 +512,7 @@
"list_density": { "list_density": {
"label": "Densité", "label": "Densité",
"description": "Contrôlez l'espacement dans toute l'interface", "description": "Contrôlez l'espacement dans toute l'interface",
"extra_compact": "Très compacte",
"compact": "Compacte", "compact": "Compacte",
"regular": "Normale", "regular": "Normale",
"comfortable": "Confortable" "comfortable": "Confortable"
+1
View File
@@ -512,6 +512,7 @@
"list_density": { "list_density": {
"label": "Densità", "label": "Densità",
"description": "Controlla la spaziatura in tutta l'interfaccia", "description": "Controlla la spaziatura in tutta l'interfaccia",
"extra_compact": "Molto compatto",
"compact": "Compatto", "compact": "Compatto",
"regular": "Normale", "regular": "Normale",
"comfortable": "Comodo" "comfortable": "Comodo"
+1
View File
@@ -512,6 +512,7 @@
"list_density": { "list_density": {
"label": "密度", "label": "密度",
"description": "UI全体の間隔を調整", "description": "UI全体の間隔を調整",
"extra_compact": "極小",
"compact": "コンパクト", "compact": "コンパクト",
"regular": "標準", "regular": "標準",
"comfortable": "ゆったり" "comfortable": "ゆったり"
+1
View File
@@ -512,6 +512,7 @@
"list_density": { "list_density": {
"label": "Dichtheid", "label": "Dichtheid",
"description": "Regeleer de ruimte in de gehele interface", "description": "Regeleer de ruimte in de gehele interface",
"extra_compact": "Extra compact",
"compact": "Compact", "compact": "Compact",
"regular": "Normaal", "regular": "Normaal",
"comfortable": "Comfortabel" "comfortable": "Comfortabel"
+1
View File
@@ -512,6 +512,7 @@
"list_density": { "list_density": {
"label": "Densidade", "label": "Densidade",
"description": "Controle o espaçamento em toda a interface", "description": "Controle o espaçamento em toda a interface",
"extra_compact": "Extra compacto",
"compact": "Compacto", "compact": "Compacto",
"regular": "Regular", "regular": "Regular",
"comfortable": "Confortável" "comfortable": "Confortável"
+9 -1
View File
@@ -19,7 +19,7 @@ let isLoadingFromServer = false;
const SYNC_DEBOUNCE_MS = 2000; const SYNC_DEBOUNCE_MS = 2000;
export type FontSize = 'small' | 'medium' | 'large'; export type FontSize = 'small' | 'medium' | 'large';
export type Density = 'compact' | 'regular' | 'comfortable'; export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
/** @deprecated Use Density instead */ /** @deprecated Use Density instead */
export type ListDensity = Density; export type ListDensity = Density;
export type DeleteAction = 'trash' | 'permanent'; export type DeleteAction = 'trash' | 'permanent';
@@ -438,6 +438,14 @@ function applyDensity(density: Density) {
const root = document.documentElement; const root = document.documentElement;
const densityValues = { const densityValues = {
'extra-compact': {
'--list-item-height': 'auto',
'--density-item-py': '2px',
'--density-item-gap': '6px',
'--density-header-py': '4px',
'--density-card-p': '8px',
'--density-sidebar-py': '0px',
},
compact: { compact: {
'--list-item-height': 'auto', '--list-item-height': 'auto',
'--density-item-py': '4px', '--density-item-py': '4px',