feat(ui): consent dialog for modifier profile proposals

This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 09:43:12 -06:00
commit 8f5dca9c21
No known key found for this signature in database
4 changed files with 238 additions and 3 deletions

View file

@ -34,8 +34,9 @@ import type { PieceType } from '../schema';
import type { GameResult } from '../engine';
import { GameClient } from '../net/client';
import { PredictionManager } from '../net/prediction';
import type { Color, PresetActivation, PromotionPiece } from '../net/types';
import type { Color, PresetActivation, PromotionPiece, ModifierProfileWire, ModifierProfileProposalPendingPayload } from '../net/types';
import * as audio from '../audio';
import { toast } from 'sonner';
const WS_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
@ -53,6 +54,10 @@ export interface MultiplayerGameState {
/** Room error surfaced to the UI (e.g. ROOM_NOT_FOUND on reconnect
* after grace expiry). Null when healthy. */
error: string | null;
// Modifier proposal state
modifierProposal: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
modifierRejectionMessage: string | null;
isProposer: boolean;
}
/**
@ -71,6 +76,9 @@ export function useMultiplayerGame(code: string, token: string) {
myColor: null,
loading: true,
error: null,
modifierProposal: null,
modifierRejectionMessage: null,
isProposer: false,
});
// Mount the connection exactly once per code/token pair.
@ -105,6 +113,32 @@ export function useMultiplayerGame(code: string, token: string) {
// The first game.state snapshot after (re)connect clears `loading`.
setMeta((m) => ({ ...m, loading: false }));
};
const onProposalPending = (e: { payload: ModifierProfileProposalPendingPayload }) => {
setMeta((m) => ({
...m,
modifierProposal: { profile: e.payload.profile, expiresAt: e.payload.expiresAt, proposer: e.payload.proposer },
modifierRejectionMessage: null,
isProposer: false,
}));
};
const onProposalRejected = (e: { payload: { reason: string } }) => {
setMeta((m) => ({
...m,
modifierProposal: null,
modifierRejectionMessage: e.payload.reason,
isProposer: false,
}));
setTimeout(() => {
setMeta((m) => m.modifierRejectionMessage === e.payload.reason ? { ...m, modifierRejectionMessage: null } : m);
}, 3000);
};
const onProposalConsentReceived = () => {
setMeta((m) => ({ ...m, modifierProposal: null, isProposer: false }));
toast.success("Opponent approved your profile change. It will take effect next turn.");
};
const onProposalQueued = () => {
setMeta((m) => ({ ...m, isProposer: true }));
};
const onGameDelta = (e: {
payload: {
gameOver: { winner: string; reason: string } | null;
@ -148,6 +182,10 @@ export function useMultiplayerGame(code: string, token: string) {
client.on('room.joined', onJoined);
client.on('game.state', onGameState);
client.on('game.delta', onGameDelta);
client.on('modifier-profile.proposal-pending', onProposalPending);
client.on('modifier-profile.rejected', onProposalRejected);
client.on('modifier-profile.consent-received', onProposalConsentReceived);
client.on('modifier-profile.queued', onProposalQueued);
client.on('error', onError);
client.connect(code, token).catch((err: unknown) => {
@ -166,6 +204,10 @@ export function useMultiplayerGame(code: string, token: string) {
client.off('room.joined', onJoined);
client.off('game.state', onGameState);
client.off('game.delta', onGameDelta);
client.off('modifier-profile.proposal-pending', onProposalPending);
client.off('modifier-profile.rejected', onProposalRejected);
client.off('modifier-profile.consent-received', onProposalConsentReceived);
client.off('modifier-profile.queued', onProposalQueued);
client.off('error', onError);
client.close();
clientRef.current = null;
@ -277,5 +319,14 @@ export function useMultiplayerGame(code: string, token: string) {
connected: meta.connected,
loading: meta.loading,
error: meta.error,
modifierProposal: meta.modifierProposal,
modifierRejectionMessage: meta.modifierRejectionMessage,
isProposer: meta.isProposer,
sendConsent: (decision: 'approve' | 'reject') => {
clientRef.current?.send({
type: 'modifier-profile.consent',
payload: { roomCode: code, decision }
});
},
};
}

View file

@ -18,6 +18,10 @@ import type {
GameMovePayload,
GamePresetsPayload,
GameStatePayload,
ModifierProfileProposalPendingPayload,
ModifierProfileRejectedPayload,
ModifierProfileConsentReceivedPayload,
ModifierProfileQueuedPayload,
PresetActivation,
PromotionPiece,
RoomCreatedPayload,
@ -36,6 +40,10 @@ export type GameClientEvent =
| { type: "game.presets"; payload: GamePresetsPayload }
| { type: "room.created"; payload: RoomCreatedPayload }
| { type: "room.joined"; payload: RoomJoinedPayload }
| { type: "modifier-profile.proposal-pending"; payload: ModifierProfileProposalPendingPayload }
| { type: "modifier-profile.rejected"; payload: ModifierProfileRejectedPayload }
| { type: "modifier-profile.consent-received"; payload: ModifierProfileConsentReceivedPayload }
| { type: "modifier-profile.queued"; payload: ModifierProfileQueuedPayload }
| { type: "error"; payload: ErrorPayload }
| { type: "connected" }
| { type: "disconnected"; willReconnect: boolean };
@ -438,6 +446,18 @@ export class GameClient {
case "room.joined":
this.emit({ type, payload: payload as RoomJoinedPayload });
return;
case "modifier-profile.proposal-pending":
this.emit({ type, payload: payload as ModifierProfileProposalPendingPayload });
return;
case "modifier-profile.rejected":
this.emit({ type, payload: payload as ModifierProfileRejectedPayload });
return;
case "modifier-profile.consent-received":
this.emit({ type, payload: payload as ModifierProfileConsentReceivedPayload });
return;
case "modifier-profile.queued":
this.emit({ type, payload: payload as ModifierProfileQueuedPayload });
return;
case "error":
this.emit({ type, payload: payload as ErrorPayload });
return;

View file

@ -2,10 +2,11 @@ import { Board } from './Board';
import { RulesDrawer } from './RulesDrawer';
import { ModifierTooltip } from './ModifierTooltip.js';
import { ModifierPinnedPanel } from './ModifierPinnedPanel.js';
import { ModifierProposalDialog } from './ModifierProposalDialog.js';
import { useChessEngine } from '../hooks/useChessEngine';
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
import type { Color, PresetActivation } from '../net/types';
import type { Color, PresetActivation, ModifierProfileWire } from '../net/types';
import type { GameResult } from '../engine';
import type { LegalMove } from '../rules/types';
import type { ChessEngine } from '../engine';
@ -38,6 +39,10 @@ interface GameEngineState {
refresh: () => void;
activations: PresetActivation[];
setPresets: (activations: PresetActivation[]) => void;
modifierProposal?: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
modifierRejectionMessage?: string | null;
isProposer?: boolean;
sendConsent?: (decision: 'approve' | 'reject') => void;
}
interface GameViewProps {
@ -140,7 +145,16 @@ function GameLayout({
refresh,
activations,
setPresets,
} = state;
modifierProposal,
modifierRejectionMessage,
isProposer,
sendConsent,
} = state as GameEngineState & {
modifierProposal?: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
modifierRejectionMessage?: string | null;
isProposer?: boolean;
sendConsent?: (decision: 'approve' | 'reject') => void;
};
const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
applyMove(from, to, promoteTo || 'queen');
@ -252,6 +266,13 @@ function GameLayout({
setPresets={setPresets}
onRulesChanged={refresh}
/>
<ModifierProposalDialog
proposal={modifierProposal || null}
rejectionMessage={modifierRejectionMessage || null}
isProposer={isProposer ?? false}
onApprove={() => sendConsent?.('approve')}
onReject={() => sendConsent?.('reject')}
/>
<div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4">
{/* Header/Info section */}

