commit 176d7cc30054fe81a93f0325ab0ab31d7a0ad149 Author: robi Date: Wed Sep 23 16:31:05 2026 +0300 Initial commit: game development diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..c839344 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,179 @@ +# Tic-Tac-Toe vs CPU — Implementation Plan + +A polished, mobile-friendly tic-tac-toe game (X vs O) playable in the browser against a +random-move CPU opponent. Pure HTML/CSS/JS — no build step, no dependencies. +Open `index.html` directly in a browser to play. + +## 1. Project Structure + +``` +game1/ +├── index.html # Page structure (board, scoreboard, status, overlay) +├── style.css # All styling: glassmorphism dark theme, animations +├── game.js # Game logic: state, win detection, CPU, scoring, FX +└── docs/ + ├── PLAN.md # This document + └── mockup.svg # UI mockup (open in a browser to view) +``` + +## 2. Visual Design + +Research (Dribbble/Figma/CodePen examples) shows the current popular style for +tic-tac-toe: **dark glassmorphism with neon accents**, frosted panels, soft glows, +and confetti celebrations on wins. + +- **Background**: deep gradient (dark navy → violet) with two soft blurred + colored blobs (cyan + magenta) for depth. +- **Panels**: frosted glass cards — `background: rgba(255,255,255,0.06)`, + `backdrop-filter: blur(14px)`, 1px translucent white border, soft shadow. + (Blur kept modest for mobile performance.) +- **Colors**: + - X (player) — neon cyan `#22d3ee` + - O (CPU) — neon pink/magenta `#f472b6` + - Text: white / muted gray +- **Typography**: system font stack, bold title, tabular numerals for scores. +- **Mockup**: see `docs/mockup.svg` (open in a browser) for the envisioned + look — dark glass panels, neon cyan X / pink O with glow, gradient title. +- **Layout (top → bottom)**: + 1. Title: "Tic-Tac-Toe" with subtle gradient text + 2. Scoreboard: three glass chips — You (X) · CPU (O) · Draws + 3. Status bar: "Your turn" / "CPU is thinking…" / result text + 4. Board: 3×3 grid of glass cells + 5. Buttons: "New Game" (primary) and "Reset Scores" (ghost) + +## 3. Mobile-Friendliness + +- `` +- Board width: `min(88vw, 340px)`; cells are large touch targets (≥ 72px on phones). +- `touch-action: manipulation` on buttons/cells to kill double-tap zoom delay. +- No hover-only interaction: hover glow is a desktop enhancement only; taps work + everywhere. Use `:active` press feedback. +- Layout uses flex column, centered, max-width 420px — looks like a mobile app + on desktop too. +- `user-select: none` on the board to avoid text selection while tapping fast. +- Test: 360px-wide viewport, fast repeated taps, orientation change. + +## 4. Game Rules + +- Standard 3×3 tic-tac-toe. Player is **X**, CPU is **O**. +- Player always starts the round with X (keeps it simple and predictable). +- Click/tap an empty cell to place your mark. +- CPU responds on a random empty cell after a **600 ms "thinking" delay** + (feels natural, shows the "CPU is thinking…" status). +- Win: 3 in a row (8 winning lines). Draw: board full with no winner. +- After a round ends, a result overlay appears; "Play Again" starts a fresh round + (board clears, scores persist). + +## 5. Scoring + +- Track **You**, **CPU**, and **Draws** across rounds. +- Persisted in `localStorage` (key `ttt-scores`) so scores survive reloads. +- "Reset Scores" button zeroes them (with a short confirm-less toast feedback). +- Score chips animate a quick pop when the value changes. + +## 6. Game-Over Reactions + +- **Winning line**: an SVG line is drawn across the three winning cells with a + stroke-dashoffset animation (draws itself in ~400 ms). +- **Result overlay**: frosted glass modal fades/scales in with: + - Win → "You Win! 🎉" + confetti burst (DOM particles, ~40 pieces, random + colors/velocities, fall + fade over ~1.8 s, then removed). + - Loss → "CPU Wins" + a subtle red-tinted shake of the board. + - Draw → "It's a Draw" + neutral pulse. +- Overlay has a single big "Play Again" button. +- Small micro-feedback during play: + - Marks pop in with a scale+fade animation (150 ms). + - Empty cells glow faintly on hover (desktop) and brighten on `:active`. + - Status bar text cross-fades on turn change; "CPU is thinking…" pulses. + +## 7. JavaScript Architecture (game.js) + +Single file, plain functions + one state object: + +``` +state = { + board: Array(9).fill(null), // null | 'X' | 'O' + turn: 'X', // 'X' = player, 'O' = CPU + gameOver: false, + scores: { you: 0, cpu: 0, draws: 0 } +} +``` + +Modules (sections in the file): + +1. **DOM refs** — cells, status, score chips, overlay, buttons. +2. **Win detection** — `WIN_LINES` = 8 combos; `checkWinner(board)` returns + `{winner, line}` or `{winner: 'draw'}` or `null`. +3. **Player move** — `onCellTap(i)`: ignore if occupied/game over/CPU turn; + place X, render, check end; else schedule CPU. +4. **CPU move** — `cpuMove()`: after 600 ms, pick a random empty index, + place O, render, check end. +5. **Render** — `renderMark(i, mark)` (pop animation), `renderStatus()`, + `renderScores()`, `drawWinLine(line)` (SVG overlay on the board). +6. **Round end** — `endRound(result)`: update + persist scores, show overlay + with appropriate FX (confetti / shake / pulse). +7. **Confetti** — `burstConfetti()`: create N absolutely-positioned divs with + random transforms, animate via CSS transitions, remove after finish. +8. **Reset** — `newRound()` (clear board, player first), `resetScores()`. +9. **Init** — load scores from localStorage, build board DOM, bind events. + +## 8. Implementation Steps + +1. `index.html` — semantic structure, viewport meta, SVG defs for win line. +2. `style.css` — theme tokens (CSS custom properties), background, glass panels, + board grid, marks (X/O drawn with CSS or inline SVG for crispness), + animations (pop, pulse, shake, confetti fall), overlay, responsive tweaks. +3. `game.js` — implement sections 1–9 above. +4. Manual test pass: + - Win all 8 line types (play through scenarios). + - Draw scenario. + - Rapid tapping (no double placement, no CPU overlap). + - Scores persist across reload; reset works. + - 360px viewport: no overflow, big touch targets, no zoom on tap. +5. Polish: check animation smoothness on a mid-range phone if available. + +## 9. Hosting the App + +The app is three static files, so any static file server works. The goal is a +one-line start, no config files, no build step. + +**Primary option — Python (already installed, zero setup):** + +```bash +python3 -m http.server 8000 +``` + +Run from the `game1/` folder. It binds to all network interfaces by default, +so the game is immediately reachable: + +- **Same machine**: http://localhost:8000 +- **Same network (LAN)**: http://\:8000 + (find the IP with `hostname -I` or `ip addr`) +- **Internet**: forward port 8000 in the router/firewall, or put the machine + behind a reverse proxy. For public exposure, prefer caddy (see below) and + keep the raw port closed otherwise. + +**Alternative — Caddy (nicest for internet exposure, auto-TLS with a domain):** + +```bash +caddy file-server --root . --listen :8000 +``` + +**Alternative — Node (if Python is unavailable):** + +```bash +npx serve -l 8000 +``` + +Notes: + +- These dev servers are fine for LAN / home use. For a public internet + endpoint, use caddy behind a domain (it handles TLS automatically) and do + not expose the raw port to the internet with a bare dev server. +- No deploy step: the folder *is* the app — copy it anywhere and serve it. + +## 10. Non-Goals (kept simple on purpose) + +- No minimax / unbeatable AI (CPU is random, as requested). +- No sound effects, no multi-language support, no user-vs-user mode. +- No framework, no bundler — the game is three files. diff --git a/docs/mockup.svg b/docs/mockup.svg new file mode 100644 index 0000000..1ec2fc4 --- /dev/null +++ b/docs/mockup.svg @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Tic-Tac-Toe + + + + + YOU (X) + 2 + + + CPU (O) + 1 + + + DRAWS + 1 + + + + CPU is thinking... + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + New Game + + + Reset Scores + diff --git a/game.js b/game.js new file mode 100644 index 0000000..bc558f1 --- /dev/null +++ b/game.js @@ -0,0 +1,315 @@ +'use strict'; + +/* ---------- 1. DOM refs ---------- */ +const boardEl = document.getElementById('board'); +const statusEl = document.getElementById('status'); +const statusTextEl = document.getElementById('status-text'); +const overlayEl = document.getElementById('overlay'); +const overlayTitleEl = document.getElementById('overlay-title'); +const winLineEl = document.getElementById('win-line-el'); +const confettiLayer = document.getElementById('confetti-layer'); +const toastEl = document.getElementById('toast'); + +const scoreEls = { + you: document.getElementById('score-you'), + cpu: document.getElementById('score-cpu'), + draws: document.getElementById('score-draws'), +}; + +const cells = []; + +/* ---------- state ---------- */ +const state = { + board: Array(9).fill(null), + turn: 'X', + gameOver: false, + scores: { you: 0, cpu: 0, draws: 0 }, +}; + +let cpuTimer = 0; +let toastTimer = 0; + +const WIN_LINES = [ + [0, 1, 2], [3, 4, 5], [6, 7, 8], + [0, 3, 6], [1, 4, 7], [2, 5, 8], + [0, 4, 8], [2, 4, 6], +]; + +const SCORES_KEY = 'ttt-scores'; +const CPU_DELAY = 600; + +const X_SVG = + ''; + +const O_SVG = + ''; + +/* ---------- 2. Win detection ---------- */ +function checkWinner(board) { + for (const line of WIN_LINES) { + const [a, b, c] = line; + if (board[a] && board[a] === board[b] && board[a] === board[c]) { + return { winner: board[a], line }; + } + } + if (board.every((v) => v !== null)) return { winner: 'draw' }; + return null; +} + +/* ---------- 3. Player move ---------- */ +function onCellTap(i) { + if (state.gameOver || state.turn !== 'X' || state.board[i]) return; + placeMark(i, 'X'); + const result = checkWinner(state.board); + if (result) { + endRound(result); + return; + } + state.turn = 'O'; + renderStatus('CPU is thinking…', 'thinking'); + cpuTimer = setTimeout(cpuMove, CPU_DELAY); +} + +/* ---------- 4. CPU move ---------- */ +function cpuMove() { + if (state.gameOver) return; + const empty = []; + state.board.forEach((v, i) => { + if (v === null) empty.push(i); + }); + if (!empty.length) return; + const i = empty[Math.floor(Math.random() * empty.length)]; + placeMark(i, 'O'); + const result = checkWinner(state.board); + if (result) { + endRound(result); + return; + } + state.turn = 'X'; + renderStatus('Your turn', 'normal'); +} + +function placeMark(i, mark) { + state.board[i] = mark; + renderMark(i, mark); +} + +/* ---------- 5. Render ---------- */ +function renderMark(i, mark) { + const cell = cells[i]; + cell.classList.remove('empty'); + cell.classList.add(mark === 'X' ? 'has-x' : 'has-o'); + cell.setAttribute('aria-label', mark === 'X' ? 'X' : 'O'); + cell.innerHTML = mark === 'X' ? X_SVG : O_SVG; +} + +function renderStatus(text, mode) { + mode = mode || 'normal'; + statusEl.classList.toggle('thinking', mode === 'thinking'); + if (statusTextEl.textContent === text) return; + statusEl.classList.add('fading'); + setTimeout(() => { + statusTextEl.textContent = text; + statusEl.classList.remove('fading'); + }, 150); +} + +function renderScores(changed) { + for (const k of ['you', 'cpu', 'draws']) { + scoreEls[k].textContent = state.scores[k]; + if (k === changed) { + scoreEls[k].classList.remove('pop'); + void scoreEls[k].offsetWidth; + scoreEls[k].classList.add('pop'); + } + } +} + +function cellCenter(i, boardRect) { + const r = cells[i].getBoundingClientRect(); + return { + x: r.left + r.width / 2 - boardRect.left, + y: r.top + r.height / 2 - boardRect.top, + }; +} + +function drawWinLine(line) { + const boardRect = boardEl.getBoundingClientRect(); + const p1 = cellCenter(line[0], boardRect); + const p2 = cellCenter(line[2], boardRect); + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const len = Math.hypot(dx, dy) || 1; + const ext = 16; + const ux = dx / len; + const uy = dy / len; + const x1 = p1.x - ux * ext; + const y1 = p1.y - uy * ext; + const x2 = p2.x + ux * ext; + const y2 = p2.y + uy * ext; + + winLineEl.setAttribute('x1', x1); + winLineEl.setAttribute('y1', y1); + winLineEl.setAttribute('x2', x2); + winLineEl.setAttribute('y2', y2); + + const total = Math.hypot(x2 - x1, y2 - y1); + winLineEl.style.transition = 'none'; + winLineEl.style.strokeDasharray = total; + winLineEl.style.strokeDashoffset = total; + winLineEl.style.opacity = '1'; + void winLineEl.getBoundingClientRect(); + winLineEl.style.transition = 'stroke-dashoffset 400ms ease'; + winLineEl.style.strokeDashoffset = '0'; +} + +function clearWinLine() { + winLineEl.style.transition = 'none'; + winLineEl.style.opacity = '0'; + winLineEl.style.strokeDashoffset = winLineEl.style.strokeDasharray; +} + +/* ---------- 6. Round end ---------- */ +function endRound(result) { + state.gameOver = true; + let title; + let key; + if (result.winner === 'X') { + key = 'you'; + title = 'You Win! 🎉'; + renderStatus('You win! 🎉', 'normal'); + } else if (result.winner === 'O') { + key = 'cpu'; + title = 'CPU Wins'; + renderStatus('CPU wins', 'normal'); + boardEl.classList.add('shake'); + } else { + key = 'draws'; + title = "It's a Draw"; + renderStatus("It's a draw", 'normal'); + boardEl.classList.add('pulse'); + } + + state.scores[key]++; + persistScores(); + renderScores(key); + + if (result.line) drawWinLine(result.line); + + setTimeout(() => { + overlayTitleEl.textContent = title; + overlayEl.classList.add('show'); + if (result.winner === 'X') burstConfetti(); + }, result.winner === 'draw' ? 400 : 600); +} + +/* ---------- 7. Confetti ---------- */ +const CONFETTI_COLORS = ['#22d3ee', '#f472b6', '#a78bfa', '#fbbf24', '#34d399', '#ffffff']; + +function burstConfetti() { + const pieces = []; + for (let i = 0; i < 40; i++) { + const el = document.createElement('div'); + el.className = 'confetti'; + const size = 6 + Math.random() * 8; + el.style.width = size + 'px'; + el.style.height = size * 1.4 + 'px'; + el.style.left = Math.random() * 100 + 'vw'; + el.style.background = CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)]; + el.style.setProperty('--drift', (Math.random() * 160 - 80) + 'px'); + el.style.setProperty('--spin', (360 + Math.random() * 720) + 'deg'); + el.style.animationDuration = 1.3 + Math.random() * 0.5 + 's'; + el.style.animationDelay = Math.random() * 0.25 + 's'; + confettiLayer.appendChild(el); + pieces.push(el); + } + setTimeout(() => pieces.forEach((p) => p.remove()), 2200); +} + +/* ---------- 8. Reset ---------- */ +function newRound() { + clearTimeout(cpuTimer); + state.board = Array(9).fill(null); + state.turn = 'X'; + state.gameOver = false; + cells.forEach((cell) => { + cell.innerHTML = ''; + cell.classList.add('empty'); + cell.classList.remove('has-x', 'has-o'); + cell.setAttribute('aria-label', 'Empty cell'); + }); + clearWinLine(); + boardEl.classList.remove('shake', 'pulse'); + overlayEl.classList.remove('show'); + confettiLayer.innerHTML = ''; + renderStatus('Your turn', 'normal'); +} + +function resetScores() { + state.scores = { you: 0, cpu: 0, draws: 0 }; + persistScores(); + renderScores(); + showToast('Scores reset'); +} + +function persistScores() { + try { + localStorage.setItem(SCORES_KEY, JSON.stringify(state.scores)); + } catch (e) { + /* storage unavailable */ + } +} + +function loadScores() { + try { + const raw = localStorage.getItem(SCORES_KEY); + if (raw) { + const s = JSON.parse(raw); + state.scores = { + you: Number(s.you) || 0, + cpu: Number(s.cpu) || 0, + draws: Number(s.draws) || 0, + }; + } + } catch (e) { + /* corrupted storage — keep defaults */ + } +} + +function showToast(msg) { + toastEl.textContent = msg; + toastEl.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1600); +} + +/* ---------- 9. Init ---------- */ +function buildBoard() { + for (let i = 0; i < 9; i++) { + const cell = document.createElement('button'); + cell.type = 'button'; + cell.className = 'cell empty'; + cell.dataset.index = String(i); + cell.setAttribute('aria-label', 'Empty cell'); + cell.addEventListener('click', () => onCellTap(i)); + boardEl.appendChild(cell); + cells.push(cell); + } +} + +function init() { + buildBoard(); + loadScores(); + renderScores(); + document.getElementById('btn-new').addEventListener('click', newRound); + document.getElementById('btn-reset').addEventListener('click', resetScores); + document.getElementById('btn-again').addEventListener('click', newRound); + renderStatus('Your turn', 'normal'); +} + +init(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..aff44c3 --- /dev/null +++ b/index.html @@ -0,0 +1,67 @@ + + + + + + + Tic-Tac-Toe + + + + + + +
+

