diff --git a/README.md b/README.md
index ab263ad..d9a52ac 100644
--- a/README.md
+++ b/README.md
@@ -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).
diff --git a/backend/app/main.py b/backend/app/main.py
index a8ddfac..1be83f6 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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(
diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx
index 6ac15ee..4bad711 100644
--- a/frontend/src/Lobbies.jsx
+++ b/frontend/src/Lobbies.jsx
@@ -186,7 +186,7 @@ function Chat({ me, people, msgs, poll, id, to, setTo }) {
const who = (m) => (
<>
{m.mine ? 'You' : m.from || '?'}
- {m.to && {m.mine ? `to ${m.to}` : 'to you'} (private)}
+ {m.to && {m.to === m.from ? '(only visible to you, the DM)' : m.mine ? `to ${m.to} (private)` : 'to you (private)'}}
>
)
@@ -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 }) {
d{d}
))}
- {to ? `Private: ${to}` : 'Public'}
+ {to ? `Private: ${to}` : isDm ? 'Hidden (only you)' : 'Public'}
{err && {err}}
)
@@ -297,7 +297,7 @@ function DiceTray({ rolls, me }) {
const t = rollText(m)
return (
-
{m.mine ? 'You' : m.from} rolled {t.dice}{m.to ? ` · private${m.mine ? ` to ${m.to}` : ''}` : ''}
+
{m.mine ? 'You' : m.from} rolled {t.dice}{m.to ? (m.to === m.from ? ' · only you' : ` · private${m.mine ? ` to ${m.to}` : ''}`) : ''}
{m.roll.results.map((v, i) => )}
{t.n > 1 && = {t.sum}}
@@ -461,7 +461,7 @@ function LobbyView({ id, rules, user, onOpen }) {
-
+
)
}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index ca065f1..9ad124a 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -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; }