From aad8e0515b5cf4fed44b1af57bceb00c5aebc6ea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:23:36 +0000 Subject: [PATCH] Add lobbies: DM sees all characters, players only a short card of others Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K --- README.md | 16 ++- backend/app/admin.py | 4 +- backend/app/main.py | 186 ++++++++++++++++++++++++++++++++- frontend/src/App.jsx | 6 +- frontend/src/Lobbies.jsx | 220 +++++++++++++++++++++++++++++++++++++++ frontend/src/styles.css | 13 +++ 6 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 frontend/src/Lobbies.jsx diff --git a/README.md b/README.md index cc8fd75..7a254fa 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,14 @@ While creating, the full character sheet stays visible next to the wizard. Edits Each wizard page has a **Reset this page** button that clears only that page's data, plus **Start over** for the whole character. +### Lobbies + +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. + ## API All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie. @@ -91,9 +99,15 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l | POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts | | GET | `/api/rules` | Rule data including presets | | GET / POST | `/api/characters` | Own and shared-with-me characters / create one | -| GET | `/api/characters/{id}` | Read (owner or shared users) | +| GET | `/api/characters/{id}` | Read (owner, shared users, or the DM of a lobby it is in) | | POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) | | PUT / DELETE | `/api/characters/{id}` | Change / delete (owner only) | +| GET / POST | `/api/lobbies` | Own and joined lobbies / create one (you are the DM) | +| 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) | +| 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 | | PUT / DELETE | `/api/presets/{id}` | Change / delete (owner only) | | POST | `/api/export/pdf` or `/docx` | Character JSON in the body, file in the response | diff --git a/backend/app/admin.py b/backend/app/admin.py index 545841b..96a22c0 100644 --- a/backend/app/admin.py +++ b/backend/app/admin.py @@ -33,8 +33,10 @@ def main(argv): return 1 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 lobbies WHERE owner_id=?", (row["id"],)) con.execute("DELETE FROM users WHERE id=?", (row["id"],)) - print("User deleted. Their characters and presets stay on the server without an owner.") + print("User deleted. Their lobbies are removed; their characters and presets stay on the server without an owner.") else: print(__doc__) return 1 diff --git a/backend/app/main.py b/backend/app/main.py index 65dcb7e..dd2c655 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,6 +22,9 @@ MAX_BODY = 200_000 # bytes per character MAX_PRESETS = 200 MAX_CHARACTERS = 300 # per user MAX_SHARES = 30 # per character +MAX_LOBBIES = 20 # per DM +MAX_MEMBERS = 20 # per lobby +CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I # No public API docs: everything about the API stays behind the login app = FastAPI(title="Arcane Wasteland Character Creator", docs_url=None, redoc_url=None, openapi_url=None) @@ -48,6 +51,11 @@ def db() -> sqlite3.Connection: auth.init_tables(con) con.execute("""CREATE TABLE IF NOT EXISTS shares ( character_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (character_id, user_id))""") + con.execute("""CREATE TABLE IF NOT EXISTS lobbies ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, owner_id INTEGER NOT NULL, code TEXT NOT NULL UNIQUE, created REAL NOT NULL)""") + 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))""") 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") @@ -326,11 +334,13 @@ def create_character(ch: dict = Body(...), user: dict = Depends(current_user)): @api.get("/characters/{cid}") def read_character(cid: str, user: dict = Depends(current_user)): - """Only the owner and the users the character is shared with may read it.""" + """Readable by the owner, by users the character is shared with, and by the DM of a lobby it plays in.""" with db() as con: row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone() allowed = bool(row) and (row["owner_id"] == user["id"] or con.execute( - "SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone()) + "SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone() or con.execute( + """SELECT 1 FROM lobby_members m JOIN lobbies l ON l.id = m.lobby_id + WHERE m.character_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone()) if not allowed: raise HTTPException(404, "Character not found") return json.loads(row["data"]) @@ -376,6 +386,178 @@ def delete_character(cid: str, user: dict = Depends(current_user)): _owned(con, "characters", cid, user) con.execute("DELETE FROM characters WHERE id=?", (cid,)) con.execute("DELETE FROM shares WHERE character_id=?", (cid,)) + con.execute("UPDATE lobby_members SET character_id=NULL WHERE character_id=?", (cid,)) + return {"ok": True} + + +# ---- lobbies: the creator is the DM and sees every member's character; players only see a short card of the others + +def _lobby_for(con, lid: str, user: dict): + """Returns the lobby row if the user is its DM or a member, otherwise 404 (hides that it exists).""" + row = con.execute("""SELECT l.*, u.username AS dm FROM lobbies l JOIN users u ON u.id = l.owner_id WHERE l.id=?""", + (lid,)).fetchone() + if not row or (row["owner_id"] != user["id"] and not con.execute( + "SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone()): + raise HTTPException(404, "Lobby not found") + return row + + +def _dm_only(lobby, user: dict): + if lobby["owner_id"] != user["id"]: + raise HTTPException(403, "Only the DM can do this") + + +def _new_code(con) -> str: + while True: + code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(8)) + if not con.execute("SELECT 1 FROM lobbies WHERE code=?", (code,)).fetchone(): + return code + + +def _own_character(con, cid, user: dict): + row = con.execute("SELECT id FROM characters WHERE id=? AND owner_id=?", (cid, user["id"])).fetchone() + if not row: + raise HTTPException(422, "Pick one of your own characters") + + +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) + cls = next((c for c in rules["classes"] if c["id"] == ch.get("class_id")), None) + sub = next((x for x in (cls or {}).get("subclasses", []) if x["id"] == ch.get("subclass_id")), None) + prof = next((p for p in rules["professions"] if p["id"] == ch.get("profession")), None) + return {"name": ch.get("name") or "Nameless", "concept": str(ch.get("concept") or "")[:120], + "class": (cls or {}).get("name", ""), "subclass": (sub or {}).get("name", ""), + "profession": (prof or {}).get("name", "")} + + +@api.get("/lobbies") +def my_lobbies(user: dict = Depends(current_user)): + with db() as con: + mine = con.execute("""SELECT l.id, l.name, (SELECT COUNT(*) FROM lobby_members m WHERE m.lobby_id = l.id) AS n + FROM lobbies l WHERE l.owner_id=? ORDER BY l.created DESC""", (user["id"],)).fetchall() + joined = con.execute("""SELECT l.id, l.name, u.username AS dm, c.data FROM lobby_members m + JOIN lobbies l ON l.id = m.lobby_id JOIN users u ON u.id = l.owner_id + LEFT JOIN characters c ON c.id = m.character_id + WHERE m.user_id=? ORDER BY m.joined DESC""", (user["id"],)).fetchall() + return {"mine": [{"id": r["id"], "name": r["name"], "members": r["n"]} for r in mine], + "joined": [{"id": r["id"], "name": r["name"], "dm": r["dm"], "character": _name(r["data"]) if r["data"] else ""} + for r in joined]} + + +@api.post("/lobbies") +def create_lobby(body: dict = Body(...), user: dict = Depends(current_user)): + name = str(body.get("name", "")).strip()[:60] + if not name: + raise HTTPException(422, "Give the lobby a name") + with db() as con: + if con.execute("SELECT COUNT(*) FROM lobbies WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_LOBBIES: + raise HTTPException(409, "Lobby limit reached") + 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())) + return {"id": lid, "code": code} + + +@api.post("/lobbies/join") +def join_lobby(request: Request, body: dict = Body(...), user: dict = Depends(current_user)): + ip = client_ip(request) + throttle(f"join:{ip}", 10, 600) # only wrong codes count, so guessing is slow + throttle(f"join:u{user['id']}", 10, 600) + code = re.sub(r"[^A-Za-z0-9]", "", str(body.get("code", ""))).upper() + with db() as con: + lobby = con.execute("SELECT * FROM lobbies WHERE code=?", (code,)).fetchone() + if not lobby: + limiter.fail(f"join:{ip}"); limiter.fail(f"join:u{user['id']}") + raise HTTPException(404, "No lobby with this code") + if lobby["owner_id"] == user["id"]: + raise HTTPException(422, "You are the DM of this lobby") + _own_character(con, body.get("character_id"), user) + known = con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lobby["id"], user["id"])).fetchone() + if not known and con.execute("SELECT COUNT(*) FROM lobby_members WHERE lobby_id=?", (lobby["id"],)).fetchone()[0] >= MAX_MEMBERS: + raise HTTPException(409, "This lobby is full") + 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())) + return {"id": lobby["id"]} + + +@api.get("/lobbies/{lid}") +def read_lobby(lid: str, user: dict = Depends(current_user)): + rules = load_rules() + with db() as con: + lobby = _lobby_for(con, lid, user) + is_dm = lobby["owner_id"] == user["id"] + rows = con.execute("""SELECT m.user_id, m.character_id, u.username, c.data FROM lobby_members m + JOIN users u ON u.id = m.user_id LEFT JOIN characters c ON c.id = m.character_id + WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall() + members = [] + for r in rows: + mine = r["user_id"] == user["id"] + card = _card(r["data"], rules) if r["data"] else None + # The id is only given out where the viewer may open the full character (the DM sees all, a player only their own) + members.append({"username": r["username"], "me": mine, "character": card, + "character_id": r["character_id"] if (is_dm or mine) and card else None}) + return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm, + "code": lobby["code"] if is_dm else None, "members": members} + + +@api.put("/lobbies/{lid}") +def rename_lobby(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): + name = str(body.get("name", "")).strip()[:60] + if not name: + raise HTTPException(422, "Give the lobby a name") + with db() as con: + _dm_only(_lobby_for(con, lid, user), user) + con.execute("UPDATE lobbies SET name=? WHERE id=?", (name, lid)) + return {"ok": True} + + +@api.delete("/lobbies/{lid}") +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 lobbies WHERE id=?", (lid,)) + return {"ok": True} + + +@api.post("/lobbies/{lid}/code") +def new_lobby_code(lid: str, user: dict = Depends(current_user)): + """New join code, the old one stops working (members stay).""" + with db() as con: + _dm_only(_lobby_for(con, lid, user), user) + code = _new_code(con) + con.execute("UPDATE lobbies SET code=? WHERE id=?", (code, lid)) + return {"code": code} + + +@api.put("/lobbies/{lid}/me") +def switch_lobby_character(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): + with db() as con: + _lobby_for(con, lid, user) + _own_character(con, body.get("character_id"), user) + cur = con.execute("UPDATE lobby_members SET character_id=? WHERE lobby_id=? AND user_id=?", + (body["character_id"], lid, user["id"])) + if not cur.rowcount: + raise HTTPException(422, "You are the DM of this lobby") + return {"ok": True} + + +@api.delete("/lobbies/{lid}/me") +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"])) + return {"ok": True} + + +@api.delete("/lobbies/{lid}/members/{username}") +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)) return {"ok": True} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fe1648d..464b5b3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,7 @@ import Creator from './Creator.jsx' import Rules from './Rules.jsx' import Characters from './Characters.jsx' import Presets from './Presets.jsx' +import Lobbies from './Lobbies.jsx' import Login from './Login.jsx' import Account from './Account.jsx' import { claimLegacy } from './legacy.js' @@ -52,7 +53,7 @@ export default function App() { if (!auth.user) return
setAuth({ ...auth, user: u })} />
const page = hash.split('/')[0] - const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters']] + const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters'], ['lobbies', 'Lobbies']] return ( <> @@ -77,6 +78,9 @@ export default function App() { { setLoadRequest(entry); window.location.hash = 'creator' }} onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} /> )} + {rules && page === 'lobbies' && ( + { setLoadRequest(entry); window.location.hash = 'creator' }} /> + )} {page === 'account' && } diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx new file mode 100644 index 0000000..a3fb83d --- /dev/null +++ b/frontend/src/Lobbies.jsx @@ -0,0 +1,220 @@ +import { useEffect, useState } from 'react' + +const J = { 'Content-Type': 'application/json' } +const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) }) +const msg = (d, fallback) => (typeof d?.detail === 'string' ? d.detail : fallback) + +function useMine() { + const [mine, setMine] = useState([]) + useEffect(() => { fetch('/api/characters').then((r) => r.json()).then((d) => setMine(d.mine || [])).catch(() => {}) }, []) + return mine +} + +function CharSelect({ mine, value, onChange, label }) { + return ( + + ) +} + +// ---- list of lobbies: create one (you become the DM) or join one with a code +function LobbyList() { + const mine = useMine() + const [data, setData] = useState(null) + const [name, setName] = useState('') + const [code, setCode] = useState('') + const [cid, setCid] = useState('') + const [err, setErr] = useState('') + + const load = () => fetch('/api/lobbies').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your lobbies.')) + useEffect(() => { load() }, []) + + const create = async (e) => { + e.preventDefault(); setErr('') + const r = await send('/api/lobbies', 'POST', { name }) + const d = await r.json().catch(() => ({})) + if (!r.ok) return setErr(msg(d, 'Could not create the lobby.')) + window.location.hash = `lobbies/${d.id}` + } + const join = async (e) => { + e.preventDefault(); setErr('') + if (!cid) return setErr('Choose which of your characters joins.') + const r = await send('/api/lobbies/join', 'POST', { code, character_id: cid }) + const d = await r.json().catch(() => ({})) + if (!r.ok) return setErr(msg(d, 'Could not join.')) + window.location.hash = `lobbies/${d.id}` + } + + return ( +
+