Tic-Tac-Toe

+ +
+
+ You (X) + 0 +
+
+ CPU (O) + 0 +
+
+ Draws + 0 +
+
+ +
+ Your turn +
+ +
+
+ +
+ +
+ + +
+
+ + + + +
+ + + + diff --git a/style.css b/style.css new file mode 100644 index 0000000..14bd919 --- /dev/null +++ b/style.css @@ -0,0 +1,431 @@ +:root { + --bg-1: #0d0f24; + --bg-2: #1c1140; + --cyan: #22d3ee; + --pink: #f472b6; + --glass: rgba(255, 255, 255, 0.06); + --glass-strong: rgba(255, 255, 255, 0.1); + --glass-border: rgba(255, 255, 255, 0.14); + --text: #ffffff; + --muted: rgba(255, 255, 255, 0.55); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + height: 100%; +} + +body { + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-2) 100%); + background-attachment: fixed; + color: var(--text); + min-height: 100dvh; + display: flex; + justify-content: center; + overflow-x: hidden; +} + +/* ---------- background blobs ---------- */ +.bg-blob { + position: fixed; + border-radius: 50%; + filter: blur(70px); + pointer-events: none; + z-index: 0; +} + +.blob-cyan { + width: 340px; + height: 340px; + left: -120px; + top: 8%; + background: var(--cyan); + opacity: 0.2; +} + +.blob-magenta { + width: 380px; + height: 380px; + right: -140px; + bottom: 4%; + background: var(--pink); + opacity: 0.16; +} + +/* ---------- layout ---------- */ +.app { + position: relative; + z-index: 1; + width: 100%; + max-width: 420px; + min-height: 100dvh; + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + padding: + calc(28px + env(safe-area-inset-top)) + 16px + calc(28px + env(safe-area-inset-bottom)); +} + +.title { + font-size: clamp(28px, 8vw, 34px); + font-weight: 800; + letter-spacing: 0.5px; + background: linear-gradient(90deg, var(--cyan), var(--pink)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* ---------- scoreboard ---------- */ +.scoreboard { + display: flex; + gap: 10px; + width: 100%; +} + +.chip { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 12px 8px; + background: var(--glass); + border: 1px solid var(--glass-border); + border-radius: 16px; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); +} + +.chip-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 1.5px; + text-transform: uppercase; + color: var(--muted); +} + +.chip-value { + font-size: 22px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.chip-x { color: var(--cyan); } +.chip-o { color: var(--pink); } + +.chip-value.pop { + animation: score-pop 320ms ease; +} + +@keyframes score-pop { + 0% { transform: scale(1); } + 40% { transform: scale(1.35); } + 100% { transform: scale(1); } +} + +/* ---------- status ---------- */ +.status { + height: 20px; + font-size: 14px; + color: rgba(255, 255, 255, 0.7); + transition: opacity 150ms ease; +} + +.status.fading { opacity: 0; } + +.status.thinking .status-text { + animation: status-pulse 900ms ease-in-out infinite; +} + +@keyframes status-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ---------- board ---------- */ +.board-wrap { + position: relative; +} + +.board { + width: min(88vw, 340px); + aspect-ratio: 1; + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); + gap: 10px; + padding: 14px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 24px; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); + user-select: none; + -webkit-user-select: none; +} + +.board.shake { + animation: shake 450ms ease; + border-color: rgba(244, 63, 94, 0.5); + box-shadow: 0 0 34px rgba(244, 63, 94, 0.3); +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-7px); } + 40% { transform: translateX(6px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(3px); } +} + +.board.pulse { + animation: board-pulse 600ms ease; +} + +@keyframes board-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.03); } +} + +.cell { + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + transition: background 150ms ease, border-color 150ms ease, box-shadow 150ms ease; +} + +.cell.empty:active { + background: var(--glass-strong); + border-color: rgba(255, 255, 255, 0.25); +} + +@media (hover: hover) { + .cell.empty:hover { + background: var(--glass-strong); + border-color: rgba(255, 255, 255, 0.25); + box-shadow: 0 0 18px rgba(34, 211, 238, 0.15); + } +} + +/* ---------- marks ---------- */ +.mark { + width: 58%; + height: 58%; + animation: mark-pop 150ms ease-out; +} + +@keyframes mark-pop { + 0% { transform: scale(0.3); opacity: 0; } + 60% { transform: scale(1.12); opacity: 1; } + 100% { transform: scale(1); opacity: 1; } +} + +.mark-x { + filter: drop-shadow(0 0 7px rgba(34, 211, 238, 0.65)); +} + +.mark-o { + filter: drop-shadow(0 0 7px rgba(244, 114, 182, 0.65)); +} + +.mark line { + stroke: var(--cyan); + stroke-width: 9; + stroke-linecap: round; +} + +.mark circle { + fill: none; + stroke: var(--pink); + stroke-width: 9; +} + +/* ---------- win line ---------- */ +.win-line { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: visible; + z-index: 5; +} + +#win-line-el { + filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45)); +} + +/* ---------- buttons ---------- */ +.buttons { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + max-width: 300px; +} + +.btn { + border: none; + border-radius: 27px; + height: 54px; + font-family: inherit; + font-size: 16px; + font-weight: 700; + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + transition: transform 100ms ease, filter 150ms ease; +} + +.btn:active { + transform: scale(0.97); +} + +.btn.primary { + background: linear-gradient(90deg, var(--cyan), var(--pink)); + color: #0d1025; + box-shadow: 0 6px 24px rgba(34, 211, 238, 0.25); +} + +.btn.primary:hover { + filter: brightness(1.1); +} + +.btn.ghost { + height: 48px; + font-size: 14px; + font-weight: 500; + border-radius: 24px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.18); + color: rgba(255, 255, 255, 0.7); +} + +.btn.ghost:hover { + background: rgba(255, 255, 255, 0.09); +} + +/* ---------- overlay ---------- */ +.overlay { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + background: rgba(13, 15, 36, 0.55); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease; + padding: 24px; +} + +.overlay.show { + opacity: 1; + pointer-events: auto; +} + +.modal { + width: 100%; + max-width: 300px; + display: flex; + flex-direction: column; + align-items: center; + gap: 22px; + padding: 34px 28px; + background: var(--glass); + border: 1px solid var(--glass-border); + border-radius: 24px; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + transform: scale(0.85); + transition: transform 250ms cubic-bezier(0.2, 1.4, 0.4, 1); +} + +.overlay.show .modal { + transform: scale(1); +} + +.modal-title { + font-size: 24px; + font-weight: 800; +} + +.modal .btn { + width: 100%; +} + +/* ---------- confetti ---------- */ +#confetti-layer { + position: fixed; + inset: 0; + overflow: hidden; + pointer-events: none; + z-index: 100; +} + +.confetti { + position: absolute; + top: -24px; + border-radius: 2px; + animation-name: confetti-fall; + animation-timing-function: linear; + animation-fill-mode: forwards; +} + +@keyframes confetti-fall { + from { + transform: translate3d(0, 0, 0) rotate(0deg); + opacity: 1; + } + to { + transform: translate3d(var(--drift), 108vh, 0) rotate(var(--spin)); + opacity: 0.85; + } +} + +/* ---------- toast ---------- */ +.toast { + position: fixed; + bottom: calc(24px + env(safe-area-inset-bottom)); + left: 50%; + transform: translateX(-50%) translateY(8px); + z-index: 90; + padding: 10px 20px; + font-size: 14px; + background: var(--glass-strong); + border: 1px solid var(--glass-border); + border-radius: 999px; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease, transform 200ms ease; +} + +.toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +}