Guest mode + viewport-aware screens + wireframe-driven UI
The platform from the previous post worked, but three gaps capped the games-as-vehicle hypothesis at once: friends couldn’t join without an account, mobile re-rendered on every scroll, and the UI had drifted a month from its own wireframes. Three March task-logs closed those gaps before any new game.
TL;DR
- Guest share-links:
POST /api/v2/games/:roomId/share-linkreturns a URL with a raw secret; server stores onlysecretHash. TTL 5 min via explicit check + TTL index. - Guest UUID derived from
secret + normalizedUsername + gameType + roomNumber, so refresh keeps the same seat. - Viewport mode resolved once per screen mount at
DESKTOP_MIN_WIDTH = 1025. No liveresizeflips; each shared screen splits into*Mobileand*Desktopsiblings. - Wireframes (
docs/wireframes/board-game-client/) are the source of truth; 35-testwireframe-sync.spec.tsPlaywright suite asserts labels and structure. - Both fixes leaned on in-memory E2E (
e2e/_shared/,mongodb-memory-server).
Friends needed an account; share-links removed the step
POST /api/v1/auth/guest already existed; what was missing was a way to land on a specific room without typing a 6-digit code. Three v2 endpoints:
POST /api/v2/games/:roomId/share-link // host creates, auth required
GET /api/v2/share-links/resolve // public validate + expiry
POST /api/v2/share-links/join // post-guest-login
- Host call returns a URL with
gameType,roomNumber, rawsecret; server storessecretHashonly (sha256). - TTL 5 min — enforced by
expiresAt > nowin the handler plus a TTL index for cleanup. resolveis public so the guest screen shows “this link expired” before forcing a login.
Guest identity took the longest. The client derives the guest UUID from secret + normalizedUsername + gameType + roomNumber, calls POST /api/v1/auth/guest, and persists at cloudnest_guest_share_v1:{gameType}:{roomNumber}. Same browser, same UUID, same seat server-side. The user gets isGuest: true and guestDisplayName so I can filter guests out of player stats later.
e2e/chess-v2-inmemory/guest-share-link.spec.ts covers four scenarios: host copies, guest joins, refresh keeps the seat, expired link blocks the join. The expired case mattered most — it’s the only one users hit by accident.
Mobile broke; the fix was picking a mode once at mount
The phone bug was Tailwind’s fault and mine. Shared screens used sm:* classes; alkagi’s MultiplayerGame had useCanvasSize on window.resize. When mobile Safari collapses the address bar, innerHeight jumps, resize fires, the canvas re-measures, the board re-renders. Visible flash on every scroll.
Options were debouncing every resize handler or splitting the component by shape. I picked the second:
const DESKTOP_MIN_WIDTH = 1025;
function resolveScreenMode(): "mobile" | "desktop" {
if (typeof window === "undefined") return "mobile";
return window.innerWidth >= DESKTOP_MIN_WIDTH ? "desktop" : "mobile";
}
- Resolved once per screen mount. Refresh re-evaluates. Live
resizedoesn’t flip the mode. - Each shared screen splits into siblings —
HomeScreenMobile/HomeScreenDesktop, same forLobbyScreen,GameRoomScreen, auth, and games. Router-level components are thin selectors.
Rollout: resolver + tests, shared screens, games (one per PR), then desktop and mobile Playwright projects so regressions fail CI. The playground was normalised to desktop-only — it’s for me, on my desk. HomeScreen.tsx became three files; better than writing clamp() Tailwind forever.
Wireframes had drifted; the fix was a habit, not code
docs/wireframes/board-game-client/ already existed — index.html, design-system.html, user-flows.html. A month out of sync. Fix: promote the wireframe to step one of any UI change. One resync round caught the screens up:
RegisterScreenlostconfirmPassword, gainedname(이름).LoginScreenretitled from a generic platform header to its actual purpose.GuestJoinScreencollapsed two buttons into one — saved-credential dual-button UX was overkill once auto-detection worked.HomeScreengained per-game card icons (GameTypeConfiggoticon/iconBg) and clickable cards instead of card+button pairs.LobbyScreenrendered방 #XXXXfrom the last four chars of the room ID viaderiveRoomDisplayName(), instead of leaking the host UUID.GameRoomScreengot host/participant avatars, ”?” placeholders for empty seats, and the share-link button where the wireframe said.
A 35-test wireframe-sync.spec.ts Playwright suite asserts labels and structure. Cheap to run because of how E2E was wired the week before — see Sidebar 1.
Sidebar 1: in-memory E2E made the wireframe suite affordable
e2e/_shared/ wires the function handler graph in-process (not Docker compose) and uses mongodb-memory-server — one ephemeral mongod per Playwright worker, isolated by construction. The whole board-game-inmemory project finishes well under a minute. The Docker-backed multiplayer-chess suite needed ./test-functions.sh start plus a healthcheck wait — fine for one run, painful for thirty-five.
Sidebar 2: ErrorBoundary + 30-second WS timeout (mid-April)
Mid-April: ErrorBoundary.tsx wraps <App /> and each lazy game <Screen /> — a throw shows a 오류가 발생했습니다 card with refresh CTA instead of killing the nav shell. use-ws-reconnect-timeout.ts returns true after 30 s of connectionStatus === "reconnecting"; GameRoomScreen then shows a modal with “새로고침” and “로비로 돌아가기”. Boundaries catch JS crashes; the timeout catches WS hangs.
What this changes going forward
The wireframe-as-source-of-truth habit got reused on April 28: docs/wireframes/games-design-system.html shipped as a single visual spec for all seven games. Every remodel session now begins by porting the canonical render<Game>Board() IIFE from HTML into the React component. Same habit, bigger surface.
AI workflow note
Claude enforced the wireframe-first habit. For each UI change I opened the wireframe HTML and the React component side by side, sketched the change in HTML first (Claude editing inline), then asked for the component diff against the updated wireframe. The responsive-vs-explicit-split decision went through the architect agent — I asked it to argue both sides, and the resize-thrash and canvas-flash points came back as the deciding factors, not anything I’d led with. Reusable habit: when changing a screen, ask Claude to first list every component that imports it, then propose the change against that list. Kills “I forgot this call site” bugs that used to land in PR review.
