Keep popup instructions readable until continued
Show concise progress during first room create/join; reserve persistent recovery instructions for saved/uncertain attempts. Retain completed-action text until a local Continue button, without gating rules or AI. Preserve unread text across backgrounding, bounded summaries, mute and private-safe feedback. Fix the container QA helper path-import collision. Verified: typecheck, lint, 5200 offline tests, 52 HTTP tests and production build. Delayed/lost-response browser checks prove progress, refresh recovery and exact retry without duplicates. Notice stayed for over 47 seconds while the QA game progressed; Continue advances only presentation. Mobile 16px/44px controls verified. Existing games preserved. Container/deployed acceptance follows publication.
This commit is contained in:
1 parent
c2d392e155
commit
f29d05620c
10 files changed
+238
-115
No files matched your search
@@ -61,6 +61,10 @@ for saved retries and limits.
|
|||||||
|
|
||||||
New create/join requests save a private, tab-scoped retry record before sending. If the response is uncertain, keep the tab open and use the explicit retry; refresh preserves the same request. Confirmed success removes that record. Clearly rejected first requests return to the form for correction. A retry can recover the original room and seat without creating duplicates, but closing the tab or clearing its data can lose the proof. This record is separate from the long-term seat recovery kit.
|
New create/join requests save a private, tab-scoped retry record before sending. If the response is uncertain, keep the tab open and use the explicit retry; refresh preserves the same request. Confirmed success removes that record. Clearly rejected first requests return to the form for correction. A retry can recover the original room and seat without creating duplicates, but closing the tab or clearing its data can lose the proof. This record is separate from the long-term seat recovery kit.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Use **Pass your seat to another player** for a voluntary one-time transfer. The private offer lasts 24 hours; acceptance preserves progress and revokes the old owner’s sessions and recovery kits. The recipient uses **Accept a seat handover** on the home page, then creates their own recovery kit. Keep the tab open until acceptance is confirmed; exact private retry details survive refresh in that tab. See [seat handover](docs/SEAT_HANDOVER.md) for cancellation, retry behavior and current limits.
|
Use **Pass your seat to another player** for a voluntary one-time transfer. The private offer lasts 24 hours; acceptance preserves progress and revokes the old owner’s sessions and recovery kits. The recipient uses **Accept a seat handover** on the home page, then creates their own recovery kit. Keep the tab open until acceptance is confirmed; exact private retry details survive refresh in that tab. See [seat handover](docs/SEAT_HANDOVER.md) for cancellation, retry behavior and current limits.
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export default function Home() {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [entryProblem, setEntryProblem] = useState('');
|
const [entryProblem, setEntryProblem] = useState('');
|
||||||
|
const [initialEntryPending, setInitialEntryPending] = useState(false);
|
||||||
const [showEntryRecovery, setShowEntryRecovery] = useState(false);
|
const [showEntryRecovery, setShowEntryRecovery] = useState(false);
|
||||||
const [showHandover, setShowHandover] = useState(false);
|
const [showHandover, setShowHandover] = useState(false);
|
||||||
const [handoverPending, setHandoverPending] = useState(true);
|
const [handoverPending, setHandoverPending] = useState(true);
|
||||||
@@ -407,6 +408,7 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (firstDispatch) setInitialEntryPending(false);
|
||||||
mutationPending.current = false;
|
mutationPending.current = false;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -443,6 +445,7 @@ export default function Home() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingEntry.current = attempt;
|
pendingEntry.current = attempt;
|
||||||
|
setInitialEntryPending(true);
|
||||||
setEntryAttempt(attempt);
|
setEntryAttempt(attempt);
|
||||||
setAbandonAcknowledged(false);
|
setAbandonAcknowledged(false);
|
||||||
await dispatchEntry(attempt, true);
|
await dispatchEntry(attempt, true);
|
||||||
@@ -808,6 +811,20 @@ export default function Home() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
// A first request is progress, not recovery. Keep the actionable recovery
|
||||||
|
// instructions on screen only after uncertainty or a saved-request restore.
|
||||||
|
if (initialEntryPending && entryAttempt)
|
||||||
|
return (
|
||||||
|
<main className="table-shell" aria-busy="true">
|
||||||
|
<header className="masthead">
|
||||||
|
<div className="wordmark">DUNE<span>ARRAKIS TABLE</span></div>
|
||||||
|
</header>
|
||||||
|
<section className="notice">
|
||||||
|
<h1>{entryAttempt.kind === 'create' ? 'Creating your room…' : 'Joining the room…'}</h1>
|
||||||
|
<output aria-live="polite">Please wait.</output>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
if (entryAttempt || entryProblem)
|
if (entryAttempt || entryProblem)
|
||||||
return (
|
return (
|
||||||
<main className="table-shell" aria-busy={busy}>
|
<main className="table-shell" aria-busy={busy}>
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useCallback,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useReducer,
|
||||||
useState,
|
|
||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Bell, BellOff } from 'lucide-react';
|
import { Bell, BellOff } from 'lucide-react';
|
||||||
import { FACTIONS } from '@/game/catalog';
|
import { FACTIONS } from '@/game/catalog';
|
||||||
import { advanceActionNotices, type AutomaticActionEvent, type QueuedActionNotice } from '@/lib/action-notice-queue';
|
import { initialActionNotices, updateActionNotices, type AutomaticActionEvent, type QueuedActionNotice } from '@/lib/action-notice-queue';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
export type { AutomaticActionEvent } from '@/lib/action-notice-queue';
|
export type { AutomaticActionEvent } from '@/lib/action-notice-queue';
|
||||||
@@ -42,89 +40,28 @@ export function AutomaticActionNotice({
|
|||||||
readPreference,
|
readPreference,
|
||||||
serverPreference,
|
serverPreference,
|
||||||
);
|
);
|
||||||
// The first snapshot establishes a baseline. Refreshing never replays history.
|
const [presentation, updatePresentation] = useReducer(updateActionNotices, events, initialActionNotices);
|
||||||
const seenSeq = useRef(
|
|
||||||
events.reduce((last, event) => Math.max(last, event.seq), -1),
|
|
||||||
);
|
|
||||||
const queue = useRef<QueuedActionNotice[]>([]);
|
|
||||||
const active = useRef<QueuedActionNotice | null>(null);
|
|
||||||
const [notice, setNotice] = useState<
|
|
||||||
(QueuedActionNotice & { fading: boolean }) | null
|
|
||||||
>(null);
|
|
||||||
const [completed, setCompleted] = useState(0);
|
|
||||||
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
||||||
const clearTimers = useCallback(() => {
|
|
||||||
for (const timer of timers.current) clearTimeout(timer);
|
|
||||||
timers.current = [];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const showNext = useCallback(() => {
|
|
||||||
if (active.current) return;
|
|
||||||
if (!readPreference() || document.visibilityState === 'hidden') {
|
|
||||||
queue.current = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const next = queue.current.shift();
|
|
||||||
if (!next) return;
|
|
||||||
active.current = next;
|
|
||||||
clearTimers();
|
|
||||||
timers.current = [
|
|
||||||
setTimeout(() => {
|
|
||||||
setNotice({ ...next, fading: false });
|
|
||||||
}, 0),
|
|
||||||
setTimeout(() => {
|
|
||||||
setNotice((current) =>
|
|
||||||
current?.id === next.id ? { ...current, fading: true } : current,
|
|
||||||
);
|
|
||||||
}, 1250),
|
|
||||||
setTimeout(() => {
|
|
||||||
active.current = null;
|
|
||||||
setNotice(null);
|
|
||||||
setCompleted((count) => count + 1);
|
|
||||||
}, 1500),
|
|
||||||
];
|
|
||||||
}, [clearTimers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Preserve the visible notice. Summarize older pending actions so a bot
|
updatePresentation({
|
||||||
// batch cannot leave minutes of stale feedback; the chronicle keeps detail.
|
type: 'events', events, enabled,
|
||||||
const next = advanceActionNotices({ seenSeq: seenSeq.current, pending: queue.current }, events, {
|
visible: document.visibilityState !== 'hidden',
|
||||||
active: !!active.current,
|
|
||||||
show: readPreference() && document.visibilityState !== 'hidden',
|
|
||||||
});
|
});
|
||||||
seenSeq.current = next.seenSeq;
|
}, [events, enabled]);
|
||||||
queue.current = next.pending;
|
|
||||||
showNext();
|
|
||||||
}, [events, showNext, completed]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hide = () => {
|
const hide = () => {
|
||||||
if (document.visibilityState !== 'hidden') return;
|
if (document.visibilityState !== 'hidden') return;
|
||||||
clearTimers();
|
updatePresentation({ type: 'hide' });
|
||||||
queue.current = [];
|
|
||||||
active.current = null;
|
|
||||||
setNotice(null);
|
|
||||||
};
|
};
|
||||||
document.addEventListener('visibilitychange', hide);
|
document.addEventListener('visibilitychange', hide);
|
||||||
return () => document.removeEventListener('visibilitychange', hide);
|
return () => document.removeEventListener('visibilitychange', hide);
|
||||||
}, [clearTimers]);
|
}, []);
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
clearTimers();
|
|
||||||
queue.current = [];
|
|
||||||
active.current = null;
|
|
||||||
},
|
|
||||||
[clearTimers],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
const next = !enabled;
|
const next = !enabled;
|
||||||
memoryPreference = next;
|
memoryPreference = next;
|
||||||
clearTimers();
|
updatePresentation({ type: 'clear' });
|
||||||
queue.current = [];
|
|
||||||
active.current = null;
|
|
||||||
setNotice(null);
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(PREFERENCE_KEY, String(next));
|
localStorage.setItem(PREFERENCE_KEY, String(next));
|
||||||
memoryPreference = undefined;
|
memoryPreference = undefined;
|
||||||
@@ -133,9 +70,6 @@ export function AutomaticActionNotice({
|
|||||||
}
|
}
|
||||||
window.dispatchEvent(new Event(PREFERENCE_EVENT));
|
window.dispatchEvent(new Event(PREFERENCE_EVENT));
|
||||||
};
|
};
|
||||||
const house = notice
|
|
||||||
? FACTIONS.find((candidate) => candidate.id === notice.faction)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -155,26 +89,30 @@ export function AutomaticActionNotice({
|
|||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
className="pointer-events-none fixed top-[max(1rem,env(safe-area-inset-top))] left-1/2 z-40 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2"
|
className="pointer-events-none fixed top-[max(1rem,env(safe-area-inset-top))] left-1/2 z-40 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2"
|
||||||
>
|
>
|
||||||
{enabled && notice && (
|
{enabled && presentation.active && (
|
||||||
<span
|
<ActionNoticeCard notice={presentation.active} onContinue={() => updatePresentation({ type: 'continue' })} />
|
||||||
className={`block rounded-xl border border-[#807456] border-l-4 bg-[#141814]/95 px-4 py-3 text-[#f5ecda] shadow-xl transition-opacity duration-250 motion-reduce:opacity-100 motion-reduce:transition-none ${notice.fading ? 'opacity-0' : 'opacity-100'}`}
|
|
||||||
style={{ borderLeftColor: house?.color ?? '#d4b77b' }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="block text-xs font-semibold tracking-wider uppercase"
|
|
||||||
style={{ color: house?.color ?? '#d4b77b' }}
|
|
||||||
>
|
|
||||||
{house?.name ?? 'Automatic action'}
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 block break-words text-sm font-medium leading-5">
|
|
||||||
{notice.name}
|
|
||||||
</span>
|
|
||||||
{notice.count > 1 && <span className="mt-1 block text-sm leading-5 text-[#d6cdb8]">
|
|
||||||
{notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle.
|
|
||||||
</span>}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</output>
|
</output>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The notice alone accepts input; it does not trap or move keyboard focus. */
|
||||||
|
export function ActionNoticeCard({ notice, onContinue }: { notice: QueuedActionNotice; onContinue: () => void }) {
|
||||||
|
const house = FACTIONS.find((candidate) => candidate.id === notice.faction);
|
||||||
|
return <span
|
||||||
|
className="pointer-events-auto block max-h-[calc(100dvh-2rem)] overflow-y-auto rounded-xl border border-[#807456] border-l-4 bg-[#141814]/95 px-4 py-3 text-[#f5ecda] shadow-xl"
|
||||||
|
style={{ borderLeftColor: house?.color ?? '#d4b77b' }}
|
||||||
|
>
|
||||||
|
<span className="block text-sm font-semibold leading-5 tracking-wider uppercase" style={{ color: house?.color ?? '#d4b77b' }}>
|
||||||
|
{house?.name ?? 'Automatic action'}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block break-words text-base font-medium leading-6">{notice.name}</span>
|
||||||
|
{notice.count > 1 && <span className="mt-1 block text-sm leading-5 text-[#d6cdb8]">
|
||||||
|
{notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle.
|
||||||
|
</span>}
|
||||||
|
<Button type="button" variant="outline" className="mt-3 min-h-11 max-w-full whitespace-normal" style={{ fontSize: '1rem' }} onClick={onContinue}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
+23
-11
@@ -15,30 +15,41 @@ its separate mute and volume settings still apply.
|
|||||||
|
|
||||||
## Bursts, refresh and controls
|
## Bursts, refresh and controls
|
||||||
|
|
||||||
Each notice lasts 1.5 seconds and does not intercept pointer or keyboard input.
|
Each notice stays visible until the player chooses **Continue** or switches
|
||||||
|
notices off. Continue dismisses only this local message; it does not submit a
|
||||||
|
game action, delay AI progress or pause a saved decision. Notices do not move
|
||||||
|
keyboard focus or open a modal. Only the notice itself intercepts pointer input;
|
||||||
|
the rest of the table remains usable. Regular text is at least 14 pixels, with
|
||||||
|
a 44-pixel minimum button height and scrolling for a short viewport.
|
||||||
|
|
||||||
The queue holds at most three notices including the visible one. When more
|
The queue holds at most three notices including the visible one. When more
|
||||||
actions arrive, older pending notices merge into a count beside the most recent
|
actions arrive, older pending notices merge into a count beside the most recent
|
||||||
action in that group; their individual details remain in the table chronicle.
|
action in that group; their individual details remain in the table chronicle.
|
||||||
A new burst never replaces the currently displayed notice. After arrivals stop,
|
A new burst never replaces the currently displayed notice. Continue displays
|
||||||
the visible notice and pending queue finish within 4.5 seconds, rather than
|
the next queued notice, if any, until that notice is also dismissed.
|
||||||
retaining minutes of old events.
|
|
||||||
|
|
||||||
Initial and restored snapshots establish a silent baseline. Already-seen events
|
Initial and restored snapshots establish a silent baseline. Already-seen events
|
||||||
do not replay. Events received while notices are off or the page is hidden are
|
do not replay. Events received while notices are off or the page is hidden are
|
||||||
consumed silently. Hiding the page clears any visible or queued notice. The
|
consumed silently. Hiding the page clears pending notices while preserving the
|
||||||
|
unread notice already displayed, so it is still there when the player returns. The
|
||||||
**Automatic action notices** toggle remains cosmetic; it cannot submit a game
|
**Automatic action notices** toggle remains cosmetic; it cannot submit a game
|
||||||
action or change a saved decision. Reduced motion removes the fade.
|
action or change a saved decision. Notices have no timed fade or dismissal.
|
||||||
|
|
||||||
## Verification boundary
|
## Verification boundary
|
||||||
|
|
||||||
`tests/action-notice-queue.test.tsx` checks small updates, a full 250-entry burst,
|
`tests/action-notice-queue.test.tsx` checks small updates, a full 250-entry burst,
|
||||||
successive bursts during an active notice, exact represented counts and the
|
successive bursts during an active notice, exact represented counts and the
|
||||||
initial nonblocking output, plus stale/duplicate sequence handling and silent
|
initial nonblocking output, plus stale/duplicate sequence handling and silent
|
||||||
consumption without replay. `tests/completed-action-events.test.ts` exercises
|
consumption without replay. The local presentation tests exercise Continue,
|
||||||
|
unread text across bursts/backgrounding, mute and no replay; the rendered notice
|
||||||
|
test checks readable content and its connected Continue control without focus
|
||||||
|
or modal behavior. These tests do not mount a browser DOM.
|
||||||
|
`tests/completed-action-events.test.ts` exercises
|
||||||
committed results and pending/canceled paths. Private checkpoint evidence records
|
committed results and pending/canceled paths. Private checkpoint evidence records
|
||||||
the browser checks and final source-bound verification.
|
the browser checks and final source-bound verification.
|
||||||
|
|
||||||
Browser QA used genuine Basic Atreides/Harkonnen/Emperor setup. Private batches
|
Earlier browser QA, before the persistent Continue control, used genuine Basic
|
||||||
|
Atreides/Harkonnen/Emperor setup. Private batches
|
||||||
advanced only that QA room through real engine actions, preserving card/force
|
advanced only that QA room through real engine actions, preserving card/force
|
||||||
custody and seat controllers. A seven-event burst displayed the Emperor's
|
custody and seat controllers. A seven-event burst displayed the Emperor's
|
||||||
**Revival** with four earlier completed actions, then drained. The chronicle
|
**Revival** with four earlier completed actions, then drained. The chronicle
|
||||||
@@ -48,10 +59,11 @@ left it empty. Refresh retained the exact saved game row. At a 390-pixel viewpor
|
|||||||
the notice container was 343 pixels within a 375-pixel document without horizontal
|
the notice container was 343 pixels within a 375-pixel document without horizontal
|
||||||
overflow; transient mobile text was not captured. The viewport was reset afterward.
|
overflow; transient mobile text was not captured. The viewport was reset afterward.
|
||||||
|
|
||||||
The provider reports background tabs as visible, so an actual background-page
|
That provider reported background tabs as visible, so an actual background-page
|
||||||
transition was not exercised. The cursor tests and handler review cover that
|
transition was not exercised. The cursor tests and handler review cover that
|
||||||
branch; the automated component test does not mount timers. Existing screenshot
|
branch. That earlier timed-notice evidence does not verify the persistent
|
||||||
capture failures leave full visual acceptance open.
|
control; current browser acceptance belongs to the new checkpoint report.
|
||||||
|
Existing screenshot capture failures leave full visual acceptance open.
|
||||||
|
|
||||||
This extends functional feedback; complete animation design, every successful
|
This extends functional feedback; complete animation design, every successful
|
||||||
power tag and full visual/device acceptance remain unfinished. It does not
|
power tag and full visual/device acceptance remain unfinished. It does not
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ Expansion starts and full rules/publication acceptance remain gated.**
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
|
||||||
|
The [readable-notice fix](READABLE_NOTICES.md) replaces the flashing recovery
|
||||||
|
screen during ordinary room creation/join with concise progress. Actual recovery
|
||||||
|
instructions stay until resolved, and completed-action text waits for a local
|
||||||
|
Continue control. It does not add confirmation steps to game rules or AI turns.
|
||||||
|
|
||||||
|
Administrator access/directory checkpoint `c2d392e` passed types, lint, 5,196
|
||||||
|
offline tests, 52 HTTP tests and build, plus focused authenticated/browser checks.
|
||||||
|
Its production acceptance is being combined with this readability follow-up to
|
||||||
|
avoid two deployments; saved games and full administration gates remain intact.
|
||||||
|
|
||||||
The user added a complete [administration panel](ADMIN_PANEL.md) to the goal on
|
The user added a complete [administration panel](ADMIN_PANEL.md) to the goal on
|
||||||
22 September: secure admin access, room creation/removal and lifecycle controls,
|
22 September: secure admin access, room creation/removal and lifecycle controls,
|
||||||
participant support, backup/restore, operational views and audited actions.
|
participant support, backup/restore, operational views and audited actions.
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 22 September 2026 — Readable room entry and completed-action text
|
||||||
|
|
||||||
|
Normal create/join now shows concise pending text while the first request runs;
|
||||||
|
the existing full recovery panel is reserved for saved or uncertain requests and
|
||||||
|
remains until explicitly resolved. Automatic-action notices retain text behind a
|
||||||
|
local Continue control without gating rules, server actions or AI pacing. See
|
||||||
|
[readable notices](READABLE_NOTICES.md) and [action feedback](ACTION_FEEDBACK.md).
|
||||||
|
Focused regressions, delayed/lost-response browser QA and required checkpoint
|
||||||
|
checks accompany the change. Deployed evidence remains separate and required.
|
||||||
|
|
||||||
## 22 September 2026 — Administrator access and room directory prototype
|
## 22 September 2026 — Administrator access and room directory prototype
|
||||||
|
|
||||||
The first [admin panel](ADMIN_PANEL.md#access-and-directory-prototype) slice adds
|
The first [admin panel](ADMIN_PANEL.md#access-and-directory-prototype) slice adds
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Readable progress, recovery and action notices
|
||||||
|
|
||||||
|
The user reported that a popup after room creation disappeared before it could
|
||||||
|
be read or acted on. The first create/join request was rendering the full saved
|
||||||
|
request recovery panel while awaiting its initial response. Successful creation
|
||||||
|
then removed it immediately, making actionable recovery instructions appear to
|
||||||
|
flash on screen even when nothing had gone wrong.
|
||||||
|
|
||||||
|
First submissions now show only **Creating your room…** or **Joining the room…**
|
||||||
|
and a short progress message. Successful responses still open the confirmed
|
||||||
|
table. A lost/uncertain response, saved request after refresh, or storage problem
|
||||||
|
shows the existing persistent recovery panel. Its exact retry, deliberate
|
||||||
|
abandonment and saved-kit recovery controls remain available until the player
|
||||||
|
resolves the request. The private retry proof is still saved before submission;
|
||||||
|
there is no automatic retry or new seat operation.
|
||||||
|
|
||||||
|
Completed-action notices also retain their text until **Continue**, instead of
|
||||||
|
vanishing after 1.5 seconds. This is a local display control: the server has
|
||||||
|
already completed the action and human/AI play continues normally. New events
|
||||||
|
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
|
||||||
|
countdowns keep their existing behavior.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Browser QA uses dedicated rooms and a temporary local transport that delays the
|
||||||
|
first response and can lose one successful create response. This verifies actual
|
||||||
|
rendered progress, durable recovery instructions, and explicit same-request retry
|
||||||
|
without introducing production timing changes. Existing room-entry regressions
|
||||||
|
cover retry identity, storage failure, ambiguous responses and no duplicate
|
||||||
|
creates; notice regressions cover retention, continuation, summaries and mute.
|
||||||
|
|
||||||
|
Record browser, source-bound checks and deployed acceptance separately. Test
|
||||||
|
normal create/join and notice controls on the deployed revision; a local delayed
|
||||||
|
transport test does not establish production behavior. Preserve existing games.
|
||||||
@@ -11,6 +11,36 @@ export type QueuedActionNotice = AutomaticActionEvent & { count: number };
|
|||||||
|
|
||||||
export type ActionNoticeCursor = { seenSeq: number; pending: QueuedActionNotice[] };
|
export type ActionNoticeCursor = { seenSeq: number; pending: QueuedActionNotice[] };
|
||||||
|
|
||||||
|
export type ActionNoticePresentation = ActionNoticeCursor & { active: QueuedActionNotice | null };
|
||||||
|
|
||||||
|
type ActionNoticeUpdate =
|
||||||
|
| { type: 'events'; events: readonly AutomaticActionEvent[]; enabled: boolean; visible: boolean }
|
||||||
|
| { type: 'continue' }
|
||||||
|
| { type: 'hide' }
|
||||||
|
| { type: 'clear' };
|
||||||
|
|
||||||
|
/** Existing history establishes a silent baseline, including after refresh. */
|
||||||
|
export function initialActionNotices(events: readonly AutomaticActionEvent[]): ActionNoticePresentation {
|
||||||
|
return { seenSeq: events.reduce((last, event) => Math.max(last, event.seq), -1), pending: [], active: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local presentation only: reading or dismissing a notice never advances the game. */
|
||||||
|
export function updateActionNotices(state: ActionNoticePresentation, update: ActionNoticeUpdate): ActionNoticePresentation {
|
||||||
|
if (update.type === 'hide') return { ...state, pending: [] };
|
||||||
|
if (update.type === 'clear') return { ...state, active: null, pending: [] };
|
||||||
|
if (update.type === 'continue') return { ...state, active: state.pending[0] ?? null, pending: state.pending.slice(1) };
|
||||||
|
|
||||||
|
const active = update.enabled ? state.active : null;
|
||||||
|
const cursor = advanceActionNotices(state, update.events, {
|
||||||
|
active: !!active,
|
||||||
|
show: update.enabled && update.visible,
|
||||||
|
});
|
||||||
|
// New updates never replace text that the player has not dismissed.
|
||||||
|
return active
|
||||||
|
? { ...cursor, active }
|
||||||
|
: { ...cursor, active: cursor.pending[0] ?? null, pending: cursor.pending.slice(1) };
|
||||||
|
}
|
||||||
|
|
||||||
/** Consume every observed sequence, even when presentation is disabled. */
|
/** Consume every observed sequence, even when presentation is disabled. */
|
||||||
export function advanceActionNotices(
|
export function advanceActionNotices(
|
||||||
cursor: ActionNoticeCursor,
|
cursor: ActionNoticeCursor,
|
||||||
@@ -30,7 +60,7 @@ export function advanceActionNotices(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep at most three 1.5-second notices, including the one already on screen. */
|
/** Keep at most three notices, including the one awaiting local dismissal. */
|
||||||
export function appendActionNotices(
|
export function appendActionNotices(
|
||||||
pending: readonly QueuedActionNotice[],
|
pending: readonly QueuedActionNotice[],
|
||||||
incoming: readonly AutomaticActionEvent[],
|
incoming: readonly AutomaticActionEvent[],
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { Children, isValidElement } from 'react';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import { AutomaticActionNotice } from '../components/automatic-action-notice';
|
import { ActionNoticeCard, AutomaticActionNotice } from '../components/automatic-action-notice';
|
||||||
import { advanceActionNotices, appendActionNotices, type AutomaticActionEvent } from '../lib/action-notice-queue';
|
import { Button } from '../components/ui/button';
|
||||||
|
import { advanceActionNotices, appendActionNotices, initialActionNotices, updateActionNotices, type AutomaticActionEvent } from '../lib/action-notice-queue';
|
||||||
|
|
||||||
const events = (first: number, count: number): AutomaticActionEvent[] =>
|
const events = (first: number, count: number): AutomaticActionEvent[] =>
|
||||||
Array.from({ length: count }, (_, index) => ({
|
Array.from({ length: count }, (_, index) => ({
|
||||||
@@ -17,7 +19,7 @@ void test('ordinary notices keep their public action and house without mutating
|
|||||||
assert.deepEqual(appendActionNotices([], [], false), []);
|
assert.deepEqual(appendActionNotices([], [], false), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('a full log batch becomes three brief notices while accounting for every action', () => {
|
void test('a full log batch becomes three notices while accounting for every action', () => {
|
||||||
const incoming = events(1, 250);
|
const incoming = events(1, 250);
|
||||||
const queue = appendActionNotices([], incoming, false);
|
const queue = appendActionNotices([], incoming, false);
|
||||||
assert.equal(queue.length, 3);
|
assert.equal(queue.length, 3);
|
||||||
@@ -28,6 +30,66 @@ void test('a full log batch becomes three brief notices while accounting for eve
|
|||||||
assert.equal(queue[0].faction, incoming[247].faction);
|
assert.equal(queue[0].faction, incoming[247].faction);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('unread action text survives new bursts and each Continue advances only the local queue', () => {
|
||||||
|
const initial = initialActionNotices(events(1, 4));
|
||||||
|
assert.deepEqual(initial, { seenSeq: 4, pending: [], active: null });
|
||||||
|
const shown = updateActionNotices(initial, { type: 'events', events: events(1, 7), enabled: true, visible: true });
|
||||||
|
assert.equal(shown.active?.seq, 5);
|
||||||
|
const before = structuredClone(shown);
|
||||||
|
const burst = updateActionNotices(shown, { type: 'events', events: events(8, 250), enabled: true, visible: true });
|
||||||
|
assert.deepEqual(burst.active, shown.active);
|
||||||
|
assert.equal(burst.pending.length, 2);
|
||||||
|
assert.equal((burst.active?.count ?? 0) + burst.pending.reduce((sum, notice) => sum + notice.count, 0), 253);
|
||||||
|
assert.deepEqual(shown, before);
|
||||||
|
const first = updateActionNotices(burst, { type: 'continue' });
|
||||||
|
assert.deepEqual(first.active, burst.pending[0]);
|
||||||
|
assert.deepEqual(first.pending, burst.pending.slice(1));
|
||||||
|
const second = updateActionNotices(first, { type: 'continue' });
|
||||||
|
assert.deepEqual(second.active, burst.pending[1]);
|
||||||
|
const finished = updateActionNotices(second, { type: 'continue' });
|
||||||
|
assert.deepEqual(finished, { seenSeq: 257, pending: [], active: null });
|
||||||
|
assert.deepEqual(updateActionNotices(finished, { type: 'events', events: events(1, 257), enabled: true, visible: true }), finished);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('backgrounding retains the unread notice while discarding pending and hidden events without replay', () => {
|
||||||
|
const shown = updateActionNotices(initialActionNotices([]), { type: 'events', events: events(1, 3), enabled: true, visible: true });
|
||||||
|
const hidden = updateActionNotices(shown, { type: 'hide' });
|
||||||
|
assert.deepEqual(hidden.active, shown.active);
|
||||||
|
assert.deepEqual(hidden.pending, []);
|
||||||
|
const updated = updateActionNotices(hidden, { type: 'events', events: events(1, 20), enabled: true, visible: false });
|
||||||
|
assert.deepEqual(updated, { seenSeq: 20, pending: [], active: shown.active });
|
||||||
|
const returned = updateActionNotices(updated, { type: 'events', events: events(1, 20), enabled: true, visible: true });
|
||||||
|
assert.deepEqual(returned, updated);
|
||||||
|
const dismissed = updateActionNotices(returned, { type: 'continue' });
|
||||||
|
assert.deepEqual(dismissed, { seenSeq: 20, pending: [], active: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('turning notices off clears unread content and consumes muted updates without replay', () => {
|
||||||
|
const shown = updateActionNotices(initialActionNotices([]), { type: 'events', events: events(1, 3), enabled: true, visible: true });
|
||||||
|
assert.deepEqual(updateActionNotices(shown, { type: 'clear' }), { seenSeq: 3, pending: [], active: null });
|
||||||
|
const muted = updateActionNotices(shown, { type: 'events', events: events(1, 20), enabled: false, visible: true });
|
||||||
|
assert.deepEqual(muted, { seenSeq: 20, pending: [], active: null });
|
||||||
|
assert.deepEqual(updateActionNotices(muted, { type: 'events', events: events(1, 20), enabled: true, visible: true }), muted);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('an action card exposes a readable local Continue button without modal or automatic focus behavior', () => {
|
||||||
|
let continued = 0;
|
||||||
|
const notice = { ...events(1, 1)[0], name: 'Shipment completed', count: 8 };
|
||||||
|
const card = ActionNoticeCard({ notice, onContinue: () => { continued++; } });
|
||||||
|
const markup = renderToStaticMarkup(card);
|
||||||
|
assert.match(markup, /Atreides/);
|
||||||
|
assert.match(markup, /Shipment completed/);
|
||||||
|
assert.match(markup, /7 earlier actions.*table chronicle/);
|
||||||
|
assert.match(markup, /pointer-events-auto/);
|
||||||
|
assert.match(markup, /text-base/);
|
||||||
|
assert.match(markup, /<button[^>]*type="button"[^>]*>Continue<\/button>/);
|
||||||
|
assert.doesNotMatch(markup, /text-xs|autofocus|aria-modal|role="dialog"/);
|
||||||
|
const control = Children.toArray(card.props.children).filter(isValidElement).find(child => child.type === Button);
|
||||||
|
assert.ok(control);
|
||||||
|
(control.props as { onClick: () => void }).onClick();
|
||||||
|
assert.equal(continued, 1);
|
||||||
|
});
|
||||||
|
|
||||||
void test('incoming bursts preserve the visible notice and merge existing summaries without losing counts', () => {
|
void test('incoming bursts preserve the visible notice and merge existing summaries without losing counts', () => {
|
||||||
const queue = appendActionNotices([], events(1, 12), false);
|
const queue = appendActionNotices([], events(1, 12), false);
|
||||||
const active = queue[0], before = structuredClone(queue);
|
const active = queue[0], before = structuredClone(queue);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { setTimeout as delay } from 'node:timers/promises';
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join as joinPath } from 'node:path';
|
||||||
|
|
||||||
const image = process.argv[2];
|
const image = process.argv[2];
|
||||||
if (!image) throw new Error('Usage: node tools/verify-container.mjs IMAGE');
|
if (!image) throw new Error('Usage: node tools/verify-container.mjs IMAGE');
|
||||||
@@ -13,7 +13,7 @@ const name = `dune-verify-${randomUUID()}`;
|
|||||||
const volume = `${name}-data`;
|
const volume = `${name}-data`;
|
||||||
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim();
|
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim();
|
||||||
let base;
|
let base;
|
||||||
const adminFiles = mkdtempSync(join(tmpdir(), 'dune-admin-container-'));
|
const adminFiles = mkdtempSync(joinPath(tmpdir(), 'dune-admin-container-'));
|
||||||
let adminCookie;
|
let adminCookie;
|
||||||
async function ready() {
|
async function ready() {
|
||||||
for (let attempt = 0; attempt < 90; attempt++) {
|
for (let attempt = 0; attempt < 90; attempt++) {
|
||||||
@@ -36,12 +36,12 @@ try {
|
|||||||
docker('volume', 'create', volume);
|
docker('volume', 'create', volume);
|
||||||
start();
|
start();
|
||||||
await ready();
|
await ready();
|
||||||
execFileSync('node', ['tools/admin-access.mjs', '--name', 'Container QA', '--role', 'viewer', '--out', join(adminFiles, 'key')]);
|
execFileSync('node', ['tools/admin-access.mjs', '--name', 'Container QA', '--role', 'viewer', '--out', joinPath(adminFiles, 'key')]);
|
||||||
execFileSync('docker', ['exec', '-i', name, 'node', '-e', "require('node:fs').writeFileSync('/tmp/dune-admin-provision.sql', require('node:fs').readFileSync(0), {mode:0o600})"], {
|
execFileSync('docker', ['exec', '-i', name, 'node', '-e', "require('node:fs').writeFileSync('/tmp/dune-admin-provision.sql', require('node:fs').readFileSync(0), {mode:0o600})"], {
|
||||||
input: readFileSync(join(adminFiles, 'key/provision.sql')), // Container USER owns this private file.
|
input: readFileSync(joinPath(adminFiles, 'key/provision.sql')), // Container USER owns this private file.
|
||||||
});
|
});
|
||||||
docker('exec', name, 'node', 'node_modules/wrangler/bin/wrangler.js', 'd1', 'execute', 'DB', '--local', '--config', 'tools/wrangler.local.json', '--persist-to', '/data', '--file', '/tmp/dune-admin-provision.sql');
|
docker('exec', name, 'node', 'node_modules/wrangler/bin/wrangler.js', 'd1', 'execute', 'DB', '--local', '--config', 'tools/wrangler.local.json', '--persist-to', '/data', '--file', '/tmp/dune-admin-provision.sql');
|
||||||
const adminKey = readFileSync(join(adminFiles, 'key/access-key.txt'), 'utf8').trim();
|
const adminKey = readFileSync(joinPath(adminFiles, 'key/access-key.txt'), 'utf8').trim();
|
||||||
const adminLogin = await fetch(`${base}/api/admin/session`, {
|
const adminLogin = await fetch(`${base}/api/admin/session`, {
|
||||||
method: 'POST', headers: { 'content-type': 'application/json', origin: base },
|
method: 'POST', headers: { 'content-type': 'application/json', origin: base },
|
||||||
body: JSON.stringify({ action: 'login', key: adminKey }),
|
body: JSON.stringify({ action: 'login', key: adminKey }),
|
||||||
@@ -79,7 +79,7 @@ try {
|
|||||||
start();
|
start();
|
||||||
await ready();
|
await ready();
|
||||||
await restored();
|
await restored();
|
||||||
execFileSync('node', ['tools/verify-admin.mjs', '--url', base, '--key-file', join(adminFiles, 'key/access-key.txt'), '--qa-account', adminKey.split('.')[1], '--out', join(adminFiles, 'http')], { stdio: 'inherit' });
|
execFileSync('node', ['tools/verify-admin.mjs', '--url', base, '--key-file', joinPath(adminFiles, 'key/access-key.txt'), '--qa-account', adminKey.split('.')[1], '--out', joinPath(adminFiles, 'http')], { stdio: 'inherit' });
|
||||||
adminCookie = undefined; // The acceptance test intentionally signs out all sessions.
|
adminCookie = undefined; // The acceptance test intentionally signs out all sessions.
|
||||||
execFileSync('npm', ['run', 'test:integration'], {
|
execFileSync('npm', ['run', 'test:integration'], {
|
||||||
stdio: 'inherit', env: { ...process.env, DUNE_TEST_URL: base },
|
stdio: 'inherit', env: { ...process.env, DUNE_TEST_URL: base },
|
||||||
|
|||||||
Reference in new issue
Block a user