Refresh the scene dashboard around a crew bubble column

Replace the roster banner and the standalone character-sheet panel with
a left-hand column of crew bubbles. Each bubble shows the player's role
icon, name, rank, current hand size, and a 🛞 for the Captain; clicking
one pops out that player's character sheet as a modal.

- New CrewColumn.svelte: bubbles (Pi-Rats first, Deep last) + an
  Objectives button that opens a crew-objectives popup.
- CharacterSheet.svelte is now a target-driven modal: description,
  likes/hates, and personal objectives for anyone; secret J/Q/K only on
  your own sheet; the Duel UI (no "Challenge whom?" — target is fixed)
  only when a living Pi-Rat views another living Pi-Rat.
- ScenePhase.svelte → 3-column layout (crew | obstacles | hand/deep);
  crew column collapses to a horizontal strip on narrow screens.
- Shared .modal-backdrop/.modal-box/.modal-close moved into components.css;
  removed the now-dead roster-banner and captain-badge styles.

Backend unchanged — the state blob already carries hands, roles, ranks,
objectives, and the captain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 20:03:41 -07:00
parent 8614a6c820
commit 2ea91c066a
6 changed files with 367 additions and 236 deletions

View File

@@ -45,7 +45,7 @@
- [ ] **Look for dead code.** While implementing the rollback feature, we discovered /scene/rollback, an unused and buggy single-card-play rollback route that was added but never hooked up to the UI. Look for other code that matches that pattern -- orphan functions and routes that never get called.
- [ ] **Scene dashboard design refresh.** There should be a column of bubbles/buttons to the left of the obstacle list panel. These represent the crew. You can click a Pi-Rat to pop out their character sheet. If the other player is a Pi-Rat in the scene, there's the "challenge them" UI, which no longer needs the "Challenge whom?" since it's tied to a particular character sheet. Finally, there's the personal objective list. There's also an objectives button (at the top level of the hierarchy) which shows a popup for crew objectives. This entirely replaces the character sheet panel on the current dashboard, as well as the roster at the top of the obstacle list. The captain has a steering wheel emoji next to them. The display should also show current hand size for all players.
- [x] **Scene dashboard design refresh.** There should be a column of bubbles/buttons to the left of the obstacle list panel. These represent the crew. You can click a Pi-Rat to pop out their character sheet. If the other player is a Pi-Rat in the scene, there's the "challenge them" UI, which no longer needs the "Challenge whom?" since it's tied to a particular character sheet. Finally, there's the personal objective list. There's also an objectives button (at the top level of the hierarchy) which shows a popup for crew objectives. This entirely replaces the character sheet panel on the current dashboard, as well as the roster at the top of the obstacle list. The captain has a steering wheel emoji next to them. The display should also show current hand size for all players.
- [ ] Interaction during a challenge should be promoted to the top of the UI

View File

@@ -398,25 +398,6 @@
color: var(--ghost);
}
.captain-badge {
background: color-mix(in srgb, var(--accent) 14%, transparent);
border: 1px solid var(--accent);
color: var(--accent);
padding: 0.4rem 1rem;
border-radius: 20px;
font-size: 1rem;
font-weight: 700;
font-family: var(--font-heading);
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.captain-badge.vacant {
background: color-mix(in srgb, var(--text) 4%, transparent);
border: 1px dashed var(--edge);
color: var(--text-muted);
}
/* --- Waiting indicator --- */
.waiting-box, .waiting-indicator {
@@ -428,3 +409,56 @@
gap: 0.75rem;
}
/* --- Modal overlay (shared by popups) --- */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
backdrop-filter: blur(6px);
}
.modal-box {
position: relative;
max-width: 440px;
width: 100%;
max-height: 88vh;
overflow-y: auto;
padding: 1.75rem;
border: 1px solid var(--edge);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
}
.modal-box h3 {
margin-top: 0;
}
.modal-box.sheet-modal {
max-width: 520px;
text-align: left;
}
.modal-close {
position: absolute;
top: 0.6rem;
right: 0.75rem;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 1.6rem;
line-height: 1;
cursor: pointer;
padding: 0.1rem 0.4rem;
border-radius: var(--radius-sm);
}
.modal-close:hover {
color: var(--text);
background: color-mix(in srgb, var(--text) 10%, transparent);
}

View File

