Compare commits

...

6 Commits

Author SHA1 Message Date
e4c78391fc Declutter the challenge panel and obstacle titles (v4)
Per playtest feedback:
- Drop the per-challenge 'Plays:' line — redundant with the card column and the
  event log (also removes the now-unused chPlays const).
- Drop the 'Attempted by … drag a card … assist' how-to paragraph; players know
  the basics, and the challenge header already names who's challenged.
- Replace the 'Red/Black Obstacle · match to draw' tag with the original card in
  the title (e.g. '5♣ — Loitering Mermaid'), and color the title by suit. The
  left border already marks color; the dead .obstacle-color-tag CSS is removed.

Suit glyphs in the title use plain text symbols (not SUIT_EMOJI) so they take
the theme-aware suit color and stay readable on the dark panel. Verified in
light and dark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:56:07 -07:00
922fc38c0a Rules: add card diagrams to the Example of Play (v3)
The Cheesy Heist walkthrough was a wall of text. Add a card-state diagram at
each step (after the draw, and after each of the three Challenges) showing both
Obstacle columns as they grow — reusing the same overlapping-column visual as
the in-app obstacle display: original card at the back with a gold ring, plays
stacked in front (green = beat the difficulty, red = didn't), newest in full
view. Self-contained CSS in rules.html; card faces stay light parchment in both
themes. Verified in light/dark and on mobile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:44:59 -07:00
cf9ee33b52 Condense obstacle cards into an overlapping column (v2)
Replace the big-original-card + row-of-minis layout with a single vertical
column: the original sits at the back (accent ring), each play stacks in front
with a heavy negative margin so only its rank+suit peeks out, and the latest
card is fully visible at the bottom. Played cards keep a green/red success ring;
the ChallengePanel's per-play list still credits each player.

Top-aligns the Current Difficulty / Successes boxes so they no longer stretch to
the now-taller card column. Matches the rulebook's column card layout and is far
more compact. Verified in the ChallengePanel, the obstacle list, and on mobile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:38:01 -07:00
17160f9d9b Add version number + About dialog with changelog (v1)
Start tracking a version number and a player-facing changelog, surfaced from
the ☰ menu via a new About item.

- frontend/src/lib/changelog.js: VERSION (bump every commit) + CHANGELOG.
- AboutModal.svelte: shows the version and changelog using the shared modal CSS;
  closes via ×, backdrop click, or Escape.
- CornerMenu.svelte: new 'ℹ️ About' menu item, modal rendered outside the menu's
  stacking context so it overlays correctly on every page.
- AGENTS.md: documents the bump-every-commit / log-only-user-facing convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:20:47 -07:00
44f7fd939b Home: list Rejoin a Crew above Muster, with a gap between
Returning players are the common case, so surface their saved crews first.
The two glass panels had no margin between them; add spacing on the
Rejoin box so it sits clear of the Muster box below it.

Also tidies the TODO 'Polish' section (renamed from 'UI Polish'; drops the
now-committed Rank-3 bonus 'Gameplay gaps' entry).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:14:27 -07:00
5b5087ac23 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>
2026-06-15 09:10:10 -07:00
12 changed files with 682 additions and 103 deletions

View File

@@ -17,6 +17,14 @@ Do NOT add ad-hoc `ALTER TABLE` statements to `database.py` — that legacy list
When you run into a repeatable problem during testing (e.g. port assignment collision, missing executable, etc), note down the problem and solution in this file so that you'll have access to it in future sessions. When you run into a repeatable problem during testing (e.g. port assignment collision, missing executable, etc), note down the problem and solution in this file so that you'll have access to it in future sessions.
## Versioning & changelog
The app shows its version number and a player-facing changelog in the ☰ menu → About. Both come from `frontend/src/lib/changelog.js` (rendered by `frontend/src/components/AboutModal.svelte`).
- Bump `VERSION` by one on **every** commit.
- When a commit changes something players can see, add an entry to the **top** of `CHANGELOG` (`{ version, date, changes: [...] }`) describing it in player-facing terms. Group everything shipping under one version into a single entry.
- The changelog is for players: skip refactors, tests, tooling, and other internal-only changes. A commit with no user-facing change bumps `VERSION` but adds no entry.
## Work order ## Work order
You will be working on tasks in [TODO.md](./TODO.md]. Work on at most two tasks at a time (one is preferable, but two is fine if they dove tail really nicely), and after you finish each task, make a git commit and update the TODO list to check off the completed task. You will be working on tasks in [TODO.md](./TODO.md]. Work on at most two tasks at a time (one is preferable, but two is fine if they dove tail really nicely), and after you finish each task, make a git commit and update the TODO list to check off the completed task.

13
TODO.md
View File

@@ -1,18 +1,15 @@
## UI Polish ## Polish
- [ ] On the home page, rejoin a crew should be above muster a crew, and there should be a gap between the two boxes - [x] On the home page, rejoin a crew should be above muster a crew, and there should be a gap between the two boxes
- [x] **Version number and change log** Start keeping track of version number. It's fine to just start with v1 and increment on every commit. In the hamburger menu, add an "About" item that pops up a modal with the current version number and a change log. The change log should only cover user-facing changes. Add a note to AGENTS.md about maintaining the change log.
- [x] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.
- [x] **Condense Obstacle card display**: Change how the cards in an Obstacle are laid out. Instead of being one big card and a bunch of little ones under it in a row, they should be a single column, with the cards overlapping so that you can just see the numbers and suits of the previous cards peeking out behind the latest card. This is more compact, puts the focus on the most relevant information, and is in line with the rulebook describing the card layout as a column. This is probably also how the cards in the example play section should be laid out.
## Words Words Words ## Words Words Words
- [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint. - [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
- [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient - [ ] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
- [ ] **Version number and change log**
## Security ## Security
- [ ] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection (SQLModel should handle this?), doing some basic sanitizing of free inputs from players (valid unicode only + generous character limit should be enough), and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary database updates. - [ ] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection (SQLModel should handle this?), doing some basic sanitizing of free inputs from players (valid unicode only + generous character limit should be enough), and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary database updates.
## Gameplay gaps
- [x] **Finish the Rank-3 story bonus.** A Pi-Rat who completes their 3rd Personal Objective (RULEBOOK §93) now gets a "⭐ Your Story is Complete!" modal (`RankBonusModal.svelte`, keyed on `needs_rank_3_bonus`) to grant another eligible crewmate +1 Rank — or forfeit it when none qualify. *(Done 2026-06-15: logic in `crud_scene.grant_story_bonus_rank` reusing the shared `is_eligible_nominee` guard; route slimmed to the (ok, msg) convention; unit tests added; verified end-to-end in the browser.)*

View File

@@ -205,58 +205,42 @@
.suit-c, .suit-s { border-left: 4px solid var(--suit-black); } .suit-c, .suit-s { border-left: 4px solid var(--suit-black); }
.suit-h, .suit-d { border-left: 4px solid var(--suit-red); } .suit-h, .suit-d { border-left: 4px solid var(--suit-red); }
.obstacle-card-wrapper { /* Overlapping card column for an Obstacle. Cards stack with a heavy negative
margin so each earlier card shows only its top strip (rank + suit); the most
recent play sits fully visible at the bottom. Rings: accent = the original
card, green/red = a play that did / didn't beat the difficulty. */
.obstacle-stack {
--peek: 34px; /* visible strip of each earlier card */
--card-h: 112px; /* medium card height (card.css) */
align-self: start;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
align-items: center; align-items: center;
gap: 0.3rem; padding: 2px; /* room for the rings, which overflow:hidden would clip */
} }
.card-caption { .stack-card {
font-size: 0.6rem; position: relative;
text-transform: uppercase; border-radius: var(--radius-sm);
letter-spacing: 0.04em; line-height: 0;
color: var(--text-muted);
} }
/* Obstacle color (red/black) — fixed by the original card; matching it draws back */ .stack-card + .stack-card {
.obstacle-color-tag { margin-top: calc(var(--peek) - var(--card-h));
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.4rem;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.12rem 0.5rem;
border-radius: 999px;
cursor: help;
} }
.obstacle-color-tag::before { .stack-card.is-original {
content: ''; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
display: inline-block;
} }
.obstacle-color-tag.red { .stack-card.is-success {
color: var(--suit-red); box-shadow: 0 0 0 2px var(--success);
background: color-mix(in srgb, var(--suit-red) 14%, transparent);
} }
.obstacle-color-tag.red::before { background: var(--suit-red); } .stack-card.is-failure {
box-shadow: 0 0 0 2px var(--danger);
.obstacle-color-tag.black {
color: var(--suit-black);
background: color-mix(in srgb, var(--suit-black) 14%, transparent);
} }
.obstacle-color-tag.black::before { background: var(--suit-black); }
.suit-badge { .suit-badge {
font-weight: 900; font-weight: 900;
font-size: 1.1rem; font-size: 1.1rem;
@@ -277,6 +261,13 @@
font-size: 1.15rem; font-size: 1.15rem;
} }
/* The title and its "K♦" card-code prefix take the Obstacle's fixed suit color
(red border on the left already marks color, so the old tag is gone). */
.suit-h .obstacle-details h4, .suit-d .obstacle-details h4 { color: var(--suit-red); }
.suit-c .obstacle-details h4, .suit-s .obstacle-details h4 { color: var(--suit-black); }
.obs-card-code { opacity: 0.8; }
.obstacle-details .in-challenge-tag { .obstacle-details .in-challenge-tag {
color: var(--deep); color: var(--deep);
font-size: 0.8rem; font-size: 0.8rem;
@@ -288,6 +279,7 @@
} }
.obstacle-value-display { .obstacle-value-display {
align-self: start;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@@ -299,6 +291,7 @@
} }
.obstacle-successes-display { .obstacle-successes-display {
align-self: start;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@@ -327,26 +320,6 @@
color: var(--accent); color: var(--accent);
} }
.played-column {
grid-column: 1 / -1;
border-top: 1px solid var(--edge-soft);
padding-top: 0.75rem;
margin-top: 0.5rem;
}
.played-column h5 {
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.column-cards-flex {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.clear-obstacle-row { .clear-obstacle-row {
grid-column: 1 / -1; grid-column: 1 / -1;
} }

View File

@@ -0,0 +1,82 @@
<script>
import { createEventDispatcher } from 'svelte';
import { VERSION, CHANGELOG } from '../lib/changelog';
const dispatch = createEventDispatcher();
function close() { dispatch('close'); }
</script>
<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 about-modal">
<button class="modal-close" on:click={close} aria-label="Close">×</button>
<h3>About</h3>
<p class="about-tagline">A remote-play companion for <em>Rats with Gats</em>.</p>
<p class="about-version">Version {VERSION}</p>
<h4 class="about-heading">What's New</h4>
<ul class="changelog">
{#each CHANGELOG as entry (entry.version)}
<li class="changelog-entry">
<div class="changelog-head">
<span class="changelog-ver">v{entry.version}</span>
<span class="changelog-date text-muted">{entry.date}</span>
</div>
<ul class="changelog-changes">
{#each entry.changes as change}
<li>{change}</li>
{/each}
</ul>
</li>
{/each}
</ul>
</div>
</div>
<style>
.about-tagline {
margin: 0.25rem 0 0.75rem;
color: var(--text-muted);
}
.about-version {
font-family: var(--font-heading);
font-weight: 700;
color: var(--accent);
margin: 0;
}
.about-heading {
margin: 1.25rem 0 0.5rem;
border-top: 1px solid var(--edge-soft);
padding-top: 1rem;
}
.changelog {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.changelog-head {
display: flex;
align-items: baseline;
gap: 0.6rem;
margin-bottom: 0.25rem;
}
.changelog-ver {
font-family: var(--font-heading);
font-weight: 700;
color: var(--accent);
}
.changelog-date {
font-size: 0.85rem;
}
.changelog-changes {
margin: 0;
padding-left: 1.2rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
</style>

View File

@@ -1,8 +1,10 @@
<script> <script>
import AboutModal from './AboutModal.svelte';
import { extraMenuLinks } from '../lib/menu'; import { extraMenuLinks } from '../lib/menu';
import { theme, toggleTheme } from '../lib/theme'; import { theme, toggleTheme } from '../lib/theme';
let open = false; let open = false;
let showAbout = false;
$: themeLink = $theme === 'dark' $: themeLink = $theme === 'dark'
? { label: '☀️ Light Mode', action: toggleTheme, keepOpen: true, title: 'Switch to the light theme' } ? { label: '☀️ Light Mode', action: toggleTheme, keepOpen: true, title: 'Switch to the light theme' }
@@ -11,6 +13,7 @@
$: baseLinks = [ $: baseLinks = [
{ label: '📖 Rules', href: '/rules', external: true, title: 'Open the rulebook in a new tab' }, { label: '📖 Rules', href: '/rules', external: true, title: 'Open the rulebook in a new tab' },
themeLink, themeLink,
{ label: ' About', action: () => (showAbout = true), title: 'Version and changelog' },
]; ];
$: links = [...$extraMenuLinks, ...baseLinks]; $: links = [...$extraMenuLinks, ...baseLinks];
@@ -52,6 +55,10 @@
{/if} {/if}
</div> </div>
{#if showAbout}
<AboutModal on:close={() => (showAbout = false)} />
{/if}
<style> <style>
.corner-menu { .corner-menu {
position: fixed; position: fixed;

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

@@ -95,7 +95,6 @@
{/if} {/if}
{#each openChallenges as ch} {#each openChallenges as ch}
{@const chPlays = JSON.parse(ch.plays || '[]')}
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')} {@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
<div class="challenge-item"> <div class="challenge-item">
<div class="challenge-head"> <div class="challenge-head">
@@ -127,10 +126,6 @@
Temporary Obstacle: <strong use:tooltip={{ html: cardTooltipHtml(ch.temp_card, $obstacleTable) }}>{getCardDisplay(ch.temp_card)}</strong>{playerName(ch.acting_player_id)} must answer it! Temporary Obstacle: <strong use:tooltip={{ html: cardTooltipHtml(ch.temp_card, $obstacleTable) }}>{getCardDisplay(ch.temp_card)}</strong>{playerName(ch.acting_player_id)} must answer it!
</p> </p>
{:else} {:else}
<p class="info-text" style="margin: 0.5rem 0 0 0;">
Attempted by <strong>{playerName(ch.acting_player_id)}</strong>{ch.acting_player_id !== ch.target_player_id ? ` (took it over via ${ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax)` : ''}.
Drag a card onto an Obstacle below to play it. Other Pi-Rats may assist (assistants don't draw back).
</p>
<div class="challenge-obstacles"> <div class="challenge-obstacles">
{#each chObstacleIds as oid} {#each chObstacleIds as oid}
{@const obs = state.obstacles.find(o => o.id === oid)} {@const obs = state.obstacles.find(o => o.id === oid)}
@@ -142,12 +137,6 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if chPlays.length > 0}
<p class="info-text" style="margin: 0.5rem 0 0 0; font-size: 0.85rem;">
Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')}
</p>
{/if}
<!-- Tax: pending request aimed at me --> <!-- Tax: pending request aimed at me -->
{#if ch.tax_state === 'requested'} {#if ch.tax_state === 'requested'}
{#if myTaxRequest && myTaxRequest.id === ch.id} {#if myTaxRequest && myTaxRequest.id === ch.id}

View File

@@ -0,0 +1,112 @@
<script>
import { apiRequest } from '../../lib/api';
import { isJoker, cardValue } 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
// Plain (text-presentation) suit glyphs so they inherit the title's suit color,
// unlike SUIT_EMOJI which renders as fixed-color emoji.
const SUIT_CHAR = { C: '♣', S: '♠', H: '♥', D: '♦' };
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));
$: cardCodeLabel = `${cardValue(obs.original_card)}${SUIT_CHAR[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}
<!-- Card column: original at the back, each play stacked in front of it, the
latest fully visible and earlier cards peeking their rank + suit. -->
<div class="obstacle-stack">
<div class="stack-card is-original" use:tooltip={'Original card — fixes the Obstacles color and never changes.'}>
<Card card={obs.original_card} size="medium" />
</div>
{#each column_cards as pc, i (i)}
<div class="stack-card {pc.success ? 'is-success' : 'is-failure'}"
class:is-latest={i === column_cards.length - 1}
use:tooltip={`${pc.player_name} played this — ${pc.success ? 'success' : 'no success'}.`}>
<Card card={pc.card} size="medium" />
</div>
{/each}
</div>
<div class="obstacle-details">
<h4><span class="obs-card-code">{cardCodeLabel}</span>{obs.title} {#if in_challenge && !embedded}<span class="in-challenge-tag">⚔️ In Challenge</span>{/if}</h4>
<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>
{#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,42 @@
// App version and player-facing changelog, surfaced in the ☰ menu → About
// (rendered by AboutModal.svelte).
//
// Maintenance (see AGENTS.md "Versioning & changelog"):
// - Bump VERSION by one on every commit.
// - Add a CHANGELOG entry only when a commit changes something players can
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
export const VERSION = 4;
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
export const CHANGELOG = [
{
version: 4,
date: '2026-06-15',
changes: [
'Tidied the Challenge panel: dropped the redundant “Plays” list and the how-to-play blurb. Each Obstacle now leads with its card (e.g. “K♦”) in the title, colored by suit.',
],
},
{
version: 3,
date: '2026-06-15',
changes: [
'The rulebooks Example of Play now has card diagrams at each step, showing how each Obstacles column grows as the scene unfolds.',
],
},
{
version: 2,
date: '2026-06-15',
changes: [
'Obstacle cards now stack as a single overlapping column — the latest play sits in full view with earlier cards peeking out behind it, matching the rulebooks card layout.',
],
},
{
version: 1,
date: '2026-06-15',
changes: [
'First tracked version — the app now reports its version here in the ☰ menu → About, with a log of player-facing changes.',
'Home screen lists crews you can rejoin above “Muster a New Crew”.',
],
},
];

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);
},
};
}

View File

@@ -63,33 +63,6 @@
<div class="hero-flourish" aria-hidden="true">🐀 ⚓ 🐀</div> <div class="hero-flourish" aria-hidden="true">🐀 ⚓ 🐀</div>
</header> </header>
<div class="glass-panel creation-card">
<h2>Muster a New Crew</h2>
<form on:submit|preventDefault={createGame}>
<div class="form-group">
<label for="crewName">Crew Name</label>
<input
type="text"
id="crewName"
bind:value={crewName}
required
placeholder="e.g. The Salty Dogs"
class="input-field input-large"
autocomplete="off"
/>
</div>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
<button type="submit" class="btn btn-primary btn-large btn-full" disabled={creating}>
{creating ? 'Creating...' : 'Create Game'}
</button>
</form>
<p class="join-hint">Joining someone else's crew? Ask the game creator for the join link from their lobby.</p>
</div>
{#if sessions.length > 0} {#if sessions.length > 0}
<div class="glass-panel creation-card saved-sessions"> <div class="glass-panel creation-card saved-sessions">
<h2>Rejoin a Crew</h2> <h2>Rejoin a Crew</h2>
@@ -118,12 +91,43 @@
</div> </div>
{/if} {/if}
<div class="glass-panel creation-card">
<h2>Muster a New Crew</h2>
<form on:submit|preventDefault={createGame}>
<div class="form-group">
<label for="crewName">Crew Name</label>
<input
type="text"
id="crewName"
bind:value={crewName}
required
placeholder="e.g. The Salty Dogs"
class="input-field input-large"
autocomplete="off"
/>
</div>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
<button type="submit" class="btn btn-primary btn-large btn-full" disabled={creating}>
{creating ? 'Creating...' : 'Create Game'}
</button>
</form>
<p class="join-hint">Joining someone else's crew? Ask the game creator for the join link from their lobby.</p>
</div>
<div class="welcome-links"> <div class="welcome-links">
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a> <a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
</div> </div>
</div> </div>
<style> <style>
/* Space the Rejoin box off the Muster box below it. */
.saved-sessions {
margin-bottom: 1.75rem;
}
.session-list { .session-list {
list-style: none; list-style: none;
margin: 0; margin: 0;

View File

@@ -232,6 +232,103 @@ a { color: var(--neon-cyan); }
font-family: var(--font-heading); font-family: var(--font-heading);
cursor: pointer; cursor: pointer;
} }
/* ============================================================
Example-of-Play card diagrams. Mirror the SPA's Obstacle column:
the original card sits at the back (gold ring), each play stacks in
front with a heavy negative margin so only its rank + suit peeks out,
and the newest card — which sets the difficulty — is fully visible at
the bottom. Green ring = a play that beat the difficulty; red = one
that didn't. Card faces stay light parchment in both themes, like the app.
============================================================ */
:root {
--ex-card-face: #faf4e0;
--ex-card-shade: #ead9b8;
--ex-card-red: #c2303a;
--ex-card-black: #2c3245;
--ex-success: #3f9d5b;
--ex-fail: #c0392b;
--ex-card-h: 74px;
--ex-peek: 30px;
}
.ex-board {
display: flex;
flex-wrap: wrap;
gap: 1.25rem 2.25rem;
margin: 0.75rem 0 1.25rem;
padding: 1rem 1.25rem;
background: var(--well-bg);
border: 1px solid var(--row-border);
border-radius: 10px;
}
.ex-board-label {
flex-basis: 100%;
font-family: var(--font-heading);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.ex-obstacle { display: flex; align-items: flex-start; gap: 0.85rem; }
.ex-stack {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
padding: 2px;
}
.ex-stack-card { position: relative; border-radius: 6px; line-height: 0; }
.ex-stack-card + .ex-stack-card { margin-top: calc(var(--ex-peek) - var(--ex-card-h)); }
.ex-stack-card.is-original { box-shadow: 0 0 0 2px var(--gold); }
.ex-stack-card.is-success { box-shadow: 0 0 0 2px var(--ex-success); }
.ex-stack-card.is-fail { box-shadow: 0 0 0 2px var(--ex-fail); }
.ex-card {
width: 52px;
height: var(--ex-card-h);
border-radius: 6px;
background: linear-gradient(150deg, var(--ex-card-face), var(--ex-card-shade));
border: 1px solid rgba(44, 50, 69, 0.35);
color: var(--ex-card-black);
padding: 4px 5px;
position: relative;
box-shadow: 0 2px 5px rgba(36, 62, 87, 0.25);
}
.ex-card.red { color: var(--ex-card-red); }
.ex-card .ex-corner {
display: flex;
flex-direction: column;
align-items: center;
width: max-content;
line-height: 1;
font-weight: 800;
}
.ex-card .ex-corner .r { font-size: 0.82rem; }
.ex-card .ex-corner .s { font-size: 0.72rem; }
.ex-card .ex-pip {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
}
.ex-meta { font-size: 0.85rem; line-height: 1.35; }
.ex-meta .ex-title { display: block; font-family: var(--font-heading); font-weight: 700; color: var(--text-primary); }
.ex-meta .ex-val { color: var(--gold); font-weight: 800; }
.ex-legend .ex-ok { color: var(--ex-success); font-weight: 700; }
.ex-legend .ex-no { color: var(--ex-fail); font-weight: 700; }
</style> </style>
</head> </head>
<body> <body>
@@ -1021,6 +1118,28 @@ a { color: var(--neon-cyan); }
are a total of two Obstacles on the list. Sally draws from the top of the deck: 10 of ♥ are a total of two Obstacles on the list. Sally draws from the top of the deck: 10 of ♥
Hearts: Cheese Thief. The Obstacle List now has: 7 of ♥ Hearts: Bad Breakups (value: 7) and Hearts: Cheese Thief. The Obstacle List now has: 7 of ♥ Hearts: Bad Breakups (value: 7) and
10 of ♥ Hearts: Cheese Thief (value: 10).</p> 10 of ♥ Hearts: Cheese Thief (value: 10).</p>
<p class="aside ex-legend">In the diagrams below, each Obstacle is a column of cards. The
<strong>original</strong> card (gold outline) stays at the back and fixes the color; each play
stacks in front of it, and the newest card — which sets the current difficulty — sits in full
view. <span class="ex-ok">Green</span> marks a play that beat the difficulty, <span class="ex-no">red</span>
one that didn't.</p>
<div class="ex-board">
<div class="ex-board-label">Obstacle List after the draw</div>
<div class="ex-obstacle">
<div class="ex-stack">
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">7</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
</div>
<div class="ex-meta"><span class="ex-title">Bad Breakups</span>Difficulty <span class="ex-val">7</span><br>0 successes</div>
</div>
<div class="ex-obstacle">
<div class="ex-stack">
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">10</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
</div>
<div class="ex-meta"><span class="ex-title">Cheese Thief</span>Difficulty <span class="ex-val">10</span><br>0 successes</div>
</div>
</div>
<p><strong>Step 3: Frame the Scene!</strong></p> <p><strong>Step 3: Frame the Scene!</strong></p>
<p>Sally: "Alright, so you rowdy rats are in the galley below deck, drinking grog, and <p>Sally: "Alright, so you rowdy rats are in the galley below deck, drinking grog, and
shootin' the breeze. The crew's been tense ever since that breakup between Salty Socks Pete shootin' the breeze. The crew's been tense ever since that breakup between Salty Socks Pete
@@ -1065,6 +1184,24 @@ a { color: var(--neon-cyan); }
<p><strong>Draw Cards:</strong> Jake played a 3 of ♥ Hearts against a 10 of ♥ Hearts. Since <p><strong>Draw Cards:</strong> Jake played a 3 of ♥ Hearts against a 10 of ♥ Hearts. Since
both were red, Jake draws 1 card and adds it to their hand.</p> both were red, Jake draws 1 card and adds it to their hand.</p>
<div class="ex-board">
<div class="ex-board-label">After the First Challenge</div>
<div class="ex-obstacle">
<div class="ex-stack">
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">7</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">8</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
</div>
<div class="ex-meta"><span class="ex-title">Bad Breakups</span>Difficulty <span class="ex-val">8</span><br>1 success</div>
</div>
<div class="ex-obstacle">
<div class="ex-stack">
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">10</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
<div class="ex-stack-card is-fail"><div class="ex-card red"><span class="ex-corner"><span class="r">3</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
</div>
<div class="ex-meta"><span class="ex-title">Cheese Thief</span>Difficulty <span class="ex-val">3</span><br>0 successes</div>
</div>
</div>
<h4>Second Challenge</h4> <h4>Second Challenge</h4>
<p>Gabe: "While everyone's yelling at Carlos, I sneak toward the cheese wheel on the table, <p>Gabe: "While everyone's yelling at Carlos, I sneak toward the cheese wheel on the table,
looking for clues, and maybe to steal it." Sally: "Oh no you don't! Challenge time! Can you looking for clues, and maybe to steal it." Sally: "Oh no you don't! Challenge time! Can you
@@ -1089,6 +1226,25 @@ a { color: var(--neon-cyan); }
<p><strong>Draw Cards:</strong> Gabe played a King of ♠ Spades (black) against a red <p><strong>Draw Cards:</strong> Gabe played a King of ♠ Spades (black) against a red
Obstacle, so he doesn't draw any cards.</p> Obstacle, so he doesn't draw any cards.</p>
<div class="ex-board">
<div class="ex-board-label">After the Second Challenge</div>
<div class="ex-obstacle">
<div class="ex-stack">
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">7</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">8</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
</div>
<div class="ex-meta"><span class="ex-title">Bad Breakups</span>Difficulty <span class="ex-val">8</span><br>1 success</div>
</div>
<div class="ex-obstacle">
<div class="ex-stack">
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">10</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
<div class="ex-stack-card is-fail"><div class="ex-card red"><span class="ex-corner"><span class="r">3</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">K</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
</div>
<div class="ex-meta"><span class="ex-title">Cheese Thief</span>Difficulty <span class="ex-val">Rank</span><br>1 success<br><span class="aside">(King = Secret Pirate Technique)</span></div>
</div>
</div>
<h4>Third Challenge</h4> <h4>Third Challenge</h4>
<p>Gabe: "Whiskey Shrinkage is up on deck now, eating cheese openly. He's taunting the crew <p>Gabe: "Whiskey Shrinkage is up on deck now, eating cheese openly. He's taunting the crew
with it, hoping to draw out the Cheese Thief. 'Will the owner of this delicious havarti come with it, hoping to draw out the Cheese Thief. 'Will the owner of this delicious havarti come
@@ -1123,6 +1279,27 @@ a { color: var(--neon-cyan); }
draws 1 card. Jake played Queen of ♥ Hearts (red) against a red Obstacle, so they WOULD draw draws 1 card. Jake played Queen of ♥ Hearts (red) against a red Obstacle, so they WOULD draw
1 card, but they were assisting, so they didn't get to draw.</p> 1 card, but they were assisting, so they didn't get to draw.</p>
<div class="ex-board">
<div class="ex-board-label">After the Third Challenge</div>
<div class="ex-obstacle">
<div class="ex-stack">
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">7</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">8</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
<div class="ex-stack-card is-success"><div class="ex-card red"><span class="ex-corner"><span class="r">Q</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
</div>
<div class="ex-meta"><span class="ex-title">Bad Breakups</span>Difficulty <span class="ex-val">Rank</span><br>2 successes<br><span class="aside">(Queen = Secret Pirate Technique)</span></div>
</div>
<div class="ex-obstacle">
<div class="ex-stack">
<div class="ex-stack-card is-original"><div class="ex-card red"><span class="ex-corner"><span class="r">10</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
<div class="ex-stack-card is-fail"><div class="ex-card red"><span class="ex-corner"><span class="r">3</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
<div class="ex-stack-card is-success"><div class="ex-card"><span class="ex-corner"><span class="r">K</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
<div class="ex-stack-card is-success"><div class="ex-card red"><span class="ex-corner"><span class="r">9</span><span class="s"></span></span><span class="ex-pip"></span></div></div>
</div>
<div class="ex-meta"><span class="ex-title">Cheese Thief</span>Difficulty <span class="ex-val">9</span><br>2 successes</div>
</div>
</div>
<h4>Ending the Scene</h4> <h4>Ending the Scene</h4>
<p>Sally: "Alright, let's check the requirements for ending the Scene. Both Pi-Rat Players <p>Sally: "Alright, let's check the requirements for ending the Scene. Both Pi-Rat Players
have been challenged at least once. Has anyone made progress on their Objectives?"</p> have been challenged at least once. Has anyone made progress on their Objectives?"</p>