Lobby: chat at the right window edge, DM rolls hidden from players

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-21 17:00:02 +00:00
parent a88b8d2fa3
commit 69e53c1520
4 changed files with 25 additions and 16 deletions
+2 -2
View File
@@ -85,10 +85,10 @@ 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 lobby page is built for desktop: the main area on the left, the chat as a fixed column on the right and a dice hotbar at the bottom centre.
The lobby page is built for desktop: the main area on the left, the chat as a column at the right edge of the window and a dice hotbar at the bottom centre of the main area.
- **Chat** (right column): 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.
- **Dice** (bottom centre): d4, d6, d8, d10, d12, d20 and d100, with a count (1 to 10 dice). A click rolls on the server (so nobody can cheat), the dice tumble into a tray above the hotbar for everybody in the lobby and the result is posted in the chat. Rolls follow the chat's recipient dropdown: with a person selected it is a private roll only that person (and you) can see.
- **Dice** (bottom centre): d4, d6, d8, d10, d12, d20 and d100, with a count (1 to 10 dice). A click rolls on the server (so nobody can cheat), the dice tumble into a tray above the hotbar for everybody in the lobby and the result is posted in the chat. Rolls follow the chat's recipient dropdown: with a person selected it is a private roll only that person (and you) can see. **The DM's rolls are hidden from the players** (the hotbar says "Hidden (only you)"); the DM can still pick a player as recipient to show them a roll.
- **Party** (tab): 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** (next to Party): 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).
+4 -1
View File
@@ -582,7 +582,8 @@ MAX_DICE = 10
@api.post("/lobbies/{lid}/rolls")
def roll_dice(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""Rolls on the server (so nobody can cheat) and posts the result in the chat; with `to` it is a private roll."""
"""Rolls on the server (so nobody can cheat) and posts the result in the chat; with `to` it is a private roll.
Rolls by the DM are only visible to the DM, or to the person picked with `to`."""
try:
sides, count = int(body.get("sides")), int(body.get("count", 1))
except (TypeError, ValueError):
@@ -595,6 +596,8 @@ def roll_dice(lid: str, body: dict = Body(...), user: dict = Depends(current_use
with db() as con:
lobby = _lobby_for(con, lid, user)
to_id = _recipient(con, lobby, user, body.get("to"))
if to_id is None and lobby["owner_id"] == user["id"]:
to_id = user["id"] # the DM's rolls are hidden from the players (unless the DM picks someone to show them to)
cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)",
(lid, user["id"], to_id, "roll", json.dumps({"sides": sides, "results": results}), time.time()))
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
+5 -5
View File
@@ -186,7 +186,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
const who = (m) => (
<>
<strong>{m.mine ? 'You' : m.from || '?'}</strong>
{m.to && <em className="small whispertag"> {m.mine ? `to ${m.to}` : 'to you'} (private)</em>}
{m.to && <em className="small whispertag"> {m.to === m.from ? '(only visible to you, the DM)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}</em>}
</>
)
@@ -241,7 +241,7 @@ function DieIcon({ sides }) {
)
}
function DiceBar({ id, to, poll }) {
function DiceBar({ id, to, poll, isDm }) {
const [count, setCount] = useState(1)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
@@ -265,7 +265,7 @@ function DiceBar({ id, to, poll }) {
<DieIcon sides={d} /><span>d{d}</span>
</button>
))}
<span className={`small dicedest ${to ? 'priv' : 'muted'}`}>{to ? `Private: ${to}` : 'Public'}</span>
<span className={`small dicedest ${to || isDm ? 'priv' : 'muted'}`}>{to ? `Private: ${to}` : isDm ? 'Hidden (only you)' : 'Public'}</span>
{err && <span className="warn small">{err}</span>}
</div>
)
@@ -297,7 +297,7 @@ function DiceTray({ rolls, me }) {
const t = rollText(m)
return (
<div key={m.key} className="trayroll">
<div className="small">{m.mine ? 'You' : m.from} rolled <strong>{t.dice}</strong>{m.to ? ` · private${m.mine ? ` to ${m.to}` : ''}` : ''}</div>
<div className="small">{m.mine ? 'You' : m.from} rolled <strong>{t.dice}</strong>{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}</div>
<div className="trayrow">
{m.roll.results.map((v, i) => <Die key={i} sides={m.roll.sides} value={v} delay={i * 120} />)}
{t.n > 1 && <span className="traytotal">= {t.sum}</span>}
@@ -461,7 +461,7 @@ function LobbyView({ id, rules, user, onOpen }) {
</div>
<DiceTray rolls={tray} me={user} />
<DiceBar id={id} to={dest} poll={feed.poll} />
<DiceBar id={id} to={dest} poll={feed.poll} isDm={lobby.is_dm} />
</section>
)
}
+14 -8
View File
@@ -205,10 +205,11 @@ h2 + .charlist, .card + h2 { margin-top: 1.4rem; }
.sheetview table { width: 100%; }
@media (max-width: 760px) { .sheetcols { grid-template-columns: 1fr; } .chatform { flex-wrap: wrap; } .chatform select { width: 100%; } }
/* lobby desktop layout: main area left, chat column right, dice bar bottom centre */
.lobbypage { max-width: 1500px; padding-bottom: 8rem; }
.lobbygrid { display: grid; grid-template-columns: minmax(0, 1fr) 400px; gap: 1.4rem; align-items: start; }
.lobbychat { position: sticky; top: 4.6rem; height: calc(100vh - 11rem); min-height: 22rem; padding: .7rem; display: flex; flex-direction: column; }
/* lobby desktop layout: main area left, chat column fixed at the right edge of the window, dice bar bottom centre of the main area */
:root { --chatw: 380px; --hdr: 3.7rem; }
body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(--chatw) + 1.5rem); }
.lobbypage { max-width: 1200px; margin: 0 auto; padding-bottom: 8rem; }
.lobbychat { position: fixed; right: 0; top: var(--hdr); bottom: 0; width: var(--chatw); z-index: 10; border-radius: 0; border-width: 0 0 0 1px; padding: .8rem; display: flex; flex-direction: column; overflow: hidden; }
.lobbychat .chatwrap { flex: 1; min-height: 0; grid-template-rows: minmax(0, 1fr) auto auto auto auto; }
.lobbychat .chatbox { height: auto; min-height: 0; }
.lobbychat .chatform { flex-wrap: wrap; }
@@ -217,17 +218,17 @@ h2 + .charlist, .card + h2 { margin-top: 1.4rem; }
.partygrid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.msg.rollmsg .rollres b { font-size: 1.05rem; color: var(--noxium); }
.whispertag { color: #8a63c7; }
@media (max-width: 1050px) { .lobbygrid { grid-template-columns: 1fr; } .lobbychat { position: static; height: 28rem; } }
.dicebar { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); z-index: 30; display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; justify-content: center;
max-width: calc(100vw - 1.5rem); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: .4rem .8rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .22); }
.dicebar { position: fixed; bottom: 1rem; left: calc((100vw - var(--chatw)) / 2); transform: translateX(-50%); z-index: 30; display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; justify-content: center;
max-width: calc(100vw - var(--chatw) - 1.5rem); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: .4rem .8rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .22); }
.dicecount { display: flex; align-items: center; gap: .3rem; margin-right: .3rem; }
.dicecount select { width: auto; padding: .25rem .4rem; }
.diebtn { display: flex; flex-direction: column; align-items: center; gap: 0; padding: .2rem .5rem; border-radius: 12px; min-width: 3.1rem; font-size: .75rem; line-height: 1.1; }
.diebtn:hover:not(:disabled) { border-color: var(--noxium); color: var(--noxium); }
.dicedest { margin-left: .4rem; padding-left: .6rem; border-left: 1px solid var(--line); }
.dicedest.priv { color: #8a63c7; font-weight: 600; }
.dicetray { position: fixed; bottom: 5.4rem; left: 50%; transform: translateX(-50%); z-index: 29; display: grid; gap: .5rem; justify-items: center; pointer-events: none; }
.dicetray { position: fixed; bottom: 5.4rem; left: calc((100vw - var(--chatw)) / 2); transform: translateX(-50%); z-index: 29; display: grid; gap: .5rem; justify-items: center; pointer-events: none; }
.trayroll { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: .5rem .9rem; box-shadow: 0 6px 24px rgba(0, 0, 0, .25); animation: trayin .25s ease-out; }
.trayrow { display: flex; align-items: center; gap: .5rem; margin-top: .3rem; flex-wrap: wrap; justify-content: center; max-width: 30rem; }
.traytotal { font-weight: 700; font-size: 1.2rem; margin-left: .3rem; }
@@ -245,3 +246,8 @@ h2 + .charlist, .card + h2 { margin-top: 1.4rem; }
@keyframes land { 0% { transform: scale(1.35); } 100% { transform: scale(1); } }
@keyframes trayin { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .die.rolling, .die.landed, .trayroll { animation: none; } }
@media (max-width: 1050px) { body:has(.lobbychat) main { max-width: 1280px; margin: 0 auto; padding-right: 1rem; } .lobbychat { position: static; width: auto; height: 28rem; border-width: 1px; border-radius: var(--radius); margin-top: 1rem; } .dicebar, .dicetray { left: 50%; } .dicebar { max-width: calc(100vw - 1.5rem); } }
.lobbycard { align-content: start; }
.lobbycard .chips { align-items: flex-start; align-content: flex-start; }
.lobbycard .chip { align-self: flex-start; }