Lobbies

+

+ A lobby is a group for one campaign. Whoever creates it is the DM and can look at every character in it. + Players only see a short card of each other (name, concept, class and profession). +

+ {err &&

{err}

} + +
+

Join a lobby

+
+ setCode(e.target.value)} placeholder="Join code from your DM" aria-label="Join code" autoComplete="off" /> + + + + {mine.length === 0 &&

You need a saved character first (Character → Summary → Save).

} +
+ +

Lobbies you joined

+ {data && data.joined.length === 0 &&

None yet.

} +
    + {(data?.joined || []).map((l) => ( +
  • +
    + {l.name} +
    DM {l.dm}{l.character ? ` · playing ${l.character}` : ' · no character selected'}
    +
    + +
  • + ))} +
+ +

Lobbies you run (as DM)

+ {data && data.mine.length === 0 &&

None yet.

} +
    + {(data?.mine || []).map((l) => ( +
  • +
    + {l.name} +
    {l.members} player{l.members === 1 ? '' : 's'}
    +
    + +
  • + ))} +
+
+ setName(e.target.value)} placeholder="Name of a new lobby" aria-label="Lobby name" maxLength={60} /> + +
+
+ ) +} + +function Card({ m, isDm, onOpen, onKick }) { + const c = m.character + return ( +
  • +
    +
    + {c ? c.name : 'No character'} +
    {m.username}{m.me ? ' (you)' : ''}
    +
    +
    + {m.character_id && } + {isDm && } +
    +
    + {c && ( +
    + {[c.class && (c.subclass ? `${c.class} · ${c.subclass}` : c.class), c.profession].filter(Boolean).map((t) => {t})} +
    + )} + {c?.concept &&

    {c.concept}

    } +
  • + ) +} + +// ---- one lobby (DM view or player view) +function LobbyView({ id, 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 load = () => fetch(`/api/lobbies/${id}`).then(async (r) => { + if (!r.ok) throw new Error(r.status) + setLobby(await r.json()) + }).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 + return () => clearInterval(t) + }, [id]) // eslint-disable-line + + const act = async (url, method, body, done) => { + setErr(''); setInfo('') + const r = await send(url, method, body).catch(() => null) + const d = r ? await r.json().catch(() => ({})) : {} + if (!r || !r.ok) return setErr(msg(d, 'That did not work.')) + done ? done(d) : load() + } + + 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 }) }) + + return ( +
    +

    ← All lobbies

    +

    {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')} /> + ))} +
    + {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 + ? + : } +
    +
    + ) +} + +export default function Lobbies({ section, onOpen }) { + return section ? : +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 2b71974..22b0917 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -169,3 +169,16 @@ tr.dim td { opacity: .55; } .tipwrap:hover .tipicon, .tipwrap:focus .tipicon { color: var(--noxium); border-color: var(--noxium); } .tippop { position: fixed; z-index: 1000; max-width: 340px; padding: .55rem .75rem; background: var(--panel2); color: var(--text); border: 1px solid var(--diesium); border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.35); font-size: .85rem; line-height: 1.4; pointer-events: none; } .tippop div + div { margin-top: .35rem; } + +/* lobbies */ +a.button { display: inline-block; font-weight: 600; text-decoration: none; background: var(--noxium); color: #12150f; border: 1px solid var(--noxium); padding: .5rem 1rem; border-radius: var(--radius); } +a.button:hover { filter: brightness(1.1); } +.wrapform { flex-wrap: wrap; gap: .5rem; margin-top: .6rem; } +.wrapform > input, .wrapform > select { flex: 1 1 12rem; width: auto; } +.lobbycard { display: grid; gap: .45rem; } +.lobbycard.me { border-color: var(--noxium); } +.lobbycard p { margin: 0; } +.joincode { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1.5rem; letter-spacing: .15em; } +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; }