@@ -1,8 +1,9 @@
/* --- Scene board layout --- */
.scene-view-layout {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 2rem;
grid-template-columns: 180px 1.4fr 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 1100px) {
@@ -11,6 +12,98 @@
}
}
/* --- Crew bubble column --- */
.crew-column {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.crew-objectives-btn {
width: 100%;
padding: 0.6rem 0.5rem;
background: color-mix(in srgb, var(--accent) 14%, transparent);
border: 1px solid var(--accent);
border-radius: var(--radius-md);
color: var(--text);
font-family: var(--font-heading);
font-size: 0.9rem;
cursor: pointer;
transition: var(--transition-smooth);
}
.crew-objectives-btn:hover {
background: color-mix(in srgb, var(--accent) 24%, transparent);
}
.crew-bubbles {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.crew-bubble {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
width: 100%;
text-align: left;
padding: 0.55rem 0.7rem;
background: var(--well);
border: 1px solid var(--edge);
border-left: 4px solid var(--pirat);
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition-smooth);
}
.crew-bubble:hover {
background: color-mix(in srgb, var(--text) 8%, transparent);
transform: translateX(2px);
}
.crew-bubble.is-deep {
border-left-color: var(--deep);
background: color-mix(in srgb, var(--deep) 6%, transparent);
}
.crew-bubble.is-you {
border-color: var(--accent);
border-left-color: var(--accent);
}
.crew-bubble.is-ghost { opacity: 0.7; filter: grayscale(0.6); }
.crew-bubble.is-dead { opacity: 0.6; filter: grayscale(0.8); }
.crew-bubble .bubble-icon {
font-size: 1.1rem;
}
.crew-bubble .bubble-name {
font-weight: 700;
font-size: 0.9rem;
line-height: 1.15;
overflow-wrap: anywhere;
}
.crew-bubble .bubble-meta {
font-size: 0.75rem;
color: var(--text-muted);
}
/* On narrow screens the crew column becomes a horizontal strip above the board */
@media (max-width: 1100px) {
.crew-bubbles {
flex-direction: row;
flex-wrap: wrap;
}
.crew-bubble {
width: auto;
flex: 1 1 140px;
}
}
.card-header {
display: flex;
justify-content: space-between;
@@ -32,44 +125,6 @@
font-weight: 700;
}
/* Scene status banner (captain + roster) */
.scene-status-banner {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--well);
border: 1px solid var(--edge);
border-radius: var(--radius-md);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.roster-chips {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.roster-chips .roster-label {
font-size: 0.85rem;
color: var(--text-muted);
font-family: var(--font-heading);
margin-right: 0.25rem;
}
.roster-chips .player-chip {
font-size: 0.8rem;
padding: 0.25rem 0.6rem;
border-radius: 12px;
margin: 0;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
/* --- Obstacles --- */
.obstacles-container {
display: flex;

View File

@@ -1,24 +1,22 @@
<script>
import Card from './Card.svelte';
import { displayName } from '../lib/cards';
import ChallengePanel from './scene/ChallengePanel.svelte';
import ObstacleBoard from './scene/ObstacleBoard.svelte';
import DeepControlPanel from './scene/DeepControlPanel.svelte';
import CharacterSheet from './scene/CharacterSheet.svelte';
import CrewColumn from './scene/CrewColumn.svelte';
export let state;
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: captain = state.players.find(p => p.id === state.game.captain_player_id) || null;
$: isCaptain = state.game.captain_player_id === state.player.id;
$: playerTechs = { J: state.player.tech_jack, Q: state.player.tech_queen, K: state.player.tech_king };
</script>
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
<!-- LEFT COLUMN: The Shared Table -->
<div class="table-column">
<!-- LEFT COLUMN: The Crew -->
<CrewColumn {state} />
<!-- Game Deck and Obstacles Roster -->
<!-- MIDDLE COLUMN: The Shared Table -->
<div class="table-column">
<div class="card glass-panel obstacle-list-card">
<div class="card-header">
<h3>🌊 The Obstacle List</h3>
@@ -26,45 +24,13 @@
</div>
<div class="obstacles-container" id="scene-obstacles-container">
<!-- TOP STATUS BAR: Scene Info and Captain -->
<div class="scene-status-banner">
<!-- Captain Badge -->
<div>
{#if captain}
<span class="captain-badge">
🏴‍☠️ Captain: {displayName(captain)} (Rank {captain.rank}){isCaptain ? ' — You!' : ''}
</span>
{:else}
<span class="captain-badge vacant">
🏴‍☠️ Captain: None{state.game.completed_crew_1 ? '' : ' (steal a ship first!)'}
</span>
{/if}
</div>
<!-- Active Scene Roster Mini-list -->
<div class="roster-chips">
<span class="roster-label">Active Roster:</span>
{#each state.players as p}
{#if p.role === 'deep'}
<span class="player-chip deep-chip">
🌊 {displayName(p)} (Deep)
</span>
{:else if p.role === 'pirat'}
<span class="player-chip pirat-chip {p.is_ghost ? 'is-ghost' : ''} {p.is_dead ? 'is-dead' : ''} {p.id === state.game.captain_player_id ? 'is-captain' : ''}">
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {displayName(p)} (Rank {p.rank}){p.id === state.game.captain_player_id ? ' ⭐' : ''}
</span>
{/if}
{/each}
</div>
</div>
<ChallengePanel {state} />
<ObstacleBoard {state} />
</div>
</div>
</div>
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
<!-- RIGHT COLUMN: Private Hand / Deep Controls -->
<div class="private-column">
{#if state.player.role === "deep"}
<DeepControlPanel {state} />
@@ -90,8 +56,6 @@
{/each}
</div>
</div>
<CharacterSheet {state} />
{/if}
</div>
</div>

View File

@@ -1,45 +1,62 @@
<script>
import { createEventDispatcher } from 'svelte';
import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker, displayName, cardObstacleInfo, obstacleTable } from '../../lib/cards';
export let state;
export let target; // the player whose sheet is being viewed
const dispatch = createEventDispatcher();
function close() { dispatch('close'); }
let error = '';
let pvpOpponentId = '';
let pvpCard = '';
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: scenePirats = state.players.filter(p => p.role === 'pirat');
$: viewer = state.player;
$: isSelf = target.id === viewer.id;
// The viewer throws one of their OWN cards in a duel.
$: hand = viewer.hand_cards ? JSON.parse(viewer.hand_cards) : [];
$: canDuel = !isSelf
&& viewer.role === 'pirat' && !viewer.is_dead && !viewer.needs_reroll
&& target.role === 'pirat' && !target.is_dead;
// What the chosen duel card becomes as a temporary Obstacle for the defender
$: pvpCardObstacle = cardObstacleInfo(pvpCard, $obstacleTable);
function getPlayerName(id) {
if (!id) return 'a crewmate';
const p = state.players.find(pl => pl.id === id);
return p ? displayName(p) : 'Unknown';
}
$: duelTargets = scenePirats.filter(p => p.id !== state.player.id && !p.is_dead);
// What the chosen duel card becomes as a temporary Obstacle for the defender
$: pvpCardObstacle = cardObstacleInfo(pvpCard, $obstacleTable);
async function createPvp() {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/pvp/create`, 'POST', {
defender_id: pvpOpponentId,
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/challenge/pvp/create`, 'POST', {
defender_id: target.id,
card_code: pvpCard
});
pvpOpponentId = '';
pvpCard = '';
} catch(e) {
close();
} catch (e) {
error = e.message;
}
}
</script>
<div class="card glass-panel sheet-card">
<h3>🐀 {state.player.name}'s Character Sheet</h3>
{#if state.player.player_name && state.player.player_name !== state.player.name}
<svelte:window on:keydown={(e) => e.key === 'Escape' && close()} />
<div class="modal-backdrop" on:click|self={close}>
<div class="modal-box sheet-modal glass-panel">
<button class="modal-close" on:click={close} aria-label="Close">×</button>
<h3>
{#if target.role === 'deep'}🌊{:else if target.is_ghost}👻{:else if target.is_dead}💀{:else}🐀{/if}
{target.name}'s Character Sheet
{#if target.id === state.game.captain_player_id}🛞{/if}
</h3>
{#if target.player_name && target.player_name !== target.name}
<p class="info-text" style="margin-top: -0.5rem; font-size: 0.85rem;">
Played by {state.player.player_name}{#if !state.player.completed_personal_2}&nbsp;— they'll go by their smell until they earn a Name!{/if}
Played by {target.player_name}{#if !target.completed_personal_2}&nbsp;— they'll go by their smell until they earn a Name!{/if}
</p>
{/if}
@@ -48,28 +65,28 @@
<div class="alert alert-danger">{error}</div>
{/if}
{#if state.player.needs_reroll}
{#if isSelf && state.player.needs_reroll}
<div class="prompt-box accent text-center">
<h4>🐀 Recruit Incoming!</h4>
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You don't have a Pi-Rat in this scene. Sit back, enjoy the chaos, and you'll create your recruit with the crew between scenes.</p>
</div>
{/if}
{#if state.player.is_dead}
{#if isSelf && state.player.is_dead}
<div class="prompt-box danger text-center">
<h4>💀 You have Died / Retired!</h4>
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">Your story arc is complete. You will choose to become a Ghost or roll a new character during the upkeep phase between scenes.</p>
</div>
{/if}
{#if state.player.is_ghost}
{#if isSelf && state.player.is_ghost}
<div class="prompt-box ghostly text-center">
<h4>👻 Playing as a Ghost</h4>
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You are in the Ghost World (grayscale view). You cannot interact well with the living but you can still help them resolve challenges!</p>
</div>
{/if}
{#if state.player.tax_banned}
{#if isSelf && state.player.tax_banned}
<div class="prompt-box danger text-center">
<p class="info-text" style="margin: 0; font-size: 0.9rem;">🚫 You failed a refused Tax — no more Gat/Name Taxes for you this scene.</p>
</div>
@@ -77,46 +94,43 @@
<div class="sheet-group">
<h4>Description:</h4>
<p><strong>Look:</strong> {state.player.avatar_look}</p>
<p><strong>Smell:</strong> {state.player.avatar_smell}</p>
<p><strong>First Words:</strong> "{state.player.first_words}"</p>
{#if state.player.gat_description}
<p><strong>🔫 Gat:</strong> {state.player.gat_description}</p>
<p><strong>Look:</strong> {target.avatar_look}</p>
<p><strong>Smell:</strong> {target.avatar_smell}</p>
<p><strong>First Words:</strong> "{target.first_words}"</p>
{#if target.gat_description}
<p><strong>🔫 Gat:</strong> {target.gat_description}</p>
{/if}
</div>
{#if state.player.other_like || state.player.other_hate}
{#if target.other_like || target.other_hate}
<div class="sheet-group margin-top">
<h4>What the Crew Thinks:</h4>
{#if state.player.other_like}
<p><strong>👍 Likes:</strong> "{state.player.other_like}" <span class="text-muted">{getPlayerName(state.player.other_like_from_player_id)}</span></p>
{#if target.other_like}
<p><strong>👍 Likes:</strong> "{target.other_like}" <span class="text-muted">{getPlayerName(target.other_like_from_player_id)}</span></p>
{/if}
{#if state.player.other_hate}
<p><strong>👎 Hates:</strong> "{state.player.other_hate}" <span class="text-muted">{getPlayerName(state.player.other_hate_from_player_id)}</span></p>
{#if target.other_hate}
<p><strong>👎 Hates:</strong> "{target.other_hate}" <span class="text-muted">{getPlayerName(target.other_hate_from_player_id)}</span></p>
{/if}
</div>
{/if}
<!-- Secret techniques are visible only on your own sheet -->
{#if isSelf}
<div class="sheet-group margin-top">
<h4>Assigned Secret Techniques (J/Q/K):</h4>
<ul class="techniques-list-sheet">
<li><strong>Jack (J):</strong> "{state.player.tech_jack}"</li>
<li><strong>Queen (Q):</strong> "{state.player.tech_queen}"</li>
<li><strong>King (K):</strong> "{state.player.tech_king}"</li>
<li><strong>Jack (J):</strong> "{target.tech_jack}"</li>
<li><strong>Queen (Q):</strong> "{target.tech_queen}"</li>
<li><strong>King (K):</strong> "{target.tech_king}"</li>
</ul>
</div>
{/if}
<!-- Duel another Pi-Rat -->
{#if !state.player.is_dead && !state.player.needs_reroll && duelTargets.length > 0}
<!-- Duel this Pi-Rat (target is fixed to this sheet) -->
{#if canDuel}
<div class="sheet-group margin-top">
<h4>⚔️ Duel a Pi-Rat</h4>
<p class="info-text" style="font-size: 0.85rem;">Challenge a crewmate (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p>
<select class="select-field" bind:value={pvpOpponentId} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Challenge whom?</option>
{#each duelTargets as p}
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
{/each}
</select>
<h4>⚔️ Duel {displayName(target)}</h4>
<p class="info-text" style="font-size: 0.85rem;">Challenge them (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p>
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Throw which card?</option>
{#each hand.filter(c => !isJoker(c)) as card}
@@ -128,44 +142,27 @@
As an Obstacle, {getCardDisplay(pvpCard)} is <strong>{pvpCardObstacle.title}</strong>: {pvpCardObstacle.description}
</p>
{/if}
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpOpponentId || !pvpCard}>Throw Down!</button>
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpCard}>Throw Down!</button>
</div>
{/if}
<div class="sheet-group margin-top">
<h4>Crew Objectives</h4>
<div class="objectives-checklist">
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_1} disabled>
<span>Steal a Ship</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_2} disabled>
<span>Choose a Captain</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_3} disabled>
<span>Commit Piracy</span>
</label>
</div>
</div>
<div class="sheet-group margin-top">
<h4>Personal Objectives</h4>
<div class="objectives-checklist">
<label class="checkbox-label">
<input type="checkbox" checked={state.player.completed_personal_1} disabled>
<input type="checkbox" checked={target.completed_personal_1} disabled>
<span>1. Gat</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.player.completed_personal_2} disabled>
<input type="checkbox" checked={target.completed_personal_2} disabled>
<span>2. Name</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.player.completed_personal_3} disabled>
<input type="checkbox" checked={target.completed_personal_3} disabled>
<span>3. Die/Retire</span>
</label>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,81 @@
<script>
import CharacterSheet from './CharacterSheet.svelte';
export let state;
let openTargetId = null;
let showObjectives = false;
// Pi-Rats first, the Deep last; otherwise keep join order.
$: crew = [...state.players].sort((a, b) => (a.role === 'deep' ? 1 : 0) - (b.role === 'deep' ? 1 : 0));
$: openTarget = openTargetId ? state.players.find(p => p.id === openTargetId) : null;
function handSize(p) {
try { return JSON.parse(p.hand_cards || '[]').length; } catch { return 0; }
}
function roleIcon(p) {
if (p.role === 'deep') return '🌊';
if (p.is_ghost) return '👻';
if (p.is_dead) return '💀';
return '🐀';
}
function onKeydown(e) {
// CharacterSheet handles its own Escape; this closes the objectives popup.
if (e.key === 'Escape') showObjectives = false;
}
</script>
<svelte:window on:keydown={onKeydown} />
<div class="crew-column">
<button class="crew-objectives-btn" on:click={() => showObjectives = true}>🎯 Objectives</button>
<div class="crew-bubbles">
{#each crew as p (p.id)}
<button
class="crew-bubble"
class:is-you={p.id === state.player.id}
class:is-deep={p.role === 'deep'}
class:is-dead={p.is_dead}
class:is-ghost={p.is_ghost}
title="View {p.name}'s character sheet"
on:click={() => openTargetId = p.id}
>
<span class="bubble-icon">{roleIcon(p)}</span>
<span class="bubble-name">{p.name}{#if p.id === state.game.captain_player_id}&nbsp;🛞{/if}</span>
<span class="bubble-meta">
{#if p.role === 'deep'}Deep{:else}Rank {p.rank}{/if} · 🃏 {handSize(p)}
</span>
</button>
{/each}
</div>
</div>
{#if openTarget}
<CharacterSheet {state} target={openTarget} on:close={() => openTargetId = null} />
{/if}
{#if showObjectives}
<div class="modal-backdrop" on:click|self={() => showObjectives = false}>
<div class="modal-box glass-panel" style="text-align: left;">
<button class="modal-close" on:click={() => showObjectives = false} aria-label="Close">×</button>
<h3>🎯 Crew Objectives</h3>
<p class="info-text" style="font-size: 0.85rem;">The whole crew works toward these. The Deep marks them off as you accomplish them.</p>
<div class="objectives-checklist">
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_1} disabled>
<span>Steal a Ship</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_2} disabled>
<span>Choose a Captain</span>
</label>
<label class="checkbox-label">
<input type="checkbox" checked={state.game.completed_crew_3} disabled>
<span>Commit Piracy</span>
</label>
</div>
</div>
</div>
{/if}