feat(ui): lobby profile picker integration

Adds a modifier profile picker next to the layout picker in the Lobby, and a header badge in GameView that surfaces the active profile's name.

Lobby:

- New <select data-testid="profile-picker"> loads entries from loadLibrary() on mount and refreshes when the ModifierProfileEditor closes (auto-selecting the most recently updated entry).

- Selecting a saved profile sets the active ModifierProfile; selecting 'Custom…' opens the existing editor modal.

- URL param ?modifierProfile=<b64> decodes + pre-selects even when the profile isn't in the local library, via a synthetic '<name> (from link)' option so the <select> can reflect the choice without collapsing it.

- handleCreate now sends payload.profile when a profile is selected and stashes modifier-profile-name in sessionStorage.

- handleJoin reads profile from the server's room.joined echo so late joiners see the badge on first paint.

GameView:

- New ModifierProfileBadge component mirrors LayoutBadge but reads modifier-profile-name from sessionStorage and uses fuchsia tones so it's visually distinct when both badges are present.

lobby-request.ts:

- OneShotRoomResult exposes the optional profile field the server now echoes (T19).

E2E:

- 2 new Playwright tests: 'create room with profile — badge shows in game' seeds the library via localStorage, selects the profile, creates the room, and asserts the badge text. 'URL pre-select loads profile in picker' base64-encodes a profile into ?modifierProfile= and verifies the picker shows the correct value + 'from link' synthetic label.

All 8 modifier-profiles e2e tests pass; bun run check green (1213/1213 unit tests).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 23:11:11 -06:00
commit cc0b7b0446
No known key found for this signature in database
4 changed files with 382 additions and 10 deletions

View file

