- Voice messages: MediaRecorder → encrypted attachment, custom waveform player via OfflineAudioContext, 60s limit + live mic-level meter - Offline message queue: localStorage outbox, exponential backoff retries, optimistic pending bubble with retry/discard - Delivery indicator: message_deliveries table + RLS (reciprocal receipts), ✓ / ✓✓ / ✓✓-blue tick states, group-aware (all members must ack) - Per-participant volume slider in calls via right-click tile menu, persisted to localStorage, applied to attached audio elements - Group call scaling: grid up to 12 tiles with pagination, active-speaker auto-promotion in fullscreen - Push notifications scaffolding: service worker, VAPID subscription registration, notify-push edge function skeleton - Backup recovery code: 24-char base32 code (~120 bits entropy) as alternative decrypt path, restore UI with mode toggle - Admin panel: conversations list, audit log (admin_audit_log table + admin_log_action RPC), audit entry on user flag toggle - Search v2: sender filter, attachment-only toggle, date range - Reactions pop animation (scale 0.4→1.15→1 on count change) - Message list windowing (150 default, expand via IntersectionObserver) - Stub cleanup: removed dead ScreenshareStub from CallParticipantTile Fixes: - Focus-triggered flicker: dropped window.focus listeners in three spots, throttled visibilitychange/online wake-refreshes to 30s, keep existing data visible during background re-syncs (no more spinner on every click) - Voice attachment audio element collapsed to 0px on peer side — now forces 280px min-width on bubble Migrations (push required): 20260421000001_message_deliveries.sql 20260421000002_admin_audit_log.sql Server TODO: VAPID keys + notify-push edge function deploy
20 KiB
Handoff: ChatApp — Clean Rail Redesign (Chat + Friends + Admin + Settings + Calls)
Overview
Full design spec for redesigning the existing ChatApp (Netralax) interface around a Clean Rail layout aesthetic. Covers four main screens plus the complete call UX:
- Chat — icon-rail + chat list + conversation area
- Friends — friends list, outgoing requests, incoming requests, user search
- Admin — stats, invite-code management, user administration
- Settings — account, appearance, privacy, voice/audio, screenshare, devices, danger zone
- Calls — incoming overlay → in-call dock → fullscreen → picture-in-picture
All screens work in light and dark mode.
About the Design Files
The files in this bundle are design references created in HTML/React/CSS — prototypes showing the intended look, layout, and behavior. They are not production code to copy verbatim.
Your task is to recreate these designs inside the existing ChatApp codebase using its established patterns (existing React components, styling conventions, state management, icon library, etc.). If some tokens or helper components don't exist yet in the codebase, add them but keep them aligned with what's already there.
File layout:
ChatApp Redesign.html— entry HTML (open in a browser to see the prototype)components/app.jsx— main layout (rail + chat list + chat area + call state machine + tab switching)components/pages.jsx— Friends, Admin, Settings page components plus shared page primitives (PageHeader, Section, Toggle, SegmentedControl, Row, Empty, Banner, FlagChip, FriendRow)components/call.jsx— call UI (IncomingCallScreen, ActiveCallScreen, ParticipantTile, CallControls, PipCall)components/icons.jsx— SVG icon setcomponents/data.jsx— dummy chat/message datastyles/base.css— shared base tokens + layout primitivesstyles/variants.css—variant-cleanlight + dark token definitionsstyles/rail.css— rail-layout specificsstyles/call.css— call UI stylesstyles/pages.css— Friends/Admin/Settings page stylesscreenshots/— reference images
Fidelity
High-fidelity. Exact colors, radii, spacing, typography and motion are specified. Recreate pixel-perfectly using the codebase's existing component library — but prefer the codebase's primitives over duplicating the prototype's raw CSS where equivalents exist.
Design Tokens (Clean Rail)
Colors — Light mode (.variant-clean)
--bg: #fafaf9
--bg-2: #f4f4f2
--bg-3: #ffffff
--fg: #0a0a0f
--fg-muted: #737380
--line: rgba(10,10,15,0.08)
--accent: #4F46E5
--accent-fg: #ffffff
Colors — Dark mode (.variant-clean.dark)
--bg: #050507
--bg-2: #111118
--bg-3: #0A0A0F
--fg: #f4f4f5
--fg-muted: #8a8a99
--line: rgba(255,255,255,0.07)
--accent: #6D73FF
--accent-fg: #ffffff
Semantic Colors
Success / Online / E2EE: #16a34a (light) · #4ade80 / #22c55e (dark)
Danger / Decline: #dc2626 / #b91c1c hover · #fb7185 (dark accent for borders/text)
Warning / Expired: #f59e0b · #d97706 light · #fbbf24 dark
Live / Recording: #ef4444
Avatar swatches (assigned per user)
violet: bg #ddd6fe / fg #5b21b6 (dark: #312E81 / #C4B5FD)
amber: bg #fde68a / fg #78350f
rose: bg #fecdd3 / fg #881337
teal: bg #99f6e4 / fg #134e4a (dark: #134e4a / #99f6e4)
Typography
- Family:
'Inter', system-ui, sans-serifbase; display'Outfit'(page titles, call participant name, stat-card values); mono'JetBrains Mono'(invite codes, device IDs, keyboard hotkeys, inline stats). - Scale:
- Page title (
Freunde,Admin,Einstellungen, call participant): 28px (page) / 24px (call) / 700 / Outfit / letter-spacing -0.02em - Section label (uppercase eyebrow): 11px / 600 / tracked 0.08em
- Body labels, chat name, friend name: 14–15px / 600
- Body text, bubbles, settings hint: 12–14px / 400–500
- Meta, timestamps, handles, stat labels: 11–12px / 500, uppercase 10–11px tracked 0.06em
- Mono values: 11–12px / JetBrains Mono / 500
- Page title (
Spacing / Radius
- Page content max-width: 820px, centered, 28px top padding, 32px horizontal.
- Section card radius 16, section header padded 16×20 with bottom border, section body padded 16×20 gap 14.
- Button radius 8–10, chip/pill 999, stat card 12, device card 12.
- Toggle switch: 40×22 with 16×16 thumb, 2px inset, transitions all 0.2s.
Shadows
- Elevated card light:
0 20px 60px rgba(0,0,0,0.1) - Elevated card dark:
0 20px 60px rgba(0,0,0,0.5) - Accent button:
0 4px 14px rgba(79,70,229,0.25) - Avatar-large glow:
0 2px 6px rgba(0,0,0,0.08)
Motion
- Standard UI transition:
all 0.15s - Toggle switch thumb:
transform 0.2s - PTT hotkey capture pulse:
keyPulse 1.2s ease-in-out infinite(opacity 1↔0.6)
Tab Switching Behavior
The icon rail (.icon-rail) holds 4 primary tabs: Chats, Friends, Admin, Settings. Clicking an icon sets activeTab. Layout adapts:
activeTab === 'chats'→ renders icon-rail + chat-list + chat-area (3 columns:64px 280px 1fr).activeTab !== 'chats'→ renders icon-rail + page content (2 columns:64px 1fr). Chat list is removed. The page component (FriendsPageContent,AdminPageContent,SettingsPageContent) renders inside the chat-area slot with its own scroll container and 820px centered inner.
The .rail-layout.page-mode class toggles the grid-template-columns. IMPORTANT: this override is inlined in the entry HTML as a <style> block to avoid CSS-cache issues during dev. In production, put it in your main stylesheet.
The call dock and call screen switcher are only shown on the chats tab.
Screen 1: Chat
Structure (left → right):
- Icon Rail (64px): brand logo, tab buttons (Chats/Friends/Admin), spacer, Settings + theme-toggle at bottom. Active tab shows a vertical pill indicator on the left edge.
- Chat List (280px): header with title + "new chat" icon, search input, list of
ChatItems (avatar + name + time + preview, optional unread dot),rail-userfooter showing own profile + quick mute/settings. - Chat Area (flex): chat header (counterpart avatar + name + presence/handle + search/voice/video), messages, composer.
Message bubbles:
- Mine: filled accent bg, white text, radius
18px 18px 4px 18px. - Theirs: transparent bg, 1px line border, fg text, radius
18px 18px 18px 4px. - Avatars only on the last message of a run.
- Reactions: pill row below,
.mine= filled accent. - System events: centered
event-pill, muted bg/fg, 11px.
Screen 2: Friends
Component: FriendsPageContent (components/pages.jsx)
Layout
PageHeaderwith title "Freunde" and subtitle"{N} Freunde · {M} offene Anfragen".- Global search bar (sticky at top of scroll): icon + input, placeholder "Benutzer suchen oder hinzufügen…". Focused state → border becomes accent.
- Tabs (only when not searching): 3 pill tabs with inline counts —
Freunde,Ausgehend,Anfragen. When there are pending incoming requests, the tab count badge is red (.tab-count.highlight). - List area: either tab content OR search results, mutually exclusive.
Search behavior
- Trigger: input.length ≥ 2. Below that, tabs show.
- Filters a global user directory (API:
GET /api/users/search?q={query}— returns up to 20 matches). - Each result renders a
FriendRowwith a single primary button: "Anfrage senden" (icon + label). On click →POST /api/friendshipswith{ targetUserId }, optimistically updates the row to "Angefragt". - Empty state: "Keine Ergebnisse für „{query}"" with Search icon.
Tabs
Freunde (status === 'accepted'):
- FriendRow with: Nachricht (primary button opens conversation), Voice icon button (starts call), "Entfernen" (danger-ghost, confirms before
DELETE /api/friendships/{id}). - Empty: "Noch keine Freunde. Suche oben nach Benutzern um anzufangen."
Ausgehend (status === 'pending' && direction === 'outgoing'):
- "Wartet auf Antwort…" italicized +
btn-ghost"Abbrechen" →DELETE /api/friendships/{id}. - Empty: "Keine ausgehenden Anfragen."
Anfragen (status === 'pending' && direction === 'incoming'):
btn-primary"Annehmen" (check icon) →PATCH /api/friendships/{id}with{ accept: true };btn-ghost"Ablehnen" → same endpoint{ accept: false }.- Empty: "Keine eingehenden Anfragen."
FriendRow (shared)
12px gap row, hover → bg-2. Avatar (with presence indicator on accepted friends), name (14px/600), handle @{username} (12px muted), actions on the right.
Data shapes
type Friendship = {
id: string;
userId: string; // the OTHER user's ID
displayName: string;
username: string; // @handle
avatarColor: 'violet'|'amber'|'rose'|'teal';
initial: string; // derived from displayName[0] uppercase
status: 'accepted' | 'pending';
direction: 'outgoing' | 'incoming' | null; // null when accepted
presence?: 'online' | 'idle' | 'dnd' | 'offline';
};
Screen 3: Admin
Component: AdminPageContent (components/pages.jsx)
Gated route — only accessible if currentUser.isAdmin === true. If not, render 403 "Kein Zugriff" page.
Layout
PageHeader"Admin" + subtitle"{userCount} User · {activeInviteCount} aktive Invites".- Stats grid (4 cards, 1fr × 4 @ ≥1200px, 2×2 below):
- Gesamt User
- Admins
- Gebannt (value in
#dc2626/ dark#fb7185) - Aktive Invites
- Each card: 11px uppercase tracked label, 28px Outfit bold value, tabular-nums.
- System section: single
Toggleforinvites_enabled. Hint: "Wenn aus, können keine neuen User per Invite-Code beitreten."- Maps to
PATCH /api/admin/settings { invitesEnabled: boolean }.
- Maps to
- Invite-Codes section:
- Header action: + Neuer Code primary button → opens modal (out of scope here) with fields for
code(optional; auto-generated if blank),usesLimit(int or ∞),expiresAt(date or never). - Table columns: Code · Nutzungen · Ablauf · Status · actions.
- Code column: mono 12px.
- Status pill:
Aktiv(green),Abgelaufen(amber),Deaktiviert(red). - Actions (per row, right-aligned): Copy icon (shows check on copy, 1.4s timeout), "Deaktivieren/Aktivieren" ghost xs button, red Trash icon.
- API:
- List:
GET /api/admin/invites - Create:
POST /api/admin/invites - Toggle:
PATCH /api/admin/invites/{code} { disabled: boolean } - Delete:
DELETE /api/admin/invites/{code}
- List:
- Header action: + Neuer Code primary button → opens modal (out of scope here) with fields for
- Benutzer section:
- Row per user: avatar + name (with Admin badge if admin — pill with crown icon, accent bg +
accent-fg) +@handle · beigetreten {date}meta. - Flag chips on the right:
- Admin (toggle accent when active)
- Invite-Block (toggle accent when active) — prevents this user from creating invites
- Bann (
tone="danger"— red when active)
- Click a chip →
PATCH /api/admin/users/{userId} { [flag]: boolean }. Optimistic update. - No delete — banning is the terminal state.
- Row per user: avatar + name (with Admin badge if admin — pill with crown icon, accent bg +
Data shapes
type Invite = {
code: string;
usesCount: number;
usesLimit: number | null; // null = unlimited
expiresAt: string | null; // ISO date
disabled: boolean;
createdBy: string; // userId
createdAt: string;
};
type AdminUser = {
userId: string;
displayName: string;
username: string;
initial: string;
avatarColor: string;
isAdmin: boolean;
blockedFromInviting: boolean;
banned: boolean;
joinedAt: string; // display-formatted in list
};
Screen 4: Settings
Component: SettingsPageContent (components/pages.jsx)
Sections (in this exact order)
1. Account
avatar-controlrow: 64px circular avatar with initial, label + hint, "Ändern" primary, "Entfernen" danger-ghost.- Upload: 512×512 max, WebP only, client-side crop,
POST /api/me/avatar.
- Upload: 512×512 max, WebP only, client-side crop,
row-stackwith 3Rowentries:- Benutzername (editable — click to inline-edit,
PATCH /api/me { username }, uniqueness check) - Anzeigename (editable,
PATCH /api/me { displayName }) - User-ID (mono, read-only)
- Benutzername (editable — click to inline-edit,
2. Aussehen
- Sprache:
SegmentedControlDeutsch/English →PATCH /api/me { locale }and updates i18n context. - Theme:
SegmentedControl☀ Light / ☾ Dark → toggles dark mode globally. Wire to your theme provider; persist in localStorage +PATCH /api/me { theme }.
3. Datenschutz
ToggleLesebestätigungen (bidirectional — hint explains).ToggleDMs von Fremden erlauben.
4. Sprache & Audio
SegmentedControlAudio-Qualität:Sprache(default) vsHiFi.- Sprache: 48 kbps Mono Opus, 48 kHz, DSP on (noise suppression + echo cancellation + auto-gain).
- HiFi: 510 kbps Stereo Opus, 48 kHz, DSP off.
- Hint text updates live with the mode.
- Inline
stats-inlinebox showing the 4 resolved parameters (Bitrate / Channels / Sample / DSP). - Divider.
TogglePush-to-Talk. When enabled, an extra row appears: label "Hotkey" +.key-capturebutton showing the current key. Click → enters capture mode (pulsing animation), next keydown sets the key. Esc cancels.- Divider.
ToggleE2EE (default on). Hint: all participants must have it on.
5. Bildschirmfreigabe
SegmentedControlFlüssig / Balanced / Scharf:- Flüssig: 3 Mbps, 1280×720, 60 fps
- Balanced: 6 Mbps, 1920×1080, 30 fps (default)
- Scharf: 12 Mbps, 2560×1440, 30 fps
- Inline
stats-inlineshows resolved params.
6. Geräte
device-card(green-tinted): lock icon + "Dieses Gerät", followed by Row stack: Name, Plattform, Device-ID (mono).device-backupcard: password input + "Export erstellen" button → generates an encrypted JSON file containing the device's private key + room keys, downloaded client-side (never sent to server).
7. Danger Zone
- Single
btn-danger"Abmelden" (full red button). Triggers sign-out + clears all local state.
SegmentedControl
Inline group with 3px padding, transparent buttons, active button elevated with white bg (light) or elevated surface (dark) + shadow. 12px/500.
Toggle
40×22 track, 16×16 thumb, 999 radius. Off: bg-2 bg + line border. On: accent bg+border. Thumb translates 18px on. Full click target = entire row (via <label>).
State persistence
Every setting writes to PATCH /api/me/settings { [key]: value } on change, with a debounced save (300ms for toggles is fine, immediate for segmented controls). Show a subtle top-right toast "Gespeichert" on success (not shown in this prototype).
Screen 5: Calls (unchanged from previous scope)
See previous sections — Incoming → Active Dock → Fullscreen Cinema → PiP flow with callScreen + callMode state machine, Esc key handling, Discord-style dock above messages.
Shared Page Primitives
All in components/pages.jsx. Re-use these across Friends/Admin/Settings; they define the aesthetic rhythm of the whole app.
<PageHeader title="…" subtitle="…" actions={<button />} />
<Section title="…" action={<button />}>…</Section>
<Toggle label="…" hint="…" checked={} onChange={} />
<SegmentedControl value={} onChange={} options={[{value, label}]} />
<Row label="…" value="…" mono /> // key/value line in a stack
<Stat label="…" value="…" /> // used inside stats-inline
<Banner kind="error|success">…</Banner>
<Empty icon={Icon.X}>…</Empty> // for empty states
<FlagChip active={} tone="danger" onToggle={}>Admin</FlagChip>
<FriendRow profile={}>{actions}</FriendRow>
API Endpoints (used by pages)
Friends:
GET /api/friendships → { accepted, outgoing, incoming }
POST /api/friendships { targetUserId } → creates outgoing
PATCH /api/friendships/{id} { accept: boolean }
DELETE /api/friendships/{id}
GET /api/users/search?q=… → up to 20 users (friendships excluded)
Admin:
GET /api/admin/stats → { userCount, adminCount, bannedCount, activeInviteCount }
GET /api/admin/settings → { invitesEnabled }
PATCH /api/admin/settings { invitesEnabled }
GET /api/admin/invites → Invite[]
POST /api/admin/invites { code?, usesLimit?, expiresAt? } → Invite
PATCH /api/admin/invites/{code} { disabled }
DELETE /api/admin/invites/{code}
GET /api/admin/users → AdminUser[]
PATCH /api/admin/users/{userId} { isAdmin? | blockedFromInviting? | banned? }
Settings / Me:
GET /api/me → profile + settings
PATCH /api/me { username? | displayName? | locale? | theme? }
PATCH /api/me/settings { readReceipts? | allowDmsFromStrangers? | audioQuality? | ptt? | pttKey? | e2ee? | screenShareQuality? }
POST /api/me/avatar multipart → new avatar URL
DELETE /api/me/avatar
GET /api/me/devices → Device[]
POST /api/me/devices/backup { passphrase } → encrypted blob download
POST /api/auth/signout
Scope assumption: your codebase already has most of these (see FriendshipsContext.tsx, AdminPage.tsx, SettingsPage.tsx). Wire the new UI to the existing hooks — don't rebuild the data layer.
Call State Machine (unchanged)
const [callScreen, setCallScreen] = useState('none'); // 'none' | 'incoming' | 'active' | 'pip'
const [callMode, setCallMode] = useState('grid'); // 'grid' | 'focus' | 'fullscreen'
const [focusedId, setFocusedId] = useState(firstSharingOrSpeakerId);
const [callState, setCallState] = useState({ muted: false, sharing: false, video: false, duration });
Triggers, keyboard and visibility rules identical to previous spec.
Implementation Checklist
- Tokens: add the Clean variant light + dark CSS variables to your theme layer.
- Rail layout: refactor top-nav to icon-rail + chat list + chat area (
.rail-layout). Add.rail-layout.page-mode(2-column) for non-chat tabs. - Tab switching: lift
activeTabstate. Conditionally render chat area vs page content. - Page primitives: implement
PageHeader,Section,Toggle,SegmentedControl,Row,Stat,Banner,Empty,FlagChip,FriendRowusing your existing component library where possible. - FriendsPage: wire to
FriendshipsContext+ user-search endpoint. Tabs + search + optimistic mutations. - AdminPage: stats grid + system toggle + invites table (copy-to-clipboard, enable/disable, delete) + users list (flag chips with optimistic updates).
- SettingsPage: all 7 sections with two-way binding to user profile + settings. Debounced saves. Theme toggle wired to theme context.
- Message bubbles + call UX (already specified in previous scope — unchanged).
Notes for Claude Code
- The Clean Rail variant is Variante 2 in the prototype's variant switcher — ignore the other 5 aesthetic variants (Playful, Y2K, Cyberpunk, Warm, Brutal).
components/pages.jsxuses dummy data for display. Replace with your live contexts (FriendshipsContext, admin API hooks, user/settings hooks).- Prefer composition over copying raw CSS. If the codebase has a
Card,Button,Toggle, use them. - Keep German copy as-is — it matches the existing product. English translations via i18n are a separate task.
- The
.rail-layout.page-modegrid override is inlined in the HTML in this prototype. Move it to your main stylesheet. - All settings that affect media (audio quality, PTT, E2EE, screen-share quality) must be read at call-start time by the existing call code — they do not hot-apply to an active call.