Crawler-Factory

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.

  1. Two-layer type safety. zod for runtime form validation; tsc --noEmit strict for write-time. No any. Use T | null, not T | undefined, when the absence is meaningful (e.g., detectedDomain: ContentDomain | null per Spec 01 PathGroupSchema).
  2. No console.log. Top of every component file: import { createLogger } from '@/lib/utils/logger'; and const log = createLogger('factory:ui:rolling-log'); (or appropriate context). Wrap any error path with log.error({ event, ... }, 'short_msg'): never raw console.error from training priors. The logger module is already shipped (src/lib/utils/logger.ts via path alias @/lib/utils/logger): confirm path before importing; if the alias resolves to lib/utils/logger.ts use the relative path that works in your codebase build.
  3. Try/catch every fallible call inside event handlers. Form submit handlers wrap discover() in try/catch and log on rejection.
  4. Functions ≤20 lines, ≤3 params. More than 3 params → use a typed config object. Components receive a single typed Props object.
  5. 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).
  6. No raw dicts crossing module boundaries. Props are typed interfaces; arrays of LogEntry and PathGroup come from the Spec 01 zod-validated types.
  7. TDD cadence. Test first → run, expect FAIL → implement → run, expect PASS → commit.
  8. No live network in unit tests. useDiscoveryStream is mocked via vi.mock. Real SSE lives behind useDiscoveryStream in Spec 09 and is integration-tested there.
  9. React rules. - Functional components only. - Hooks at top level, never inside conditionals. - useEffect deps arrays explicit; ESLint react-hooks/exhaustive-deps must stay clean. - No inline any. No as unknown as X casts. - Each component file exports exactly one component (plus any helper types): no barrel files.
  10. Naming. TypeScript: camelCase for vars/functions, PascalCase for components/types, UPPER_SNAKE_CASE for module constants. Files: PascalCase.tsx for components (matches existing HealthHUD.tsx, BugTracker.tsx convention). Tests: same name with .test.tsx.
  11. Conventional commits. feat(factory):, test(factory):, chore(factory):, etc. One logical change per commit.
  12. Accessibility. All form controls have associated <label> (use shadcn FormLabel). Buttons disabled when invalid. Live regions (aria-live="polite") on the status line + log container so screen readers announce updates.
  13. No frontend may render an emoji. Use lucide-react icons (consistent with Admin.tsx).

Visual convention reminders (from src/pages/Admin.tsx + src/components/admin/HealthHUD.tsx)

  • Background: bg-[#0a0a0f], text white, borders border-white/10.
  • Section headers: text-3xl font-black uppercase tracking-tighter with 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 by h-px bg-white/10 flex-1 dividers.
  • Cards: bg-white/5 border border-white/10 rounded-2xl backdrop-blur-md.
  • Mono font: font-mono for 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-motion initial={{ 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.tsx with 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
  • DomainBadge with 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:

  1. src/components/admin/factory/RollingLog.tsx exists, exports RollingLog, accepts { entries, maxHeight? }, auto-scrolls on new entry, color-codes level badges (info=indigo, warn=amber, error=red), and renders monospace timestamps.
  2. src/components/admin/factory/DomainBadge.tsx exists, exports DomainBadge, 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).
  3. src/components/admin/factory/DiscoveryPanel.tsx exists, exports DiscoveryPanel, takes no required props, owns a react-hook-form + zod URL form (https only), invokes useDiscoveryStream.discover(url) on submit, shows status line ("Awaiting URL." → "Discovering... N URLs found, M categorized." → "Error: …"), and embeds RollingLog.
  4. src/components/admin/factory/CategoryReview.tsx exists, exports CategoryReview, accepts { pathGroups, onConfigureSelected, onDomainOverride? }, groups rows by detected content domain (collapsible per group), renders shadcn Checkbox, DomainBadge, sample-URL toggle (≤3 URLs), reclassify dropdown (9 options), and a "Configure Selected (N)" CTA enabled only when ≥1 row is selected.
  5. All four components apply the /admin visual 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.
  6. ~34 new vitest cases pass, full project test suite stays GREEN, tsc --noEmit clean, npm run lint clean.
  7. Per CodingSOPs: every component file has a logger, every public component has a docstring, no any, no console.log, all hooks at top-level with explicit deps. All form controls have associated <label>. All live regions use aria-live="polite".

Out of scope (handled by other specs)

  • The useDiscoveryStream hook implementation: Spec 09 (mocked here).
  • The useFactorySession + useCrawlProgress hooks: Spec 09.
  • CategoryConfigurator drawer (Step 3: Configure): Spec 11.
  • LiveCrawlMonitor (Step 4: Live crawl): Spec 12 (but it reuses RollingLog from this spec).
  • CrawlerFactory.tsx orchestrator + FSM driver: Spec 13.
  • AdminFactory.tsx page shell + route registration: Spec 13.
  • Backend SSE endpoints (/api/factory/discover, etc.): Specs 06–08.
  • Persisting selectedPathGroupIds to the session record: Spec 13 orchestrator.
  • Persisting manual reclassify overrides into the session: Spec 13 orchestrator wires onDomainOverride to useFactorySession.update.
  • Visual smoke against a live API: Spec 13 manual verification.