Keep admin and seat recovery notices readable
Show progress during initial admin and seat-control submissions; retain exact recovery instructions for uncertain requests and explicit retries. Keep seat recovery and handover confirmations above the table until Continue without blocking gameplay. Use readable 16px text and 44px controls. Verified isolated checkpoint: types, lint, 5381 offline tests, 52 HTTP tests and build. Local delayed/lost-response browser QA covered admin creation, durable retry, handover and seat recovery, mobile readability and saved-seat restoration. Existing 2098 rooms and 4455 seats preserved. Deployment acceptance follows separately.
This commit is contained in:
1 parent
e918e2e0c0
commit
137f9223f1
15 files changed
+134
-31
No files matched your search
@@ -74,7 +74,9 @@ New create/join requests save a private, tab-scoped retry record before sending.
|
||||
|
||||
Normal creation/join shows brief progress; recovery instructions stay open when
|
||||
attention is needed. Completed-action notices wait for **Continue** so their text
|
||||
can be read at your pace. See [readable notices](docs/READABLE_NOTICES.md).
|
||||
can be read at your pace. Seat recovery and handover confirmations also wait for
|
||||
**Continue**; admin and seat-control requests show recovery instructions only
|
||||
when needed. See [readable notices](docs/READABLE_NOTICES.md).
|
||||
|
||||
An uncertain saved-kit recovery keeps its exact retry details in memory and prevents conflicting navigation. If that recovery has become obsolete, deliberate abandonment releases the controls after explaining that it cannot undo a completed recovery. Keep a valid saved kit before discarding retry details.
|
||||
|
||||
|
||||
+11
-4
@@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { TableTalk } from '@/components/table-talk';
|
||||
import { RemovedRoomNotice } from '@/components/removed-room-notice';
|
||||
import { GameTable } from '@/components/game-table';
|
||||
import { SeatConfirmation, type SeatConfirmationKind } from '@/components/seat-confirmation';
|
||||
import { RulesetControls } from '@/components/ruleset-controls';
|
||||
import {
|
||||
SeatHandoverSetup,
|
||||
@@ -78,6 +79,7 @@ export default function Home() {
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [seatConfirmation, setSeatConfirmation] = useState<SeatConfirmationKind | null>(null);
|
||||
const seatClaimUncertain = useRef(false);
|
||||
const [claimUncertain, setClaimUncertain] = useState(false);
|
||||
const pendingEntry = useRef<RoomEntryAttempt | null>(null);
|
||||
@@ -494,6 +496,7 @@ export default function Home() {
|
||||
setBotRecovery(false);
|
||||
setConnection('');
|
||||
setGame(null);
|
||||
setSeatConfirmation(null);
|
||||
setRemovedRoom(null);
|
||||
setRestoreFailed(false);
|
||||
setNotice('');
|
||||
@@ -627,7 +630,7 @@ export default function Home() {
|
||||
setBusy(pending);
|
||||
}
|
||||
|
||||
function restoredSeat(view: GameView, claim = false) {
|
||||
function restoredSeat(view: GameView, claim = false, confirmation?: SeatConfirmationKind) {
|
||||
if (removedRooms.current.has(view.code)) return false;
|
||||
if (
|
||||
!claim &&
|
||||
@@ -635,6 +638,7 @@ export default function Home() {
|
||||
)
|
||||
return;
|
||||
if (claim) {
|
||||
setSeatConfirmation(confirmation ?? null);
|
||||
setHandoverPending(true);
|
||||
setAiPermissionPending(true);
|
||||
activeRoom.current = view.code;
|
||||
@@ -701,7 +705,7 @@ export default function Home() {
|
||||
<SeatHandoverClaim
|
||||
onCancel={() => window.location.reload()}
|
||||
onRestored={(view) => {
|
||||
if (restoredSeat(view, true)) setShowHandover(false);
|
||||
if (restoredSeat(view, true, 'handover')) setShowHandover(false);
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
@@ -709,6 +713,9 @@ export default function Home() {
|
||||
if (game)
|
||||
return (
|
||||
<>
|
||||
{seatConfirmation && !roomUnavailable && <div className="table-shell">
|
||||
<SeatConfirmation kind={seatConfirmation} onContinue={() => setSeatConfirmation(null)} />
|
||||
</div>}
|
||||
{roomUnavailable ? <main className="table-shell"><RemovedRoomNotice code={game.code} busy={busy || checkingConnection} onRetry={() => void reconnect()} onExit={exitTable} exitDisabled={handoverPending || aiPermissionPending || claimUncertain} /></main> : <GameTable
|
||||
game={game}
|
||||
send={send}
|
||||
@@ -956,7 +963,7 @@ export default function Home() {
|
||||
disabled={busy || checkingConnection}
|
||||
onPending={controlPending}
|
||||
onUncertain={controlUncertain}
|
||||
onRestored={(view) => restoredSeat(view, true)}
|
||||
onRestored={(view) => restoredSeat(view, true, 'recovery')}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
@@ -1148,7 +1155,7 @@ export default function Home() {
|
||||
disabled={busy || needsReconcile || checkingConnection}
|
||||
onPending={controlPending}
|
||||
onUncertain={controlUncertain}
|
||||
onRestored={(view) => restoredSeat(view, true)}
|
||||
onRestored={(view) => restoredSeat(view, true, 'recovery')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -31,6 +31,7 @@ export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpda
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(false), inFlight = useRef(false);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
@@ -101,7 +102,7 @@ export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpda
|
||||
input = saved ?? newAdminLobbyRequest(lobby, action, reason);
|
||||
saveAdminLobbyRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving lobby change…');
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving lobby change…');
|
||||
try {
|
||||
const result = adminLobbyConfirmation(await requestJson<unknown>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
@@ -127,7 +128,7 @@ export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpda
|
||||
if (alive.current) setLobby(current);
|
||||
} catch { /* Preserve the original outcome and retry instructions. */ }
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
|
||||
}
|
||||
function discardUnreadableRecord() {
|
||||
if (!confirmed || busy) return;
|
||||
@@ -152,7 +153,7 @@ export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpda
|
||||
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Removing it does not cancel a change already saved on the server.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} onChange={e => setConfirmed(e.target.checked)} />I have inspected the current lobby and want to remove the local retry record.</label>
|
||||
<Button disabled={busy || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
|
||||
</div> : pending ? <div className="admin-confirm">
|
||||
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
|
||||
<p>Saved request for {code}: {describeAdminLobbyAction(pending.action, lobby)}.</p><p>Reason: {pending.reason}</p>
|
||||
<p>The exact request stays in this tab across refresh. Retrying confirms an already completed change without applying it again, including after the game has started.</p>
|
||||
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved lobby request</Button>
|
||||
|
||||
@@ -23,6 +23,7 @@ export function AdminRoomArchive({ accountId, code, canManage, onClose, onUpdate
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(false), inFlight = useRef(false);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
@@ -69,7 +70,7 @@ export function AdminRoomArchive({ accountId, code, canManage, onClose, onUpdate
|
||||
input = saved ?? newAdminArchiveRequest(room!, !room!.archived, reason);
|
||||
saveAdminArchiveRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice(input.archived ? 'Archiving room…' : 'Unarchiving room…');
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice(input.archived ? 'Archiving room…' : 'Unarchiving room…');
|
||||
try {
|
||||
const result = adminArchiveConfirmation(await requestJson<unknown>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
@@ -96,7 +97,7 @@ export function AdminRoomArchive({ accountId, code, canManage, onClose, onUpdate
|
||||
if (alive.current) setRoom(current);
|
||||
} catch { /* Preserve the original outcome and saved retry. */ }
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
|
||||
}
|
||||
|
||||
function discardUnreadableRecord() {
|
||||
@@ -121,7 +122,7 @@ export function AdminRoomArchive({ accountId, code, canManage, onClose, onUpdate
|
||||
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a server change.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !room} onChange={e => setConfirmed(e.target.checked)} />I have inspected room {code} and want to remove its local retry record.</label>
|
||||
<Button disabled={busy || !room || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
|
||||
</div> : pending ? <div className="admin-confirm">
|
||||
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
|
||||
<p>Saved request: <strong>{pending.archived ? 'Archive' : 'Unarchive'} room {code}</strong>.</p><p>Reason: {pending.reason}</p>
|
||||
<p>The exact request stays in this tab across refresh. Retrying confirms the original change without applying it again; the current archive status may differ after another administrator’s action.</p>
|
||||
<p>After refresh or sign-in, choose All rooms in both the Directory and Archive filters to find the room and reopen these controls.</p>
|
||||
|
||||
@@ -23,6 +23,7 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(false), inFlight = useRef(false);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
@@ -69,7 +70,7 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate
|
||||
input = saved ?? newAdminClosureRequest(room!, !room!.closed, reason);
|
||||
saveAdminClosureRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice(input.closed ? 'Closing room…' : 'Reopening room…');
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice(input.closed ? 'Closing room…' : 'Reopening room…');
|
||||
try {
|
||||
const result = adminClosureConfirmation(await requestJson<unknown>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
@@ -96,7 +97,7 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate
|
||||
if (alive.current) setRoom(current);
|
||||
} catch { /* Preserve the original outcome and saved retry. */ }
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
|
||||
}
|
||||
|
||||
function discardUnreadableRecord() {
|
||||
@@ -121,7 +122,7 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate
|
||||
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a change already saved on the server.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !room} onChange={e => setConfirmed(e.target.checked)} />I have inspected room {code} and want to remove its local retry record.</label>
|
||||
<Button disabled={busy || !room || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
|
||||
</div> : pending ? <div className="admin-confirm">
|
||||
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
|
||||
<p>Saved request: <strong>{pending.closed ? 'Close' : 'Reopen'} room {code}</strong>.</p><p>Reason: {pending.reason}</p>
|
||||
<p>The exact request stays in this tab across refresh and reopening these controls. Retrying confirms a completed change without applying it again. Current availability can differ if another administrator changed the room afterward.</p>
|
||||
<p>After refreshing the page or signing in again, choose All rooms in both the Directory and Archive filters to find this room and reopen these controls.</p>
|
||||
|
||||
@@ -34,6 +34,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(false);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
@@ -84,7 +85,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat
|
||||
input = saved ?? newAdminRoomRequest(control, paused, joinLocked, reason);
|
||||
saveAdminRoomRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving room settings…');
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving room settings…');
|
||||
try {
|
||||
const result = await requestJson<RoomControl & { replayed: boolean }>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
@@ -116,7 +117,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat
|
||||
} catch { /* Preserve the original outcome and retry instructions. */ }
|
||||
}
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
|
||||
}
|
||||
|
||||
function discardUnreadableRecord() {
|
||||
@@ -141,7 +142,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat
|
||||
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Removing it does not cancel a change already saved on the server.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} onChange={e => setConfirmed(e.target.checked)} />I have inspected the current settings and want to remove the local retry record.</label>
|
||||
<Button disabled={busy || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
|
||||
</div> : pending ? <div className="admin-confirm">
|
||||
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
|
||||
<p>Saved request: {pending.paused ? 'pause' : 'resume'} room {code}; {pending.joinLocked ? 'lock' : 'open'} new joins.</p>
|
||||
<p>Reason: {pending.reason}</p>
|
||||
<p>The exact request is kept in this tab across refresh. Retrying confirms an already completed operation without applying it again.</p>
|
||||
|
||||
@@ -28,6 +28,7 @@ export function AdminRoomCreation({ accountId, onClose, onCreated, onDenied, onB
|
||||
const [notice, setNotice] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const alive = useRef(false), inFlight = useRef(false);
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
@@ -51,7 +52,7 @@ export function AdminRoomCreation({ accountId, onClose, onCreated, onDenied, onB
|
||||
input = saved ?? newAdminRoomCreationRequest({ name, faction, advanced, techTokens, strongholdCards, bots, reason });
|
||||
saveAdminRoomCreation(window.sessionStorage, accountId, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true);
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true);
|
||||
setRecord({ kind: 'pending', input }); setNotice('Creating your room…');
|
||||
try {
|
||||
const result = await requestJson<AdminRoomCreationResult>('/api/admin/rooms', {
|
||||
@@ -82,7 +83,7 @@ export function AdminRoomCreation({ accountId, onClose, onCreated, onDenied, onB
|
||||
setNotice(requestMayHaveCompleted(error) ? `${message(error)} The room may already exist. Retry the saved request to confirm it.` : message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
|
||||
}
|
||||
|
||||
function clearRecord() {
|
||||
@@ -107,7 +108,7 @@ export function AdminRoomCreation({ accountId, onClose, onCreated, onDenied, onB
|
||||
<p>A room may already have been created. Check the directory before removing this unreadable record. Removing it cannot cancel creation and may lose this tab’s host-seat retry proof.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} onChange={event => setConfirmed(event.target.checked)} />I checked the directory and want to discard this local record.</label>
|
||||
<Button disabled={!confirmed} onClick={clearRecord}>Discard unreadable local record</Button>
|
||||
</div> : record?.kind === 'pending' ? <div className="admin-confirm">
|
||||
</div> : initialRequestPending ? null : record?.kind === 'pending' ? <div className="admin-confirm">
|
||||
<p>Unconfirmed room: host <strong>{record.input.name}</strong> · {record.input.advanced ? 'Advanced preview' : 'Basic'} · {record.input.bots.length} AI players.</p>
|
||||
<p>The exact request is kept privately in this tab across refresh. Keep the tab and its storage until the result is confirmed. Retrying finds the same room.</p>
|
||||
<Button disabled={busy} onClick={() => void submit(record.input)}>Retry saved room creation</Button>
|
||||
|
||||
@@ -23,6 +23,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(false), inFlight = useRef(false);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
@@ -69,7 +70,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate
|
||||
input = saved ?? newAdminRemovalRequest(room!, !room!.removed, reason);
|
||||
saveAdminRemovalRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice(input.removed ? 'Removing room…' : 'Restoring room…');
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice(input.removed ? 'Removing room…' : 'Restoring room…');
|
||||
try {
|
||||
const result = adminRemovalConfirmation(await requestJson<unknown>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
@@ -96,7 +97,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate
|
||||
if (alive.current) setRoom(current);
|
||||
} catch { /* Preserve the original outcome and saved retry. */ }
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } }
|
||||
}
|
||||
|
||||
function discardUnreadableRecord() {
|
||||
@@ -121,7 +122,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate
|
||||
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Removing the record does not cancel a change already saved on the server.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !room} onChange={e => setConfirmed(e.target.checked)} />I have inspected room {code} and want to remove its local retry record.</label>
|
||||
<Button disabled={busy || !room || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
|
||||
</div> : pending ? <div className="admin-confirm">
|
||||
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
|
||||
<p>Saved request: <strong>{pending.removed ? 'Remove' : 'Restore'} room {code}</strong>.</p><p>Reason: {pending.reason}</p>
|
||||
<p>The exact request stays in this tab across refresh and reopening these controls. Retrying confirms a completed change without applying it again. Current availability can differ if another administrator changed the room afterward.</p>
|
||||
<p>After refreshing the page or signing in again, choose All rooms in both the Directory and Archive filters to find this room and reopen these controls.</p>
|
||||
|
||||
@@ -35,6 +35,7 @@ export function SeatAiDelegation({
|
||||
const [record, setRecord] = useState<SeatAiDelegateRequest | null>(null);
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [abandon, setAbandon] = useState(false);
|
||||
const [delegateId, setDelegateId] = useState('');
|
||||
@@ -86,6 +87,7 @@ export function SeatAiDelegation({
|
||||
if (locked || pending.current || (closed && !record)) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
setInitialRequestPending(!record);
|
||||
setExpanded(true);
|
||||
setAbandon(false);
|
||||
onPending(true);
|
||||
@@ -148,6 +150,7 @@ export function SeatAiDelegation({
|
||||
);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setInitialRequestPending(false);
|
||||
setBusy(false);
|
||||
onPending(false);
|
||||
}
|
||||
@@ -175,7 +178,7 @@ export function SeatAiDelegation({
|
||||
your own AI control, or recovering or transferring either seat
|
||||
invalidates unused permission.
|
||||
</p>
|
||||
{record || storageProblem ? (
|
||||
{initialRequestPending ? <output className="block">Saving AI permission…</output> : record || storageProblem ? (
|
||||
<section
|
||||
aria-label="Unconfirmed AI permission request"
|
||||
className="flex flex-col gap-3"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export type SeatConfirmationKind = 'recovery' | 'handover';
|
||||
|
||||
/** Local acknowledgement only; reconnects and game actions never dismiss it. */
|
||||
export function SeatConfirmation({ kind, onContinue }: { kind: SeatConfirmationKind; onContinue: () => void }) {
|
||||
return <section className="notice" style={{ fontSize: '1rem', lineHeight: 1.5 }} aria-label="Seat confirmation">
|
||||
<h2>{kind === 'recovery' ? 'Seat recovered' : 'Seat handover complete'}</h2>
|
||||
<output className="block">{kind === 'recovery'
|
||||
? 'Your seat is recovered in this browser. Previous browser sessions for this seat have been revoked.'
|
||||
: 'You now control this seat and its saved progress. The previous owner’s access has been revoked. Use Protect your saved seat to create your own recovery kit.'}</output>
|
||||
<Button type="button" variant="outline" className="mt-3 min-h-11" style={{ fontSize: '1rem' }} onClick={onContinue}>Continue</Button>
|
||||
</section>;
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export function SeatHandoverSetup({
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [replaceAcknowledged, setReplaceAcknowledged] = useState(false);
|
||||
const closed = !!game.roomControl?.closed;
|
||||
const own = game.players.find((player) => player.id === game.me);
|
||||
@@ -101,6 +102,7 @@ export function SeatHandoverSetup({
|
||||
pending.current = true;
|
||||
setReplaceAcknowledged(false);
|
||||
setBusy(true);
|
||||
setInitialRequestPending(!record?.operation);
|
||||
onPending(true);
|
||||
try {
|
||||
store({ kit, expiresAt: record?.expiresAt ?? null, operation });
|
||||
@@ -151,6 +153,7 @@ export function SeatHandoverSetup({
|
||||
);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setInitialRequestPending(false);
|
||||
setBusy(false);
|
||||
onPending(false);
|
||||
}
|
||||
@@ -172,7 +175,7 @@ export function SeatHandoverSetup({
|
||||
share the kit with the intended recipient.
|
||||
</p>
|
||||
{closed && <p>The room is closed. New handovers and cancellations are stopped; exact saved requests can still confirm a completed change.</p>}
|
||||
{!record ? (
|
||||
{initialRequestPending ? <output className="block">Saving handover…</output> : !record ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || closed || busy || !loaded}
|
||||
@@ -308,6 +311,7 @@ export function SeatHandoverClaim({
|
||||
const [stored, setStored] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initialRequestPending, setInitialRequestPending] = useState(false);
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
useEffect(() => {
|
||||
@@ -333,6 +337,7 @@ export function SeatHandoverClaim({
|
||||
if (!claim || pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
setInitialRequestPending(!stored);
|
||||
try {
|
||||
saveHandoverClaim(sessionStorage, claim);
|
||||
setStored(true);
|
||||
@@ -366,6 +371,7 @@ export function SeatHandoverClaim({
|
||||
);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setInitialRequestPending(false);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -434,7 +440,7 @@ export function SeatHandoverClaim({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{stored && (
|
||||
{stored && !initialRequestPending && (
|
||||
<>
|
||||
<p>
|
||||
Abandoning removes this tab’s exact retry proof. It cannot undo a
|
||||
|
||||
+12
-2
@@ -1,11 +1,18 @@
|
||||
# Current development status
|
||||
|
||||
Updated 22 September 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
Updated 26 September 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
is playable and the six classic factions have a user-authorized Advanced preview.
|
||||
Expansion starts and full rules/publication acceptance remain gated.**
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
The [readable-notice follow-up](READABLE_NOTICES.md) extends first-request progress
|
||||
to admin operations, AI permissions and seat handovers, preventing recovery
|
||||
instructions from flashing before a successful response. Seat recovery/handover
|
||||
confirmations remain above the table until Continue. Existing retry proofs and
|
||||
rules/AI pacing are unchanged. New-revision deployed acceptance remains separate
|
||||
from local checks; positive production admin testing still needs authorized access.
|
||||
|
||||
Current work adds [administrative archive/unarchive](ADMIN_ROOM_ARCHIVE.md):
|
||||
closed rooms can leave the default directory without changing saved games,
|
||||
credentials, discussion or AI deadlines. Unarchiving leaves play closed;
|
||||
@@ -19,7 +26,10 @@ groups and lost-response desktop/mobile browser QA. It is deployed on the NAS:
|
||||
container verification passed, migration 0013 applied, all eight prior rooms and
|
||||
ten seats were preserved, and public HTTPS/browser saved-seat restoration passed.
|
||||
Anonymous admin routes remain denied. Positive production administrator acceptance
|
||||
still awaits authorized QA access; archive/unarchive is not deployed yet.
|
||||
still awaits authorized QA access. Archive checkpoint `e918e2e` is also deployed:
|
||||
migration 0014, integrity and public HTTPS/browser saved-seat restoration passed;
|
||||
all eight prior rooms and ten seats were preserved. Its positive production
|
||||
administrator acceptance remains pending the same authorization.
|
||||
|
||||
Checkpoint `238de38` added [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md)
|
||||
and passed types/lint, 5,314 offline tests, 52 HTTP tests and build, with fourteen
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 September 2026 — Readable admin and saved-seat notices
|
||||
|
||||
First administrator and seat-control submissions show progress while the request
|
||||
is in flight; saved/uncertain requests retain their recovery controls. This
|
||||
extends the room-entry fix to admin creation, controls, lobby changes, removal,
|
||||
closure, archive, AI permissions and handovers. Successful recovery and handover
|
||||
confirmations remain above the table until Continue, including after polling or
|
||||
game actions. No game action waits for that acknowledgement.
|
||||
|
||||
Independent read-only review found no material issues. Focused notice/entry
|
||||
regressions and delayed/lost-response browser checks accompany the checkpoint;
|
||||
broad checks and deployed evidence are recorded separately. The complete admin
|
||||
panel and rules goal remain unfinished. See [readable notices](READABLE_NOTICES.md).
|
||||
|
||||
## 22 September 2026 — Administrative archive/unarchive prototype
|
||||
|
||||
[Archive or unarchive](ADMIN_ROOM_ARCHIVE.md) organizes closed rooms without
|
||||
|
||||
@@ -21,9 +21,23 @@ cannot replace the text being read. The optional toggle, bounded summaries,
|
||||
private-information filtering and silent refresh baseline remain; see
|
||||
[completed-action feedback](ACTION_FEEDBACK.md).
|
||||
|
||||
The notice audit found no other timer removing substantive text in app/component
|
||||
flows. Errors and recovery prompts have explicit controls; component inspectors
|
||||
have close buttons. Network progress, polling deadlines and permission-expiry
|
||||
The follow-up audit also covers instructions replaced by a quick server response,
|
||||
even without a dismissal timer. Initial administrator creation, room/lobby controls,
|
||||
removal, closure and archive requests now show only progress while awaiting their
|
||||
first response. The same applies to AI permissions and handover creation,
|
||||
cancellation and acceptance. Saved requests restored after refresh and uncertain
|
||||
results retain their recovery controls; explicitly retrying leaves those controls
|
||||
visible and disabled until the response. Private retry proofs are still saved
|
||||
before dispatch, independently of whether recovery instructions are visible.
|
||||
|
||||
Successful seat recovery and handover show a confirmation above the table with
|
||||
**Continue**, including the previous owner's/session's revoked access. It remains
|
||||
while the player reads or plays; background polling and new actions do not clear
|
||||
it. Continue dismisses only the message. Leaving or reloading the page clears this
|
||||
local confirmation, without changing the recovered seat.
|
||||
|
||||
Errors and recovery prompts have explicit controls; component inspectors have
|
||||
close buttons. Network progress, polling deadlines and permission-expiry
|
||||
countdowns keep their existing behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Children, isValidElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SeatConfirmation } from '../components/seat-confirmation';
|
||||
import { Button } from '../components/ui/button';
|
||||
|
||||
for (const kind of ['recovery', 'handover'] as const) {
|
||||
void test(`${kind} confirmation explains revoked access and only acknowledges on Continue`, () => {
|
||||
let acknowledged = 0;
|
||||
const notice = SeatConfirmation({ kind, onContinue: () => { acknowledged++; } });
|
||||
const markup = renderToStaticMarkup(notice);
|
||||
assert.match(markup, /<output/);
|
||||
assert.match(markup, /revoked/);
|
||||
assert.match(markup, /font-size:1rem/);
|
||||
assert.match(markup, /<button[^>]*type="button"[^>]*>Continue<\/button>/);
|
||||
assert.doesNotMatch(markup, /autofocus|aria-modal/);
|
||||
if (kind === 'handover') assert.match(markup, /Protect your saved seat/);
|
||||
assert.equal(acknowledged, 0);
|
||||
const button = Children.toArray(notice.props.children).filter(isValidElement).find(child => child.type === Button);
|
||||
assert.ok(button);
|
||||
(button.props as { onClick: () => void }).onClick();
|
||||
assert.equal(acknowledged, 1);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user