Engineering-Specs/10-frontend-discovery.md
Crawler Factory: Spec 10: Frontend Discovery Flow Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship the four discovery-phase UI components: RollingLog, DomainBadge, DiscoveryPanel, CategoryReview: that drive the first half of the Crawler Factory user flow. Together they let an operator paste a URL, watch streamed discovery events, and select pathGroups to configure.
Architecture: Pure presentation + form components. No backend calls live in these components: they consume props/hooks already built by Spec 09 (useDiscoveryStream). The shell page (AdminFactory.tsx) and configurator drawer (Spec 11) are out of scope. Visual conventions match the existing /admin aesthetic: dark indigo background, framer-motion fade-up, mono fonts for IDs, color-coded badges. shadcn primitives (scroll-area, card, checkbox, input, button, form, badge) are the building blocks: no custom CSS beyond Tailwind utility classes.
Tech Stack: React 18 (function components, hooks), TypeScript strict, framer-motion, react-hook-form 7 + @hookform/resolvers/zod, zod 3.25, shadcn UI (Radix primitives), Tailwind, lucide-react icons, vitest + @testing-library/react + jsdom.
Depends on:
- Spec 01 (ContentDomain type from lib/factory/types.ts)
- Spec 09 (useDiscoveryStream hook + LogEntry shape from lib/factory/types.ts)
- Existing shadcn components in src/components/ui/
Consumed by:
- Spec 11 (CategoryConfigurator reads selectedPathGroupIds from CategoryReview.onConfigureSelected)
- Spec 12 (LiveCrawlMonitor reuses RollingLog as a read-only persistent log)
- Spec 13 (smoke test verifies the discovery flow end-to-end)
Plan source: Projects/Crawler-Factory/00-Plan.md §5 (UI specs: page layout, panels), §7 Tasks 24, 25, 26. §19c for traffic-light thresholds. Visual conventions referenced from src/pages/Admin.tsx and src/components/admin/HealthHUD.tsx.
File structure
| Path | Responsibility |
|---|---|
src/components/admin/factory/RollingLog.tsx |
Auto-scrolling structured log viewer (shadcn ScrollArea). Stateless presentation only. |
src/components/admin/factory/DomainBadge.tsx |
Pill badge: content domain name, confidence percent, traffic-light dot. Stateless. |
src/components/admin/factory/DiscoveryPanel.tsx |
Step 1 panel: URL input form (react-hook-form + zod), submit, status line, embedded RollingLog. |
src/components/admin/factory/CategoryReview.tsx |
Step 2 panel: pathGroup tree grouped by content domain, checkboxes, manual reclassify dropdown, "Configure Selected (N)" CTA. |
src/components/admin/factory/__tests__/RollingLog.test.tsx |
Auto-scroll + level-color tests. |
src/components/admin/factory/__tests__/DomainBadge.test.tsx |
Domain color + traffic-light threshold tests. |
src/components/admin/factory/__tests__/DiscoveryPanel.test.tsx |
Form validation + submit tests (mocked hook). |
src/components/admin/factory/__tests__/CategoryReview.test.tsx |
Grouping + selection + CTA tests. |
SOP rules applied (non-negotiable; from CodingSOPs.md, TestingSOPs.md, WritingSOPs.md)
Every file in this spec MUST follow these rules. They are not optional.
- Two-layer type safety. zod for runtime form validation;
tsc --noEmitstrict for write-time. Noany. UseT | null, notT | undefined, when the absence is meaningful (e.g.,detectedDomain: ContentDomain | nullper Spec 01PathGroupSchema). - No
console.log. Top of every component file:import { createLogger } from '@/lib/utils/logger';andconst log = createLogger('factory:ui:rolling-log');(or appropriate context). Wrap any error path withlog.error({ event, ... }, 'short_msg'): never rawconsole.errorfrom training priors. The logger module is already shipped (src/lib/utils/logger.tsvia path alias@/lib/utils/logger): confirm path before importing; if the alias resolves tolib/utils/logger.tsuse the relative path that works in your codebase build. - Try/catch every fallible call inside event handlers. Form submit handlers wrap
discover()in try/catch and log on rejection. - Functions ≤20 lines, ≤3 params. More than 3 params → use a typed config object. Components receive a single typed
Propsobject. - Docstring on every exported component. Single line above the export explaining purpose. Comment WHY, not WHAT, for non-obvious logic (e.g., the auto-scroll ref pattern, the traffic-light threshold math).
- No raw dicts crossing module boundaries. Props are typed interfaces; arrays of
LogEntryandPathGroupcome from the Spec 01 zod-validated types. - TDD cadence. Test first → run, expect FAIL → implement → run, expect PASS → commit.
- No live network in unit tests.
useDiscoveryStreamis mocked viavi.mock. Real SSE lives behinduseDiscoveryStreamin Spec 09 and is integration-tested there. - React rules.
- Functional components only.
- Hooks at top level, never inside conditionals.
-
useEffectdeps arrays explicit; ESLintreact-hooks/exhaustive-depsmust stay clean. - No inlineany. Noas unknown as Xcasts. - Each component file exports exactly one component (plus any helper types): no barrel files. - Naming. TypeScript:
camelCasefor vars/functions,PascalCasefor components/types,UPPER_SNAKE_CASEfor module constants. Files:PascalCase.tsxfor components (matches existingHealthHUD.tsx,BugTracker.tsxconvention). Tests: same name with.test.tsx. - Conventional commits.
feat(factory):,test(factory):,chore(factory):, etc. One logical change per commit. - Accessibility. All form controls have associated
<label>(use shadcnFormLabel). Buttons disabled when invalid. Live regions (aria-live="polite") on the status line + log container so screen readers announce updates. - No frontend may render an emoji. Use
lucide-reacticons (consistent withAdmin.tsx).
Visual convention reminders (from src/pages/Admin.tsx + src/components/admin/HealthHUD.tsx)
- Background:
bg-[#0a0a0f], text white, bordersborder-white/10. - Section headers:
text-3xl font-black uppercase tracking-tighterwith indigo accent badge (bg-indigo-500/10 text-indigo-400 border border-indigo-500/30). - Section subheaders:
text-sm font-bold text-white/40 uppercase tracking-widest, flanked byh-px bg-white/10 flex-1dividers. - Cards:
bg-white/5 border border-white/10 rounded-2xl backdrop-blur-md. - Mono font:
font-monofor URLs, IDs, timestamps. - Badges:
bg-{color}-500/20 text-{color}-400 border border-{color}-500/30 rounded-md px-2 py-1 text-[10px] font-bold uppercase tracking-widest. - Animations:
framer-motioninitial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }}on top-level sections.
Task 24: RollingLog: auto-scrolling structured log viewer
Files:
- Create: src/components/admin/factory/RollingLog.tsx
- Create: src/components/admin/factory/__tests__/RollingLog.test.tsx
RollingLog is a pure presentation component: takes an array of LogEntry, renders them inside shadcn ScrollArea, and auto-scrolls to the bottom whenever the list grows. Each entry shows a monospace timestamp, a colored level badge, and the message. It is reused by DiscoveryPanel (Step 1) and later by LiveCrawlMonitor (Spec 12).
LogEntry is imported from lib/factory/types.ts (Spec 01):
// (Already shipped — do not redefine)
export const LogEntrySchema = z.object({
ts: z.number().int().positive(),
level: z.enum(['info', 'warn', 'error']),
message: z.string().min(1),
meta: z.record(z.unknown()).optional(),
});
export type LogEntry = z.infer<typeof LogEntrySchema>;
- [ ] Step 24.1: Write failing test for empty-state render
Create src/components/admin/factory/__tests__/RollingLog.test.tsx:
/**
* RollingLog tests — verify auto-scroll on new entry, level badge colors,
* empty-state message. No backend; no SSE; pure presentation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { RollingLog } from '../RollingLog';
import type { LogEntry } from '@/../lib/factory/types';
// jsdom does not implement scrollIntoView — stub it.
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn();
});
const entry = (level: LogEntry['level'], ts: number, message: string): LogEntry => ({
level,
ts,
message,
});
describe('RollingLog', () => {
it('renders empty-state hint when entries are empty', () => {
render(<RollingLog entries={[]} />);
expect(screen.getByText(/no events yet/i)).toBeInTheDocument();
});
});
- [ ] Step 24.2: Run, expect FAIL
npx vitest run src/components/admin/factory/__tests__/RollingLog.test.tsx
Expected: FAIL: Cannot find module '../RollingLog'.
- [ ] Step 24.3: Implement
RollingLog.tsxwith empty state
Create src/components/admin/factory/RollingLog.tsx:
/**
* RollingLog — auto-scrolling structured log viewer.
*
* Why a sentinel ref + scrollIntoView: the shadcn ScrollArea wraps a Radix
* Viewport whose scrollHeight is not stable across renders; ref'ing the last
* <li> and calling scrollIntoView after every entry change is the most
* reliable cross-browser pattern (matches the LiveCrawlMonitor reuse plan).
*/
import { useEffect, useRef } from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import type { LogEntry } from '@/../lib/factory/types';
const LEVEL_STYLES: Record<LogEntry['level'], string> = {
info: 'bg-indigo-500/20 text-indigo-400 border-indigo-500/30',
warn: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
error: 'bg-red-500/20 text-red-400 border-red-500/30',
};
export interface RollingLogProps {
entries: ReadonlyArray<LogEntry>;
/** Tailwind class controlling viewport height. Defaults to h-96. */
maxHeight?: string;
}
/**
* Render a stream of LogEntry rows with auto-scroll-to-bottom on append.
*/
export function RollingLog({ entries, maxHeight = 'h-96' }: RollingLogProps) {
const sentinelRef = useRef<HTMLLIElement | null>(null);
useEffect(() => {
// Auto-scroll to the bottom whenever a new entry lands.
sentinelRef.current?.scrollIntoView({ block: 'end', behavior: 'smooth' });
}, [entries.length]);
if (entries.length === 0) {
return (
<div
className={cn(
'rounded-2xl border border-white/10 bg-white/5 backdrop-blur-md p-6 flex items-center justify-center',
maxHeight,
)}
aria-live="polite"
>
<span className="text-xs text-white/30 uppercase tracking-widest font-mono">
No events yet — discovery has not started.
</span>
</div>
);
}
return (
<ScrollArea
className={cn(
'rounded-2xl border border-white/10 bg-white/5 backdrop-blur-md',
maxHeight,
)}
data-testid="rolling-log"
aria-live="polite"
>
<ul className="divide-y divide-white/5 px-4 py-2">
{entries.map((e, i) => {
const isLast = i === entries.length - 1;
return (
<li
key={`${e.ts}-${i}`}
ref={isLast ? sentinelRef : undefined}
className="flex items-start gap-3 py-2 text-xs"
data-level={e.level}
>
<span className="font-mono text-white/40 shrink-0">
{new Date(e.ts).toISOString().slice(11, 19)}
</span>
<span
className={cn(
'shrink-0 rounded-md border px-2 py-0.5 text-[10px] font-bold uppercase tracking-widest',
LEVEL_STYLES[e.level],
)}
>
{e.level}
</span>
<span className="text-white/80 break-all">{e.message}</span>
</li>
);
})}
</ul>
</ScrollArea>
);
}
- [ ] Step 24.4: Run empty-state test, expect PASS
npx vitest run src/components/admin/factory/__tests__/RollingLog.test.tsx
Expected: 1 test pass.
- [ ] Step 24.5: Add tests for level colors + auto-scroll
Append to src/components/admin/factory/__tests__/RollingLog.test.tsx:
describe('RollingLog level styling', () => {
it('renders an info entry with indigo level badge', () => {
render(
<RollingLog
entries={[entry('info', 1714432200000, 'robots.txt OK')]}
/>,
);
const badge = screen.getByText('info');
expect(badge.className).toMatch(/text-indigo-400/);
expect(badge.className).toMatch(/border-indigo-500\/30/);
});
it('renders a warn entry with amber level badge', () => {
render(
<RollingLog
entries={[entry('warn', 1714432200000, 'sitemap missing')]}
/>,
);
const badge = screen.getByText('warn');
expect(badge.className).toMatch(/text-amber-400/);
});
it('renders an error entry with red level badge', () => {
render(
<RollingLog
entries={[entry('error', 1714432200000, 'fetch failed')]}
/>,
);
const badge = screen.getByText('error');
expect(badge.className).toMatch(/text-red-400/);
});
it('renders the message text', () => {
render(
<RollingLog
entries={[entry('info', 1714432200000, '1247 URLs found')]}
/>,
);
expect(screen.getByText(/1247 URLs found/)).toBeInTheDocument();
});
});
describe('RollingLog auto-scroll', () => {
it('calls scrollIntoView after a new entry is appended', () => {
const spy = vi.fn();
Element.prototype.scrollIntoView = spy;
const initial = [entry('info', 1714432200000, 'first')];
const { rerender } = render(<RollingLog entries={initial} />);
// Initial mount — sentinel scrolls into view once.
expect(spy).toHaveBeenCalledTimes(1);
// Append a second entry — sentinel scrolls again.
act(() => {
rerender(
<RollingLog
entries={[
...initial,
entry('info', 1714432200001, 'second'),
]}
/>,
);
});
expect(spy).toHaveBeenCalledTimes(2);
});
it('does NOT call scrollIntoView when entries length is unchanged', () => {
const spy = vi.fn();
Element.prototype.scrollIntoView = spy;
const initial = [entry('info', 1714432200000, 'first')];
const { rerender } = render(<RollingLog entries={initial} />);
expect(spy).toHaveBeenCalledTimes(1);
// Re-render with the SAME array length — useEffect dep unchanged.
rerender(<RollingLog entries={initial} />);
expect(spy).toHaveBeenCalledTimes(1);
});
});
- [ ] Step 24.6: Run all RollingLog tests, expect PASS
npx vitest run src/components/admin/factory/__tests__/RollingLog.test.tsx
Expected: 7 tests pass.
- [ ] Step 24.7: Run
tsc --noEmit
npx tsc --noEmit
Expected: clean.
- [ ] Step 24.8: Commit
git add src/components/admin/factory/RollingLog.tsx src/components/admin/factory/__tests__/RollingLog.test.tsx
git commit -m "feat(factory): RollingLog component with auto-scroll and level badges"
Task 25: DomainBadge: color-coded pill with confidence + traffic light
Files:
- Create: src/components/admin/factory/DomainBadge.tsx
- Create: src/components/admin/factory/__tests__/DomainBadge.test.tsx
DomainBadge shows the detected content domain on a pill, the confidence as a small monospace percentage, and a traffic-light dot keyed to §19c thresholds:
| Confidence | Dot color | Class |
|---|---|---|
| ≥ 0.85 | green | bg-emerald-400 |
| 0.65–0.84 | yellow | bg-yellow-400 |
| < 0.65 | amber | bg-amber-500 |
Per-domain pill colors (from §7 Task 24):
| Domain | Color |
|---|---|
| marketing | indigo |
| support | red |
| education | emerald |
| technical | blue |
| customer-stories | purple |
| product-catalog | amber |
| events | pink |
| legal | slate |
| social | fuchsia |
- [ ] Step 25.1: Write failing tests
Create src/components/admin/factory/__tests__/DomainBadge.test.tsx:
/**
* DomainBadge tests — verify per-domain color, confidence rendering,
* and traffic-light dot per §19c thresholds.
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { DomainBadge } from '../DomainBadge';
describe('DomainBadge — domain color', () => {
it('renders marketing pill with indigo classes', () => {
render(<DomainBadge contentDomain="marketing" confidence={0.9} />);
const pill = screen.getByTestId('domain-badge-pill');
expect(pill.className).toMatch(/text-indigo-400/);
expect(pill.className).toMatch(/border-indigo-500\/30/);
expect(screen.getByText(/marketing/i)).toBeInTheDocument();
});
it('renders support pill with red classes', () => {
render(<DomainBadge contentDomain="support" confidence={0.9} />);
expect(screen.getByTestId('domain-badge-pill').className).toMatch(/text-red-400/);
});
it('renders product-catalog pill with amber classes', () => {
render(<DomainBadge contentDomain="product-catalog" confidence={0.9} />);
expect(screen.getByTestId('domain-badge-pill').className).toMatch(/text-amber-400/);
});
});
describe('DomainBadge — confidence rendering', () => {
it('renders confidence as integer percentage', () => {
render(<DomainBadge contentDomain="marketing" confidence={0.923} />);
expect(screen.getByText('92%')).toBeInTheDocument();
});
it('renders 0% for confidence 0', () => {
render(<DomainBadge contentDomain="marketing" confidence={0} />);
expect(screen.getByText('0%')).toBeInTheDocument();
});
it('renders 100% for confidence 1', () => {
render(<DomainBadge contentDomain="marketing" confidence={1} />);
expect(screen.getByText('100%')).toBeInTheDocument();
});
});
describe('DomainBadge — traffic-light dot per §19c thresholds', () => {
it('uses green dot at confidence >= 0.85', () => {
render(<DomainBadge contentDomain="marketing" confidence={0.85} />);
expect(screen.getByTestId('domain-badge-dot').className).toMatch(/bg-emerald-400/);
});
it('uses green dot at confidence 0.95', () => {
render(<DomainBadge contentDomain="marketing" confidence={0.95} />);
expect(screen.getByTestId('domain-badge-dot').className).toMatch(/bg-emerald-400/);
});
it('uses yellow dot at confidence 0.65', () => {
render(<DomainBadge contentDomain="marketing" confidence={0.65} />);
expect(screen.getByTestId('domain-badge-dot').className).toMatch(/bg-yellow-400/);
});
it('uses yellow dot at confidence 0.84', () => {
render(<DomainBadge contentDomain="marketing" confidence={0.84} />);
expect(screen.getByTestId('domain-badge-dot').className).toMatch(/bg-yellow-400/);
});
it('uses amber dot at confidence 0.64', () => {
render(<DomainBadge contentDomain="marketing" confidence={0.64} />);
expect(screen.getByTestId('domain-badge-dot').className).toMatch(/bg-amber-500/);
});
it('uses amber dot at confidence 0', () => {
render(<DomainBadge contentDomain="marketing" confidence={0} />);
expect(screen.getByTestId('domain-badge-dot').className).toMatch(/bg-amber-500/);
});
});
- [ ] Step 25.2: Run, expect FAIL
npx vitest run src/components/admin/factory/__tests__/DomainBadge.test.tsx
Expected: FAIL: Cannot find module '../DomainBadge'.
- [ ] Step 25.3: Implement
DomainBadge.tsx
Create src/components/admin/factory/DomainBadge.tsx:
/**
* DomainBadge — pill showing detected content domain, confidence percentage,
* and a §19c traffic-light dot. Stateless presentation only.
*/
import { cn } from '@/lib/utils';
import type { ContentDomain } from '@/../lib/factory/types';
const DOMAIN_PILL: Record<ContentDomain, string> = {
marketing: 'bg-indigo-500/20 text-indigo-400 border-indigo-500/30',
support: 'bg-red-500/20 text-red-400 border-red-500/30',
education: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
technical: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
'customer-stories': 'bg-purple-500/20 text-purple-400 border-purple-500/30',
'product-catalog': 'bg-amber-500/20 text-amber-400 border-amber-500/30',
events: 'bg-pink-500/20 text-pink-400 border-pink-500/30',
legal: 'bg-slate-500/20 text-slate-300 border-slate-500/30',
social: 'bg-fuchsia-500/20 text-fuchsia-400 border-fuchsia-500/30',
};
const DOMAIN_LABEL: Record<ContentDomain, string> = {
marketing: 'marketing',
support: 'support',
education: 'education',
technical: 'technical',
'customer-stories': 'customer-stories',
'product-catalog': 'product-catalog',
events: 'events',
legal: 'legal',
social: 'social',
};
export interface DomainBadgeProps {
contentDomain: ContentDomain;
/** 0..1 confidence from the detection cascade (Spec 04). */
confidence: number;
}
/**
* Pick the traffic-light dot class for a given confidence value.
* Thresholds match 00-Plan.md §19c.
*/
function trafficLightClass(confidence: number): string {
if (confidence >= 0.85) return 'bg-emerald-400';
if (confidence >= 0.65) return 'bg-yellow-400';
return 'bg-amber-500';
}
/**
* Render a domain pill: dot + name + monospace confidence percentage.
*/
export function DomainBadge({ contentDomain, confidence }: DomainBadgeProps) {
const pct = Math.round(Math.max(0, Math.min(1, confidence)) * 100);
return (
<span className="inline-flex items-center gap-2">
<span
data-testid="domain-badge-dot"
className={cn(
'h-2 w-2 rounded-full shrink-0',
trafficLightClass(confidence),
)}
aria-hidden="true"
/>
<span
data-testid="domain-badge-pill"
className={cn(
'rounded-md border px-2 py-0.5 text-[10px] font-bold uppercase tracking-widest',
DOMAIN_PILL[contentDomain],
)}
>
{DOMAIN_LABEL[contentDomain]}
</span>
<span className="font-mono text-[10px] text-white/60">{pct}%</span>
</span>
);
}
- [ ] Step 25.4: Run all DomainBadge tests, expect PASS
npx vitest run src/components/admin/factory/__tests__/DomainBadge.test.tsx
Expected: 12 tests pass.
- [ ] Step 25.5: Run
tsc --noEmit
npx tsc --noEmit
Expected: clean.
- [ ] Step 25.6: Commit
git add src/components/admin/factory/DomainBadge.tsx src/components/admin/factory/__tests__/DomainBadge.test.tsx
git commit -m "feat(factory): DomainBadge with per-domain color and traffic-light dot"
Task 26: DiscoveryPanel: URL form + status line + embedded log
Files:
- Create: src/components/admin/factory/DiscoveryPanel.tsx
- Create: src/components/admin/factory/__tests__/DiscoveryPanel.test.tsx
DiscoveryPanel is the Step 1 layout (per §5b page diagram): a 40/60 two-column card. Panel A holds the URL input, Submit, and a status line. Panel B holds an embedded RollingLog driven by useDiscoveryStream from Spec 09.
Hook contract assumed from Spec 09 (already shipped: do NOT redefine):
// from src/hooks/factory/useDiscoveryStream.ts (Spec 09)
export interface UseDiscoveryStreamReturn {
logs: LogEntry[];
urlCount: number;
classifiedCount: number;
isStreaming: boolean;
error: string | null;
discover: (sourceUrl: string) => Promise<void>;
}
export function useDiscoveryStream(): UseDiscoveryStreamReturn;
If Spec 09 has not yet shipped, the implementation below mocks the hook in tests and the runtime imports remain valid because the import path resolves to the (separately built) Spec 09 module. Do not write Spec 09's body inside this spec.
- [ ] Step 26.1: Write failing test for URL validation
Create src/components/admin/factory/__tests__/DiscoveryPanel.test.tsx:
/**
* DiscoveryPanel tests — form validation rejects non-URLs, submit calls
* useDiscoveryStream.discover, status line reflects hook state, embedded
* RollingLog renders entries.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import type { LogEntry } from '@/../lib/factory/types';
// Mock the discovery hook BEFORE importing the component.
const mockDiscover = vi.fn(async () => {});
let mockReturn = {
logs: [] as LogEntry[],
urlCount: 0,
classifiedCount: 0,
isStreaming: false,
error: null as string | null,
discover: mockDiscover,
};
vi.mock('@/hooks/factory/useDiscoveryStream', () => ({
useDiscoveryStream: () => mockReturn,
}));
// jsdom does not implement scrollIntoView — used by RollingLog.
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn();
mockDiscover.mockClear();
mockReturn = {
logs: [],
urlCount: 0,
classifiedCount: 0,
isStreaming: false,
error: null,
discover: mockDiscover,
};
});
import { DiscoveryPanel } from '../DiscoveryPanel';
describe('DiscoveryPanel — form validation', () => {
it('rejects an empty URL on submit and does NOT call discover', async () => {
render(<DiscoveryPanel />);
const submit = screen.getByRole('button', { name: /start discovery/i });
fireEvent.click(submit);
await waitFor(() => {
expect(screen.getByText(/enter a valid https url/i)).toBeInTheDocument();
});
expect(mockDiscover).not.toHaveBeenCalled();
});
it('rejects a non-URL string', async () => {
render(<DiscoveryPanel />);
const input = screen.getByPlaceholderText(/https:\/\//i);
fireEvent.change(input, { target: { value: 'not a url' } });
fireEvent.click(screen.getByRole('button', { name: /start discovery/i }));
await waitFor(() => {
expect(screen.getByText(/enter a valid https url/i)).toBeInTheDocument();
});
expect(mockDiscover).not.toHaveBeenCalled();
});
it('rejects an http (non-TLS) URL', async () => {
render(<DiscoveryPanel />);
fireEvent.change(screen.getByPlaceholderText(/https:\/\//i), {
target: { value: 'http://example.com' },
});
fireEvent.click(screen.getByRole('button', { name: /start discovery/i }));
await waitFor(() => {
expect(screen.getByText(/enter a valid https url/i)).toBeInTheDocument();
});
expect(mockDiscover).not.toHaveBeenCalled();
});
it('accepts a valid https URL and calls discover with it', async () => {
render(<DiscoveryPanel />);
fireEvent.change(screen.getByPlaceholderText(/https:\/\//i), {
target: { value: 'https://www.algolia.com' },
});
fireEvent.click(screen.getByRole('button', { name: /start discovery/i }));
await waitFor(() => {
expect(mockDiscover).toHaveBeenCalledWith('https://www.algolia.com');
});
});
});
describe('DiscoveryPanel — status line', () => {
it('shows idle hint before discovery has started', () => {
render(<DiscoveryPanel />);
expect(screen.getByText(/awaiting url/i)).toBeInTheDocument();
});
it('shows streaming counts when isStreaming=true', () => {
mockReturn = {
...mockReturn,
isStreaming: true,
urlCount: 1247,
classifiedCount: 8,
};
render(<DiscoveryPanel />);
expect(screen.getByText(/1247 URLs found/i)).toBeInTheDocument();
expect(screen.getByText(/8 categorized/i)).toBeInTheDocument();
});
it('shows error message when hook reports error', () => {
mockReturn = { ...mockReturn, error: 'sitemap fetch failed' };
render(<DiscoveryPanel />);
expect(screen.getByText(/sitemap fetch failed/i)).toBeInTheDocument();
});
});
describe('DiscoveryPanel — embedded RollingLog', () => {
it('renders log entries from the hook', () => {
mockReturn = {
...mockReturn,
logs: [
{ ts: 1714432200000, level: 'info', message: 'robots.txt OK' },
{ ts: 1714432201000, level: 'info', message: '1247 URLs found' },
],
};
render(<DiscoveryPanel />);
expect(screen.getByText(/robots\.txt OK/)).toBeInTheDocument();
expect(screen.getByText(/1247 URLs found/)).toBeInTheDocument();
});
});
- [ ] Step 26.2: Run, expect FAIL
npx vitest run src/components/admin/factory/__tests__/DiscoveryPanel.test.tsx
Expected: FAIL: Cannot find module '../DiscoveryPanel'.
- [ ] Step 26.3: Implement
DiscoveryPanel.tsx
Create src/components/admin/factory/DiscoveryPanel.tsx:
/**
* DiscoveryPanel — Step 1 of the Crawler Factory flow.
* Two-column card: URL input + status (left, 40%) | RollingLog (right, 60%).
*
* Form is react-hook-form + zod. URL must parse and be https:// (we never
* crawl plaintext-http origins in the factory; saves a class of WAF/redirect
* pain documented in 00-Plan.md §3d).
*/
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Loader2, Search } from 'lucide-react';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useDiscoveryStream } from '@/hooks/factory/useDiscoveryStream';
import { createLogger } from '@/lib/utils/logger';
import { RollingLog } from './RollingLog';
const log = createLogger('factory:ui:discovery-panel');
const FormSchema = z.object({
sourceUrl: z
.string()
.min(1, 'Enter a valid https URL')
.url('Enter a valid https URL')
.refine((v) => v.startsWith('https://'), 'Enter a valid https URL'),
});
type FormValues = z.infer<typeof FormSchema>;
/**
* URL input form + live status + embedded log. Stateless w.r.t. session —
* the hook owns SSE state.
*/
export function DiscoveryPanel() {
const stream = useDiscoveryStream();
const form = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: { sourceUrl: '' },
});
async function onSubmit(values: FormValues) {
try {
await stream.discover(values.sourceUrl);
} catch (err) {
log.error(
{ event: 'discover_invoke_failed', err: String(err) },
'discover() invocation rejected',
);
}
}
return (
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
{/* Panel A — input + status (40%) */}
<section className="md:col-span-2 rounded-2xl border border-white/10 bg-white/5 backdrop-blur-md p-6 space-y-5">
<div>
<h3 className="text-sm font-bold text-white/40 uppercase tracking-widest">
Step 1 — Discover
</h3>
<p className="mt-2 text-xs text-white/60 font-mono">
Paste an apex domain. We walk robots → sitemaps → group by path → classify.
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="sourceUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs text-white/60 uppercase tracking-widest">
Source URL
</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://www.example.com"
autoComplete="off"
disabled={stream.isStreaming}
className="bg-black/40 border-white/10 text-white font-mono"
/>
</FormControl>
<FormMessage className="text-[11px] text-red-400" />
</FormItem>
)}
/>
<Button
type="submit"
disabled={stream.isStreaming}
className="w-full bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 hover:bg-indigo-500/30"
>
{stream.isStreaming ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Discovering...
</>
) : (
<>
<Search className="w-4 h-4 mr-2" />
Start discovery
</>
)}
</Button>
</form>
</Form>
<StatusLine
isStreaming={stream.isStreaming}
urlCount={stream.urlCount}
classifiedCount={stream.classifiedCount}
error={stream.error}
/>
</section>
{/* Panel B — rolling log (60%) */}
<section className="md:col-span-3">
<h3 className="text-sm font-bold text-white/40 uppercase tracking-widest mb-3">
Live log
</h3>
<RollingLog entries={stream.logs} />
</section>
</div>
);
}
interface StatusLineProps {
isStreaming: boolean;
urlCount: number;
classifiedCount: number;
error: string | null;
}
function StatusLine({ isStreaming, urlCount, classifiedCount, error }: StatusLineProps) {
if (error) {
return (
<p
role="status"
className="text-xs font-mono text-red-400"
aria-live="polite"
>
Error: {error}
</p>
);
}
if (isStreaming) {
return (
<p
role="status"
className="text-xs font-mono text-indigo-400"
aria-live="polite"
>
Discovering... {urlCount} URLs found, {classifiedCount} categorized.
</p>
);
}
return (
<p
role="status"
className="text-xs font-mono text-white/40"
aria-live="polite"
>
Awaiting URL.
</p>
);
}
- [ ] Step 26.4: Run all DiscoveryPanel tests, expect PASS
npx vitest run src/components/admin/factory/__tests__/DiscoveryPanel.test.tsx
Expected: 8 tests pass.
- [ ] Step 26.5: Run
tsc --noEmit
npx tsc --noEmit
Expected: clean. If useDiscoveryStream is not yet shipped (Spec 09 pending), tsc will fail on the import: block this task on Spec 09 completion.
- [ ] Step 26.6: Commit
git add src/components/admin/factory/DiscoveryPanel.tsx src/components/admin/factory/__tests__/DiscoveryPanel.test.tsx
git commit -m "feat(factory): DiscoveryPanel with URL form, status line, embedded log"
Task 27: CategoryReview: pathGroup tree, selection, manual reclassify, CTA
Files:
- Create: src/components/admin/factory/CategoryReview.tsx
- Create: src/components/admin/factory/__tests__/CategoryReview.test.tsx
CategoryReview renders the post-discovery tree (per §5b second diagram). Top-level rows are content domains. Each row is collapsible (chevron icon flips). Inside, each pathGroup row contains:
- shadcn
Checkbox(controlled) for selection - path pattern (mono font)
- URL count
DomainBadgewith confidence- "show samples" toggle (revealing up to 3 sample URLs)
- "reclassify" dropdown (shadcn
Select) listing all 9 content domains for manual override
The bottom CTA Configure Selected (N) is enabled only when ≥1 row is selected. Clicking calls props.onConfigureSelected(selectedPathGroupIds).
PathGroup shape (already shipped from Spec 01 lib/factory/types.ts):
export type PathGroup = {
id: string;
pattern: string;
urlCount: number;
sampleUrls: string[];
detectedDomain: ContentDomain | null;
detectionConfidence: number | null;
detectionMethod: DetectedVia | null;
selected: boolean;
blueprint?: { /* ... */ };
};
This component owns local UI state for selection and manual domain override: both are edited optimistically, then bubbled up via onSelectionChange and onDomainOverride so the parent (Spec 13 CrawlerFactory.tsx) can persist into the session. The pathGroups prop remains the source of truth between renders.
- [ ] Step 27.1: Write failing tests
Create src/components/admin/factory/__tests__/CategoryReview.test.tsx:
/**
* CategoryReview tests — verify grouping by content domain, selection,
* manual reclassify, and the Configure Selected CTA gating.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/react';
import type { PathGroup } from '@/../lib/factory/types';
import { CategoryReview } from '../CategoryReview';
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn();
});
const pg = (overrides: Partial<PathGroup>): PathGroup => ({
id: 'pg_default',
pattern: '/blog/*',
urlCount: 10,
sampleUrls: ['https://x.com/blog/a', 'https://x.com/blog/b', 'https://x.com/blog/c'],
detectedDomain: 'marketing',
detectionConfidence: 0.9,
detectionMethod: 'json-ld',
selected: false,
...overrides,
});
const sample: PathGroup[] = [
pg({ id: 'pg_blog', pattern: '/blog/*', urlCount: 412, detectedDomain: 'marketing' }),
pg({ id: 'pg_news', pattern: '/news/*', urlCount: 88, detectedDomain: 'marketing' }),
pg({ id: 'pg_docs', pattern: '/docs/*', urlCount: 208, detectedDomain: 'technical' }),
pg({
id: 'pg_unclassified',
pattern: '/random/*',
urlCount: 5,
detectedDomain: null,
detectionConfidence: null,
detectionMethod: null,
}),
];
describe('CategoryReview — grouping', () => {
it('groups pathGroups by detectedDomain', () => {
render(<CategoryReview pathGroups={sample} onConfigureSelected={vi.fn()} />);
// Two domain headers + one "unclassified" header.
expect(screen.getByRole('heading', { name: /^marketing/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /^technical/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /^unclassified/i })).toBeInTheDocument();
});
it('renders pathGroup pattern and URL count', () => {
render(<CategoryReview pathGroups={sample} onConfigureSelected={vi.fn()} />);
expect(screen.getByText('/blog/*')).toBeInTheDocument();
expect(screen.getByText(/412 URLs/)).toBeInTheDocument();
expect(screen.getByText('/docs/*')).toBeInTheDocument();
expect(screen.getByText(/208 URLs/)).toBeInTheDocument();
});
});
describe('CategoryReview — selection + CTA', () => {
it('Configure Selected CTA is disabled when nothing is selected', () => {
render(<CategoryReview pathGroups={sample} onConfigureSelected={vi.fn()} />);
const cta = screen.getByRole('button', { name: /configure selected \(0\)/i });
expect(cta).toBeDisabled();
});
it('checkbox toggles selection and updates CTA label', () => {
render(<CategoryReview pathGroups={sample} onConfigureSelected={vi.fn()} />);
const blogRow = screen.getByTestId('pathgroup-row-pg_blog');
const checkbox = within(blogRow).getByRole('checkbox');
fireEvent.click(checkbox);
expect(
screen.getByRole('button', { name: /configure selected \(1\)/i }),
).toBeEnabled();
});
it('clicking Configure Selected emits the selected pathGroup ids', () => {
const onConfigureSelected = vi.fn();
render(
<CategoryReview pathGroups={sample} onConfigureSelected={onConfigureSelected} />,
);
const blogRow = screen.getByTestId('pathgroup-row-pg_blog');
const docsRow = screen.getByTestId('pathgroup-row-pg_docs');
fireEvent.click(within(blogRow).getByRole('checkbox'));
fireEvent.click(within(docsRow).getByRole('checkbox'));
fireEvent.click(
screen.getByRole('button', { name: /configure selected \(2\)/i }),
);
expect(onConfigureSelected).toHaveBeenCalledWith(['pg_blog', 'pg_docs']);
});
});
describe('CategoryReview — sample URL toggle', () => {
it('hides sample URLs by default and shows them after click', () => {
render(<CategoryReview pathGroups={sample} onConfigureSelected={vi.fn()} />);
const blogRow = screen.getByTestId('pathgroup-row-pg_blog');
expect(within(blogRow).queryByText('https://x.com/blog/a')).not.toBeInTheDocument();
fireEvent.click(within(blogRow).getByRole('button', { name: /show samples/i }));
expect(within(blogRow).getByText('https://x.com/blog/a')).toBeInTheDocument();
expect(within(blogRow).getByText('https://x.com/blog/b')).toBeInTheDocument();
expect(within(blogRow).getByText('https://x.com/blog/c')).toBeInTheDocument();
});
});
describe('CategoryReview — manual reclassify', () => {
it('changing the reclassify dropdown calls onDomainOverride', () => {
const onDomainOverride = vi.fn();
render(
<CategoryReview
pathGroups={sample}
onConfigureSelected={vi.fn()}
onDomainOverride={onDomainOverride}
/>,
);
const blogRow = screen.getByTestId('pathgroup-row-pg_blog');
const select = within(blogRow).getByLabelText(/reclassify/i) as HTMLSelectElement;
fireEvent.change(select, { target: { value: 'support' } });
expect(onDomainOverride).toHaveBeenCalledWith('pg_blog', 'support');
});
});
- [ ] Step 27.2: Run, expect FAIL
npx vitest run src/components/admin/factory/__tests__/CategoryReview.test.tsx
Expected: FAIL: Cannot find module '../CategoryReview'.
- [ ] Step 27.3: Implement
CategoryReview.tsx
Create src/components/admin/factory/CategoryReview.tsx:
/**
* CategoryReview — Step 2 of the Crawler Factory flow.
*
* Tree of pathGroups grouped by detected content domain. User picks rows,
* optionally overrides the auto-detected domain, then clicks Configure Selected
* to advance to Spec 11's CategoryConfigurator drawer.
*
* Selection state is local; reclassify decisions are bubbled up immediately
* so the parent can persist them into the session record.
*/
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { ContentDomain, PathGroup } from '@/../lib/factory/types';
import { DomainBadge } from './DomainBadge';
const ALL_DOMAINS: ReadonlyArray<ContentDomain> = [
'marketing',
'support',
'education',
'technical',
'customer-stories',
'product-catalog',
'events',
'legal',
'social',
];
const UNCLASSIFIED_KEY = 'unclassified' as const;
type DomainKey = ContentDomain | typeof UNCLASSIFIED_KEY;
export interface CategoryReviewProps {
pathGroups: ReadonlyArray<PathGroup>;
onConfigureSelected: (selectedPathGroupIds: string[]) => void;
onDomainOverride?: (pathGroupId: string, newDomain: ContentDomain) => void;
}
/**
* Render the categorized pathGroup tree with selection + reclassify controls.
*/
export function CategoryReview({
pathGroups,
onConfigureSelected,
onDomainOverride,
}: CategoryReviewProps) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [collapsed, setCollapsed] = useState<Set<DomainKey>>(new Set());
const [showSamples, setShowSamples] = useState<Set<string>>(new Set());
const grouped = useMemo<ReadonlyArray<readonly [DomainKey, ReadonlyArray<PathGroup>]>>(() => {
const buckets = new Map<DomainKey, PathGroup[]>();
for (const g of pathGroups) {
const key: DomainKey = g.detectedDomain ?? UNCLASSIFIED_KEY;
const arr = buckets.get(key) ?? [];
arr.push(g);
buckets.set(key, arr);
}
// Stable order: known domains in canonical order, then unclassified last.
const ordered: Array<[DomainKey, ReadonlyArray<PathGroup>]> = [];
for (const d of ALL_DOMAINS) {
const arr = buckets.get(d);
if (arr && arr.length > 0) ordered.push([d, arr]);
}
const unclassified = buckets.get(UNCLASSIFIED_KEY);
if (unclassified && unclassified.length > 0) {
ordered.push([UNCLASSIFIED_KEY, unclassified]);
}
return ordered;
}, [pathGroups]);
function toggleSelected(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleCollapsed(key: DomainKey) {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
function toggleSamples(id: string) {
setShowSamples((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function handleConfigure() {
// Preserve discovery order — emit ids in the order they appear in pathGroups.
const ordered = pathGroups
.filter((g) => selected.has(g.id))
.map((g) => g.id);
onConfigureSelected(ordered);
}
const selectedCount = selected.size;
return (
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-md p-6 space-y-4">
<header className="flex items-center justify-between">
<h3 className="text-sm font-bold text-white/40 uppercase tracking-widest">
Step 2 — Review categories
</h3>
<span className="text-[11px] font-mono text-white/40">
{pathGroups.length} path groups · {grouped.length} domains
</span>
</header>
<div className="space-y-3">
{grouped.map(([domain, groups]) => {
const isCollapsed = collapsed.has(domain);
const headingLabel = domain === UNCLASSIFIED_KEY ? 'unclassified' : domain;
return (
<section
key={domain}
className="rounded-xl border border-white/10 bg-black/20"
>
<button
type="button"
onClick={() => toggleCollapsed(domain)}
className="w-full flex items-center gap-3 p-3 hover:bg-white/5"
aria-expanded={!isCollapsed}
>
{isCollapsed ? (
<ChevronRight className="w-4 h-4 text-white/40" />
) : (
<ChevronDown className="w-4 h-4 text-white/40" />
)}
<h4 className="text-xs font-bold uppercase tracking-widest text-white/80">
{headingLabel}
</h4>
<span className="ml-auto text-[10px] font-mono text-white/40">
{groups.length} group{groups.length === 1 ? '' : 's'}
</span>
</button>
{!isCollapsed && (
<ul className="divide-y divide-white/5">
{groups.map((g) => {
const isSelected = selected.has(g.id);
const samplesOpen = showSamples.has(g.id);
return (
<li
key={g.id}
data-testid={`pathgroup-row-${g.id}`}
className={cn(
'p-3 flex flex-col gap-2',
isSelected && 'bg-indigo-500/5',
)}
>
<div className="flex items-center gap-3 flex-wrap">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelected(g.id)}
aria-label={`Select pathGroup ${g.pattern}`}
/>
<span className="font-mono text-xs text-white/90">{g.pattern}</span>
<span className="font-mono text-[10px] text-white/40">
{g.urlCount} URLs
</span>
{g.detectedDomain && g.detectionConfidence !== null && (
<DomainBadge
contentDomain={g.detectedDomain}
confidence={g.detectionConfidence}
/>
)}
<button
type="button"
onClick={() => toggleSamples(g.id)}
className="ml-auto text-[10px] uppercase tracking-widest text-white/40 hover:text-white/70"
>
{samplesOpen ? 'Hide samples' : 'Show samples'}
</button>
</div>
{samplesOpen && g.sampleUrls.length > 0 && (
<ul className="ml-7 space-y-0.5 text-[11px] font-mono text-white/50">
{g.sampleUrls.slice(0, 3).map((u) => (
<li key={u} className="break-all">
{u}
</li>
))}
</ul>
)}
{onDomainOverride && (
<div className="ml-7 flex items-center gap-2">
<label
htmlFor={`reclassify-${g.id}`}
className="text-[10px] uppercase tracking-widest text-white/40"
>
Reclassify
</label>
<select
id={`reclassify-${g.id}`}
value={g.detectedDomain ?? ''}
onChange={(e) =>
onDomainOverride(g.id, e.target.value as ContentDomain)
}
className="bg-black/40 border border-white/10 rounded-md text-[11px] text-white/80 px-2 py-1 font-mono"
>
<option value="" disabled>
Pick a domain…
</option>
{ALL_DOMAINS.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</div>
)}
</li>
);
})}
</ul>
)}
</section>
);
})}
</div>
<footer className="pt-2 flex justify-end">
<Button
type="button"
onClick={handleConfigure}
disabled={selectedCount === 0}
className="bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 hover:bg-indigo-500/30"
>
Configure Selected ({selectedCount})
</Button>
</footer>
</div>
);
}
- [ ] Step 27.4: Run all CategoryReview tests, expect PASS
npx vitest run src/components/admin/factory/__tests__/CategoryReview.test.tsx
Expected: 7 tests pass.
- [ ] Step 27.5: Run
tsc --noEmit
npx tsc --noEmit
Expected: clean.
- [ ] Step 27.6: Commit
git add src/components/admin/factory/CategoryReview.tsx src/components/admin/factory/__tests__/CategoryReview.test.tsx
git commit -m "feat(factory): CategoryReview pathGroup tree with selection and reclassify"
Task 28: Cluster validation: full regression
Files: - (Read-only verification)
- [ ] Step 28.1: Run the full project test suite
npx vitest run
Expected: existing baseline tests + 4 new factory UI test files (~34 new test cases), all GREEN.
- [ ] Step 28.2: Run full tsc
npx tsc --noEmit
Expected: clean.
- [ ] Step 28.3: Run lint
npm run lint
Expected: clean. ESLint react-hooks/exhaustive-deps and @typescript-eslint/no-explicit-any must pass.
- [ ] Step 28.4: Visual smoke (manual, post-merge)
Boot the dev shell:
npm run dev
Navigate to /admin/factory once Spec 13 (AdminFactory.tsx) is mounted. Confirm:
- The two-column DiscoveryPanel renders with the dark indigo aesthetic.
- The URL input rejects non-https values and shows the inline error.
- An empty RollingLog shows the "no events yet" hint.
- A populated CategoryReview groups pathGroups by content domain, the chevron flips on collapse, and the "Configure Selected (0)" button is disabled.
Note: this manual smoke is documented here for completeness; it depends on Spec 09 + Spec 13. Do not block the commit on it.
- [ ] Step 28.5: Mark Spec 10 done in vault
Status.md
In the vault Projects/Crawler-Factory/Status.md, change:
- | Cluster 10 (Frontend Discovery) | ⏸ | RollingLog, DomainBadge, DiscoveryPanel, CategoryReview |
+ | Cluster 10 (Frontend Discovery) | ✅ | RollingLog, DomainBadge, DiscoveryPanel, CategoryReview |
Acceptance criteria: Spec 10 done means:
src/components/admin/factory/RollingLog.tsxexists, exportsRollingLog, accepts{ entries, maxHeight? }, auto-scrolls on new entry, color-codes level badges (info=indigo, warn=amber, error=red), and renders monospace timestamps.src/components/admin/factory/DomainBadge.tsxexists, exportsDomainBadge, accepts{ contentDomain, confidence }, renders one of nine domain colors, monospace integer percentage, and a green/yellow/amber traffic-light dot per §19c thresholds (≥0.85 / 0.65–0.84 / <0.65).src/components/admin/factory/DiscoveryPanel.tsxexists, exportsDiscoveryPanel, takes no required props, owns a react-hook-form + zod URL form (https only), invokesuseDiscoveryStream.discover(url)on submit, shows status line ("Awaiting URL." → "Discovering... N URLs found, M categorized." → "Error: …"), and embedsRollingLog.src/components/admin/factory/CategoryReview.tsxexists, exportsCategoryReview, accepts{ pathGroups, onConfigureSelected, onDomainOverride? }, groups rows by detected content domain (collapsible per group), renders shadcnCheckbox,DomainBadge, sample-URL toggle (≤3 URLs), reclassify dropdown (9 options), and a "Configure Selected (N)" CTA enabled only when ≥1 row is selected.- All four components apply the
/adminvisual conventions:bg-[#0a0a0f]-compatible cards (bg-white/5 border-white/10 rounded-2xl backdrop-blur-md), uppercase tracked-widest section headers, mono fonts for IDs/URLs/timestamps, indigo accent for primary actions. - ~34 new vitest cases pass, full project test suite stays GREEN,
tsc --noEmitclean,npm run lintclean. - Per CodingSOPs: every component file has a logger, every public component has a docstring, no
any, noconsole.log, all hooks at top-level with explicit deps. All form controls have associated<label>. All live regions usearia-live="polite".
Out of scope (handled by other specs)
- The
useDiscoveryStreamhook implementation: Spec 09 (mocked here). - The
useFactorySession+useCrawlProgresshooks: Spec 09. CategoryConfiguratordrawer (Step 3: Configure): Spec 11.LiveCrawlMonitor(Step 4: Live crawl): Spec 12 (but it reusesRollingLogfrom this spec).CrawlerFactory.tsxorchestrator + FSM driver: Spec 13.AdminFactory.tsxpage shell + route registration: Spec 13.- Backend SSE endpoints (
/api/factory/discover, etc.): Specs 06–08. - Persisting
selectedPathGroupIdsto the session record: Spec 13 orchestrator. - Persisting manual reclassify overrides into the session: Spec 13 orchestrator wires
onDomainOverridetouseFactorySession.update. - Visual smoke against a live API: Spec 13 manual verification.