Skip to content

Latest commit

 

History

History
771 lines (648 loc) · 25 KB

File metadata and controls

771 lines (648 loc) · 25 KB

Evolith Tracker — React Frontend Design

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)


1. Architecture Overview

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/.

1.1 Stack

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

1.2 Monorepo Structure (Microfrontends)

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(), ...)

1.3 Vite Configuration — Shell Host & Remote

// 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
});

1.4 Microfrontend Governance Rules (T-002)

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

2. State Management — Dual Strategy

2.1 Server State (TanStack Query)

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 });
    },
  });

2.2 Client State (Zustand)

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 }) }
  )
);

3. Permission-Driven UI

3.1 Permission Context

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)),
  };
};

3.2 Protected Components

// 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: [...],
};

4. Routing Architecture

4.1 Route Tree

// 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,
])});

4.2 Root Layout with Permission Provider

// 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>
  );
};

5. Feature Components (Key Screens)

5.1 Pipeline View (Home Screen)

// 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>
  );
};

5.2 Initiative Card

// 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>
  );
};

5.3 Discovery Hub (Conversation-First)

// 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>
  );
};

5.4 Quality Gate Panel

// 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>
  );
};

6. Forms and Validation

6.1 Initiative Submission Form

// 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>
  );
};

7. API Client (OpenAPI Generated)

7.1 API Client Setup

// 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);

8. Observability (Frontend)

8.1 Tracing Integration

// 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',
  }),
];

8.2 Error Boundary

// 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
    );
  }
}

9. Component Library Strategy

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}
      />
    );
  },
);

10. Responsive Design

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

11. Testing Strategy

// 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);
});

References