From 94d3505b82fc39e5aa21892faee8903cf13ba3bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:38:28 +0000 Subject: [PATCH] Lobby: tabs, live chat with whispers, event log, embedded read-only character sheets Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K --- README.md | 11 +- backend/app/admin.py | 1 + backend/app/main.py | 77 +++++++++++- frontend/src/App.jsx | 2 +- frontend/src/Lobbies.jsx | 236 ++++++++++++++++++++++++++++--------- frontend/src/SheetView.jsx | 57 +++++++++ frontend/src/styles.css | 22 ++++ 7 files changed, 343 insertions(+), 63 deletions(-) create mode 100644 frontend/src/SheetView.jsx diff --git a/README.md b/README.md index 7a254fa..b386b80 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,13 @@ Each wizard page has a **Reset this page** button that clears only that page's d The **Lobbies** tab groups a campaign. Whoever creates a lobby is its DM and gets an 8-character join code. Players join with the code and pick one of their saved characters (they can switch it later or leave). -- The DM can open **every** character in the lobby (read-only, always the current saved version) and can remove players, rename or delete the lobby and generate a new code. -- Players see their own character in full, but only a short card of the others (name, concept, class, profession). -- Guessing join codes is rate limited. Deleting a character takes it out of the lobbies; deleting a lobby does not delete characters. +A lobby has tabs: + +- **Chat**: live chat for everyone, plus **whispers** to a single person (pick them in the dropdown next to the message field). Whispers are only visible to sender and recipient, the DM does not see other people's whispers. The same feed shows the **event log** (joins, leaves, character switches, renames, new codes); it can be hidden with a checkbox. The chat polls the server every few seconds, keeps the last 1000 entries per lobby and allows 500 characters per message and 30 messages per minute. +- **Party**: member cards (name, concept, class, profession). The DM also finds the join code, rename, remove player and delete lobby here. +- **One sheet tab per character**: the DM gets a tab for every character in the lobby (read-only, always the current saved version, refreshed every 15 s); a player gets "My character". Hover or tap a value to see how it is made up. "Open in the Character tab" jumps to the full editor (for the DM it is a read-only look, "Save" would create the DM's own copy). + +Players only see a short card of the other players, never their sheets. Guessing join codes is rate limited. Deleting a character takes it out of the lobbies (the player can pick another); deleting a lobby does not delete characters. ## API @@ -106,6 +110,7 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l | POST | `/api/lobbies/join` | Join with `{code, character_id}` | | GET / PUT / DELETE | `/api/lobbies/{id}` | Members and cards (DM sees all ids) / rename / delete (DM only) | | POST | `/api/lobbies/{id}/code` | New join code (DM only) | +| GET / POST | `/api/lobbies/{id}/messages` | Chat and event log (`?after=` to poll) / send, optional `to` = whisper | | PUT / DELETE | `/api/lobbies/{id}/me` | Switch your character / leave | | DELETE | `/api/lobbies/{id}/members/{username}` | Remove a player (DM only) | | POST | `/api/presets` | Create a preset | diff --git a/backend/app/admin.py b/backend/app/admin.py index 96a22c0..d3062d2 100644 --- a/backend/app/admin.py +++ b/backend/app/admin.py @@ -34,6 +34,7 @@ def main(argv): con.execute("DELETE FROM sessions WHERE user_id=?", (row["id"],)) con.execute("DELETE FROM shares WHERE user_id=?", (row["id"],)) con.execute("DELETE FROM lobby_members WHERE user_id=? OR lobby_id IN (SELECT id FROM lobbies WHERE owner_id=?)", (row["id"], row["id"])) + con.execute("DELETE FROM lobby_messages WHERE lobby_id IN (SELECT id FROM lobbies WHERE owner_id=?)", (row["id"],)) con.execute("DELETE FROM lobbies WHERE owner_id=?", (row["id"],)) con.execute("DELETE FROM users WHERE id=?", (row["id"],)) print("User deleted. Their lobbies are removed; their characters and presets stay on the server without an owner.") diff --git a/backend/app/main.py b/backend/app/main.py index dd2c655..2995cae 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,6 +24,8 @@ MAX_CHARACTERS = 300 # per user MAX_SHARES = 30 # per character MAX_LOBBIES = 20 # per DM MAX_MEMBERS = 20 # per lobby +MAX_MESSAGE = 500 # characters per chat message +KEEP_MESSAGES = 1000 # per lobby, older ones are dropped CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I # No public API docs: everything about the API stays behind the login @@ -56,6 +58,10 @@ def db() -> sqlite3.Connection: con.execute("""CREATE TABLE IF NOT EXISTS lobby_members ( lobby_id TEXT NOT NULL, user_id INTEGER NOT NULL, character_id TEXT, joined REAL NOT NULL, PRIMARY KEY (lobby_id, user_id))""") + con.execute("""CREATE TABLE IF NOT EXISTS lobby_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, lobby_id TEXT NOT NULL, user_id INTEGER, to_user_id INTEGER, + kind TEXT NOT NULL, text TEXT NOT NULL, created REAL NOT NULL)""") + con.execute("CREATE INDEX IF NOT EXISTS lobby_messages_lobby ON lobby_messages (lobby_id, id)") for table in ("characters", "presets"): # older databases: add the owner column if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]: con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER") @@ -420,6 +426,17 @@ def _own_character(con, cid, user: dict): raise HTTPException(422, "Pick one of your own characters") +def _event(con, lid: str, text: str, uid=None): + """Writes a line into the lobby's event log (shown in the chat feed to everybody).""" + con.execute("INSERT INTO lobby_messages (lobby_id, user_id, kind, text, created) VALUES (?,?, 'event', ?, ?)", + (lid, uid, text, time.time())) + + +def _char_name(con, cid) -> str: + row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone() + return _name(row["data"]) if row else "a character" + + def _card(data: str, rules: dict) -> dict: """The superficial look at a character that fellow players get: name, concept and class/profession names only.""" ch = json.loads(data) @@ -456,6 +473,7 @@ def create_lobby(body: dict = Body(...), user: dict = Depends(current_user)): lid, code = uuid.uuid4().hex[:12], _new_code(con) con.execute("INSERT INTO lobbies (id, name, owner_id, code, created) VALUES (?,?,?,?,?)", (lid, name, user["id"], code, time.time())) + _event(con, lid, f"{user['username']} created the lobby", user["id"]) return {"id": lid, "code": code} @@ -479,6 +497,7 @@ def join_lobby(request: Request, body: dict = Body(...), user: dict = Depends(cu con.execute("""INSERT INTO lobby_members (lobby_id, user_id, character_id, joined) VALUES (?,?,?,?) ON CONFLICT(lobby_id, user_id) DO UPDATE SET character_id=excluded.character_id""", (lobby["id"], user["id"], body["character_id"], time.time())) + _event(con, lobby["id"], f"{user['username']} {'switched to' if known else 'joined as'} {_char_name(con, body['character_id'])}", user["id"]) return {"id": lobby["id"]} @@ -502,6 +521,50 @@ def read_lobby(lid: str, user: dict = Depends(current_user)): "code": lobby["code"] if is_dm else None, "members": members} +@api.get("/lobbies/{lid}/messages") +def read_messages(lid: str, after: int = 0, user: dict = Depends(current_user)): + """Chat and event log. Whispers are only returned to the sender and the recipient (not even the DM sees them). + after=0 returns the latest 100 entries, otherwise everything newer than that id (the client polls).""" + with db() as con: + _lobby_for(con, lid, user) + q = """SELECT m.id, m.kind, m.text, m.created, m.user_id, m.to_user_id, f.username AS sender, t.username AS target + FROM lobby_messages m LEFT JOIN users f ON f.id = m.user_id LEFT JOIN users t ON t.id = m.to_user_id + WHERE m.lobby_id=? AND (m.to_user_id IS NULL OR m.to_user_id=? OR m.user_id=?)""" + if after > 0: + rows = con.execute(q + " AND m.id>? ORDER BY m.id LIMIT 200", (lid, user["id"], user["id"], after)).fetchall() + else: + rows = list(reversed(con.execute(q + " ORDER BY m.id DESC LIMIT 100", (lid, user["id"], user["id"])).fetchall())) + return [{"id": r["id"], "kind": r["kind"], "text": r["text"], "time": r["created"], "from": r["sender"] or "", + "to": r["target"] or "", "mine": r["user_id"] == user["id"] and r["kind"] != "event"} for r in rows] + + +@api.post("/lobbies/{lid}/messages") +def send_message(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): + text = str(body.get("text", "")).strip() + if not text: + raise HTTPException(422, "Write something first") + if len(text) > MAX_MESSAGE: + raise HTTPException(422, f"Messages can have at most {MAX_MESSAGE} characters") + throttle(f"chat:{user['id']}", 30, 60) # 30 messages per minute + limiter.fail(f"chat:{user['id']}") + with db() as con: + lobby = _lobby_for(con, lid, user) + to = str(body.get("to") or "").strip() + to_id = None + if to: + target = con.execute("""SELECT u.id FROM users u WHERE u.username=? AND (u.id=? OR EXISTS + (SELECT 1 FROM lobby_members m WHERE m.lobby_id=? AND m.user_id=u.id))""", + (to, lobby["owner_id"], lid)).fetchone() + if not target or target["id"] == user["id"]: + raise HTTPException(422, "You can only whisper to other people in this lobby") + to_id = target["id"] + cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)", + (lid, user["id"], to_id, "whisper" if to_id else "chat", text, time.time())) + con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE( + (SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES)) + return {"id": cur.lastrowid} + + @api.put("/lobbies/{lid}") def rename_lobby(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): name = str(body.get("name", "")).strip()[:60] @@ -510,6 +573,7 @@ def rename_lobby(lid: str, body: dict = Body(...), user: dict = Depends(current_ with db() as con: _dm_only(_lobby_for(con, lid, user), user) con.execute("UPDATE lobbies SET name=? WHERE id=?", (name, lid)) + _event(con, lid, f"The lobby was renamed to \"{name}\"", user["id"]) return {"ok": True} @@ -518,6 +582,7 @@ def delete_lobby(lid: str, user: dict = Depends(current_user)): with db() as con: _dm_only(_lobby_for(con, lid, user), user) con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,)) + con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,)) con.execute("DELETE FROM lobbies WHERE id=?", (lid,)) return {"ok": True} @@ -529,6 +594,7 @@ def new_lobby_code(lid: str, user: dict = Depends(current_user)): _dm_only(_lobby_for(con, lid, user), user) code = _new_code(con) con.execute("UPDATE lobbies SET code=? WHERE id=?", (code, lid)) + _event(con, lid, "The DM created a new join code", user["id"]) return {"code": code} @@ -541,6 +607,7 @@ def switch_lobby_character(lid: str, body: dict = Body(...), user: dict = Depend (body["character_id"], lid, user["id"])) if not cur.rowcount: raise HTTPException(422, "You are the DM of this lobby") + _event(con, lid, f"{user['username']} switched to {_char_name(con, body['character_id'])}", user["id"]) return {"ok": True} @@ -548,7 +615,9 @@ def switch_lobby_character(lid: str, body: dict = Body(...), user: dict = Depend def leave_lobby(lid: str, user: dict = Depends(current_user)): with db() as con: _lobby_for(con, lid, user) - con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])) + gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).rowcount + if gone: + _event(con, lid, f"{user['username']} left the lobby", user["id"]) return {"ok": True} @@ -556,8 +625,10 @@ def leave_lobby(lid: str, user: dict = Depends(current_user)): def kick_member(lid: str, username: str, user: dict = Depends(current_user)): with db() as con: _dm_only(_lobby_for(con, lid, user), user) - con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=(SELECT id FROM users WHERE username=?)", - (lid, username)) + gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=(SELECT id FROM users WHERE username=?)", + (lid, username)).rowcount + if gone: + _event(con, lid, f"{username} was removed by the DM", user["id"]) return {"ok": True} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 464b5b3..eff8a7b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -79,7 +79,7 @@ export default function App() { onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} /> )} {rules && page === 'lobbies' && ( - { setLoadRequest(entry); window.location.hash = 'creator' }} /> + { setLoadRequest(entry); window.location.hash = 'creator' }} /> )} {page === 'account' && } diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx index a3fb83d..73fba98 100644 --- a/frontend/src/Lobbies.jsx +++ b/frontend/src/Lobbies.jsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import SheetView from './SheetView.jsx' const J = { 'Content-Type': 'application/json' } const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) }) @@ -111,7 +112,7 @@ function Card({ m, isDm, onOpen, onKick }) {
{m.username}{m.me ? ' (you)' : ''}
- {m.character_id && } + {m.character_id && } {isDm && }
@@ -125,14 +126,113 @@ function Card({ m, isDm, onOpen, onKick }) { ) } -// ---- one lobby (DM view or player view) -function LobbyView({ id, onOpen }) { +const clock = (t) => new Date(t * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + +// ---- chat, whispers and event log (polls the server every few seconds while the tab is open) +function Chat({ id, me, people, onEvent }) { + const [msgs, setMsgs] = useState([]) + const [text, setText] = useState('') + const [to, setTo] = useState('') + const [events, setEvents] = useState(true) + const [err, setErr] = useState('') + const last = useRef(0) + const box = useRef(null) + const stick = useRef(true) + + const poll = async () => { + if (document.hidden) return + const r = await fetch(`/api/lobbies/${id}/messages?after=${last.current}`).catch(() => null) + if (!r || !r.ok) return + const fresh = await r.json() + if (!fresh.length) return + last.current = fresh[fresh.length - 1].id + setMsgs((old) => [...old, ...fresh].slice(-300)) + if (fresh.some((m) => m.kind === 'event')) onEvent() + } + useEffect(() => { + poll() + const t = setInterval(poll, 2500) + return () => clearInterval(t) + }, [id]) // eslint-disable-line + useEffect(() => { // keep the newest line in view unless the user scrolled up + const el = box.current + if (el && stick.current) el.scrollTop = el.scrollHeight + }, [msgs, events]) + + const submit = async (e) => { + e.preventDefault(); setErr('') + if (!text.trim()) return + const r = await send_(id, text, to) + if (!r.ok) return setErr(msg(await r.json().catch(() => ({})), 'Could not send.')) + setText(''); stick.current = true; poll() + } + const shown = msgs.filter((m) => events || m.kind !== 'event') + + return ( +
+
{ const el = e.currentTarget; stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40 }} + role="log" aria-live="polite"> + {shown.length === 0 &&

Nothing here yet.

} + {shown.map((m) => m.kind === 'event' ? ( +
{clock(m.time)} {m.text}
+ ) : ( +
+ {clock(m.time)}{' '} + {m.mine ? 'You' : m.from || '?'} + {m.kind === 'whisper' && {m.mine ? `whisper to ${m.to}` : 'whispers to you'}} + : {m.text} +
+ ))} +
+
+ + setText(e.target.value)} placeholder={to ? `Whisper to ${to} …` : 'Message …'} maxLength={500} aria-label="Message" autoComplete="off" /> + +
+ {err &&

{err}

} + +

Whispers are only seen by you and the person you send them to, the DM does not see other people's whispers.

+
+ ) +} +const send_ = (id, text, to) => send(`/api/lobbies/${id}/messages`, 'POST', { text, to }) + +// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves +function CharTab({ cid, owner, rules, onOpen, name }) { + const [data, setData] = useState(null) + const [err, setErr] = useState('') + useEffect(() => { + setData(null); setErr('') + const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then(setData).catch(() => setErr('Could not load this character.')) + load() + const t = setInterval(() => !document.hidden && load(), 15000) + return () => clearInterval(t) + }, [cid]) + return ( +
+
+

{data?.name || name || 'Nameless'}

+ +
+

{owner ? `Played by ${owner}. ` : ''}Read-only, hover or tap a value to see how it is made up.

+ {err &&

{err}

} + {data && } +
+ ) +} + +// ---- one lobby: Chat, Party (members, settings) and a sheet tab per visible character +function LobbyView({ id, rules, user, onOpen }) { const mine = useMine() const [lobby, setLobby] = useState(null) const [err, setErr] = useState('') const [info, setInfo] = useState('') const [pick, setPick] = useState('') const [rename, setRename] = useState('') + const [tab, setTab] = useState('chat') const load = () => fetch(`/api/lobbies/${id}`).then(async (r) => { if (!r.ok) throw new Error(r.status) @@ -140,7 +240,7 @@ function LobbyView({ id, onOpen }) { }).catch((e) => { if (String(e.message) === '404') setErr('This lobby does not exist or you are not part of it.') }) useEffect(() => { load() - const t = setInterval(load, 15000) // pick up joins and character changes of the others + const t = setInterval(() => !document.hidden && load(), 15000) // picks up joins and character changes of the others return () => clearInterval(t) }, [id]) // eslint-disable-line @@ -152,69 +252,93 @@ function LobbyView({ id, onOpen }) { done ? done(d) : load() } - if (err && !lobby) return

{err}

Back to all lobbies
- if (!lobby) return

Loading …

+ if (err && !lobby) return

{err}

Back to all lobbies
+ if (!lobby) return

Loading …

const me = lobby.members.find((m) => m.me) const open = (m) => onOpen({ id: m.character_id, name: m.character?.name, ...(m.me ? {} : { owner: m.username }) }) - + const people = [lobby.dm, ...lobby.members.map((m) => m.username)] + const sheets = lobby.members.filter((m) => m.character_id) // the server only gives out ids the viewer may open + const active = tab.startsWith('sheet:') && !sheets.find((m) => m.username === tab.slice(6)) ? 'party' : tab + return ( -
+

← All lobbies

-

{lobby.name}

-

DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}

+
+

{lobby.name}

+ DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''} +
{err &&

{err}

} {info &&

{info}

} - {lobby.is_dm && ( -
-
-
-
Join code – give it to your players
- {lobby.code} -
-
- - -
-
-
{ e.preventDefault(); if (rename.trim()) act(`/api/lobbies/${id}`, 'PUT', { name: rename }, () => { setRename(''); load() }) }}> - setRename(e.target.value)} placeholder="Rename lobby" aria-label="New lobby name" maxLength={60} /> - -
-
- )} - -

Characters ({lobby.members.length})

- {lobby.members.length === 0 &&

Nobody has joined yet. Share the code above.

} -
    - {lobby.members.map((m) => ( - window.confirm(`Remove ${x.username} from the lobby?`) && act(`/api/lobbies/${id}/members/${encodeURIComponent(x.username)}`, 'DELETE')} /> +
    + + + {sheets.map((m) => ( + ))} -
- {lobby.is_dm &&

As DM you can open every character here, but only read it. "Save" in the Character tab would create your own copy.

} - - {!lobby.is_dm && me && ( -
-

Your character

-
{ e.preventDefault(); if (pick) act(`/api/lobbies/${id}/me`, 'PUT', { character_id: pick }, () => { setPick(''); load() }) }}> - - - -

The DM always sees the current version of your character, so changes you save show up for them.

-
- )} - -
- {lobby.is_dm - ? - : }
+ + {active === 'chat' && } + + {active.startsWith('sheet:') && (() => { + const m = sheets.find((x) => x.username === active.slice(6)) + return open(m)} /> + })()} + + {active === 'party' && ( + <> + {lobby.is_dm && ( +
+
+
+
Join code – give it to your players
+ {lobby.code} +
+
+ + +
+
+
{ e.preventDefault(); if (rename.trim()) act(`/api/lobbies/${id}`, 'PUT', { name: rename }, () => { setRename(''); load() }) }}> + setRename(e.target.value)} placeholder="Rename lobby" aria-label="New lobby name" maxLength={60} /> + +
+
+ )} + + {lobby.members.length === 0 &&

Nobody has joined yet. Share the code above.

} +
    + {lobby.members.map((m) => ( + setTab(`sheet:${x.username}`)} + onKick={(x) => window.confirm(`Remove ${x.username} from the lobby?`) && act(`/api/lobbies/${id}/members/${encodeURIComponent(x.username)}`, 'DELETE')} /> + ))} +
+ {!lobby.is_dm &&

You see your own character in full. Of the others you only see this short card.

} + + {!lobby.is_dm && me && ( +
+

Your character

+
{ e.preventDefault(); if (pick) act(`/api/lobbies/${id}/me`, 'PUT', { character_id: pick }, () => { setPick(''); load() }) }}> + + + +

The DM always sees the current saved version of your character.

+
+ )} + +
+ {lobby.is_dm + ? + : } +
+ + )}
) } -export default function Lobbies({ section, onOpen }) { - return section ? : +export default function Lobbies({ section, rules, user, onOpen }) { + return section ? : } diff --git a/frontend/src/SheetView.jsx b/frontend/src/SheetView.jsx new file mode 100644 index 0000000..0e0444d --- /dev/null +++ b/frontend/src/SheetView.jsx @@ -0,0 +1,57 @@ +import { useMemo } from 'react' +import Tip from './Tip.jsx' +import { normalize } from './calc.js' +import { sheetModel } from './sheetModel.js' + +function Rows({ rows, valueLabel }) { + if (!rows.length) return

–

+ return ( + + {rows.map((r) => ( + + + + {r.extra !== undefined && } + + ))} +
{r.label}{r.desc &&
{r.desc}
}
{valueLabel ? valueLabel(r) : r.val}{r.extra}
+ ) +} + +// Read-only character sheet (used in lobbies). Hover or tap a value to see how it is made up. +export default function SheetView({ data, baseRules }) { + const m = useMemo(() => sheetModel(normalize(data, baseRules), baseRules), [data, baseRules]) + const c = m.c + const trained = m.skills.filter((s) => c.skills.find((x) => x.id === s.k && (x.points > 0 || x.pips > 0 || (x.type === 'advanced' && x.usable)))) + const rest = m.skills.filter((s) => !trained.includes(s)) + + return ( +
+ h.k !== 'name')} /> +
+
+

Attributes

+ +

Derived

+ +
+
+

Skills

+ + {rest.length > 0 && ( +
+ All other skills ({rest.length}) + +
+ )} +
+
+

Talents

+ +

Mutations / implants {c.capacityUsed}/{c.capacityMax}

+ +

Equipment {c.weight}/{c.carryMax} kg

+ (r.val > 1 ? `x${r.val}` : '')} /> +
+ ) +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 22b0917..ee6d2db 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -182,3 +182,25 @@ a.button:hover { filter: brightness(1.1); } span.chip { border: 1px solid var(--line); font-size: .85rem; padding: .1rem .6rem; } .lobbycard .chips { gap: .4rem; } h2 + .charlist, .card + h2 { margin-top: 1.4rem; } + +/* lobby page: tabs, chat, sheet */ +.lobbypage { max-width: 980px; } +.tabs { display: flex; flex-wrap: wrap; gap: .3rem; border-bottom: 1px solid var(--line); margin: .8rem 0 1rem; } +.tabs button { background: transparent; color: var(--text); border: 1px solid transparent; border-bottom: none; border-radius: var(--radius) var(--radius) 0 0; font-weight: 500; padding: .45rem .9rem; margin-bottom: -1px; } +.tabs button.on { background: var(--panel); border-color: var(--line); border-bottom: 1px solid var(--panel); color: var(--text); font-weight: 700; } +.chatwrap { display: grid; gap: .6rem; } +.chatbox { height: min(55vh, 520px); overflow-y: auto; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: .6rem .8rem; display: flex; flex-direction: column; gap: .25rem; } +.msg { overflow-wrap: anywhere; line-height: 1.35; } +.msg .time { color: var(--muted); font-size: .75rem; font-variant-numeric: tabular-nums; } +.msg.event { color: var(--muted); font-size: .85rem; font-style: italic; } +.msg.mine strong { color: var(--noxium); } +.msg.whisper { background: var(--panel2); border-left: 3px solid #8a63c7; padding: .15rem .5rem; border-radius: 4px; } +.msg.whisper em { color: #8a63c7; } +.chatform { flex-wrap: nowrap; gap: .5rem; } +.chatform select { width: 12rem; flex: 0 0 auto; } +.chatform select.whisper { border-color: #8a63c7; } +.chatform input { flex: 1 1 auto; width: auto; min-width: 0; } +.sheetcols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 2rem; } +.sheetview h4 { margin: 1rem 0 .3rem; } +.sheetview table { width: 100%; } +@media (max-width: 760px) { .sheetcols { grid-template-columns: 1fr; } .chatform { flex-wrap: wrap; } .chatform select { width: 100%; } }