fix(web): apply Terminal palette and UX fixes to game page
Build and Deploy / build-and-push (push) Successful in 1m19s

Aligns /play with the landing page and app color scheme — same
bg, panel, accent, and felt tokens from ui_theme.rs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-13 16:26:51 -07:00
parent 0ef75a0c9a
commit 98f9933ed0
3 changed files with 105 additions and 80 deletions
+59 -66
View File
@@ -1,21 +1,28 @@
/* Solitaire Quest — interactive game page.
Palette and card styles mirror replay.css; adds drag, selection,
HUD, and win-overlay layers. */
Palette mirrors the Bevy app's Terminal (base16-eighties) design system.
Card faces/backs are PNG images served from /assets/cards/. */
@font-face {
font-family: "FiraMono";
src: url("/assets/fonts/main.ttf") format("truetype");
}
:root {
--bg: #0f0a1f;
--felt: #0f4c30;
--panel: #1a0f2e;
--panel-hi: #2d1b69;
--text: #f5f0ff;
--text-muted: #b5a8d5;
--accent: #ffd23f;
--red: #cc3344;
--black: #1a0f2e;
--card-bg: #ffffff;
--card-border: #ccc;
--bg: #151515;
--felt: #0f5232;
--panel: #202020;
--panel-hi: #2a2a2a;
--text: #d0d0d0;
--text-muted: #a0a0a0;
--accent: #a54242;
--accent-hi: #c25e5e;
--red: #fb9fb1;
--black: #151515;
--drop-success: #acc267;
--card-bg: #f8f5f0;
--card-border: #c8b8a0;
--card-w: 80px;
--card-h: 112px;
--card-h: 120px;
--gap: 12px;
--fan: 28px;
}
@@ -23,7 +30,7 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-family: "FiraMono", "Fira Mono", monospace;
background: var(--bg);
color: var(--text);
min-height: 100vh;
@@ -66,7 +73,7 @@ button {
font-family: inherit;
transition: background 120ms;
}
button:hover { background: var(--accent); color: var(--black); }
button:hover { background: var(--accent); color: var(--text); }
button:disabled { opacity: 0.4; cursor: default; }
.toggle-label {
@@ -84,25 +91,32 @@ button:disabled { opacity: 0.4; cursor: default; }
main {
flex: 1;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 20px;
overflow-x: auto;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
/* Full-bleed felt surface — flex:1 reliably fills main's remaining height. */
#board {
position: relative;
flex: 1;
background: var(--felt);
border-radius: 12px;
padding: 20px;
/* 7 columns wide */
width: calc(7 * var(--card-w) + 6 * var(--gap) + 40px);
/* top row + generous fan budget for a 13-card column */
height: calc(var(--card-h) + 28px + var(--card-h) + 12 * var(--fan) + 40px);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
cursor: default;
}
/* Natural-size coordinate space for cards and slots.
The CSS transform scale is applied here, not on #board. */
#card-area {
position: relative;
flex-shrink: 0;
width: calc(7 * var(--card-w) + 6 * var(--gap) + 40px);
height: calc(var(--card-h) + 28px + var(--card-h) + 12 * var(--fan) + 40px);
touch-action: none; /* prevent browser scroll/pan from stealing pointer events */
}
/* Empty-pile slot markers */
.slot {
position: absolute;
@@ -114,8 +128,8 @@ main {
}
.slot.drop-active {
border-color: var(--accent);
background: rgba(255, 210, 63, 0.08);
border-color: var(--drop-success);
background: rgba(172, 194, 103, 0.10);
}
/* ── Cards ───────────────────────────────────────────────────────────── */
@@ -125,13 +139,15 @@ main {
top: 0; left: 0;
width: var(--card-w);
height: var(--card-h);
background: var(--card-bg);
background-color: var(--card-bg);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border: 1px solid var(--card-border);
border-radius: 6px;
box-shadow: 0 2px 5px rgba(0,0,0,0.35);
padding: 4px 6px;
font-weight: 600;
line-height: 1;
padding: 0;
overflow: hidden;
cursor: grab;
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 200ms ease,
@@ -142,40 +158,12 @@ main {
.card:active { cursor: grabbing; }
.card.face-down {
background:
repeating-linear-gradient(
45deg,
#482f97 0, #482f97 6px,
#2d1b69 6px, #2d1b69 12px
);
background-color: #2d1b69;
color: transparent;
border-color: #4a3a8a;
cursor: default;
}
.card .corner {
position: absolute;
font-size: 14px;
line-height: 1.1;
text-align: center;
}
.card .corner.top { top: 4px; left: 6px; }
/* No rotation — ♠ rotated 180° is indistinguishable from ♥ */
.card .corner.bottom { bottom: 4px; right: 6px; text-align: right; }
.card.red { color: var(--red); }
.card.black { color: var(--black); }
.card .center {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
font-size: 28px;
}
/* Stock pile: pointer cursor since it's a click target, not draggable */
.card.stock-card { cursor: pointer; }
/* Selected / being-dragged state */
.card.selected {
box-shadow: 0 0 0 2px var(--accent), 0 4px 12px rgba(0,0,0,0.5);
@@ -185,14 +173,19 @@ main {
/* Drop-target highlight on cards (top of a tableau column) */
.card.drop-target {
box-shadow: 0 0 0 2px var(--accent), 0 4px 12px rgba(0,0,0,0.5);
box-shadow: 0 0 0 2px var(--drop-success), 0 4px 12px rgba(0,0,0,0.5);
}
/* Recycle indicator on empty stock — JS sets transform to position it */
/* Recycle indicator on empty stock — sized to match the slot, symbol centred */
.recycle-label {
position: absolute;
top: 0; left: 0;
font-size: 26px;
width: var(--card-w);
height: var(--card-h);
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: rgba(255,255,255,0.3);
pointer-events: none;
}
@@ -202,7 +195,7 @@ main {
#win-overlay {
position: fixed;
inset: 0;
background: rgba(10, 5, 20, 0.75);
background: rgba(21, 21, 21, 0.92);
display: flex;
align-items: center;
justify-content: center;
+3 -1
View File
@@ -28,7 +28,9 @@
</header>
<main>
<section id="board"></section>
<section id="board">
<div id="card-area"></div>
</section>
</main>
<div id="win-overlay" class="hidden">
+43 -13
View File
@@ -14,12 +14,16 @@ import init, { SolitaireGame } from "/web/pkg/solitaire_wasm.js";
// ── Layout constants (must match game.css --card-w / --card-h / --gap / --pad)
const CARD_W = 80;
const CARD_H = 112;
const CARD_H = 120; // 2:3 ratio matching 256×384 card PNGs
const GAP = 12;
const PAD = 20; // board inner padding — cards start at (PAD, PAD)
const FAN = 28; // vertical offset per fanned tableau card
const WASTE_FAN = 18; // horizontal offset for draw-3 waste fan
// Natural board dimensions — used for scale-to-fit calculation.
const BOARD_W = PAD * 2 + 7 * CARD_W + 6 * GAP; // 672
const BOARD_H = PAD * 2 + CARD_H + 28 + CARD_H + 12 * FAN; // 644
// Pile origins in board-element coordinates (include PAD so (0,0) = board edge).
const TOP_Y = PAD;
const BOTTOM_Y = PAD + CARD_H + 28;
@@ -45,7 +49,7 @@ const PILE_ORIGIN = {
// Foundation suit hints shown when the slot is empty.
const FOUND_SUIT_HINT = ["♠", "♥", "♦", "♣"];
const SUIT_GLYPH = { clubs: "", diamonds: "", hearts: "", spades: "" };
const SUIT_CODE = { clubs: "C", diamonds: "D", hearts: "H", spades: "S" };
const RANK_LABELS = ["","A","2","3","4","5","6","7","8","9","10","J","Q","K"];
const RED_SUITS = new Set(["diamonds", "hearts"]);
@@ -73,8 +77,11 @@ let elapsedSecs = 0;
// Auto-complete
let acTimer = null;
// Current scale factor applied to #board.
let boardScale = 1.0;
// ── DOM refs ─────────────────────────────────────────────────────────────────
const board = document.getElementById("board");
const board = document.getElementById("card-area");
const hudScore = document.getElementById("hud-score");
const hudMoves = document.getElementById("hud-moves");
const hudTimer = document.getElementById("hud-timer");
@@ -89,6 +96,21 @@ const winMoves = document.getElementById("win-moves");
const winTime = document.getElementById("win-time");
const btnWinNew = document.getElementById("btn-win-new");
// ── Scale to fit ─────────────────────────────────────────────────────────────
// Scales #card-area to fill #board without overflowing either dimension.
// boardRelative() divides by boardScale to keep hit-testing correct.
function scaleBoard() {
// Measure the actual rendered #board element — more reliable than
// computing window.innerHeight minus estimated header height, which
// breaks under different browser chrome / OS scaling factors.
const outerBoard = document.getElementById("board");
const bw = outerBoard.clientWidth;
const bh = outerBoard.clientHeight;
boardScale = Math.min(bw / BOARD_W, bh / BOARD_H, 2.0);
board.style.transform = `scale(${boardScale})`;
board.style.transformOrigin = "center center";
}
// ── Bootstrap ────────────────────────────────────────────────────────────────
async function bootstrap() {
await init();
@@ -99,6 +121,8 @@ async function bootstrap() {
chkDraw3.checked = drawThree;
buildSlots();
scaleBoard();
window.addEventListener("resize", scaleBoard);
startGame(urlSeed);
attachHandlers();
}
@@ -242,7 +266,7 @@ function render(s) {
board.appendChild(recycleEl);
}
const o = PILE_ORIGIN.stock;
recycleEl.style.transform = `translate(${o.x + CARD_W / 2}px, ${o.y + CARD_H / 2}px)`;
recycleEl.style.transform = `translate(${o.x}px, ${o.y}px)`;
} else if (recycleEl) {
recycleEl.remove();
}
@@ -268,15 +292,13 @@ function updateCardEl(el, card, pileName, idx, total) {
if (!card.face_up) {
el.className = "card face-down";
el.innerHTML = "";
el.style.backgroundImage = "url('/assets/cards/backs/back_0.png')";
el.innerHTML = "";
} else {
const isRed = RED_SUITS.has(card.suit);
el.className = `card ${isRed ? "red" : "black"}`;
const r = RANK_LABELS[card.rank];
const s = SUIT_GLYPH[card.suit];
el.innerHTML = `<div class="corner top">${r}<br>${s}</div>
<div class="center">${s}</div>
<div class="corner bottom">${r}<br>${s}</div>`;
el.style.backgroundImage = `url('/assets/cards/faces/${RANK_LABELS[card.rank]}${SUIT_CODE[card.suit]}.png')`;
el.innerHTML = "";
}
}
@@ -345,9 +367,14 @@ function attachHandlers() {
// ── Coordinate helpers ────────────────────────────────────────────────────────
// Returns cursor position in board-element coordinates
// (0,0 = board element top-left corner, which is the padding edge).
// Divides by boardScale because getBoundingClientRect() returns the SCALED
// visual rect; we need coordinates in the natural (pre-scale) system.
function boardRelative(clientX, clientY) {
const rect = board.getBoundingClientRect();
return { x: clientX - rect.left, y: clientY - rect.top };
return {
x: (clientX - rect.left) / boardScale,
y: (clientY - rect.top) / boardScale,
};
}
function hitTestCard(bx, by) {
@@ -487,6 +514,8 @@ function onPointerUp(e) {
const targetPile = findDropTarget(bx, by);
let moved = false;
let illegalAttempt = false;
if (targetPile && targetPile !== drag.fromPile) {
const r = game.move_cards(drag.fromPile, targetPile, drag.cardIds.length);
if (r.ok) {
@@ -494,13 +523,14 @@ function onPointerUp(e) {
drag.cardIds.forEach(id => cardEls.get(id)?.classList.remove("selected"));
render(r.snapshot);
} else {
flashIllegal(drag.cardIds);
illegalAttempt = true;
}
}
if (!moved) {
drag.cardIds.forEach(id => cardEls.get(id)?.classList.remove("selected"));
render(snap); // snap cards back to their pre-drag positions
render(snap); // snap cards back first — then animate so shake plays on settled positions
if (illegalAttempt) flashIllegal(drag.cardIds);
}
drag = null;