View file

@ -0,0 +1,143 @@
import { useEffect, useState } from 'react';
import type { ModifierProfileWire, InstanceModifierWire, TypeModifierWire } from '../net/types.js';
import { MODIFIER_REGISTRY } from '../modifiers/registry.js';
import type { ModifierKindId } from '../modifiers/types.js';
export interface ModifierProposalDialogProps {
proposal: { profile: ModifierProfileWire; expiresAt: number; proposer: string } | null;
onApprove: () => void;
onReject: () => void;
rejectionMessage?: string | null;
isProposer?: boolean;
}
export function ModifierProposalDialog({
proposal,
onApprove,
onReject,
rejectionMessage,
isProposer = false,
}: ModifierProposalDialogProps) {
const [now, setNow] = useState(Date.now());
useEffect(() => {
if (!proposal && !rejectionMessage && !isProposer) return;
// Update countdown timer every 100ms for smooth ticking
const interval = setInterval(() => setNow(Date.now()), 100);
return () => clearInterval(interval);
}, [proposal, rejectionMessage, isProposer]);
if (rejectionMessage) {
return (
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-[100]">
<div
data-testid="proposal-rejected-toast"
className="px-4 py-2 bg-red-50 border border-red-200 text-red-700 text-sm font-medium rounded-md shadow-sm flex items-center gap-2"
>
<span>✕</span>
{rejectionMessage}
</div>
</div>
);
}
if (!proposal) return null;
const remainingSeconds = Math.max(0, Math.ceil((proposal.expiresAt - now) / 1000));
if (isProposer) {
return (
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-[100]">
<div
data-testid="proposal-pending-toast"
className="px-4 py-2 bg-blue-50 border border-blue-200 text-blue-700 text-sm font-medium rounded-md shadow-sm flex items-center gap-2"
>
<div className="w-3 h-3 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
<span>Proposal sent — waiting for opponent. Expires in {remainingSeconds}s.</span>
</div>
</div>
);
}
// Determine top 5 modifiers to show as summary
const allModifiers: (TypeModifierWire | InstanceModifierWire)[] = [
...(proposal.profile.perType || []),
...(proposal.profile.perInstance || [])
];
const previewModifiers = allModifiers.slice(0, 5).map((mod, i) => {
const descriptor = MODIFIER_REGISTRY.get(mod.kind as ModifierKindId);
if (!descriptor) return null;
const desc = (descriptor.describe as (v: unknown) => string)(mod.value);
const target = 'square' in mod ? `Square ${mod.square}` : `${(mod as TypeModifierWire).color} ${(mod as TypeModifierWire).pieceType}`;
return (
<li key={i} className="text-sm text-neutral-600 flex justify-between gap-4 border-b border-neutral-100 py-1 last:border-0">
<span className="font-medium">{target}</span>
<span className="text-right text-neutral-800">{desc}</span>
</li>
);
});
return (
<div
data-testid="proposal-dialog"
className="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md overflow-hidden flex flex-col animate-in fade-in zoom-in-95 duration-200">
<div className="px-6 py-4 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-lg font-bold text-neutral-900 flex items-center gap-2">
<span className="capitalize">{proposal.proposer}</span> proposes a rule change
</h2>
</div>
<div className="px-6 py-4 flex flex-col gap-4">
<div>
<h3 className="font-semibold text-neutral-800 text-lg mb-1">{proposal.profile.name || "Untitled Profile"}</h3>
<p className="text-sm text-neutral-500">
{proposal.profile.perType?.length || 0} piece type rules, {proposal.profile.perInstance?.length || 0} specific square rules
</p>
</div>
{allModifiers.length > 0 && (
<div className="bg-neutral-50 rounded-lg p-3 border border-neutral-200">
<h4 className="text-xs font-bold text-neutral-500 uppercase tracking-wider mb-2">Changes included</h4>
<ul className="flex flex-col">
{previewModifiers}
{allModifiers.length > 5 && (
<li className="text-xs text-neutral-500 italic mt-2 text-center">
+ {allModifiers.length - 5} more
</li>
)}
</ul>
</div>
)}
</div>
<div className="px-6 py-4 border-t border-neutral-200 bg-neutral-50 flex items-center justify-between">
<div className="text-sm font-medium text-neutral-500">
Expires in <span className={remainingSeconds <= 10 ? "text-red-500 font-bold" : "text-neutral-700"}>{remainingSeconds}s</span>
</div>
<div className="flex gap-2">
<button
data-testid="proposal-reject"
onClick={onReject}
className="px-4 py-2 text-sm font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-50 transition-colors"
>
Reject
</button>
<button
data-testid="proposal-approve"
onClick={onApprove}
className="px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
>
Approve
</button>
</div>
</div>
</div>
</div>
);
}