feat(observer): M-Obs 4-7 — widgets, dashboard, docs, integration example
Some checks failed
Test / test (push) Has been cancelled

M-Obs 4: @shade/widgets React library
- ShadeProvider context with observer URL + token + theme
- useShadeState (polling) + useShadeEvents (SSE) hooks
- 7 widgets: IdentityCard, SessionList, PrekeyStock, RecentActivity,
  ServerStatus, FingerprintCompare, WidgetCatalog (meta-widget for
  user-selectable layout with localStorage persistence)
- Self-contained CSS via inline styles, no external CSS conflicts
- Light/dark/auto theme via tokens

M-Obs 5: @shade/dashboard standalone SPA
- Vite + React app composing all widgets into a full debugger layout
- Login screen with token persistence to localStorage
- Build script copies dist/ to @shade/observer/dist/ for embedded serving
- 211 KB JS bundle (66 KB gzipped)

M-Obs 6: Documentation + integration example
- READMEs for @shade/observer and @shade/widgets
- examples/06-observer-dashboard runnable demo: spins up prekey server +
  observer, runs Alice ↔ Bob conversation loop, dashboard at :3901
- Updated root README and docker-compose.yml with observer integration

M-Obs 7: End-to-end verification
- StateAggregator now drains buffered events on subscription, so
  identity.initialized fires before observer construction are still seen
- Verified live: snapshot endpoint returns full state, dashboard serves,
  401 without auth, sessions/messages/ratchet steps all tracked

220 tests passing, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 19:00:21 +02:00
parent b014f9b44c
commit 9ceab037ca
32 changed files with 2016 additions and 2 deletions

View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shade Observer</title>
<style>
:root { color-scheme: dark; }
html, body { margin: 0; padding: 0; }
body {
background: #0a0a0a;
color: #e5e5e5;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
min-height: 100vh;
}
#root { min-height: 100vh; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,20 @@
{
"name": "@shade/dashboard",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && bun run scripts/copy-to-observer.ts"
},
"dependencies": {
"@shade/widgets": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"vite": "^6.0.0"
}
}

View File

