-
Notifications
You must be signed in to change notification settings - Fork 0
Description
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 testexits 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: Usesbg-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-xlwith light text.pipelineStageCard: Usesbg-white/60,text-slate-900— same issue. Change to dark glass styling matching the existing inline styles inpipeline-stage-list.tsx.progressBar: Usesbg-slate-200/80for the track — too bright on dark background. Usebg-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
Manrope→Intereverywhere (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.variableIn 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 testexits 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 buildsucceeds 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 correctlysrc/lib/store/in-memory-store.ts— works correctlysrc/lib/pipeline/simulation-engine.ts— works correctlysrc/lib/pipeline/stage-runner.ts— works correctlysrc/lib/pipeline/log-generator.ts— works correctlysrc/lib/pipeline/stats-calculator.ts— works correctlysrc/lib/api/client.ts— works correctlysrc/lib/api/response.ts— works correctlysrc/lib/api/errors.ts— works correctlysrc/hooks/use-pipeline-state.ts— works correctlysrc/hooks/use-sse.ts— works correctly (the fix is server-side only)src/components/project-brief/— both files work correctlysrc/app/api/routes (except the events route SSE format fix)src/lib/validation/— schemas work correctlysrc/lib/types/domain.ts— types are correct