Track files left untracked by recent commits

RankBonusModal.svelte, scene/ObstacleItem.svelte, and lib/tooltip.js were created during the 2026-06-15 scene-dashboard and Rank-3-bonus work and are imported by already-committed components (Dashboard, ChallengePanel, ObstacleBoard, Card, components.css), but were never git-added — so HEAD did not build on a clean checkout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 09:10:10 -07:00
parent daeac91d07
commit 5b5087ac23
3 changed files with 304 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
<script>
import { apiRequest } from '../lib/api';
import { displayName } from '../lib/cards';
export let state;
let error = '';
let submitting = false;
$: me = state.player;
// Mirror the backend's is_eligible_nominee guard: another living, in-play Pi-Rat.
$: eligible = (state.players || []).filter(
(p) => p.id !== me.id && p.role !== 'deep' && !p.is_dead && !p.needs_reroll
);
async function grant(targetId) {
if (submitting) return;
error = '';
submitting = true;
try {
await apiRequest(`/game/${state.game.id}/player/${me.id}/bonus-rank-up`, 'POST', {
target_player_id: targetId || ''
});
} catch (e) {
error = e.message;
} finally {
submitting = false;
}
}
</script>
{#if me.needs_rank_3_bonus}
<div class="modal-backdrop">
<div class="modal-box glass-panel">
<h3>⭐ Your Story is Complete!</h3>
<p class="info-text">
You completed your final Personal Objective and earned your rest. Choose a
crewmate to carry on stronger — they gain <strong>1 Rank</strong>.
</p>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
{#if eligible.length}
<div class="bonus-choices">
{#each eligible as p (p.id)}
<button
class="btn btn-gold btn-full"
on:click={() => grant(p.id)}
disabled={submitting}
>
{displayName(p)}
<span class="rank-hint">Rank {p.rank}{p.rank + 1}</span>
</button>
{/each}
</div>
{:else}
<p class="info-text">No crewmate is eligible to receive the Rank right now.</p>
<button
class="btn btn-secondary btn-full"
on:click={() => grant('')}
disabled={submitting}
>
Forfeit the Rank Bonus
</button>
{/if}
</div>
</div>
{/if}
<style>
.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 {
max-width: 440px;
width: 100%;
padding: 1.75rem;
border: 1px solid var(--edge);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
text-align: center;
}
.modal-box h3 {
margin-top: 0;
}
.bonus-choices {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
}
.rank-hint {
font-size: 0.85em;
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,116 @@
<script>
import { apiRequest } from '../../lib/api';
import { isJoker } from '../../lib/cards';
import { tooltip } from '../../lib/tooltip';
import Card from '../Card.svelte';
export let state;
export let obs;
export let embedded = false; // rendered inside the Challenge panel — skip the "in challenge" flag
const COLOR_HINT = "An Obstacle's color is fixed by its original card and never changes, no matter what's played on it. "
+ 'Beat the difficulty with a card of any suit to pass — but play a card of the matching color (red/black) '
+ 'to also draw one back into your hand.';
const VALUE_HINT = 'The number to beat. Set by the most recent card played on the column — or the original card '
+ 'until one is played. (Color stays fixed to the original.)';
let error = '';
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
$: column_cards = JSON.parse(obs.played_cards || '[]');
$: active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card;
$: success_count = column_cards.filter(c => c.success).length;
$: is_completed = success_count >= state.players.length;
$: in_challenge = openChallenges.some(c => JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
$: is_red = ['H', 'D'].includes(obs.suit);
$: is_face_active = ['J', 'Q', 'K'].includes(active_card_code.slice(0, -1));
// A face-card Obstacle is worth the challenged Rat's Rank. When this Obstacle is
// part of an open Challenge we know who that is — return their rank, else null.
$: challengedRank = (() => {
const ch = openChallenges.find(c => c.challenge_type === 'deep' && JSON.parse(c.obstacle_ids || '[]').includes(obs.id));
if (!ch) return null;
return state.players.find(p => p.id === ch.acting_player_id)?.rank ?? null;
})();
async function post(path, data) {
error = '';
try {
await apiRequest(path, 'POST', data);
} catch (e) {
error = e.message;
}
}
const playCard = (cardCode) => post(`/game/${state.game.id}/player/${state.player.id}/play-card`, { obstacle_id: obs.id, card_code: cardCode });
const playJoker = (cardCode) => post(`/game/${state.game.id}/player/${state.player.id}/play-joker`, { obstacle_id: obs.id, card_code: cardCode });
const clearObstacle = () => post(`/game/${state.game.id}/scene/obstacle/${obs.id}/clear`);
</script>
<div class="obstacle-item suit-{obs.suit.toLowerCase()}"
class:completed={is_completed}
class:in-challenge={in_challenge && !embedded}
data-obstacle-id={obs.id}
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
on:dragleave={(e) => { if (!is_completed) e.currentTarget.classList.remove("drag-over"); }}
on:drop={(e) => {
if (is_completed) return;
e.preventDefault();
e.currentTarget.classList.remove("drag-over");
const cardCode = e.dataTransfer.getData('text/plain');
if (cardCode) {
if (isJoker(cardCode)) playJoker(cardCode);
else playCard(cardCode);
}
}}>
{#if error}
<div class="alert alert-danger" style="grid-column: 1 / -1;">{error}</div>
{/if}
<div class="obstacle-card-wrapper">
<Card card={obs.original_card} size="medium" />
<span class="card-caption">Original card</span>
</div>
<div class="obstacle-details">
<h4>{obs.title} {#if in_challenge && !embedded}<span class="in-challenge-tag">⚔️ In Challenge</span>{/if}</h4>
<span class="obstacle-color-tag {is_red ? 'red' : 'black'}" use:tooltip={COLOR_HINT}>
{is_red ? 'Red' : 'Black'} obstacle · match to draw
</span>
<p class="desc">{obs.description}</p>
</div>
<!-- Value Display -->
<div class="obstacle-value-display text-center" use:tooltip={VALUE_HINT}>
<span class="val-label">Current Difficulty</span>
<span class="val-number">
{#if is_face_active}
{challengedRank !== null ? `${challengedRank} (Rat's Rank)` : "Challenged Rat's Rank"}
{:else}
{obs.current_value}
{/if}
</span>
</div>
<!-- Successes Tracker -->
<div class="obstacle-successes-display text-center">
<span class="val-label">Successes</span>
<span class="val-number">{success_count} / {state.players.length}</span>
</div>
<!-- Played Cards Column -->
<div class="played-column">
<h5>Card History (Column)</h5>
<div class="column-cards-flex">
<Card card={obs.original_card} size="mini" />
{#each column_cards as pc}
<Card card={pc.card} size="mini" rotated success={pc.success} owner={pc.player_name.slice(0, 4)} />
{/each}
</div>
</div>
{#if is_completed && state.player.role === 'deep'}
<div class="clear-obstacle-row text-center">
<button class="btn btn-success btn-full" on:click={clearObstacle}>Clear Obstacle</button>
</div>
{/if}
</div>

View File

@@ -0,0 +1,84 @@
// A styled hover/focus tooltip that replaces the native `title` attribute.
//
// use:tooltip={"plain text"} -> rendered as text
// use:tooltip={{ html: "<b>rich</b>" }} -> rendered as HTML (trusted, static only)
//
// Only ever pass trusted, static HTML (e.g. built from the rulebook tables in
// lib/cards.js); never interpolate raw player input into the `html` form.
//
// The floating element is appended to <body> and positioned with fixed
// coordinates so it escapes any overflow:hidden / transformed ancestors
// (cards, modals, scrolling columns).
function contentEmpty(content) {
if (!content) return true;
if (typeof content === 'object') return !content.html;
return String(content).trim() === '';
}
export function tooltip(node, content) {
let current = content;
let tip = null;
function show() {
if (tip || contentEmpty(current)) return;
tip = document.createElement('div');
tip.className = 'tooltip-pop';
tip.setAttribute('role', 'tooltip');
if (typeof current === 'object' && current.html != null) {
tip.innerHTML = current.html;
} else {
tip.textContent = String(current);
}
document.body.appendChild(tip);
position();
requestAnimationFrame(() => tip && tip.classList.add('visible'));
}
function position() {
if (!tip) return;
const r = node.getBoundingClientRect();
const t = tip.getBoundingClientRect();
const margin = 8;
// Prefer above the target; flip below when there isn't room.
let top = r.top - t.height - margin;
if (top < margin) top = r.bottom + margin;
if (top + t.height > window.innerHeight - margin) {
top = Math.max(margin, window.innerHeight - t.height - margin);
}
let left = r.left + r.width / 2 - t.width / 2;
left = Math.max(margin, Math.min(left, window.innerWidth - t.width - margin));
tip.style.top = `${top}px`;
tip.style.left = `${left}px`;
}
function hide() {
if (tip) {
tip.remove();
tip = null;
}
}
node.addEventListener('mouseenter', show);
node.addEventListener('mouseleave', hide);
node.addEventListener('focus', show);
node.addEventListener('blur', hide);
return {
update(newContent) {
current = newContent;
if (tip) {
// Refresh the content of an already-visible tip in place.
hide();
show();
}
},
destroy() {
hide();
node.removeEventListener('mouseenter', show);
node.removeEventListener('mouseleave', hide);
node.removeEventListener('focus', show);
node.removeEventListener('blur', hide);
},
};
}