@@ -0,0 +1,26 @@
/**
* After Vite builds the dashboard, copy the dist/ output into
* @shade/observer's dist/ directory so the observer endpoint can
* serve it from /dashboard/.
*/
import { existsSync, mkdirSync, cpSync, rmSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const here = dirname(fileURLToPath(import.meta.url));
const dashboardDist = join(here, '..', 'dist');
const observerDist = join(here, '..', '..', 'shade-observer', 'dist');
if (!existsSync(dashboardDist)) {
console.error(`Dashboard dist not found at ${dashboardDist}. Run \`vite build\` first.`);
process.exit(1);
}
// Clean and recreate observer dist
if (existsSync(observerDist)) {
rmSync(observerDist, { recursive: true });
}
mkdirSync(observerDist, { recursive: true });
cpSync(dashboardDist, observerDist, { recursive: true });
console.log(`✓ Copied dashboard build to ${observerDist}`);

View File

@@ -0,0 +1,149 @@
import React, { useState, useEffect } from 'react';
import {
ShadeProvider,
IdentityCard,
SessionList,
PrekeyStock,
RecentActivity,
ServerStatus,
FingerprintCompare,
} from '@shade/widgets';
import { Login } from './Login.js';
const STORAGE_KEY = 'shade-dashboard-config';
interface DashboardConfig {
observerUrl: string;
token: string;
}
export function App(): React.ReactElement {
const [config, setConfig] = useState<DashboardConfig | null>(null);
useEffect(() => {
// Determine the default observer URL: assume dashboard is served from
// the same origin as the observer, so the API is at the parent path
const defaultUrl = window.location.origin + window.location.pathname.replace(/\/dashboard\/?$/, '');
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as DashboardConfig;
if (parsed.observerUrl && parsed.token) {
setConfig(parsed);
return;
}
}
} catch {}
// Show login screen
setConfig({ observerUrl: defaultUrl, token: '' });
}, []);
function handleLogin(observerUrl: string, token: string): void {
const cfg = { observerUrl, token };
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
setConfig(cfg);
}
function handleLogout(): void {
window.localStorage.removeItem(STORAGE_KEY);
setConfig({ observerUrl: window.location.origin, token: '' });
}
if (!config) {
return <div style={{ padding: 40, textAlign: 'center' }}>Loading</div>;
}
if (!config.token) {
return <Login defaultUrl={config.observerUrl} onLogin={handleLogin} />;
}
return (
<ShadeProvider observerUrl={config.observerUrl} token={config.token}>
<DashboardLayout onLogout={handleLogout} />
</ShadeProvider>
);
}
function DashboardLayout({ onLogout }: { onLogout: () => void }): React.ReactElement {
return (
<div
style={{
maxWidth: 1400,
margin: '0 auto',
padding: 24,
display: 'flex',
flexDirection: 'column',
gap: 24,
}}
>
<header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 16,
borderBottom: '1px solid #262626',
}}
>
<div>
<h1
style={{
margin: 0,
fontSize: 22,
fontWeight: 600,
color: '#f7c948',
letterSpacing: '-0.01em',
}}
>
Shade Observer
</h1>
<div style={{ fontSize: 12, color: '#a3a3a3', marginTop: 2 }}>
Live debugger for your Signal Protocol deployment
</div>
</div>
<button
onClick={onLogout}
style={{
background: 'transparent',
border: '1px solid #262626',
color: '#a3a3a3',
padding: '6px 14px',
borderRadius: 6,
cursor: 'pointer',
fontSize: 12,
fontFamily: 'inherit',
}}
>
Sign out
</button>
</header>
<IdentityCard />
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(280px, 1fr) minmax(280px, 1fr) minmax(280px, 1fr)',
gap: 20,
}}
>
<PrekeyStock />
<ServerStatus />
<FingerprintCompare />
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 20,
}}
>
<SessionList />
<RecentActivity limit={100} />
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import React, { useState } from 'react';
interface LoginProps {
defaultUrl: string;
onLogin: (url: string, token: string) => void;
}
export function Login({ defaultUrl, onLogin }: LoginProps): React.ReactElement {
const [url, setUrl] = useState(defaultUrl);
const [token, setToken] = useState('');
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
setError(null);
if (!url.trim() || !token.trim()) {
setError('Both fields are required');
return;
}
try {
const res = await fetch(`${url.replace(/\/$/, '')}/api/state`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
setError(`Auth failed: HTTP ${res.status}`);
return;
}
onLogin(url.trim(), token.trim());
} catch (err) {
setError((err as Error).message);
}
}
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
}}
>
<form
onSubmit={handleSubmit}
style={{
background: '#161616',
border: '1px solid #262626',
borderRadius: 8,
padding: 32,
width: '100%',
maxWidth: 420,
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
<div>
<h1 style={{ margin: 0, color: '#f7c948', fontSize: 22 }}>Shade Observer</h1>
<p style={{ margin: '4px 0 0', color: '#a3a3a3', fontSize: 13 }}>
Connect to your observer endpoint
</p>
</div>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 11, color: '#a3a3a3', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Observer URL
</span>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://shade.example.com/shade-observer"
style={inputStyle}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 11, color: '#a3a3a3', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Bearer token
</span>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="SHADE_OBSERVER_TOKEN value"
style={inputStyle}
/>
</label>
{error && (
<div style={{ color: '#ef4444', fontSize: 12, padding: '8px 10px', background: 'rgba(239, 68, 68, 0.1)', borderRadius: 4 }}>
{error}
</div>
)}
<button
type="submit"
style={{
background: '#f7c948',
color: '#0a0a0a',
border: 'none',
padding: '10px 16px',
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
Connect
</button>
</form>
</div>
);
}
const inputStyle: React.CSSProperties = {
background: '#0a0a0a',
border: '1px solid #262626',
borderRadius: 6,
padding: '10px 12px',
fontSize: 13,
color: '#e5e5e5',
fontFamily: 'system-ui, -apple-system, sans-serif',
outline: 'none',
};

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
const container = document.getElementById('root');
if (!container) throw new Error('Missing #root');
createRoot(container).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["src", "vite.config.ts"]
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
base: '/dashboard/',
build: {
outDir: 'dist',
emptyOutDir: true,
target: 'es2022',
},
});

View File

@@ -0,0 +1,64 @@
# @shade/observer
Live observability backend for Shade — exposes a snapshot endpoint, an SSE event stream, and serves the bundled dashboard SPA.
## Install
```bash
bun add @shade/observer @shade/server @shade/core
```
## Usage
```ts
import { createObserver } from '@shade/observer';
import { ShadeEventEmitter, ShadeSessionManager } from '@shade/core';
import { PrekeyServerEvents, createPrekeyServer } from '@shade/server';
// 1. Create event emitters
const clientEvents = new ShadeEventEmitter();
const serverEvents = new PrekeyServerEvents();
// 2. Wire them into your session manager and prekey server
const manager = new ShadeSessionManager(crypto, storage, { events: clientEvents });
const prekeyServer = createPrekeyServer({ crypto, events: serverEvents });
// 3. Create the observer
const observer = createObserver({
token: process.env.SHADE_OBSERVER_TOKEN!,
clientEvents,
serverEvents,
});
// 4. Mount or serve standalone
import { Hono } from 'hono';
const app = new Hono();
app.route('/shade-observer', observer);
Bun.serve({ port: 3900, fetch: app.fetch });
```
After this, visit `http://localhost:3900/shade-observer/dashboard/` and enter your bearer token to see the dashboard.
## Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/state` | Bearer | Current snapshot (identity, sessions, prekeys, server stats) |
| GET | `/api/events` | Bearer (or `?token=`) | SSE stream of live events |
| GET | `/dashboard/` | None | Bundled web UI |
| GET | `/health` | None | Liveness check |
## Configuration
| Env var | Required | Description |
|---------|----------|-------------|
| `SHADE_OBSERVER_TOKEN` | Yes | Bearer token (min 16 chars). Refuses to start if shorter. |
The token is checked with constant-time comparison.
## Security notes
- Event payloads contain NO key material, plaintext, or signatures — only structural facts (counters, addresses, short hashes for display).
- The observer is intended for internal/debugging use. Put it behind a reverse proxy and authenticate access.
- The dashboard stores the bearer token in `localStorage` for convenience. Don't load the dashboard on shared computers.

