Skip to content

Sprint 2: Fix SSE Streaming, Tests, and Design Drift #3

@ibuzzardo

Description

@ibuzzardo

Sprint 2: Fix SSE Streaming, Tests, and Design Drift

Building on the existing codebase from Sprint 1 (PR #2).

Context

This project was built by Dark Factory in Sprint 1. The current state:

  • Working: Form wizard (3-step), API endpoints (CRUD + validation), pipeline simulation engine (6 stages, runs to completion), Build Tracker page rendering
  • Broken: SSE events never reach the browser (stat cards stuck at 0, log feed shows "Waiting for logs..."), all 8 unit test suites fail (vitest can't resolve @/ path alias), stat card and design token classes reference light-mode styles on a dark-mode app

Hard Constraints

  • Language: TypeScript (strict) — same as Sprint 1
  • No regressions: All existing API endpoints must continue to work identically
  • Test requirement: All tests must pass (npm test exits 0)
  • Preserve file structure: Do not reorganize or rename existing files
  • Framework: Next.js 14 App Router — do not change

Changes Required

1. Bug Fix: SSE Events Not Reaching Browser

The SSE endpoint uses named events (event: snapshot, event: stage, etc.) via the encodeSse function, but the browser's EventSource.onmessage handler only fires for unnamed events (events without an event: field). The useSse hook uses source.onmessage which will NEVER receive named events.

Fix in src/app/api/projects/[projectId]/events/route.ts: Change encodeSse to NOT include the event: field. The format should be:

id: 1
data: {"type":"snapshot",...}

NOT:

id: 1
event: snapshot
data: {"type":"snapshot",...}

Here is the current encodeSse function that must be changed:

function encodeSse(event: PipelineEvent): string {
  return `id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
}

Change it to:

function encodeSse(event: PipelineEvent): string {
  return `id: ${event.id}\ndata: ${JSON.stringify(event)}\n\n`;
}

The useSse hook in src/hooks/use-sse.ts is correct as-is — it uses source.onmessage which handles unnamed events. Do NOT change the hook.

2. Bug Fix: Vitest Cannot Resolve @/ Path Alias

All 8 test suites fail with: Failed to load url @/lib/... Does the file exist?

There is no vitest config file. Create vitest.config.ts in the project root:

import { defineConfig } from "vitest/config";
import path from "path";

export default defineConfig({
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "src")
    }
  },
  test: {
    environment: "jsdom",
    globals: true,
    include: ["tests/**/*.test.{ts,tsx}"],
    setupFiles: []
  }
});

After creating this file, run npm test and verify all 8 unit test suites pass. Fix any test failures that appear after the alias is resolved.

3. Bug Fix: Light-Mode Classes on Dark-Mode App

The componentClasses object in src/lib/constants/design-tokens.ts has several classes that assume a light background. This is a dark-mode app (color-scheme: dark, bg #0B1020). The following classes need dark-appropriate values:

  • statCard: Uses bg-white/70, text-slate-900, text-slate-600 — these render as white cards with dark text on a dark background. Change to use dark glass styling: border-white/15 bg-white/5 backdrop-blur-xl with light text.
  • pipelineStageCard: Uses bg-white/60, text-slate-900 — same issue. Change to dark glass styling matching the existing inline styles in pipeline-stage-list.tsx.
  • progressBar: Uses bg-slate-200/80 for the track — too bright on dark background. Use bg-slate-700/50.

File: src/lib/constants/design-tokens.ts — Update these specific componentClasses entries:

Current statCard:

"rounded-xl border border-slate-300/30 bg-white/70 p-4 backdrop-blur-md transition-colors hover:border-primary/40 hover:bg-white/80"

Change to:

"rounded-xl border border-white/15 bg-white/5 p-4 backdrop-blur-xl transition-colors hover:border-primary/40 hover:bg-white/10"

File: src/components/build-tracker/stat-card.tsx — The text classes also need updating:

Current:

<p className="text-xs text-slate-600">{label}</p>
<p className="mt-1 text-xl font-semibold text-slate-900">{value}</p>

Change to:

<p className="text-xs text-slate-400">{label}</p>
<p className="mt-1 text-xl font-semibold text-slate-100">{value}</p>

Current pipelineStageCard in design-tokens:

"relative overflow-hidden rounded-xl border border-slate-300/30 bg-white/60 p-4 backdrop-blur-md transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg data-[status=active]:border-primary/70 data-[status=active]:shadow-[0_0_0_1px_rgba(59,130,246,0.35)] data-[status=complete]:border-secondary/70 data-[status=failed]:border-destructive/70"

Change to:

"relative overflow-hidden rounded-xl border border-slate-700/60 bg-slate-900/50 p-4 backdrop-blur-md transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg data-[status=active]:border-primary/70 data-[status=active]:shadow-[0_0_0_1px_rgba(59,130,246,0.35)] data-[status=complete]:border-secondary/70 data-[status=failed]:border-destructive/70"

Current progressBar in design-tokens:

"h-2 w-full overflow-hidden rounded-full bg-slate-200/80 ..."

Change the track bg to:

"h-2 w-full overflow-hidden rounded-full bg-slate-700/50 ..."

4. Design Token Alignment

Update src/lib/constants/design-tokens.ts colors and tailwind.config.ts to match the original Stitch design spec:

  • Background: Change #0B1020#020617 (slate-950)
  • Font: Change ManropeInter everywhere (design-tokens.ts, tailwind.config.ts, layout.tsx)

In tailwind.config.ts:

colors: {
  background: "#020617",  // was #0B1020
  // ... keep all other colors
},
fontFamily: {
  sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"]  // was Manrope
}

In src/app/layout.tsx, change the font import:

import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
// Update html className to use inter.variable

In src/lib/constants/design-tokens.ts:

typography: {
  fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif"  // was Manrope
},
colors: {
  background: "#020617",  // was #0B1020
  // keep all other colors
}

In src/components/build-tracker/tracker-dashboard.tsx, update the gradient background:

bg-[linear-gradient(180deg,_#020617_0%,_#0a0f1a_55%,_#070b14_100%)]

was:

bg-[linear-gradient(180deg,_#0b1020_0%,_#0a0f1a_55%,_#070b14_100%)]

Existing Code Reference

Current file: src/app/api/projects/[projectId]/events/route.ts

import { eventBus } from "@/lib/store/event-bus";
import { inMemoryStore } from "@/lib/store/in-memory-store";
import type { PipelineEvent } from "@/lib/types/domain";

interface Params {
  params: { projectId: string };
}

function encodeSse(event: PipelineEvent): string {
  return `id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
}

export async function GET(request: Request, { params }: Params): Promise<Response> {
  try {
    const project = inMemoryStore.getProject(params.projectId);
    if (!project) {
      return new Response(JSON.stringify({ success: false, error: { code: "NOT_FOUND", message: "Project not found" } }), {
        status: 404,
        headers: { "Content-Type": "application/json" }
      });
    }

    const lastEventIdHeader = request.headers.get("last-event-id");
    const parsedLastId = Number.isNaN(Number(lastEventIdHeader)) ? 0 : Number(lastEventIdHeader ?? "0");

    const stream = new ReadableStream<Uint8Array>({
      start(controller) {
        const encoder = new TextEncoder();

        const replay = eventBus.replayFrom(params.projectId, parsedLastId);
        replay.forEach((event) => controller.enqueue(encoder.encode(encodeSse(event))));

        const unsubscribe = eventBus.subscribe(params.projectId, (event) => {
          controller.enqueue(encoder.encode(encodeSse(event)));
        });

        const heartbeat = setInterval(() => {
          const event = eventBus.publish(params.projectId, {
            type: "heartbeat",
            projectId: params.projectId,
            timestamp: new Date().toISOString(),
            payload: { ok: true }
          });
          controller.enqueue(encoder.encode(encodeSse(event)));
        }, 15000);

        request.signal.addEventListener("abort", () => {
          clearInterval(heartbeat);
          unsubscribe();
          controller.close();
        });
      }
    });

    return new Response(stream, {
      headers: {
        "Content-Type": "text/event-stream; charset=utf-8",
        "Cache-Control": "no-cache, no-transform",
        Connection: "keep-alive",
        "X-Accel-Buffering": "no"
      }
    });
  } catch (error) {
    return new Response(JSON.stringify({ success: false, error: { code: "INTERNAL_SERVER_ERROR", message: "Unexpected server error" } }), {
      status: 500,
      headers: { "Content-Type": "application/json" }
    });
  }
}

Current file: src/hooks/use-sse.ts

"use client";

import { useEffect, useRef } from "react";
import type { PipelineEvent } from "@/lib/types/domain";

export function useSse(projectId: string, onEvent: (event: PipelineEvent) => void): void {
  const retryRef = useRef<number>(1000);

  useEffect(() => {
    let source: EventSource | null = null;
    let timeoutId: ReturnType<typeof setTimeout> | null = null;

    const connect = (): void => {
      source = new EventSource(`/api/projects/${projectId}/events`);

      source.onmessage = (message): void => {
        const parsed = JSON.parse(message.data) as PipelineEvent;
        onEvent(parsed);
        retryRef.current = 1000;
      };

      source.onerror = (): void => {
        source?.close();
        timeoutId = setTimeout(() => {
          retryRef.current = Math.min(5000, retryRef.current * 2);
          connect();
        }, retryRef.current);
      };
    };

    connect();

    return (): void => {
      source?.close();
      if (timeoutId) clearTimeout(timeoutId);
    };
  }, [onEvent, projectId]);
}

Current file: src/lib/constants/design-tokens.ts

export const designTokens = {
  colors: {
    primary: "#3B82F6",
    secondary: "#14B8A6",
    background: "#0B1020",
    foreground: "#E5E7EB",
    muted: "#1F2937",
    accent: "#8B5CF6",
    destructive: "#EF4444",
    chartStage1Light: "#3B82F6",
    chartStage2Light: "#14B8A6",
    chartStage3Light: "#8B5CF6",
    chartStage4Light: "#F59E0B",
    chartStage5Light: "#EC4899",
    chartStage6Light: "#22C55E",
    chartStage1Dark: "#60A5FA",
    chartStage2Dark: "#2DD4BF",
    chartStage3Dark: "#A78BFA",
    chartStage4Dark: "#FBBF24",
    chartStage5Dark: "#F472B6",
    chartStage6Dark: "#4ADE80"
  },
  typography: {
    fontFamily: "Manrope, ui-sans-serif, system-ui, sans-serif"
  },
  radius: {
    default: "rounded-xl",
    input: "rounded-lg",
    glass: "rounded-2xl"
  }
} as const;

export const componentClasses = {
  button:
    "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-semibold transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 bg-primary text-slate-50 hover:bg-primary/90",
  cardGlass:
    "rounded-2xl border border-white/20 bg-white/10 p-4 md:p-6 shadow-[0_10px_40px_-12px_rgba(15,23,42,0.55)] backdrop-blur-xl supports-[backdrop-filter]:bg-white/10",
  input:
    "h-11 w-full rounded-lg border border-slate-600/70 bg-slate-900/70 px-3 text-sm text-slate-100 placeholder:text-slate-400 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:border-primary/60 disabled:cursor-not-allowed disabled:opacity-60",
  textarea:
    "min-h-[120px] w-full rounded-lg border border-slate-600/70 bg-slate-900/70 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-400 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:border-primary/60",
  select:
    "h-11 w-full rounded-lg border border-slate-600/70 bg-slate-900/70 px-3 text-sm text-slate-100 transition-colors hover:border-slate-500 focus:outline-none focus:ring-2 focus:ring-primary/60",
  stepProgress:
    "flex items-center gap-2 md:gap-3 text-xs md:text-sm text-slate-300 [&_.step-node]:flex [&_.step-node]:h-8 [&_.step-node]:w-8 [&_.step-node]:items-center [&_.step-node]:justify-center [&_.step-node]:rounded-full [&_.step-node]:border [&_.step-node]:transition-all [&_.step-node.active]:border-primary [&_.step-node.active]:bg-primary [&_.step-node.active]:text-white [&_.step-node.done]:border-secondary [&_.step-node.done]:bg-secondary [&_.step-node.done]:text-white [&_.step-line]:h-0.5 [&_.step-line]:flex-1 [&_.step-line]:bg-slate-300",
  pipelineStageCard:
    "relative overflow-hidden rounded-xl border border-slate-300/30 bg-white/60 p-4 backdrop-blur-md transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg data-[status=active]:border-primary/70 data-[status=active]:shadow-[0_0_0_1px_rgba(59,130,246,0.35)] data-[status=complete]:border-secondary/70 data-[status=failed]:border-destructive/70",
  progressBar:
    "h-2 w-full overflow-hidden rounded-full bg-slate-200/80 [&_[data-slot=indicator]]:h-full [&_[data-slot=indicator]]:bg-gradient-to-r [&_[data-slot=indicator]]:from-primary [&_[data-slot=indicator]]:to-accent [&_[data-slot=indicator]]:transition-all [&_[data-slot=indicator]]:duration-500",
  logFeed:
    "rounded-xl border border-slate-300/30 bg-slate-950/90 p-3 md:p-4 text-xs font-mono leading-5 text-slate-100 shadow-inner max-h-[420px] overflow-y-auto",
  statCard:
    "rounded-xl border border-slate-300/30 bg-white/70 p-4 backdrop-blur-md transition-colors hover:border-primary/40 hover:bg-white/80",
  dialog:
    "rounded-2xl border border-white/20 bg-white/85 p-6 shadow-2xl backdrop-blur-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 duration-200"
} as const;

Current file: src/components/build-tracker/stat-card.tsx

import { componentClasses } from "@/lib/constants/design-tokens";

interface StatCardProps {
  label: string;
  value: string | number;
}

export function StatCard({ label, value }: StatCardProps): JSX.Element {
  return (
    <article className={componentClasses.statCard}>
      <p className="text-xs text-slate-600">{label}</p>
      <p className="mt-1 text-xl font-semibold text-slate-900">{value}</p>
    </article>
  );
}

Current file: tailwind.config.ts

import type { Config } from "tailwindcss";

const config: Config = {
  darkMode: ["class"],
  content: ["./src/**/*.{ts,tsx}"],
  theme: {
    extend: {
      colors: {
        primary: "#3B82F6",
        secondary: "#14B8A6",
        background: "#0B1020",
        foreground: "#E5E7EB",
        muted: "#1F2937",
        accent: "#8B5CF6",
        destructive: "#EF4444"
      },
      borderRadius: {
        lg: "10px",
        xl: "12px",
        "2xl": "16px"
      },
      fontFamily: {
        sans: ["Manrope", "ui-sans-serif", "system-ui", "sans-serif"]
      }
    }
  },
  plugins: []
};

export default config;

Current file: src/app/layout.tsx

import type { Metadata } from "next";
import { Manrope } from "next/font/google";
import "@/app/globals.css";
import { env } from "@/lib/config/env";

const manrope = Manrope({ subsets: ["latin"], variable: "--font-manrope" });

export const metadata: Metadata = {
  title: env.NEXT_PUBLIC_APP_NAME,
  description: "Forge client portal"
};

export default function RootLayout({ children }: { children: React.ReactNode }): JSX.Element {
  return (
    <html lang="en" className={manrope.variable}>
      <body className="font-sans">{children}</body>
    </html>
  );
}

Current file: src/components/build-tracker/tracker-dashboard.tsx

"use client";

import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { StatCard } from "@/components/build-tracker/stat-card";
import { PipelineStageList } from "@/components/build-tracker/pipeline-stage-list";
import { LogFeed } from "@/components/build-tracker/log-feed";
import { Progress } from "@/components/ui/progress";
import { useSse } from "@/hooks/use-sse";
import { usePipelineState } from "@/hooks/use-pipeline-state";
import { startProjectPipeline } from "@/lib/api/client";
import type { ProjectRecord } from "@/lib/types/domain";

export function TrackerDashboard({ project }: { project: ProjectRecord }): JSX.Element {
  const { state, applyEvent } = usePipelineState(project.stages);
  const [starting, setStarting] = useState<boolean>(false);
  const [statusText, setStatusText] = useState<string>(project.status);

  const handleEvent = useCallback(applyEvent, [applyEvent]);
  useSse(project.id, handleEvent);

  useEffect(() => {
    setStatusText(state.status);
  }, [state.status]);

  const handleStart = async (): Promise<void> => {
    try {
      setStarting(true);
      await startProjectPipeline(project.id);
    } catch (error) {
      setStatusText("failed");
    } finally {
      setStarting(false);
    }
  };

  return (
    <div className="min-h-screen bg-[linear-gradient(180deg,_#0b1020_0%,_#0a0f1a_55%,_#070b14_100%)] text-foreground">
      <main className="mx-auto w-full max-w-7xl px-4 py-6 md:px-8 md:py-8">
        <header className="mb-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
          <div>
            <h1 className="text-2xl font-bold">Build Tracker</h1>
            <p className="text-sm text-slate-300">Live 6-stage pipeline execution</p>
          </div>
          <div className="flex gap-2">
            <Badge>{statusText}</Badge>
            <Button onClick={handleStart} disabled={starting || statusText === "running" || statusText === "complete"}>
              {starting ? "Starting..." : "Start Pipeline"}
            </Button>
          </div>
        </header>

        <section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
          <StatCard label="Completed" value={state.stats?.completedStages ?? 0} />
          <StatCard label="Percent" value={`${state.stats?.percentComplete ?? 0}%`} />
          <StatCard label="ETA" value={`${state.stats?.etaSeconds ?? 0}s`} />
          <StatCard label="Logs" value={state.stats?.logsEmitted ?? state.logs.length} />
        </section>

        <section className="mt-4 grid gap-4 xl:grid-cols-[1.3fr_0.7fr]">
          <div className="space-y-3 rounded-2xl border border-white/15 bg-white/5 p-4 backdrop-blur-xl md:p-6">
            <PipelineStageList stages={state.stages} />
            <Progress value={state.stats?.percentComplete ?? 0} />
          </div>
          <div className="rounded-2xl border border-white/15 bg-white/5 p-3 backdrop-blur-xl md:p-4">
            <LogFeed logs={state.logs} />
          </div>
        </section>
      </main>
    </div>
  );
}

Acceptance Criteria

  • SSE events stream to the browser in real-time — stat cards update, logs appear in the feed, progress bars animate as pipeline runs
  • npm test exits 0 — all 8 unit test suites pass with the new vitest config
  • Stat cards have light text on dark glass background (not dark text on white)
  • Font is Inter (not Manrope) throughout the app
  • Background color is #020617 (not #0B1020)
  • npm run build succeeds with zero TypeScript errors
  • No regressions in form wizard, API endpoints, or pipeline simulation

What NOT to Change

  • src/lib/store/event-bus.ts — works correctly
  • src/lib/store/in-memory-store.ts — works correctly
  • src/lib/pipeline/simulation-engine.ts — works correctly
  • src/lib/pipeline/stage-runner.ts — works correctly
  • src/lib/pipeline/log-generator.ts — works correctly
  • src/lib/pipeline/stats-calculator.ts — works correctly
  • src/lib/api/client.ts — works correctly
  • src/lib/api/response.ts — works correctly
  • src/lib/api/errors.ts — works correctly
  • src/hooks/use-pipeline-state.ts — works correctly
  • src/hooks/use-sse.ts — works correctly (the fix is server-side only)
  • src/components/project-brief/ — both files work correctly
  • src/app/api/ routes (except the events route SSE format fix)
  • src/lib/validation/ — schemas work correctly
  • src/lib/types/domain.ts — types are correct

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions