feat: Gmail-style email block with inbox container layout (#531)

* feat: restyle email block with Gmail-style layout and avatar

* style: apply Google Sans/Roboto font to email block

* feat: add Gmail inbox-style multi-email block with accordion rows

* style: fix sender name casing, weight, and email display in expanded view

* feat: emails inbox block with container layout, two-line rows, Gmail title style
This commit is contained in:
gagan 2026-05-06 21:41:26 +05:30 committed by GitHub
parent 3630032d21
commit 0e3d058c29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 899 additions and 363 deletions

View file

@ -20,7 +20,7 @@ import { IframeBlockExtension } from '@/extensions/iframe-block'
import { ChartBlockExtension } from '@/extensions/chart-block'
import { TableBlockExtension } from '@/extensions/table-block'
import { CalendarBlockExtension } from '@/extensions/calendar-block'
import { EmailBlockExtension } from '@/extensions/email-block'
import { EmailBlockExtension, EmailsBlockExtension } from '@/extensions/email-block'
import { TranscriptBlockExtension } from '@/extensions/transcript-block'
import { MermaidBlockExtension } from '@/extensions/mermaid-block'
import { Markdown } from 'tiptap-markdown'
@ -707,6 +707,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
ChartBlockExtension,
TableBlockExtension,
CalendarBlockExtension,
EmailsBlockExtension,
EmailBlockExtension,
TranscriptBlockExtension,
MermaidBlockExtension,

View file

@ -1,6 +1,6 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, Mail, ChevronDown, ExternalLink, Copy, Check, MessageSquare } from 'lucide-react'
import { X, ExternalLink, Copy, Check, MessageSquare, ChevronDown } from 'lucide-react'
import { blocks } from '@x/shared'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTheme } from '@/contexts/theme-context'
@ -11,17 +11,47 @@ function formatEmailDate(dateStr: string): string {
try {
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) +
' ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
const now = new Date()
const isToday = d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()
if (isToday) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
} catch {
return dateStr
}
}
/** Extract just the name part from "Name <email>" format */
function senderFirstName(from: string): string {
const name = from.replace(/<.*>/, '').trim()
return name.split(/\s+/)[0] || name
function formatFullDate(dateStr: string): string {
try {
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) +
', ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
} catch {
return dateStr
}
}
function extractName(from: string): string {
const match = from.match(/^([^<]+)</)
if (match) return match[1].trim()
const username = from.replace(/@.*/, '').replace(/[._+]/g, ' ').trim()
return username.replace(/\b\w/g, c => c.toUpperCase())
}
function getInitial(from: string): string {
const name = extractName(from)
return (name[0] || '?').toUpperCase()
}
const GMAIL_AVATAR_COLORS = [
'#1a73e8', '#e8453c', '#34a853', '#8430ce', '#f29900',
'#00796b', '#c62828', '#1565c0', '#6a1b9a', '#2e7d32',
]
function avatarColor(from: string): string {
let hash = 0
for (let i = 0; i < from.length; i++) hash = (hash * 31 + from.charCodeAt(i)) >>> 0
return GMAIL_AVATAR_COLORS[hash % GMAIL_AVATAR_COLORS.length]
}
declare global {
@ -30,7 +60,308 @@ declare global {
}
}
// --- Email Block ---
// --- Shared: expanded email body used by both block types ---
function EmailExpandedBody({
config,
resolvedTheme,
}: {
config: blocks.EmailBlock
resolvedTheme: string
}) {
const [draftBody, setDraftBody] = useState(config.draft_response || '')
const [copied, setCopied] = useState(false)
const bodyRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
setDraftBody(config.draft_response || '')
}, [config.draft_response])
useEffect(() => {
if (bodyRef.current) {
bodyRef.current.style.height = 'auto'
bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px'
}
}, [draftBody])
const draftWithAssistant = useCallback(() => {
let prompt = draftBody
? `Help me refine this draft response to an email`
: `Help me draft a response to this email`
if (config.threadId) {
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
}
prompt += `.\n\n**From:** ${config.from || 'Unknown'}\n**Subject:** ${config.subject || 'No subject'}\n`
if (draftBody) prompt += `\n**Current draft:**\n${draftBody}\n`
window.__pendingEmailDraft = { prompt }
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
}, [config, draftBody])
const copyDraft = useCallback(() => {
navigator.clipboard.writeText(draftBody).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}).catch(() => {
const el = document.createElement('textarea')
el.value = draftBody
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}, [draftBody])
const gmailUrl = config.threadId
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
: null
const senderName = config.from ? extractName(config.from) : 'Unknown'
const initial = config.from ? getInitial(config.from) : '?'
const color = config.from ? avatarColor(config.from) : '#5f6368'
const hasDraft = !!config.draft_response
return (
<div className="email-gmail-expanded">
{config.subject && (
<div className="email-gmail-exp-subject">{config.subject}</div>
)}
<div className="email-gmail-exp-meta">
<div className="email-gmail-exp-avatar" style={{ backgroundColor: color }}>{initial}</div>
<div className="email-gmail-exp-meta-right">
<div className="email-gmail-exp-sender">{config.from || 'Unknown'}</div>
<div className="email-gmail-exp-to-date">
{config.to && <span>to {config.to}</span>}
{config.date && <span className="email-gmail-exp-fulldate">{formatFullDate(config.date)}</span>}
</div>
</div>
</div>
<div className="email-gmail-exp-body">{config.latest_email}</div>
{config.past_summary && (
<div className="email-gmail-exp-history">
<div className="email-gmail-exp-history-label">Earlier conversation</div>
<div className="email-gmail-exp-history-body">{config.past_summary}</div>
</div>
)}
{!hasDraft && (
<div className="email-gmail-reply-row">
{gmailUrl && (
<button
className="email-gmail-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
>
<ExternalLink size={13} />
Open in Gmail
</button>
)}
<button
className="email-gmail-btn email-gmail-btn-primary email-gmail-reply-row-end"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
>
<MessageSquare size={13} />
Draft with Rowboat
</button>
</div>
)}
{hasDraft && (
<div className="email-gmail-compose">
<div className="email-gmail-compose-to">
<span className="email-gmail-compose-to-label">Reply</span>
{config.from && <span className="email-gmail-compose-to-addr">{config.from}</span>}
</div>
<textarea
key={resolvedTheme}
ref={bodyRef}
className="email-gmail-compose-body"
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
placeholder="Write your reply..."
rows={3}
/>
<div className="email-gmail-compose-footer">
<button
className="email-gmail-btn email-gmail-btn-primary"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); draftWithAssistant() }}
>
<MessageSquare size={13} />
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
</button>
<button
className="email-gmail-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); copyDraft() }}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? 'Copied!' : 'Copy draft'}
</button>
{gmailUrl && (
<button
className="email-gmail-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); window.open(gmailUrl, '_blank') }}
>
<ExternalLink size={13} />
Open in Gmail
</button>
)}
</div>
</div>
)}
</div>
)
}
// --- Multi-email inbox block (language-emails) ---
function EmailsBlockView({ node, deleteNode }: {
node: { attrs: Record<string, unknown> }
deleteNode: () => void
}) {
const raw = node.attrs.data as string
let config: blocks.EmailsBlock | null = null
try {
config = blocks.EmailsBlockSchema.parse(JSON.parse(raw))
} catch { /* fallback below */ }
const { resolvedTheme } = useTheme()
const [expandedIndex, setExpandedIndex] = useState<number | null>(null)
if (!config || config.emails.length === 0) {
return (
<NodeViewWrapper className="email-block-wrapper" data-type="emails-block">
<div className="email-block-card email-block-error"><span>Invalid emails block</span></div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper className="email-block-wrapper" data-type="emails-block">
<div className="email-block-card email-inbox-card" onMouseDown={(e) => e.stopPropagation()}>
<button className="email-block-delete" onClick={deleteNode} aria-label="Remove block"><X size={14} /></button>
{config.title && (
<div className="email-inbox-title">{config.title}</div>
)}
<div className="email-inbox-list">
{config.emails.map((email, i) => {
const isExpanded = expandedIndex === i
const senderName = email.from ? extractName(email.from) : 'Unknown'
const initial = email.from ? getInitial(email.from) : '?'
const color = email.from ? avatarColor(email.from) : '#5f6368'
const snippet = email.summary
|| (email.latest_email ? email.latest_email.slice(0, 100).replace(/\s+/g, ' ').trim() : '')
return (
<div key={i} className={`email-inbox-row${isExpanded ? ' email-inbox-row-expanded' : ''}`}>
{/* Collapsed row */}
<div
className="email-inbox-row-header"
onClick={(e) => { e.stopPropagation(); setExpandedIndex(isExpanded ? null : i) }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="email-inbox-avatar" style={{ backgroundColor: color }}>{initial}</div>
<div className="email-inbox-content">
<div className="email-inbox-top-row">
<span className="email-inbox-sender">{senderName}</span>
{email.date && <span className="email-inbox-date">{formatEmailDate(email.date)}</span>}
</div>
<div className="email-inbox-bottom-row">
{email.subject && <span className="email-inbox-subject">{email.subject}</span>}
{snippet && (
<span className="email-inbox-snippet">
{email.subject ? `${snippet}` : snippet}
</span>
)}
</div>
</div>
<ChevronDown
size={14}
className={`email-inbox-chevron${isExpanded ? ' email-inbox-chevron-open' : ''}`}
/>
</div>
{/* Expanded content */}
{isExpanded && (
<div className="email-inbox-expanded-wrap">
<EmailExpandedBody
config={email}
resolvedTheme={resolvedTheme}
/>
</div>
)}
</div>
)
})}
</div>
</div>
</NodeViewWrapper>
)
}
export const EmailsBlockExtension = Node.create({
name: 'emailsBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return { data: { default: '{}' } }
},
parseHTML() {
return [{
tag: 'pre',
priority: 61,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
if ((code.className || '').includes('language-emails')) {
return { data: code.textContent || '{}' }
}
return false
},
}]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'emails-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(EmailsBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```emails\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {},
},
}
},
})
// --- Single email block (language-email, backward compat) ---
function EmailBlockView({ node, deleteNode, updateAttributes }: {
node: { attrs: Record<string, unknown> }
@ -42,194 +373,57 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
try {
config = blocks.EmailBlockSchema.parse(JSON.parse(raw))
} catch {
// fallback below
}
const hasDraft = !!config?.draft_response
const hasPastSummary = !!config?.past_summary
} catch { /* fallback below */ }
const { resolvedTheme } = useTheme()
const [expanded, setExpanded] = useState(false)
// Local draft state for editing
const [draftBody, setDraftBody] = useState(config?.draft_response || '')
const [emailExpanded, setEmailExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const bodyRef = useRef<HTMLTextAreaElement>(null)
// Sync draft from external changes
useEffect(() => {
try {
const parsed = blocks.EmailBlockSchema.parse(JSON.parse(raw))
setDraftBody(parsed.draft_response || '')
} catch { /* ignore */ }
}, [raw])
// Auto-resize textarea
useEffect(() => {
if (bodyRef.current) {
bodyRef.current.style.height = 'auto'
bodyRef.current.style.height = bodyRef.current.scrollHeight + 'px'
}
}, [draftBody])
const commitDraft = useCallback((newBody: string) => {
try {
const current = JSON.parse(raw) as Record<string, unknown>
updateAttributes({ data: JSON.stringify({ ...current, draft_response: newBody }) })
} catch { /* ignore */ }
}, [raw, updateAttributes])
const draftWithAssistant = useCallback(() => {
if (!config) return
let prompt = draftBody
? `Help me refine this draft response to an email`
: `Help me draft a response to this email`
if (config.threadId) {
prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context`
}
prompt += `.\n\n`
prompt += `**From:** ${config.from || 'Unknown'}\n`
prompt += `**Subject:** ${config.subject || 'No subject'}\n`
if (draftBody) {
prompt += `\n**Current draft:**\n${draftBody}\n`
}
window.__pendingEmailDraft = { prompt }
window.dispatchEvent(new Event('email-block:draft-with-assistant'))
}, [config, draftBody])
void updateAttributes // available for future per-email draft persistence
if (!config) {
return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
<div className="email-block-card email-block-error">
<Mail size={16} />
<span>Invalid email block</span>
</div>
<div className="email-block-card email-block-error"><span>Invalid email block</span></div>
</NodeViewWrapper>
)
}
const gmailUrl = config.threadId
? `https://mail.google.com/mail/u/0/#all/${config.threadId}`
: null
// Build summary: use explicit summary, or auto-generate from sender + subject
const summary = config.summary
|| (config.from && config.subject
? `${senderFirstName(config.from)} reached out about ${config.subject}`
: config.subject || 'New email')
const senderName = config.from ? extractName(config.from) : 'Unknown'
const initial = config.from ? getInitial(config.from) : '?'
const color = config.from ? avatarColor(config.from) : '#5f6368'
const snippet = config.summary
|| (config.latest_email ? config.latest_email.slice(0, 120).replace(/\s+/g, ' ').trim() : '')
return (
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
<div className="email-block-card email-block-card-gmail" onMouseDown={(e) => e.stopPropagation()}>
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block">
<X size={14} />
</button>
<button className="email-block-delete" onClick={deleteNode} aria-label="Delete email block"><X size={14} /></button>
{/* Header: Email badge */}
<div className="email-block-badge">
<Mail size={13} />
Email
</div>
{/* Summary */}
<div className="email-block-summary">{summary}</div>
{/* Expandable email details */}
<button
className="email-block-expand-btn"
onClick={(e) => { e.stopPropagation(); setEmailExpanded(!emailExpanded) }}
<div
className={`email-gmail-row${expanded ? ' email-gmail-row-expanded' : ''}`}
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }}
onMouseDown={(e) => e.stopPropagation()}
>
<ChevronDown size={13} className={`email-block-toggle-chevron ${emailExpanded ? 'email-block-toggle-chevron-open' : ''}`} />
{emailExpanded ? 'Hide email' : 'Show email'}
{config.from && <span className="email-block-expand-meta">· From {senderFirstName(config.from)}</span>}
{config.date && <span className="email-block-expand-meta">· {formatEmailDate(config.date)}</span>}
</button>
{emailExpanded && (
<div className="email-block-email-details">
<div className="email-block-message">
<div className="email-block-message-header">
<div className="email-block-sender-info">
<div className="email-block-sender-row">
<div className="email-block-sender-name">{config.from || 'Unknown'}</div>
{config.date && <div className="email-block-sender-date">{formatEmailDate(config.date)}</div>}
</div>
{config.subject && <div className="email-block-subject-line">Subject: {config.subject}</div>}
</div>
</div>
<div className="email-block-message-body">{config.latest_email}</div>
<div className="email-gmail-avatar" style={{ backgroundColor: color }} aria-hidden="true">{initial}</div>
<div className="email-gmail-content">
<div className="email-gmail-top-row">
<span className="email-gmail-sender">{senderName}</span>
{config.date && <span className="email-gmail-date">{formatEmailDate(config.date)}</span>}
</div>
<div className="email-gmail-bottom-row">
{config.subject && <span className="email-gmail-subject">{config.subject}</span>}
{snippet && <span className="email-gmail-snippet">{config.subject ? `${snippet}` : snippet}</span>}
</div>
{hasPastSummary && (
<div className="email-block-context-section">
<div className="email-block-context-label">Earlier conversation</div>
<div className="email-block-context-summary">{config.past_summary}</div>
</div>
)}
</div>
)}
{/* Draft section */}
{hasDraft && (
<div className="email-block-draft-section">
<div className="email-block-draft-label">Draft reply</div>
<textarea
key={resolvedTheme}
ref={bodyRef}
className="email-draft-block-body-input"
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
onBlur={() => commitDraft(draftBody)}
placeholder="Write your reply..."
rows={3}
/>
</div>
)}
{/* Action buttons */}
<div className="email-block-actions">
<button
className="email-block-gmail-btn email-block-gmail-btn-primary"
onClick={draftWithAssistant}
>
<MessageSquare size={13} />
{hasDraft ? 'Refine with Rowboat' : 'Draft with Rowboat'}
</button>
{hasDraft && (
<button
className="email-block-gmail-btn email-block-gmail-btn-primary"
onClick={() => {
navigator.clipboard.writeText(draftBody).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}).catch(() => {
// Fallback for Electron contexts where clipboard API may fail
const textarea = document.createElement('textarea')
textarea.value = draftBody
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? 'Copied!' : 'Copy draft'}
</button>
)}
{gmailUrl && (
<button
className="email-block-gmail-btn"
onClick={() => window.open(gmailUrl, '_blank')}
>
<ExternalLink size={13} />
Open in Gmail
</button>
)}
<ChevronDown size={15} className={`email-gmail-chevron${expanded ? ' email-gmail-chevron-open' : ''}`} />
</div>
{expanded && (
<EmailExpandedBody
config={config}
resolvedTheme={resolvedTheme}
/>
)}
</div>
</NodeViewWrapper>
)
@ -243,9 +437,7 @@ export const EmailBlockExtension = Node.create({
draggable: false,
addAttributes() {
return {
data: { default: '{}' },
}
return { data: { default: '{}' } }
},
parseHTML() {
@ -256,7 +448,7 @@ export const EmailBlockExtension = Node.create({
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-email') && !cls.includes('language-emailDraft')) {
if (cls.includes('language-email') && !cls.includes('language-emailDraft') && !cls.includes('language-emails')) {
return { data: code.textContent || '{}' }
}
return false

View file

@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap');
/* Tiptap Editor Styles */
.tiptap-editor {
@ -820,6 +822,49 @@
margin: 8px 0;
}
/* Consecutive email blocks — zero gap, shared outer border */
/* Kill margins between adjacent email wrappers */
.tiptap-editor .ProseMirror .email-block-wrapper:has(+ .email-block-wrapper) {
margin-bottom: 0;
}
.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper {
margin-top: 0;
}
/* Strip card border/radius from every card inside a sequence */
.tiptap-editor .ProseMirror .email-block-wrapper:has(+ .email-block-wrapper) .email-block-card,
.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper .email-block-card {
border-radius: 0;
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
}
/* First in group: restore top border + top radius */
.tiptap-editor .ProseMirror .email-block-wrapper:not(.email-block-wrapper + .email-block-wrapper):has(+ .email-block-wrapper) .email-block-card {
border-top: 1px solid var(--border);
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
/* Last in group: restore bottom border + bottom radius, remove hairline */
.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper:not(:has(+ .email-block-wrapper)) .email-block-card {
border-bottom: 1px solid var(--border);
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
/* Middle cards: just left + right borders */
.tiptap-editor .ProseMirror .email-block-wrapper + .email-block-wrapper:has(+ .email-block-wrapper) .email-block-card {
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
}
.tiptap-editor .ProseMirror .image-block-card,
.tiptap-editor .ProseMirror .embed-block-card,
.tiptap-editor .ProseMirror .iframe-block-card,
@ -1422,141 +1467,209 @@
/* Email block Gmail style */
.tiptap-editor .ProseMirror .email-block-card-gmail {
background-color: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 1px rgba(0, 0, 0, 0.06);
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
}
.tiptap-editor .ProseMirror .email-block-card-gmail:hover {
background-color: var(--background);
}
/* Email badge */
.tiptap-editor .ProseMirror .email-block-badge {
display: inline-flex;
/* Gmail-style two-column row */
.tiptap-editor .ProseMirror .email-gmail-row {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
margin-bottom: 8px;
gap: 12px;
cursor: pointer;
padding: 2px 0;
border-radius: 4px;
transition: background 0.1s ease;
user-select: none;
}
/* Summary */
.tiptap-editor .ProseMirror .email-block-summary {
.tiptap-editor .ProseMirror .email-gmail-row:hover {
background: color-mix(in srgb, var(--foreground) 4%, transparent);
}
.tiptap-editor .ProseMirror .email-gmail-row.email-gmail-row-expanded {
padding-bottom: 12px;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
margin-bottom: 2px;
}
/* Sender avatar circle */
.tiptap-editor .ProseMirror .email-gmail-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 500;
color: var(--foreground);
line-height: 1.4;
margin-bottom: 10px;
color: #fff;
flex-shrink: 0;
letter-spacing: 0;
}
/* Expand button */
.tiptap-editor .ProseMirror .email-block-expand-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
font-size: 13px;
font-weight: 400;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
background: none;
border: none;
cursor: pointer;
transition: color 0.12s ease;
margin-bottom: 4px;
}
.tiptap-editor .ProseMirror .email-block-expand-btn:hover {
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-block-expand-meta {
color: color-mix(in srgb, var(--foreground) 35%, transparent);
}
.tiptap-editor .ProseMirror .email-block-toggle-chevron {
transition: transform 0.15s ease;
}
.tiptap-editor .ProseMirror .email-block-toggle-chevron-open {
transform: rotate(180deg);
}
/* Email details (expanded) */
.tiptap-editor .ProseMirror .email-block-email-details {
margin-top: 10px;
padding: 12px;
background: color-mix(in srgb, var(--foreground) 4%, transparent);
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 12px;
}
.tiptap-editor .ProseMirror .email-block-message {
padding: 0;
}
.tiptap-editor .ProseMirror .email-block-message-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
}
.tiptap-editor .ProseMirror .email-block-sender-info {
display: flex;
flex-direction: column;
min-width: 0;
/* Content column */
.tiptap-editor .ProseMirror .email-gmail-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.tiptap-editor .ProseMirror .email-block-sender-row {
.tiptap-editor .ProseMirror .email-gmail-top-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.tiptap-editor .ProseMirror .email-block-sender-name {
.tiptap-editor .ProseMirror .email-gmail-sender {
font-size: 14px;
font-weight: 500;
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tiptap-editor .ProseMirror .email-block-sender-date {
.tiptap-editor .ProseMirror .email-gmail-date {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
white-space: nowrap;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .email-block-subject-line {
font-size: 12px;
.tiptap-editor .ProseMirror .email-gmail-bottom-row {
font-size: 13px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
}
.tiptap-editor .ProseMirror .email-gmail-subject {
color: color-mix(in srgb, var(--foreground) 80%, transparent);
font-weight: 500;
}
.tiptap-editor .ProseMirror .email-gmail-snippet {
color: color-mix(in srgb, var(--foreground) 45%, transparent);
}
.tiptap-editor .ProseMirror .email-block-message-body {
/* Chevron */
.tiptap-editor .ProseMirror .email-gmail-chevron {
flex-shrink: 0;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
transition: transform 0.15s ease;
}
.tiptap-editor .ProseMirror .email-gmail-chevron.email-gmail-chevron-open {
transform: rotate(180deg);
}
/* Expanded area */
.tiptap-editor .ProseMirror .email-gmail-expanded {
padding-top: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.tiptap-editor .ProseMirror .email-gmail-exp-subject {
font-size: 18px;
font-weight: 400;
color: var(--foreground);
line-height: 1.35;
letter-spacing: -0.01em;
}
/* Metadata strip (avatar + from/to/date + open button) */
.tiptap-editor .ProseMirror .email-gmail-exp-meta {
display: flex;
align-items: flex-start;
gap: 10px;
}
.tiptap-editor .ProseMirror .email-gmail-exp-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 500;
color: #fff;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .email-gmail-exp-meta-right {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.tiptap-editor .ProseMirror .email-gmail-exp-sender {
font-size: 14px;
font-weight: 500;
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-gmail-exp-to-date {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.tiptap-editor .ProseMirror .email-gmail-exp-fulldate {
color: color-mix(in srgb, var(--foreground) 40%, transparent);
}
.tiptap-editor .ProseMirror .email-gmail-open-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: none;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.tiptap-editor .ProseMirror .email-gmail-open-btn:hover {
background: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--foreground);
}
/* Email body */
.tiptap-editor .ProseMirror .email-gmail-exp-body {
font-size: 14px;
color: color-mix(in srgb, var(--foreground) 80%, transparent);
white-space: pre-wrap;
line-height: 1.58;
line-height: 1.6;
padding-left: 50px;
}
.tiptap-editor .ProseMirror .email-block-context-section {
/* Earlier conversation */
.tiptap-editor .ProseMirror .email-gmail-exp-history {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 10px;
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
padding-left: 50px;
}
.tiptap-editor .ProseMirror .email-block-context-label {
.tiptap-editor .ProseMirror .email-gmail-exp-history-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
@ -1564,68 +1677,88 @@
color: color-mix(in srgb, var(--foreground) 40%, transparent);
}
.tiptap-editor .ProseMirror .email-block-context-summary {
font-size: 14px;
color: color-mix(in srgb, var(--foreground) 65%, transparent);
line-height: 1.58;
.tiptap-editor .ProseMirror .email-gmail-exp-history-body {
font-size: 13px;
color: color-mix(in srgb, var(--foreground) 55%, transparent);
line-height: 1.55;
white-space: pre-wrap;
padding-left: 12px;
border-left: 3px solid color-mix(in srgb, var(--foreground) 12%, transparent);
}
/* Draft section */
.tiptap-editor .ProseMirror .email-block-draft-section {
margin-top: 10px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
border-radius: 6px;
/* Compose / draft box */
.tiptap-editor .ProseMirror .email-gmail-compose {
margin-top: 4px;
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
border-radius: 8px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.tiptap-editor .ProseMirror .email-block-draft-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--foreground) 40%, transparent);
margin-bottom: 4px;
.tiptap-editor .ProseMirror .email-gmail-compose-to {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px 6px;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
}
.tiptap-editor .ProseMirror .email-draft-block-body-input {
.tiptap-editor .ProseMirror .email-gmail-compose-to-label {
font-size: 12px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
}
.tiptap-editor .ProseMirror .email-gmail-compose-to-addr {
font-size: 13px;
color: color-mix(in srgb, var(--foreground) 70%, transparent);
}
.tiptap-editor .ProseMirror .email-gmail-compose-body {
width: 100%;
font-size: 14px;
color: var(--foreground);
background: none;
border: none;
outline: none;
padding: 4px 0;
padding: 10px 12px;
font-family: inherit;
line-height: 1.58;
resize: none;
overflow: hidden;
box-sizing: border-box;
}
.tiptap-editor .ProseMirror .email-draft-block-body-input::placeholder {
.tiptap-editor .ProseMirror .email-gmail-compose-body::placeholder {
color: color-mix(in srgb, var(--foreground) 35%, transparent);
}
/* Action buttons */
.tiptap-editor .ProseMirror .email-block-actions {
.tiptap-editor .ProseMirror .email-gmail-compose-footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 8px 12px;
border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
}
.tiptap-editor .ProseMirror .email-block-gmail-btn {
/* Action buttons */
.tiptap-editor .ProseMirror .email-gmail-actions {
display: flex;
align-items: center;
gap: 8px;
padding-left: 50px;
}
.tiptap-editor .ProseMirror .email-gmail-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
font-size: 14px;
font-size: 13px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
background: transparent;
border: 1px solid var(--border);
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
border-radius: 18px;
cursor: pointer;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
@ -1633,24 +1766,19 @@
letter-spacing: 0.01em;
}
.tiptap-editor .ProseMirror .email-block-gmail-btn:hover {
background: color-mix(in srgb, var(--foreground) 8%, transparent);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 1px rgba(0, 0, 0, 0.06);
.tiptap-editor .ProseMirror .email-gmail-btn:hover {
background: color-mix(in srgb, var(--foreground) 6%, transparent);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-block-gmail-btn:disabled {
opacity: 0.6;
cursor: default;
}
.tiptap-editor .ProseMirror .email-block-gmail-btn-primary {
.tiptap-editor .ProseMirror .email-gmail-btn-primary {
color: #fff;
background: #1a73e8;
border-color: #1a73e8;
}
.tiptap-editor .ProseMirror .email-block-gmail-btn-primary:hover:not(:disabled) {
.tiptap-editor .ProseMirror .email-gmail-btn-primary:hover {
background: #1765cc;
box-shadow: 0 1px 2px 0 rgba(26, 115, 232, 0.45), 0 1px 3px 1px rgba(26, 115, 232, 0.3);
color: #fff;
@ -1665,6 +1793,167 @@
font-size: 14px;
}
/* Reply / Forward pill buttons (in expanded view) */
.tiptap-editor .ProseMirror .email-gmail-reply-row {
display: flex;
gap: 8px;
padding-left: 50px;
}
.tiptap-editor .ProseMirror .email-gmail-reply-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 20px;
font-size: 13px;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 65%, transparent);
background: transparent;
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
border-radius: 18px;
cursor: pointer;
transition: background 0.12s ease, box-shadow 0.12s ease;
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
}
.tiptap-editor .ProseMirror .email-gmail-reply-btn:hover {
background: color-mix(in srgb, var(--foreground) 6%, transparent);
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-gmail-reply-row-end {
margin-left: auto;
}
/* ---- Emails inbox block (language-emails) ---- */
.tiptap-editor .ProseMirror .email-inbox-card {
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
}
.tiptap-editor .ProseMirror .email-inbox-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
color: color-mix(in srgb, var(--foreground) 70%, transparent);
letter-spacing: 0.01em;
}
.tiptap-editor .ProseMirror .email-inbox-list {
display: flex;
flex-direction: column;
}
/* Each email row — hairline separator only, no card */
.tiptap-editor .ProseMirror .email-inbox-row {
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
}
.tiptap-editor .ProseMirror .email-inbox-row:last-child {
border-bottom: none;
}
.tiptap-editor .ProseMirror .email-inbox-row-header {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 4px 7px 0;
cursor: pointer;
transition: background 0.1s ease;
user-select: none;
border-radius: 4px;
}
.tiptap-editor .ProseMirror .email-inbox-row-header:hover {
background: color-mix(in srgb, var(--foreground) 5%, transparent);
}
.tiptap-editor .ProseMirror .email-inbox-row.email-inbox-row-expanded .email-inbox-row-header {
background: color-mix(in srgb, var(--foreground) 3%, transparent);
}
/* Avatar */
.tiptap-editor .ProseMirror .email-inbox-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 500;
color: #fff;
flex-shrink: 0;
}
/* Content column — two-line layout */
.tiptap-editor .ProseMirror .email-inbox-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.tiptap-editor .ProseMirror .email-inbox-top-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.tiptap-editor .ProseMirror .email-inbox-sender {
font-size: 14px;
font-weight: 500;
font-family: 'Google Sans', Roboto, RobotoDraft, Helvetica, Arial, sans-serif;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tiptap-editor .ProseMirror .email-inbox-date {
font-size: 12px;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
white-space: nowrap;
flex-shrink: 0;
}
.tiptap-editor .ProseMirror .email-inbox-bottom-row {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tiptap-editor .ProseMirror .email-inbox-subject {
font-weight: 500;
color: var(--foreground);
}
.tiptap-editor .ProseMirror .email-inbox-snippet {
color: color-mix(in srgb, var(--foreground) 45%, transparent);
font-weight: 400;
}
/* Expand chevron */
.tiptap-editor .ProseMirror .email-inbox-chevron {
flex-shrink: 0;
color: color-mix(in srgb, var(--foreground) 35%, transparent);
transition: transform 0.15s ease;
}
.tiptap-editor .ProseMirror .email-inbox-chevron.email-inbox-chevron-open {
transform: rotate(180deg);
}
/* Expanded content padding */
.tiptap-editor .ProseMirror .email-inbox-expanded-wrap {
padding: 8px 0 12px 0;
border-top: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
}
/* Transcript block */
.tiptap-editor .ProseMirror .transcript-block-toggle {
display: flex;