View File

@@ -82,9 +82,16 @@ export class StateAggregator {
private readonly store?: PrekeyStore,
) {
if (clientEvents) {
// Replay any events that fired before subscription, then subscribe
for (const e of clientEvents.getBufferedSince(0)) {
this.handleClientEvent(e);
}
clientEvents.on((e) => this.handleClientEvent(e));
}
if (serverEvents) {
for (const e of serverEvents.getBufferedSince(0)) {
this.handleServerEvent(e);
}
serverEvents.on((e) => this.handleServerEvent(e));
}
}

View File

@@ -0,0 +1,89 @@
# @shade/widgets
Embeddable React widgets for live Shade observability. Drop them into any React dashboard (Nova, Orchestrator, your own apps) to show what's happening in your Shade deployment.
## Install
```bash
bun add @shade/widgets react react-dom
```
## Quick start
```tsx
import { ShadeProvider, IdentityCard, SessionList, RecentActivity } from '@shade/widgets';
function MyDashboard() {
return (
<ShadeProvider
observerUrl="https://shade.example.com/shade-observer"
token={process.env.SHADE_TOKEN!}
>
<IdentityCard />
<SessionList />
<RecentActivity />
</ShadeProvider>
);
}
```
You need a running [`@shade/observer`](../shade-observer/README.md) endpoint for the widgets to talk to.
## Components
| Component | Description |
|-----------|-------------|
| `<IdentityCard />` | Your fingerprint, registration ID, init/rotation timestamps |
| `<SessionList />` | Active Shade sessions with per-session message counts and DH ratchet steps |
| `<PrekeyStock lowThreshold={5} />` | Gauge of remaining one-time prekeys with low-stock warning |
| `<RecentActivity limit={50} />` | Live SSE feed of events flowing through the system |
| `<ServerStatus />` | Prekey server stats (registered identities, fetches, replenishes, rate limits) |
| `<FingerprintCompare />` | Paste a safety number to verify it matches your identity |
| `<WidgetCatalog />` | Meta-widget letting users pick which widgets to display |
## Letting users pick widgets
```tsx
<ShadeProvider observerUrl="..." token="...">
<WidgetCatalog
available={['identity', 'sessions', 'prekeys', 'activity', 'server']}
defaultLayout={['identity', 'sessions', 'activity']}
/>
</ShadeProvider>
```
User selections persist to `localStorage`. Pass `onLayoutChange` to override with your own persistence.
## Theming
```tsx
<ShadeProvider observerUrl="..." token="..." themeMode="auto">
```
Modes: `dark` (default), `light`, `auto` (matches `prefers-color-scheme`).
Each widget renders self-contained CSS via inline styles — no Tailwind, no external CSS file, no conflicts with your host app.
## Hooks
For custom layouts, use the underlying hooks directly:
```tsx
import { useShadeState, useShadeEvents } from '@shade/widgets';
function CustomWidget() {
const { state, loading } = useShadeState();
const { events, connected } = useShadeEvents();
// ... render whatever you want
}
```
## Polling interval
Default poll for `/api/state` is 5 seconds. Override:
```tsx
<ShadeProvider observerUrl="..." token="..." pollIntervalMs={2000}>
```
The SSE event stream updates instantly regardless of poll interval.

View File

@@ -0,0 +1,17 @@
{
"name": "@shade/widgets",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react": "^19.2.5",
"react-dom": "^19.2.5"
}
}

View File

@@ -0,0 +1,67 @@
import React, { createContext, useContext, useMemo } from 'react';
import { resolveTheme, type ShadeTheme, type ThemeMode } from './theme.js';
export interface ShadeContextValue {
observerUrl: string;
token: string;
theme: ShadeTheme;
pollIntervalMs: number;
}
const ShadeContext = createContext<ShadeContextValue | null>(null);
export interface ShadeProviderProps {
/** Base URL of the Shade observer endpoint, e.g. "https://shade.example.com/shade-observer" */
observerUrl: string;
/** Bearer token for the observer (matches SHADE_OBSERVER_TOKEN on the server) */
token: string;
/** Theme mode: dark, light, or auto (matches system preference). Default: dark. */
themeMode?: ThemeMode;
/** How often to poll /api/state in milliseconds. Default: 5000. */
pollIntervalMs?: number;
children: React.ReactNode;
}
/**
* ShadeProvider — root context provider that all Shade widgets need.
*
* Wrap your dashboard or specific widget container with this and pass
* the observer URL + token. All child widgets will share the connection.
*
* ```tsx
* <ShadeProvider observerUrl="https://x.com/shade-observer" token={process.env.TOKEN!}>
* <SessionList />
* <PrekeyStock />
* </ShadeProvider>
* ```
*/
export function ShadeProvider({
observerUrl,
token,
themeMode = 'dark',
pollIntervalMs = 5000,
children,
}: ShadeProviderProps): React.ReactElement {
const value = useMemo<ShadeContextValue>(
() => ({
observerUrl: observerUrl.replace(/\/$/, ''),
token,
theme: resolveTheme(themeMode),
pollIntervalMs,
}),
[observerUrl, token, themeMode, pollIntervalMs],
);
return <ShadeContext.Provider value={value}>{children}</ShadeContext.Provider>;
}
/** Internal hook used by widgets to access the context. Throws if no provider. */
export function useShadeContext(): ShadeContextValue {
const ctx = useContext(ShadeContext);
if (!ctx) {
throw new Error(
'Shade widgets must be wrapped in <ShadeProvider observerUrl="..." token="..." />',
);
}
return ctx;
}

