Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions BUILD_NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# SourceKit Recruiter OS — Build Notes

## What Was Built

A fully separate recruiter product experience at `/recruiter` within the existing SourceKit SPA. This is not a reskin — it's a distinct product boundary with its own layout, navigation, data model, routing, and screen copy, built on SourceKit infrastructure.

**Product Name:** SourceKit Recruiter OS
**Route Prefix:** `/recruiter`
**Target Users:** Heads of Talent, technical recruiters, sourcers, founder-operators hiring AI-native technical talent

## How to Access

Navigate to `/recruiter` when authenticated. The original SourceKit app at `/` remains fully intact.

## Architecture

- **Route Group:** `/recruiter/*` route group inside existing SPA via React Router v6
- **Lazy Loading:** All recruiter pages are lazy-loaded for code splitting
- **Isolation:** Separate layout component (`RecruiterLayout`), dedicated CSS scope (`.recruiter-os` class), own page components, hooks, services, and types
- **Shared Infra:** Supabase client, auth, React Query provider, shadcn/ui primitives, and existing API utilities are reused

## Sections Built

| Section | Route | Description |
|---------|-------|-------------|
| Command Center | `/recruiter` | Operational dashboard: stats, review queue, agent runs, scorecards, pipeline snapshot |
| Search Lab | `/recruiter/search` | Role-brief-driven search workspace with archetypes, signals, source toggles, search modes |
| Candidate Intel | `/recruiter/candidates` | Dense sortable/filterable candidate table |
| Candidate Profile | `/recruiter/candidates/:id` | Full profile with 6-dimension scoring, evidence tabs, outreach history, notes |
| Team Pipeline | `/recruiter/pipeline` | Board + table views with tier columns, stage grouping, bulk actions |
| Outreach Studio | `/recruiter/outreach` | Three-column outreach workspace: queue, editor with evidence grounding, candidate evidence |
| Role Scorecards | `/recruiter/scorecards` | CRUD for reusable role definitions with signals, weights, suppressions, eval questions |
| Scorecard Detail | `/recruiter/scorecards/:id` | Full scorecard editor with launch-search integration |
| Agent Runs | `/recruiter/agents` | Run log with expandable detail, error display, filtering, auto-refresh |
| Reports | `/recruiter/reports` | 8 report types with data adapters and graceful degradation |
| Settings | `/recruiter/settings` | ATS integration, API keys, notifications, scoring defaults, team, appearance |

## Transparent Scoring Model

Six scoring dimensions, each with evidence citations:
- **EEA Score** — Evidence of Exceptional Ability (USCIS criteria)
- **Builder Score** — Shipping velocity, ownership, project completion
- **AI Recency Score** — Recent frontier AI work
- **Systems Depth Score** — Infrastructure and architecture signals
- **Product Instinct Score** — User-facing craft and product thinking
- **Hidden Gem Score** — High proof-to-visibility ratio

Scores are structured as `{ score: number, evidence: [], confidence: 'high'|'medium'|'low', reason: string }` and designed to be replaceable by backend-calculated values.

## Files Created

