From 72d8113ece3eba4cea2ea7b95ddceb4354db2f4d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 15:55:23 +0000 Subject: [PATCH] Initiative tracker, radial token menu, slimmer lobby header Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K --- README.md | 7 ++ backend/app/combat.py | 170 +++++++++++++++++++++++++++++++++++++++ backend/app/main.py | 6 +- frontend/src/Lobbies.jsx | 69 +++++++++++++--- frontend/src/MapView.jsx | 31 ++++++- frontend/src/styles.css | 26 ++++++ 6 files changed, 295 insertions(+), 14 deletions(-) create mode 100644 backend/app/combat.py diff --git a/README.md b/README.md index 871f6bf..887d2e0 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,12 @@ The **NPCs** tab is a library of rules-based NPCs. It uses the same wizard as th Players and the DM show a green dot in the lobby while they have it open (online presence over the WebSocket; without WebSockets everyone appears offline). +### Initiative and fights + +The DM presses **⚔ Start fight** in the lobby. Every player with a character joins automatically and rolls initiative (1d20 + the character's Initiative value, as in the rules). **Right click** a token (DM, full view) for a radial menu: add it to the fight, open its sheet, hide/reveal or remove an NPC token. Players who join later can be added with the **+ name** buttons in the initiative bar. + +The bar shows the order, the round and whose turn it is (the token pulses on the map). The DM can overwrite every number, reroll, remove entries, step **◀ / Next turn ▶** and **End fight**. Hidden NPCs are not shown to players. Fight start, joins and the end are logged in the chat feed. + ### Maps, tokens and line of sight - **Maps tab** (top navigation): all your maps with preview. Upload PNG, JPEG or WebP (up to 12 MB, 30 maps each; the server checks and re-encodes every image as WebP, max 4096 px per side, into `db/maps/`). **Edit** opens the map editor, **Share** gives other users access by username (like characters: they can put the map on their own table or **Copy** it, but not change it), **Duplicate** copies a map. @@ -147,6 +153,7 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l | GET | `/api/rules` | Rule data including presets | | GET / POST | `/api/characters[?npc=1]` | Own and shared-with-me characters (or the NPC library) / create one | | GET | `/api/characters/{id}/portrait` | Portrait image (readers of the character) | +| POST / PUT / DELETE | `/api/lobbies/{id}/combat` | Start a fight / add, remove, set, reroll, next, prev / end (DM) | | GET | `/api/lobbies/{id}/token-portrait/{token}` | Portrait of an NPC token (lobby members) | | GET / PUT | `/api/admin/users[/{username}]` | List accounts / make or remove an admin (admins) | | GET | `/api/characters/{id}` | Read (owner, shared users, or the DM of a lobby it is in) | diff --git a/backend/app/combat.py b/backend/app/combat.py new file mode 100644 index 0000000..ff81ed9 --- /dev/null +++ b/backend/app/combat.py @@ -0,0 +1,170 @@ +"""Initiative tracker. One fight per lobby, stored as JSON on the lobby row. + +{"round": 1, "current": "", "entries": [{"id", "kind": "player"|"npc", "user": username, "token": token id, + "name", "init": number, "base": initiative modifier, "roll": d20}]} +The DM starts and ends the fight; players are added automatically at the start. Initiative is 1d20 + the character's +Initiative value (Agility Mod + Perception Mod), the DM can overwrite every number.""" +import json +import secrets +import uuid + +from fastapi import Body, Depends, HTTPException + +from .derive import compute + +MAX_ENTRIES = 60 + + +def schema(con): + if "combat" not in [r["name"] for r in con.execute("PRAGMA table_info(lobbies)")]: + con.execute("ALTER TABLE lobbies ADD COLUMN combat TEXT") + + +def _sorted(c: dict): + c["entries"].sort(key=lambda e: (-(e["init"] if e["init"] is not None else -999), e.get("seq", 0))) + + +def install(api, db, current_user, lobby_for, dm_only, hub, event, load_rules): + def base_of(data, rules) -> int: + try: + return int(compute(json.loads(data), rules)["derived"].get("initiative", 0)) + except Exception: + return 0 + + def new_entry(c, kind, name, base, **kw): + roll = secrets.randbelow(20) + 1 + c["seq"] = c.get("seq", 0) + 1 + return {"id": uuid.uuid4().hex[:10], "kind": kind, "name": name, "base": base, "roll": roll, "init": roll + base, "seq": c["seq"], **kw} + + def load(lobby): + return json.loads(lobby["combat"]) if lobby["combat"] else None + + def save(con, lid, c, log=None, uid=None): + con.execute("UPDATE lobbies SET combat=? WHERE id=?", (json.dumps(c) if c else None, lid)) + if log: + event(con, lid, log, uid) + hub.notify(lid, "lobby") + + def view(con, lobby, is_dm): + """What the viewer gets: hidden NPCs are left out for players; NPCs whose token is gone disappear.""" + c = load(lobby) + if not c: + return None + toks = {r["id"]: r["hidden"] for r in con.execute("SELECT id, hidden FROM lobby_tokens WHERE lobby_id=?", (lobby["id"],))} + out = [] + for e in c["entries"]: + if e["kind"] == "npc": + if e["token"] not in toks: + continue + if toks[e["token"]] and not is_dm: + continue + out.append({k: e[k] for k in ("id", "kind", "name", "init", "base", "roll") if k in e} | {k: e[k] for k in ("user", "token") if k in e}) + cur = c.get("current") + return {"round": c["round"], "current": cur if any(e["id"] == cur for e in out) else None, "entries": out} + + @api.post("/lobbies/{lid}/combat") + def start(lid: str, user: dict = Depends(current_user)): + rules = load_rules() + with db() as con: + lobby = lobby_for(con, lid, user) + dm_only(lobby, user) + c = {"round": 1, "current": None, "seq": 0, "entries": []} + for r in con.execute("""SELECT u.username, ch.data FROM lobby_members m JOIN users u ON u.id = m.user_id + JOIN characters ch ON ch.id = m.character_id WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)): + nm = json.loads(r["data"]).get("name") or r["username"] + c["entries"].append(new_entry(c, "player", nm, base_of(r["data"], rules), user=r["username"])) + _sorted(c) + c["current"] = c["entries"][0]["id"] if c["entries"] else None + save(con, lid, c, "⚔ A fight begins. Initiative: " + ", ".join(f"{e['name']} {e['init']}" for e in c["entries"]), user["id"]) + return {"ok": True} + + @api.put("/lobbies/{lid}/combat") + def change(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): + rules = load_rules() + with db() as con: + lobby = lobby_for(con, lid, user) + dm_only(lobby, user) + c = load(lobby) + if not c: + raise HTTPException(409, "There is no fight") + op, eid = body.get("op"), str(body.get("id") or "") + ents = c["entries"] + ent = next((e for e in ents if e["id"] == eid), None) + log = None + if op == "add": + if len(ents) >= MAX_ENTRIES: + raise HTTPException(409, "Too many entries") + if body.get("token"): + t = con.execute("""SELECT t.id, t.name, t.user_id, n.data FROM lobby_tokens t LEFT JOIN characters n ON n.id = t.npc_id + WHERE t.id=? AND t.lobby_id=?""", (str(body["token"]), lid)).fetchone() + if not t or t["user_id"]: + raise HTTPException(404, "No NPC token") + if any(e.get("token") == t["id"] for e in ents): + raise HTTPException(409, "Already in the fight") + e = new_entry(c, "npc", t["name"], base_of(t["data"], rules) if t["data"] else 0, token=t["id"]) + elif body.get("user"): + r = con.execute("""SELECT u.username, ch.data FROM lobby_members m JOIN users u ON u.id = m.user_id + JOIN characters ch ON ch.id = m.character_id WHERE m.lobby_id=? AND u.username=?""", (lid, str(body["user"]))).fetchone() + if not r: + raise HTTPException(404, "No such player with a character") + if any(e.get("user") == r["username"] for e in ents): + raise HTTPException(409, "Already in the fight") + e = new_entry(c, "player", json.loads(r["data"]).get("name") or r["username"], base_of(r["data"], rules), user=r["username"]) + else: + raise HTTPException(422, "Say which token or player to add") + ents.append(e) + log = f"{e['name']} joins the fight with initiative {e['init']}" + elif op == "remove": + if not ent: + raise HTTPException(404, "No such entry") + if c["current"] == eid: + i = ents.index(ent) + nxt = ents[(i + 1) % len(ents)] if len(ents) > 1 else None + c["current"] = nxt["id"] if nxt else None + ents.remove(ent) + elif op == "init": + if not ent: + raise HTTPException(404, "No such entry") + try: + ent["init"] = max(-99, min(999, int(body.get("value")))) + except (TypeError, ValueError): + raise HTTPException(422, "Initiative must be a whole number") + elif op == "reroll": + if not ent: + raise HTTPException(404, "No such entry") + ent["roll"] = secrets.randbelow(20) + 1 + ent["init"] = ent["roll"] + ent["base"] + log = f"{ent['name']} rerolls initiative: {ent['init']}" + elif op in ("next", "prev"): + _sorted(c) + if ents: + i = next((k for k, e in enumerate(ents) if e["id"] == c.get("current")), -1) + if op == "next": + if i + 1 >= len(ents): + c["round"] += 1 + i = (i + 1) % len(ents) + else: + if i <= 0: + c["round"] = max(1, c["round"] - 1) + i = (i - 1) % len(ents) + c["current"] = ents[i]["id"] + else: + raise HTTPException(422, "Unknown action") + cur = c.get("current") + _sorted(c) + if cur and not any(e["id"] == cur for e in c["entries"]): + c["current"] = c["entries"][0]["id"] if c["entries"] else None + save(con, lid, c, log, user["id"]) + return {"ok": True} + + @api.delete("/lobbies/{lid}/combat") + def end(lid: str, user: dict = Depends(current_user)): + with db() as con: + lobby = lobby_for(con, lid, user) + dm_only(lobby, user) + c = load(lobby) + if c: + save(con, lid, None, f"The fight is over after {c['round']} round{'s' if c['round'] != 1 else ''}.", user["id"]) + return {"ok": True} + + return view diff --git a/backend/app/main.py b/backend/app/main.py index 58cef4c..3c6c24f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,7 @@ from . import rules_edit from . import maps as maps_mod from . import sounds as sounds_mod from . import portrait as portrait_mod +from . import combat as combat_mod from .realtime import hub from .export import to_docx, to_pdf @@ -109,6 +110,7 @@ def db() -> sqlite3.Connection: con.execute("ALTER TABLE characters ADD COLUMN npc INTEGER NOT NULL DEFAULT 0") maps_mod.schema(con) sounds_mod.schema(con) + combat_mod.schema(con) 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") @@ -658,6 +660,7 @@ def read_lobby(lid: str, user: dict = Depends(current_user)): rows = con.execute("""SELECT m.user_id, m.character_id, m.color, m.char_mod, 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() + combat = combat_view(con, lobby, is_dm) members = [] online = hub.online(lid) for r in rows: @@ -672,7 +675,7 @@ def read_lobby(lid: str, user: dict = Depends(current_user)): if hidden and not is_dm: diff = {"label": "", "mod": None, "hidden": True} return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm, - "dm_online": lobby["owner_id"] in online, "code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff, + "dm_online": lobby["owner_id"] in online, "combat": combat, "code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff, "sounds": sounds_mod.settings(lobby["sounds"]), "levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR} @@ -1125,6 +1128,7 @@ def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps", _can_read_character) +combat_view = combat_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, load_rules) purge_sounds = sounds_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, Path(DB_PATH).parent / "sounds") diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx index abe0f07..3e3de12 100644 --- a/frontend/src/Lobbies.jsx +++ b/frontend/src/Lobbies.jsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx' import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js' -import { MapView, MapLibrary, NpcPicker, TokenForm, WallLayer } from './MapView.jsx' +import { MapView, MapLibrary, NpcPicker, RadialMenu, TokenForm, WallLayer } from './MapView.jsx' const J = { 'Content-Type': 'application/json' } const send = (url, method, body) => fetch(url, { method, headers: J, body: body === undefined ? undefined : JSON.stringify(body) }) @@ -424,9 +424,13 @@ function DiceTray({ rolls }) { } // ---- character bar: one card per player, later also for initiative and enemies -function CharBar({ lobby, sel, onSel }) { +function CharBar({ lobby, sel, onSel, settings }) { return (
+
+ DM + {lobby.dm}{lobby.is_dm ? ' (you)' : ''} +
{lobby.members.map((m) => { const c = m.character, can = !!m.character_id return ( @@ -444,6 +448,38 @@ function CharBar({ lobby, sel, onSel }) { ) })} {lobby.members.length === 0 && Nobody has joined yet – share the join code (⚙ Lobby).} + {settings} +
+ ) +} + +// ---- initiative tracker: the DM starts a fight, players join automatically, NPCs are added from the token menu +function InitiativeBar({ lobby, onOp, onStart, onEnd, onPick }) { + const c = lobby.combat, dm = lobby.is_dm + if (!c) return dm ?
: null + const missing = dm ? lobby.members.filter((m) => m.character && !c.entries.some((e) => e.user === m.username)) : [] + return ( +
+ Round {c.round} + {c.entries.map((e) => ( +
onPick(e)}> + {dm + ? ev.stopPropagation()} onBlur={(ev) => ev.target.value !== String(e.init) && onOp({ op: 'init', id: e.id, value: ev.target.value })} onKeyDown={(ev) => ev.key === 'Enter' && ev.currentTarget.blur()} /> + : {e.init}} + {e.name} + {dm && } + {dm && } +
+ ))} + {missing.map((m) => )} + {dm && ( + + + + + + )}
) } @@ -680,6 +716,8 @@ function LobbyView({ id, rules, user }) { setMapState((m) => ({ ...m, tokens: m.tokens.map((t) => (t.id === tid ? { ...t, ...p } : t)) })) return send(`/api/lobbies/${id}/tokens/${tid}`, 'PUT', p).catch(() => null) } + const [menu, setMenu] = useState(null) // right click menu on a token + const combatOp = (b) => act(`/api/lobbies/${id}/combat`, 'PUT', b) const [npcPick, setNpcPick] = useState(false) const addNpcToken = (n) => { const m = mapState.map @@ -727,14 +765,6 @@ function LobbyView({ id, rules, user }) { return (
-
- ← Lobbies -