View File

@@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { useShadeState } from '../useShadeState.js';
import { useShadeContext } from '../ShadeProvider.js';
import { WidgetShell } from './shared.js';
/**
* FingerprintCompare — paste a fingerprint and check if it matches your own
* or any active session.
*/
export function FingerprintCompare(): React.ReactElement {
const { state } = useShadeState();
const { theme } = useShadeContext();
const [input, setInput] = useState('');
const normalized = input.replace(/\s+/g, ' ').trim();
const yourFp = state?.identity.fingerprint?.replace(/\s+/g, ' ').trim();
const matches = normalized && yourFp && normalized === yourFp;
return (
<WidgetShell title="Verify fingerprint">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<textarea
placeholder="Paste a 60-digit safety number to compare with your own identity"
value={input}
onChange={(e) => setInput(e.target.value)}
rows={3}
style={{
width: '100%',
background: theme.bg,
border: `1px solid ${theme.border}`,
borderRadius: 6,
padding: 10,
fontFamily: theme.fontMono,
fontSize: 12,
color: theme.text,
resize: 'vertical',
outline: 'none',
boxSizing: 'border-box',
}}
/>
{normalized && (
<div
style={{
padding: 10,
borderRadius: 6,
fontSize: 12,
fontWeight: 600,
border: `1px solid ${matches ? theme.success : theme.danger}`,
background: matches ? 'rgba(34, 197, 94, 0.08)' : 'rgba(239, 68, 68, 0.08)',
color: matches ? theme.success : theme.danger,
textAlign: 'center',
}}
>
{matches ? '✓ Matches your identity' : '✗ Does not match your identity'}
</div>
)}
</div>
</WidgetShell>
);
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { useShadeState } from '../useShadeState.js';
import { useShadeContext } from '../ShadeProvider.js';
import { WidgetShell, formatRelative } from './shared.js';
/**
* IdentityCard — shows your own Shade identity: fingerprint, registration ID,
* when it was initialized and last rotated.
*/
export function IdentityCard(): React.ReactElement {
const { state, loading, error } = useShadeState();
const { theme } = useShadeContext();
return (
<WidgetShell title="Identity">
{loading && !state ? (
<div style={{ color: theme.textMuted, fontSize: 13 }}>Loading</div>
) : error ? (
<div style={{ color: theme.danger, fontSize: 13 }}>{error.message}</div>
) : !state?.identity.fingerprint ? (
<div style={{ color: theme.textMuted, fontSize: 13 }}>No identity initialized yet</div>
) : (
<>
<div
style={{
fontFamily: theme.fontMono,
fontSize: 12,
color: theme.text,
wordBreak: 'break-all',
padding: 12,
background: theme.bg,
borderRadius: 6,
border: `1px solid ${theme.border}`,
lineHeight: 1.6,
}}
>
{state.identity.fingerprint}
</div>
<div
style={{
display: 'flex',
gap: 16,
marginTop: 12,
fontSize: 11,
color: theme.textMuted,
}}
>
<div>
<span style={{ color: theme.textDim }}>Reg ID</span>{' '}
<span style={{ fontFamily: theme.fontMono, color: theme.text }}>
{state.identity.registrationId}
</span>
</div>
<div>
<span style={{ color: theme.textDim }}>Initialized</span>{' '}
{formatRelative(state.identity.lastInitialized)}
</div>
{state.identity.lastRotated && (
<div>
<span style={{ color: theme.textDim }}>Rotated</span>{' '}
{formatRelative(state.identity.lastRotated)}
</div>
)}
</div>
</>
)}
</WidgetShell>
);
}

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { useShadeState } from '../useShadeState.js';
import { useShadeContext } from '../ShadeProvider.js';
import { WidgetShell, formatRelative } from './shared.js';
/**
* PrekeyStock — gauge showing one-time prekey count + signed prekey info.
*/
export function PrekeyStock({ lowThreshold = 5 }: { lowThreshold?: number }): React.ReactElement {
const { state } = useShadeState();
const { theme } = useShadeContext();
const remaining = state?.prekeys.oneTimeRemaining ?? 0;
const isLow = remaining < lowThreshold;
const color = isLow ? theme.warning : theme.success;
return (
<WidgetShell title="Prekey stock">
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 12 }}>
<div style={{ fontSize: 36, fontWeight: 700, color, fontFamily: theme.fontMono, lineHeight: 1 }}>
{remaining}
</div>
<div style={{ fontSize: 12, color: theme.textMuted }}>one-time prekeys</div>
</div>
{isLow && (
<div
style={{
fontSize: 11,
color: theme.warning,
background: 'rgba(234, 179, 8, 0.1)',
border: `1px solid ${theme.warning}`,
borderRadius: 4,
padding: '6px 8px',
marginBottom: 12,
}}
>
Low replenish recommended (threshold: {lowThreshold})
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11, color: theme.textMuted }}>
{state?.prekeys.signedPreKeyId != null && (
<div>
<span style={{ color: theme.textDim }}>Signed prekey ID</span>{' '}
<span style={{ fontFamily: theme.fontMono, color: theme.text }}>
{state.prekeys.signedPreKeyId}
</span>
</div>
)}
{state?.prekeys.lastGenerated && (
<div>
<span style={{ color: theme.textDim }}>Last generated</span>{' '}
{formatRelative(state.prekeys.lastGenerated)}
</div>
)}
{state?.prekeys.lastConsumed && (
<div>
<span style={{ color: theme.textDim }}>Last consumed</span>{' '}
{formatRelative(state.prekeys.lastConsumed)}
</div>
)}
</div>
</WidgetShell>
);
}

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { useShadeEvents, type ShadeEventEnvelope } from '../useShadeEvents.js';
import { useShadeContext } from '../ShadeProvider.js';
import { WidgetShell, ConnectionStatus, formatRelative } from './shared.js';
/**
* RecentActivity — live feed of all events flowing through the system.
*/
export function RecentActivity({ limit = 50 }: { limit?: number }): React.ReactElement {
const { events, connected } = useShadeEvents(limit);
const { theme } = useShadeContext();
// Show newest first
const sorted = [...events].reverse();
return (
<WidgetShell title="Recent activity" status={<ConnectionStatus connected={connected} />}>
{sorted.length === 0 ? (
<div style={{ color: theme.textMuted, fontSize: 13, padding: '16px 0', textAlign: 'center' }}>
{connected ? 'Waiting for events…' : 'Not connected'}
</div>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
maxHeight: 320,
overflowY: 'auto',
}}
>
{sorted.map((e) => (
<EventRow key={`${e.source}-${e.seq}`} event={e} />
))}
</div>
)}
</WidgetShell>
);
}
function EventRow({ event }: { event: ShadeEventEnvelope }): React.ReactElement {
const { theme } = useShadeContext();
const color = colorForEvent(event.name, theme);
const summary = summaryForEvent(event);
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '60px 1fr auto',
gap: 8,
alignItems: 'center',
padding: '6px 8px',
background: theme.bg,
borderRadius: 4,
fontSize: 11,
}}
>
<span style={{ color: theme.textDim, fontFamily: theme.fontMono, fontSize: 10 }}>
{formatRelative(event.timestamp)}
</span>
<span style={{ color, fontFamily: theme.fontMono, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<strong>{event.name}</strong> <span style={{ color: theme.textMuted }}>{summary}</span>
</span>
<span
style={{
fontSize: 9,
color: theme.textDim,
textTransform: 'uppercase',
letterSpacing: '0.05em',
padding: '1px 4px',
background: theme.bgElevated,
borderRadius: 2,
}}
>
{event.source}
</span>
</div>
);
}
function colorForEvent(name: string, theme: any): string {
if (name.startsWith('message.encrypted')) return theme.accent;
if (name.startsWith('message.decrypted')) return theme.success;
if (name.startsWith('ratchet')) return theme.accent;
if (name.startsWith('identity')) return theme.warning;
if (name.startsWith('trust')) return theme.warning;
if (name.startsWith('server.rate_limited')) return theme.danger;
return theme.textMuted;
}
function summaryForEvent(e: ShadeEventEnvelope): string {
const d = e.data;
switch (e.name) {
case 'identity.initialized':
return `reg ${d.registrationId}`;
case 'identity.rotated':
return 'new identity';
case 'session.created':
return `${d.address} (${d.remoteIdentityKeyHash})`;
case 'session.removed':
return `${d.address}`;
case 'message.encrypted':
return `${d.address} #${d.counter} (${d.ciphertextSize}b)`;
case 'message.decrypted':
return `${d.address} #${d.counter} (${d.plaintextSize}b)`;
case 'ratchet.dh_step':
return `${d.address}`;
case 'prekey.generated':
return `+${d.count} (now ${d.totalAfter})`;
case 'prekey.consumed':
return `id ${d.keyId}`;
case 'signed_prekey.rotated':
return `${d.oldKeyId}${d.newKeyId}`;
case 'trust.pinned':
return `${d.address} (${d.identityKeyHash})`;
case 'trust.changed':
return `${d.address}`;
case 'server.identity_registered':
return `${d.address}`;
case 'server.bundle_fetched':
return `${d.address}${d.hadOneTimePreKey ? ' (with OTPK)' : ''}`;
case 'server.prekeys_replenished':
return `${d.address} +${d.count} (now ${d.totalAfter})`;
case 'server.identity_deleted':
return `${d.address}`;
case 'server.rate_limited':
return `${d.route}`;
default:
return '';
}
}

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { useShadeState } from '../useShadeState.js';
import { useShadeContext } from '../ShadeProvider.js';
import { WidgetShell } from './shared.js';
/**
* ServerStatus — prekey server stats: registered identities, fetches, etc.
*/
export function ServerStatus(): React.ReactElement {
const { state } = useShadeState();
const { theme } = useShadeContext();
const stats = state?.server;
return (
<WidgetShell title="Prekey server">
{!stats ? (
<div style={{ color: theme.textMuted, fontSize: 13 }}>Loading</div>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 12,
}}
>
<Stat label="Registered" value={stats.registeredIdentities.length} theme={theme} />
<Stat label="Bundle fetches" value={stats.totalBundleFetches} theme={theme} />
<Stat label="Replenishes" value={stats.totalReplenishes} theme={theme} />
<Stat
label="Rate limited"
value={stats.totalRateLimited}
theme={theme}
color={stats.totalRateLimited > 0 ? theme.danger : undefined}
/>
</div>
)}
</WidgetShell>
);
}
function Stat({
label,
value,
theme,
color,
}: {
label: string;
value: number;
theme: any;
color?: string;
}): React.ReactElement {
return (
<div
style={{
padding: 10,
background: theme.bg,
border: `1px solid ${theme.border}`,
borderRadius: 6,
}}
>
<div style={{ fontSize: 10, color: theme.textDim, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{label}
</div>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: color ?? theme.text,
fontFamily: theme.fontMono,
marginTop: 2,
lineHeight: 1.1,
}}
>
{value.toLocaleString()}
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import React from 'react';
import { useShadeState } from '../useShadeState.js';
import { useShadeContext } from '../ShadeProvider.js';
import { WidgetShell, formatRelative } from './shared.js';
/**
* SessionList — table of all active Shade sessions with per-session stats.
*/
export function SessionList(): React.ReactElement {
const { state, loading, error } = useShadeState();
const { theme } = useShadeContext();
return (
<WidgetShell
title="Sessions"
status={
<span style={{ fontSize: 11, color: theme.textMuted }}>
{state?.sessions.length ?? 0} active
</span>
}
>
{loading && !state ? (
<div style={{ color: theme.textMuted, fontSize: 13 }}>Loading</div>
) : error ? (
<div style={{ color: theme.danger, fontSize: 13 }}>{error.message}</div>
) : !state?.sessions.length ? (
<div style={{ color: theme.textMuted, fontSize: 13, padding: '16px 0', textAlign: 'center' }}>
No active sessions
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{state.sessions.map((s) => (
<div
key={s.address}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: 10,
background: theme.bg,
border: `1px solid ${theme.border}`,
borderRadius: 6,
gap: 12,
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 13,
color: theme.text,
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{s.address}
</div>
<div
style={{
fontSize: 10,
color: theme.textDim,
fontFamily: theme.fontMono,
marginTop: 2,
}}
>
{s.remoteIdentityKeyHash}
</div>
</div>
<div style={{ display: 'flex', gap: 12, fontSize: 11, color: theme.textMuted, flexShrink: 0 }}>
<div title="Messages sent">
<span style={{ color: theme.accent }}></span> {s.messageCountSent}
</div>
<div title="Messages received">
<span style={{ color: theme.accent }}></span> {s.messageCountReceived}
</div>
<div title="DH ratchet steps">
<span style={{ color: theme.textDim }}></span> {s.dhRatchetSteps}
</div>
<div style={{ minWidth: 60, textAlign: 'right' }}>{formatRelative(s.lastActivity)}</div>
</div>
</div>
))}
</div>
)}
</WidgetShell>
);
}

View File

@@ -0,0 +1,156 @@
import React, { useState, useEffect } from 'react';
import { useShadeContext } from '../ShadeProvider.js';
import { IdentityCard } from './IdentityCard.js';
import { SessionList } from './SessionList.js';
import { PrekeyStock } from './PrekeyStock.js';
import { RecentActivity } from './RecentActivity.js';
import { ServerStatus } from './ServerStatus.js';
import { FingerprintCompare } from './FingerprintCompare.js';
/**
* WidgetCatalog — meta-widget that lets the user pick which Shade widgets
* to display in their dashboard. Selection is persisted to localStorage
* (or via onLayoutChange callback).
*/
export type WidgetKey = 'identity' | 'sessions' | 'prekeys' | 'activity' | 'server' | 'fingerprint';
const ALL_WIDGETS: Record<WidgetKey, { label: string; component: React.ComponentType }> = {
identity: { label: 'Identity', component: IdentityCard },
sessions: { label: 'Sessions', component: SessionList },
prekeys: { label: 'Prekey stock', component: PrekeyStock },
activity: { label: 'Recent activity', component: RecentActivity },
server: { label: 'Server status', component: ServerStatus },
fingerprint: { label: 'Verify fingerprint', component: FingerprintCompare },
};
export interface WidgetCatalogProps {
/** Which widgets are available for selection. Default: all. */
available?: WidgetKey[];
/** Initial layout if nothing in localStorage. Default: identity, sessions, activity. */
defaultLayout?: WidgetKey[];
/** Optional callback when user changes the layout (for persistence) */
onLayoutChange?: (layout: WidgetKey[]) => void;
/** localStorage key to persist layout. Default: "shade-widget-layout" */
storageKey?: string;
}
export function WidgetCatalog({
available = Object.keys(ALL_WIDGETS) as WidgetKey[],
defaultLayout = ['identity', 'sessions', 'activity'],
onLayoutChange,
storageKey = 'shade-widget-layout',
}: WidgetCatalogProps): React.ReactElement {
const { theme } = useShadeContext();
const [layout, setLayout] = useState<WidgetKey[]>(() => {
if (typeof window === 'undefined') return defaultLayout;
try {
const stored = window.localStorage.getItem(storageKey);
if (stored) {
const parsed = JSON.parse(stored) as WidgetKey[];
if (Array.isArray(parsed)) return parsed.filter((k) => available.includes(k));
}
} catch {}
return defaultLayout;
});
const [editing, setEditing] = useState(false);
useEffect(() => {
try {
window.localStorage.setItem(storageKey, JSON.stringify(layout));
} catch {}
onLayoutChange?.(layout);
}, [layout, onLayoutChange, storageKey]);
function toggle(key: WidgetKey): void {
setLayout((current) => {
if (current.includes(key)) {
return current.filter((k) => k !== key);
}
return [...current, key];
});
}
return (
<div className="shade-widget-catalog" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 0',
}}
>
<span style={{ fontSize: 12, color: theme.textMuted }}>
{layout.length} widget{layout.length !== 1 ? 's' : ''} active
</span>
<button
onClick={() => setEditing(!editing)}
style={{
background: 'transparent',
border: `1px solid ${theme.border}`,
color: theme.text,
borderRadius: theme.radius,
padding: '4px 12px',
fontSize: 11,
fontFamily: theme.font,
cursor: 'pointer',
}}
>
{editing ? 'Done' : 'Customize'}
</button>
</div>
{editing && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: 8,
padding: 12,
background: theme.bgElevated,
border: `1px solid ${theme.border}`,
borderRadius: theme.radius,
}}
>
{available.map((key) => {
const isActive = layout.includes(key);
return (
<label
key={key}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 8px',
background: isActive ? theme.accentMuted : theme.bg,
border: `1px solid ${isActive ? theme.accent : theme.border}`,
borderRadius: 4,
cursor: 'pointer',
fontSize: 12,
color: isActive ? theme.accent : theme.text,
fontFamily: theme.font,
}}
>
<input
type="checkbox"
checked={isActive}
onChange={() => toggle(key)}
style={{ accentColor: theme.accent }}
/>
{ALL_WIDGETS[key].label}
</label>
);
})}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{layout.map((key) => {
const W = ALL_WIDGETS[key].component;
return <W key={key} />;
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import React from 'react';
import { useShadeContext } from '../ShadeProvider.js';
import type { ShadeTheme } from '../theme.js';
/**
* Common widget shell — provides consistent border, padding, header.
* All widgets render inside one of these for visual consistency.
*/
export interface WidgetShellProps {
title: string;
icon?: React.ReactNode;
status?: React.ReactNode;
children: React.ReactNode;
className?: string;
}
export function WidgetShell({ title, icon, status, children, className }: WidgetShellProps): React.ReactElement {
const { theme } = useShadeContext();
return (
<div
className={`shade-widget ${className ?? ''}`}
style={{
background: theme.bgElevated,
border: `1px solid ${theme.border}`,
borderRadius: theme.radius,
padding: 16,
fontFamily: theme.font,
color: theme.text,
boxShadow: theme.shadow,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
gap: 8,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
{icon}
<span
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: theme.accent,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{title}
</span>
</div>
{status}
</div>
{children}
</div>
);
}
export function formatRelative(ts: number | null): string {
if (!ts) return 'never';
const diff = Date.now() - ts;
if (diff < 1000) return 'just now';
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`;
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`;
return `${Math.floor(diff / 86400_000)}d ago`;
}
export function StatusDot({ color }: { color: string }): React.ReactElement {
return (
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: color,
}}
/>
);
}
export function ConnectionStatus({ connected }: { connected: boolean }): React.ReactElement {
const { theme } = useShadeContext();
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: theme.textMuted }}>
<StatusDot color={connected ? theme.success : theme.danger} />
{connected ? 'live' : 'offline'}
</div>
);
}