### Core Structure
```
src/recruiter/
├── RecruiterLayout.tsx # App shell with left nav
├── RecruiterNav.tsx # Persistent sidebar navigation
├── routes.tsx # Route definitions with lazy loading
├── pages/
│ ├── CommandCenter.tsx # /recruiter
│ ├── SearchLab.tsx # /recruiter/search
│ ├── CandidateIntelList.tsx # /recruiter/candidates
│ ├── CandidateIntelProfile.tsx# /recruiter/candidates/:id
│ ├── TeamPipeline.tsx # /recruiter/pipeline
│ ├── OutreachStudio.tsx # /recruiter/outreach
│ ├── RoleScorecardList.tsx # /recruiter/scorecards
│ ├── RoleScorecardDetail.tsx # /recruiter/scorecards/:id
│ ├── AgentRuns.tsx # /recruiter/agents
│ ├── Reports.tsx # /recruiter/reports
│ └── RecruiterSettings.tsx # /recruiter/settings
├── components/
│ ├── TierBadge.tsx # Tier classification badge
│ ├── ScoreCard.tsx # Score display with evidence expand
│ ├── StatusBadge.tsx # Stage, contact, run status, ATS, priority badges
│ ├── PageHeader.tsx # Page header with title and actions
│ ├── StatCard.tsx # Stat card for command center
│ └── EmptyState.tsx # Empty state pattern
├── hooks/
│ ├── useRecruiterCandidates.ts# Candidate CRUD + pipeline stats
│ ├── useRecruiterScorecard.ts # Scorecard CRUD
│ ├── useAgentRuns.ts # Agent run queries + polling
│ ├── useRecruiterOutreach.ts # Outreach CRUD
│ ├── useRecruiterNotes.ts # Candidate notes
│ └── useRecruiterSavedSearches.ts # Saved search management
├── services/
│ ├── scoring.ts # Client-side scoring utilities
│ └── export.ts # CSV export
├── lib/
│ ├── types.ts # All TypeScript interfaces
│ └── constants.ts # Tier/stage/score/report configs
└── styles/
└── recruiter-tokens.css # CSS custom properties + animations
```

### Database Migration
```
supabase/migrations/20260323_recruiter_os_schema.sql
```

7 new tables:
- `recruiter_scorecards` — Role scorecard definitions
- `recruiter_candidates` — Extended candidate model with multi-surface data
- `recruiter_candidate_notes` — Recruiter notes per candidate
- `recruiter_outreach` — Outreach messages with grounding artifacts
- `recruiter_agent_runs` — Agent job tracking
- `recruiter_saved_searches` — Saved search configurations
- `recruiter_sequence_templates` — Outreach sequence templates

All tables have:
- UUID primary keys
- User-scoped RLS policies
- Appropriate indexes
- Updated_at triggers

## Files Modified

| File | Change |
|------|--------|
| `src/App.tsx` | Added `/recruiter/*` route group with lazy-loaded RecruiterLayout |
| `tailwind.config.ts` | Added `ros-fade-in` keyframe and animation |
| `vite.config.ts` | Added recruiter chunk naming for code splitting |

## Design System

- **Dark mode default** (forced via `.recruiter-os` CSS scope)
- **Fonts:** DM Sans (body) + JetBrains Mono (labels, scores, data)
- **Accent:** `#00e5a0` (same as SourceKit primary)
- **Background:** `#0a0a0f` (deeper than default dark mode)
- **Information density:** Compact tables, small text, sticky headers
- **CSS variables:** All recruiter tokens scoped under `.recruiter-os` class

## Backend Assumptions / TODOs

1. **Database migration** needs to be applied to Supabase: `supabase db push` or run the SQL manually
2. **Search execution** — Search Lab UI is wired but `recruiter-search-orchestrator` edge function needs to be built to orchestrate multi-source search
3. **Scoring backend** — `recruiter-scoring` edge function needs to be built for server-side multi-dimensional scoring with Claude
4. **Outreach generation** — `recruiter-outreach-gen` edge function needs to be built for artifact-grounded message generation
5. **Enrichment** — `recruiter-enrichment` edge function for multi-surface candidate enrichment (LinkedIn, HuggingFace, web)
6. **ATS sync** — Webhook-based export is stubbed in Settings, needs `recruiter-ats-sync` edge function
7. **Dedup** — Cross-search deduplication needs `recruiter-dedup` edge function
8. **Reports** — Some report types show placeholder structures pending sufficient data
9. **Drag-and-drop** — Pipeline board uses explicit actions (not DnD) for reliability
10. **Team features** — Data model supports `user_id` scoping; multi-user/team features deferred

## Running the App

```bash
npm install
npm run dev
# Navigate to http://localhost:8080/recruiter
```

The original SourceKit app continues to work at `http://localhost:8080/`.
16 changes: 16 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import DeveloperProfile from "./pages/DeveloperProfile";
import Auth from "./pages/Auth";
import NotFound from "./pages/NotFound";
import { Analytics } from "@vercel/analytics/react";
import { lazy, Suspense } from "react";

