From 2d1ca9d0d656ed5b374d800b6f761cfa22146365 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 4 Jul 2026 20:59:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20email=20dashboard=20UX=20refactor?= =?UTF-8?q?=20=E2=80=94=20split-view,=20row=20polish,=20dashboard=20(PHASE?= =?UTF-8?q?S=201/3/4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch of 4 parallel units, rebased onto current develop and integrated: - PHASE 1 split-view: clicking an email opens an in-place, collapsible + resizable reading pane (shared EmailDetail extracted from Senders) instead of a new tab; list stays interactive, selection preserved, mobile stacks (<768px); list gains Skeleton loading + EmptyState. (FolderView, SearchResults, +EmailDetail, +split.css) - PHASE 3 row polish: EmailRow gains onOpen (Gmail fallback kept), a new accessible Checkbox primitive (ui/checkbox.jsx), 44px rows, clearer hierarchy + hover; keeps the search why-matched highlight rendering. (EmailRow, ui/checkbox, styles.css) - PHASE 4 bulk+keys: BulkToolbar hierarchy/responsive/clear-selection; keyboard nav adds ArrowUp/Down + u=unread, hardened input guard. (BulkToolbar, useListKeyboardNav) - PHASE 3/4 dashboard: remove dead HeatmapWidget, replace CategoryHeatmap with a clickable Emails-by-Category bar (a11y: rank by text+count, not colour); Skeleton + EmptyState on widgets. (widgets, Dashboard, client.js, styles.css) Each unit was self-code-reviewed and built green; integrated build passes. Supersedes PRs #40-43 (their branches had a stale 38-commit base). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + frontend/src/api/client.js | 1 - frontend/src/components/BulkToolbar.jsx | 69 ++++++++--- frontend/src/components/EmailDetail.jsx | 115 ++++++++++++++++++ frontend/src/components/EmailRow.jsx | 22 +++- frontend/src/components/ui/checkbox.jsx | 43 +++++++ frontend/src/components/ui/index.js | 1 + frontend/src/components/widgets.jsx | 118 +++++++++--------- frontend/src/hooks/useListKeyboardNav.js | 80 ++++++++---- frontend/src/main.jsx | 1 + frontend/src/pages/Dashboard.jsx | 35 ++++-- frontend/src/pages/FolderView.jsx | 91 ++++++++++---- frontend/src/pages/SearchResults.jsx | 97 +++++++++++---- frontend/src/split.css | 148 +++++++++++++++++++++++ frontend/src/styles.css | 80 +++++++----- 15 files changed, 712 insertions(+), 192 deletions(-) create mode 100644 frontend/src/components/EmailDetail.jsx create mode 100644 frontend/src/components/ui/checkbox.jsx create mode 100644 frontend/src/split.css diff --git a/.gitignore b/.gitignore index 24889e1..c6ecbe0 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ vendor/ coverage/ .cache/ tmp/ + +# agent worktrees (never commit) +.claude/worktrees/ diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index f8df3ff..5dbf1ea 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -38,7 +38,6 @@ export const AnalyticsApi = { health: () => api.get('/analytics/health').then((r) => r.data), topSenders: (take = 20) => api.get(`/analytics/top-senders?take=${take}`).then((r) => r.data), volume: (days = 90) => api.get(`/analytics/volume?days=${days}`).then((r) => r.data), - heatmap: () => api.get('/analytics/heatmap').then((r) => r.data), categoryHeatmap: () => api.get('/analytics/category-heatmap').then((r) => r.data), attachments: () => api.get('/analytics/attachments').then((r) => r.data), sidebarCounts: () => api.get('/analytics/sidebar-counts').then((r) => r.data), diff --git a/frontend/src/components/BulkToolbar.jsx b/frontend/src/components/BulkToolbar.jsx index 97fa140..53c4be7 100644 --- a/frontend/src/components/BulkToolbar.jsx +++ b/frontend/src/components/BulkToolbar.jsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { MailOpen, Mail, Star, Archive, Trash2 } from 'lucide-react'; +import { MailOpen, Mail, Star, Archive, Trash2, X } from 'lucide-react'; import { BulkApi } from '../api/client.js'; import { Button, useToast, @@ -49,25 +49,54 @@ export default function BulkToolbar({ selectedIds, onDone, onClear }) { }; return ( -
- {count} selected -
- - - - - - +
+ {/* Selection count — primary emphasis so it reads first. */} +
+ + + selected + + {/* Obvious clear-selection affordance, kept next to the count. */} + +
+ +
+ +
+ + + + + +
!busy && setConfirmTrash(o)}> diff --git a/frontend/src/components/EmailDetail.jsx b/frontend/src/components/EmailDetail.jsx new file mode 100644 index 0000000..8671efd --- /dev/null +++ b/frontend/src/components/EmailDetail.jsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react'; +import { EmailApi } from '../api/client.js'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +const fmtSize = (b) => { + if (!b) return ''; + if (b < 1024) return `${b} B`; + if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`; + return `${(b / 1048576).toFixed(1)} MB`; +}; + +/** + * Shared reading-pane / full single-email view. + * + * Accepts either an `email` summary object (as emitted by the list rows) or a + * bare `emailId`. Fetches the full detail via EmailApi.get(id) and the AI + * summary lazily via EmailApi.summary(id). Renders subject, metadata, an AI + * summary button, the body, and per-email actions including "Open in Gmail". + * + * `onClose` collapses the pane. + */ +export default function EmailDetail({ email: summary, emailId, onClose }) { + const id = summary?.id ?? emailId; + + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [aiSummary, setAiSummary] = useState(null); + const [aiLoading, setAiLoading] = useState(false); + + useEffect(() => { + if (id == null) return; + let cancelled = false; + setDetail(null); + setLoading(true); + setAiSummary(null); + setAiLoading(false); + EmailApi.get(id) + .then((d) => { if (!cancelled) setDetail(d); }) + .catch(() => { if (!cancelled) setDetail(null); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [id]); + + const email = detail ?? summary ?? {}; + + const fetchAiSummary = () => { + if (id == null) return; + setAiLoading(true); + EmailApi.summary(id) + .then((r) => setAiSummary(r.summary)) + .catch(() => setAiSummary(null)) + .finally(() => setAiLoading(false)); + }; + + const openInGmail = () => window.open( + `https://mail.google.com/mail/u/0/#all/${email.gmailMessageId}`, + '_blank', 'noopener,noreferrer' + ); + + return ( +
+
+ +
+ +
+
{email.subject || '(no subject)'}
+
+ {email.senderDisplayName || email.senderAddress} + {email.sentAtUtc && ( + <> + · + {new Date(email.sentAtUtc).toLocaleString()} + + )} + {email.sizeEstimateBytes > 0 && ( + <> + · + {fmtSize(email.sizeEstimateBytes)} + + )} +
+
+ + {!loading && ( +
+ {aiSummary != null ? ( +
✨ {aiSummary}
+ ) : ( + + )} +
+ )} + + {loading &&
Loading message…
} + + {!loading && detail?.bodyText && ( +
{detail.bodyText}
+ )} + + {!loading && !detail?.bodyText && email.snippet && ( +
{email.snippet}
+ )} + +
+ +
+
+ ); +} diff --git a/frontend/src/components/EmailRow.jsx b/frontend/src/components/EmailRow.jsx index 41b3b00..d795d92 100644 --- a/frontend/src/components/EmailRow.jsx +++ b/frontend/src/components/EmailRow.jsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { EmailApi } from '../api/client.js'; +import { Checkbox } from './ui/checkbox.jsx'; const fmtDate = (iso) => { const d = new Date(iso); @@ -35,7 +36,7 @@ function renderHighlight(s) { return out; } -export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused }) { +export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused, onOpen }) { const [email, setEmail] = useState(initial); const [acting, setActing] = useState(false); @@ -88,12 +89,16 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS return ( onOpen ? onOpen(email) : openInGmail()} + title={onOpen ? 'Open' : 'Open in Gmail'} > {onToggleSelect && ( e.stopPropagation()}> - onToggleSelect(email.id)} /> + onToggleSelect(email.id)} + aria-label={selected ? 'Deselect email' : 'Select email'} + /> )} {email.isUnread && } @@ -103,8 +108,8 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS {email.subject || '(no subject)'} {email.matchHighlight - ? — {renderHighlight(email.matchHighlight)} - : email.snippet && — {email.snippet}} + ? {renderHighlight(email.matchHighlight)} + : email.snippet && {email.snippet}} {email.hasAttachments && 📎} @@ -134,6 +139,11 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS disabled={email._unsubDone} >{email._unsubDone ? '✓' : '✉✕'} )} + ); })}
@@ -196,4 +196,10 @@ export function StorageWidget({ bytes }) { return ; } -function Empty() { return
No data yet — run a sync.
; } +function Empty() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/hooks/useListKeyboardNav.js b/frontend/src/hooks/useListKeyboardNav.js index 9021191..c19518d 100644 --- a/frontend/src/hooks/useListKeyboardNav.js +++ b/frontend/src/hooks/useListKeyboardNav.js @@ -2,9 +2,15 @@ import { useEffect, useState } from 'react'; import { BulkApi } from '../api/client.js'; /// -/// j/k move focus down/up the list, e archives the focused email, # (shift+3) -/// trashes it. Ignored while an input/textarea/select has focus, or while -/// the "/" search shortcut is active, so typing is never hijacked. +/// Keyboard navigation for an email list. Shortcuts: +/// j / ArrowDown move focus down +/// k / ArrowUp move focus up +/// e archive the focused email +/// u mark the focused email unread +/// # (shift+3) trash the focused email +/// All shortcuts are ignored while an input/textarea/select (or any +/// contenteditable) has focus, and modifier chords (Ctrl/Cmd/Alt) are left +/// alone, so typing and browser/OS shortcuts are never hijacked. /// `onRemoved(id)` lets the caller drop the row from local state after a /// successful archive/trash. /// @@ -12,31 +18,59 @@ export default function useListKeyboardNav(emails, onRemoved) { const [focusedId, setFocusedId] = useState(null); useEffect(() => { + const isEditable = (el) => { + if (!el) return false; + const tag = el.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable; + }; + const handler = async (e) => { - const tag = document.activeElement?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + // Never hijack typing or modifier chords (Ctrl+C, Cmd+K, Alt+…). + if (isEditable(document.activeElement)) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; if (!emails.length) return; const idx = emails.findIndex((x) => x.id === focusedId); - if (e.key === 'j') { - e.preventDefault(); - const next = idx < 0 ? 0 : Math.min(idx + 1, emails.length - 1); - setFocusedId(emails[next].id); - } else if (e.key === 'k') { - e.preventDefault(); - const prev = idx < 0 ? 0 : Math.max(idx - 1, 0); - setFocusedId(emails[prev].id); - } else if (e.key === 'e' && idx >= 0) { - e.preventDefault(); - const id = emails[idx].id; - await BulkApi.archive([id]); - onRemoved(id); - } else if (e.key === '#' && idx >= 0) { - e.preventDefault(); - const id = emails[idx].id; - await BulkApi.trash([id]); - onRemoved(id); + switch (e.key) { + case 'j': + case 'ArrowDown': { + e.preventDefault(); + const next = idx < 0 ? 0 : Math.min(idx + 1, emails.length - 1); + setFocusedId(emails[next].id); + break; + } + case 'k': + case 'ArrowUp': { + e.preventDefault(); + const prev = idx < 0 ? 0 : Math.max(idx - 1, 0); + setFocusedId(emails[prev].id); + break; + } + case 'e': { + if (idx < 0) break; + e.preventDefault(); + const id = emails[idx].id; + await BulkApi.archive([id]); + onRemoved(id); + break; + } + case 'u': { + if (idx < 0) break; + e.preventDefault(); + await BulkApi.markUnread([emails[idx].id]); + break; + } + case '#': { + if (idx < 0) break; + e.preventDefault(); + const id = emails[idx].id; + await BulkApi.trash([id]); + onRemoved(id); + break; + } + default: + break; } }; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index c6a4cf3..0651dca 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -7,6 +7,7 @@ import { ToastProvider, TooltipProvider } from './components/ui'; import '@fontsource-variable/inter'; import './index.css'; import './styles.css'; +import './split.css'; // Route-level code splitting: each page loads its own chunk on first visit, so the // initial bundle no longer carries Chart.js / grid-layout / every page at once. diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 83ffeda..057f020 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -6,8 +6,9 @@ import { AnalyticsApi, LayoutApi, ExportApi, SyncApi } from '../api/client.js'; import SyncSplash from '../components/SyncSplash.jsx'; import { HealthWidget, StatCard, TopSendersWidget, VolumeWidget, - CategoryHeatmapWidget, AttachmentsWidget, StorageWidget + CategoryBreakdownWidget, AttachmentsWidget, StorageWidget } from '../components/widgets.jsx'; +import { Skeleton } from '../components/ui/skeleton.jsx'; // Default grid geometry; overridden by the user's saved layout. const DEFAULT_LAYOUT = [ @@ -17,12 +18,23 @@ const DEFAULT_LAYOUT = [ { i: 'storage', x: 7, y: 0, w: 2, h: 2 }, { i: 'top-senders', x: 3, y: 2, w: 3, h: 5 }, { i: 'volume', x: 6, y: 2, w: 6, h: 4 }, - { i: 'category-heatmap', x: 0, y: 5, w: 6, h: 5 }, + { i: 'category-breakdown', x: 0, y: 5, w: 6, h: 5 }, { i: 'attachments', x: 6, y: 6, w: 4, h: 4 }, ]; const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i); +function WidgetSkeleton() { + return ( +
+ + + + +
+ ); +} + export default function Dashboard() { const [data, setData] = useState(null); const [layout, setLayout] = useState(DEFAULT_LAYOUT); @@ -87,15 +99,18 @@ export default function Dashboard() { const visibleLayout = useMemo(() => layout.filter((l) => !hidden.includes(l.i)), [layout, hidden]); const render = (key) => { + // category-breakdown self-fetches, so it renders regardless of dashboard load state. + if (key === 'category-breakdown') return ; + // While the dashboard payload loads, show a skeleton placeholder per widget. + if (!data) return ; switch (key) { - case 'inbox-health': return ; - case 'total-emails': return ; - case 'unread-emails': return ; - case 'storage': return ; - case 'top-senders': return ; - case 'volume': return ; - case 'category-heatmap': return ; - case 'attachments': return ; + case 'inbox-health': return ; + case 'total-emails': return ; + case 'unread-emails': return ; + case 'storage': return ; + case 'top-senders': return ; + case 'volume': return ; + case 'attachments': return ; default: return null; } }; diff --git a/frontend/src/pages/FolderView.jsx b/frontend/src/pages/FolderView.jsx index da35f92..d980d33 100644 --- a/frontend/src/pages/FolderView.jsx +++ b/frontend/src/pages/FolderView.jsx @@ -2,7 +2,9 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useParams } from 'react-router-dom'; import { SearchApi } from '../api/client.js'; import EmailRow from '../components/EmailRow.jsx'; +import EmailDetail from '../components/EmailDetail.jsx'; import BulkToolbar from '../components/BulkToolbar.jsx'; +import { Skeleton, EmptyState } from '../components/ui'; import useSelection from '../hooks/useSelection.js'; import useListKeyboardNav from '../hooks/useListKeyboardNav.js'; @@ -35,6 +37,20 @@ const FOLDER_META = { const PAGE_SIZE = 50; +function ListSkeleton({ rows = 6 }) { + return ( + + ); +} + export default function FolderView() { const { slug } = useParams(); @@ -46,6 +62,7 @@ export default function FolderView() { const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [selectedEmail, setSelectedEmail] = useState(null); const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id))); @@ -56,6 +73,7 @@ export default function FolderView() { setPage(1); setHasMore(true); setError(null); + setSelectedEmail(null); clear(); }, [slug, clear]); @@ -115,34 +133,57 @@ export default function FolderView() { }} /> - {!loading && !error && emails.length === 0 && ( -
No emails in this folder.
- )} +
+
+ {loading && emails.length === 0 && !error && } - {emails.length > 0 && ( - - - {emails.map((e) => ( - setEmails((prev) => prev.filter((x) => x.id !== id))} - /> - ))} - -
- )} + {!loading && !error && emails.length === 0 && ( + + )} - {/* Sentinel — triggers next page load when scrolled into view */} -
+ {emails.length > 0 && ( + + + {emails.map((e) => ( + setSelectedEmail(email)} + onRemove={(id) => { + setEmails((prev) => prev.filter((x) => x.id !== id)); + setSelectedEmail((cur) => (cur?.id === id ? null : cur)); + }} + /> + ))} + +
+ )} - {loading &&
Loading…
} - {!hasMore && emails.length > 0 && ( -
— {emails.length.toLocaleString()} emails —
- )} + {/* Sentinel — triggers next page load when scrolled into view */} +
+ + {loading && emails.length > 0 &&
Loading…
} + {!hasMore && emails.length > 0 && ( +
— {emails.length.toLocaleString()} emails —
+ )} +
+ + {selectedEmail && ( + + )} +
); } diff --git a/frontend/src/pages/SearchResults.jsx b/frontend/src/pages/SearchResults.jsx index 31115f7..24d2e23 100644 --- a/frontend/src/pages/SearchResults.jsx +++ b/frontend/src/pages/SearchResults.jsx @@ -2,13 +2,29 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { SearchApi } from '../api/client.js'; import EmailRow from '../components/EmailRow.jsx'; +import EmailDetail from '../components/EmailDetail.jsx'; import BulkToolbar from '../components/BulkToolbar.jsx'; +import { Skeleton, EmptyState } from '../components/ui'; import useSelection from '../hooks/useSelection.js'; import useListKeyboardNav from '../hooks/useListKeyboardNav.js'; import useSavedSearches from '../hooks/useSavedSearches.js'; const PAGE_SIZE = 50; +function ListSkeleton({ rows = 6 }) { + return ( + + ); +} + export default function SearchResults() { const [searchParams] = useSearchParams(); @@ -20,6 +36,7 @@ export default function SearchResults() { const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [selectedEmail, setSelectedEmail] = useState(null); const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id))); const { searches: savedSearches, add: addSavedSearch } = useSavedSearches(); @@ -36,6 +53,7 @@ export default function SearchResults() { setPage(1); setHasMore(true); setError(null); + setSelectedEmail(null); clear(); }, [q, clear]); @@ -83,11 +101,13 @@ export default function SearchResults() { )}
- {!q.trim() &&
Enter a search query above.
} - {error &&
{error}
} - {!loading && !error && q.trim() && emails.length === 0 && !hasMore && ( -
No results for "{q}".
+ {!q.trim() && ( + )} + {error &&
{error}
} - {emails.length > 0 && ( - - - {emails.map((e) => ( - setEmails((prev) => prev.filter((x) => x.id !== id))} - /> - ))} - -
- )} + {q.trim() && ( +
+
+ {loading && emails.length === 0 && !error && } -
- {loading &&
Loading…
} - {!hasMore && emails.length > 0 && ( -
— {emails.length.toLocaleString()} results —
+ {!loading && !error && emails.length === 0 && !hasMore && ( + + )} + + {emails.length > 0 && ( + + + {emails.map((e) => ( + setSelectedEmail(email)} + onRemove={(id) => { + setEmails((prev) => prev.filter((x) => x.id !== id)); + setSelectedEmail((cur) => (cur?.id === id ? null : cur)); + }} + /> + ))} + +
+ )} + +
+ {loading && emails.length > 0 &&
Loading…
} + {!hasMore && emails.length > 0 && ( +
— {emails.length.toLocaleString()} results —
+ )} +
+ + {selectedEmail && ( + + )} +
)}
); diff --git a/frontend/src/split.css b/frontend/src/split.css new file mode 100644 index 0000000..2e8b70a --- /dev/null +++ b/frontend/src/split.css @@ -0,0 +1,148 @@ +/* ── Split-view reading pane ──────────────────────────────────────────────── + * Owned by Unit 1. Gmail/Outlook-style master-detail: the email list stays on + * the left, a collapsible + horizontally resizable reading pane sits on the + * right. Below 768px the pane overlays the list (mobile stack) instead of + * squishing the columns side-by-side. + * + * These pages (.folder-view) are styled from styles.css, so we reference its + * legacy CSS variables (--panel, --panel-2, --text, --muted, --accent) with + * hardcoded hex fallbacks. The modern index.css tokens are stored as raw HSL + * channel triplets and only work via hsl(var(--x)), so they are NOT used bare + * here. + * ------------------------------------------------------------------------- */ + +.sv-split { + display: flex; + align-items: stretch; + gap: 0; + width: 100%; +} + +/* Left column — the list. Flexes to fill remaining space and scrolls itself. */ +.sv-list-pane { + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; +} + +/* Right column — the reading pane. Resizable via the native handle; the width + * gives it a sensible default, and the user can drag the bottom-right corner. + * `resize: horizontal` needs `overflow` other than visible. */ +.sv-reading-pane { + flex: 0 0 auto; + width: clamp(320px, 42%, 640px); + min-width: 300px; + max-width: 80vw; + border-left: 1px solid var(--panel-2, #222a3d); + overflow: auto; + resize: horizontal; + background: var(--panel, #1a2030); + align-self: stretch; +} + +/* ── Reading pane inner content ─────────────────────────────────────────── */ +.sv-detail { padding: 18px 20px; } + +.sv-detail-topbar { + display: flex; + justify-content: flex-end; + margin-bottom: 6px; +} +.sv-close { + background: transparent; + border: 1px solid var(--panel-2, #222a3d); + color: var(--muted, #8b93a7); + border-radius: 6px; + width: 28px; + height: 28px; + font-size: 13px; + line-height: 1; + cursor: pointer; +} +.sv-close:hover { + background: var(--panel-2, #222a3d); + color: var(--text, #e6e9f0); +} + +.sv-detail-header { margin-bottom: 14px; } +.sv-detail-subject { + font-size: 18px; + font-weight: 700; + margin-bottom: 6px; + color: var(--text, #e6e9f0); + word-break: break-word; +} +.sv-detail-meta { + font-size: 12px; + color: var(--muted, #8b93a7); + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +} +.sv-detail-sep { opacity: 0.4; } + +.sv-ai-summary { margin-bottom: 14px; } +.sv-ai-summary-text { + background: var(--panel-2, #222a3d); + border: 1px solid var(--panel-2, #222a3d); + border-radius: 8px; + padding: 10px 14px; + font-size: 13px; + color: var(--text, #e6e9f0); +} + +.sv-body-loading { padding: 8px 0 18px; } +.sv-body { + background: var(--panel, #1a2030); + border: 1px solid var(--panel-2, #222a3d); + border-radius: 8px; + padding: 16px 18px; + font-size: 13px; + line-height: 1.7; + color: var(--text, #e6e9f0); + white-space: pre-wrap; + word-break: break-word; + margin-bottom: 18px; + max-height: 60vh; + overflow-y: auto; +} +.sv-snippet { + font-size: 13px; + line-height: 1.6; + color: var(--muted, #8b93a7); + margin-bottom: 18px; +} +.sv-detail-actions { display: flex; gap: 10px; flex-wrap: wrap; } + +/* ── List loading / empty states ───────────────────────────────────────── */ +.sv-skeleton-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 0; +} +.sv-skeleton-row { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 8px; +} + +/* ── Mobile: stack / overlay the reading pane ──────────────────────────── */ +@media (max-width: 767px) { + .sv-reading-pane { + position: fixed; + inset: 56px 0 0 0; /* below the topbar */ + width: 100% !important; + max-width: 100vw; + min-width: 0; + border-left: none; + resize: none; + z-index: 40; + } + /* When the pane is open, hide the underlying list to avoid double-scroll. */ + .sv-split.sv-split--open .sv-list-pane { + display: none; + } +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3696b82..072c0ad 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -117,8 +117,6 @@ button:disabled { opacity: 0.5; cursor: default; } .widget--link:hover { border-color: var(--accent); } .mini-row--link { cursor: pointer; } .mini-row--link:hover td { color: var(--accent); } -.chm-label--link { cursor: pointer; text-decoration: underline dotted; } -.chm-label--link:hover { color: var(--accent); } .widget canvas { flex: 1; min-height: 0; } .stat { align-items: flex-start; justify-content: center; } @@ -137,17 +135,21 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #332f2b; t .num { text-align: right; } .muted { color: var(--muted); font-size: 12px; } -.heatmap { display: flex; flex-direction: column; gap: 2px; } -.hm-row { display: flex; align-items: center; gap: 2px; } -.hm-day { width: 30px; font-size: 10px; color: var(--muted); } -.hm-cell { width: 10px; height: 10px; background: var(--accent); border-radius: 2px; } - -/* Category heatmap */ -.cat-heatmap { display: flex; flex-direction: column; gap: 3px; overflow: auto; } -.chm-row { display: grid; grid-template-columns: 92px repeat(7, 1fr); gap: 3px; align-items: stretch; } -.chm-head .chm-col { font-size: 10px; color: var(--muted); text-align: center; } -.chm-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.chm-cell { background: var(--accent); border-radius: 3px; min-height: 22px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #fff; } +/* Emails by Category — ranked horizontal bars */ +.cat-bars { display: flex; flex-direction: column; gap: 6px; overflow: auto; } +.cat-bar { + display: grid; grid-template-columns: 92px 1fr 40px; gap: 8px; align-items: center; + width: 100%; padding: 3px 4px; margin: 0; border: none; background: transparent; + border-radius: 6px; text-align: left; font: inherit; color: inherit; +} +.cat-bar--link { cursor: pointer; } +.cat-bar--link:hover { background: rgba(255, 255, 255, 0.04); } +.cat-bar:disabled { cursor: default; } +.cat-bar-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cat-bar-track { height: 14px; background: rgba(255, 255, 255, 0.06); border-radius: 4px; overflow: hidden; } +.cat-bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; min-width: 2px; } +.cat-bar-count { font-size: 11px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; } +.cat-bar--link:hover .cat-bar-label { color: var(--accent); } /* react-grid-layout resize handle — make it clearly visible on the dark theme */ .react-resizable-handle { @@ -296,32 +298,57 @@ input, select { background: var(--panel-2); border: 1px solid #332f2b; color: va .fv-error { color: var(--danger); font-size: 14px; padding: 12px 0; } .email-list { width: 100%; border-collapse: collapse; font-size: 13px; } -.email-row { border-bottom: 1px solid #332f2b; cursor: pointer; } +.email-row { + height: 44px; + border-bottom: 1px solid #332f2b; + cursor: pointer; + transition: background 0.1s ease; +} +.email-row > td { padding-top: 0; padding-bottom: 0; vertical-align: middle; } +/* Single, consistent hover state for the whole row. */ .email-row:hover { background: var(--panel); } -.email-row--unread .el-sender, +/* Unread: prominent subject, keep sender readable but not shouty. */ .email-row--unread .el-subj-text { font-weight: 700; color: var(--text); } +.email-row--unread .el-sender { font-weight: 600; color: var(--text); } -.el-unread { width: 14px; padding: 10px 4px 10px 0; } +.el-select { width: 34px; padding: 0 4px 0 10px; text-align: center; } +.el-select > * { vertical-align: middle; } + +.el-unread { width: 14px; padding: 0 4px 0 0; text-align: center; } .unread-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); } -.el-sender { width: 180px; padding: 10px 12px 10px 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--muted); } -.el-subject { padding: 10px 8px; overflow: hidden; } + +/* Sender: secondary in the hierarchy — muted by default. */ +.el-sender { + width: 180px; padding: 0 12px 0 4px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + color: var(--muted); font-size: 12.5px; +} + +/* Subject + snippet share one line: subject prominent, snippet muted. */ +.el-subject { padding: 0 8px; overflow: hidden; max-width: 0; white-space: nowrap; text-overflow: ellipsis; } .el-subj-text { color: var(--text); } -.el-snippet { color: var(--muted); } -.el-meta { width: 80px; padding: 10px 8px; text-align: right; white-space: nowrap; } +.el-snippet { + color: var(--muted); font-size: 12.5px; +} +.el-snippet::before { content: '—'; margin: 0 6px; opacity: 0.55; } + +.el-meta { width: 80px; padding: 0 8px; text-align: right; white-space: nowrap; } .el-attach { margin-right: 4px; font-size: 12px; } .el-size { font-size: 11px; color: var(--muted); } -.el-date { width: 70px; padding: 10px 0 10px 8px; text-align: right; color: var(--muted); white-space: nowrap; font-size: 12px; } +.el-date { width: 70px; padding: 0 0 0 8px; text-align: right; color: var(--muted); white-space: nowrap; font-size: 12px; } -.el-actions { width: 80px; padding: 0 6px; text-align: right; white-space: nowrap; } +.el-actions { width: 96px; padding: 0 6px; text-align: right; white-space: nowrap; } .action-btn { - background: none; border: none; padding: 3px 4px; cursor: pointer; - font-size: 13px; opacity: 0; transition: opacity 0.1s, color 0.1s; + background: none; border: none; padding: 4px 5px; cursor: pointer; + font-size: 13px; line-height: 1; opacity: 0; + transition: opacity 0.1s ease, color 0.1s ease, background 0.1s ease; border-radius: 4px; color: var(--muted); } -.action-btn:hover { background: var(--panel-2); opacity: 1 !important; } +.action-btn:hover { background: var(--panel-2); color: var(--text); opacity: 1 !important; } .action-btn--active { opacity: 1 !important; } .action-btn--danger:hover { color: var(--danger); } -.email-row:hover .action-btn { opacity: 0.6; } +.email-row:hover .action-btn { opacity: 0.65; } +.action-btn:focus-visible { opacity: 1 !important; outline: 2px solid var(--accent); outline-offset: 1px; } .email-row--acting { opacity: 0.6; pointer-events: none; } .action-btn--unsub { font-size: 11px; } .action-btn--done { opacity: 1 !important; color: var(--ok); } @@ -405,7 +432,6 @@ input, select { background: var(--panel-2); border: 1px solid #332f2b; color: va } .bulk-btn:hover { border-color: var(--accent); } .bulk-btn--danger:hover { border-color: var(--danger); color: var(--danger); } -.el-select { width: 28px; text-align: center; } /* ── Keyboard shortcut help ──────────────────────────────────────────────── */ .kbd-hint { position: fixed; bottom: 14px; right: 14px; font-size: 11px; color: var(--muted); background: var(--panel); border: 1px solid #332f2b; border-radius: 6px; padding: 4px 10px; opacity: 0.7; } -- 2.52.0