View File

@@ -0,0 +1,18 @@
export { ShadeProvider, useShadeContext } from './ShadeProvider.js';
export { useShadeState } from './useShadeState.js';
export { useShadeEvents } from './useShadeEvents.js';
export { darkTheme, lightTheme, resolveTheme } from './theme.js';
export { IdentityCard } from './components/IdentityCard.js';
export { SessionList } from './components/SessionList.js';
export { PrekeyStock } from './components/PrekeyStock.js';
export { RecentActivity } from './components/RecentActivity.js';
export { ServerStatus } from './components/ServerStatus.js';
export { FingerprintCompare } from './components/FingerprintCompare.js';
export { WidgetCatalog } from './components/WidgetCatalog.js';
export type { ShadeProviderProps, ShadeContextValue } from './ShadeProvider.js';
export type { ShadeState, UseShadeStateResult } from './useShadeState.js';
export type { ShadeEventEnvelope, UseShadeEventsResult } from './useShadeEvents.js';
export type { ShadeTheme, ThemeMode } from './theme.js';
export type { WidgetCatalogProps, WidgetKey } from './components/WidgetCatalog.js';

View File

@@ -0,0 +1,72 @@
/**
* Theme tokens for Shade widgets.
*
* Self-contained — no external CSS required. Each widget reads tokens
* from the active theme via context.
*/
export interface ShadeTheme {
bg: string;
bgElevated: string;
border: string;
text: string;
textMuted: string;
textDim: string;
accent: string;
accentMuted: string;
success: string;
warning: string;
danger: string;
font: string;
fontMono: string;
radius: string;
shadow: string;
}
export const darkTheme: ShadeTheme = {
bg: '#0a0a0a',
bgElevated: '#161616',
border: '#262626',
text: '#e5e5e5',
textMuted: '#a3a3a3',
textDim: '#525252',
accent: '#f7c948',
accentMuted: 'rgba(247, 201, 72, 0.15)',
success: '#22c55e',
warning: '#eab308',
danger: '#ef4444',
font: 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif',
fontMono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
radius: '8px',
shadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
};
export const lightTheme: ShadeTheme = {
bg: '#ffffff',
bgElevated: '#f5f5f5',
border: '#e5e5e5',
text: '#0a0a0a',
textMuted: '#525252',
textDim: '#a3a3a3',
accent: '#ca8a04',
accentMuted: 'rgba(202, 138, 4, 0.1)',
success: '#16a34a',
warning: '#ca8a04',
danger: '#dc2626',
font: 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif',
fontMono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
radius: '8px',
shadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
};
export type ThemeMode = 'dark' | 'light' | 'auto';
export function resolveTheme(mode: ThemeMode): ShadeTheme {
if (mode === 'dark') return darkTheme;
if (mode === 'light') return lightTheme;
// auto: use prefers-color-scheme
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: light)').matches) {
return lightTheme;
}
return darkTheme;
}

