+
+ {menuOpen && (
+ <>
+
setMenuOpen(false)} />
+
+
{user.username}
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/UserAvatar.css b/frontend/src/components/UserAvatar.css
new file mode 100644
index 0000000..f512e15
--- /dev/null
+++ b/frontend/src/components/UserAvatar.css
@@ -0,0 +1,10 @@
+.user-avatar {
+ border-radius: var(--radius-pill);
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+ color: var(--ds-text);
+ font-size: 0.68rem;
+}
diff --git a/frontend/src/components/UserAvatar.tsx b/frontend/src/components/UserAvatar.tsx
new file mode 100644
index 0000000..92081fa
--- /dev/null
+++ b/frontend/src/components/UserAvatar.tsx
@@ -0,0 +1,19 @@
+import { accentForIndex, initials } from '../lib/avatar'
+import './UserAvatar.css'
+
+interface UserAvatarProps {
+ username: string
+ colorIndex: number
+ size?: number
+}
+
+export function UserAvatar({ username, colorIndex, size = 28 }: UserAvatarProps) {
+ return (
+
+ {initials(username)}
+
+ )
+}
diff --git a/frontend/src/hooks/useWindowWidth.ts b/frontend/src/hooks/useWindowWidth.ts
new file mode 100644
index 0000000..69c32e9
--- /dev/null
+++ b/frontend/src/hooks/useWindowWidth.ts
@@ -0,0 +1,15 @@
+import { useEffect, useState } from 'react'
+
+export function useWindowWidth(): number {
+ const [width, setWidth] = useState(() => window.innerWidth)
+
+ useEffect(() => {
+ const onResize = () => setWidth(window.innerWidth)
+ window.addEventListener('resize', onResize)
+ return () => window.removeEventListener('resize', onResize)
+ }, [])
+
+ return width
+}
+
+export const MOBILE_BREAKPOINT = 860
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 36ea3c0..a9e2e6d 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,20 +1 @@
-:root {
- font-family: system-ui, sans-serif;
- color-scheme: light dark;
-}
-
-body {
- margin: 0;
-}
-
-input,
-button {
- font: inherit;
- padding: 0.4rem;
-}
-
-label {
- display: flex;
- flex-direction: column;
- gap: 0.25rem;
-}
+@import './styles/tokens.css';
diff --git a/frontend/src/lib/avatar.ts b/frontend/src/lib/avatar.ts
new file mode 100644
index 0000000..8a0a81a
--- /dev/null
+++ b/frontend/src/lib/avatar.ts
@@ -0,0 +1,20 @@
+const ACCENT_CYCLE = [
+ 'var(--accent-cycle-1)',
+ 'var(--accent-cycle-2)',
+ 'var(--accent-cycle-3)',
+ 'var(--accent-cycle-4)',
+]
+
+export function initials(name: string): string {
+ return name
+ .split(' ')
+ .filter(Boolean)
+ .map((w) => w[0])
+ .join('')
+ .slice(0, 2)
+ .toUpperCase()
+}
+
+export function accentForIndex(index: number): string {
+ return ACCENT_CYCLE[((index % ACCENT_CYCLE.length) + ACCENT_CYCLE.length) % ACCENT_CYCLE.length]
+}
diff --git a/frontend/src/lib/messageGrouping.ts b/frontend/src/lib/messageGrouping.ts
new file mode 100644
index 0000000..d777dbd
--- /dev/null
+++ b/frontend/src/lib/messageGrouping.ts
@@ -0,0 +1,11 @@
+import type { RoomMember } from '../types'
+
+export function senderColorIndex(username: string, members: RoomMember[]): number {
+ const idx = members.findIndex((m) => m.username === username)
+ if (idx >= 0) return idx
+ // Fallback for a sender no longer in the room (e.g. they left): derive a
+ // stable index from the username instead of always colliding on 0.
+ let hash = 0
+ for (let i = 0; i < username.length; i++) hash = (hash * 31 + username.charCodeAt(i)) | 0
+ return Math.abs(hash)
+}
diff --git a/frontend/src/pages/ChatRoomPage.tsx b/frontend/src/pages/ChatRoomPage.tsx
deleted file mode 100644
index c74a465..0000000
--- a/frontend/src/pages/ChatRoomPage.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useCallback, useEffect, useState } from 'react'
-import { useNavigate, useParams } from 'react-router-dom'
-import { getRoomMessages } from '../api/rooms'
-import { MessageInput } from '../components/MessageInput'
-import { MessageList } from '../components/MessageList'
-import { useAuth } from '../context/AuthContext'
-import { useChatSocket } from '../ws/useChatSocket'
-import type { ChatMessageEnvelope, Message, ServerEnvelope } from '../types'
-
-export function ChatRoomPage() {
- const { roomId } = useParams<{ roomId: string }>()
- const { user } = useAuth()
- const navigate = useNavigate()
- const [history, setHistory] = useState
([])
- const [live, setLive] = useState([])
- const [wsError, setWsError] = useState(null)
-
- useEffect(() => {
- if (!roomId) return
- setHistory([])
- setLive([])
- getRoomMessages(roomId).then(setHistory).catch((err) => setWsError(String(err)))
- }, [roomId])
-
- const onMessage = useCallback((envelope: ServerEnvelope) => {
- if (envelope.type === 'message') {
- setLive((prev) => [...prev, envelope])
- } else if (envelope.type === 'error') {
- setWsError(envelope.detail)
- }
- }, [])
-
- const onUnauthenticated = useCallback(() => navigate('/login'), [navigate])
-
- const { connected, send } = useChatSocket({
- roomId: roomId ?? '',
- onMessage,
- onUnauthenticated,
- })
-
- if (!roomId) return null
-
- const usernames = user ? { [user.id]: user.username } : {}
-
- return (
-
-
-
- {!connected && Connecting...}
-
-
- {wsError &&
{wsError}
}
-
-
-
-
- )
-}
diff --git a/frontend/src/pages/ChatShellPage.css b/frontend/src/pages/ChatShellPage.css
new file mode 100644
index 0000000..ff19006
--- /dev/null
+++ b/frontend/src/pages/ChatShellPage.css
@@ -0,0 +1,25 @@
+.chat-shell {
+ height: 100vh;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ background: var(--ds-void);
+ overflow: hidden;
+}
+
+.chat-shell-body {
+ flex: 1;
+ display: flex;
+ min-height: 0;
+ position: relative;
+}
+
+.chat-empty-state {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--ds-muted);
+ font-size: 0.92rem;
+ background: var(--ds-void);
+}
diff --git a/frontend/src/pages/ChatShellPage.tsx b/frontend/src/pages/ChatShellPage.tsx
new file mode 100644
index 0000000..25a2c0d
--- /dev/null
+++ b/frontend/src/pages/ChatShellPage.tsx
@@ -0,0 +1,149 @@
+import { useCallback, useEffect, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { listMyInvites } from '../api/invites'
+import { listMyRooms, listRoomMembers } from '../api/rooms'
+import { BrowseRoomsModal } from '../components/BrowseRoomsModal'
+import { ChatPane } from '../components/ChatPane'
+import { InvitesModal } from '../components/InvitesModal'
+import { NewRoomModal } from '../components/NewRoomModal'
+import { RoomInfoPanel } from '../components/RoomInfoPanel'
+import { Sidebar } from '../components/Sidebar'
+import { TopBar } from '../components/TopBar'
+import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
+import type { MyRoomItem, RoomMember } from '../types'
+import './ChatShellPage.css'
+
+type ModalKind = 'new' | 'browse' | 'invites' | null
+
+export function ChatShellPage() {
+ const { roomId } = useParams<{ roomId?: string }>()
+ const navigate = useNavigate()
+ const width = useWindowWidth()
+ const isMobile = width < MOBILE_BREAKPOINT
+
+ const [rooms, setRooms] = useState([])
+ const [search, setSearch] = useState('')
+ const [members, setMembers] = useState([])
+ const [inviteCount, setInviteCount] = useState(0)
+ const [infoOpen, setInfoOpen] = useState(false)
+ const [modal, setModal] = useState(null)
+
+ const activeRoom = rooms.find((r) => r.id === roomId)
+
+ const refreshRooms = useCallback(async () => {
+ const list = await listMyRooms()
+ setRooms(list)
+ return list
+ }, [])
+
+ const refreshMembers = useCallback(() => {
+ if (!roomId) return
+ listRoomMembers(roomId).then(setMembers).catch(() => setMembers([]))
+ }, [roomId])
+
+ useEffect(() => {
+ refreshRooms().catch(() => {})
+ }, [refreshRooms])
+
+ useEffect(() => {
+ listMyInvites()
+ .then((list) => setInviteCount(list.length))
+ .catch(() => {})
+ }, [])
+
+ useEffect(() => {
+ refreshMembers()
+ }, [refreshMembers])
+
+ function goToRoom(id: string) {
+ navigate(`/rooms/${id}`)
+ }
+
+ return (
+
+
+
+ {(!isMobile || !roomId) && (
+
setModal('new')}
+ onOpenBrowse={() => setModal('browse')}
+ onOpenInvites={() => setModal('invites')}
+ inviteCount={inviteCount}
+ />
+ )}
+
+ {(!isMobile || roomId) &&
+ (activeRoom ? (
+ navigate('/rooms')}
+ onToggleInfo={() => setInfoOpen((v) => !v)}
+ infoOpen={infoOpen}
+ />
+ ) : (
+ !isMobile && (
+
+
Select a room to start chatting.
+
+ )
+ ))}
+
+ {!isMobile && infoOpen && activeRoom && (
+ setInfoOpen(false)}
+ onMembersChanged={refreshMembers}
+ onRoomUpdated={() => refreshRooms()}
+ onRoomDeleted={() => {
+ setInfoOpen(false)
+ navigate('/rooms')
+ refreshRooms()
+ }}
+ onLeft={() => {
+ setInfoOpen(false)
+ navigate('/rooms')
+ refreshRooms()
+ }}
+ />
+ )}
+
+
+ {modal === 'new' && (
+
setModal(null)}
+ onCreated={(id) => {
+ setModal(null)
+ refreshRooms().then(() => goToRoom(id))
+ }}
+ />
+ )}
+ {modal === 'browse' && (
+ setModal(null)}
+ onJoined={(id) => {
+ setModal(null)
+ refreshRooms().then(() => goToRoom(id))
+ }}
+ />
+ )}
+ {modal === 'invites' && (
+ setModal(null)}
+ onInvitesChanged={setInviteCount}
+ onAccepted={(id) => {
+ setModal(null)
+ refreshRooms().then(() => goToRoom(id))
+ }}
+ />
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/LoginPage.css b/frontend/src/pages/LoginPage.css
new file mode 100644
index 0000000..52ea40b
--- /dev/null
+++ b/frontend/src/pages/LoginPage.css
@@ -0,0 +1,92 @@
+.login-screen {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--sp-6);
+ background:
+ radial-gradient(600px 400px at 50% -10%, color-mix(in srgb, var(--ds-accent-3) 18%, transparent), transparent 70%),
+ var(--ds-void);
+}
+
+.login-card {
+ width: min(380px, 100%);
+ background: var(--card-bg);
+ border: 1px solid var(--ds-border);
+ border-radius: var(--radius);
+ padding: var(--sp-8) var(--sp-6);
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-6);
+}
+
+.login-brand {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--sp-2);
+}
+
+.login-brand img {
+ width: 40px;
+ height: 40px;
+ border-radius: 8px;
+ object-fit: cover;
+}
+
+.login-brand span {
+ font-weight: 800;
+ font-size: 1.45rem;
+}
+
+.login-copy {
+ text-align: center;
+ color: var(--ds-muted);
+ font-size: 0.9rem;
+ line-height: 1.5;
+ margin: calc(-1 * var(--sp-4)) 0 0;
+}
+
+.login-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-4);
+}
+
+.login-form label {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ font-size: 0.78rem;
+ color: var(--ds-muted);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.login-form input {
+ background: var(--ds-void);
+ border: 1px solid var(--ds-border);
+ border-radius: var(--radius);
+ padding: 9px 11px;
+ font-size: 0.9rem;
+ color: var(--ds-text);
+ text-transform: none;
+ letter-spacing: 0;
+ font-weight: 400;
+}
+
+.login-form input:focus {
+ border-color: var(--ds-accent);
+ outline: none;
+}
+
+.login-error {
+ font-size: 0.86rem;
+ color: var(--ds-danger);
+ border: 1px solid color-mix(in srgb, var(--ds-danger) 40%, transparent);
+ background: color-mix(in srgb, var(--ds-danger) 10%, transparent);
+ border-radius: var(--radius);
+ padding: 8px 10px;
+ margin: 0;
+}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
index c5913a9..6d26d64 100644
--- a/frontend/src/pages/LoginPage.tsx
+++ b/frontend/src/pages/LoginPage.tsx
@@ -2,6 +2,8 @@ import { useState, type FormEvent } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { ApiError } from '../api/client'
+import logo from '../assets/logo.png'
+import './LoginPage.css'
export function LoginPage() {
const { user, login } = useAuth()
@@ -28,28 +30,33 @@ export function LoginPage() {
}
return (
-
-
KeepItTalking
-
This is an invite-only site. Ask an admin for an account.
-
+
+
+
+

+
KeepItTalking
+
+
This is an invite-only site. Ask an admin for an account.
+
+
)
}
diff --git a/frontend/src/pages/RoomListPage.tsx b/frontend/src/pages/RoomListPage.tsx
deleted file mode 100644
index dc8f851..0000000
--- a/frontend/src/pages/RoomListPage.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { useEffect, useState, type FormEvent } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { createRoom, joinRoom, listRooms } from '../api/rooms'
-import { RoomListItem } from '../components/RoomListItem'
-import { useAuth } from '../context/AuthContext'
-import type { RoomListItem as RoomListItemType } from '../types'
-
-export function RoomListPage() {
- const { user, logout } = useAuth()
- const navigate = useNavigate()
- const [rooms, setRooms] = useState
([])
- const [newRoomName, setNewRoomName] = useState('')
- const [joiningId, setJoiningId] = useState(null)
- const [error, setError] = useState(null)
-
- async function refresh() {
- setRooms(await listRooms())
- }
-
- useEffect(() => {
- refresh().catch((err) => setError(String(err)))
- }, [])
-
- async function handleCreate(e: FormEvent) {
- e.preventDefault()
- const name = newRoomName.trim()
- if (!name) return
- setError(null)
- try {
- await createRoom(name)
- setNewRoomName('')
- await refresh()
- } catch (err) {
- setError(String(err))
- }
- }
-
- async function handleJoin(roomId: string) {
- setJoiningId(roomId)
- setError(null)
- try {
- await joinRoom(roomId)
- await refresh()
- navigate(`/rooms/${roomId}`)
- } catch (err) {
- setError(String(err))
- } finally {
- setJoiningId(null)
- }
- }
-
- return (
-
-
-
-
-
- {error &&
{error}
}
-
-
- {rooms.map((room) => (
-
- ))}
-
-
- )
-}
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
new file mode 100644
index 0000000..01869ea
--- /dev/null
+++ b/frontend/src/styles/tokens.css
@@ -0,0 +1,142 @@
+:root {
+ color-scheme: dark;
+
+ /* DarkSingularity brand tokens (Style-Guide/BRAND_STYLE_GUIDE.md), verbatim */
+ --ds-void: #07080f;
+ --ds-surface: #101030;
+ --ds-surface-2: #181848;
+ --ds-border: #242478;
+ --ds-text: #fce4fc;
+ --ds-muted: #c0ccd8;
+ --ds-accent: #60d8fc;
+ --ds-accent-2: #6c60fc;
+ --ds-accent-3: #7848fc;
+ --ds-highlight: #f060fc;
+
+ /* Sidebar/list background used in the design handoff -- one step darker
+ than --ds-surface, not itself a named brand token. */
+ --ds-void-2: #0b0c1a;
+
+ /* Derived, not in the brand guide: destructive/danger action color, kept
+ in the same neon-on-black family since the guide has no semantic red. */
+ --ds-danger: #fc6060;
+
+ --sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
+ --mono: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+
+ --sp-1: 0.25rem;
+ --sp-2: 0.5rem;
+ --sp-3: 0.75rem;
+ --sp-4: 1rem;
+ --sp-6: 1.5rem;
+ --sp-8: 2rem;
+
+ --radius: 8px;
+ --radius-pill: 999px;
+
+ --card-bg: linear-gradient(180deg, rgba(24, 24, 72, 0.96), rgba(16, 16, 48, 0.96));
+
+ --accent-cycle-1: var(--ds-accent);
+ --accent-cycle-2: var(--ds-accent-2);
+ --accent-cycle-3: var(--ds-accent-3);
+ --accent-cycle-4: var(--ds-highlight);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ background: var(--ds-void);
+ color: var(--ds-text);
+ font-family: var(--sans);
+ -webkit-font-smoothing: antialiased;
+}
+
+input,
+textarea,
+button {
+ font: inherit;
+ color: inherit;
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--ds-border);
+ border-radius: var(--radius);
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+:focus-visible {
+ outline: 2px solid var(--ds-accent);
+ outline-offset: 2px;
+}
+
+/* Shared buttons -- used across modals, panels, and pages */
+.btn-primary {
+ background: var(--ds-accent);
+ color: var(--ds-void);
+ border: 1px solid transparent;
+ border-radius: var(--radius);
+ padding: 8px 14px;
+ font-size: 0.86rem;
+ font-weight: 700;
+ cursor: pointer;
+}
+.btn-primary:hover {
+ background: #8ae4fc;
+}
+.btn-primary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-secondary {
+ background: transparent;
+ color: var(--ds-text);
+ border: 1px solid var(--ds-border);
+ border-radius: var(--radius);
+ padding: 7px 12px;
+ font-size: 0.84rem;
+ font-weight: 700;
+ cursor: pointer;
+}
+.btn-secondary:hover {
+ border-color: var(--ds-accent);
+}
+.btn-secondary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+@keyframes ds-typing-dot {
+ 0%,
+ 60%,
+ 100% {
+ opacity: 0.25;
+ transform: translateY(0);
+ }
+ 30% {
+ opacity: 1;
+ transform: translateY(-2px);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ * {
+ animation-duration: 0.001ms !important;
+ transition-duration: 0.001ms !important;
+ }
+}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 29234eb..bd051b1 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -7,6 +7,8 @@ export interface User {
created_at: string
}
+export type RoomRole = 'owner' | 'admin' | 'member'
+
export interface Room {
id: string
name: string
@@ -20,10 +22,40 @@ export interface RoomListItem extends Room {
is_member: boolean
}
+export interface MyRoomItem extends Room {
+ role: RoomRole
+}
+
+export interface RoomMember {
+ user_id: string
+ username: string
+ role: RoomRole
+ joined_at: string
+}
+
+export type InviteStatus = 'pending' | 'accepted' | 'revoked'
+
+export interface Invite {
+ id: string
+ room_id: string
+ invited_by: string
+ target_user_id: string | null
+ target_username: string | null
+ status: InviteStatus
+ expires_at: string
+ created_at: string
+}
+
+export interface MyInvite extends Invite {
+ room_name: string
+ invited_by_username: string
+}
+
export interface Message {
id: string
room_id: string
user_id: string
+ username: string
content: string
created_at: string
}