diff --git a/README.md b/README.md
index f5d4387c..568f78d8 100644
--- a/README.md
+++ b/README.md
@@ -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.
+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.
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.
diff --git a/app/page.tsx b/app/page.tsx
index afb10945..13318c95 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -80,6 +80,7 @@ export default function Home() {
null,
);
const [entryProblem, setEntryProblem] = useState('');
+ const [initialEntryPending, setInitialEntryPending] = useState(false);
const [showEntryRecovery, setShowEntryRecovery] = useState(false);
const [showHandover, setShowHandover] = useState(false);
const [handoverPending, setHandoverPending] = useState(true);
@@ -407,6 +408,7 @@ export default function Home() {
);
}
} finally {
+ if (firstDispatch) setInitialEntryPending(false);
mutationPending.current = false;
setBusy(false);
}
@@ -443,6 +445,7 @@ export default function Home() {
return;
}
pendingEntry.current = attempt;
+ setInitialEntryPending(true);
setEntryAttempt(attempt);
setAbandonAcknowledged(false);
await dispatchEntry(attempt, true);
@@ -808,6 +811,20 @@ export default function Home() {
);
+ // 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 (
+
+
+
DUNEARRAKIS TABLE
+
+
+
{entryAttempt.kind === 'create' ? 'Creating your room…' : 'Joining the room…'}
+
+
+
+ );
if (entryAttempt || entryProblem)
return (
diff --git a/components/automatic-action-notice.tsx b/components/automatic-action-notice.tsx
index fc0b59bc..cc357b52 100644
--- a/components/automatic-action-notice.tsx
+++ b/components/automatic-action-notice.tsx
@@ -1,15 +1,13 @@
'use client';
import {
- useCallback,
useEffect,
- useRef,
- useState,
+ useReducer,
useSyncExternalStore,
} from 'react';
import { Bell, BellOff } from 'lucide-react';
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';
export type { AutomaticActionEvent } from '@/lib/action-notice-queue';
@@ -42,89 +40,28 @@ export function AutomaticActionNotice({
readPreference,
serverPreference,
);
- // The first snapshot establishes a baseline. Refreshing never replays history.
- const seenSeq = useRef(
- events.reduce((last, event) => Math.max(last, event.seq), -1),
- );
- const queue = useRef([]);
- const active = useRef(null);
- const [notice, setNotice] = useState<
- (QueuedActionNotice & { fading: boolean }) | null
- >(null);
- const [completed, setCompleted] = useState(0);
- const timers = useRef[]>([]);
- 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]);
+ const [presentation, updatePresentation] = useReducer(updateActionNotices, events, initialActionNotices);
useEffect(() => {
- // Preserve the visible notice. Summarize older pending actions so a bot
- // batch cannot leave minutes of stale feedback; the chronicle keeps detail.
- const next = advanceActionNotices({ seenSeq: seenSeq.current, pending: queue.current }, events, {
- active: !!active.current,
- show: readPreference() && document.visibilityState !== 'hidden',
+ updatePresentation({
+ type: 'events', events, enabled,
+ visible: document.visibilityState !== 'hidden',
});
- seenSeq.current = next.seenSeq;
- queue.current = next.pending;
- showNext();
- }, [events, showNext, completed]);
+ }, [events, enabled]);
useEffect(() => {
const hide = () => {
if (document.visibilityState !== 'hidden') return;
- clearTimers();
- queue.current = [];
- active.current = null;
- setNotice(null);
+ updatePresentation({ type: 'hide' });
};
document.addEventListener('visibilitychange', hide);
return () => document.removeEventListener('visibilitychange', hide);
- }, [clearTimers]);
-
- useEffect(
- () => () => {
- clearTimers();
- queue.current = [];
- active.current = null;
- },
- [clearTimers],
- );
+ }, []);
const toggle = () => {
const next = !enabled;
memoryPreference = next;
- clearTimers();
- queue.current = [];
- active.current = null;
- setNotice(null);
+ updatePresentation({ type: 'clear' });
try {
localStorage.setItem(PREFERENCE_KEY, String(next));
memoryPreference = undefined;
@@ -133,9 +70,6 @@ export function AutomaticActionNotice({
}
window.dispatchEvent(new Event(PREFERENCE_EVENT));
};
- const house = notice
- ? FACTIONS.find((candidate) => candidate.id === notice.faction)
- : undefined;
return (
<>
@@ -155,26 +89,30 @@ export function AutomaticActionNotice({
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"
>
- {enabled && notice && (
-
-
- {house?.name ?? 'Automatic action'}
-
-
- {notice.name}
-
- {notice.count > 1 &&
- {notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle.
- }
-
+ {enabled && presentation.active && (
+ updatePresentation({ type: 'continue' })} />
)}
>
);
}
+
+/** 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
+
+ {house?.name ?? 'Automatic action'}
+
+ {notice.name}
+ {notice.count > 1 &&
+ {notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle.
+ }
+
+ ;
+}
diff --git a/docs/ACTION_FEEDBACK.md b/docs/ACTION_FEEDBACK.md
index 79717781..ea809287 100644
--- a/docs/ACTION_FEEDBACK.md
+++ b/docs/ACTION_FEEDBACK.md
@@ -15,30 +15,41 @@ its separate mute and volume settings still apply.
## 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
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.
-A new burst never replaces the currently displayed notice. After arrivals stop,
-the visible notice and pending queue finish within 4.5 seconds, rather than
-retaining minutes of old events.
+A new burst never replaces the currently displayed notice. Continue displays
+the next queued notice, if any, until that notice is also dismissed.
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
-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
-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
`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
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
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
custody and seat controllers. A seven-event burst displayed the Emperor's
**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
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
-branch; the automated component test does not mount timers. Existing screenshot
-capture failures leave full visual acceptance open.
+branch. That earlier timed-notice evidence does not verify the persistent
+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
power tag and full visual/device acceptance remain unfinished. It does not
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index c36fe027..c0c2e5e9 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -6,6 +6,16 @@ Expansion starts and full rules/publication acceptance remain gated.**
## 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
22 September: secure admin access, room creation/removal and lifecycle controls,
participant support, backup/restore, operational views and audited actions.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 9999e77b..899db359 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,15 @@
# 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
The first [admin panel](ADMIN_PANEL.md#access-and-directory-prototype) slice adds
diff --git a/docs/READABLE_NOTICES.md b/docs/READABLE_NOTICES.md
new file mode 100644
index 00000000..fc4252a9
--- /dev/null
+++ b/docs/READABLE_NOTICES.md
@@ -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.
diff --git a/lib/action-notice-queue.ts b/lib/action-notice-queue.ts
index b0670cc7..9a236239 100644
--- a/lib/action-notice-queue.ts
+++ b/lib/action-notice-queue.ts
@@ -11,6 +11,36 @@ export type QueuedActionNotice = AutomaticActionEvent & { count: number };
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. */
export function advanceActionNotices(
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(
pending: readonly QueuedActionNotice[],
incoming: readonly AutomaticActionEvent[],
diff --git a/tests/action-notice-queue.test.tsx b/tests/action-notice-queue.test.tsx
index f4c55636..222546fc 100644
--- a/tests/action-notice-queue.test.tsx
+++ b/tests/action-notice-queue.test.tsx
@@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
+import { Children, isValidElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
-import { AutomaticActionNotice } from '../components/automatic-action-notice';
-import { advanceActionNotices, appendActionNotices, type AutomaticActionEvent } from '../lib/action-notice-queue';
+import { ActionNoticeCard, AutomaticActionNotice } from '../components/automatic-action-notice';
+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[] =>
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), []);
});
-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 queue = appendActionNotices([], incoming, false);
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);
});
+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, /