diff --git a/src/App.tsx b/src/App.tsx
index ed3c9209..46c75241 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,8 +1,10 @@
import { CssBaseline } from '@mui/material';
+import { ThemeProvider } from '@mui/material/styles';
import { Provider } from 'react-redux';
import { RouterProvider } from 'react-router-dom';
import { createAppRouter } from './app/router';
import { createAppRuntime } from './app/runtime';
+import { appTheme } from './app/theme';
import { appStore } from './state/store';
const appRuntime = createAppRuntime({ store: appStore });
@@ -27,8 +29,10 @@ if (import.meta.hot) {
function App() {
return (
-
-
+
+
+
+
);
}
diff --git a/src/app/ConnectDialog.tsx b/src/app/ConnectDialog.tsx
index 85bf14a8..aa391b4f 100644
--- a/src/app/ConnectDialog.tsx
+++ b/src/app/ConnectDialog.tsx
@@ -7,14 +7,17 @@ import {
DialogActions,
DialogContent,
DialogTitle,
- Divider,
- Grid,
+ IconButton,
Stack,
TextField,
+ Tooltip,
Typography,
} from '@mui/material';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import RefreshIcon from '@mui/icons-material/Refresh';
import { useFetcher } from 'react-router-dom';
import { appConfig, type ConnectionOption } from '../config';
+import { monoValueSx } from './consoleStyles';
import type { RootActionData } from './routeData';
import type { SignifyConnectionState } from './runtime';
@@ -33,29 +36,6 @@ export interface ConnectDialogProps {
onClose: () => void;
}
-/**
- * User-facing status label for the typed connection state.
- *
- * Keep this local until another component truly needs the same presentation
- * policy; duplicating a second tiny formatter is preferable to a premature
- * shared status abstraction.
- */
-const connectionStatusLabel = (connection: SignifyConnectionState): string => {
- if (connection.status === 'idle') {
- return 'Not Connected';
- }
-
- if (connection.status === 'connecting') {
- return 'Connecting';
- }
-
- if (connection.status === 'connected') {
- return 'Connected';
- }
-
- return 'Error';
-};
-
/**
* Modal form for selecting a configured KERIA target and passcode.
*
@@ -73,7 +53,7 @@ export const ConnectDialog = ({
const [selectedConnection, setSelectedConnection] =
useState(appConfig.connectionOptions[0]);
const [draftPasscode, setDraftPasscode] = useState(null);
- const isConnected = connection.status === 'connected';
+ const [copiedPasscode, setCopiedPasscode] = useState(false);
const isSubmitting =
connection.status === 'connecting' || connectFetcher.state !== 'idle';
const isGenerating = passcodeFetcher.state !== 'idle';
@@ -83,6 +63,7 @@ export const ConnectDialog = ({
? passcodeFetcher.data.passcode
: null;
const passcode = draftPasscode ?? generatedPasscode ?? '';
+ const passcodeReady = passcode.length >= 21;
const actionError =
connection.status === 'error'
? connection.error.message
@@ -105,9 +86,22 @@ export const ConnectDialog = ({
const formData = new FormData();
formData.set('intent', 'generatePasscode');
setDraftPasscode(null);
+ setCopiedPasscode(false);
passcodeFetcher.submit(formData, { method: 'post', action: '/' });
};
+ const handleCopyPasscode = () => {
+ if (passcode.length === 0) {
+ return;
+ }
+
+ void globalThis.navigator.clipboard
+ ?.writeText(passcode)
+ .catch(() => undefined);
+ setCopiedPasscode(true);
+ globalThis.setTimeout(() => setCopiedPasscode(false), 1500);
+ };
+
return (
);
diff --git a/src/app/ConnectionRequired.tsx b/src/app/ConnectionRequired.tsx
index 97fe553b..d9059e46 100644
--- a/src/app/ConnectionRequired.tsx
+++ b/src/app/ConnectionRequired.tsx
@@ -1,4 +1,5 @@
import { Box, Typography } from '@mui/material';
+import { ConsolePanel, StatusPill } from './Console';
/**
* Blocked-state view for routes that need a connected Signify client.
@@ -7,7 +8,15 @@ import { Box, Typography } from '@mui/material';
* direct URL navigation should be passive until the user chooses to connect.
*/
export const ConnectionRequired = () => (
-
- Connect to KERIA before opening this view.
+
+ }
+ >
+
+ Connect to KERIA before opening this view.
+
+
);
diff --git a/src/app/Console.tsx b/src/app/Console.tsx
new file mode 100644
index 00000000..a818fa52
--- /dev/null
+++ b/src/app/Console.tsx
@@ -0,0 +1,240 @@
+import type { ReactNode } from 'react';
+import { Box, Button, Stack, Typography } from '@mui/material';
+import type { ButtonProps, SxProps, Theme } from '@mui/material';
+import { monoValueSx } from './consoleStyles';
+
+export interface PageHeaderProps {
+ eyebrow?: string;
+ title: string;
+ summary?: string;
+ actions?: ReactNode;
+}
+
+export const PageHeader = ({
+ eyebrow,
+ title,
+ summary,
+ actions,
+}: PageHeaderProps) => (
+
+
+ {eyebrow && (
+
+ {eyebrow}
+
+ )}
+
+ {title}
+
+ {summary && (
+
+ {summary}
+
+ )}
+
+ {actions && {actions}}
+
+);
+
+export interface ConsolePanelProps {
+ children: ReactNode;
+ title?: string;
+ eyebrow?: string;
+ actions?: ReactNode;
+ sx?: SxProps;
+}
+
+export const ConsolePanel = ({
+ children,
+ title,
+ eyebrow,
+ actions,
+ sx,
+}: ConsolePanelProps) => (
+
+ {(title || eyebrow || actions) && (
+
+
+ {eyebrow && (
+
+ {eyebrow}
+
+ )}
+ {title && (
+
+ {title}
+
+ )}
+
+ {actions}
+
+ )}
+ {children}
+
+);
+
+export interface EmptyStateProps {
+ title: string;
+ message: string;
+ action?: ReactNode;
+}
+
+export const EmptyState = ({ title, message, action }: EmptyStateProps) => (
+
+ {title}
+
+ {message}
+
+ {action && {action}}
+
+);
+
+export interface StatusPillProps {
+ label: string;
+ tone?: 'neutral' | 'success' | 'warning' | 'error' | 'info';
+}
+
+const statusColors = {
+ neutral: { borderColor: 'divider', color: 'text.secondary' },
+ success: { borderColor: 'success.main', color: 'success.main' },
+ warning: { borderColor: 'warning.main', color: 'warning.main' },
+ error: { borderColor: 'error.main', color: 'error.main' },
+ info: { borderColor: 'primary.main', color: 'primary.main' },
+} as const;
+
+export const StatusPill = ({ label, tone = 'neutral' }: StatusPillProps) => (
+
+ {label}
+
+);
+
+export interface TelemetryRowProps {
+ label: string;
+ value: ReactNode;
+ mono?: boolean;
+}
+
+export const TelemetryRow = ({
+ label,
+ value,
+ mono = false,
+}: TelemetryRowProps) => (
+
+
+ {label}
+
+
+ {value}
+
+
+);
+
+export const CommandButton = (props: ButtonProps) => (
+
+);
diff --git a/src/app/LoadingOverlay.tsx b/src/app/LoadingOverlay.tsx
index 8a70a6cf..852ef848 100644
--- a/src/app/LoadingOverlay.tsx
+++ b/src/app/LoadingOverlay.tsx
@@ -26,8 +26,8 @@ export const LoadingOverlay = ({
data-pending-source={source ?? undefined}
sx={(theme) => ({
zIndex: theme.zIndex.modal + 2,
- color: 'common.white',
- bgcolor: 'rgba(0, 0, 0, 0.48)',
+ color: 'text.primary',
+ bgcolor: 'rgba(0, 0, 0, 0.72)',
px: 2,
})}
>
@@ -42,17 +42,17 @@ export const LoadingOverlay = ({
px: 3,
py: 2.5,
borderRadius: 1,
+ border: 1,
+ borderColor: 'primary.main',
bgcolor: 'background.paper',
color: 'text.primary',
- boxShadow: 6,
+ boxShadow:
+ '0 20px 56px rgba(0, 0, 0, 0.54), 0 0 28px rgba(39, 215, 255, 0.12)',
textAlign: 'center',
}}
>
-
-
+
+
{label}
diff --git a/src/app/NavigationDrawer.tsx b/src/app/NavigationDrawer.tsx
index 2a5dc97d..864e86d7 100644
--- a/src/app/NavigationDrawer.tsx
+++ b/src/app/NavigationDrawer.tsx
@@ -1,7 +1,22 @@
import type { KeyboardEvent } from 'react';
-import { Drawer, List, ListItemButton, ListItemText } from '@mui/material';
-import { useNavigate } from 'react-router-dom';
+import {
+ Box,
+ Drawer,
+ List,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ Tooltip,
+ Typography,
+} from '@mui/material';
+import BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined';
+import CreditCardIcon from '@mui/icons-material/CreditCard';
+import ListAltIcon from '@mui/icons-material/ListAlt';
+import NotificationsIcon from '@mui/icons-material/Notifications';
+import TerminalIcon from '@mui/icons-material/Terminal';
+import { useLocation, useNavigate } from 'react-router-dom';
import { APP_NAV_ITEMS } from './router';
+import type { AppRouteId } from './router';
/**
* Props for the route navigation drawer.
@@ -13,6 +28,45 @@ export interface NavigationDrawerProps {
onClose: () => void;
}
+const routeIcon = (routeId: AppRouteId) => {
+ if (routeId === 'identifiers') {
+ return ;
+ }
+
+ if (routeId === 'credentials') {
+ return ;
+ }
+
+ if (routeId === 'client') {
+ return ;
+ }
+
+ if (routeId === 'operations') {
+ return ;
+ }
+
+ return ;
+};
+
+const navButtonSx = (active: boolean) => ({
+ mx: 1,
+ my: 0.5,
+ border: 1,
+ borderColor: active ? 'primary.main' : 'transparent',
+ borderRadius: 1,
+ color: active ? 'primary.main' : 'text.secondary',
+ bgcolor: active ? 'action.selected' : 'transparent',
+ '&:hover': {
+ borderColor: 'primary.main',
+ bgcolor: 'action.hover',
+ color: 'text.primary',
+ },
+ '.MuiListItemIcon-root': {
+ color: 'inherit',
+ minWidth: 38,
+ },
+});
+
/**
* Drawer generated from data-router route handles.
*
@@ -22,6 +76,7 @@ export interface NavigationDrawerProps {
*/
export const NavigationDrawer = ({ open, onClose }: NavigationDrawerProps) => {
const navigate = useNavigate();
+ const location = useLocation();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Tab' || event.key === 'Shift') {
@@ -39,6 +94,7 @@ export const NavigationDrawer = ({ open, onClose }: NavigationDrawerProps) => {
paper: {
sx: {
width: 'min(80vw, 280px)',
+ pt: 1,
},
},
}}
@@ -53,7 +109,13 @@ export const NavigationDrawer = ({ open, onClose }: NavigationDrawerProps) => {
onClose();
}}
data-testid={view.testId}
+ sx={navButtonSx(
+ location.pathname.startsWith(view.path)
+ )}
>
+
+ {routeIcon(view.routeId)}
+
))}
@@ -62,3 +124,69 @@ export const NavigationDrawer = ({ open, onClose }: NavigationDrawerProps) => {
);
};
+
+export const DesktopNavigationRail = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ return (
+ theme.zIndex.appBar - 1,
+ width: 184,
+ flexDirection: 'column',
+ gap: 0.5,
+ borderRight: 1,
+ borderColor: 'divider',
+ bgcolor: 'rgba(7, 13, 20, 0.9)',
+ px: 1,
+ py: 2,
+ backdropFilter: 'blur(10px)',
+ }}
+ >
+ {APP_NAV_ITEMS.map((view) => {
+ const active = location.pathname.startsWith(view.path);
+
+ return (
+
+ navigate(view.path)}
+ data-testid={`rail-${view.testId}`}
+ selected={active}
+ sx={{
+ ...navButtonSx(active),
+ mx: 0,
+ minHeight: 48,
+ }}
+ >
+
+ {routeIcon(view.routeId)}
+
+
+ {view.label}
+
+ }
+ />
+
+
+ );
+ })}
+
+ );
+};
diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx
index 1c1c81bd..85e09102 100644
--- a/src/app/RootLayout.tsx
+++ b/src/app/RootLayout.tsx
@@ -7,7 +7,7 @@ import { AppRuntimeProvider } from './runtimeContext';
import { ConnectDialog } from './ConnectDialog';
import { derivePendingState } from './pendingState';
import { LoadingOverlay } from './LoadingOverlay';
-import { NavigationDrawer } from './NavigationDrawer';
+import { DesktopNavigationRail, NavigationDrawer } from './NavigationDrawer';
import { TopBar } from './TopBar';
import { useAppSelector } from '../state/hooks';
import {
@@ -65,6 +65,7 @@ const RootLayoutContent = () => {
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
/>
+
{
minHeight: '100dvh',
overflowX: 'clip',
px: { xs: 2, sm: 3 },
+ ml: { xs: 0, md: '184px' },
pt: { xs: 'calc(56px + 16px)', sm: 'calc(64px + 24px)' },
pb: {
xs: 'calc(88px + env(safe-area-inset-bottom))',
sm: 3,
},
+ background:
+ 'linear-gradient(180deg, rgba(39, 215, 255, 0.04) 0%, rgba(5, 9, 13, 0) 220px)',
}}
>
diff --git a/src/app/RouteErrorBoundary.tsx b/src/app/RouteErrorBoundary.tsx
index c76205e9..2d840c9a 100644
--- a/src/app/RouteErrorBoundary.tsx
+++ b/src/app/RouteErrorBoundary.tsx
@@ -1,5 +1,6 @@
import { Box, Typography } from '@mui/material';
import { isRouteErrorResponse, useRouteError } from 'react-router-dom';
+import { ConsolePanel, StatusPill } from './Console';
import { toError } from '../signify/client';
export interface RouteErrorBoundaryProps {
@@ -29,11 +30,16 @@ export const RouteErrorBoundary = ({ title }: RouteErrorBoundaryProps) => {
const error = useRouteError();
return (
-
-
- {title}
-
- {routeErrorMessage(error)}
+
+ }
+ >
+
+ {routeErrorMessage(error)}
+
+
);
};
diff --git a/src/app/TopBar.tsx b/src/app/TopBar.tsx
index 2cf13232..71c4b191 100644
--- a/src/app/TopBar.tsx
+++ b/src/app/TopBar.tsx
@@ -18,6 +18,7 @@ import CircleIcon from '@mui/icons-material/Circle';
import MenuIcon from '@mui/icons-material/Menu';
import NotificationsIcon from '@mui/icons-material/Notifications';
import { Link as RouterLink } from 'react-router-dom';
+import { StatusPill } from './Console';
import type { AppNotificationRecord } from '../state/appNotifications.slice';
import type { OperationRecord } from '../state/operations.slice';
import { allAppNotificationsRead } from '../state/appNotifications.slice';
@@ -99,6 +100,7 @@ export const TopBar = ({
display: 'flex',
gap: { xs: 0.5, sm: 1.5 },
minWidth: 0,
+ minHeight: { xs: 56, sm: 64 },
}}
>
@@ -116,10 +119,18 @@ export const TopBar = ({
sx={{
flex: '1 1 auto',
minWidth: 0,
+ color: 'text.primary',
+ fontWeight: 700,
}}
>
- Signify Client
+ Signify Ops
+
+
+
@@ -144,7 +155,7 @@ export const TopBar = ({
sx={{
width: 22,
height: 22,
- borderRadius: '50%',
+ borderRadius: 1,
border: 2,
borderColor: 'currentColor',
display: 'block',
@@ -171,27 +182,30 @@ export const TopBar = ({
@@ -202,7 +216,7 @@ export const TopBar = ({
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
-
+
{activeOperations.length === 0 ? (
setOperationsAnchor(null)}
+ sx={{
+ border: 1,
+ borderColor: 'divider',
+ borderRadius: 1,
+ mb: 0.75,
+ }}
>
setOperationsAnchor(null)}
+ sx={{ borderRadius: 1 }}
>
-
+
@@ -239,7 +260,7 @@ export const TopBar = ({
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
-
+
{recentNotifications.length === 0 ? (
setNotificationsAnchor(null)}
+ sx={{ borderRadius: 1 }}
>
diff --git a/src/app/consoleStyles.ts b/src/app/consoleStyles.ts
new file mode 100644
index 00000000..5382e395
--- /dev/null
+++ b/src/app/consoleStyles.ts
@@ -0,0 +1,6 @@
+export const monoValueSx = {
+ fontFamily: 'var(--app-mono-font)',
+ letterSpacing: 0,
+ overflowWrap: 'anywhere',
+ wordBreak: 'break-word',
+} as const;
diff --git a/src/app/theme.ts b/src/app/theme.ts
new file mode 100644
index 00000000..48dbd62c
--- /dev/null
+++ b/src/app/theme.ts
@@ -0,0 +1,298 @@
+import { createTheme } from '@mui/material/styles';
+
+const graphite = {
+ abyss: '#05090d',
+ deck: '#091018',
+ panel: '#0d1722',
+ panelHigh: '#132334',
+ line: '#234055',
+ lineStrong: '#3a6680',
+ text: '#e7f4ff',
+ muted: '#8aa1b2',
+ dim: '#5f7380',
+ cyan: '#27d7ff',
+ blue: '#2487ff',
+ amber: '#ffb02e',
+ green: '#39d47a',
+ red: '#ff3d4f',
+ violet: '#a778ff',
+};
+
+export const appTheme = createTheme({
+ palette: {
+ mode: 'dark',
+ primary: {
+ main: graphite.cyan,
+ light: '#76e8ff',
+ dark: '#008bb8',
+ contrastText: '#041016',
+ },
+ secondary: {
+ main: graphite.violet,
+ light: '#c5a8ff',
+ dark: '#7450c7',
+ contrastText: '#ffffff',
+ },
+ success: {
+ main: graphite.green,
+ dark: '#18a955',
+ contrastText: '#041016',
+ },
+ warning: {
+ main: graphite.amber,
+ dark: '#c67b00',
+ contrastText: '#05090d',
+ },
+ error: {
+ main: graphite.red,
+ dark: '#ba1628',
+ contrastText: '#ffffff',
+ },
+ info: {
+ main: graphite.blue,
+ dark: '#0063cc',
+ contrastText: '#ffffff',
+ },
+ background: {
+ default: graphite.abyss,
+ paper: graphite.panel,
+ },
+ text: {
+ primary: graphite.text,
+ secondary: graphite.muted,
+ disabled: graphite.dim,
+ },
+ divider: graphite.line,
+ action: {
+ active: graphite.cyan,
+ hover: 'rgba(39, 215, 255, 0.09)',
+ selected: 'rgba(39, 215, 255, 0.16)',
+ disabled: 'rgba(138, 161, 178, 0.38)',
+ disabledBackground: 'rgba(95, 115, 128, 0.18)',
+ },
+ },
+ shape: {
+ borderRadius: 4,
+ },
+ typography: {
+ fontFamily:
+ 'var(--app-interface-font), Inter, system-ui, Helvetica, Arial, sans-serif',
+ h1: { fontWeight: 600, letterSpacing: 0 },
+ h2: { fontWeight: 600, letterSpacing: 0 },
+ h3: { fontWeight: 600, letterSpacing: 0 },
+ h4: { fontWeight: 600, letterSpacing: 0 },
+ h5: { fontWeight: 600, letterSpacing: 0 },
+ h6: { fontWeight: 600, letterSpacing: 0 },
+ button: {
+ fontWeight: 700,
+ letterSpacing: 0,
+ textTransform: 'uppercase',
+ },
+ caption: {
+ letterSpacing: 0,
+ },
+ },
+ components: {
+ MuiCssBaseline: {
+ styleOverrides: {
+ body: {
+ backgroundColor: graphite.abyss,
+ backgroundImage:
+ 'linear-gradient(rgba(39, 215, 255, 0.035) 1px, transparent 1px), linear-gradient(90deg, rgba(39, 215, 255, 0.025) 1px, transparent 1px)',
+ backgroundSize: '48px 48px, 48px 48px',
+ },
+ },
+ },
+ MuiAppBar: {
+ styleOverrides: {
+ root: {
+ background: 'rgba(6, 13, 20, 0.94)',
+ borderBottom: `1px solid ${graphite.lineStrong}`,
+ boxShadow: '0 12px 28px rgba(0, 0, 0, 0.32)',
+ backdropFilter: 'blur(12px)',
+ },
+ },
+ },
+ MuiPaper: {
+ styleOverrides: {
+ root: {
+ backgroundImage: 'none',
+ border: `1px solid ${graphite.line}`,
+ boxShadow: '0 18px 44px rgba(0, 0, 0, 0.32)',
+ },
+ },
+ },
+ MuiCard: {
+ styleOverrides: {
+ root: {
+ backgroundImage: 'none',
+ backgroundColor: graphite.panel,
+ border: `1px solid ${graphite.line}`,
+ boxShadow: '0 12px 32px rgba(0, 0, 0, 0.24)',
+ },
+ },
+ },
+ MuiDialog: {
+ styleOverrides: {
+ paper: {
+ backgroundColor: graphite.deck,
+ border: `1px solid ${graphite.lineStrong}`,
+ boxShadow:
+ '0 24px 70px rgba(0, 0, 0, 0.62), inset 0 1px 0 rgba(118, 232, 255, 0.12)',
+ },
+ },
+ },
+ MuiDialogTitle: {
+ styleOverrides: {
+ root: {
+ borderBottom: `1px solid ${graphite.line}`,
+ color: graphite.text,
+ paddingTop: 20,
+ paddingBottom: 16,
+ },
+ },
+ },
+ MuiDialogActions: {
+ styleOverrides: {
+ root: {
+ borderTop: `1px solid ${graphite.line}`,
+ },
+ },
+ },
+ MuiButton: {
+ defaultProps: {
+ disableElevation: true,
+ },
+ variants: [
+ {
+ props: { variant: 'contained', color: 'primary' },
+ style: {
+ background:
+ 'linear-gradient(180deg, #37ddff 0%, #1478c7 100%)',
+ boxShadow:
+ 'inset 0 1px 0 rgba(255, 255, 255, 0.24), 0 0 18px rgba(39, 215, 255, 0.18)',
+ '&:hover': {
+ background:
+ 'linear-gradient(180deg, #78eaff 0%, #198eea 100%)',
+ },
+ '&.Mui-disabled': {
+ background: 'rgba(95, 115, 128, 0.18)',
+ color: 'rgba(138, 161, 178, 0.38)',
+ boxShadow: 'none',
+ },
+ },
+ },
+ ],
+ styleOverrides: {
+ root: {
+ borderRadius: 3,
+ minHeight: 40,
+ },
+ outlined: {
+ borderColor: graphite.lineStrong,
+ color: graphite.text,
+ '&:hover': {
+ borderColor: graphite.cyan,
+ backgroundColor: 'rgba(39, 215, 255, 0.08)',
+ },
+ },
+ text: {
+ color: graphite.cyan,
+ },
+ },
+ },
+ MuiIconButton: {
+ styleOverrides: {
+ root: {
+ borderRadius: 3,
+ '&:hover': {
+ backgroundColor: 'rgba(39, 215, 255, 0.1)',
+ },
+ },
+ },
+ },
+ MuiTextField: {
+ defaultProps: {
+ variant: 'outlined',
+ },
+ },
+ MuiOutlinedInput: {
+ styleOverrides: {
+ root: {
+ backgroundColor: 'rgba(5, 9, 13, 0.68)',
+ borderRadius: 3,
+ '& fieldset': {
+ borderColor: graphite.lineStrong,
+ },
+ '&:hover fieldset': {
+ borderColor: graphite.cyan,
+ },
+ '&.Mui-focused fieldset': {
+ borderColor: graphite.cyan,
+ boxShadow: '0 0 0 1px rgba(39, 215, 255, 0.24)',
+ },
+ },
+ },
+ },
+ MuiInputLabel: {
+ styleOverrides: {
+ root: {
+ color: graphite.muted,
+ '&.Mui-focused': {
+ color: graphite.cyan,
+ },
+ },
+ },
+ },
+ MuiTableCell: {
+ styleOverrides: {
+ root: {
+ borderBottom: `1px solid ${graphite.line}`,
+ },
+ head: {
+ color: graphite.muted,
+ fontSize: '0.76rem',
+ fontWeight: 700,
+ textTransform: 'uppercase',
+ },
+ },
+ },
+ MuiChip: {
+ styleOverrides: {
+ root: {
+ borderRadius: 3,
+ fontWeight: 700,
+ },
+ },
+ },
+ MuiAccordion: {
+ styleOverrides: {
+ root: {
+ backgroundColor: graphite.panel,
+ backgroundImage: 'none',
+ border: `1px solid ${graphite.line}`,
+ boxShadow: 'none',
+ '&:before': {
+ display: 'none',
+ },
+ },
+ },
+ },
+ MuiDrawer: {
+ styleOverrides: {
+ paper: {
+ backgroundColor: '#070d14',
+ borderRight: `1px solid ${graphite.lineStrong}`,
+ },
+ },
+ },
+ MuiBackdrop: {
+ styleOverrides: {
+ root: {
+ backgroundColor: 'rgba(0, 0, 0, 0.68)',
+ backdropFilter: 'blur(2px)',
+ },
+ },
+ },
+ },
+});
diff --git a/src/features/client/AidCard.tsx b/src/features/client/AidCard.tsx
index 4333a28a..e1a76ec4 100644
--- a/src/features/client/AidCard.tsx
+++ b/src/features/client/AidCard.tsx
@@ -1,5 +1,6 @@
-import { Card, CardContent, Divider, Grid, Typography } from '@mui/material';
+import { Stack } from '@mui/material';
import type { KeyState } from 'signify-ts';
+import { ConsolePanel, TelemetryRow } from '../../app/Console';
import { keyStateFieldDescriptions } from './keyStateFieldDescriptions';
/**
@@ -17,32 +18,18 @@ export interface AidCardProps {
* to the raw key. State extraction belongs in `ClientView`.
*/
export const AidCard = ({ data, text }: AidCardProps) => (
-
-
-
- {text}
-
-
-
- {Object.entries(data).map(([key, value]) =>
- typeof value === 'string' ? (
-
-
-
- {keyStateFieldDescriptions[key]?.title ??
- key}
- {' '}
- {value}
-
-
- ) : null
- )}
-
-
-
+
+
+ {Object.entries(data).map(([key, value]) =>
+ typeof value === 'string' ? (
+
+ ) : null
+ )}
+
+
);
diff --git a/src/features/client/ClientView.tsx b/src/features/client/ClientView.tsx
index 58c20668..ed141b20 100644
--- a/src/features/client/ClientView.tsx
+++ b/src/features/client/ClientView.tsx
@@ -1,6 +1,8 @@
-import { Box, Grid, Typography } from '@mui/material';
+import { Box, Grid, Stack } from '@mui/material';
import { useLoaderData } from 'react-router-dom';
import { ConnectionRequired } from '../../app/ConnectionRequired';
+import { ConsolePanel, PageHeader, TelemetryRow } from '../../app/Console';
+import { monoValueSx } from '../../app/consoleStyles';
import type { ClientLoaderData } from '../../app/routeData';
import { AidCard } from './AidCard';
@@ -23,21 +25,40 @@ export const ClientView = () => {
const controller = summary.state.controller.state;
return (
-
-
-
- Controller AID: {summary.controllerPre}
-
-
- Agent AID: {summary.agentPre}
-
-
+
+
+
+
+
+ {summary.controllerPre}
+
+ }
+ />
+
+ {summary.agentPre}
+
+ }
+ />
+
+
diff --git a/src/features/credentials/CredentialsView.tsx b/src/features/credentials/CredentialsView.tsx
index 7af1afde..86b5cb80 100644
--- a/src/features/credentials/CredentialsView.tsx
+++ b/src/features/credentials/CredentialsView.tsx
@@ -1,8 +1,34 @@
-import { Box, Typography } from '@mui/material';
+import { Box, Grid, Stack, Typography } from '@mui/material';
import { useLoaderData } from 'react-router-dom';
import { ConnectionRequired } from '../../app/ConnectionRequired';
+import { ConsolePanel, PageHeader, StatusPill } from '../../app/Console';
import type { CredentialsLoaderData } from '../../app/routeData';
+const roleCards = [
+ {
+ role: 'Issuer',
+ state: 'Prepare',
+ tone: 'info' as const,
+ summary:
+ 'Create registry, issue SEDI voter credential, grant to holder.',
+ steps: ['Registry', 'Credential draft', 'IPEX grant'],
+ },
+ {
+ role: 'Holder',
+ state: 'Receive',
+ tone: 'warning' as const,
+ summary: 'Resolve issuer, admit credential grant, present to verifier.',
+ steps: ['Issuer OOBI', 'Grant admit', 'Presentation'],
+ },
+ {
+ role: 'Verifier',
+ state: 'Validate',
+ tone: 'success' as const,
+ summary: 'Trust issuer/schema, validate presentation, emit result.',
+ steps: ['Trusted issuer', 'TEL state', 'Webhook event'],
+ },
+];
+
/**
* Placeholder credentials route.
*
@@ -18,13 +44,79 @@ export const CredentialsView = () => {
}
return (
-
-
- Credentials
-
-
- Credential workflow placeholder
-
+
+
+
+ {roleCards.map((card) => (
+
+
+ }
+ sx={{ height: '100%' }}
+ >
+
+
+ {card.summary}
+
+
+ {card.steps.map((step, index) => (
+
+
+ {index + 1}
+
+ {step}
+
+ ))}
+
+
+
+
+ ))}
+
+
+
+ Replace this mission board with live schema resolution,
+ registry creation, issuer grant, holder admit, holder
+ presentation, and verifier result panels as those operations
+ land behind the runtime workflow boundary.
+
+
);
};
diff --git a/src/features/identifiers/IdentifierTable.tsx b/src/features/identifiers/IdentifierTable.tsx
index 8b51c68e..776fc5d2 100644
--- a/src/features/identifiers/IdentifierTable.tsx
+++ b/src/features/identifiers/IdentifierTable.tsx
@@ -122,6 +122,13 @@ export const IdentifierTable = ({
key={identifier.name}
variant="outlined"
data-testid={`identifier-row-${identifier.name}`}
+ sx={{
+ bgcolor: 'background.paper',
+ borderColor: 'divider',
+ '&:hover': {
+ borderColor: 'primary.main',
+ },
+ }}
>
PIDX:{' '}
{formatIdentifierMetadata(
- identifierIdentifierIndex(identifier)
+ identifierIdentifierIndex(
+ identifier
+ )
)}
@@ -190,11 +199,20 @@ export const IdentifierTable = ({
-
+
Name
AID
Type
@@ -209,6 +227,9 @@ export const IdentifierTable = ({
key={identifier.name}
sx={{
cursor: 'pointer',
+ '&:hover': {
+ bgcolor: 'action.hover',
+ },
'&:last-child td, &:last-child th': {
border: 0,
},
diff --git a/src/features/identifiers/IdentifiersView.tsx b/src/features/identifiers/IdentifiersView.tsx
index 20b75048..c3d9d9c8 100644
--- a/src/features/identifiers/IdentifiersView.tsx
+++ b/src/features/identifiers/IdentifiersView.tsx
@@ -1,8 +1,9 @@
import { useState } from 'react';
-import { Box, Fab, Typography } from '@mui/material';
+import { Box, Button, Fab, Typography } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { useFetcher, useLoaderData } from 'react-router-dom';
import { ConnectionRequired } from '../../app/ConnectionRequired';
+import { EmptyState, PageHeader, StatusPill } from '../../app/Console';
import type {
IdentifierActionData,
IdentifiersLoaderData,
@@ -113,7 +114,9 @@ export const IdentifiersView = () => {
fetcher.submit(formData, { method: 'post' });
};
- const handleCreate = async (draft: IdentifierCreateDraft): Promise => {
+ const handleCreate = async (
+ draft: IdentifierCreateDraft
+ ): Promise => {
const requestId = globalThis.crypto.randomUUID();
setActiveCreateRequestId(requestId);
setPendingMessage(`Creating identifier ${draft.name}`);
@@ -126,12 +129,61 @@ export const IdentifiersView = () => {
const isRotateDisabled = (identifierName: string): boolean =>
activeResourceKeys.has(`identifier:aid:${identifierName}`);
+ const openCreate = () => {
+ setActiveCreateRequestId(null);
+ setCreateOpen(true);
+ };
return (
-
+
+ }
+ aria-label="create identifier"
+ onClick={openCreate}
+ disabled={actionRunning}
+ sx={{ display: { xs: 'none', sm: 'inline-flex' } }}
+ >
+ Create Identifier
+
+ }
+ />
{actionState.message && (
-
+
+ {' '}
{
)}
+ {identifiers.length === 0 && (
+ }
+ aria-label="add"
+ onClick={openCreate}
+ disabled={actionRunning}
+ sx={{ display: { xs: 'inline-flex', sm: 'none' } }}
+ >
+ Create Identifier
+
+ }
+ />
+ )}
@@ -154,7 +224,10 @@ export const IdentifiersView = () => {
}
/>
{
color="primary"
aria-label="add"
onClick={() => {
- setActiveCreateRequestId(null);
- setCreateOpen(true);
+ openCreate();
}}
disabled={actionRunning}
sx={{
+ display: {
+ xs:
+ createDialogOpen || identifiers.length === 0
+ ? 'none'
+ : 'inline-flex',
+ sm: 'none',
+ },
position: 'fixed',
right: { xs: '16px', sm: '24px' },
bottom: {
diff --git a/src/features/notifications/AppNotificationsView.tsx b/src/features/notifications/AppNotificationsView.tsx
index be6969ca..cd91e79d 100644
--- a/src/features/notifications/AppNotificationsView.tsx
+++ b/src/features/notifications/AppNotificationsView.tsx
@@ -1,7 +1,6 @@
import { useEffect } from 'react';
import {
Box,
- Chip,
Link,
List,
ListItem,
@@ -10,6 +9,7 @@ import {
Typography,
} from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
+import { EmptyState, PageHeader, StatusPill } from '../../app/Console';
import { useAppDispatch, useAppSelector } from '../../state/hooks';
import { allAppNotificationsRead } from '../../state/appNotifications.slice';
import { selectAppNotifications } from '../../state/selectors';
@@ -38,14 +38,17 @@ export const AppNotificationsView = () => {
}, [dispatch, unreadCount]);
return (
-
-
- Notifications
-
+
+
{notifications.length === 0 ? (
-
- No app notifications yet.
-
+
) : (
{notifications.map((notification) => (
@@ -59,7 +62,7 @@ export const AppNotificationsView = () => {
alignItems: 'flex-start',
bgcolor:
notification.status === 'unread'
- ? 'common.white'
+ ? 'action.selected'
: 'action.hover',
opacity:
notification.status === 'unread' ? 1 : 0.72,
@@ -78,16 +81,16 @@ export const AppNotificationsView = () => {
{notification.title}
-
diff --git a/src/features/operations/OperationDetailView.tsx b/src/features/operations/OperationDetailView.tsx
index 2de7c2f7..80c23f83 100644
--- a/src/features/operations/OperationDetailView.tsx
+++ b/src/features/operations/OperationDetailView.tsx
@@ -1,26 +1,37 @@
-import {
- Box,
- Button,
- Chip,
- Divider,
- Link,
- Stack,
- Typography,
-} from '@mui/material';
+import { Box, Button, Link, Stack } from '@mui/material';
import { Link as RouterLink, useParams } from 'react-router-dom';
+import {
+ ConsolePanel,
+ PageHeader,
+ StatusPill,
+ TelemetryRow,
+} from '../../app/Console';
import { useAppSelector } from '../../state/hooks';
import { selectOperationById } from '../../state/selectors';
-const DetailRow = ({ label, value }: { label: string; value: string | null }) => (
-
-
- {label}
-
-
- {value ?? 'None'}
-
-
-);
+const DetailRow = ({
+ label,
+ value,
+}: {
+ label: string;
+ value: string | null;
+}) => ;
+
+const operationTone = (status: string) => {
+ if (status === 'error') {
+ return 'error' as const;
+ }
+
+ if (status === 'success') {
+ return 'success' as const;
+ }
+
+ if (status === 'running') {
+ return 'warning' as const;
+ }
+
+ return 'neutral' as const;
+};
export const OperationDetailView = () => {
const { requestId = '' } = useParams();
@@ -29,10 +40,16 @@ export const OperationDetailView = () => {
if (operation === null) {
return (
-
- Operation Not Found
-
-
@@ -40,32 +57,33 @@ export const OperationDetailView = () => {
}
return (
-
-
-
- {operation.title}
-
-
-
- {operation.description && (
-
- {operation.description}
-
- )}
-
-
+
+
+ }
+ />
+
-
+
-
+
{
: null
}
/>
-
+
-
+
All Operations
{operation.resultRoute && (
diff --git a/src/features/operations/OperationsView.tsx b/src/features/operations/OperationsView.tsx
index d972a646..5410d02b 100644
--- a/src/features/operations/OperationsView.tsx
+++ b/src/features/operations/OperationsView.tsx
@@ -1,6 +1,5 @@
import {
Box,
- Chip,
List,
ListItemButton,
ListItemText,
@@ -8,21 +7,41 @@ import {
Typography,
} from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
+import { EmptyState, PageHeader, StatusPill } from '../../app/Console';
import { useAppSelector } from '../../state/hooks';
import { selectOperationRecords } from '../../state/selectors';
+const operationTone = (status: string) => {
+ if (status === 'error') {
+ return 'error' as const;
+ }
+
+ if (status === 'success') {
+ return 'success' as const;
+ }
+
+ if (status === 'running') {
+ return 'warning' as const;
+ }
+
+ return 'neutral' as const;
+};
+
export const OperationsView = () => {
const operations = [...useAppSelector(selectOperationRecords)].reverse();
return (
-
-
- Operations
-
+
+
{operations.length === 0 ? (
-
- No operations have run in this browser session.
-
+
) : (
{operations.map((operation) => (
@@ -36,6 +55,11 @@ export const OperationsView = () => {
borderRadius: 1,
mb: 1,
alignItems: 'flex-start',
+ bgcolor: 'background.paper',
+ '&:hover': {
+ borderColor: 'primary.main',
+ bgcolor: 'action.hover',
+ },
}}
>
{
{operation.title}
-
}
diff --git a/src/index.css b/src/index.css
index 9bda864a..f41ae5ba 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,9 +1,12 @@
-@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;500;600&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700&family=Source+Code+Pro:wght@400;500;600&display=swap');
:root {
- font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
- --app-mono-font: 'Source Code Pro', 'Roboto Mono', 'SFMono-Regular',
- Consolas, 'Liberation Mono', monospace;
+ font-family:
+ Rajdhani, Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ --app-interface-font: 'Rajdhani';
+ --app-mono-font:
+ 'Source Code Pro', 'Roboto Mono', 'SFMono-Regular', Consolas,
+ 'Liberation Mono', monospace;
line-height: 1.5;
font-weight: 400;