// Recruiter OS — lazy-loaded route group
const RecruiterLayout = lazy(() => import("./recruiter/RecruiterLayout"));
import { recruiterRoutes } from "./recruiter/routes";

const queryClient = new QueryClient();

Expand Down Expand Up @@ -68,6 +73,17 @@ const App = () => {
<>
<Route path="/" element={<Index />} />
<Route path="/developer/:id" element={<DeveloperProfile />} />
<Route path="/recruiter/*" element={
<Suspense fallback={
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="animate-spin w-6 h-6 border-2 border-primary border-t-transparent rounded-full" />
</div>
}>
<RecruiterLayout />
</Suspense>
}>
{recruiterRoutes}
</Route>
<Route path="/auth" element={<Navigate to="/" replace />} />
<Route path="*" element={<NotFound />} />
</>
Expand Down
59 changes: 59 additions & 0 deletions src/recruiter/RecruiterLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Outlet } from 'react-router-dom';
import { useState } from 'react';
import { Menu, X } from 'lucide-react';
import RecruiterNav from './RecruiterNav';
import '../recruiter/styles/recruiter-tokens.css';

export default function RecruiterLayout() {
const [mobileOpen, setMobileOpen] = useState(false);

return (
<div className="recruiter-os flex min-h-screen" style={{ background: 'var(--ros-bg-primary)', color: 'var(--ros-text-primary)' }}>
{/* Desktop sidebar */}
<aside className="hidden lg:block w-60 shrink-0 fixed inset-y-0 left-0 z-40 border-r" style={{ borderColor: 'var(--ros-border)' }}>
<RecruiterNav />
</aside>

{/* Mobile sidebar */}
{mobileOpen && (
<>
<div
className="fixed inset-0 z-40 lg:hidden"
style={{ background: 'rgba(10,10,15,0.8)', backdropFilter: 'blur(4px)' }}
onClick={() => setMobileOpen(false)}
/>
<aside className="fixed inset-y-0 left-0 w-60 z-50 lg:hidden border-r" style={{ borderColor: 'var(--ros-border)' }}>
<button
onClick={() => setMobileOpen(false)}
className="absolute top-4 right-4 p-1.5 rounded-md"
style={{ color: 'var(--ros-text-muted)' }}
>
<X className="w-4 h-4" />
</button>
<RecruiterNav />
</aside>
</>
)}

{/* Main content */}
<div className="flex-1 lg:ml-60 min-w-0">
{/* Mobile header */}
<header className="sticky top-0 z-30 lg:hidden flex items-center gap-3 px-4 py-3 border-b" style={{ background: 'var(--ros-bg-secondary)', borderColor: 'var(--ros-border)' }}>
<button onClick={() => setMobileOpen(true)} className="p-1.5 rounded-md" style={{ color: 'var(--ros-text-secondary)' }}>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded flex items-center justify-center" style={{ background: 'var(--ros-accent-muted)' }}>
<span className="text-[8px] font-bold font-mono" style={{ color: 'var(--ros-accent)' }}>SK</span>
</div>
<span className="text-sm font-semibold" style={{ color: 'var(--ros-text-primary)' }}>Recruiter OS</span>
</div>
</header>

<main className="p-4 lg:p-6">
<Outlet />
</main>
</div>
</div>
);
}
99 changes: 99 additions & 0 deletions src/recruiter/RecruiterNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard, Search, Users, Kanban, Mail,
ClipboardList, Bot, BarChart2, Settings, LogOut,
} from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';

const NAV_ITEMS = [
{ path: '/recruiter', label: 'Command Center', icon: LayoutDashboard, exact: true },
{ path: '/recruiter/search', label: 'Search Lab', icon: Search },
{ path: '/recruiter/candidates', label: 'Candidate Intel', icon: Users },
{ path: '/recruiter/pipeline', label: 'Team Pipeline', icon: Kanban },
{ path: '/recruiter/outreach', label: 'Outreach Studio', icon: Mail },
{ path: '/recruiter/scorecards', label: 'Role Scorecards', icon: ClipboardList },
{ path: '/recruiter/agents', label: 'Agent Runs', icon: Bot },
{ path: '/recruiter/reports', label: 'Reports', icon: BarChart2 },
{ path: '/recruiter/settings', label: 'Settings', icon: Settings },
] as const;

