Spare/rarity filters, owned-only suggestions, and a Game night tab

Library filters:
- "Only spare copies" quick filter (Library): cards with more copies
  owned than the decks' main boards use, the opposite of the ⚠
  over-assignment warning. Rows show "N spare" while it's on.
- Rarity toggles (C/U/R/M) in the shared criteria panel, so Library,
  Card Search and the set browser all get them. Any printing counts,
  since the library tracks cards rather than printings; within a set
  it's that set's printing. Arena-only printings don't count for a card
  that also exists on paper, so a digital re-rarity can't make a paper
  common read as rare.

Suggestions:
- "From my collection" toggle (remembered) narrows candidates to owned
  cards before scoring and paging, so "Suggest more" keeps finding them.
- Each suggestion reports copies other decks already use, shown as
  "N spare" / "all in other decks". suggest_cards now takes a
  SuggestOptions struct instead of a bare `unusual` flag.

Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a
running change bubble, poison and per-opponent commander damage (which
also comes off life), out-of-the-game markers, dice and a coin, and a
random first player. A seat's deck puts its commander's art behind the
life total. On phones the board fills the screen with the far row
turned to face across the table, and asks to keep the screen awake.
The game is per-device scratch state in localStorage, not synced.

Plain filter chips now show an accent background when active; before,
"Unusual picks" only changed its text colour.
This commit is contained in:
2026-10-14 22:09:39 -05:00
parent 00a76a0f19
commit 067e85dc69
13 changed files with 1601 additions and 32 deletions

View File

