Bilingual Navigation: English (this document) · Versión en Español
Document Status: Draft
Type: Frontend Architecture Design
Satellite: Evolith Tracker
Upstream: Evolith Core
Date: 2026-06-07
Author: Architect Agent (BMAD)
Topology: The frontend is a Microfrontend architecture (Module Federation) from Phase 1, per T-002 and T-002. A Shell Host orchestrates independently deployable React remotes, one per Bounded Context. The patterns in §2 onward (state management, components, permission-driven UI) apply inside each remote; cross-cutting code (UI kit, auth, contracts) lives in shared federated
packages/.
| Component | Technology | Justification |
|---|---|---|
| Runtime | Node.js 20 LTS | Standard React tooling requirement |
| Framework | React 18+ with TypeScript 5.x (strict) | Component-based UI, strong typing |
| Bundler | Vite 5+ | Fast HMR, ESM-native |
| Microfrontends | Module Federation (@module-federation/vite) |
Independent build & deploy per remote (T-002) |
| Routing | TanStack Router (v3) | Type-safe routing, nested layouts; global routing in Shell Host |
| Server State | TanStack Query v5 | Async state, caching, background refetch (shared singleton) |
| Client State | Zustand v4 | Minimal global UI state (theme, sidebar, notifications) |
| Cross-MFE State | Client-side event bus + URL/storage | Minimal coupling between remotes (T-002 §4) |
| Forms | React Hook Form + Zod | Performance, schema validation |
| UI Library | Shared ui-kit Design System (shadcn/ui base) |
Accessible, customizable; prevents visual drift across remotes |
| Charts | Recharts | DORA/SPACE metrics visualization |
| Testing | Vitest + React Testing Library + Playwright | Unit + E2E |
| i18n | react-i18next | English / Spanish bilingual support |
apps/
├── shell-host/ # Host: global routing, layout, session, MF orchestration
│ ├── src/
│ │ ├── app/ # Providers (QueryClient, PermissionProvider, ThemeProvider)
│ │ ├── bootstrap/ # Remote registry, dynamic remote loading, error boundaries
│ │ └── layout/ # Shell chrome (nav, header, global gate status bar)
│ └── vite.config.ts # host federation config (remotes registry)
├── mfe-discovery/ # Remote: Discovery module UI
│ ├── src/
│ │ ├── App.tsx # Remote entry exposed via Module Federation
│ │ ├── features/ # InitiativeCard, CanvasForm, ApprovalPanel
│ │ ├── hooks/ # useInitiatives, useInitiativeDetail
│ │ ├── routes.tsx # Remote-local routes
│ │ └── bootstrap.tsx # Standalone bootstrap (remote can run isolated in dev)
│ └── vite.config.ts # remote federation config (exposes ./App)
├── mfe-design/ # Remote: Design module UI
├── mfe-construction/ # Remote: Construction module UI
├── mfe-qa/ # Remote: QA module UI
├── mfe-release/ # Remote: Release module UI
├── mfe-governance/ # Remote: Governance + agent orchestration UI
└── mfe-metrics/ # Remote: Scorecards & analytics UI
packages/ # Shared federated libraries (Module Federation `shared`)
├── ui-kit/ # Shared Design System (Button, Modal, Badge, ...) — single source of visual truth
├── contracts/ # Generated OpenAPI client types
├── auth/ # usePermission, PermissionProvider, route guards
├── event-bus/ # Client-side event bus for cross-MFE communication
├── shared-stores/ # Zustand stores for cross-cutting UI state (theme, notifications)
└── utils/ # Shared utilities (formatDate, cn(), ...)
// apps/shell-host/vite.config.ts (HOST)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import federation from '@module-federation/vite';
export default defineConfig({
plugins: [
react(),
federation({
name: 'shell_host',
remotes: {
// Loaded dynamically from a versioned registry in production
mfe_discovery: 'mfeDiscovery@/remotes/discovery/remoteEntry.js',
mfe_design: 'mfeDesign@/remotes/design/remoteEntry.js',
mfe_construction: 'mfeConstruction@/remotes/construction/remoteEntry.js',
mfe_qa: 'mfeQa@/remotes/qa/remoteEntry.js',
mfe_release: 'mfeRelease@/remotes/release/remoteEntry.js',
mfe_governance: 'mfeGovernance@/remotes/governance/remoteEntry.js',
mfe_metrics: 'mfeMetrics@/remotes/metrics/remoteEntry.js',
},
shared: ['react', 'react-dom', '@tanstack/react-query'], // singletons — no duplicate instances
}),
],
server: { port: 5173, proxy: { '/api': { target: process.env.VITE_API_URL ?? 'http://localhost:3000', changeOrigin: true } } },
});// apps/mfe-discovery/vite.config.ts (REMOTE)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import federation from '@module-federation/vite';
export default defineConfig({
plugins: [
react(),
federation({
name: 'mfeDiscovery',
filename: 'remoteEntry.js',
exposes: { './App': './src/App.tsx' },
shared: ['react', 'react-dom', '@tanstack/react-query'],
}),
],
server: { port: 5174 }, // each remote runs on its own port in dev
});| Concern | Rule |
|---|---|
| Visual consistency | All remotes consume the shared ui-kit — no remote ships its own primitives |
| Shared dependencies | react, react-dom, @tanstack/react-query are Module Federation shared singletons |
| Cross-MFE state | Minimal; via URL params, storage, or shared event-bus. No direct remote-to-remote imports |
| Fault isolation | Each remote wrapped in an error boundary in the Shell Host — a crashing remote must not take down the suite |
| Independent deployment | Each remote builds and deploys independently; Shell Host loads from a versioned registry |
| Auth | Permission context provided by the Shell Host via shared auth package; remotes never re-implement authorization |
All data from the backend is managed via TanStack Query. Queries and mutations are colocated with their feature modules.
// features/discovery/api/initiative-api.ts
import { trackerApiClient } from '@/shared/contracts/api-client';
export const initiativeKeys = {
all: ['initiatives'] as const,
detail: (id: string) => ['initiatives', id] as const,
backlog: (id: string) => ['initiatives', id, 'backlog'] as const,
};
export const useInitiatives = (tenantId: string) =>
useQuery({
queryKey: initiativeKeys.all,
queryFn: () => trackerApiClient.initiative.list({ tenantId }),
staleTime: 30_000,
});
export const useInitiativeDetail = (id: string) =>
useQuery({
queryKey: initiativeKeys.detail(id),
queryFn: () => trackerApiClient.initiative.get(id),
});
export const useSubmitInitiative = () =>
useMutation({
mutationFn: (data: SubmitInitiativeInput) =>
trackerApiClient.initiative.submit(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: initiativeKeys.all });
},
});Only UI state that is ephemeral, local to the session, and not backed by the server uses Zustand. Per ADR-0045, server state must never flow through Zustand.
// shared/stores/ui.store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UiState {
sidebarCollapsed: boolean;
activeInitiativeId: string | null;
notificationQueue: Notification[];
theme: 'light' | 'dark' | 'system';
toggleSidebar: () => void;
setActiveInitiative: (id: string | null) => void;
addNotification: (n: Notification) => void;
dismissNotification: (id: string) => void;
}
export const useUiStore = create<UiState>()(
persist(
(set) => ({
sidebarCollapsed: false,
activeInitiativeId: null,
notificationQueue: [],
theme: 'system',
toggleSidebar: () =>
set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setActiveInitiative: (id) =>
set({ activeInitiativeId: id }),
addNotification: (n) =>
set((s) => ({ notificationQueue: [...s.notificationQueue, n] })),
dismissNotification: (id) =>
set((s) => ({
notificationQueue: s.notificationQueue.filter((n) => n.id !== id),
})),
}),
{ name: 'evolith-ui-store', partialize: (s) => ({ theme: s.theme, sidebarCollapsed: s.sidebarCollapsed }) }
)
);The backend resolves canonical permissions from the UMS authorization graph. The frontend receives these as a flat array of TrackerPermission strings in the auth context.
// shared/hooks/usePermission.ts
import { createContext } from 'react';
import { useAuthStore } from '@/shared/stores/auth.store';
export type TrackerPermission =
| 'tracker:initiative:read'
| 'tracker:initiative:create'
| 'tracker:initiative:approve'
| 'tracker:initiative:reject'
| 'tracker:design:read'
| 'tracker:design:contract:submit'
| 'tracker:design:approve'
| 'tracker:construction:read'
| 'tracker:construction:task:complete'
| 'tracker:qa:gate:evaluate'
| 'tracker:release:authorize'
| 'tracker:agent:assign'
| 'tracker:scorecard:read'
| 'tracker:settings:manage';
interface PermissionContextValue {
permissions: TrackerPermission[];
can: (permission: TrackerPermission) => boolean;
canAny: (permissions: TrackerPermission[]) => boolean;
canAll: (permissions: TrackerPermission[]) => boolean;
}
export const PermissionContext = createContext<PermissionContextValue>({
permissions: [],
can: () => false,
canAny: () => false,
canAll: () => false,
});
export const usePermission = () => {
const permissions = useAuthStore((s) => s.permissions);
return {
permissions,
can: (p: TrackerPermission) => permissions.includes(p),
canAny: (ps: TrackerPermission[]) => ps.some((p) => permissions.includes(p)),
canAll: (ps: TrackerPermission[]) => ps.every((p) => permissions.includes(p)),
};
};// shared/components/permission-guard.tsx
interface PermissionGuardProps {
permission: TrackerPermission;
fallback?: React.ReactNode;
children: React.ReactNode;
}
export const PermissionGuard: FC<PermissionGuardProps> = ({
permission,
fallback = null,
children,
}) => {
const { can } = usePermission();
return can(permission) ? <>{children}</> : <>{fallback}</>;
};
// Usage
<PermissionGuard permission="tracker:initiative:approve">
<ApproveButton onClick={handleApprove} />
</PermissionGuard>
// Route-level protection
const initiativeRoute = {
path: '/initiatives/:id',
loader: async () => {
const { can } = usePermission();
if (!can('tracker:initiative:read')) throw redirect('/403');
return {};
},
children: [...],
};// app/router.tsx
import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router';
import { RootLayout } from './root';
import { PipelinePage } from '@/features/governance/routes/pipeline';
import { DiscoveryHubPage } from '@/features/discovery/routes/hub';
import { DesignReviewPage } from '@/features/design/routes/review';
import { ConstructionBoardPage } from '@/features/construction/routes/board';
import { QaCommandPage } from '@/features/qa/routes/command';
import { ReleasePlannerPage } from '@/features/release/routes/planner';
import { AgentConsolePage } from '@/features/governance/routes/agent-console';
import { ScorecardsPage } from '@/features/metrics/routes/scorecards';
import { InitiativeDetailPage } from '@/features/discovery/routes/initiative-detail';
const rootRoute = createRootRoute({ component: RootLayout });
const pipelineRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: PipelinePage,
});
const initiativesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/initiatives',
children: {
detail: createRoute({
getParentRoute: () => initiativesRoute,
path: '$initiativeId',
component: InitiativeDetailPage,
}),
},
});
const discoveryHubRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/discovery',
component: DiscoveryHubPage,
});
const designReviewRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/design',
component: DesignReviewPage,
});
// ... construction, qa, release, agent-console, scorecards routes
export const router = createRouter({ routeTree: rootRoute.addChildren([
pipelineRoute,
initiativesRoute,
discoveryHubRoute,
designReviewRoute,
constructionRoute,
qaRoute,
releaseRoute,
agentConsoleRoute,
scorecardsRoute,
settingsRoute,
])});// app/root.tsx
export const RootLayout: FC = () => {
return (
<AuthProvider>
<PermissionProvider>
<QueryClientProvider>
<ThemeProvider>
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</ThemeProvider>
</QueryClientProvider>
</PermissionProvider>
</AuthProvider>
);
};// features/governance/routes/pipeline.tsx
export const PipelinePage: FC = () => {
const { data: initiatives, isLoading } = useInitiatives();
const { can } = usePermission();
if (isLoading) return <PipelineSkeleton />;
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Initiative Pipeline</h1>
{can('tracker:initiative:create') && (
<Button as={Link} to="/discovery/new">New Initiative</Button>
)}
</div>
<div className="grid grid-cols-5 gap-4">
{INITIATIVE_GATES.map((gate) => (
<PipelineColumn key={gate} gate={gate}>
{initiatives
.filter((i) => i.currentGate === gate)
.map((initiative) => (
<InitiativeCard
key={initiative.id}
initiative={initiative}
onSelect={() => navigate({
to: '/initiatives/$initiativeId',
params: { initiativeId: initiative.id },
})}
/>
))}
</PipelineColumn>
))}
</div>
</div>
);
};// features/governance/components/initiative-card.tsx
interface InitiativeCardProps {
initiative: InitiativeSummary;
onSelect: () => void;
}
export const InitiativeCard: FC<InitiativeCardProps> = ({ initiative, onSelect }) => {
const { blockers, timeInGate, assignedAgent } = initiative;
return (
<Card
className={cn(
'cursor-pointer transition-shadow hover:shadow-md',
initiative.status === 'blocked' && 'border-amber-300',
initiative.status === 'rejected' && 'border-red-300',
)}
onClick={onSelect}
>
<CardHeader>
<CardTitle className="text-sm">{initiative.name}</CardTitle>
<GateProgressIndicator gates={5} current={initiative.currentGateIndex} />
</CardHeader>
<CardContent>
{blockers.length > 0 ? (
<p className="text-xs text-amber-600 truncate">
Blocked: {blockers[0].description}
</p>
) : (
<p className="text-xs text-green-600">Flowing</p>
)}
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{timeInGate}</span>
{assignedAgent && <AgentBadge agent={assignedAgent} />}
</div>
</CardContent>
</Card>
);
};// features/discovery/routes/hub.tsx
export const DiscoveryHubPage: FC = () => {
const [conversation, setConversation] = useState<ChatMessage[]>([]);
const submitMutation = useSubmitInitiative();
const handleCanvasSubmit = async (canvas: DiscoveryCanvasData) => {
const result = await submitMutation.mutateAsync(canvas);
if (result.isSuccess) {
setConversation((prev) => [
...prev,
{ role: 'assistant', content: 'Canvas submitted successfully. Awaiting approval.' },
]);
}
};
return (
<div className="flex h-full">
<div className="w-2/3 p-6">
<h1 className="text-2xl font-semibold mb-4">Discovery Hub</h1>
<ConversationFlow messages={conversation} onComplete={handleCanvasSubmit} />
</div>
<aside className="w-1/3 border-l p-6">
<InitiativeList />
</aside>
</div>
);
};// features/qa/components/quality-gate-panel.tsx
interface QualityGatePanelProps {
initiativeId: string;
}
export const QualityGatePanel: FC<QualityGatePanelProps> = ({ initiativeId }) => {
const { data: gateStatus } = useQualityGate(initiativeId);
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">QA Gate Status</h2>
<GateChecklist checks={gateStatus.checks} />
<div className="flex items-center gap-4">
<GateVerdictBadge verdict={gateStatus.verdict} />
{gateStatus.verdict === 'PASS' && (
<Button onClick={() => navigate({ to: '/release', params: { initiativeId } })}>
Proceed to Release
</Button>
)}
{gateStatus.verdict === 'FAIL' && (
<Button variant="destructive" onClick={() => navigate({ to: '/qa/defects' })}>
Log Defects
</Button>
)}
</div>
</div>
);
};// features/discovery/components/submit-initiative-form.tsx
const initiativeSchema = z.object({
name: z.string().min(3).max(200),
description: z.string().max(5000),
canvasData: z.object({
problem: z.string().min(10),
valueProposition: z.string().min(10),
roiEstimate: z.enum(['LOW', 'MEDIUM', 'HIGH']),
kpis: z.array(z.string()).min(1),
risks: z.array(z.object({
description: z.string(),
likelihood: z.enum(['LOW', 'MEDIUM', 'HIGH']),
impact: z.enum(['LOW', 'MEDIUM', 'HIGH']),
})),
}),
});
type InitiativeFormData = z.infer<typeof initiativeSchema>;
export const SubmitInitiativeForm: FC = () => {
const form = useForm<InitiativeFormData>({
resolver: zodResolver(initiativeSchema),
defaultValues: { name: '', description: '', canvasData: { problem: '', valueProposition: '', roiEstimate: 'MEDIUM', kpis: [], risks: [] } },
});
const submitMutation = useSubmitInitiative();
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((data) => submitMutation.mutate(data))}>
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem>
<FormLabel>Initiative Name</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
{/* ... remaining fields */}
<Button type="submit" loading={submitMutation.isPending}>Submit Initiative</Button>
</form>
</FormProvider>
);
};// shared/contracts/api-client.ts
import { Client } from '@hey-api/client';
export const trackerApiClient = new Client({
baseUrl: import.meta.env.VITE_API_URL ?? '/api/v1',
headers: {
'Content-Type': 'application/json',
},
}).withAuth(({ token }) => ({
headers: { Authorization: `Bearer ${token}` },
}));
// Generated from OpenAPI spec at build time via @hey-api/openapi-ts
import * as InitiativeEndpoints from './generated/initiative';
import * as DesignEndpoints from './generated/design';
import * as ConstructionEndpoints from './generated/construction';
import * as QaEndpoints from './generated/qa';
import * as ReleaseEndpoints from './generated/release';
trackerApiClient.addEndpoints(InitiativeEndpoints);
trackerApiClient.addEndpoints(DesignEndpoints);
trackerApiClient.addEndpoints(ConstructionEndpoints);
trackerApiClient.addEndpoints(QaEndpoints);
trackerApiClient.addEndpoints(ReleaseEndpoints);// app/providers.tsx
import { TraceProvider } from '@tanstack/react-tracing';
export const providers = [
QueryClientProvider,
PermissionProvider,
TraceProvider.configure({
endpoint: import.meta.env.VITE_OTEL_EXPORTER_OTLP_ENDPOINT,
serviceName: 'evolith-tracker-web',
}),
];// shared/components/error-boundary.tsx
export class ErrorBoundary extends React.Component<Props, State> {
componentDidCatch(error: Error, info: ErrorInfo) {
logger.error({ error: error.message, componentStack: info.componentStack });
Sentry.captureException(error);
}
render() {
return this.state.hasError ? (
<ErrorFallback onReset={() => this.setState({ hasError: false })} />
) : (
this.props.children
);
}
}UI components are built on top of a headless component library (Radix UI primitives) with a custom design system layer applied. This ensures:
- Full accessibility compliance (WCAG 2.1 AA)
- Complete style customization via CSS variables
- No vendor lock-in on a specific component library
// shared/components/ui/button.tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'destructive' | 'ghost';
size?: 'sm' | 'md' | 'lg';
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
buttonVariants[variant],
buttonSizes[size],
className,
)}
{...props}
/>
);
},
);| Breakpoint | Behavior |
|---|---|
sm (640px) |
Pipeline collapses to 2-column kanban |
md (768px) |
Full 5-column pipeline, sidebar visible |
lg (1024px) |
Sidebar pinned, full detail panels |
xl (1280px) |
Max content width 1400px, centered |
// tests/unit/features/discovery/components/initiative-card.test.tsx
import { render, screen } from '@testing-library/react';
import { InitiativeCard } from '@/features/discovery/components/initiative-card';
describe('InitiativeCard', () => {
it('shows blocked status correctly', () => {
render(<InitiativeCard initiative={mockInitiative('blocked')} onSelect={fn} />);
expect(screen.getByText(/Blocked:/)).toBeInTheDocument();
});
it('hides action button when user lacks permission', () => {
const { container } = render(
<PermissionProvider permissions={[]}>
<InitiativeCard initiative={mockInitiative()} onSelect={fn} />
</PermissionProvider>,
);
expect(container.querySelector('button')).toBeNull();
});
});
// tests/e2e/pipeline.spec.ts
import { test, expect } from '@playwright/test';
test('pipeline shows all active initiatives', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Initiative Pipeline')).toBeVisible();
await expect(page.locator('[data-testid="initiative-card"]')).toHaveCount(5);
});- Tracker Target Architecture — Primary TAD
- UX Concept — UX philosophy and navigation model
- ADR-0045: State Management
- TanStack Query Documentation
- TanStack Router Documentation