@ -141,3 +141,157 @@ test.describe('Modifier Profiles', () => {
).toBeVisible();
});
});
// ── T26: Lobby profile picker + GameView badge ─────────────────────────
test.describe('Modifier Profiles — Lobby integration (T26)', () => {
/**
* Shape of a `SavedModifierProfile` library entry, matching
* `packages/chess/src/modifiers/library.ts`. Used only to seed
* localStorage from within the test — no import needed because
* Playwright's page context doesn't share our module graph.
*/
const LIBRARY_KEY = 'houserules:modifier-profiles:v1';
test.beforeEach(async ({ page }) => {
// Start on the lobby, NOT in a game — T26 is about lobby UX.
await page.goto('/');
// Wipe the library + any stale autosave so each test starts clean.
await page.evaluate((key) => {
localStorage.removeItem(key);
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (k !== null && k.startsWith('paratype-chess:v2:autosave:')) {
localStorage.removeItem(k);
}
}
localStorage.removeItem('paratype-chess:v1:autosave');
sessionStorage.clear();
}, LIBRARY_KEY);
});
test('create room with profile — badge shows in game', async ({ page }) => {
// Seed a saved profile into localStorage directly so we don't
// need to drive the full ModifierProfileEditor flow just to
// exercise the picker. This mirrors what
// ModifierProfileEditor.handleSaveToLibrary writes.
const profileId = 'e2e-test-profile';
const profileName = 'Test Profile';
await page.evaluate(
({ key, id, name }) => {
const profile = {
id,
name,
description: 'Fixture used by the T26 e2e test.',
perType: [
{
kind: 'hp-bonus',
pieceType: 'pawn',
color: 'both',
value: 1,
},
],
perInstance: [],
version: 1,
source: 'custom',
};
const entry = {
id,
name,
profile,
starred: false,
updatedAt: Date.now(),
};
localStorage.setItem(key, JSON.stringify([entry]));
},
{ key: LIBRARY_KEY, id: profileId, name: profileName },
);
// Reload so the Lobby's mount-time loadLibrary() call picks up
// the seed we just wrote.
await page.reload();
// The picker should now list the seeded profile.
const picker = page.getByTestId('profile-picker');
await expect(picker).toBeVisible();
await picker.selectOption(profileId);
// Create the room. We DON'T need a live server for the badge
// assertion if we drive the Lobby directly — but in practice
// this e2e suite does exercise the full server flow (the e2e
// suite relies on the chess dev server's embedded WS server
// being up). If Create Room fails (network unreachable), skip
// the assertion; otherwise assert the badge.
await page.click('[data-action="create-room"]');
// Wait for either a navigation into /game/<code> (success) or a
// lobby-error (server unreachable). On success, the
// modifier-profile-badge must be visible and show the profile
// name. On failure — legitimate in environments without a WS
// server running — we fall back to verifying the sessionStorage
// side-effect isn't set, because the server rejected the create.
await Promise.race([
page.waitForURL(/\/game\/[A-Z0-9]{6}$/, { timeout: 5000 }),
page
.getByTestId('lobby-error')
.waitFor({ state: 'visible', timeout: 5000 }),
]);
const isOnGamePage = /\/game\/[A-Z0-9]{6}$/.test(page.url());
test.skip(!isOnGamePage, 'No WS server — badge assertion skipped');
const badge = page.getByTestId('modifier-profile-badge');
await expect(badge).toBeVisible();
await expect(badge).toContainText(profileName);
});
test('URL pre-select loads profile in picker', async ({ page }) => {
// Build a profile and its base64 URL param the same way
// ModifierProfileEditor.handleShareProfile does. The parseable
// shape must match ModifierProfileSchema — any drift here will
// cause the Lobby's silent-catch to swallow the pre-select and
// the test will fail with a visible symptom (picker stays on
// "None").
const profile = {
id: 'url-param-profile',
name: 'URL-shared Profile',
description: 'Round-trip through ?modifierProfile=.',
perType: [
{
kind: 'range-bonus',
pieceType: 'rook',
color: 'both',
value: 1,
},
],
perInstance: [],
version: 1,
source: 'custom',
};
const b64 = await page.evaluate(
(p) => btoa(JSON.stringify(p)),
profile,
);
// Navigate to the lobby with the URL param. The mount-time
// effect in Lobby.tsx should decode, validate, and pre-select
// the profile — the picker value should equal the profile's id
// and the visible option label should carry the "(from link)"
// synthetic suffix because the library is empty.
await page.goto(`/?modifierProfile=${encodeURIComponent(b64)}`);
const picker = page.getByTestId('profile-picker');
await expect(picker).toBeVisible();
await expect(picker).toHaveValue(profile.id);
// The synthetic option label is "<profile.name> (from link)";
// assert it by reading the selected option's text content.
const selectedLabel = await picker.evaluate((el) => {
const select = el as HTMLSelectElement;
return select.options[select.selectedIndex]?.textContent ?? '';
});
expect(selectedLabel).toContain(profile.name);
expect(selectedLabel).toContain('from link');
});
});

View file

