Stage 17 #5: hide finished games from your own lobby list #27

Merged
developer merged 2 commits from feature/hide-finished-games into development 2026-06-08 22:45:07 +00:00
21 changed files with 439 additions and 17 deletions
+11 -2
View File
@@ -1415,9 +1415,18 @@ provided cert) at the contour caddy; prod VPN; rollback.
and `--tg-content-top`), with a small padding bump so the native controls aren't flush.
- **Tests backfilled** for the merged round-6 work: e2e for the in-game "✓ in friends" item
and a board→board tile relocation; codec units for `last_activity_unix` + `OutgoingRequestList`.
- **Hide finished games (#5, shipped):** a player can remove a finished game from their own
*my games* list — **per-account, finished-only and irreversible** (the game stays for the
other players; there is no un-hide). On a finished row a **swipe-left** (touch) or a tap on
its **kebab ⋮** (the desktop affordance) reveals a **❌** that hides it; active rows carry an
inert **** chevron purely to keep the right-edge icons aligned. New table
`game_hidden(account_id, game_id)` + migration `00012`; `ListGamesForAccount` filters the
hidden set; `POST /api/v1/user/games/:id/hide` behind the `game.hide` edge op (reusing
`GameActionRequest` → an `Ack`); the lobby drops the card optimistically and keeps the cache
in sync. Covered by an integration test (active→`ErrGameActive`, outsider→`ErrNotAPlayer`,
per-account visibility, idempotent), a gateway transcode test, and a mock e2e (kebab → ❌).
- **Deferred to the next PR (agreed):** #4 enrich the out-of-app "your turn" / game-end push
with the opponent's name, last word and score; #5 let a player hide finished games from
their lobby (swipe + a desktop affordance).
with the opponent's name, last word and score.
## Deferred TODOs (cross-stage)
+24
View File
@@ -727,6 +727,30 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]
return svc.store.ListGamesForAccount(ctx, accountID)
}
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
// players); it is irreversible by design. Only a player of a finished game may hide it
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op (Stage 17).
func (svc *Service) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return err
}
seated := false
for _, s := range g.Seats {
if s.AccountID == accountID {
seated = true
break
}
}
if !seated {
return ErrNotAPlayer
}
if g.Status != StatusFinished {
return ErrGameActive
}
return svc.store.HideGame(ctx, accountID, gameID)
}
// GameByID returns a game with its seats for the admin console detail view.
func (svc *Service) GameByID(ctx context.Context, id uuid.UUID) (Game, error) {
return svc.store.GetGame(ctx, id)
+47
View File
@@ -186,6 +186,23 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
if len(grows) == 0 {
return nil, nil
}
// Drop games the account has hidden from its own lobby (Stage 17).
hidden, err := s.hiddenGameIDs(ctx, accountID)
if err != nil {
return nil, err
}
if len(hidden) > 0 {
kept := grows[:0]
for _, g := range grows {
if !hidden[g.GameID] {
kept = append(kept, g)
}
}
grows = kept
if len(grows) == 0 {
return nil, nil
}
}
ids := make([]postgres.Expression, len(grows))
for i, g := range grows {
@@ -215,6 +232,36 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
return out, nil
}
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
// game is finished and the account is a player (Stage 17).
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO backend.game_hidden (account_id, game_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
accountID, gameID)
if err != nil {
return fmt.Errorf("game: hide game: %w", err)
}
return nil
}
// hiddenGameIDs returns the set of games the account has hidden from its lobby.
func (s *Store) hiddenGameIDs(ctx context.Context, accountID uuid.UUID) (map[uuid.UUID]bool, error) {
rows, err := s.db.QueryContext(ctx, `SELECT game_id FROM backend.game_hidden WHERE account_id = $1`, accountID)
if err != nil {
return nil, fmt.Errorf("game: hidden ids: %w", err)
}
defer rows.Close()
out := map[uuid.UUID]bool{}
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("game: scan hidden id: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
// ListGames returns games for the admin games list, most-recently-updated first,
// paginated. status filters by lifecycle ("active"/"finished") when non-empty.
// The seats are not loaded — the list shows summaries; the detail view uses
+67
View File
@@ -0,0 +1,67 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/game"
)
// TestHideFinishedGame covers Stage 17 per-account game hiding: an active game cannot be
// hidden, a finished game is removed from the hider's own list while staying visible to the
// other player, an outsider cannot hide it, and the action is idempotent.
func TestHideFinishedGame(t *testing.T) {
ctx := context.Background()
svc, gameID, seats, _ := newDraftGame(t)
// Hiding while the game is still active is refused.
if err := svc.HideGame(ctx, seats[0], gameID); !errors.Is(err, game.ErrGameActive) {
t.Fatalf("hide active = %v, want ErrGameActive", err)
}
// Finish the game by seat 0 resigning.
if _, err := svc.Resign(ctx, gameID, seats[0]); err != nil {
t.Fatalf("resign: %v", err)
}
// A non-player cannot hide it.
if err := svc.HideGame(ctx, provisionAccount(t), gameID); !errors.Is(err, game.ErrNotAPlayer) {
t.Fatalf("hide by outsider = %v, want ErrNotAPlayer", err)
}
// Seat 0 hides the finished game; hiding again is a no-op success.
if err := svc.HideGame(ctx, seats[0], gameID); err != nil {
t.Fatalf("hide: %v", err)
}
if err := svc.HideGame(ctx, seats[0], gameID); err != nil {
t.Fatalf("hide twice: %v", err)
}
// It is gone from seat 0's list but still in seat 1's (hiding is per-account).
if containsGame(t, svc, seats[0], gameID) {
t.Error("hidden game still listed for the hider")
}
if !containsGame(t, svc, seats[1], gameID) {
t.Error("hidden game should remain listed for the other player")
}
}
// containsGame reports whether the account's lobby list includes gameID.
func containsGame(t *testing.T, svc *game.Service, accountID, gameID uuid.UUID) bool {
t.Helper()
games, err := svc.ListForAccount(context.Background(), accountID)
if err != nil {
t.Fatalf("list for account: %v", err)
}
for _, g := range games {
if g.ID == gameID {
return true
}
}
return false
}
@@ -0,0 +1,18 @@
-- +goose Up
-- Stage 17: per-account hidden games. A row hides game_id from account_id's own "my games"
-- lobby list, leaving it visible to the other players. Only finished games are hidden, and the
-- action is irreversible by design (there is no un-hide). Queried with raw SQL, so no generated
-- jet code is needed.
SET search_path = backend, pg_catalog;
CREATE TABLE game_hidden (
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, game_id)
);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE game_hidden;
+1
View File
@@ -68,6 +68,7 @@ func (s *Server) registerRoutes() {
u.GET("/games/:id/gcg", s.handleExportGCG)
u.GET("/games/:id/draft", s.handleGetDraft)
u.PUT("/games/:id/draft", s.handleSaveDraft)
u.POST("/games/:id/hide", s.handleHideGame)
}
if s.matchmaker != nil {
u.POST("/lobby/enqueue", s.handleEnqueue)
+19
View File
@@ -94,6 +94,25 @@ func (s *Server) handleSubmitPlay(c *gin.Context) {
}
// handleGameState returns the player's view of a game.
// handleHideGame hides a finished game from the caller's own lobby list (Stage 17).
func (s *Server) handleHideGame(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
gameID, ok := gameIDParam(c)
if !ok {
abortBadRequest(c, "invalid game id")
return
}
if err := s.games.HideGame(c.Request.Context(), uid, gameID); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
func (s *Server) handleGameState(c *gin.Context) {
uid, ok := userID(c)
if !ok {
+4
View File
@@ -419,6 +419,10 @@ English game the Latin pool.
(pending confirm-codes) and `game_invitations` / `game_invitation_invitees`
(friend-game invitations). Stage 8's migration `00006` widened the `friendships`
status to admit `declined` and added `friend_codes` (one-time add-a-friend codes).
Stage 17 added `game_drafts` (a player's in-progress rack order + board composition per
game) and `game_hidden` (`(account_id, game_id)` rows that drop a finished game from one
account's own lobby list, leaving it visible to the other players — finished-only and
irreversible by design, so there is no un-hide).
The matchmaking pool is **in-memory** and persists nothing.
- **Active games are event-sourced.** A game is a `games` row (pinned
`variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised
+4 -1
View File
@@ -62,7 +62,10 @@ Bottom tab menu: **my games**, **profile**. The **my games** list groups games i
sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and
orders them so the games awaiting your move come first, the longest-waiting on top, while
opponent-turn and finished games are most-recent first; it renders as a compact,
line-separated list (Stage 17). The game types offered on **New Game** are
line-separated list (Stage 17). You can **remove a finished game from your own list**:
swipe a finished row left (or, on desktop, tap its **⋮**) to reveal a **❌**, then tap it.
The removal is per-account and permanent — the game disappears only from your list and stays
in the other players' lists, and there is no undo. The game types offered on **New Game** are
limited to the languages the player's sign-in service supports (English → Scrabble;
Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is
unrestricted). Variants are shown by their **display name** — both Scrabble variants read
+5 -1
View File
@@ -63,7 +63,11 @@ Mini App** авторизует по подписанным `initData` плат
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
соперника и завершённые — самые свежие сверху; отображается компактным списком с
линиями-разделителями (Stage 17). Типы партий на экране **Новая игра**
линиями-разделителями (Stage 17). Завершённую партию можно **убрать из своего списка**:
проведи по строке завершённой партии влево (или, на десктопе, нажми её **⋮**), чтобы открыть
**❌**, и нажми её. Удаление действует только для твоего аккаунта и необратимо — партия
исчезает лишь из твоего списка и остаётся в списках других игроков, отмены нет. Типы партий
на экране **Новая игра**
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
+6
View File
@@ -354,6 +354,12 @@ func (c *Client) SaveDraft(ctx context.Context, userID, gameID string, body json
return c.do(ctx, http.MethodPut, c.gamePath(gameID, "/draft"), userID, "", body, nil)
}
// HideGame hides a finished game from the caller's own games list (Stage 17). The action is
// per-account and irreversible; the game stays visible to the other players.
func (c *Client) HideGame(ctx context.Context, userID, gameID string) error {
return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/hide"), userID, "", struct{}{}, nil)
}
// Evaluate previews a tentative play's legality and score. The tiles are addressed by
// alphabet index (Stage 13).
func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (EvalResultResp, error) {
+14
View File
@@ -41,6 +41,7 @@ const (
MsgChatNudge = "chat.nudge"
MsgDraftGet = "draft.get"
MsgDraftSave = "draft.save"
MsgGameHide = "game.hide"
)
// Request is one decoded Execute call.
@@ -113,6 +114,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan
r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true}
r.ops[MsgDraftGet] = Op{Handler: getDraftHandler(backend), Auth: true}
r.ops[MsgDraftSave] = Op{Handler: saveDraftHandler(backend), Auth: true}
r.ops[MsgGameHide] = Op{Handler: hideGameHandler(backend), Auth: true}
registerStage8(r, backend)
registerStage11(r, backend, tg, defaultLanguages)
return r
@@ -451,3 +453,15 @@ func saveDraftHandler(backend *backendclient.Client) Handler {
return encodeDraftView(""), nil
}
}
// hideGameHandler hides a finished game from the caller's own list (Stage 17). It reuses
// GameActionRequest for the game id and echoes an Ack.
func hideGameHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
if err := backend.HideGame(ctx, req.UserID, string(in.GameId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
@@ -150,6 +150,39 @@ func gameActionPayload(gameID string) []byte {
return b.FinishedBytes()
}
// TestHideGameForwardsToBackend checks game.hide reuses GameActionRequest, POSTs to the
// game's /hide endpoint with the caller's id, and echoes an Ack (Stage 17).
func TestHideGameForwardsToBackend(t *testing.T) {
var hit bool
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
hit = true
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/user/games/g-1/hide" {
t.Errorf("unexpected %s %q", r.Method, r.URL.Path)
}
if got := r.Header.Get("X-User-ID"); got != "u-1" {
t.Errorf("X-User-ID = %q, want u-1", got)
}
_, _ = w.Write([]byte(`{"ok":true}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, ok := reg.Lookup(transcode.MsgGameHide)
if !ok {
t.Fatal("game.hide not registered")
}
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: gameActionPayload("g-1")})
if err != nil {
t.Fatalf("handler: %v", err)
}
if !hit {
t.Error("backend not called")
}
if ack := fb.GetRootAsAck(payload, 0); !ack.Ok() {
t.Error("ack not ok")
}
}
func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-User-ID"); got != "u-9" {
+3
View File
@@ -170,6 +170,9 @@ test('chat and word-check open as their own screens and back to the game (Stage
await expect(page).toHaveURL(/\/game\/g1\/chat$/);
await expect(page.locator('.pane')).toHaveCount(1); // let the slide transition settle
await expect(page.locator('.chat')).toBeVisible();
// The outgoing game header and the incoming chat header both carry a .back mid-slide; wait
// for the game's to unmount so the click targets a single, settled button.
await expect(page.locator('.back')).toHaveCount(1);
await page.locator('.back').click(); // header back chevron returns to the game
await expect(page).toHaveURL(/\/game\/g1$/);
await expect(page.locator('.pane')).toHaveCount(1);
+28
View File
@@ -86,6 +86,34 @@ test('finished game draws an inert footer and trims the live-only menu', async (
await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0);
});
test('lobby: hiding a finished game removes it (kebab → ❌), keeping the others', async ({ page }) => {
await loginLobby(page);
// Both seeded finished games are listed; the active row carries the inert chevron, not a kebab.
await expect(page.locator('.rowwrap', { hasText: 'Kaya' })).toBeVisible();
await expect(page.locator('.rowwrap', { hasText: 'Rick' })).toBeVisible();
const annRow = page.locator('.rowwrap', { hasText: 'Ann' });
await expect(annRow.locator('.kebab')).toHaveCount(0);
await expect(annRow.locator('.chev')).toBeVisible();
// The kebab reveals the delete action in place (no dropdown menu); tapping ❌ hides the game.
const kayaRow = page.locator('.rowwrap', { hasText: 'Kaya' });
await kayaRow.locator('.kebab').click();
await expect(kayaRow).toHaveClass(/revealed/);
await kayaRow.locator('.del').click();
// The hidden game is gone from the list; the other finished game remains.
await expect(page.locator('.rowwrap', { hasText: 'Kaya' })).toHaveCount(0);
await expect(page.locator('.rowwrap', { hasText: 'Rick' })).toBeVisible();
});
test('lobby: the active-row chevron opens the game (not a no-op)', async ({ page }) => {
await loginLobby(page);
// The inert-looking '>' on an active row is a tap target that opens the game, like the row.
await page.locator('.rowwrap', { hasText: 'Ann' }).locator('.chev').click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
});
test('lobby hamburger shows the pending notification count', async ({ page }) => {
await loginLobby(page);
// One incoming friend request (Rick) + one invitation (Kaya) = 2.
+2
View File
@@ -82,6 +82,8 @@ export interface GatewayClient {
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
complaint(gameId: string, word: string, note: string): Promise<void>;
/** Hide a finished game from the caller's own lobby list (Stage 17); per-account, irreversible. */
hideGame(gameId: string): Promise<void>;
// --- draft (Stage 17) ---
/** The player's server-persisted client-side composition (rack order + board tiles), so a
+1
View File
@@ -35,6 +35,7 @@ export const en = {
'lobby.about': 'About',
'lobby.yourTurn': 'Your turn',
'lobby.theirTurn': 'Their turn',
'lobby.hideGame': 'Remove from list',
'lobby.vs': 'vs {opponents}',
'lobby.soon': 'Coming soon',
+1
View File
@@ -36,6 +36,7 @@ export const ru: Record<MessageKey, string> = {
'lobby.about': 'О программе',
'lobby.yourTurn': 'Ваш ход',
'lobby.theirTurn': 'Ход соперника',
'lobby.hideGame': 'Убрать из списка',
'lobby.vs': 'против {opponents}',
'lobby.soon': 'Скоро',
+9
View File
@@ -324,6 +324,15 @@ export class MockGateway implements GatewayClient {
}
async complaint(): Promise<void> {}
// Hide a finished game from the caller's list (Stage 17): drop it from the in-memory store so a
// subsequent gamesList omits it, mirroring the backend's per-account, finished-only rule.
async hideGame(gameId: string): Promise<void> {
const g = this.game(gameId);
if (g.view.status !== 'finished') throw new GatewayError('game_active');
this.games.delete(gameId);
this.drafts.delete(gameId);
}
// --- draft (Stage 17): an in-memory composition store, so the reload/off-turn flow is
// exercised without a backend. A committed move clears the actor's own draft, as on the server.
async draftGet(gameId: string): Promise<string> {
+3
View File
@@ -114,6 +114,9 @@ export function createTransport(baseUrl: string): GatewayClient {
async complaint(id, word, note) {
await exec('game.complaint', codec.encodeComplaint(id, word, note));
},
async hideGame(id) {
await exec('game.hide', codec.encodeGameAction(id));
},
async draftGet(id) {
return codec.decodeDraftView(await exec('draft.get', codec.encodeGameAction(id)));
},
+139 -13
View File
@@ -61,6 +61,60 @@
return `${me?.score ?? 0} : ${opp.join(', ')}`;
}
// Hiding a finished game (Stage 17). The delete action sits behind each finished row and is
// revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is
// per-account and irreversible. Only one row is revealed at a time.
let revealedId = $state<string | null>(null);
let drag: { id: string; x0: number; y0: number } | null = null;
// A horizontal swipe must not also count as a tap that opens the game; armed on swipe,
// consumed by the next tap, and reset on the next pointerdown so a later tap is never eaten.
let swiped = false;
function onRowDown(e: PointerEvent, id: string): void {
swiped = false;
if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe
drag = { id, x0: e.clientX, y0: e.clientY };
}
function onRowUp(e: PointerEvent, finished: boolean): void {
if (!drag) return;
const dx = e.clientX - drag.x0;
const dy = e.clientY - drag.y0;
// Only a finished row reveals on a horizontal swipe; that swipe then suppresses the tap so it
// does not also open the game. Active rows ignore swipes and stay plain tap-to-open.
if (finished && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
swiped = true;
revealedId = dx < 0 ? drag.id : null;
}
drag = null;
}
function openGame(g: GameView): void {
if (swiped) {
swiped = false;
return;
}
if (revealedId === g.id) {
revealedId = null;
return;
}
navigate(`/game/${g.id}`);
}
function toggleReveal(id: string): void {
revealedId = revealedId === id ? null : id;
}
async function hide(id: string): Promise<void> {
revealedId = null;
const prev = games;
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
setLobby({ games, invitations, incoming });
try {
await gateway.hideGame(id);
} catch (e) {
games = prev;
setLobby({ games, invitations, incoming });
handleError(e);
}
}
const menuItems = $derived([
...(guest ? [] : [{ label: t('lobby.friends'), onclick: () => navigate('/friends'), badge: incoming.length }]),
{ label: t('lobby.profile'), onclick: () => navigate('/profile') },
@@ -129,19 +183,38 @@
</section>
{/if}
{#each [{ h: 'lobby.yourTurn', list: groups.yourTurn }, { h: 'lobby.theirTurn', list: groups.theirTurn }, { h: 'lobby.finishedGames', list: groups.finished }] as group (group.h)}
{#each [{ h: 'lobby.yourTurn', list: groups.yourTurn, finished: false }, { h: 'lobby.theirTurn', list: groups.theirTurn, finished: false }, { h: 'lobby.finishedGames', list: groups.finished, finished: true }] as group (group.h)}
{#if group.list.length}
<section>
<h2>{t(group.h as 'lobby.yourTurn')}</h2>
<div class="list">
{#each group.list as g (g.id)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="info">
<span class="who">{opponents(g) || '—'}</span>
<span class="sub">{scoreline(g)}</span>
</span>
<span class="emoji">{resultBadge(g, myId).emoji}</span>
</button>
<div class="rowwrap" class:revealed={group.finished && revealedId === g.id}>
{#if group.finished}
<button class="del" onclick={() => hide(g.id)} aria-label={t('lobby.hideGame')}></button>
{/if}
<div class="row">
<button
class="open"
onpointerdown={(e) => onRowDown(e, g.id)}
onpointerup={(e) => onRowUp(e, group.finished)}
onclick={() => openGame(g)}
>
<span class="info">
<span class="who">{opponents(g) || '—'}</span>
<span class="sub">{scoreline(g)}</span>
</span>
<span class="emoji">{resultBadge(g, myId).emoji}</span>
</button>
{#if group.finished}
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}></button>
{:else}
<!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out
of the tab order / a11y tree since the .open button already does the same. -->
<button class="chev" onclick={() => openGame(g)} tabindex={-1} aria-hidden="true"></button>
{/if}
</div>
</div>
{/each}
</div>
</section>
@@ -208,25 +281,78 @@
display: flex;
flex-direction: column;
}
/* Each finished row can slide left to reveal a delete action sitting behind it; the row's
own opaque background hides that action until revealed (Stage 17). */
.rowwrap {
position: relative;
overflow: hidden;
}
.rowwrap + .rowwrap {
border-top: 1px solid var(--border);
}
.del {
position: absolute;
inset: 0 0 0 auto;
width: 64px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: var(--danger);
color: #fff;
font-size: 1.1rem;
}
.row {
position: relative;
display: flex;
align-items: center;
gap: 2px;
background: var(--bg);
transform: translateX(0);
transition: transform 0.18s ease;
}
.rowwrap.revealed .row {
transform: translateX(-64px);
}
.open {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-width: 0;
text-align: left;
padding: 10px 6px;
border: none;
background: none;
color: var(--text);
user-select: none;
touch-action: pan-y; /* keep vertical list scroll; we only read horizontal swipes */
}
.row + .row {
border-top: 1px solid var(--border);
}
.row:active {
.open:active {
background: var(--surface-2);
}
.kebab {
flex: 0 0 auto;
width: 30px;
padding: 10px 0;
border: none;
background: none;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
}
.chev {
flex: 0 0 auto;
width: 30px;
padding: 10px 0;
border: none;
background: none;
text-align: center;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
}
.info {
display: flex;
flex-direction: column;