@@ -1413,6 +1413,9 @@ li:focus-within .row-actions {
background: var(--bg-hover);
}
.filter-chip.active {
/* Plain on/off chips ("Unusual picks"); the category chips below swap in
their own colour. */
background: var(--accent);
color: #fff;
border-color: transparent;
}
@@ -1481,6 +1484,13 @@ li:focus-within .row-actions {
opacity: 1;
box-shadow: 0 0 0 2px var(--accent-hover);
}
/* Rarity toggles borrow the colour pips' on/off treatment, tinted like the
expansion-symbol colours printed on the cards themselves. */
/* Common's black symbol needs a ring to show at all on the dark panel. */
.pip.rarity-pip.common { background: #2b2a2e; color: #e6e1d8; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4); }
.pip.rarity-pip.uncommon { background: linear-gradient(135deg, #c9d3dc, #7f8c98); color: #1d232a; }
.pip.rarity-pip.rare { background: linear-gradient(135deg, #e6c66e, #a77f2a); color: #2b2210; }
.pip.rarity-pip.mythic { background: linear-gradient(135deg, #f08a3c, #c2401f); color: #fff; }
/* ============================================================
Tables
@@ -2919,3 +2929,373 @@ tbody tr:hover {
font-variant-numeric: tabular-nums;
margin-left: 2px;
}
/* ============================================================
Game night (life counter)
============================================================ */
.game-setup {
max-width: 720px;
}
.game-resume {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
margin-bottom: var(--space-4);
}
.game-setup-form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.game-seats {
display: flex;
flex-direction: column;
gap: 6px;
}
.game-seat {
display: flex;
align-items: center;
gap: 8px;
}
.game-seat-color {
width: 28px;
height: 28px;
flex-shrink: 0;
padding: 0;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.15);
}
.game-seat-name,
.game-seat-deck {
flex: 1 1 120px;
min-width: 0;
}
.game-board {
position: relative;
height: 100%;
min-height: 420px;
display: flex;
flex-direction: column;
gap: 6px;
user-select: none;
-webkit-user-select: none;
}
.game-row {
flex: 1;
min-height: 0;
display: flex;
gap: 6px;
}
.game-player {
position: relative;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: var(--radius-lg);
border: 2px solid var(--player-color);
background-color: var(--bg-panel);
background-color: color-mix(in srgb, var(--player-color) 28%, var(--bg-panel));
background-size: cover;
background-position: center 30%;
color: var(--text-strong);
/* Lets the life total size itself to the panel (cq units) — a 6-player
phone layout has panels a third the size of a 2-player one. */
container-type: size;
}
/* Darkens commander art enough for the numbers to read over it. */
.game-player::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.55));
pointer-events: none;
}
.game-player.rotated {
transform: rotate(180deg);
}
.game-player.first {
box-shadow: 0 0 0 3px var(--warn), 0 0 24px rgba(224, 163, 74, 0.45);
}
/* Dims the panel rather than filtering it, which would also grey out the
"out" reason and the first-player highlight that need to stay legible. */
.game-player.out::before {
background: rgba(12, 11, 10, 0.78);
}
.game-player.out .game-life {
opacity: 0.5;
}
.game-life-half {
position: absolute;
top: 0;
bottom: 0;
z-index: 1;
width: 50%;
display: flex;
align-items: center;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
color: rgba(255, 255, 255, 0.35);
font-size: 28px;
font-weight: 300;
touch-action: manipulation;
}
.game-life-half:hover {
background: rgba(255, 255, 255, 0.03);
}
.game-life-half:active {
background: rgba(255, 255, 255, 0.08);
}
.game-life-half.minus {
left: 0;
justify-content: flex-start;
padding-left: 8%;
}
.game-life-half.plus {
right: 0;
justify-content: flex-end;
padding-right: 8%;
}
.game-player-head {
position: absolute;
top: 8px;
left: 12px;
right: 12px;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.7);
}
.game-player-name {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
font-weight: 700;
}
.game-player-deck {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
opacity: 0.8;
}
.game-life {
position: relative;
font-size: 64px;
font-size: min(34cqw, 44cqh);
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
pointer-events: none;
}
.game-delta {
position: absolute;
top: 0;
left: 100%;
margin-left: 6px;
padding: 1px 7px;
border-radius: var(--radius-pill);
background: rgba(0, 0, 0, 0.5);
font-size: 16px;
font-size: max(13px, min(8cqw, 11cqh));
font-weight: 700;
text-shadow: none;
}
.game-delta.up {
color: #86e0a4;
}
.game-delta.down {
color: #ff9a86;
}
.game-out-label {
position: relative;
margin-top: 6px;
padding: 2px 10px;
border-radius: var(--radius-pill);
background: var(--danger);
color: #fff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
pointer-events: none;
}
.game-player-foot {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
z-index: 2;
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
/* Taps between the badges still reach the −/+ halves underneath. */
pointer-events: none;
}
.game-player-foot > button {
pointer-events: auto;
}
.game-badge {
padding: 3px 9px;
border-radius: var(--radius-pill);
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.5);
color: var(--text-strong);
font-size: 12px;
font-weight: 700;
}
.game-badge-btn:hover {
background: rgba(0, 0, 0, 0.7);
}
.game-badge.first {
border-color: transparent;
background: var(--warn);
color: #2b1d05;
}
.game-strip {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.game-hub {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 50%;
border: 1px solid var(--border-soft);
background: var(--bg-elev);
color: var(--text-strong);
}
.game-hub:hover {
background: var(--bg-hover);
}
.game-menu {
display: flex;
flex-direction: column;
gap: 8px;
}
.game-menu button {
width: 100%;
justify-content: flex-start;
}
.game-counters {
display: flex;
flex-direction: column;
gap: 10px;
}
.game-counter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.game-counter-heading {
margin-top: 6px;
font-size: 13px;
font-weight: 600;
}
.game-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
}
.game-stepper {
display: inline-flex;
align-items: center;
gap: 4px;
}
.game-stepper button {
min-width: 34px;
justify-content: center;
padding: 4px 8px;
}
.game-stepper-value {
min-width: 34px;
text-align: center;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.game-dice-buttons {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.game-dice-buttons button {
justify-content: center;
}
.game-dice-result {
min-height: 96px;
margin: 12px 0 4px;
display: flex;
align-items: center;
justify-content: center;
}
.game-dice-latest {
display: flex;
flex-direction: column;
align-items: center;
animation: game-dice-pop 0.22s ease-out;
}
.game-dice-value {
font-size: 52px;
font-weight: 800;
line-height: 1.05;
color: var(--text-strong);
}
.game-dice-history {
font-size: 12px;
text-align: center;
}
@keyframes game-dice-pop {
from {
transform: scale(0.6);
opacity: 0;
}
to {
transform: none;
opacity: 1;
}
}
/* On a phone the table takes the whole screen — topbar and bottom nav
included; the hub menu's "Leave the table" (or back) is the way out. */
@media (max-width: 720px) {
.game-board {
position: fixed;
inset: 0;
z-index: 300;
height: auto;
min-height: 0;
padding: calc(env(safe-area-inset-top) + 6px) 6px calc(env(safe-area-inset-bottom) + 6px);
background: var(--bg-app);
}
.game-life-half {
font-size: 22px;
}
}