View File

@@ -0,0 +1,62 @@
import { useEffect, useState, useRef } from 'react';
import { useShadeContext } from './ShadeProvider.js';
export interface ShadeEventEnvelope {
source: 'client' | 'server';
seq: number;
timestamp: number;
name: string;
data: Record<string, any>;
}
export interface UseShadeEventsResult {
events: ShadeEventEnvelope[];
connected: boolean;
error: Error | null;
}
/**
* Hook that subscribes to /api/events SSE stream and accumulates events.
*
* @param maxBuffer Max number of events to keep in memory (default: 200).
* Older events are dropped.
*/
export function useShadeEvents(maxBuffer = 200): UseShadeEventsResult {
const ctx = useShadeContext();
const [events, setEvents] = useState<ShadeEventEnvelope[]>([]);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<Error | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
const url = `${ctx.observerUrl}/api/events?token=${encodeURIComponent(ctx.token)}`;
const es = new EventSource(url);
eventSourceRef.current = es;
es.addEventListener('open', () => setConnected(true));
es.addEventListener('error', () => {
setConnected(false);
setError(new Error('SSE connection error'));
});
es.addEventListener('shade', (msg) => {
try {
const event = JSON.parse((msg as MessageEvent).data) as ShadeEventEnvelope;
setEvents((prev) => {
const next = [...prev, event];
return next.length > maxBuffer ? next.slice(-maxBuffer) : next;
});
} catch (err) {
console.error('[Shade] Failed to parse event:', err);
}
});
return () => {
es.close();
eventSourceRef.current = null;
setConnected(false);
};
}, [ctx.observerUrl, ctx.token, maxBuffer]);
return { events, connected, error };
}