{lobby.name}

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

{err}

} {info &&

{info}

} @@ -760,7 +790,11 @@ function LobbyView({ id, rules, user }) {
{!sheetOpen && } - { setSel(u); setMapSel(null); setLeftTab('sheet') }} /> + { setSel(u); setMapSel(null); setLeftTab('sheet') }} + settings={} /> + act(`/api/lobbies/${id}/combat`, 'POST')} + onEnd={() => window.confirm('End the fight?') && act(`/api/lobbies/${id}/combat`, 'DELETE')} + onPick={(e) => { if (e.kind === 'player') { setSel(e.user); setMapSel(null); setLeftTab('sheet') } else if (lobby.is_dm) { setMapSel(e.token); setLeftTab('sheet') } }} /> act(`/api/lobbies/${id}/difficulty`, 'PUT', b)} />
{showSettings && !lobby.is_dm && setShowSettings(false)} />} @@ -771,6 +805,8 @@ function LobbyView({ id, rules, user }) { return ( t.portrait ? (t.kind === 'npc' ? `/api/lobbies/${id}/token-portrait/${t.id}?v=${t.portrait}` : `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}`) : null} tokens={mapState.tokens} grid={showGrid} onMove={moveToken} selected={mapSel} onSelect={setMapSel} + onMenu={lobby.is_dm && !viewAs ? (t, x, y) => setMenu({ t, x, y }) : undefined} + isTurn={(t) => { const e = lobby.combat?.entries.find((x) => x.id === lobby.combat.current); return !!e && (e.kind === 'npc' ? e.token === t.id : e.user === t.username) }} overlay={walls.length ? act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}> {mapState.vision?.empty &&
{lobby.is_dm ? 'This player is not on the map yet. Switch to the full view and drag their token onto it.' : 'Waiting for the DM to put your token on the map.'}
}
@@ -791,6 +827,17 @@ function LobbyView({ id, rules, user }) { ) })()} + {menu && (() => { + const t = menu.t, e = lobby.combat?.entries.find((x) => (t.kind === 'npc' ? x.token === t.id : x.user === t.username)) + const fight = !!lobby.combat + return setMenu(null)} items={[ + { icon: '⚔', label: e ? 'In fight' : fight ? 'Add to fight' : 'No fight', disabled: !fight || !!e, onClick: () => combatOp(t.kind === 'npc' ? { op: 'add', token: t.id } : { op: 'add', user: t.username }) }, + { icon: '📄', label: 'Sheet', disabled: t.kind === 'npc' && !t.npc_id, onClick: () => { if (t.kind === 'npc') setMapSel(t.id); else { setSel(t.username); setMapSel(null) } setLeftTab('sheet'); setSheetOpen(true) } }, + ...(t.kind === 'npc' ? [ + { icon: t.hidden ? '👁' : '🚫', label: t.hidden ? 'Reveal' : 'Hide', onClick: () => patchToken(t.id, { hidden: !t.hidden }) }, + { icon: '🗑', label: 'Remove', onClick: () => { send(`/api/lobbies/${id}/tokens/${t.id}`, 'DELETE'); setMapSel(null) } }] : []), + ]} /> + })()} {canCheck && !pending &&

Click an attribute or skill on your sheet to roll it.

} {pending && { setPending(null); setMErr('') }} err={mErr} />} diff --git a/frontend/src/MapView.jsx b/frontend/src/MapView.jsx index 094b0ef..aba13f4 100644 --- a/frontend/src/MapView.jsx +++ b/frontend/src/MapView.jsx @@ -6,7 +6,7 @@ const clamp = (v, a, b) => Math.min(b, Math.max(a, v)) // Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels. // Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise. -export function MapView({ map, image, portraitUrl, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) { +export function MapView({ map, image, portraitUrl, onMenu, isTurn, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) { const box = useRef(null) const [view, setView] = useState({ tx: 0, ty: 0, s: 1 }) const [drag, setDrag] = useState(null) @@ -122,7 +122,8 @@ export function MapView({ map, image, portraitUrl, tokens, onMove, selected, onS const x = dr ? dr.x : t.x, y = dr ? dr.y : t.y if (x == null || (dr && dr.out)) return null return ( -
{ if (onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(t, e.clientX, e.clientY) } }} style={{ left: x - d(t) / 2, top: y - d(t) / 2, width: d(t), height: d(t), '--tc': t.color, fontSize: Math.max(8, d(t) * 0.45) }} onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '')}> {portraitUrl?.(t) ? : {(t.name || '?').trim().slice(0, 2)}} @@ -149,6 +150,32 @@ export function MapView({ map, image, portraitUrl, tokens, onMove, selected, onS ) } +// Small ring of round buttons around a point (right click on a token). items: [{icon, label, onClick, disabled}] +export function RadialMenu({ x, y, title, items, onClose }) { + useEffect(() => { + const k = (e) => e.key === 'Escape' && onClose() + window.addEventListener('keydown', k) + return () => window.removeEventListener('keydown', k) + }, [onClose]) + const R = items.length > 4 ? 74 : 62 + return createPortal( +
{ e.preventDefault(); onClose() }}> +
e.stopPropagation()}> + {title} + {items.map((it, i) => { + const a = -Math.PI / 2 + (i * 2 * Math.PI) / items.length + return ( + + ) + })} +
+
, document.body) +} + // Name, size, colour and visibility of one token (used in the live table and in the map editor) export function TokenForm({ t, onChange, onDelete, onDuplicate }) { return ( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3eed402..6f8fe1c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -501,3 +501,29 @@ label.chk input[type=checkbox] { width: auto; margin: 0; } .presence.on { background: #3fb950; } .presence.inline { position: static; display: inline-block; width: 9px; height: 9px; border: 0; margin-right: .2rem; } .pcard.off { opacity: .6; } + +/* lobby header removed: DM chip and settings button live in the player bar */ +.playarea { height: calc(100vh - var(--hdr) - 1.2rem); } +.sheetcol { max-height: calc(100vh - var(--hdr) - 1.2rem); } +.dmcard { min-width: 0; border-left-color: var(--accent, gold); } +.pavatar.dm { font-size: .7rem; font-weight: 700; } +.lobbycfg { margin-left: auto; align-self: center; max-width: 16rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.fightrow { display: flex; justify-content: flex-end; margin-top: -.3rem; } +.initbar { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; padding: .35rem .5rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); } +.initround { padding-right: .4rem; border-right: 1px solid var(--line); } +.initcard { display: flex; align-items: center; gap: .35rem; padding: .2rem .5rem; border: 2px solid var(--line); border-radius: var(--radius); cursor: pointer; background: var(--panel2); } +.initcard.player { border-left: 5px solid #4f93e6; } .initcard.npc { border-left: 5px solid #c0392b; } +.initcard.cur { border-color: var(--accent, gold); box-shadow: 0 0 0 1px var(--accent, gold); background: color-mix(in srgb, var(--accent, gold) 18%, var(--panel2)); } +.initnum { font-weight: 700; min-width: 1.6rem; text-align: center; } +.initcard .modin { width: 3.2rem; } +.initctl { margin-left: auto; display: flex; gap: .3rem; } +.maptoken.turn { animation: turnpulse 1.4s ease-in-out infinite; } +@keyframes turnpulse { 0%, 100% { box-shadow: 0 0 0 3px var(--accent, gold), 0 0 6px 3px rgba(255,215,0,.5); } 50% { box-shadow: 0 0 0 3px var(--accent, gold), 0 0 16px 8px rgba(255,215,0,.8); } } +.radialback { position: fixed; inset: 0; z-index: 60; } +.radial { position: fixed; width: 0; height: 0; } +.radialtitle { position: absolute; transform: translate(-50%, -50%); background: rgba(0,0,0,.75); color: #fff; padding: .15rem .5rem; border-radius: 999px; font-size: .75rem; white-space: nowrap; max-width: 9rem; overflow: hidden; text-overflow: ellipsis; } +.radialbtn { position: absolute; left: -26px; top: -26px; width: 52px; height: 52px; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 0; gap: 0; line-height: 1; box-shadow: 0 2px 8px rgba(0,0,0,.6); background: var(--panel); border: 2px solid var(--line); } +.radialbtn:hover:not(:disabled) { border-color: var(--accent, gold); background: var(--panel2); } +.radialbtn .ri { font-size: 1.2rem; } .radialbtn .rl { font-size: .55rem; margin-top: 2px; } +.initcard .modin { width: 2.8rem; padding: .1rem .25rem; min-height: 0; } +.initcard { padding: .1rem .4rem; }