export default function RecruiterNav() {
const location = useLocation();
const navigate = useNavigate();

const isActive = (item: typeof NAV_ITEMS[number]) => {
if (item.exact) return location.pathname === item.path;
return location.pathname.startsWith(item.path);
};

return (
<div className="flex flex-col h-full" style={{ background: 'var(--ros-bg-secondary)' }}>
{/* Logo */}
<div className="flex items-center gap-2.5 px-5 py-5 border-b" style={{ borderColor: 'var(--ros-border)' }}>
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: 'var(--ros-accent-muted)' }}>
<span className="text-xs font-bold font-mono" style={{ color: 'var(--ros-accent)' }}>SK</span>
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold tracking-tight" style={{ color: 'var(--ros-text-primary)' }}>
SourceKit
</span>
<span className="text-[10px] font-mono font-medium tracking-widest uppercase" style={{ color: 'var(--ros-accent)' }}>
Recruiter OS
</span>
</div>
</div>

{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
{NAV_ITEMS.map((item) => {
const active = isActive(item);
return (
<button
key={item.path}
onClick={() => navigate(item.path)}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-150"
style={{
background: active ? 'var(--ros-accent-muted)' : 'transparent',
color: active ? 'var(--ros-accent)' : 'var(--ros-text-secondary)',
borderLeft: active ? '2px solid var(--ros-accent)' : '2px solid transparent',
}}
onMouseEnter={(e) => {
if (!active) e.currentTarget.style.background = 'var(--ros-bg-hover)';
}}
onMouseLeave={(e) => {
if (!active) e.currentTarget.style.background = 'transparent';
}}
>
<item.icon className="w-4 h-4 shrink-0" />
<span className="font-mono text-xs tracking-wide">{item.label}</span>
</button>
);
})}
</nav>

{/* Bottom */}
<div className="px-3 py-4 border-t space-y-1" style={{ borderColor: 'var(--ros-border)' }}>
<button
onClick={() => navigate('/')}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-xs font-mono transition-colors"
style={{ color: 'var(--ros-text-muted)' }}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--ros-text-secondary)'; e.currentTarget.style.background = 'var(--ros-bg-hover)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--ros-text-muted)'; e.currentTarget.style.background = 'transparent'; }}
>
<LayoutDashboard className="w-3.5 h-3.5" />
<span>Original SourceKit</span>
</button>
<button
onClick={() => supabase.auth.signOut()}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-xs font-mono transition-colors"
style={{ color: 'var(--ros-text-muted)' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; e.currentTarget.style.background = 'rgba(239,68,68,0.08)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--ros-text-muted)'; e.currentTarget.style.background = 'transparent'; }}
>
<LogOut className="w-3.5 h-3.5" />
<span>Sign Out</span>
</button>
</div>
</div>
);
}
19 changes: 19 additions & 0 deletions src/recruiter/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react';

interface EmptyStateProps {
icon?: ReactNode;
title: string;
description: string;
action?: ReactNode;
}

export default function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center ros-fade-in">
{icon && <div className="mb-4" style={{ color: 'var(--ros-text-muted)' }}>{icon}</div>}
<h3 className="text-sm font-medium mb-1" style={{ color: 'var(--ros-text-secondary)' }}>{title}</h3>
<p className="text-xs max-w-sm" style={{ color: 'var(--ros-text-muted)' }}>{description}</p>
{action && <div className="mt-4">{action}</div>}
</div>
);
}
25 changes: 25 additions & 0 deletions src/recruiter/components/PageHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ReactNode } from 'react';

interface PageHeaderProps {
title: string;
subtitle?: string;
actions?: ReactNode;
}

export default function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
return (
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-xl font-semibold tracking-tight" style={{ color: 'var(--ros-text-primary)' }}>
{title}
</h1>
{subtitle && (
<p className="text-sm mt-0.5" style={{ color: 'var(--ros-text-muted)' }}>
{subtitle}
</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}
Loading