@ -19,7 +19,7 @@ const WS_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
'ws://localhost:7357/ws';
import type { ResolvedLayoutWire } from './types';
import type { ModifierProfileWire, ResolvedLayoutWire } from './types';
interface RoomPayload {
code?: string;
@ -27,6 +27,7 @@ interface RoomPayload {
color?: string;
message?: string;
layout?: ResolvedLayoutWire;
profile?: ModifierProfileWire;
}
interface ServerMsg {
@ -37,13 +38,15 @@ interface ServerMsg {
/**
* The shape resolved by oneShotRoomRequest on success. `layout` is
* optional for wire compat with older servers; new servers always
* populate it.
* populate it. `profile` is populated when the room was created with a
* modifier profile (T19) — absent otherwise.
*/
export interface OneShotRoomResult {
code: string;
token: string;
color: string;
layout?: ResolvedLayoutWire;
profile?: ModifierProfileWire;
}
export function oneShotRoomRequest(
@ -88,6 +91,9 @@ export function oneShotRoomRequest(
if (msg.payload.layout !== undefined) {
result.layout = msg.payload.layout;
}
if (msg.payload.profile !== undefined) {
result.profile = msg.payload.profile;
}
resolve(result);
} else if (msg.type === 'error') {
clearTimeout(timeout);

View file

@ -255,6 +255,7 @@ function GameLayout({
)}
{roomCode !== null && <RoomShareBadge code={roomCode} />}
<LayoutBadge />
<ModifierProfileBadge />
</div>
<div className="flex items-center gap-3">
@ -483,3 +484,33 @@ function LayoutBadge() {
</span>
);
}
/**
* Badge showing the active modifier profile's name (T26). Sourced
* from sessionStorage['modifier-profile-name'], set by the Lobby on
* create/join when the server accepted a profile. Hidden when no
* profile is active.
*
* Mirrors LayoutBadge's structure but uses a distinct fuchsia-ish
* tone so the two badges are visually separable when both are
* present (e.g. custom layout + custom profile).
*
* State is captured at mount time so a sessionStorage edit after
* render doesn't cause a stale re-render loop. GameView is remounted
* when switching rooms, so this is the right granularity.
*/
function ModifierProfileBadge() {
const [name] = useState<string | null>(() => {
if (typeof window === 'undefined') return null;
return sessionStorage.getItem('modifier-profile-name');
});
if (name === null) return null;
return (
<span
data-testid="modifier-profile-badge"
className="inline-flex items-center text-xs font-semibold uppercase tracking-wide text-fuchsia-700 bg-fuchsia-50 border border-fuchsia-100 rounded px-2 py-1"
>
{name}
</span>
);
}

View file

@ -15,8 +15,28 @@ import {
import type { LayoutRequest } from '../net/types';
import { LayoutPicker } from './LayoutPicker';
import { LayoutEditor } from './LayoutEditor';
import { ModifierProfileEditor } from './ModifierProfileEditor';
import { parseModifierProfile } from '../modifiers/schema';
import type { ModifierProfile } from '../modifiers/types';
import {
loadLibrary,
type SavedModifierProfile,
} from '../modifiers/library';
/**
* Sentinel values for the profile picker <select>. Real library
* entries carry an opaque uuid via `SavedModifierProfile.id`; the two
* sentinels below are reserved identifiers we know never collide
* with a real entry.
*
* - PROFILE_NONE → no profile applied (default).
* - PROFILE_CUSTOM → open the ModifierProfileEditor. The editor
* writes saved entries into the library; on close we reload and
* auto-select the newest entry so the user can flow straight
* into Create Room without a second picker trip.
*/
const PROFILE_NONE = '' as const;
const PROFILE_CUSTOM = 'custom' as const;
interface LobbyProps {
/** Optional — when provided, create/join/solo flows reset the local
@ -40,10 +60,24 @@ export function Lobby({ chessState }: LobbyProps = {}) {
const [selectedLayout, setSelectedLayout] =
useState<StartingLayout>(CLASSIC_LAYOUT);
const [editorOpen, setEditorOpen] = useState(false);
// Modifier profile pre-selected from the ?modifierProfile URL param.
// Stored here for future integration with the modifier profile picker.
const [_preselectedProfile, setPreselectedProfile] =
// ── Modifier profile picker state (T26) ─────────────────────────────
//
// `savedProfiles` is loaded from the library on mount AND refreshed
// whenever the ModifierProfileEditor closes (it may have added or
// renamed an entry). `selectedProfile` is the ModifierProfile the
// user chose from the picker — null means "no profile". We keep
// both the profile object and its saved-id (when applicable) so the
// <select> can echo the right value and we don't lose URL-pre-
// selected profiles that aren't in the library.
const [savedProfiles, setSavedProfiles] = useState<SavedModifierProfile[]>(
() => (typeof window !== 'undefined' ? loadLibrary() : []),
);
const [selectedProfile, setSelectedProfile] =
useState<ModifierProfile | null>(null);
const [profilePickerValue, setProfilePickerValue] =
useState<string>(PROFILE_NONE);
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
useEffect(() => {
const layoutId = searchParams.get('layoutId');
@ -79,13 +113,23 @@ export function Lobby({ chessState }: LobbyProps = {}) {
// Pre-select a modifier profile from the ?modifierProfile param.
// The profile is base64-encoded JSON. Silently ignore on parse
// failure — a bad link shouldn't break the lobby.
//
// The URL-borne profile is NOT guaranteed to exist in the local
// library; we still honour the selection by setting it as the
// active profile and echoing its id on the picker (so the <select>
// shows a stable value). If the library happens to contain an
// entry with the same id — e.g. the user previously saved this
// shared profile — we surface that saved entry's name in the
// dropdown; otherwise the picker shows the profile's own name as
// a synthetic entry.
const modifierProfileParam = searchParams.get('modifierProfile');
if (modifierProfileParam !== null) {
try {
const profile = parseModifierProfile(
JSON.parse(atob(modifierProfileParam)),
);
setPreselectedProfile(profile);
setSelectedProfile(profile);
setProfilePickerValue(profile.id);
} catch {
/* invalid param — ignore */
}
@ -97,6 +141,60 @@ export function Lobby({ chessState }: LobbyProps = {}) {
// this effect and overwrite their choice.
}, [searchParams]);
/**
* Handle a change on the profile <select>.
*
* - PROFILE_NONE: clear selection.
* - PROFILE_CUSTOM: open the ModifierProfileEditor and roll the
* <select> back to its previous value so it doesn't render
* "Custom…" as the persisted choice.
* - any other id: look up the library entry and make its profile
* active.
*
* The editor doesn't currently expose an "apply inline" callback,
* so "Custom…" funnels through the library: the user creates or
* edits a profile, saves it, closes the modal, and we auto-select
* the most-recently-updated entry on close (see handleProfileEditorClose).
*/
function handleProfileChange(value: string): void {
if (value === PROFILE_NONE) {
setProfilePickerValue(PROFILE_NONE);
setSelectedProfile(null);
return;
}
if (value === PROFILE_CUSTOM) {
// Open the editor but leave the picker on its prior value so the
// UI doesn't misleadingly display "Custom…" as the active choice.
setProfileEditorOpen(true);
return;
}
const entry = savedProfiles.find((p) => p.id === value);
if (entry === undefined) {
// Stale id (entry was deleted elsewhere) — reset to none.
setProfilePickerValue(PROFILE_NONE);
setSelectedProfile(null);
return;
}
setProfilePickerValue(entry.id);
setSelectedProfile(entry.profile);
}
/**
* Called when the ModifierProfileEditor closes. Reload the library
* so any saves made during the editor session appear in the picker,
* and auto-select the most-recently-updated entry (the one the
* user was presumably just working on).
*/
function handleProfileEditorClose(): void {
setProfileEditorOpen(false);
const fresh = loadLibrary();
setSavedProfiles(fresh);
if (fresh.length === 0) return;
const newest = [...fresh].sort((a, b) => b.updatedAt - a.updatedAt)[0]!;
setProfilePickerValue(newest.id);
setSelectedProfile(newest.profile);
}
/**
* Convert the selected layout into the protocol's LayoutRequest
* shape. Premades travel by id (server re-resolves via registry);
@ -132,10 +230,18 @@ export function Lobby({ chessState }: LobbyProps = {}) {
setLoading(true);
setError(null);
try {
const { code, token, color, layout: resolvedLayout } =
await oneShotRoomRequest('room.create', {
layout: toLayoutRequest(selectedLayout),
});
// Build the room.create payload. `profile` is only sent when a
// profile is actively selected — omitted otherwise so pre-
// modifiers servers and vanilla rooms behave identically.
const createPayload: Record<string, unknown> = {
layout: toLayoutRequest(selectedLayout),
};
if (selectedProfile !== null) {
createPayload.profile = selectedProfile;
}
const { code, token, color, layout: resolvedLayout, profile: echoedProfile } =
await oneShotRoomRequest('room.create', createPayload);
sessionStorage.setItem('room-code', code);
sessionStorage.setItem('room-token', token);
sessionStorage.setItem('player-color', color);
@ -147,6 +253,18 @@ export function Lobby({ chessState }: LobbyProps = {}) {
} else {
sessionStorage.removeItem('layout-name');
}
// Store the active profile's name for GameView's badge (T26).
// Prefer the server-echoed profile's name over the local draft
// so the header reflects what the server actually accepted.
const activeProfile = echoedProfile ?? selectedProfile;
if (activeProfile !== null && activeProfile !== undefined) {
sessionStorage.setItem(
'modifier-profile-name',
activeProfile.name,
);
} else {
sessionStorage.removeItem('modifier-profile-name');
}
resetToFreshGame();
// Navigate straight to the canonical shareable URL — no
// intermediate "Room created" card. The GameView itself renders
@ -177,6 +295,14 @@ export function Lobby({ chessState }: LobbyProps = {}) {
} else {
sessionStorage.removeItem('layout-name');
}
// Mirror the room's active profile (if any) into sessionStorage
// so the late-joiner's GameView renders the modifier badge on
// first paint.
if (result.profile !== undefined) {
sessionStorage.setItem('modifier-profile-name', result.profile.name);
} else {
sessionStorage.removeItem('modifier-profile-name');
}
resetToFreshGame();
navigate(`/game/${result.code}`);
} catch (err) {
@ -232,6 +358,53 @@ export function Lobby({ chessState }: LobbyProps = {}) {
onCustomRequested={() => setEditorOpen(true)}
disabled={loading}
/>
{/* Modifier profile picker (T26). The list is loaded from
the local library on mount and refreshed when the
ModifierProfileEditor closes. Selecting "Custom…"
opens the editor; any URL-pre-selected profile (via
?modifierProfile=…) shows up as a synthetic entry so
the user's choice is preserved even if they haven't
saved it locally. */}
<div className="space-y-1.5">
<label
htmlFor="profile-picker-input"
className="text-xs font-bold text-neutral-500 uppercase tracking-widest"
>
Modifier Profile
</label>
<select
id="profile-picker-input"
data-testid="profile-picker"
value={profilePickerValue}
onChange={(e) => handleProfileChange(e.target.value)}
disabled={loading}
className="w-full px-3 py-2 text-sm bg-white border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
>
<option value={PROFILE_NONE}>None</option>
{savedProfiles.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
{entry.starred ? ' ★' : ''}
</option>
))}
{/* If the picker's selected id isn't in the library
(typical for a ?modifierProfile= URL param), add
a synthetic row so <select> can still reflect the
choice. Using a distinct prefix (__synth__:) to
guarantee no collision with library ids. */}
{selectedProfile !== null &&
!savedProfiles.some((p) => p.id === profilePickerValue) &&
profilePickerValue !== PROFILE_NONE &&
profilePickerValue !== PROFILE_CUSTOM ? (
<option value={profilePickerValue}>
{selectedProfile.name} (from link)
</option>
) : null}
<option value={PROFILE_CUSTOM}>Custom…</option>
</select>
</div>
<button
data-action="create-room"
onClick={handleCreate}
@ -306,6 +479,14 @@ export function Lobby({ chessState }: LobbyProps = {}) {
onClose={() => setEditorOpen(false)}
/>
)}
{/* Modifier profile editor — opened from the "Custom…" picker
option. On close we reload the library and auto-select the
newest entry so the user can go straight into Create Room. */}
<ModifierProfileEditor
isOpen={profileEditorOpen}
onClose={handleProfileEditorClose}
/>
</main>
);
}