feat(emailclient): MD3 icons, softer dark mode, full header toolbar

- Replace all emoji with Material Symbols Outlined (self-hosted woff2)
- Softer dark mode palette (blue-purple surface tones instead of near-black)
- Header: burger menu, notifications bell with badge, theme switcher,
  language selector, settings, avatar
- Folder nav icons render via Material Symbols font ligatures
- Fix Dockerfile to copy public/ dir for font serving
- Improved padding and spacing throughout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 15:41:31 +00:00
parent 827387d807
commit 6fab12f10d
7 changed files with 274 additions and 61 deletions

View File

@@ -1,5 +1,6 @@
import React from 'react'
import { Box, BoxProps, Button, IconButton } from '../..'
import { MaterialIcon } from '../../../../icons/react/fakemui'
import { useAccessible } from '../../../../hooks/useAccessible'
import { EmailHeader } from '../data-display'
@@ -46,33 +47,29 @@ export const EmailDetail = ({
<Box className="email-detail" {...accessible} {...props}>
<Box className="email-detail-toolbar">
{onClose && (
<IconButton
aria-label="Back to list"
className="email-detail-back"
onClick={onClose}
>
<span></span>
<IconButton aria-label="Back to list" className="email-detail-back" onClick={onClose}>
<MaterialIcon name="arrow_back" size={20} />
</IconButton>
)}
<Box className="email-detail-actions">
{onArchive && (
<IconButton aria-label="Archive" title="Archive" onClick={onArchive}>
<span>📥</span>
<MaterialIcon name="archive" size={20} />
</IconButton>
)}
{onDelete && (
<IconButton aria-label="Delete" title="Delete" onClick={onDelete}>
<span>🗑</span>
<MaterialIcon name="delete" size={20} />
</IconButton>
)}
{onReply && (
<IconButton aria-label="Reply" title="Reply" onClick={onReply}>
<span></span>
<MaterialIcon name="reply" size={20} />
</IconButton>
)}
{onForward && (
<IconButton aria-label="Forward" title="Forward" onClick={onForward}>
<span></span>
<MaterialIcon name="forward" size={20} />
</IconButton>
)}
</Box>
@@ -95,12 +92,14 @@ export const EmailDetail = ({
<Box className="email-detail-reply-bar">
{onReply && (
<Button variant="outlined" className="email-detail-reply-btn" onClick={onReply}>
Reply
<MaterialIcon name="reply" size={16} />
Reply
</Button>
)}
{onForward && (
<Button variant="outlined" className="email-detail-reply-btn" onClick={onForward}>
Forward
<MaterialIcon name="forward" size={16} />
Forward
</Button>
)}
</Box>

View File

@@ -1,26 +1,39 @@
import React from 'react'
import { Box, BoxProps, IconButton, Typography } from '../..'
import { Box, BoxProps, IconButton } from '../..'
import { MaterialIcon } from '../../../../icons/react/fakemui'
import { useAccessible } from '../../../../hooks/useAccessible'
export interface MailboxHeaderProps extends BoxProps {
appName?: string
appIcon?: string
searchQuery?: string
onSearchChange?: (query: string) => void
searchPlaceholder?: string
avatarLabel?: string
isDarkMode?: boolean
onToggleTheme?: () => void
onMenuClick?: () => void
onSettingsClick?: () => void
onAlertsClick?: () => void
alertCount?: number
locale?: string
onCycleLocale?: () => void
testId?: string
}
export const MailboxHeader = ({
appName = 'MetaMail',
appIcon = '📧',
searchQuery = '',
onSearchChange,
searchPlaceholder = 'Search mail',
avatarLabel = 'U',
isDarkMode = false,
onToggleTheme,
onMenuClick,
onSettingsClick,
onAlertsClick,
alertCount = 0,
locale = 'EN',
onCycleLocale,
testId: customTestId,
...props
}: MailboxHeaderProps) => {
@@ -32,13 +45,20 @@ export const MailboxHeader = ({
return (
<Box className="mailbox-header-bar" {...accessible} {...props}>
<Box className="mailbox-header-brand">
<span className="mailbox-header-icon">{appIcon}</span>
<Typography variant="h6" className="mailbox-header-title">
{appName}
</Typography>
<Box className="mailbox-header-left">
{onMenuClick && (
<IconButton aria-label="Menu" title="Menu" onClick={onMenuClick} className="mailbox-header-icon-btn">
<MaterialIcon name="menu" size={22} />
</IconButton>
)}
<Box className="mailbox-header-brand">
<MaterialIcon name="mail" size={26} className="mailbox-header-logo" />
<span className="mailbox-header-title">{appName}</span>
</Box>
</Box>
<Box className="mailbox-header-search">
<MaterialIcon name="search" size={20} className="mailbox-search-icon" />
<input
type="search"
className="mailbox-search-input"
@@ -48,10 +68,35 @@ export const MailboxHeader = ({
aria-label={searchPlaceholder}
/>
</Box>
<Box className="mailbox-header-actions">
{onAlertsClick && (
<Box className="mailbox-header-alert-wrapper">
<IconButton aria-label="Notifications" title="Notifications" onClick={onAlertsClick} className="mailbox-header-icon-btn">
<MaterialIcon name="notifications" size={20} />
</IconButton>
{alertCount > 0 && <span className="mailbox-header-badge">{alertCount}</span>}
</Box>
)}
{onToggleTheme && (
<IconButton
aria-label={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDarkMode ? 'Light mode' : 'Dark mode'}
onClick={onToggleTheme}
className="mailbox-header-icon-btn"
>
<MaterialIcon name={isDarkMode ? 'light_mode' : 'dark_mode'} size={20} />
</IconButton>
)}
{onCycleLocale && (
<button className="mailbox-header-lang-btn" onClick={onCycleLocale} aria-label="Change language" title="Change language">
<MaterialIcon name="language" size={16} />
<span>{locale}</span>
</button>
)}
{onSettingsClick && (
<IconButton aria-label="Settings" title="Settings" onClick={onSettingsClick}>
<span className="mailbox-header-action-icon"></span>
<IconButton aria-label="Settings" title="Settings" onClick={onSettingsClick} className="mailbox-header-icon-btn">
<MaterialIcon name="settings" size={20} />
</IconButton>
)}
<Box className="mailbox-header-avatar" aria-label="Account">

View File

@@ -1,5 +1,6 @@
import React from 'react'
import { Box, BoxProps } from '../..'
import { MaterialIcon } from '../../../../icons/react/fakemui'
import { useAccessible } from '../../../../hooks/useAccessible'
import { FolderNavigation, type FolderNavigationItem } from '../navigation'
@@ -30,7 +31,8 @@ export const MailboxSidebar = ({
{onCompose && (
<Box className="mailbox-sidebar-compose">
<button className="compose-btn" onClick={onCompose}>
{composeLabel}
<MaterialIcon name="edit" size={20} />
<span>{composeLabel}</span>
</button>
</Box>
)}

View File

@@ -66,6 +66,7 @@ WORKDIR /app
COPY --from=builder /app/frontends/emailclient/.next/standalone ./
COPY --from=builder /app/frontends/emailclient/.next/static ./frontends/emailclient/.next/static
COPY --from=builder /app/frontends/emailclient/public ./frontends/emailclient/public
ENV NODE_ENV=production
ENV PORT=3000

View File

@@ -17,12 +17,12 @@ import { Box, Typography } from '@metabuilder/fakemui'
// ─────────────────────────────────────────────────────────────────────────────
const DEMO_FOLDERS: FolderNavigationItem[] = [
{ id: 'inbox', label: 'Inbox', icon: '📥', unreadCount: 3, isActive: true },
{ id: 'starred', label: 'Starred', icon: '' },
{ id: 'sent', label: 'Sent', icon: '📤' },
{ id: 'drafts', label: 'Drafts', icon: '📝', unreadCount: 1 },
{ id: 'spam', label: 'Spam', icon: '⚠️' },
{ id: 'trash', label: 'Trash', icon: '🗑️' },
{ id: 'inbox', label: 'Inbox', icon: 'inbox', unreadCount: 3, isActive: true },
{ id: 'starred', label: 'Starred', icon: 'star' },
{ id: 'sent', label: 'Sent', icon: 'send' },
{ id: 'drafts', label: 'Drafts', icon: 'drafts', unreadCount: 1 },
{ id: 'spam', label: 'Spam', icon: 'report' },
{ id: 'trash', label: 'Trash', icon: 'delete' },
]
const now = Date.now()
@@ -115,6 +115,8 @@ export default function EmailClientContent() {
const [emails, setEmails] = useState(DEMO_EMAILS)
const [showCompose, setShowCompose] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [isDarkMode, setIsDarkMode] = useState(true)
const [sidebarOpen, setSidebarOpen] = useState(true)
const folders = DEMO_FOLDERS.map(f => ({
...f,
@@ -181,7 +183,17 @@ export default function EmailClientContent() {
<MailboxHeader
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
isDarkMode={isDarkMode}
onToggleTheme={() => {
setIsDarkMode(prev => !prev)
document.documentElement.setAttribute('data-theme', isDarkMode ? 'light' : 'dark')
}}
onMenuClick={() => setSidebarOpen(prev => !prev)}
onSettingsClick={() => {}}
onAlertsClick={() => {}}
alertCount={unreadCount}
locale="EN"
onCycleLocale={() => {}}
/>
)

View File

@@ -3,6 +3,36 @@
/* Uses FakeMUI M3 token system */
/* ============================================ */
/* ============================================ */
/* Material Symbols (self-hosted) */
/* ============================================ */
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 100 700;
font-display: block;
src: url('/emailclient/fonts/material-symbols-outlined.woff2') format('woff2');
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
font-feature-settings: 'liga' 1;
-webkit-font-feature-settings: 'liga' 1;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
@@ -74,10 +104,10 @@
}
/* ============================================ */
/* Dark Theme */
/* Dark Theme (softer, less jarring) */
/* ============================================ */
@media (prefers-color-scheme: dark) {
:root {
:root:not([data-theme="light"]) {
--mat-sys-primary: #d0bcff;
--mat-sys-on-primary: #381e72;
--mat-sys-primary-container: #4f378b;
@@ -87,27 +117,76 @@
--mat-sys-secondary-container: #4a4458;
--mat-sys-error: #f2b8b5;
--mat-sys-on-error: #601410;
--mat-sys-surface: #141218;
--mat-sys-on-surface: #e6e0e9;
--mat-sys-surface-variant: #49454f;
--mat-sys-on-surface-variant: #cac4d0;
--mat-sys-surface-container-highest: #36343b;
--mat-sys-surface-container-high: #2b2930;
--mat-sys-surface-container: #211f26;
--mat-sys-surface-container-low: #1d1b20;
--mat-sys-surface-container-lowest: #0f0d13;
--mat-sys-surface-dim: #141218;
--mat-sys-surface-bright: #3b383e;
--mat-sys-background: #141218;
--mat-sys-on-background: #e6e0e9;
--mat-sys-outline: #938f99;
--mat-sys-outline-variant: #49454f;
--mat-sys-inverse-surface: #e6e0e9;
--mat-sys-inverse-on-surface: #322f35;
--mat-sys-surface: #1e1e2e;
--mat-sys-on-surface: #cdd6f4;
--mat-sys-surface-variant: #45475a;
--mat-sys-on-surface-variant: #bac2de;
--mat-sys-surface-container-highest: #45475a;
--mat-sys-surface-container-high: #363849;
--mat-sys-surface-container: #2a2b3d;
--mat-sys-surface-container-low: #232434;
--mat-sys-surface-container-lowest: #1a1b2e;
--mat-sys-surface-dim: #1e1e2e;
--mat-sys-surface-bright: #45475a;
--mat-sys-background: #1e1e2e;
--mat-sys-on-background: #cdd6f4;
--mat-sys-outline: #6c7086;
--mat-sys-outline-variant: #45475a;
--mat-sys-inverse-surface: #cdd6f4;
--mat-sys-inverse-on-surface: #1e1e2e;
--mat-sys-inverse-primary: #6750a4;
}
}
/* Manual theme toggle */
:root[data-theme="dark"] {
--mat-sys-primary: #d0bcff;
--mat-sys-on-primary: #381e72;
--mat-sys-primary-container: #4f378b;
--mat-sys-on-primary-container: #eaddff;
--mat-sys-secondary: #ccc2dc;
--mat-sys-on-secondary: #332d41;
--mat-sys-secondary-container: #4a4458;
--mat-sys-error: #f2b8b5;
--mat-sys-on-error: #601410;
--mat-sys-surface: #1e1e2e;
--mat-sys-on-surface: #cdd6f4;
--mat-sys-surface-variant: #45475a;
--mat-sys-on-surface-variant: #bac2de;
--mat-sys-surface-container-highest: #45475a;
--mat-sys-surface-container-high: #363849;
--mat-sys-surface-container: #2a2b3d;
--mat-sys-surface-container-low: #232434;
--mat-sys-surface-container-lowest: #1a1b2e;
--mat-sys-surface-dim: #1e1e2e;
--mat-sys-surface-bright: #45475a;
--mat-sys-background: #1e1e2e;
--mat-sys-on-background: #cdd6f4;
--mat-sys-outline: #6c7086;
--mat-sys-outline-variant: #45475a;
--mat-sys-inverse-surface: #cdd6f4;
--mat-sys-inverse-on-surface: #1e1e2e;
--mat-sys-inverse-primary: #6750a4;
}
:root[data-theme="light"] {
--mat-sys-primary: #6750a4;
--mat-sys-on-primary: #ffffff;
--mat-sys-surface: #fef7ff;
--mat-sys-on-surface: #1d1b20;
--mat-sys-surface-variant: #e7e0ec;
--mat-sys-on-surface-variant: #49454f;
--mat-sys-surface-container-highest: #e6e0e9;
--mat-sys-surface-container-high: #ece6f0;
--mat-sys-surface-container: #f3edf7;
--mat-sys-surface-container-low: #f7f2fa;
--mat-sys-surface-container-lowest: #ffffff;
--mat-sys-background: #fef7ff;
--mat-sys-on-background: #1d1b20;
--mat-sys-outline: #79747e;
--mat-sys-outline-variant: #cac4d0;
}
/* ============================================ */
/* Base Styles */
/* ============================================ */
@@ -243,10 +322,13 @@ input, textarea, select {
}
.folder-nav-item .folder-icon {
font-size: 1.125rem;
font-family: 'Material Symbols Outlined';
font-size: 20px;
width: 24px;
text-align: center;
flex-shrink: 0;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
-webkit-font-smoothing: antialiased;
}
.folder-nav-item .folder-label {
@@ -281,44 +363,64 @@ input, textarea, select {
.mailbox-header-bar {
display: flex;
align-items: center;
gap: 12px;
gap: 8px;
width: 100%;
padding: 0 8px;
}
.mailbox-header-left {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 200px;
}
.mailbox-header-brand {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
flex-shrink: 0;
}
.mailbox-header-icon {
font-size: 24px;
.mailbox-header-logo {
color: var(--mat-sys-primary);
}
.mailbox-header-title {
font-weight: 600 !important;
font-size: 1.25rem !important;
font-weight: 600;
font-size: 1.125rem;
color: var(--mat-sys-on-surface);
white-space: nowrap;
}
.mailbox-header-search {
flex: 1;
max-width: 680px;
margin: 0 auto;
position: relative;
}
.mailbox-search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--mat-sys-on-surface-variant);
pointer-events: none;
}
.mailbox-search-input {
width: 100%;
padding: 10px 16px;
padding: 8px 16px 8px 44px;
border-radius: var(--mat-sys-corner-extra-large);
border: none;
background-color: var(--mat-sys-surface-container-high);
color: var(--mat-sys-on-surface);
font-size: 0.9375rem;
font-size: 0.875rem;
outline: none;
transition: background-color var(--mat-sys-motion-duration-short4) var(--mat-sys-motion-easing-standard);
transition: background-color var(--mat-sys-motion-duration-short4) var(--mat-sys-motion-easing-standard),
box-shadow var(--mat-sys-motion-duration-short4) var(--mat-sys-motion-easing-standard);
}
.mailbox-search-input:focus {
@@ -333,12 +435,63 @@ input, textarea, select {
.mailbox-header-actions {
display: flex;
align-items: center;
gap: 4px;
gap: 2px;
flex-shrink: 0;
}
.mailbox-header-action-icon {
font-size: 20px;
.mailbox-header-icon-btn {
color: var(--mat-sys-on-surface-variant) !important;
width: 36px !important;
height: 36px !important;
border-radius: var(--mat-sys-corner-full) !important;
transition: background-color var(--mat-sys-motion-duration-short4) !important;
}
.mailbox-header-icon-btn:hover {
background-color: var(--mat-sys-surface-container-high) !important;
}
.mailbox-header-alert-wrapper {
position: relative;
display: inline-flex;
}
.mailbox-header-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
border-radius: var(--mat-sys-corner-full);
background-color: var(--mat-sys-error);
color: var(--mat-sys-on-error);
font-size: 0.625rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
pointer-events: none;
}
.mailbox-header-lang-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
border-radius: var(--mat-sys-corner-full);
background: none;
border: none;
color: var(--mat-sys-on-surface-variant);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: background-color var(--mat-sys-motion-duration-short4);
letter-spacing: 0.04em;
}
.mailbox-header-lang-btn:hover {
background-color: var(--mat-sys-surface-container-high);
}
.mailbox-header-avatar {
@@ -353,6 +506,7 @@ input, textarea, select {
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
margin-left: 4px;
}
/* ============================================ */