diff --git a/frontend/src/components/shell/AppShell.tsx b/frontend/src/components/shell/AppShell.tsx
new file mode 100644
index 0000000..4763b1a
--- /dev/null
+++ b/frontend/src/components/shell/AppShell.tsx
@@ -0,0 +1,43 @@
+import { Outlet, Navigate } from 'react-router-dom';
+import { useSession } from '@/session/useSession';
+import { Sidebar } from './Sidebar';
+import { StatusRail } from './StatusRail';
+import { useClock } from './useClock';
+
+/**
+ * Role-aware layout shell.
+ *
+ * Composition (instrument-panel inspired):
+ * ┌──────────────────────────────────────────────────────────────┐
+ * │ StatusRail (link health · active run · UTC clock · build) │
+ * ├──────────┬───────────────────────────────────────────────────┤
+ * │ │ │
+ * │ Sidebar │ Outlet (current screen) │
+ * │ │ │
+ * └──────────┴───────────────────────────────────────────────────┘
+ *
+ * Unauthenticated visitors are redirected to /login. Inside the shell, the
+ * sidebar and rail expose only what the current session's role can see —
+ * this is layout, not enforcement: the API remains the source of truth on
+ * permissions (D-008 / F11).
+ */
+export function AppShell() {
+ const { user } = useSession();
+ const clock = useClock();
+
+ if (!user) {
+ return ;
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shell/Sidebar.tsx b/frontend/src/components/shell/Sidebar.tsx
new file mode 100644
index 0000000..ef5ef46
--- /dev/null
+++ b/frontend/src/components/shell/Sidebar.tsx
@@ -0,0 +1,120 @@
+import { NavLink } from 'react-router-dom';
+import { clsx } from 'clsx';
+import { Logo } from '@/components/brand/Logo';
+import { useSession } from '@/session/useSession';
+import { ROLE_LABELS, isRT, isLead } from '@/types/roles';
+
+interface NavItem {
+ to: string;
+ label: string;
+ shortcut: string;
+ show: (args: { canRT: boolean; canLead: boolean }) => boolean;
+}
+
+const NAV_ITEMS: NavItem[] = [
+ { to: '/engagements', label: 'Engagements', shortcut: 'E', show: () => true },
+ { to: '/library', label: 'TTP Library', shortcut: 'L', show: ({ canRT }) => canRT },
+ { to: '/scenarios', label: 'Scenarios', shortcut: 'S', show: ({ canRT }) => canRT },
+ { to: '/runs', label: 'Live Runs', shortcut: 'R', show: () => true },
+ { to: '/reports', label: 'Reports', shortcut: 'P', show: () => true },
+ { to: '/audit', label: 'Audit Log', shortcut: 'A', show: ({ canLead }) => canLead },
+];
+
+export function Sidebar() {
+ const { user, signOut } = useSession();
+ if (!user) return null;
+
+ const canRT = isRT(user.role);
+ const canLead = isLead(user.role);
+ const visible = NAV_ITEMS.filter((item) => item.show({ canRT, canLead }));
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/shell/StatusRail.tsx b/frontend/src/components/shell/StatusRail.tsx
new file mode 100644
index 0000000..b60fff2
--- /dev/null
+++ b/frontend/src/components/shell/StatusRail.tsx
@@ -0,0 +1,85 @@
+import { clsx } from 'clsx';
+
+/**
+ * Top status rail — the constant "instrument panel header strip".
+ * Renders link health, run state, time-of-day, build id.
+ *
+ * Purpose:
+ * - Persistent context across every screen, mimicking the masthead of a
+ * SDR ground-station UI (one line of telemetry visible at all times).
+ * - In MVP+, when a run is active, the run-state pill becomes the link
+ * to the live cockpit so the lead RT can always jump back to it.
+ */
+
+type LinkState = 'up' | 'degraded' | 'down';
+
+interface StatusRailProps {
+ linkState?: LinkState;
+ activeRunId?: string;
+ activeRunState?: 'running' | 'paused' | 'aborting' | 'idle';
+ clock: string;
+}
+
+const LINK_LABEL: Record = {
+ up: 'LINK · UP',
+ degraded: 'LINK · DEG',
+ down: 'LINK · DOWN',
+};
+
+const LINK_DOT: Record = {
+ up: 'text-link-up',
+ degraded: 'text-link-degraded',
+ down: 'text-link-down',
+};
+
+export function StatusRail({
+ linkState = 'up',
+ activeRunId,
+ activeRunState = 'idle',
+ clock,
+}: StatusRailProps) {
+ return (
+
+
+
+ {LINK_LABEL[linkState]}
+
+
+
+
+ {activeRunId ? (
+
+
+
+ RUN · {activeRunId.toUpperCase()} · {activeRunState.toUpperCase()}
+
+
+ ) : (
+ RUN · ──
+ )}
+
+
+
+
+ {clock}
+
+ v0.1.0-sprint0
+
+ );
+}
diff --git a/frontend/src/components/shell/useClock.ts b/frontend/src/components/shell/useClock.ts
new file mode 100644
index 0000000..291ac5e
--- /dev/null
+++ b/frontend/src/components/shell/useClock.ts
@@ -0,0 +1,15 @@
+import { useEffect, useState } from 'react';
+
+function format(date: Date): string {
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`;
+}
+
+export function useClock(): string {
+ const [now, setNow] = useState(() => format(new Date()));
+ useEffect(() => {
+ const id = window.setInterval(() => setNow(format(new Date())), 1000);
+ return () => window.clearInterval(id);
+ }, []);
+ return now;
+}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
new file mode 100644
index 0000000..f3a7754
--- /dev/null
+++ b/frontend/src/router.tsx
@@ -0,0 +1,58 @@
+import { createBrowserRouter, Navigate } from 'react-router-dom';
+import { RootLayout } from '@/router/RootLayout';
+import { AppShell } from '@/components/shell/AppShell';
+import { LoginPage } from '@/screens/login/LoginPage';
+import { EngagementsPage } from '@/screens/engagements/EngagementsPage';
+import { LiveCockpitPage } from '@/screens/cockpit/LiveCockpitPage';
+import { ScenarioComposerPage } from '@/screens/composer/ScenarioComposerPage';
+import { ReportPage } from '@/screens/report/ReportPage';
+import { TtpLibraryPage } from '@/screens/library/TtpLibraryPage';
+import { AuditPage } from '@/screens/audit/AuditPage';
+
+/**
+ * Routes mirror spec §9 (UI Web) with sprint 0 placeholders.
+ * Once real engagement scoping lands, sub-routes nest under /engagements/:eid.
+ * Sprint 0 keeps the URLs flat so the wireframes are reachable directly.
+ */
+export const router = createBrowserRouter([
+ {
+ path: '/',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/login',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/',
+ element: (
+
+
+
+ ),
+ children: [
+ { path: 'engagements', element: },
+ { path: 'library', element: },
+ { path: 'scenarios', element: },
+ { path: 'runs', element: },
+ { path: 'reports', element: },
+ { path: 'audit', element: },
+ ],
+ },
+ {
+ path: '*',
+ element: (
+
+
+
+ ),
+ },
+]);
diff --git a/frontend/src/router/RootLayout.tsx b/frontend/src/router/RootLayout.tsx
new file mode 100644
index 0000000..bca8088
--- /dev/null
+++ b/frontend/src/router/RootLayout.tsx
@@ -0,0 +1,10 @@
+import type { ReactNode } from 'react';
+import { SessionProvider } from '@/session/SessionContext';
+
+/**
+ * Wraps every top-level route with the session provider. Kept in its own
+ * file so the router config can stay export-pure (fast-refresh friendly).
+ */
+export function RootLayout({ children }: { children: ReactNode }) {
+ return {children};
+}
diff --git a/frontend/src/session/SessionContext.context.ts b/frontend/src/session/SessionContext.context.ts
new file mode 100644
index 0000000..8536bf0
--- /dev/null
+++ b/frontend/src/session/SessionContext.context.ts
@@ -0,0 +1,10 @@
+import { createContext } from 'react';
+import type { SessionUser } from '@/types/roles';
+
+export interface SessionContextValue {
+ user: SessionUser | null;
+ signIn: (user: SessionUser) => void;
+ signOut: () => void;
+}
+
+export const SessionContext = createContext(null);
diff --git a/frontend/src/session/SessionContext.tsx b/frontend/src/session/SessionContext.tsx
new file mode 100644
index 0000000..53cc684
--- /dev/null
+++ b/frontend/src/session/SessionContext.tsx
@@ -0,0 +1,25 @@
+import { useCallback, useMemo, useState, type ReactNode } from 'react';
+import type { SessionUser } from '@/types/roles';
+import { clearMockSession, readMockSession, writeMockSession } from '@/mocks/session';
+import { SessionContext, type SessionContextValue } from './SessionContext.context';
+
+export function SessionProvider({ children }: { children: ReactNode }) {
+ const [user, setUser] = useState(() => readMockSession());
+
+ const signIn = useCallback((next: SessionUser) => {
+ writeMockSession(next);
+ setUser(next);
+ }, []);
+
+ const signOut = useCallback(() => {
+ clearMockSession();
+ setUser(null);
+ }, []);
+
+ const value = useMemo(
+ () => ({ user, signIn, signOut }),
+ [user, signIn, signOut],
+ );
+
+ return {children};
+}
diff --git a/frontend/src/session/useSession.ts b/frontend/src/session/useSession.ts
new file mode 100644
index 0000000..79e0f31
--- /dev/null
+++ b/frontend/src/session/useSession.ts
@@ -0,0 +1,10 @@
+import { useContext } from 'react';
+import { SessionContext } from './SessionContext.context';
+
+export function useSession() {
+ const ctx = useContext(SessionContext);
+ if (!ctx) {
+ throw new Error('useSession must be used inside ');
+ }
+ return ctx;
+}
diff --git a/frontend/src/types/roles.ts b/frontend/src/types/roles.ts
new file mode 100644
index 0000000..bae3fdd
--- /dev/null
+++ b/frontend/src/types/roles.ts
@@ -0,0 +1,25 @@
+/**
+ * Spec §3 / F11 — three roles, enforced by the backend RBAC matrix.
+ * Frontend uses this enum only to drive layout/conditional rendering;
+ * the backend remains the source of truth on what is actually permitted.
+ */
+export type Role = 'rt_operator' | 'rt_lead' | 'soc_analyst';
+
+export interface SessionUser {
+ id: string;
+ displayName: string;
+ role: Role;
+ engagementId: string;
+ engagementName: string;
+}
+
+export const ROLE_LABELS: Record = {
+ rt_operator: 'RT Operator',
+ rt_lead: 'RT Lead',
+ soc_analyst: 'SOC Analyst',
+};
+
+/** Convenience predicates kept in one place so all screens read identically. */
+export const isRT = (role: Role): boolean => role === 'rt_operator' || role === 'rt_lead';
+export const isLead = (role: Role): boolean => role === 'rt_lead';
+export const isSOC = (role: Role): boolean => role === 'soc_analyst';