diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx
index cc609b905..4acdf93a8 100644
--- a/apps/web/src/App.test.tsx
+++ b/apps/web/src/App.test.tsx
@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
-import { describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it } from 'vitest';
import { App } from './App';
+import { authStore } from './stores/auth.store';
import { fakeCoreKitSession, fakeEngineClient, pageWrapper } from './test/authFakes';
function renderAt(path: string) {
@@ -15,6 +16,8 @@ function renderAt(path: string) {
);
}
+afterEach(() => authStore.signedOut());
+
describe('App routes', () => {
it('renders the login page at the root', () => {
renderAt('/');
@@ -22,13 +25,23 @@ describe('App routes', () => {
});
it('renders the vault browser for the vault root when no node id is routed', () => {
+ authStore.signedIn('google', 'user@example.test');
renderAt('/files');
- expect(screen.getByTestId('files-node').textContent).toBe('root');
+ expect(screen.getByTestId('app-shell')).toBeDefined();
+ expect(screen.getByTestId('file-browser')).toBeDefined();
});
it('keys the vault browser on the routed node id', () => {
- renderAt('/files/0a1b2c');
- expect(screen.getByTestId('files-node').textContent).toBe('0a1b2c');
+ authStore.signedIn('google', 'user@example.test');
+ renderAt(`/files/${'0a'.repeat(16)}`);
+ expect(screen.getByTestId('file-browser')).toBeDefined();
+ });
+
+ it('sends a signed-out tab away from the vault browser', async () => {
+ renderAt('/files');
+ // The redirect waits on the Core Kit restore: a tab that is still deciding
+ // must not be thrown out of its own vault.
+ expect(await screen.findByRole('heading', { name: 'CipherBox' })).toBeDefined();
});
it('sends an unknown path back to login', () => {
diff --git a/apps/web/src/components/file-browser/Breadcrumbs.tsx b/apps/web/src/components/file-browser/Breadcrumbs.tsx
new file mode 100644
index 000000000..ff6af7c35
--- /dev/null
+++ b/apps/web/src/components/file-browser/Breadcrumbs.tsx
@@ -0,0 +1,35 @@
+import { Fragment } from 'react';
+import type { BreadcrumbDescriptor } from '@cipherbox/client';
+import { toHex } from '@cipherbox/client';
+
+interface BreadcrumbsProps {
+ /** Root-first trail, ending at the folder on screen. */
+ crumbs: BreadcrumbDescriptor[];
+ onNavigate: (node: Uint8Array) => void;
+}
+
+/** The vault's current location as a terminal path: `~/root/documents`. */
+export function Breadcrumbs({ crumbs, onNavigate }: BreadcrumbsProps) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/file-browser/EmptyState.tsx b/apps/web/src/components/file-browser/EmptyState.tsx
new file mode 100644
index 000000000..af073e3f3
--- /dev/null
+++ b/apps/web/src/components/file-browser/EmptyState.tsx
@@ -0,0 +1,20 @@
+/** Terminal window running `ls -la` over nothing, in box-drawing characters. */
+const TERMINAL_ART = `┌──────────────────────┐
+│ $ ls -la │
+│ total 0 │
+│ $ █ │
+└──────────────────────┘`;
+
+/** What a folder with no children shows. */
+export function EmptyState() {
+ return (
+
+
+
+ {TERMINAL_ART}
+
+
// EMPTY DIRECTORY
+
+
+ );
+}
diff --git a/apps/web/src/components/file-browser/FileBrowser.tsx b/apps/web/src/components/file-browser/FileBrowser.tsx
new file mode 100644
index 000000000..0e2931d31
--- /dev/null
+++ b/apps/web/src/components/file-browser/FileBrowser.tsx
@@ -0,0 +1,37 @@
+import { useFolderNavigation } from '../../vault/useFolderNavigation';
+import { Breadcrumbs } from './Breadcrumbs';
+import { EmptyState } from './EmptyState';
+import { FileList } from './FileList';
+
+/** The vault browser: where you are, what is in it, and how to move. */
+export function FileBrowser() {
+ const { rows, breadcrumbs, isLoading, isRoot, error, navigateTo, navigateUp } =
+ useFolderNavigation();
+ const settled = !isLoading && error === null;
+
+ return (
+
+
+ {error && (
+
+ {error.message}
+
+ )}
+ {isLoading && (
+
+ {'// LOADING VAULT...'}
+
+ )}
+ {/* An empty non-root folder still lists, so `[..]` remains reachable. */}
+ {settled && (rows.length > 0 || !isRoot) && (
+
+ )}
+ {settled && rows.length === 0 &&
}
+
+ );
+}
diff --git a/apps/web/src/components/file-browser/FileList.tsx b/apps/web/src/components/file-browser/FileList.tsx
new file mode 100644
index 000000000..d4c73d5b8
--- /dev/null
+++ b/apps/web/src/components/file-browser/FileList.tsx
@@ -0,0 +1,36 @@
+import type { ListingRow } from '../../vault/listing';
+import { FileListItem } from './FileListItem';
+import { ParentDirRow } from './ParentDirRow';
+
+interface FileListProps {
+ rows: ListingRow[];
+ /** False at the vault root, which has no parent to step up to. */
+ showParentRow: boolean;
+ onOpen: (node: Uint8Array) => void;
+ onNavigateUp: () => void;
+}
+
+/** The routed folder's direct children, in columns. */
+export function FileList({ rows, showParentRow, onOpen, onNavigateUp }: FileListProps) {
+ return (
+
+
+
+ [NAME]
+
+
+ [SIZE]
+
+
+ [MODIFIED]
+
+
+
+ {showParentRow &&
}
+ {rows.map((row) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/file-browser/FileListItem.tsx b/apps/web/src/components/file-browser/FileListItem.tsx
new file mode 100644
index 000000000..ff90386ab
--- /dev/null
+++ b/apps/web/src/components/file-browser/FileListItem.tsx
@@ -0,0 +1,64 @@
+import type { ListingRow } from '../../vault/listing';
+
+interface FileListItemProps {
+ row: ListingRow;
+ /** Opens a folder. Files have no read action until #808 lands. */
+ onOpen: (node: Uint8Array) => void;
+}
+
+/** One direct child: kind marker, name, size, mtime, and its queue status. */
+export function FileListItem({ row, onOpen }: FileListItemProps) {
+ const isFolder = row.kind === 'folder';
+ const open = () => {
+ if (isFolder) onOpen(row.id);
+ };
+
+ return (
+ {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ event.preventDefault();
+ open();
+ }}
+ >
+
+
+ {row.icon}
+
+ {row.name}
+
+
+
+
+ {row.size}
+
+
+ {row.modified}
+
+
+
+ );
+}
+
+/** The engine's per-node queue flags, rendered as the engine reports them. */
+function ItemStatus({ row }: { row: ListingRow }) {
+ if (row.deadLetter) {
+ return (
+
+ [!]
+
+ );
+ }
+ if (row.pending === 'none') return null;
+ return (
+
+ [~]
+
+ );
+}
diff --git a/apps/web/src/components/file-browser/ParentDirRow.tsx b/apps/web/src/components/file-browser/ParentDirRow.tsx
new file mode 100644
index 000000000..e8ef8ded9
--- /dev/null
+++ b/apps/web/src/components/file-browser/ParentDirRow.tsx
@@ -0,0 +1,36 @@
+interface ParentDirRowProps {
+ onActivate: () => void;
+}
+
+/** The `[..]` row that opens the parent folder, first in every non-root list. */
+export function ParentDirRow({ onActivate }: ParentDirRowProps) {
+ return (
+ {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ event.preventDefault();
+ onActivate();
+ }}
+ data-testid="parent-dir-row"
+ >
+
+
+ [..]
+
+ PARENT_DIR
+
+
+
+ -
+
+
+ -
+
+
+
+ );
+}
diff --git a/apps/web/src/components/layout/AppFooter.tsx b/apps/web/src/components/layout/AppFooter.tsx
new file mode 100644
index 000000000..9d87dec3c
--- /dev/null
+++ b/apps/web/src/components/layout/AppFooter.tsx
@@ -0,0 +1,25 @@
+import { StatusIndicator } from './StatusIndicator';
+
+/** Chrome: attribution, outbound links, and the staleness rung. */
+export function AppFooter() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/layout/AppHeader.tsx b/apps/web/src/components/layout/AppHeader.tsx
new file mode 100644
index 000000000..a65ed4508
--- /dev/null
+++ b/apps/web/src/components/layout/AppHeader.tsx
@@ -0,0 +1,16 @@
+import { UserMenu } from './UserMenu';
+
+/** Wordmark and account menu. */
+export function AppHeader() {
+ return (
+
+
+ >
+ CIPHERBOX
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx
new file mode 100644
index 000000000..bd3a24620
--- /dev/null
+++ b/apps/web/src/components/layout/AppShell.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react';
+import { StagingBanner } from '../StagingBanner';
+import { AppFooter } from './AppFooter';
+import { AppHeader } from './AppHeader';
+import { AppSidebar } from './AppSidebar';
+
+interface AppShellProps {
+ children: ReactNode;
+}
+
+/** The signed-in frame: header, sidebar, scrollable main, footer. */
+export function AppShell({ children }: AppShellProps) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/layout/AppSidebar.tsx b/apps/web/src/components/layout/AppSidebar.tsx
new file mode 100644
index 000000000..b3f3eafc7
--- /dev/null
+++ b/apps/web/src/components/layout/AppSidebar.tsx
@@ -0,0 +1,18 @@
+import { useLocation } from 'react-router-dom';
+import { NavItem } from './NavItem';
+
+/** Vault navigation. Only `/files` is served in this build; the rest is #643. */
+export function AppSidebar() {
+ const { pathname } = useLocation();
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/layout/NavItem.tsx b/apps/web/src/components/layout/NavItem.tsx
new file mode 100644
index 000000000..31503f0ce
--- /dev/null
+++ b/apps/web/src/components/layout/NavItem.tsx
@@ -0,0 +1,101 @@
+import type { ReactNode } from 'react';
+import { Link } from 'react-router-dom';
+
+export type NavIcon = 'folder' | 'shared' | 'bin' | 'settings';
+
+interface NavItemProps {
+ to: string;
+ icon: NavIcon;
+ label: string;
+ active: boolean;
+ /** A destination this build does not serve yet; renders inert. */
+ comingSoon?: boolean;
+}
+
+const ICONS: Record = {
+ folder: (
+
+ ),
+ shared: (
+ <>
+
+
+
+ >
+ ),
+ bin: (
+
+ ),
+ settings: (
+ <>
+
+
+ >
+ ),
+};
+
+/** One sidebar destination. */
+export function NavItem({ to, icon, label, active, comingSoon = false }: NavItemProps) {
+ const testId = `nav-item-${label.toLowerCase()}`;
+ const body = (
+ <>
+
+
+
+ {label}
+ >
+ );
+
+ if (comingSoon) {
+ return (
+
+ {body}
+ soon
+
+ );
+ }
+
+ return (
+
+ {body}
+
+ );
+}
diff --git a/apps/web/src/components/layout/StatusIndicator.tsx b/apps/web/src/components/layout/StatusIndicator.tsx
new file mode 100644
index 000000000..e9bf99733
--- /dev/null
+++ b/apps/web/src/components/layout/StatusIndicator.tsx
@@ -0,0 +1,26 @@
+import { useStaleness } from '../../engine/useStaleness';
+
+/** The staleness ladder's rungs, as the footer renders them (#33 D4). */
+const RUNGS = {
+ fresh: { label: 'synced', className: 'status-indicator--fresh' },
+ reconciling: { label: 'syncing...', className: 'status-indicator--reconciling' },
+ stale: { label: 'stale', className: 'status-indicator--stale' },
+ offline: { label: 'offline', className: 'status-indicator--offline' },
+} as const;
+
+/** Where the vault sits on the staleness ladder. */
+export function StatusIndicator() {
+ const staleness = useStaleness();
+ const rung = RUNGS[staleness];
+
+ return (
+
+
+ {rung.label}
+
+ );
+}
diff --git a/apps/web/src/components/layout/UserMenu.test.tsx b/apps/web/src/components/layout/UserMenu.test.tsx
new file mode 100644
index 000000000..2b29b6f86
--- /dev/null
+++ b/apps/web/src/components/layout/UserMenu.test.tsx
@@ -0,0 +1,40 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { afterEach, describe, expect, it } from 'vitest';
+import { UserMenu } from './UserMenu';
+import { authStore } from '../../stores/auth.store';
+import { fakeCoreKitSession, fakeEngineClient, pageWrapper } from '../../test/authFakes';
+
+function renderMenu() {
+ const Providers = pageWrapper(fakeEngineClient().client, fakeCoreKitSession().session);
+ return render(
+
+
+
+
+
+ );
+}
+
+afterEach(() => authStore.signedOut());
+
+describe('UserMenu', () => {
+ it('closes on Escape pressed inside the dropdown, not just on the trigger', () => {
+ authStore.signedIn('google', 'user@example.test');
+ renderMenu();
+
+ fireEvent.click(screen.getByRole('button', { expanded: false }));
+ const logout = screen.getByTestId('logout-button');
+
+ fireEvent.keyDown(logout, { key: 'Escape' });
+
+ expect(screen.queryByTestId('logout-button')).toBeNull();
+ });
+
+ it('names a wallet login that carries no email', () => {
+ authStore.signedIn('wallet', null);
+ renderMenu();
+
+ expect(screen.getByTestId('user-menu').textContent).toContain('[an0n]');
+ });
+});
diff --git a/apps/web/src/components/layout/UserMenu.tsx b/apps/web/src/components/layout/UserMenu.tsx
new file mode 100644
index 000000000..712d92a43
--- /dev/null
+++ b/apps/web/src/components/layout/UserMenu.tsx
@@ -0,0 +1,42 @@
+import { useState } from 'react';
+import { LogoutButton } from '../auth/LogoutButton';
+import { useAuthState } from '../../stores/auth.store';
+
+/**
+ * Who is signed in, and the way out. Escape is handled on the container, not the
+ * trigger, so it still closes once focus moves into the dropdown.
+ */
+export function UserMenu() {
+ const { email } = useAuthState();
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+ setIsOpen(true)}
+ onMouseLeave={() => setIsOpen(false)}
+ onKeyDown={(event) => {
+ if (event.key === 'Escape') setIsOpen(false);
+ }}
+ >
+
+
+ {isOpen && (
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/engine/snapshotStore.ts b/apps/web/src/engine/snapshotStore.ts
index 8ea639376..dcef6e689 100644
--- a/apps/web/src/engine/snapshotStore.ts
+++ b/apps/web/src/engine/snapshotStore.ts
@@ -7,6 +7,7 @@
import { EngineRequestError } from '@cipherbox/client';
import type { EngineClient, SnapshotDescriptor, Staleness } from '@cipherbox/client';
+import { sameNode } from '../lib/nodeId';
/** A failed pull, carrying the engine's stable code so the UI can classify it. */
export interface SnapshotError {
@@ -153,9 +154,3 @@ function describe(error: unknown): SnapshotError {
if (error instanceof EngineRequestError) return { message: error.message, code: error.code };
return { message: error instanceof Error ? error.message : String(error) };
}
-
-/** Node ids compare by value: callers hand in a fresh array per render. */
-function sameNode(a: Uint8Array | null, b: Uint8Array | null): boolean {
- if (a === null || b === null) return a === b;
- return a.length === b.length && a.every((byte, i) => byte === b[i]);
-}
diff --git a/apps/web/src/engine/testFakes.ts b/apps/web/src/engine/testFakes.ts
index 3468a7cd0..ad19c05b6 100644
--- a/apps/web/src/engine/testFakes.ts
+++ b/apps/web/src/engine/testFakes.ts
@@ -21,6 +21,7 @@ export function view(
return {
root: ROOT_ID,
folder,
+ folderName: '',
children: Array.from({ length: children }, (_, i) => ({
id: new Uint8Array(16).fill(i + 1),
name: `child-${i}`,
diff --git a/apps/web/src/lib/nodeId.test.ts b/apps/web/src/lib/nodeId.test.ts
new file mode 100644
index 000000000..a55b15859
--- /dev/null
+++ b/apps/web/src/lib/nodeId.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+import { folderPath, folderRoute, sameNode } from './nodeId';
+
+const NODE = new Uint8Array(16).fill(0xab);
+
+describe('folderRoute', () => {
+ it('reads an absent param as the vault root', () => {
+ expect(folderRoute(undefined)).toEqual({ kind: 'root' });
+ });
+
+ it('round-trips a node id through its route', () => {
+ const route = folderRoute(folderPath(NODE).replace('/files/', ''));
+ expect(route).toEqual({ kind: 'node', id: NODE });
+ });
+
+ it.each([
+ ['too short', 'ab'],
+ ['too long', 'ab'.repeat(17)],
+ ['not hex', 'zz'.repeat(16)],
+ ['empty', ''],
+ ])('rejects a param that is %s', (_case, param) => {
+ expect(folderRoute(param)).toEqual({ kind: 'invalid' });
+ });
+});
+
+describe('folderPath', () => {
+ it('addresses the current root without a node id', () => {
+ expect(folderPath(null)).toBe('/files');
+ });
+
+ it('addresses a node by lowercase hex', () => {
+ expect(folderPath(NODE)).toBe(`/files/${'ab'.repeat(16)}`);
+ });
+});
+
+describe('sameNode', () => {
+ it('compares by value, not identity', () => {
+ expect(sameNode(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(true);
+ expect(sameNode(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(false);
+ expect(sameNode(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(false);
+ });
+
+ it('treats null as the root, matching only itself', () => {
+ expect(sameNode(null, null)).toBe(true);
+ expect(sameNode(null, new Uint8Array([1]))).toBe(false);
+ });
+});
diff --git a/apps/web/src/lib/nodeId.ts b/apps/web/src/lib/nodeId.ts
new file mode 100644
index 000000000..0b0a5b4f2
--- /dev/null
+++ b/apps/web/src/lib/nodeId.ts
@@ -0,0 +1,35 @@
+/**
+ * Node-id addressing for the UI. Routes and UI-owned state key on the stable
+ * node id (blueprint/web-client.md "UI state law"), carried in a URL as
+ * lowercase hex through the client package's one hex codec.
+ */
+
+import { fromHex, toHex } from '@cipherbox/client';
+
+/** A node id is 16 raw bytes (`crates/core` `NodeId`). */
+const NODE_ID_BYTES = 16;
+
+/** What the `/files/:nodeId?` param addresses. */
+export type FolderRoute = { kind: 'root' } | { kind: 'node'; id: Uint8Array } | { kind: 'invalid' };
+
+/** Resolves a route param; anything that is not a node id resolves invalid. */
+export function folderRoute(param: string | undefined): FolderRoute {
+ if (param === undefined) return { kind: 'root' };
+ if (param.length !== NODE_ID_BYTES * 2) return { kind: 'invalid' };
+ try {
+ return { kind: 'node', id: fromHex(param) };
+ } catch {
+ return { kind: 'invalid' };
+ }
+}
+
+/** The vault-browser route for a node id; `null` addresses the current root. */
+export function folderPath(id: Uint8Array | null): string {
+ return id === null ? '/files' : `/files/${toHex(id)}`;
+}
+
+/** Node ids compare by value: callers hand in a fresh array per render. */
+export function sameNode(a: Uint8Array | null, b: Uint8Array | null): boolean {
+ if (a === null || b === null) return a === b;
+ return a.length === b.length && a.every((byte, i) => byte === b[i]);
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 4b3324700..f363f9b75 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -2,6 +2,10 @@
import './polyfills';
import './index.css';
import './styles/login.css';
+import './styles/layout.css';
+import './styles/file-browser.css';
+import './styles/breadcrumbs.css';
+import './styles/responsive.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
diff --git a/apps/web/src/routes/FilesPage.tsx b/apps/web/src/routes/FilesPage.tsx
index 59c0d43c8..fa947bb40 100644
--- a/apps/web/src/routes/FilesPage.tsx
+++ b/apps/web/src/routes/FilesPage.tsx
@@ -1,16 +1,30 @@
-import { useParams } from 'react-router-dom';
-import { LogoutButton } from '../components/auth/LogoutButton';
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAuth } from '../auth/useAuth';
+import { FileBrowser } from '../components/file-browser/FileBrowser';
+import { AppShell } from '../components/layout/AppShell';
-/** Placeholder for the vault browser (#805). */
+/**
+ * The vault browser. No route guard framework: an unauthenticated tab redirects
+ * on facade auth state (blueprint/web-client.md "Composition").
+ */
export function FilesPage() {
- // Routes key on the stable node id; an absent id is the vault's current root
- // (blueprint/web-client.md "UI state law").
- const { nodeId } = useParams<{ nodeId: string }>();
+ const { isAuthenticated, isReady } = useAuth();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (isReady && !isAuthenticated) navigate('/');
+ }, [isAuthenticated, isReady, navigate]);
+
return (
-
- Files
- {nodeId ?? 'root'}
-
-
+
+ {isAuthenticated ? (
+
+ ) : (
+
+ {'// CHECKING SESSION...'}
+
+ )}
+
);
}
diff --git a/apps/web/src/styles/breadcrumbs.css b/apps/web/src/styles/breadcrumbs.css
new file mode 100644
index 000000000..9d2cdc55f
--- /dev/null
+++ b/apps/web/src/styles/breadcrumbs.css
@@ -0,0 +1,55 @@
+/* ==========================================================================
+ Breadcrumb Navigation - Terminal Aesthetic
+ ========================================================================== */
+
+.breadcrumb-nav {
+ display: flex;
+ align-items: center;
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ padding: var(--spacing-xs) 0;
+ min-height: 2.5rem;
+}
+
+.breadcrumb-prefix {
+ color: var(--color-text-secondary);
+}
+
+.breadcrumb-separator {
+ color: var(--color-text-secondary);
+ margin: 0 2px;
+}
+
+.breadcrumb-item {
+ background: transparent;
+ border: none;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ padding: 2px 4px;
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-normal);
+ border-radius: 0;
+ transition: color 0.15s ease;
+}
+
+.breadcrumb-item:hover {
+ color: var(--color-text-primary);
+}
+
+.breadcrumb-item:focus {
+ outline: none;
+ background-color: var(--color-green-darker);
+}
+
+.breadcrumb-item:focus-visible {
+ outline: 1px solid var(--color-green-primary);
+ outline-offset: 1px;
+}
+
+/* The folder on screen - slightly more prominent than its ancestors. */
+.breadcrumb-item--current {
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+}
diff --git a/apps/web/src/styles/file-browser.css b/apps/web/src/styles/file-browser.css
new file mode 100644
index 000000000..b90fdf9e4
--- /dev/null
+++ b/apps/web/src/styles/file-browser.css
@@ -0,0 +1,186 @@
+/* ==========================================================================
+ File Browser - Terminal Aesthetic
+ ========================================================================== */
+
+.file-browser {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: var(--spacing-sm) 20px var(--spacing-md);
+ overflow-x: hidden;
+ overflow-y: auto;
+ background-color: var(--color-background);
+ position: relative;
+}
+
+.file-browser-error {
+ 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-error);
+ background-color: rgb(239 68 68 / 8%);
+ border: var(--border-thickness) solid rgb(239 68 68 / 20%);
+}
+
+.file-browser-loading {
+ padding: var(--spacing-sm) 0;
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+}
+
+/* ==========================================================================
+ File List
+ ========================================================================== */
+
+.file-list {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ border: var(--border-thickness) solid var(--color-green-primary);
+}
+
+.file-list-header {
+ display: grid;
+ grid-template-columns: 1fr 120px 180px;
+ gap: var(--spacing-md);
+ padding: var(--spacing-sm) var(--spacing-md);
+ border-bottom: var(--border-thickness) solid var(--color-green-primary);
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--color-text-primary);
+ background-color: var(--color-background);
+}
+
+.file-list-header-name,
+.file-list-header-size,
+.file-list-header-date {
+ display: flex;
+ align-items: center;
+}
+
+.file-list-body {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+}
+
+/* Desktop is a 3-column grid; the two row wrappers collapse to lines on mobile. */
+.file-list-item {
+ display: grid;
+ grid-template-columns: 1fr 120px 180px;
+ grid-template-areas: 'name size date';
+ gap: var(--spacing-md);
+ padding: var(--spacing-sm) var(--spacing-md);
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+ border-bottom: var(--border-thickness) solid var(--color-border-dim);
+ font-family: var(--font-family-mono);
+}
+
+.file-list-item:last-child {
+ border-bottom: none;
+}
+
+.file-list-item:hover,
+.file-list-item:focus-visible {
+ background-color: var(--color-green-darker);
+}
+
+.file-list-item--parent {
+ cursor: pointer;
+}
+
+.file-list-item-row-top {
+ grid-area: name;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+
+/* Children participate directly in the parent grid on desktop. */
+.file-list-item-row-bottom {
+ display: contents;
+}
+
+.file-list-item-icon {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ flex-shrink: 0;
+ color: var(--color-text-primary);
+}
+
+.file-list-item-name {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--color-text-primary);
+ font-size: var(--font-size-sm);
+}
+
+.file-list-item-size {
+ grid-area: size;
+ display: flex;
+ align-items: center;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+}
+
+.file-list-item-date {
+ grid-area: date;
+ display: flex;
+ align-items: center;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+}
+
+/* Queue marker after the name: [~] unpublished, [!] dead-lettered. */
+.file-list-item-status {
+ flex-shrink: 0;
+ font-size: var(--font-size-xs);
+ color: var(--color-text-secondary);
+}
+
+.file-list-item-status--dead {
+ color: var(--color-error);
+}
+
+/* ==========================================================================
+ Empty State
+ ========================================================================== */
+
+.empty-state {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 300px;
+ margin: var(--spacing-lg);
+}
+
+.empty-state-content {
+ text-align: center;
+ padding: var(--spacing-lg);
+}
+
+.empty-state-ascii {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-xs);
+ line-height: 1.2;
+ color: var(--color-green-primary);
+ margin: 0 0 var(--spacing-md);
+ white-space: pre;
+}
+
+.empty-state-text {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ margin: 0;
+ color: #8b9a8f;
+}
diff --git a/apps/web/src/styles/layout.css b/apps/web/src/styles/layout.css
new file mode 100644
index 000000000..8f75d14de
--- /dev/null
+++ b/apps/web/src/styles/layout.css
@@ -0,0 +1,340 @@
+/* ==========================================================================
+ App Shell Layout - CSS Grid System
+ ========================================================================== */
+
+.app-frame {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+/* The banner takes a row of the frame rather than overlaying the header. */
+.app-frame .staging-banner {
+ position: static;
+ flex-shrink: 0;
+}
+
+.app-shell {
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ grid-template-columns: 180px 1fr;
+ grid-template-areas:
+ 'header header'
+ 'sidebar main'
+ 'footer footer';
+ height: auto;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ position: relative;
+}
+
+/* ==========================================================================
+ Header
+ ========================================================================== */
+
+.app-header {
+ grid-area: header;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px 24px;
+ background-color: rgb(0 0 0 / 50%);
+ border-bottom: var(--border-thickness) solid var(--color-border-dim);
+ position: relative;
+ z-index: 2;
+}
+
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+}
+
+.header-prompt {
+ font-size: 20px;
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-primary);
+}
+
+.header-logo {
+ font-size: 14px;
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ letter-spacing: 1px;
+}
+
+.header-right {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+}
+
+/* ==========================================================================
+ User Menu
+ ========================================================================== */
+
+.user-menu {
+ position: relative;
+}
+
+.user-menu-trigger {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px var(--spacing-xs);
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ background: none;
+ border: none;
+ cursor: pointer;
+ transition: color 0.15s ease;
+}
+
+.user-menu-trigger:hover {
+ color: var(--color-text-primary);
+}
+
+.user-menu-trigger:focus-visible {
+ outline: 1px solid var(--color-green-primary);
+ outline-offset: 1px;
+}
+
+.user-menu-email {
+ max-width: 200px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.user-menu-caret {
+ font-size: 8px;
+}
+
+.user-menu-dropdown {
+ position: absolute;
+ top: calc(100% + 4px);
+ right: 0;
+ min-width: 120px;
+ background-color: var(--color-background);
+ border: var(--border-thickness) solid var(--color-border-dim);
+ padding: 4px 0;
+ z-index: 100;
+}
+
+/* Invisible bridge over the 4px gap so the pointer never leaves .user-menu. */
+.user-menu-dropdown::before {
+ content: '';
+ position: absolute;
+ top: -4px;
+ left: 0;
+ right: 0;
+ height: 4px;
+}
+
+/* The shared .logout-link is an inline control; a menu row must fill the sheet. */
+.user-menu-dropdown .logout-link {
+ display: block;
+ width: 100%;
+ padding: var(--spacing-xs) var(--spacing-sm);
+ text-align: left;
+ transition:
+ color 0.15s ease,
+ background-color 0.15s ease;
+}
+
+.user-menu-dropdown .logout-link:hover:not(:disabled) {
+ background-color: #001a11;
+}
+
+/* ==========================================================================
+ Sidebar
+ ========================================================================== */
+
+.app-sidebar {
+ grid-area: sidebar;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ background-color: rgb(0 0 0 / 50%);
+ border-right: var(--border-thickness) solid var(--color-border-dim);
+ overflow-y: auto;
+ position: relative;
+ z-index: 1;
+}
+
+.sidebar-nav {
+ padding: var(--spacing-md);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-normal);
+ color: var(--color-text-secondary);
+ text-decoration: none;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease;
+}
+
+.nav-item:hover {
+ color: var(--color-text-primary);
+ text-decoration: none;
+}
+
+.nav-item--active {
+ background-color: #001a11;
+ color: var(--color-text-primary);
+ font-weight: var(--font-weight-semibold);
+}
+
+.nav-item--disabled,
+.nav-item--disabled:hover {
+ color: var(--color-text-dim);
+ background-color: transparent;
+ cursor: default;
+}
+
+.nav-item-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+}
+
+.nav-item-label {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.nav-item-badge {
+ margin-left: auto;
+ flex-shrink: 0;
+ padding: 0 3px;
+ font-size: var(--font-size-xxs);
+ color: var(--color-text-dim);
+ border: var(--border-thickness) solid var(--color-border-dim);
+ text-transform: lowercase;
+}
+
+/* ==========================================================================
+ Main Content Area
+ ========================================================================== */
+
+.app-main {
+ grid-area: main;
+ overflow-y: auto;
+ background-color: rgb(0 0 0 / 90%);
+ position: relative;
+ z-index: 1;
+}
+
+/* ==========================================================================
+ Footer
+ ========================================================================== */
+
+.app-footer {
+ grid-area: footer;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--spacing-xs) var(--spacing-lg);
+ background-color: rgb(0 0 0 / 50%);
+ border-top: var(--border-thickness) solid var(--color-border-dim);
+ position: relative;
+ z-index: 1;
+}
+
+.footer-left,
+.footer-center,
+.footer-right {
+ display: flex;
+ align-items: center;
+}
+
+.footer-center {
+ gap: var(--spacing-md);
+}
+
+.footer-copyright {
+ font-size: var(--font-size-xxs);
+ color: var(--color-border-dim);
+}
+
+.footer-link {
+ font-size: var(--font-size-xxs);
+ color: var(--color-text-secondary);
+ text-decoration: none;
+ transition: color 0.15s ease;
+}
+
+.footer-link:hover {
+ color: var(--color-text-primary);
+ text-decoration: none;
+}
+
+/* ==========================================================================
+ Status Indicator - the staleness ladder's rungs
+ ========================================================================== */
+
+.status-indicator {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-xs);
+ color: var(--color-text-secondary);
+}
+
+.status-indicator-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background-color: var(--color-text-secondary);
+}
+
+.status-indicator--fresh .status-indicator-dot {
+ background-color: var(--color-green-primary);
+ box-shadow: var(--glow-green);
+}
+
+.status-indicator--reconciling .status-indicator-dot {
+ background-color: var(--color-text-secondary);
+ animation: pulse 1.5s ease-in-out infinite;
+}
+
+.status-indicator--stale .status-indicator-dot {
+ background-color: var(--color-warning);
+}
+
+.status-indicator--offline .status-indicator-dot {
+ background-color: var(--color-error);
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.4;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .status-indicator--reconciling .status-indicator-dot {
+ animation: none;
+ }
+}
diff --git a/apps/web/src/styles/responsive.css b/apps/web/src/styles/responsive.css
new file mode 100644
index 000000000..33c326ba0
--- /dev/null
+++ b/apps/web/src/styles/responsive.css
@@ -0,0 +1,108 @@
+/* ==========================================================================
+ Responsive Styles - breakpoint 768px
+ ========================================================================== */
+
+@media (max-width: 768px) {
+ /* Shell collapses to a single column; the sidebar has no mobile overlay. */
+ .app-shell {
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ 'header'
+ 'main'
+ 'footer';
+ }
+
+ .app-sidebar {
+ display: none;
+ }
+
+ .app-header {
+ padding: var(--spacing-xs) var(--spacing-sm);
+ }
+
+ .app-footer {
+ padding: var(--spacing-xs) var(--spacing-sm);
+ justify-content: center;
+ gap: var(--spacing-sm);
+ }
+
+ .footer-center {
+ display: none;
+ }
+
+ .footer-left,
+ .footer-right {
+ flex-shrink: 0;
+ }
+
+ .user-menu-email {
+ max-width: 120px;
+ }
+
+ .file-browser {
+ padding: 0 var(--spacing-sm) var(--spacing-sm);
+ }
+
+ .breadcrumb-nav {
+ padding: var(--spacing-xs);
+ min-width: 0;
+ overflow: hidden;
+ }
+
+ .breadcrumb-item {
+ max-width: 100px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ /* Rows become two lines: name on top, size and date beneath it. */
+ .file-list-header {
+ grid-template-columns: 1fr;
+ }
+
+ .file-list-header-size,
+ .file-list-header-date {
+ display: none;
+ }
+
+ .file-list-item {
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ 'name'
+ 'meta';
+ gap: 4px;
+ }
+
+ .file-list-item-row-bottom {
+ grid-area: meta;
+ display: flex;
+ gap: var(--spacing-sm);
+ }
+
+ .file-list-item-size,
+ .file-list-item-date {
+ grid-area: auto;
+ }
+
+ .empty-state {
+ margin: var(--spacing-md);
+ min-height: 200px;
+ }
+}
+
+/* ==========================================================================
+ Touch Interactions
+ ========================================================================== */
+
+.file-list-item {
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ user-select: none;
+}
+
+@media (pointer: coarse) {
+ .file-list-item:active {
+ background-color: var(--color-green-darker);
+ }
+}
diff --git a/apps/web/src/utils/format.ts b/apps/web/src/utils/format.ts
new file mode 100644
index 000000000..aaac774a6
--- /dev/null
+++ b/apps/web/src/utils/format.ts
@@ -0,0 +1,23 @@
+/** Display formatting for the vault browser. */
+
+const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const;
+
+/** Human-readable byte count, at most one decimal place. */
+export function formatBytes(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
+
+ const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), UNITS.length - 1);
+ const value = bytes / 1024 ** exponent;
+ const rounded = value % 1 === 0 ? value.toString() : value.toFixed(1);
+
+ return `${rounded} ${UNITS[exponent]}`;
+}
+
+/** Locale-aware date for a Unix-millisecond timestamp. */
+export function formatDate(timestampMillis: number): string {
+ return new Intl.DateTimeFormat(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ }).format(new Date(timestampMillis));
+}
diff --git a/apps/web/src/vault/listing.test.ts b/apps/web/src/vault/listing.test.ts
new file mode 100644
index 000000000..749dadf3a
--- /dev/null
+++ b/apps/web/src/vault/listing.test.ts
@@ -0,0 +1,82 @@
+import type { SnapshotChildDescriptor } from '@cipherbox/client';
+import { describe, expect, it } from 'vitest';
+import { listingRows } from './listing';
+
+function child(overrides: Partial = {}): SnapshotChildDescriptor {
+ return {
+ id: new Uint8Array(16).fill(1),
+ name: 'notes.txt',
+ kind: 'file',
+ size: null,
+ mtime: null,
+ pending: 'none',
+ deadLetter: false,
+ contentVersion: null,
+ ...overrides,
+ };
+}
+
+describe('listingRows', () => {
+ it('puts folders first, then sorts by name case-insensitively', () => {
+ const rows = listingRows([
+ child({ id: new Uint8Array(16).fill(1), name: 'beta.txt' }),
+ child({ id: new Uint8Array(16).fill(2), name: 'Alpha.txt' }),
+ child({ id: new Uint8Array(16).fill(3), name: 'zeta', kind: 'folder' }),
+ child({ id: new Uint8Array(16).fill(4), name: 'archive', kind: 'folder' }),
+ ]);
+
+ expect(rows.map((row) => row.name)).toEqual(['archive', 'zeta', 'Alpha.txt', 'beta.txt']);
+ });
+
+ it('renders name and kind before the projection lands', () => {
+ const [row] = listingRows([child({ name: 'holiday.jpg' })]);
+
+ expect(row.name).toBe('holiday.jpg');
+ expect(row.icon).toBe('[FILE]');
+ expect(row.size).toBe('...');
+ expect(row.modified).toBe('...');
+ });
+
+ it('renders size and mtime once the projection lands', () => {
+ const [row] = listingRows([child({ size: 1536n, mtime: 1_700_000_000_000n })]);
+
+ expect(row.size).toBe('1.5 KB');
+ expect(row.modified).not.toBe('...');
+ });
+
+ it('renders an mtime past the Date range rather than throwing out of Intl', () => {
+ // A u64 mtime authored elsewhere must not blank the listing.
+ const [row] = listingRows([child({ mtime: 8_640_000_000_000_001n })]);
+
+ expect(row.modified).toBe('-');
+ });
+
+ it('renders the largest mtime the Date range still admits', () => {
+ const [row] = listingRows([child({ mtime: 8_640_000_000_000_000n })]);
+
+ expect(row.modified).not.toBe('-');
+ });
+
+ it('has no size column for a folder', () => {
+ const [row] = listingRows([child({ kind: 'folder', name: 'docs' })]);
+
+ expect(row.icon).toBe('[DIR]');
+ expect(row.size).toBe('-');
+ });
+
+ it('carries the engine queue flags verbatim', () => {
+ const rows = listingRows([
+ child({ id: new Uint8Array(16).fill(1), name: 'a', pending: 'content' }),
+ child({ id: new Uint8Array(16).fill(2), name: 'b', deadLetter: true }),
+ ]);
+
+ expect(rows[0].pending).toBe('content');
+ expect(rows[1].deadLetter).toBe(true);
+ });
+
+ it('keys each row by its hex node id', () => {
+ const [row] = listingRows([child({ id: new Uint8Array(16).fill(0xcd) })]);
+
+ expect(row.key).toBe('cd'.repeat(16));
+ });
+});
diff --git a/apps/web/src/vault/listing.ts b/apps/web/src/vault/listing.ts
new file mode 100644
index 000000000..9eed03408
--- /dev/null
+++ b/apps/web/src/vault/listing.ts
@@ -0,0 +1,72 @@
+/**
+ * The engine's direct-children projection as list rows. Ordering and labels are
+ * the UI's; every value is the engine's word verbatim
+ * (blueprint/web-client.md "UI state law").
+ */
+
+import { toHex } from '@cipherbox/client';
+import type { NodeKind, PendingClass, SnapshotChildDescriptor } from '@cipherbox/client';
+import { formatBytes, formatDate } from '../utils/format';
+
+/** Stands in for a projection the child ref does not carry yet (#27 D7). */
+const UNRESOLVED = '...';
+
+/** A column with nothing to show for this kind of node. */
+const NOT_APPLICABLE = '-';
+
+/** JS `Date` tops out here; the engine's mtime is a u64 and can exceed it. */
+const MAX_DATE_MILLIS = 8_640_000_000_000_000n;
+
+export interface ListingRow {
+ id: Uint8Array;
+ /** Hex node id: React key, route target, and `data-node-id`. */
+ key: string;
+ name: string;
+ kind: NodeKind;
+ /** Terminal-style kind marker. */
+ icon: string;
+ /** Formatted size, or `...` while the projection is still resolving. */
+ size: string;
+ /** Formatted mtime, or `...` while the projection is still resolving. */
+ modified: string;
+ pending: PendingClass;
+ deadLetter: boolean;
+}
+
+/**
+ * Sorts folders first, then by name, and labels each row. The engine orders
+ * children by node id, which is stable but meaningless to a reader.
+ */
+export function listingRows(children: readonly SnapshotChildDescriptor[]): ListingRow[] {
+ return children.map(toRow).sort(byKindThenName);
+}
+
+function toRow(child: SnapshotChildDescriptor): ListingRow {
+ const isFolder = child.kind === 'folder';
+ return {
+ id: child.id,
+ key: toHex(child.id),
+ name: child.name,
+ kind: child.kind,
+ icon: isFolder ? '[DIR]' : '[FILE]',
+ size: isFolder ? NOT_APPLICABLE : projectedSize(child.size),
+ modified: projectedDate(child.mtime),
+ pending: child.pending,
+ deadLetter: child.deadLetter,
+ };
+}
+
+function projectedSize(value: bigint | null): string {
+ return value === null ? UNRESOLVED : formatBytes(Number(value));
+}
+
+/** Out of range, `Intl` throws on the `Date` and takes the whole listing with it. */
+function projectedDate(value: bigint | null): string {
+ if (value === null) return UNRESOLVED;
+ return value > MAX_DATE_MILLIS ? NOT_APPLICABLE : formatDate(Number(value));
+}
+
+function byKindThenName(a: ListingRow, b: ListingRow): number {
+ if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1;
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
+}
diff --git a/apps/web/src/vault/useFolderNavigation.test.tsx b/apps/web/src/vault/useFolderNavigation.test.tsx
new file mode 100644
index 000000000..f8be06172
--- /dev/null
+++ b/apps/web/src/vault/useFolderNavigation.test.tsx
@@ -0,0 +1,271 @@
+import type { SnapshotDescriptor } from '@cipherbox/client';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { describe, expect, it } from 'vitest';
+import { FileBrowser } from '../components/file-browser/FileBrowser';
+import { fakeEngine } from '../engine/testFakes';
+import { folderPath } from '../lib/nodeId';
+import { EngineProvider } from '../providers/EngineProvider';
+
+const ROOT = new Uint8Array(16).fill(0);
+const DOCS = new Uint8Array(16).fill(7);
+const SUB = new Uint8Array(16).fill(9);
+const NOTE = new Uint8Array(16).fill(3);
+
+function folderView(overrides: Partial = {}): SnapshotDescriptor {
+ return {
+ root: ROOT,
+ folder: ROOT,
+ folderName: '',
+ children: [],
+ ancestors: [],
+ deadLetters: [],
+ blocked: null,
+ retainedRecords: 0,
+ staleness: 'fresh',
+ ...overrides,
+ };
+}
+
+function renderBrowser(engine: ReturnType, path = '/files') {
+ render(
+ engine.client}>
+
+
+ } />
+
+
+
+ );
+}
+
+/** Settles the pull the engine's `snapshotUpdated` event triggers. */
+async function landSnapshot(
+ engine: ReturnType,
+ view: SnapshotDescriptor
+): Promise {
+ await act(async () => {
+ engine.emit({ kind: 'snapshotUpdated' });
+ engine.pulls[engine.pulls.length - 1].resolve(view);
+ await Promise.resolve();
+ });
+}
+
+const rowNames = () =>
+ screen.getAllByTestId('file-list-item').map((row) => row.querySelector('.file-list-item-name')!);
+
+describe('the vault browser read path', () => {
+ it('renders the routed folder from the snapshot, folders first', async () => {
+ const engine = fakeEngine();
+ renderBrowser(engine);
+
+ expect(screen.getByTestId('file-browser-loading')).toBeDefined();
+
+ await landSnapshot(
+ engine,
+ folderView({
+ children: [
+ {
+ id: NOTE,
+ name: 'notes.txt',
+ kind: 'file',
+ size: 1024n,
+ mtime: 1_700_000_000_000n,
+ pending: 'none',
+ deadLetter: false,
+ contentVersion: 1n,
+ },
+ {
+ id: DOCS,
+ name: 'documents',
+ kind: 'folder',
+ size: null,
+ mtime: null,
+ pending: 'none',
+ deadLetter: false,
+ contentVersion: null,
+ },
+ ],
+ })
+ );
+
+ expect(rowNames().map((cell) => cell.textContent)).toEqual(['documents', 'notes.txt']);
+ expect(screen.queryByTestId('parent-dir-row')).toBeNull();
+ });
+
+ it('shows the empty state for a folder with no children', async () => {
+ const engine = fakeEngine();
+ renderBrowser(engine);
+ await landSnapshot(engine, folderView());
+
+ expect(screen.getByTestId('empty-state')).toBeDefined();
+ });
+
+ it('renders name and kind before size and mtime resolve, then repaints', async () => {
+ const engine = fakeEngine();
+ renderBrowser(engine);
+
+ const unresolved = {
+ id: NOTE,
+ name: 'holiday.jpg',
+ kind: 'file' as const,
+ size: null,
+ mtime: null,
+ pending: 'none' as const,
+ deadLetter: false,
+ contentVersion: null,
+ };
+ await landSnapshot(engine, folderView({ children: [unresolved] }));
+
+ const row = screen.getByTestId('file-list-item');
+ expect(row.querySelector('.file-list-item-name')?.textContent).toBe('holiday.jpg');
+ expect(row.querySelector('.file-list-item-size')?.textContent).toBe('...');
+
+ await landSnapshot(
+ engine,
+ folderView({ children: [{ ...unresolved, size: 2048n, mtime: 1_700_000_000_000n }] })
+ );
+
+ expect(
+ screen.getByTestId('file-list-item').querySelector('.file-list-item-size')?.textContent
+ ).toBe('2 KB');
+ });
+
+ it('moves the engine focus window when a folder is opened', async () => {
+ const engine = fakeEngine();
+ renderBrowser(engine);
+ await landSnapshot(
+ engine,
+ folderView({
+ children: [
+ {
+ id: DOCS,
+ name: 'documents',
+ kind: 'folder',
+ size: null,
+ mtime: null,
+ pending: 'none',
+ deadLetter: false,
+ contentVersion: null,
+ },
+ ],
+ })
+ );
+
+ await act(async () => {
+ fireEvent.doubleClick(screen.getByTestId('file-list-item'));
+ await Promise.resolve();
+ });
+
+ expect(engine.focus).toEqual([DOCS]);
+ expect(engine.reported).toEqual([DOCS]);
+
+ await act(async () => {
+ engine.ackFocus();
+ await Promise.resolve();
+ engine.pulls[engine.pulls.length - 1].resolve(
+ folderView({
+ folder: DOCS,
+ folderName: 'documents',
+ ancestors: [{ id: ROOT, name: '' }],
+ })
+ );
+ await Promise.resolve();
+ });
+
+ expect(screen.getByTestId('parent-dir-row')).toBeDefined();
+ });
+
+ it.each([['Enter'], [' ']])('opens a folder from the keyboard with %j', async (key) => {
+ const engine = fakeEngine();
+ renderBrowser(engine);
+ await landSnapshot(
+ engine,
+ folderView({
+ children: [
+ {
+ id: DOCS,
+ name: 'documents',
+ kind: 'folder',
+ size: null,
+ mtime: null,
+ pending: 'none',
+ deadLetter: false,
+ contentVersion: null,
+ },
+ ],
+ })
+ );
+
+ await act(async () => {
+ fireEvent.keyDown(screen.getByTestId('file-list-item'), { key });
+ await Promise.resolve();
+ });
+
+ expect(engine.focus).toEqual([DOCS]);
+ });
+
+ it('builds the breadcrumb chain from the ancestor trail, root first', async () => {
+ const engine = fakeEngine();
+ renderBrowser(engine, folderPath(SUB));
+
+ await act(async () => {
+ engine.ackFocus();
+ await Promise.resolve();
+ engine.pulls[engine.pulls.length - 1].resolve(
+ folderView({
+ folder: SUB,
+ folderName: 'sub',
+ ancestors: [
+ { id: DOCS, name: 'documents' },
+ { id: ROOT, name: '' },
+ ],
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const crumbs = screen.getByTestId('breadcrumbs').querySelectorAll('.breadcrumb-item');
+ expect([...crumbs].map((crumb) => crumb.textContent)).toEqual(['root', 'documents', 'sub']);
+ expect(crumbs[crumbs.length - 1].getAttribute('aria-current')).toBe('page');
+ });
+
+ it('does not list a folder the routed id does not match', async () => {
+ const engine = fakeEngine();
+ renderBrowser(engine, folderPath(SUB));
+
+ // The folder just left answers late; it must not paint under the new route.
+ await act(async () => {
+ engine.ackFocus();
+ await Promise.resolve();
+ engine.pulls[engine.pulls.length - 1].resolve(
+ folderView({
+ children: [
+ {
+ id: NOTE,
+ name: 'stale.txt',
+ kind: 'file',
+ size: null,
+ mtime: null,
+ pending: 'none',
+ deadLetter: false,
+ contentVersion: null,
+ },
+ ],
+ })
+ );
+ await Promise.resolve();
+ });
+
+ expect(screen.queryByTestId('file-list-item')).toBeNull();
+ expect(screen.getByTestId('file-browser-loading')).toBeDefined();
+ });
+
+ it('refuses a route param that is not a node id', () => {
+ const engine = fakeEngine();
+ renderBrowser(engine, '/files/not-a-node');
+
+ expect(screen.getByTestId('file-browser-error').textContent).toBe('that is not a folder id');
+ expect(engine.focus).toEqual([]);
+ });
+});
diff --git a/apps/web/src/vault/useFolderNavigation.ts b/apps/web/src/vault/useFolderNavigation.ts
new file mode 100644
index 000000000..63655cc67
--- /dev/null
+++ b/apps/web/src/vault/useFolderNavigation.ts
@@ -0,0 +1,85 @@
+/**
+ * The vault browser's read path: the routed folder, the snapshot the engine
+ * reports for it, and the navigation that moves both. The engine's focus window
+ * follows the route — there is no client-side tree to walk, only the direct
+ * children and the ancestor trail the snapshot carries.
+ */
+
+import { useCallback, useEffect, useMemo } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import type { BreadcrumbDescriptor } from '@cipherbox/client';
+import type { SnapshotError } from '../engine/snapshotStore';
+import { useSnapshot } from '../engine/useSnapshot';
+import { folderPath, folderRoute, sameNode } from '../lib/nodeId';
+import { useSnapshotStore } from '../providers/EngineProvider';
+import { listingRows, type ListingRow } from './listing';
+
+const NOT_A_FOLDER: SnapshotError = { message: 'that is not a folder id' };
+
+export interface FolderNavigation {
+ /** Direct children of the routed folder, folders first. */
+ rows: ListingRow[];
+ /** Root-first trail, ending at the folder on screen. */
+ breadcrumbs: BreadcrumbDescriptor[];
+ /** True until the engine reports a snapshot *of the routed folder*. */
+ isLoading: boolean;
+ isRoot: boolean;
+ error: SnapshotError | null;
+ navigateTo(node: Uint8Array): void;
+ /** Steps to the nearest ancestor; a no-op at the root. */
+ navigateUp(): void;
+}
+
+export function useFolderNavigation(): FolderNavigation {
+ const { nodeId } = useParams<{ nodeId: string }>();
+ const navigate = useNavigate();
+ const store = useSnapshotStore();
+ const { view, error } = useSnapshot();
+
+ const route = useMemo(() => folderRoute(nodeId), [nodeId]);
+
+ useEffect(() => {
+ if (route.kind === 'invalid') return;
+ store.setFocus(route.kind === 'node' ? route.id : null);
+ }, [route, store]);
+
+ // A pull for the folder just left still lands under the new route, so the
+ // view is only this route's answer once its folder matches.
+ const listed =
+ view !== null && sameNode(view.folder, route.kind === 'node' ? route.id : view.root)
+ ? view
+ : null;
+
+ const navigateTo = useCallback(
+ (node: Uint8Array) => {
+ // The root is addressable as itself, but `/files` survives a root cut.
+ navigate(folderPath(listed !== null && sameNode(node, listed.root) ? null : node));
+ },
+ [listed, navigate]
+ );
+
+ const navigateUp = useCallback(() => {
+ const parent = listed?.ancestors[0];
+ if (parent) navigateTo(parent.id);
+ }, [listed, navigateTo]);
+
+ const breadcrumbs = useMemo(
+ () =>
+ listed === null
+ ? []
+ : [...listed.ancestors].reverse().concat({ id: listed.folder, name: listed.folderName }),
+ [listed]
+ );
+
+ const rows = useMemo(() => (listed === null ? [] : listingRows(listed.children)), [listed]);
+
+ return {
+ rows,
+ breadcrumbs,
+ isLoading: route.kind !== 'invalid' && listed === null && error === null,
+ isRoot: listed !== null && sameNode(listed.folder, listed.root),
+ error: route.kind === 'invalid' ? NOT_A_FOLDER : error,
+ navigateTo,
+ navigateUp,
+ };
+}
diff --git a/crates/engine/src/facade.rs b/crates/engine/src/facade.rs
index d79de189c..38c80cd39 100644
--- a/crates/engine/src/facade.rs
+++ b/crates/engine/src/facade.rs
@@ -233,6 +233,10 @@ pub struct SnapshotView {
pub root: NodeId,
/// The folder this view lists.
pub folder: NodeId,
+ /// That folder's own name, empty at the root. A host cannot recover it from
+ /// `ancestors` (which starts at the parent) and must not cache a name across
+ /// a navigation, so the view carries it.
+ pub folder_name: String,
/// Direct children, deterministically ordered by node id.
pub children: Vec,
/// Ancestor trail from the folder's parent up to and including the root,
@@ -2350,6 +2354,10 @@ impl Engine {
.unwrap_or_default(),
})
.collect();
+ let folder_name = rendered
+ .node(folder)
+ .map(|meta| meta.name.clone())
+ .unwrap_or_default();
let dead_letters = dead
.iter()
.map(|(op_id, (_, reason))| DeadLetter {
@@ -2368,6 +2376,7 @@ impl Engine {
Ok(SnapshotView {
root: rendered.root,
folder,
+ folder_name,
children,
ancestors,
dead_letters,
@@ -3439,6 +3448,7 @@ mod tests {
assert!(view.dead_letters.is_empty());
assert_eq!(view.retained_records, 0);
assert!(view.ancestors.is_empty(), "the root has no ancestors");
+ assert!(view.folder_name.is_empty(), "the root has no name");
}
#[test]
@@ -3574,6 +3584,10 @@ mod tests {
],
"nearest first, ending at the root"
);
+ assert_eq!(
+ view.folder_name, "sub",
+ "the listed folder names itself; the trail starts at its parent"
+ );
}
#[test]
diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs
index 452972424..fd812479e 100644
--- a/crates/wasm/src/lib.rs
+++ b/crates/wasm/src/lib.rs
@@ -419,6 +419,12 @@ impl SnapshotView {
self.inner.folder.0.to_vec()
}
+ /// The listed folder's own name, empty at the root.
+ #[wasm_bindgen(getter, js_name = folderName)]
+ pub fn folder_name(&self) -> String {
+ self.inner.folder_name.clone()
+ }
+
/// Direct children, deterministically ordered by node id.
#[wasm_bindgen(getter)]
pub fn children(&self) -> Vec {
@@ -953,6 +959,7 @@ mod tests {
let view = SnapshotView::from_facade(facade::SnapshotView {
root: facade::NodeId([1u8; 16]),
folder: facade::NodeId([2u8; 16]),
+ folder_name: "holiday".into(),
children: vec![
facade::SnapshotChild {
id: facade::NodeId([3u8; 16]),
@@ -1000,6 +1007,7 @@ mod tests {
assert_eq!(view.root(), vec![1u8; 16]);
assert_eq!(view.folder(), vec![2u8; 16]);
+ assert_eq!(view.folder_name(), "holiday");
let dead_letters = view.dead_letters();
assert_eq!(
dead_letters
diff --git a/crates/wasm/tests/boundary.rs b/crates/wasm/tests/boundary.rs
index 09f69e801..e6d85af21 100644
--- a/crates/wasm/tests/boundary.rs
+++ b/crates/wasm/tests/boundary.rs
@@ -222,6 +222,7 @@ fn snapshot_view_getters_cross_with_boundary_shapes() {
let view: JsValue = SnapshotView::from_facade(facade::SnapshotView {
root: facade::NodeId([1u8; 16]),
folder: facade::NodeId([2u8; 16]),
+ folder_name: "holiday".into(),
children: vec![
facade::SnapshotChild {
id: facade::NodeId([3u8; 16]),
@@ -275,6 +276,12 @@ fn snapshot_view_getters_cross_with_boundary_shapes() {
vec![2u8; 16]
);
+ assert_eq!(
+ get(&view, "folderName").as_string().as_deref(),
+ Some("holiday"),
+ "folderName must cross under that JS name"
+ );
+
assert_eq!(
get(&view, "retainedRecords").as_f64(),
Some(0.0),
diff --git a/packages/client/src/broadcastTransport.test.ts b/packages/client/src/broadcastTransport.test.ts
index 5efcc5492..591ae710b 100644
--- a/packages/client/src/broadcastTransport.test.ts
+++ b/packages/client/src/broadcastTransport.test.ts
@@ -180,6 +180,7 @@ describe('broadcast transport ↔ leader relay', () => {
const view: SnapshotDescriptor = {
root: new Uint8Array(16).fill(1),
folder: new Uint8Array(16).fill(2),
+ folderName: 'holiday',
children: [
{
id: new Uint8Array(16).fill(3),
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index afaf30551..f2862534b 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -36,8 +36,8 @@ export { MediaService } from './media/service.js';
export type { MediaReader } from './media/broker.js';
// The one hex codec in TypeScript, for hosts that receive hex-encoded bytes
-// from a third-party SDK.
-export { fromHex } from './seams/bytes.js';
+// from a third-party SDK or address opaque engine byte strings by string key.
+export { fromHex, toHex } from './seams/bytes.js';
// The wire descriptors the UI exchanges with the engine over the transport.
export type {
diff --git a/packages/client/src/testkit.ts b/packages/client/src/testkit.ts
index 46790efdb..8fb1e03bf 100644
--- a/packages/client/src/testkit.ts
+++ b/packages/client/src/testkit.ts
@@ -58,6 +58,7 @@ export function emptySnapshot(folder: Uint8Array = new Uint8Array(16)): Snapshot
return {
root: new Uint8Array(16),
folder,
+ folderName: '',
children: [],
ancestors: [],
deadLetters: [],
diff --git a/packages/client/src/worker/commandCodec.test.ts b/packages/client/src/worker/commandCodec.test.ts
index ce8a1d8eb..f93b55e34 100644
--- a/packages/client/src/worker/commandCodec.test.ts
+++ b/packages/client/src/worker/commandCodec.test.ts
@@ -165,6 +165,7 @@ function baseView(): WasmSnapshotView {
return {
root: new Uint8Array(16),
folder: new Uint8Array(16),
+ folderName: '',
children: [],
ancestors: [],
deadLetters: [],
@@ -178,6 +179,7 @@ describe('readSnapshot', () => {
const view: WasmSnapshotView = {
root: new Uint8Array(16).fill(1),
folder: new Uint8Array(16).fill(2),
+ folderName: 'holiday',
children: [
{
id: new Uint8Array(16).fill(3),
@@ -221,6 +223,7 @@ describe('readSnapshot', () => {
expect(readSnapshot(fakeWasm, view)).toEqual({
root: new Uint8Array(16).fill(1),
folder: new Uint8Array(16).fill(2),
+ folderName: 'holiday',
children: [
{
id: new Uint8Array(16).fill(3),
diff --git a/packages/client/src/worker/commandCodec.ts b/packages/client/src/worker/commandCodec.ts
index 245b79f60..79e000284 100644
--- a/packages/client/src/worker/commandCodec.ts
+++ b/packages/client/src/worker/commandCodec.ts
@@ -245,6 +245,7 @@ export function readSnapshot(wasm: EngineWasm, view: WasmSnapshotView): Snapshot
return {
root: view.root,
folder: view.folder,
+ folderName: view.folderName,
children: view.children.map((child) => ({
id: child.id,
name: child.name,
diff --git a/packages/client/src/worker/engineWasm.ts b/packages/client/src/worker/engineWasm.ts
index fb949e6ef..ab6a69168 100644
--- a/packages/client/src/worker/engineWasm.ts
+++ b/packages/client/src/worker/engineWasm.ts
@@ -69,6 +69,7 @@ export interface WasmBlockedOp {
export interface WasmSnapshotView {
readonly root: Uint8Array;
readonly folder: Uint8Array;
+ readonly folderName: string;
readonly children: WasmSnapshotChild[];
readonly ancestors: WasmBreadcrumb[];
readonly deadLetters: readonly WasmDeadLetter[];
diff --git a/packages/client/src/worker/protocol.ts b/packages/client/src/worker/protocol.ts
index 050c04f8b..0da037a29 100644
--- a/packages/client/src/worker/protocol.ts
+++ b/packages/client/src/worker/protocol.ts
@@ -97,6 +97,8 @@ export interface SnapshotChildDescriptor {
export interface SnapshotDescriptor {
root: Uint8Array;
folder: Uint8Array;
+ /** The listed folder's own name, empty at the root. */
+ folderName: string;
children: SnapshotChildDescriptor[];
ancestors: BreadcrumbDescriptor[];
deadLetters: DeadLetterDescriptor[];