feat: Phase 2 Aether Drift — chat component token migration + scroll fix

Migrate all 8 chat rendering components from Discord tokens to Aether Drift
design system: Message, MarkdownRenderer, MentionBadge, MentionPopover,
FriendsPage, TypingIndicator, Embed, ImagePreview. Eliminates ~83 discord-*
class references and ~24 hardcoded hex values from components/chat/.

Fix critical scroll regression from Phase 1 grid migration by adding explicit
grid-rows-[minmax(0,1fr)] and min-h-0 to AppLayout's grid container, restoring
the height constraint chain to MessageList's overflow-y-auto.
This commit is contained in:
Jannis Braun
2026-03-01 23:23:46 +01:00
parent 79252fe54a
commit 662eb8abc5
9 changed files with 102 additions and 102 deletions
+4 -4
View File
@@ -42,10 +42,10 @@ export function Embed({ url }: EmbedProps) {
if (isLoading || !metadata) return null;
return (
<div className="mt-2 max-w-[520px] bg-discord-bg-secondary rounded-[4px] border-l-4 border-discord-bg-tertiary flex overflow-hidden">
<div className="mt-2 max-w-[520px] bg-surface-channel rounded-[4px] border-l-4 border-border-hard flex overflow-hidden">
<div className="flex-1 p-3 min-w-0">
{metadata.siteName && (
<div className="text-[12px] text-discord-text-normal font-medium mb-1 truncate">
<div className="text-[12px] text-txt-message font-medium mb-1 truncate">
{metadata.siteName}
</div>
)}
@@ -54,13 +54,13 @@ export function Embed({ url }: EmbedProps) {
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-[16px] text-discord-text-link font-semibold hover:underline block mb-2"
className="text-[16px] text-txt-link font-semibold hover:underline block mb-2"
>
{metadata.title}
</a>
)}
{metadata.description && (
<div className="text-[14px] text-discord-text-normal leading-[1.125rem]">
<div className="text-[14px] text-txt-message leading-[1.125rem]">
{metadata.description}
</div>
)}
@@ -74,13 +74,13 @@ export function FriendsPage() {
case 'online':
return (
<div className="flex-1 overflow-y-auto p-4">
<h2 className="text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2">
<h2 className="text-xs font-bold text-txt-tertiary uppercase mb-4 tracking-wider px-2">
Online {onlineFriends.length}
</h2>
{onlineFriends.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-60">
<img src="/friends-empty.svg" alt="" className="w-64 h-64 mb-4" onError={(e) => (e.target as any).style.display='none'} />
<p className="text-discord-text-muted">No one's around to play with Wumpus.</p>
<p className="text-txt-tertiary">No one's around to play with Wumpus.</p>
</div>
) : (
onlineFriends.map(friend => (
@@ -92,12 +92,12 @@ export function FriendsPage() {
case 'all':
return (
<div className="flex-1 overflow-y-auto p-4">
<h2 className="text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2">
<h2 className="text-xs font-bold text-txt-tertiary uppercase mb-4 tracking-wider px-2">
All Friends — {friends.length}
</h2>
{friends.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-60">
<p className="text-discord-text-muted">Wumpus is waiting on friends. You can add them!</p>
<p className="text-txt-tertiary">Wumpus is waiting on friends. You can add them!</p>
</div>
) : (
friends.map(friend => (
@@ -109,12 +109,12 @@ export function FriendsPage() {
case 'pending':
return (
<div className="flex-1 overflow-y-auto p-4">
<h2 className="text-xs font-bold text-discord-text-muted uppercase mb-4 tracking-wider px-2">
<h2 className="text-xs font-bold text-txt-tertiary uppercase mb-4 tracking-wider px-2">
Pending — {pendingIncoming.length + pendingOutgoing.length}
</h2>
{[...pendingIncoming, ...pendingOutgoing].length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-60">
<p className="text-discord-text-muted">There are no pending friend requests. Here's Wumpus for now!</p>
<p className="text-txt-tertiary">There are no pending friend requests. Here's Wumpus for now!</p>
</div>
) : (
<>
@@ -142,26 +142,26 @@ export function FriendsPage() {
case 'add':
return (
<div className="flex-1 p-8">
<h2 className="text-base font-bold text-discord-text-primary uppercase mb-2">Add Friend</h2>
<p className="text-sm text-discord-text-muted mb-4">You can add friends with their Backspace username.</p>
<h2 className="text-base font-bold text-txt-primary uppercase mb-2">Add Friend</h2>
<p className="text-sm text-txt-tertiary mb-4">You can add friends with their Backspace username.</p>
<form onSubmit={handleAddFriend} className="relative mb-8">
<input
type="text"
placeholder="You can add a friend with their username"
value={addUsername}
onChange={(e) => setAddUsername(e.target.value)}
className="w-full bg-discord-bg-tertiary text-discord-text-primary px-4 py-3 rounded-lg border border-transparent focus:border-discord-text-link outline-none transition-all placeholder:text-discord-text-muted/50"
className="w-full bg-surface-base text-txt-primary px-4 py-3 rounded-lg border border-transparent focus:border-txt-link outline-none transition-all placeholder:text-txt-tertiary/50"
/>
<button
type="submit"
disabled={!addUsername.trim() || isLoading}
className="absolute right-2 top-1.5 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple-hover disabled:opacity-50 disabled:bg-discord-blurple text-white text-sm font-medium rounded transition-colors"
className="absolute right-2 top-1.5 px-4 py-1.5 bg-accent-primary hover:bg-accent-primary-hover disabled:opacity-50 disabled:bg-accent-primary text-white text-sm font-medium rounded transition-colors"
>
Send Friend Request
</button>
</form>
{addStatus && (
<div className={`text-sm p-3 rounded-lg border ${addStatus.type === 'success' ? 'text-discord-text-positive border-discord-green/20 bg-discord-green/5' : 'text-discord-text-danger border-discord-red/20 bg-discord-red/5'}`}>
<div className={`text-sm p-3 rounded-lg border ${addStatus.type === 'success' ? 'text-txt-positive border-status-online/20 bg-status-online/5' : 'text-txt-danger border-accent-rose/20 bg-accent-rose/5'}`}>
{addStatus.message}
</div>
)}
@@ -171,17 +171,17 @@ export function FriendsPage() {
};
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary h-full">
<div className="flex-1 flex flex-col bg-surface-chat h-full">
{/* Header */}
<div className="h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10 bg-discord-bg-primary">
<div className="h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10 bg-surface-chat">
<div className="flex items-center gap-2 mr-4">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
<span className="font-bold text-discord-text-primary">Friends</span>
<span className="font-bold text-txt-primary">Friends</span>
</div>
<div className="w-[1px] h-6 bg-discord-bg-accent mx-2" />
<div className="w-[1px] h-6 bg-surface-elevated mx-2" />
<div className="flex items-center gap-4 ml-2">
<TabButton active={activeTab === 'online'} onClick={() => setActiveTab('online')}>Online</TabButton>
@@ -189,7 +189,7 @@ export function FriendsPage() {
<TabButton active={activeTab === 'pending'} onClick={() => setActiveTab('pending')}>
Pending
{(pendingIncoming.length > 0) && (
<span className="ml-2 px-1.5 py-0.5 bg-discord-red text-white text-[10px] rounded-full leading-none">
<span className="ml-2 px-1.5 py-0.5 bg-accent-rose text-white text-[10px] rounded-full leading-none">
{pendingIncoming.length}
</span>
)}
@@ -197,7 +197,7 @@ export function FriendsPage() {
<button
onClick={() => setActiveTab('add')}
className={`px-2 py-0.5 rounded text-[14px] font-medium transition-all ${
activeTab === 'add' ? 'text-discord-green bg-transparent' : 'bg-discord-green text-white hover:bg-discord-green/90'
activeTab === 'add' ? 'text-status-online bg-transparent' : 'bg-status-online text-white hover:bg-status-online/90'
}`}
>
Add Friend
@@ -219,7 +219,7 @@ function TabButton({ children, active, onClick }: { children: React.ReactNode, a
<button
onClick={onClick}
className={`px-2 py-0.5 rounded-[4px] text-[16px] font-medium transition-colors ${
active ? 'bg-discord-modifier-selected text-white' : 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'
active ? 'bg-interactive-selected text-white' : 'text-txt-tertiary hover:bg-interactive-hover hover:text-txt-secondary'
}`}
>
{children}
@@ -229,21 +229,21 @@ function TabButton({ children, active, onClick }: { children: React.ReactNode, a
function FriendItem({ friend, onRemove, onDm }: { friend: Friend, onRemove: () => void, onDm: () => void }) {
return (
<div className="flex items-center justify-between px-3 h-[62px] rounded-[8px] hover:bg-discord-modifier-hover group transition-colors border-t border-discord-modifier-accent mx-2">
<div className="flex items-center justify-between px-3 h-[62px] rounded-[8px] hover:bg-interactive-hover group transition-colors border-t border-interactive-muted mx-2">
<div className="flex items-center gap-3">
<Avatar src={friend.avatar} name={friend.displayName ?? friend.username} size={32} status={friend.status} />
<div className="flex flex-col leading-tight">
<div className="flex items-center gap-1.5">
<span className="text-discord-text-primary font-semibold text-[15px]">{friend.displayName ?? friend.username}</span>
<span className="text-discord-text-muted text-[13px] opacity-0 group-hover:opacity-100 transition-opacity font-medium">@{friend.username}</span>
<span className="text-txt-primary font-semibold text-[15px]">{friend.displayName ?? friend.username}</span>
<span className="text-txt-tertiary text-[13px] opacity-0 group-hover:opacity-100 transition-opacity font-medium">@{friend.username}</span>
</div>
<span className="text-[12px] text-discord-text-muted font-medium uppercase">{friend.status}</span>
<span className="text-[12px] text-txt-tertiary font-medium uppercase">{friend.status}</span>
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity pr-2">
<button
onClick={(e) => { e.stopPropagation(); onDm(); }}
className="w-9 h-9 flex items-center justify-center bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-text-primary transition-colors"
className="w-9 h-9 flex items-center justify-center bg-surface-base rounded-full text-txt-tertiary hover:text-txt-primary transition-colors"
title="Message"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
@@ -252,7 +252,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: Friend, onRemove: () =
</button>
<button
onClick={(e) => { e.stopPropagation(); onRemove(); }}
className="w-9 h-9 flex items-center justify-center bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-colors"
className="w-9 h-9 flex items-center justify-center bg-surface-base rounded-full text-txt-tertiary hover:text-txt-danger transition-colors"
title="Remove Friend"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
@@ -275,15 +275,15 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
if (!user) return null;
return (
<div className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-discord-modifier-hover group transition-colors border-t border-discord-modifier-accent mx-2">
<div className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-interactive-hover group transition-colors border-t border-interactive-muted mx-2">
<div className="flex items-center gap-3">
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status as any} />
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span className="text-discord-text-primary font-bold text-sm">{user.displayName ?? user.username}</span>
<span className="text-discord-text-muted text-xs">@{user.username}</span>
<span className="text-txt-primary font-bold text-sm">{user.displayName ?? user.username}</span>
<span className="text-txt-tertiary text-xs">@{user.username}</span>
</div>
<span className="text-xs text-discord-text-muted">{type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'}</span>
<span className="text-xs text-txt-tertiary">{type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'}</span>
</div>
</div>
<div className="flex items-center gap-2">
@@ -291,7 +291,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
<>
<button
onClick={() => onAccept?.()}
className="p-2 bg-discord-bg-tertiary rounded-full text-discord-green hover:bg-discord-green hover:text-white transition-all"
className="p-2 bg-surface-base rounded-full text-status-online hover:bg-status-online hover:text-white transition-all"
title="Accept"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
@@ -300,7 +300,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
</button>
<button
onClick={() => onDecline?.()}
className="p-2 bg-discord-bg-tertiary rounded-full text-discord-red hover:bg-discord-red hover:text-white transition-all"
className="p-2 bg-surface-base rounded-full text-txt-danger hover:bg-accent-rose hover:text-white transition-all"
title="Decline"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
@@ -311,7 +311,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
) : (
<button
onClick={() => onCancel?.()}
className="p-2 bg-discord-bg-tertiary rounded-full text-discord-text-muted hover:text-discord-red transition-all"
className="p-2 bg-surface-base rounded-full text-txt-tertiary hover:text-txt-danger transition-all"
title="Cancel Request"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
@@ -10,7 +10,7 @@ export function ImagePreview() {
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-discord-bg-overlay animate-fade-in cursor-pointer"
className="fixed inset-0 z-[200] flex items-center justify-center bg-surface-overlay animate-fade-in cursor-pointer"
onClick={closeImagePreview}
>
<button
@@ -60,15 +60,15 @@ function urlTransform(url: string): string {
return defaultUrlTransform(url);
}
// ─── Custom Theme (Discord Dark) ──────────────────────────────────────────
// Based on One Dark, tuned to match Discord's code block aesthetic.
// ─── Custom Theme (Aether Drift) ──────────────────────────────────────────
// Based on One Dark, tuned to match Aether Drift's elevated surface aesthetic.
const discordDarkTheme = {
const aetherTheme = {
...themes.oneDark,
plain: {
...themes.oneDark.plain,
backgroundColor: '#2b2d31',
color: '#dcddde',
backgroundColor: 'var(--bg-elevated)',
color: 'var(--text-message)',
},
};
@@ -80,17 +80,17 @@ function CodeBlock({ language, code }: { language: string; code: string }) {
// 'text' means a bare fenced block with no language — render without highlighting
if (language === 'text') {
return (
<pre className="mt-1 p-3 bg-[#2b2d31] border border-[#1e1f22]/50 rounded text-[0.875rem] leading-[1.125rem] font-mono overflow-x-auto whitespace-pre">
<pre className="mt-1 p-3 bg-surface-elevated border border-border-hard/50 rounded text-[0.875rem] leading-[1.125rem] font-mono overflow-x-auto whitespace-pre">
<code>{code}</code>
</pre>
);
}
return (
<Highlight theme={discordDarkTheme} code={code} language={language}>
<Highlight theme={aetherTheme} code={code} language={language}>
{({ style, tokens, getLineProps, getTokenProps }) => (
<pre
className="mt-1 rounded border border-[#1e1f22]/50 text-[0.875rem] leading-[1.125rem] font-mono overflow-x-auto"
className="mt-1 rounded border border-border-hard/50 text-[0.875rem] leading-[1.125rem] font-mono overflow-x-auto"
style={{ ...style, padding: '0.625rem 0.75rem', margin: 0 }}
>
{tokens.map((line, i) => {
@@ -132,7 +132,7 @@ function buildComponents(): Components {
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[#00aff4] hover:underline"
className="text-txt-link hover:underline"
>
{children}
</a>
@@ -149,7 +149,7 @@ function buildComponents(): Components {
// Inline code
return (
<code
className="px-[0.35em] py-[0.15em] bg-[#2b2d31] rounded-[3px] text-[0.875em] font-mono text-[#e8912d]"
className="px-[0.35em] py-[0.15em] bg-surface-elevated rounded-[3px] text-[0.875em] font-mono text-accent-coral"
{...rest}
>
{children}
@@ -161,13 +161,13 @@ function buildComponents(): Components {
pre: ({ children }) => <>{children}</>,
// Bold / Italic / Strikethrough
strong: ({ children }) => <strong className="font-bold text-[#f2f3f5]">{children}</strong>,
strong: ({ children }) => <strong className="font-bold text-txt-primary">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
del: ({ children }) => <del className="line-through text-[#a3a6aa]">{children}</del>,
del: ({ children }) => <del className="line-through text-txt-secondary">{children}</del>,
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="pl-3 border-l-[3px] border-[#4e5058] my-0.5">
<blockquote className="pl-3 border-l-[3px] border-interactive-muted my-0.5">
{children}
</blockquote>
),
@@ -178,12 +178,12 @@ function buildComponents(): Components {
li: ({ children }) => <li>{children}</li>,
// Headings — Discord renders these with slightly larger/bolder text
h1: ({ children }) => <div className="text-[1.5rem] font-bold text-[#f2f3f5] mt-2 mb-1">{children}</div>,
h2: ({ children }) => <div className="text-[1.25rem] font-bold text-[#f2f3f5] mt-2 mb-1">{children}</div>,
h3: ({ children }) => <div className="text-[1.1rem] font-bold text-[#f2f3f5] mt-1 mb-0.5">{children}</div>,
h1: ({ children }) => <div className="text-[1.5rem] font-bold text-txt-primary mt-2 mb-1">{children}</div>,
h2: ({ children }) => <div className="text-[1.25rem] font-bold text-txt-primary mt-2 mb-1">{children}</div>,
h3: ({ children }) => <div className="text-[1.1rem] font-bold text-txt-primary mt-1 mb-0.5">{children}</div>,
// Horizontal rules
hr: () => <hr className="border-[#3f4147] my-2" />,
hr: () => <hr className="border-border-soft my-2" />,
// Images (in markdown content — not attachments)
img: ({ src, alt }) => (
@@ -201,10 +201,10 @@ function buildComponents(): Components {
<table className="border-collapse text-[0.875rem]">{children}</table>
</div>
),
thead: ({ children }) => <thead className="border-b border-[#3f4147]">{children}</thead>,
thead: ({ children }) => <thead className="border-b border-border-soft">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr className="border-b border-[#3f4147]/50">{children}</tr>,
th: ({ children }) => <th className="px-3 py-1.5 text-left text-[#f2f3f5] font-semibold">{children}</th>,
tr: ({ children }) => <tr className="border-b border-border-soft/50">{children}</tr>,
th: ({ children }) => <th className="px-3 py-1.5 text-left text-txt-primary font-semibold">{children}</th>,
td: ({ children }) => <td className="px-3 py-1.5">{children}</td>,
};
}
@@ -25,13 +25,13 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention
const sorted = [...member.roles].sort((a, b) => b.position - a.position);
color = sorted[0]!.color;
} else if (ownerId && userId === ownerId) {
color = '#f23f43';
color = '#fda4af';
} else {
color = '#5865f2'; // blurple default
color = '#7c6cf6'; // accent-primary default
}
} else {
displayName = 'Unknown User';
color = '#a3a6aa'; // muted fallback
color = '#a0a0aa'; // text-secondary fallback
}
const handleClick = (e: React.MouseEvent) => {
@@ -42,14 +42,14 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove
const sorted = [...member.roles].sort((a, b) => b.position - a.position);
return sorted[0]!.color;
}
if (ownerId && member.userId === ownerId) return '#f23f43';
if (ownerId && member.userId === ownerId) return '#fda4af';
return undefined;
};
return (
<div className="absolute bottom-full left-0 w-[280px] mb-1 z-50">
<div className="bg-[#2b2d31] rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.15),0_8px_16px_rgba(0,0,0,0.24)] overflow-hidden max-h-[320px] overflow-y-auto scrollbar-thin">
<div className="px-2 py-1.5 text-[11px] font-bold text-discord-text-muted uppercase tracking-wider">
<div className="bg-surface-elevated rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.15),0_8px_16px_rgba(0,0,0,0.24)] overflow-hidden max-h-[320px] overflow-y-auto scrollbar-thin">
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
Members
</div>
{filtered.map((member, i) => {
@@ -63,7 +63,7 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove
ref={isSelected ? selectedRef : undefined}
onClick={() => onSelect(member)}
className={`flex items-center gap-2.5 px-2 py-1.5 mx-1 rounded cursor-pointer transition-colors ${
isSelected ? 'bg-[#404249]' : 'hover:bg-[#35373c]'
isSelected ? 'bg-interactive-selected' : 'hover:bg-interactive-hover'
}`}
>
<Avatar
@@ -79,7 +79,7 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove
{displayName}
</span>
{member.user.displayName && (
<span className="text-[12px] text-discord-text-muted truncate">
<span className="text-[12px] text-txt-tertiary truncate">
@{member.user.username}
</span>
)}
+29 -29
View File
@@ -133,8 +133,8 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const sorted = [...member.roles].sort((a, b) => b.position - a.position);
return { color: sorted[0]!.color };
}
if (ownerId && userId === ownerId) return { color: '#f23f43' };
return { color: '#dcdcdf' };
if (ownerId && userId === ownerId) return { color: '#fda4af' };
return { color: '#d8d8de' };
};
const roleColor = getMemberDisplayColor(message.userId);
@@ -148,15 +148,15 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
<div
className={`group relative flex px-4 py-0.5 transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''} ${
isMentioned
? 'bg-[#3c3829] border-l-2 border-l-[#f0b132] hover:bg-[#45402f]'
: 'hover:bg-discord-modifier-hover'
? 'bg-accent-amber/10 border-l-2 border-l-accent-amber hover:bg-accent-amber/15'
: 'hover:bg-interactive-hover'
}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Reply Line */}
{message.replyTo && (
<div className="absolute left-[36px] top-[-14px] w-[33px] h-[22px] border-l-2 border-t-2 border-discord-interactive-muted rounded-tl-[6px] opacity-60" />
<div className="absolute left-[36px] top-[-14px] w-[33px] h-[22px] border-l-2 border-t-2 border-interactive-muted rounded-tl-[6px] opacity-60" />
)}
{/* Avatar or timestamp column */}
@@ -172,7 +172,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
/>
</div>
) : (
<span className={`text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-2 select-none w-full text-center leading-[1.375rem] font-medium`}>
<span className={`text-[11px] text-txt-tertiary opacity-0 group-hover:opacity-100 mt-2 select-none w-full text-center leading-[1.375rem] font-medium`}>
{formatHoverTime(message.createdAt)}
</span>
)}
@@ -184,12 +184,12 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
<div className="flex items-center gap-1 mb-1 ml-[-4px] opacity-80 hover:opacity-100 cursor-pointer group/reply">
<Avatar src={message.replyTo.user.avatar} name={message.replyTo.user.username} size={16} />
<span
className="text-[14px] font-bold text-discord-text-header hover:underline"
className="text-[14px] font-bold text-txt-primary hover:underline"
style={message.replyTo ? replyRoleColor(message.replyTo) : undefined}
>
{message.replyTo.user.displayName ?? message.replyTo.user.username}
</span>
<span className="text-[14px] text-discord-text-normal truncate max-w-[400px] hover:text-discord-text-primary">
<span className="text-[14px] text-txt-message truncate max-w-[400px] hover:text-txt-primary">
{message.replyTo.content}
</span>
</div>
@@ -204,7 +204,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
>
{displayName}
</span>
<span className="text-[12px] text-discord-text-muted leading-tight font-medium hover:cursor-default">
<span className="text-[12px] text-txt-tertiary leading-tight font-medium hover:cursor-default">
{formatTime(message.createdAt)}
</span>
</div>
@@ -216,27 +216,27 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={handleEditSubmit}
className="w-full p-3 bg-discord-bg-input rounded-lg text-discord-text-primary outline-none resize-none text-[16px] leading-[1.375rem] shadow-inner"
className="w-full p-3 bg-surface-input rounded-lg text-txt-primary outline-none resize-none text-[16px] leading-[1.375rem] shadow-inner"
rows={2}
autoFocus
/>
<p className="text-[12px] text-discord-text-muted mt-1.5 ml-1">
escape to <button onClick={() => setIsEditing(false)} className="text-discord-text-link hover:underline">cancel</button>
<p className="text-[12px] text-txt-tertiary mt-1.5 ml-1">
escape to <button onClick={() => setIsEditing(false)} className="text-txt-link hover:underline">cancel</button>
{' '}&bull; enter to <button onClick={() => {
if (editContent.trim()) {
editMessage(message.id, editContent.trim(), channelKey);
setIsEditing(false);
}
}} className="text-discord-text-link hover:underline">save</button>
}} className="text-txt-link hover:underline">save</button>
</p>
</div>
) : (
<div className="flex flex-col gap-1">
{message.content && (
<div className="text-discord-text-normal text-[16px] leading-[1.375rem] break-words whitespace-pre-wrap selection:bg-discord-blurple/30">
<div className="text-txt-message text-[16px] leading-[1.375rem] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
<MarkdownRenderer content={message.content} />
{message.editedAt && (
<span className="text-[10px] text-discord-text-muted ml-1 select-none font-medium">(edited)</span>
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
)}
</div>
)}
@@ -250,12 +250,12 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
onClick={() => toggleReaction(emoji)}
className={`flex items-center gap-1.5 px-1.5 py-0.5 rounded-[8px] text-[14px] font-medium border transition-colors ${
me
? 'bg-discord-blurple/15 border-discord-blurple text-discord-blurple'
: 'bg-discord-bg-secondary border-transparent text-discord-text-muted hover:border-discord-text-muted/30'
? 'bg-accent-primary/15 border-accent-primary text-accent-primary'
: 'bg-surface-channel border-transparent text-txt-tertiary hover:border-txt-tertiary/30'
}`}
>
<span>{emoji}</span>
<span className={me ? 'text-discord-blurple' : 'text-discord-text-normal'}>{count}</span>
<span className={me ? 'text-accent-primary' : 'text-txt-message'}>{count}</span>
</button>
))}
</div>
@@ -271,7 +271,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const isImage = att.mimetype.startsWith('image/');
if (isImage) {
return (
<div key={att.id} className="max-w-fit mt-1 rounded-lg overflow-hidden border border-discord-bg-tertiary/50 bg-discord-bg-tertiary/20">
<div key={att.id} className="max-w-fit mt-1 rounded-lg overflow-hidden border border-border-hard/50 bg-surface-base/20">
<img
src={`/api/uploads/${att.filename}`}
alt={att.originalName}
@@ -287,16 +287,16 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
key={att.id}
href={`/api/uploads/${att.filename}`}
download={att.originalName}
className="flex items-center gap-3 p-4 bg-discord-bg-secondary/50 rounded-lg border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-all max-w-[400px] mt-1 group/att"
className="flex items-center gap-3 p-4 bg-surface-channel/50 rounded-lg border border-border-hard hover:bg-interactive-hover transition-all max-w-[400px] mt-1 group/att"
>
<div className="p-2 bg-discord-bg-tertiary rounded text-discord-text-muted group-hover/att:text-discord-text-primary transition-colors">
<div className="p-2 bg-surface-base rounded text-txt-tertiary group-hover/att:text-txt-primary transition-colors">
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
</div>
<div className="min-w-0">
<p className="text-discord-text-link text-[15px] font-medium truncate hover:underline">{att.originalName}</p>
<p className="text-[12px] text-discord-text-muted font-medium">
<p className="text-txt-link text-[15px] font-medium truncate hover:underline">{att.originalName}</p>
<p className="text-[12px] text-txt-tertiary font-medium">
{att.size < 1024 ? `${att.size} B` :
att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` :
`${(att.size / 1048576).toFixed(1)} MB`}
@@ -313,13 +313,13 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
{/* Action buttons on hover */}
{isHovered && !isEditing && (
<div className="absolute -top-[18px] right-4 flex items-center bg-discord-bg-primary border border-discord-bg-tertiary/50 rounded-[4px] shadow-elevation-low overflow-hidden z-10 h-8">
<div className="flex items-center px-1 border-r border-discord-bg-tertiary/50 h-full">
<div className="absolute -top-[18px] right-4 flex items-center bg-surface-chat border border-border-hard/50 rounded-[4px] shadow-elevation-low overflow-hidden z-10 h-8">
<div className="flex items-center px-1 border-r border-border-hard/50 h-full">
{['👍', '❤️', '😂', '😮'].map(emoji => (
<button
key={emoji}
onClick={() => toggleReaction(emoji)}
className="p-1 hover:bg-discord-modifier-hover rounded transition-colors text-[16px] leading-none"
className="p-1 hover:bg-interactive-hover rounded transition-colors text-[16px] leading-none"
>
{emoji}
</button>
@@ -327,7 +327,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
</div>
<button
onClick={() => setReplyTo(message)}
className="px-2 h-full text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover transition-all flex items-center justify-center"
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
title="Reply"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
@@ -340,7 +340,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
setEditContent(message.content ?? '');
setIsEditing(true);
}}
className="px-2 h-full text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover transition-all flex items-center justify-center"
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
title="Edit"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
@@ -351,7 +351,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
{canDelete && (
<button
onClick={() => deleteMessage(message.id, channelKey)}
className="px-2 h-full text-discord-text-muted hover:text-discord-red hover:bg-discord-modifier-hover transition-all flex items-center justify-center"
className="px-2 h-full text-txt-tertiary hover:text-txt-danger hover:bg-interactive-hover transition-all flex items-center justify-center"
title="Delete"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
@@ -30,12 +30,12 @@ export function TypingIndicator({ channelId }: TypingIndicatorProps) {
}
return (
<div className="h-[24px] px-4 flex items-center text-[12px] text-discord-text-header font-medium select-none pointer-events-none">
<div className="h-[24px] px-4 flex items-center text-[12px] text-txt-primary font-medium select-none pointer-events-none">
<div className="flex items-center gap-2">
<div className="flex gap-[2px] bg-discord-bg-accent/20 rounded-full px-2 py-1">
<div className="w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce" style={{ animationDelay: '0ms', animationDuration: '0.8s' }} />
<div className="w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce" style={{ animationDelay: '150ms', animationDuration: '0.8s' }} />
<div className="w-[5px] h-[5px] bg-discord-text-normal rounded-full animate-bounce" style={{ animationDelay: '300ms', animationDuration: '0.8s' }} />
<div className="flex gap-[2px] bg-surface-elevated/20 rounded-full px-2 py-1">
<div className="w-[5px] h-[5px] bg-txt-message rounded-full animate-bounce" style={{ animationDelay: '0ms', animationDuration: '0.8s' }} />
<div className="w-[5px] h-[5px] bg-txt-message rounded-full animate-bounce" style={{ animationDelay: '150ms', animationDuration: '0.8s' }} />
<div className="w-[5px] h-[5px] bg-txt-message rounded-full animate-bounce" style={{ animationDelay: '300ms', animationDuration: '0.8s' }} />
</div>
<span className="truncate max-w-[400px]">
<span className="font-bold">{text}</span>
@@ -217,7 +217,7 @@ export function AppLayout() {
}
return (
<div className="h-screen flex flex-col md:grid md:grid-cols-[312px_1fr] bg-surface-base overflow-hidden">
<div className="h-screen flex flex-col md:grid md:grid-cols-[312px_1fr] md:grid-rows-[minmax(0,1fr)] bg-surface-base overflow-hidden">
{/* Server sidebar - always visible on desktop, toggled on mobile */}
<div className={`fixed inset-y-0 left-0 z-40 flex w-[312px] transition-transform duration-200 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'} md:static md:z-auto md:w-auto md:transform-none`}>
<ServerSidebar />
@@ -225,7 +225,7 @@ export function AppLayout() {
</div>
{/* Main content area */}
<div className="flex-1 flex min-w-0 bg-surface-chat relative">
<div className="flex-1 flex min-w-0 min-h-0 bg-surface-chat relative">
<MainContent />
<RightPanel />
</div>