- {error && (
+ {ceiling !== null && (
+
+ {ceiling.message}
+ store.refresh()}
+ >
+ [retry]
+
+
+ )}
+ {error !== null && ceiling === null && (
{error.message}
diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx
index bd3a24620..dfada956a 100644
--- a/apps/web/src/components/layout/AppShell.tsx
+++ b/apps/web/src/components/layout/AppShell.tsx
@@ -1,24 +1,35 @@
import type { ReactNode } from 'react';
+import { useRefreshHints } from '../../engine/useRefreshHints';
+import { NotificationToast } from '../NotificationToast';
import { StagingBanner } from '../StagingBanner';
import { AppFooter } from './AppFooter';
import { AppHeader } from './AppHeader';
import { AppSidebar } from './AppSidebar';
+import { OfflineBanner } from './OfflineBanner';
interface AppShellProps {
children: ReactNode;
}
-/** The signed-in frame: header, sidebar, scrollable main, footer. */
+/**
+ * The signed-in frame: header, sidebar, scrollable main, footer, and the
+ * cross-cutting chrome that renders event-stream state only
+ * (blueprint/web-client.md "Composition").
+ */
export function AppShell({ children }: AppShellProps) {
+ useRefreshHints();
+
return (
);
}
diff --git a/apps/web/src/components/layout/OfflineBanner.test.tsx b/apps/web/src/components/layout/OfflineBanner.test.tsx
new file mode 100644
index 000000000..9299a460a
--- /dev/null
+++ b/apps/web/src/components/layout/OfflineBanner.test.tsx
@@ -0,0 +1,59 @@
+import { act, render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { fakeEngine, view } from '../../engine/testFakes';
+import { EngineProvider } from '../../providers/EngineProvider';
+import { OfflineBanner } from './OfflineBanner';
+
+function draw(client: ReturnType
['client']) {
+ return render(
+ client}>
+
+
+ );
+}
+
+describe('the offline banner', () => {
+ it('stays down while the engine is reconciling', () => {
+ draw(fakeEngine().client);
+ expect(screen.queryByTestId('offline-banner')).toBeNull();
+ });
+
+ it('follows the engine reaching the offline rung', async () => {
+ const engine = fakeEngine();
+ draw(engine.client);
+
+ await act(async () => {
+ engine.emit({ kind: 'stalenessChanged', staleness: 'offline' });
+ });
+
+ await waitFor(() => expect(screen.getByTestId('offline-banner')).toBeTruthy());
+ });
+
+ it('clears when the engine leaves that rung', async () => {
+ const engine = fakeEngine();
+ draw(engine.client);
+ await act(async () => {
+ engine.emit({ kind: 'stalenessChanged', staleness: 'offline' });
+ });
+ await waitFor(() => expect(screen.getByTestId('offline-banner')).toBeTruthy());
+
+ await act(async () => {
+ engine.emit({ kind: 'snapshotUpdated' });
+ engine.pulls[0].resolve(view());
+ });
+
+ await waitFor(() => expect(screen.queryByTestId('offline-banner')).toBeNull());
+ });
+
+ it('renders no banner for the rungs above it', async () => {
+ const engine = fakeEngine();
+ draw(engine.client);
+
+ for (const rung of ['fresh', 'reconciling', 'stale'] as const) {
+ await act(async () => {
+ engine.emit({ kind: 'stalenessChanged', staleness: rung });
+ });
+ expect(screen.queryByTestId('offline-banner')).toBeNull();
+ }
+ });
+});
diff --git a/apps/web/src/components/layout/OfflineBanner.tsx b/apps/web/src/components/layout/OfflineBanner.tsx
new file mode 100644
index 000000000..a7ffde77a
--- /dev/null
+++ b/apps/web/src/components/layout/OfflineBanner.tsx
@@ -0,0 +1,20 @@
+import { useStaleness } from '../../engine/useStaleness';
+
+/**
+ * The staleness ladder's bottom rung, at banner scale (blueprint/web-client.md
+ * "Staleness ladder rendering").
+ */
+export function OfflineBanner() {
+ if (useStaleness() !== 'offline') return null;
+
+ return (
+
+
+ [//]
+
+
+ {'// OFFLINE - changes queue on this device and publish when the network returns'}
+
+
+ );
+}
diff --git a/apps/web/src/components/layout/StatusIndicator.test.tsx b/apps/web/src/components/layout/StatusIndicator.test.tsx
new file mode 100644
index 000000000..61dab1400
--- /dev/null
+++ b/apps/web/src/components/layout/StatusIndicator.test.tsx
@@ -0,0 +1,43 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { fakeEngine } from '../../engine/testFakes';
+import { EngineProvider } from '../../providers/EngineProvider';
+import { StatusIndicator } from './StatusIndicator';
+
+function draw(client: ReturnType['client']) {
+ return render(
+ client}>
+
+
+ );
+}
+
+describe('the status indicator', () => {
+ it('names the rung the engine reports', async () => {
+ const engine = fakeEngine();
+ draw(engine.client);
+ expect(screen.getByTestId('status-indicator').dataset.staleness).toBe('reconciling');
+
+ for (const rung of ['fresh', 'stale', 'offline'] as const) {
+ await act(async () => {
+ engine.emit({ kind: 'stalenessChanged', staleness: rung });
+ });
+ await waitFor(() =>
+ expect(screen.getByTestId('status-indicator').dataset.staleness).toBe(rung)
+ );
+ }
+ });
+
+ it('drives a manual refresh from the rung it renders', async () => {
+ const engine = fakeEngine();
+ draw(engine.client);
+ await waitFor(() => expect(screen.getByTestId('status-indicator')).toBeTruthy());
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('status-indicator'));
+ });
+
+ expect(engine.refreshes()).toBe(1);
+ expect(engine.pulls).toHaveLength(1);
+ });
+});
diff --git a/apps/web/src/components/layout/StatusIndicator.tsx b/apps/web/src/components/layout/StatusIndicator.tsx
index e9bf99733..9134dc980 100644
--- a/apps/web/src/components/layout/StatusIndicator.tsx
+++ b/apps/web/src/components/layout/StatusIndicator.tsx
@@ -1,4 +1,5 @@
import { useStaleness } from '../../engine/useStaleness';
+import { useSnapshotStore } from '../../providers/EngineProvider';
/** The staleness ladder's rungs, as the footer renders them (#33 D4). */
const RUNGS = {
@@ -8,19 +9,23 @@ const RUNGS = {
offline: { label: 'offline', className: 'status-indicator--offline' },
} as const;
-/** Where the vault sits on the staleness ladder. */
+/** Where the vault sits on the staleness ladder, and the manual refresh. */
export function StatusIndicator() {
const staleness = useStaleness();
+ const store = useSnapshotStore();
const rung = RUNGS[staleness];
return (
- store.refresh()}
>
{rung.label}
-
+
);
}
diff --git a/apps/web/src/engine/engineNotices.test.tsx b/apps/web/src/engine/engineNotices.test.tsx
new file mode 100644
index 000000000..1bbe0b258
--- /dev/null
+++ b/apps/web/src/engine/engineNotices.test.tsx
@@ -0,0 +1,106 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import { NotificationToast } from '../components/NotificationToast';
+import { StatusIndicator } from '../components/layout/StatusIndicator';
+import { EngineProvider } from '../providers/EngineProvider';
+import { notificationStore } from '../stores/notification.store';
+import { fakeEngine } from './testFakes';
+
+afterEach(() => notificationStore.clear());
+
+/** The two surfaces side by side, so one event cannot land on both. */
+function draw(client: ReturnType['client']) {
+ return render(
+ client}>
+
+
+
+ );
+}
+
+describe('engine warnings', () => {
+ it('renders a withheld-update escalation as a warning, never as staleness', async () => {
+ const engine = fakeEngine();
+ draw(engine.client);
+ await waitFor(() => expect(screen.getByTestId('status-indicator')).toBeTruthy());
+ const rung = screen.getByTestId('status-indicator').dataset.staleness;
+
+ await act(async () => {
+ engine.emit({ kind: 'withheldUpdateEscalation', ipnsName: new Uint8Array([0xab, 0xcd]) });
+ });
+
+ const notice = await screen.findByTestId('notification-notice');
+ expect(notice.getAttribute('role')).toBe('alert');
+ // The pinned name identifies the scope for de-duplication only.
+ expect(notice.textContent).not.toContain('abcd');
+ // The ladder is untouched: a trust warning is never a rung.
+ expect(screen.getByTestId('status-indicator').dataset.staleness).toBe(rung);
+ });
+
+ it('renders an attributable-abuse report as the same warning class', async () => {
+ const engine = fakeEngine();
+ draw(engine.client);
+
+ await act(async () => {
+ engine.emit({ kind: 'attributableAbuse', description: 'k51abc: floor regression' });
+ });
+
+ const notice = await screen.findByTestId('notification-notice');
+ expect(notice.textContent).toContain('k51abc: floor regression');
+ expect(screen.getByTestId('status-indicator').dataset.staleness).toBe('reconciling');
+ });
+
+ it('collapses a scope that escalates on every tick', async () => {
+ const engine = fakeEngine();
+ draw(engine.client);
+ const name = new Uint8Array([0x01, 0x02]);
+
+ await act(async () => {
+ engine.emit({ kind: 'withheldUpdateEscalation', ipnsName: name });
+ engine.emit({ kind: 'withheldUpdateEscalation', ipnsName: name });
+ engine.emit({ kind: 'withheldUpdateEscalation', ipnsName: name });
+ });
+
+ expect(await screen.findAllByTestId('notification-notice')).toHaveLength(1);
+ });
+
+ it('dismisses a warning the reader has read', async () => {
+ const engine = fakeEngine();
+ draw(engine.client);
+ await act(async () => {
+ engine.emit({ kind: 'attributableAbuse', description: 'refused' });
+ });
+ await screen.findByTestId('notification-notice');
+
+ fireEvent.click(screen.getByLabelText('Dismiss warning'));
+
+ expect(screen.queryByTestId('notification-toast')).toBeNull();
+ });
+
+ it('drops the warnings with the engine that raised them', async () => {
+ const engine = fakeEngine();
+ const { unmount } = draw(engine.client);
+ await act(async () => {
+ engine.emit({ kind: 'attributableAbuse', description: 'refused' });
+ });
+ await screen.findByTestId('notification-notice');
+
+ unmount();
+
+ expect(notificationStore.getState()).toHaveLength(0);
+ });
+
+ it('leaves the staleness ladder to the events that own it', async () => {
+ const engine = fakeEngine();
+ draw(engine.client);
+
+ await act(async () => {
+ engine.emit({ kind: 'stalenessChanged', staleness: 'stale' });
+ });
+
+ await waitFor(() =>
+ expect(screen.getByTestId('status-indicator').dataset.staleness).toBe('stale')
+ );
+ expect(screen.queryByTestId('notification-toast')).toBeNull();
+ });
+});
diff --git a/apps/web/src/engine/snapshotStore.test.ts b/apps/web/src/engine/snapshotStore.test.ts
index 7335bed1d..9f1737fef 100644
--- a/apps/web/src/engine/snapshotStore.test.ts
+++ b/apps/web/src/engine/snapshotStore.test.ts
@@ -1,6 +1,6 @@
import { EngineRequestError } from '@cipherbox/client';
import { describe, expect, it } from 'vitest';
-import { createSnapshotStore, idleSnapshotStore } from './snapshotStore';
+import { createSnapshotStore, idleSnapshotStore, isRecoverable } from './snapshotStore';
import { ROOT_ID, fakeEngine, flush, view } from './testFakes';
describe('snapshotStore', () => {
@@ -253,3 +253,130 @@ describe('snapshotStore', () => {
expect(engine.pulls).toHaveLength(0);
});
});
+
+describe('the focus window', () => {
+ const FOLDER = new Uint8Array(16).fill(3);
+
+ it('re-asserts the cached focus after a consumer borrowed the window', async () => {
+ const engine = fakeEngine();
+ const store = createSnapshotStore(engine.client);
+ store.setFocus(FOLDER);
+ engine.ackFocus();
+ await flush();
+
+ // A folder picker drove `facade.setFocus` itself; the store's cache is
+ // unchanged, so asking it for the same folder short-circuits.
+ store.setFocus(FOLDER);
+ expect(engine.focus).toEqual([FOLDER]);
+
+ store.refocus();
+ expect(engine.focus).toEqual([FOLDER, FOLDER]);
+ expect(engine.reported).toEqual([FOLDER, FOLDER]);
+ });
+
+ it('pulls its own folder after taking the window back', async () => {
+ const engine = fakeEngine();
+ const store = createSnapshotStore(engine.client);
+ store.setFocus(FOLDER);
+ engine.ackFocus();
+ await flush();
+ engine.pulls[0].resolve(view(FOLDER));
+ await flush();
+
+ store.refocus();
+ engine.ackFocus();
+ await flush();
+
+ expect(engine.pulls[1].folder).toBe(FOLDER);
+ });
+
+ it('starts no pull for a focus that lands after disposal', async () => {
+ const engine = fakeEngine();
+ const store = createSnapshotStore(engine.client);
+ store.setFocus(FOLDER);
+
+ // The provider disposes the store, then the client; the focus it asked for
+ // settles afterwards and must not reach the closed facade.
+ store.dispose();
+ engine.ackFocus();
+ await flush();
+
+ expect(engine.pulls).toEqual([]);
+
+ // Nor may a later consumer call reopen one.
+ store.refresh();
+ store.refocus();
+ await flush();
+ expect(engine.pulls).toEqual([]);
+ expect(engine.focus).toEqual([FOLDER]);
+ });
+});
+
+describe('a manual refresh', () => {
+ it('resolves nocache, then re-pulls the focused folder', async () => {
+ const engine = fakeEngine();
+ const store = createSnapshotStore(engine.client);
+
+ store.refresh();
+ await flush();
+
+ expect(engine.refreshes()).toBe(1);
+ expect(engine.pulls).toHaveLength(1);
+ const refreshed = view();
+ engine.pulls[0].resolve(refreshed);
+ await flush();
+ expect(store.getSnapshot().view).toBe(refreshed);
+ });
+
+ it('still pulls, and keeps the listing, when the engine refuses the refresh', async () => {
+ const engine = fakeEngine();
+ const store = createSnapshotStore(engine.client);
+ engine.emit({ kind: 'snapshotUpdated' });
+ const listed = view(ROOT_ID, 'fresh', 2);
+ engine.pulls[0].resolve(listed);
+ await flush();
+
+ // A refused hint is a verdict on the hint, never on the listing.
+ engine.refuseRefresh(new EngineRequestError('not implemented yet', 'unimplemented'));
+ store.refresh();
+ await flush();
+
+ expect(engine.pulls).toHaveLength(2);
+ expect(store.getSnapshot()).toEqual({ view: listed, error: null });
+ });
+
+ it('clears a failure the retry cleared', async () => {
+ const engine = fakeEngine();
+ const store = createSnapshotStore(engine.client);
+ engine.emit({ kind: 'snapshotUpdated' });
+ engine.pulls[0].reject(new EngineRequestError('at the ceiling', 'tooManyStreams'));
+ await flush();
+ expect(store.getSnapshot().error).toEqual({
+ message: 'at the ceiling',
+ code: 'tooManyStreams',
+ });
+
+ store.refresh();
+ await flush();
+ engine.pulls[1].resolve(view());
+ await flush();
+
+ expect(store.getSnapshot().error).toBeNull();
+ });
+});
+
+describe('failure classification', () => {
+ it('treats the stream ceiling as recoverable', () => {
+ expect(isRecoverable({ message: 'ceiling', code: 'tooManyStreams' })).toBe(true);
+ });
+
+ it('fails closed on anything it does not name', () => {
+ // A verdict, an unmapped future engine code, and a codeless transport fault
+ // all read as fatal rather than as something a retry would clear.
+ expect(isRecoverable({ message: 'refused', code: 'trustViolation' })).toBe(false);
+ expect(isRecoverable({ message: 'gone', code: 'unknownNode' })).toBe(false);
+ expect(isRecoverable({ message: 'new', code: 'someFutureCeiling' })).toBe(false);
+ expect(isRecoverable({ message: 'no room', code: 'overBudget' })).toBe(false);
+ expect(isRecoverable({ message: 'worker died' })).toBe(false);
+ });
+});
diff --git a/apps/web/src/engine/snapshotStore.ts b/apps/web/src/engine/snapshotStore.ts
index dcef6e689..6bf42468f 100644
--- a/apps/web/src/engine/snapshotStore.ts
+++ b/apps/web/src/engine/snapshotStore.ts
@@ -3,11 +3,17 @@
* law"): a `useSyncExternalStore` adapter over the engine event stream with no
* independent writers. It caches the descriptor the engine handed it and never
* derives, merges, or patches one.
+ *
+ * It is also the stream's only listener, so the trust warnings that must never
+ * read as staleness are projected from here onto their own surface — a second
+ * subscription would open a render later than the engine starts, and drop the
+ * cold-start escalations that land in the gap.
*/
-import { EngineRequestError } from '@cipherbox/client';
+import { EngineRequestError, toHex } from '@cipherbox/client';
import type { EngineClient, SnapshotDescriptor, Staleness } from '@cipherbox/client';
import { sameNode } from '../lib/nodeId';
+import { notificationStore } from '../stores/notification.store';
/** A failed pull, carrying the engine's stable code so the UI can classify it. */
export interface SnapshotError {
@@ -23,6 +29,15 @@ export interface SnapshotState {
error: SnapshotError | null;
}
+/**
+ * Whether a later pull clears this on its own: `tooManyStreams` is a ceiling,
+ * not a verdict. Named codes only, so a codeless transport fault and every code
+ * this does not name — trust verdicts among them — stay fatal.
+ */
+export function isRecoverable(error: SnapshotError): boolean {
+ return error.code === 'tooManyStreams';
+}
+
export interface SnapshotStore {
/** `useSyncExternalStore` subscribe: fires on every committed change. */
subscribe(onStoreChange: () => void): () => void;
@@ -32,10 +47,18 @@ export interface SnapshotStore {
getStaleness(): Staleness;
/** Points the adapter (and the engine's focus window) at a folder. */
setFocus(node: Uint8Array | null): void;
+ /** Re-asserts the cached focus after a consumer drove `facade.setFocus` itself. */
+ refocus(): void;
+ /** Re-pulls the focused folder, behind a best-effort nocache resolve. */
+ refresh(): void;
/** Releases the event subscription. */
dispose(): void;
}
+/** The pinned name identifies the scope for de-duplication, never for reading. */
+const WITHHELD =
+ 'a shared folder stopped serving updates you are entitled to see - what it shows may be behind';
+
const IDLE: SnapshotState = { view: null, error: null };
/** A store-shaped no-op for consumers mounted before the engine client exists. */
@@ -44,6 +67,8 @@ export const idleSnapshotStore: SnapshotStore = {
getSnapshot: () => IDLE,
getStaleness: () => 'reconciling',
setFocus: () => undefined,
+ refocus: () => undefined,
+ refresh: () => undefined,
dispose: () => undefined,
};
@@ -70,6 +95,10 @@ export function createSnapshotStore(client: EngineClient): SnapshotStore {
// final view.
let inFlight = false;
let coalesced = false;
+ // The provider disposes this store and its client together, and a logout
+ // rebuild does so with the tab still live — so a continuation still holding an
+ // older intent must not reach a closed facade.
+ let disposed = false;
const commit = (next: Commit): void => {
const view = next.view === undefined ? state.view : next.view;
@@ -83,6 +112,7 @@ export function createSnapshotStore(client: EngineClient): SnapshotStore {
};
const pull = (): void => {
+ if (disposed) return;
if (inFlight) {
coalesced = true;
return;
@@ -113,12 +143,33 @@ export function createSnapshotStore(client: EngineClient): SnapshotStore {
});
};
+ const assertFocus = (): void => {
+ if (disposed) return;
+ client.reportFocus(focus);
+ const id = ++generation;
+ client.facade.setFocus(focus).then(
+ () => {
+ if (id === generation) pull();
+ },
+ (error: unknown) => {
+ if (id === generation) commit({ error: describe(error) });
+ }
+ );
+ };
+
const unsubscribe = client.facade.subscribe((event) => {
if (event.kind === 'snapshotUpdated') {
pull();
} else if (event.kind === 'stalenessChanged') {
stalenessSeq += 1;
commit({ staleness: event.staleness });
+ } else if (event.kind === 'withheldUpdateEscalation') {
+ notificationStore.warn(`withheld:${toHex(event.ipnsName)}`, WITHHELD);
+ } else if (event.kind === 'attributableAbuse') {
+ notificationStore.warn(
+ `abuse:${event.description}`,
+ `verification refused an update: ${event.description}`
+ );
}
});
@@ -132,20 +183,26 @@ export function createSnapshotStore(client: EngineClient): SnapshotStore {
setFocus(node) {
if (sameNode(focus, node)) return;
focus = node;
- client.reportFocus(node);
- const id = ++generation;
- client.facade.setFocus(node).then(
- () => {
- if (id === generation) pull();
- },
- (error: unknown) => {
- if (id === generation) commit({ error: describe(error) });
- }
- );
+ assertFocus();
+ },
+ refocus: assertFocus,
+ refresh() {
+ if (disposed) return;
+ const id = generation;
+ const again = (): void => {
+ if (id === generation) pull();
+ };
+ // The nocache hint is best-effort: only the pull it precedes sets `error`.
+ void client.facade.manualRefresh().then(again, again);
},
dispose() {
+ disposed = true;
+ // Supersede every in-flight intent, so a late answer commits nothing.
+ generation += 1;
unsubscribe();
listeners.clear();
+ // A warning names the scope it came from; it must not outlive its engine.
+ notificationStore.clear();
},
};
}
diff --git a/apps/web/src/engine/testFakes.ts b/apps/web/src/engine/testFakes.ts
index ad19c05b6..9c4d05de8 100644
--- a/apps/web/src/engine/testFakes.ts
+++ b/apps/web/src/engine/testFakes.ts
@@ -54,6 +54,8 @@ export function fakeEngine() {
const reported: (Uint8Array | null)[] = [];
let settleFocus: (() => void) | null = null;
let failFocus: ((error: Error) => void) | null = null;
+ let refreshes = 0;
+ let refuseRefresh: Error | null = null;
const client = {
facade: {
@@ -73,6 +75,10 @@ export function fakeEngine() {
failFocus = reject;
});
},
+ manualRefresh() {
+ refreshes += 1;
+ return refuseRefresh === null ? Promise.resolve() : Promise.reject(refuseRefresh);
+ },
},
reportFocus(node: Uint8Array | null) {
reported.push(node);
@@ -90,9 +96,29 @@ export function fakeEngine() {
},
ackFocus: () => settleFocus?.(),
rejectFocus: (error: Error) => failFocus?.(error),
+ refreshes: () => refreshes,
+ refuseRefresh: (error: Error) => (refuseRefresh = error),
subscriberCount: () => listeners.size,
};
}
/** Lets every pending promise callback in the store run. */
export const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0));
+
+/**
+ * jsdom leaves `navigator.onLine` and `document.visibilityState` read-only, so
+ * both stubs must redefine the property *and* fire the event the store listens
+ * on — a value change alone notifies nothing.
+ */
+export function setOnline(online: boolean): void {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, value: online });
+ window.dispatchEvent(new Event(online ? 'online' : 'offline'));
+}
+
+export function setVisible(visible: boolean): void {
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: () => (visible ? 'visible' : 'hidden'),
+ });
+ document.dispatchEvent(new Event('visibilitychange'));
+}
diff --git a/apps/web/src/engine/useRefreshHints.test.tsx b/apps/web/src/engine/useRefreshHints.test.tsx
new file mode 100644
index 000000000..e7d8b2d6e
--- /dev/null
+++ b/apps/web/src/engine/useRefreshHints.test.tsx
@@ -0,0 +1,71 @@
+import { act, render, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import { EngineProvider } from '../providers/EngineProvider';
+import { fakeEngine, setOnline, setVisible } from './testFakes';
+import { useRefreshHints } from './useRefreshHints';
+
+afterEach(() => {
+ setOnline(true);
+ setVisible(true);
+});
+
+function Hints() {
+ useRefreshHints();
+ return null;
+}
+
+function draw(client: ReturnType['client']) {
+ return render(
+ client}>
+
+
+ );
+}
+
+describe('refresh hints', () => {
+ it('does not refresh on mount', async () => {
+ const engine = fakeEngine();
+ draw(engine.client);
+ await waitFor(() => expect(engine.subscriberCount()).toBe(1));
+
+ expect(engine.refreshes()).toBe(0);
+ });
+
+ it('refreshes when the network comes back', async () => {
+ const engine = fakeEngine();
+ draw(engine.client);
+ await waitFor(() => expect(engine.subscriberCount()).toBe(1));
+
+ await act(async () => setOnline(false));
+ expect(engine.refreshes()).toBe(0);
+
+ await act(async () => setOnline(true));
+ expect(engine.refreshes()).toBe(1);
+ });
+
+ it('refreshes when a backgrounded tab comes back on screen', async () => {
+ const engine = fakeEngine();
+ draw(engine.client);
+ await waitFor(() => expect(engine.subscriberCount()).toBe(1));
+
+ await act(async () => setVisible(false));
+ await act(async () => setVisible(true));
+
+ expect(engine.refreshes()).toBe(1);
+ });
+
+ it('waits for both signals before refreshing', async () => {
+ const engine = fakeEngine();
+ draw(engine.client);
+ await waitFor(() => expect(engine.subscriberCount()).toBe(1));
+
+ await act(async () => setVisible(false));
+ await act(async () => setOnline(false));
+ await act(async () => setOnline(true));
+ // Still hidden: nothing on screen is waiting on a fresher answer.
+ expect(engine.refreshes()).toBe(0);
+
+ await act(async () => setVisible(true));
+ expect(engine.refreshes()).toBe(1);
+ });
+});
diff --git a/apps/web/src/engine/useRefreshHints.ts b/apps/web/src/engine/useRefreshHints.ts
new file mode 100644
index 000000000..84a158159
--- /dev/null
+++ b/apps/web/src/engine/useRefreshHints.ts
@@ -0,0 +1,23 @@
+import { useEffect, useRef } from 'react';
+import { useOnlineStatus } from '../hooks/useOnlineStatus';
+import { useVisibility } from '../hooks/useVisibility';
+import { useSnapshotStore } from '../providers/EngineProvider';
+
+/**
+ * Regaining the network or coming back to a backgrounded tab are the two moments
+ * a cached vault is most likely behind, so each edge back into
+ * on-screen-and-online drives one nocache refresh — the transition only, never
+ * the mount.
+ */
+export function useRefreshHints(): void {
+ const store = useSnapshotStore();
+ const online = useOnlineStatus();
+ const visible = useVisibility();
+ const wasReady = useRef(true);
+
+ useEffect(() => {
+ const ready = online && visible;
+ if (ready && !wasReady.current) store.refresh();
+ wasReady.current = ready;
+ }, [online, visible, store]);
+}
diff --git a/apps/web/src/hooks/useFolderPicker.test.tsx b/apps/web/src/hooks/useFolderPicker.test.tsx
new file mode 100644
index 000000000..bce6e31f9
--- /dev/null
+++ b/apps/web/src/hooks/useFolderPicker.test.tsx
@@ -0,0 +1,53 @@
+import { act, render, waitFor } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { fakeEngine } from '../engine/testFakes';
+import type { SnapshotStore } from '../engine/snapshotStore';
+import { EngineProvider, useSnapshotStore } from '../providers/EngineProvider';
+import { useFolderPicker } from './useFolderPicker';
+
+const HOME = new Uint8Array(16).fill(1);
+const NEXT = new Uint8Array(16).fill(2);
+
+function Picker() {
+ useFolderPicker(HOME, '');
+ return null;
+}
+
+/** Publishes the provider's store so the test can drive the route's writer. */
+function Probe({ onStore }: { onStore: (store: SnapshotStore) => void }) {
+ onStore(useSnapshotStore());
+ return null;
+}
+
+describe('the folder picker', () => {
+ it('leaves the engine on the folder the store is focused on', async () => {
+ const engine = fakeEngine();
+ let store!: SnapshotStore;
+ const { rerender } = render(
+ engine.client}>
+ (store = next)} />
+
+
+ );
+ await waitFor(() => expect(engine.focus.length).toBeGreaterThan(0));
+
+ // The route moves under the open dialog, then the dialog closes.
+ await act(async () => {
+ store.setFocus(NEXT);
+ });
+ // The store's own write already names NEXT, so only a *further* write proves
+ // the close re-asserted the window rather than inheriting it.
+ const written = engine.focus.length;
+ const reported = engine.reported.length;
+ rerender(
+ engine.client}>
+ (store = next)} />
+
+ );
+
+ await waitFor(() => expect(engine.focus.length).toBeGreaterThan(written));
+ expect(engine.reported.length).toBeGreaterThan(reported);
+ expect(engine.focus.at(-1)).toBe(NEXT);
+ expect(engine.reported.at(-1)).toBe(NEXT);
+ });
+});
diff --git a/apps/web/src/hooks/useFolderPicker.ts b/apps/web/src/hooks/useFolderPicker.ts
index a527f9b4a..419758bab 100644
--- a/apps/web/src/hooks/useFolderPicker.ts
+++ b/apps/web/src/hooks/useFolderPicker.ts
@@ -8,7 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { SnapshotDescriptor } from '@cipherbox/client';
import { errorMessage } from '../lib/errorMessage';
import { sameNode } from '../lib/nodeId';
-import { useEngine } from '../providers/EngineProvider';
+import { useEngine, useSnapshotStore } from '../providers/EngineProvider';
import { listingRows, type ListingRow } from '../vault/listing';
export interface FolderPicker {
@@ -28,14 +28,14 @@ export interface FolderPicker {
}
/**
- * @param openOn the folder to start on and to hand the focus window back to,
- * captured on mount
+ * @param openOn the folder to start the walk on, captured on mount
* @param excludedKey a subtree the picker must not offer, by hex node id —
* hiding the node hides everything under it, since the walk only descends
* through what it lists
*/
export function useFolderPicker(openOn: Uint8Array | null, excludedKey: string): FolderPicker {
const client = useEngine();
+ const store = useSnapshotStore();
const home = useRef(openOn).current;
const [cursor, setCursor] = useState(home);
const [listing, setListing] = useState(null);
@@ -65,9 +65,8 @@ export function useFolderPicker(openOn: Uint8Array | null, excludedKey: string):
};
}, [client, cursor]);
- // Handing the focus window back is best-effort: the picker is gone, and the
- // route's own focus effect reasserts it either way.
- useEffect(() => () => void client?.facade.setFocus(home).catch(() => undefined), [client, home]);
+ // The walk borrows the focus window; the store owns it and takes it back.
+ useEffect(() => () => store.refocus(), [store]);
// The read effect retires the listing a render later than the cursor moves,
// and a listing outliving its cursor names the folder just left as the
diff --git a/apps/web/src/hooks/useOnlineStatus.ts b/apps/web/src/hooks/useOnlineStatus.ts
new file mode 100644
index 000000000..7f1f538fc
--- /dev/null
+++ b/apps/web/src/hooks/useOnlineStatus.ts
@@ -0,0 +1,23 @@
+import { useSyncExternalStore } from 'react';
+
+function subscribe(onChange: () => void): () => void {
+ window.addEventListener('online', onChange);
+ window.addEventListener('offline', onChange);
+ return () => {
+ window.removeEventListener('online', onChange);
+ window.removeEventListener('offline', onChange);
+ };
+}
+
+/**
+ * Whether the browser has a network path. A refresh hint only
+ * (blueprint/web-client.md `RefreshHintSource`): `navigator.onLine` reports the
+ * link, not whether anything answers over it, so nothing renders from it.
+ */
+export function useOnlineStatus(): boolean {
+ return useSyncExternalStore(
+ subscribe,
+ () => navigator.onLine,
+ () => true
+ );
+}
diff --git a/apps/web/src/hooks/useVisibility.ts b/apps/web/src/hooks/useVisibility.ts
new file mode 100644
index 000000000..7e45df9d2
--- /dev/null
+++ b/apps/web/src/hooks/useVisibility.ts
@@ -0,0 +1,15 @@
+import { useSyncExternalStore } from 'react';
+
+function subscribe(onChange: () => void): () => void {
+ document.addEventListener('visibilitychange', onChange);
+ return () => document.removeEventListener('visibilitychange', onChange);
+}
+
+/** Whether this tab is on screen; a backgrounded tab's cache goes behind. */
+export function useVisibility(): boolean {
+ return useSyncExternalStore(
+ subscribe,
+ () => document.visibilityState === 'visible',
+ () => true
+ );
+}
diff --git a/apps/web/src/stores/notification.store.test.ts b/apps/web/src/stores/notification.store.test.ts
new file mode 100644
index 000000000..baa0513fb
--- /dev/null
+++ b/apps/web/src/stores/notification.store.test.ts
@@ -0,0 +1,45 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { notificationStore } from './notification.store';
+
+afterEach(() => notificationStore.clear());
+
+describe('the notification store', () => {
+ it('collapses a repeat of a warning that already stands', () => {
+ notificationStore.warn('withheld:aa', 'a shared folder stopped serving updates');
+ notificationStore.warn('withheld:aa', 'a shared folder stopped serving updates');
+
+ expect(notificationStore.getState()).toHaveLength(1);
+ });
+
+ it('bounds what an event storm can accumulate', () => {
+ for (let i = 0; i < 12; i += 1) notificationStore.warn(`abuse:${i}`, `refused ${i}`);
+
+ const keys = notificationStore.getState().map((notice) => notice.key);
+ expect(keys).toEqual(['abuse:7', 'abuse:8', 'abuse:9', 'abuse:10', 'abuse:11']);
+ });
+
+ it('raises the same key again once it was dismissed', () => {
+ notificationStore.warn('withheld:aa', 'first');
+ notificationStore.dismiss('withheld:aa');
+ expect(notificationStore.getState()).toHaveLength(0);
+
+ notificationStore.warn('withheld:aa', 'again');
+ expect(notificationStore.getState()).toHaveLength(1);
+ });
+
+ it('notifies only on a change and publishes a stable snapshot', () => {
+ let changes = 0;
+ const drop = notificationStore.subscribe(() => (changes += 1));
+
+ notificationStore.warn('abuse:x', 'refused');
+ notificationStore.warn('abuse:x', 'refused');
+ notificationStore.dismiss('abuse:missing');
+ expect(changes).toBe(1);
+ // `useSyncExternalStore` bails out on identity: a repeat read must match.
+ expect(notificationStore.getState()).toBe(notificationStore.getState());
+
+ drop();
+ notificationStore.warn('abuse:y', 'refused');
+ expect(changes).toBe(1);
+ });
+});
diff --git a/apps/web/src/stores/notification.store.ts b/apps/web/src/stores/notification.store.ts
new file mode 100644
index 000000000..eaae6605b
--- /dev/null
+++ b/apps/web/src/stores/notification.store.ts
@@ -0,0 +1,46 @@
+/**
+ * The warning-notice surface: a trust violation or a withheld-update escalation
+ * renders here, as its own class, and never on the staleness ladder
+ * (blueprint/web-client.md "Staleness ladder rendering").
+ *
+ * Memory only, and cleared when the engine that raised the notices goes away —
+ * a notice names the scope it came from, so it must not outlive that session.
+ */
+
+/** One standing warning. `key` is its identity: a repeat collapses onto it. */
+export interface Notice {
+ readonly key: string;
+ readonly message: string;
+}
+
+/** Distinct keys accumulate unbounded otherwise; the newest warning wins. */
+const MAX_NOTICES = 5;
+
+let notices: readonly Notice[] = Object.freeze([]);
+const listeners = new Set<() => void>();
+
+function publish(next: readonly Notice[]): void {
+ // Frozen: a consumer must not mutate what the UI is already rendering.
+ notices = Object.freeze(next);
+ for (const listener of listeners) listener();
+}
+
+export const notificationStore = {
+ subscribe(onStoreChange: () => void): () => void {
+ listeners.add(onStoreChange);
+ return () => listeners.delete(onStoreChange);
+ },
+ getState: (): readonly Notice[] => notices,
+ /** Raises `message` under `key`, or does nothing if that key already stands. */
+ warn(key: string, message: string): void {
+ if (notices.some((notice) => notice.key === key)) return;
+ publish([...notices, { key, message }].slice(-MAX_NOTICES));
+ },
+ dismiss(key: string): void {
+ const next = notices.filter((notice) => notice.key !== key);
+ if (next.length !== notices.length) publish(next);
+ },
+ clear(): void {
+ if (notices.length > 0) publish([]);
+ },
+};
diff --git a/apps/web/src/styles/file-browser.css b/apps/web/src/styles/file-browser.css
index b90fdf9e4..3de990ec9 100644
--- a/apps/web/src/styles/file-browser.css
+++ b/apps/web/src/styles/file-browser.css
@@ -23,6 +23,40 @@
border: var(--border-thickness) solid rgb(239 68 68 / 20%);
}
+/* A recoverable refusal: warning-toned and retryable, over the listing it
+ interrupted, never the settled red of a failure that will not clear. */
+.file-browser-notice {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ margin-bottom: var(--spacing-sm);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ color: var(--color-warning);
+ background-color: var(--color-warning-bg);
+ border: var(--border-thickness) solid var(--color-warning-dim);
+}
+
+.file-browser-notice-message {
+ flex: 1;
+}
+
+.file-browser-notice-retry {
+ padding: 0;
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ background: none;
+ border: none;
+ cursor: pointer;
+}
+
+.file-browser-notice-retry:hover,
+.file-browser-notice-retry:focus-visible {
+ text-decoration: underline;
+}
+
.file-browser-loading {
padding: var(--spacing-sm) 0;
font-family: var(--font-family-mono);
diff --git a/apps/web/src/styles/layout.css b/apps/web/src/styles/layout.css
index 8f75d14de..3d0d378a9 100644
--- a/apps/web/src/styles/layout.css
+++ b/apps/web/src/styles/layout.css
@@ -292,9 +292,18 @@
display: flex;
align-items: center;
gap: 6px;
+ padding: 0;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
+ background: none;
+ border: none;
+ cursor: pointer;
+}
+
+.status-indicator:hover,
+.status-indicator:focus-visible {
+ color: var(--color-text-primary);
}
.status-indicator-dot {
@@ -338,3 +347,67 @@
animation: none;
}
}
+
+/* ==========================================================================
+ Offline Banner
+ ========================================================================== */
+
+.offline-banner {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: var(--spacing-xs);
+ padding: var(--spacing-xs) var(--spacing-md);
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-xs);
+ color: var(--color-warning);
+ background-color: var(--color-warning-bg);
+ border-bottom: var(--border-thickness) solid var(--color-warning-dim);
+}
+
+/* ==========================================================================
+ Notification Toast - the warning class, never the staleness ladder
+ ========================================================================== */
+
+.notification-toast {
+ position: fixed;
+ right: var(--spacing-md);
+ /* Clears the footer rather than sitting over the staleness rung. */
+ bottom: var(--spacing-xl);
+ z-index: 40;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+ max-width: 380px;
+}
+
+.notification-toast-item {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--spacing-xs);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ color: var(--color-warning);
+ background-color: var(--color-background);
+ border: var(--border-thickness) solid var(--color-warning);
+}
+
+.notification-toast-message {
+ flex: 1;
+}
+
+.notification-toast-dismiss {
+ padding: 0;
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ background: none;
+ border: none;
+ cursor: pointer;
+}
+
+.notification-toast-dismiss:hover,
+.notification-toast-dismiss:focus-visible {
+ text-decoration: underline;
+}