Navegación Bilingüe: English · Español (este documento)
Estado del Documento: Borrador
Tipo: Diseño de Arquitectura Frontend
Satélite: Evolith Tracker
Upstream: Evolith Core
Fecha: 2026-06-07
Autor: Architect Agent (BMAD)
Topología: El frontend es una arquitectura de Microfrontend (Module Federation) desde la Fase 1, según T-002 y T-002. Un Shell Host orquesta remotes React desplegables de forma independiente, uno por Bounded Context. Los patrones de §2 en adelante (gestión de estado, componentes, UI basada en permisos) aplican dentro de cada remote; el código transversal (UI kit, auth, contracts) reside en
packages/federados compartidos.
| Componente | Tecnología | Justificación |
|---|---|---|
| Runtime | Node.js 20 LTS | Requisito estándar de herramientas React |
| Framework | React 18+ con TypeScript 5.x (strict) | UI basada en componentes, tipado fuerte |
| Bundler | Vite 5+ | HMR rápido, nativo ESM |
| Microfrontends | Module Federation (@module-federation/vite) |
Build y deploy independiente por remote (T-002) |
| Routing | TanStack Router (v3) | Routing type-safe, layouts anidados; routing global en Shell Host |
| Estado Servidor | TanStack Query v5 | Estado asíncrono, caché, refetch en segundo plano (singleton compartido) |
| Estado Cliente | Zustand v4 | Estado UI global mínimo (tema, sidebar, notificaciones) |
| Estado Cross-MFE | Event bus del lado cliente + URL/storage | Acoplamiento mínimo entre remotes (T-002 §4) |
| Formularios | React Hook Form + Zod | Rendimiento, validación por schema |
| Librería UI | Design System compartido ui-kit (base shadcn/ui) |
Accesible, personalizable; previene divergencia visual entre remotes |
| Gráficos | Recharts | Visualización de métricas DORA/SPACE |
| Testing | Vitest + React Testing Library + Playwright | Unitarias + E2E |
| i18n | react-i18next | Soporte bilingüe inglés / español |
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
});| Aspecto | Regla |
|---|---|
| Consistencia visual | Todos los remotes consumen el ui-kit compartido — ningún remote envía sus propios primitivos |
| Dependencias compartidas | react, react-dom, @tanstack/react-query son singletons shared de Module Federation |
| Estado Cross-MFE | Mínimo; vía URL params, storage, o event-bus compartido. Sin importaciones directas remote-a-remote |
| Aislamiento de fallos | Cada remote envuelto en un error boundary en el Shell Host — un remote que falle no debe tumbar la suite |
| Despliegue independiente | Cada remote se compila y despliega independientemente; el Shell Host carga desde un registro versionado |
| Auth | Contexto de permisos provisto por el Shell Host vía paquete auth compartido; los remotes nunca reimplementan autorización |
Todos los datos del backend se gestionan vía TanStack Query. Las queries y mutations se colocan junto a sus módulos de funcionalidad.
// 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 });
},
});Solo el estado UI que es efímero, local a la sesión, y no respaldado por el servidor usa Zustand. Según ADR-0045, el estado del servidor nunca debe fluir a través de 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 }) }
)
);El backend resuelve permisos canónicos del grafo de autorización UMS. El frontend recibe estos como un array plano de strings TrackerPermission en el contexto de auth.
// 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
);
}
}Los componentes UI se construyen sobre una librería de componentes headless (primitivas Radix UI) con una capa de Design System personalizado aplicada. Esto asegura:
- Cumplimiento completo de accesibilidad (WCAG 2.1 AA)
- Personalización total de estilos vía variables CSS
- Sin vendor lock-in en una librería de componentes específica
// 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 | Comportamiento |
|---|---|
sm (640px) |
Pipeline colapsa a kanban de 2 columnas |
md (768px) |
Pipeline completo de 5 columnas, sidebar visible |
lg (1024px) |
Sidebar fijo, paneles de detalle completos |
xl (1280px) |
Ancho máximo de contenido 1400px, centrado |
// 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 — TAD principal
- UX Concept — Filosofía UX y modelo de navegación
- ADR-0045: State Management
- TanStack Query Documentation
- TanStack Router Documentation