View File

@@ -0,0 +1,75 @@
import { useEffect, useState, useCallback } from 'react';
import { useShadeContext } from './ShadeProvider.js';
export interface ShadeState {
identity: {
fingerprint: string | null;
registrationId: number | null;
lastInitialized: number | null;
lastRotated: number | null;
};
sessions: Array<{
address: string;
remoteIdentityKeyHash: string;
messageCountSent: number;
messageCountReceived: number;
lastActivity: number;
dhRatchetSteps: number;
}>;
prekeys: {
oneTimeRemaining: number;
lastGenerated: number | null;
lastConsumed: number | null;
signedPreKeyId: number | null;
signedPreKeyLastRotated: number | null;
};
retiredIdentities: number;
server: {
registeredIdentities: string[];
totalBundleFetches: number;
totalReplenishes: number;
totalDeleted: number;
totalRateLimited: number;
};
}
export interface UseShadeStateResult {
state: ShadeState | null;
loading: boolean;
error: Error | null;
refresh: () => Promise<void>;
}
/**
* Hook that polls the observer's /api/state endpoint at the configured interval.
*/
export function useShadeState(): UseShadeStateResult {
const ctx = useShadeContext();
const [state, setState] = useState<ShadeState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchState = useCallback(async () => {
try {
const res = await fetch(`${ctx.observerUrl}/api/state`, {
headers: { Authorization: `Bearer ${ctx.token}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = (await res.json()) as ShadeState;
setState(json);
setError(null);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}, [ctx.observerUrl, ctx.token]);
useEffect(() => {
fetchState();
const interval = setInterval(fetchState, ctx.pollIntervalMs);
return () => clearInterval(interval);
}, [fetchState, ctx.pollIntervalMs]);
return { state, loading, error, refresh: fetchState };
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM"]
},
"include": ["src"]
}