diff --git a/src/App.jsx b/src/App.jsx
index e65df8d..aaad173 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,7 +1,7 @@
import { Toaster } from "@/components/ui/toaster"
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClientInstance } from '@/lib/query-client'
-import { Navigate, BrowserRouter as Router, Route, Routes } from 'react-router-dom';
+import { Navigate, BrowserRouter as Router, Route, Routes, useParams } from 'react-router-dom';
import PageNotFound from './lib/PageNotFound';
import { AuthProvider, useAuth } from '@/lib/AuthContext';
import ScrollToTop from './components/ScrollToTop';
@@ -42,6 +42,12 @@ import AdminProfile from '@/pages/admin/Profile';
import AdminLogin from '@/pages/admin/Login';
import AdminRoute from '@/pages/admin/AdminRoute';
+/** `/university/:id` → the same challenge inside the Admin shell, id preserved. */
+const LegacyCourseRedirect = () => {
+ const { id } = useParams();
+ return ;
+};
+
const AuthenticatedApp = () => {
const { isLoadingAuth, isLoadingPublicSettings } = useAuth();
@@ -94,7 +100,11 @@ const AuthenticatedApp = () => {
} />
} />
} />
- } />
+ {/* Same treatment as every other legacy alias: the challenge opens inside
+ the application shell rather than on a bare page, so Forge → challenge
+ keeps its navigation, header and page frame. Existing links to
+ /university/:id are unchanged and still resolve here. */}
+ } />
} />
} />
} />
diff --git a/src/components/ai-assistant/capabilities/admin.js b/src/components/ai-assistant/capabilities/admin.js
index b9eea25..5450639 100644
--- a/src/components/ai-assistant/capabilities/admin.js
+++ b/src/components/ai-assistant/capabilities/admin.js
@@ -1,6 +1,6 @@
import {
Activity, AlertTriangle, ArrowLeftRight, BellRing, Eye, Filter, Gauge, GitBranch,
- Lightbulb, ScrollText, ShieldAlert, Target, TrendingUp, UserCheck, Users,
+ Layers, Lightbulb, ScrollText, ShieldAlert, Target, TrendingUp, UserCheck, Users,
} from 'lucide-react';
import {
actions, answer, doc, funnel, heading, insights, kpis, list, meters, note, status, table,
@@ -1099,6 +1099,136 @@ const candidatePipelineSummary = (f) => {
);
};
+/* ── Analytics ──────────────────────────────────────────────────────────── */
+
+const DAY_MS = 1000 * 60 * 60 * 24;
+
+/**
+ * Hiring trend — direction of travel, not another chart.
+ *
+ * The page already draws the line; what it cannot say is whether the line is
+ * going up. So this compares the last four weeks against the four before them
+ * and reports the change, using the same created/updated dates the chart buckets.
+ */
+const hiringTrend = (f) => {
+ if (!f.total) return answer('No applications yet, so there is no trend to read.');
+
+ const inWindow = (value, from, to) => {
+ const t = new Date(value).getTime();
+ return t >= f.today.getTime() - from * DAY_MS && t < f.today.getTime() - to * DAY_MS;
+ };
+ const applied = (from, to) => f.applications.filter((a) => inWindow(a.created_date, from, to)).length;
+ /* Screening is dated by the update that carried the score, exactly as the
+ Hiring trend chart buckets it. */
+ const screened = (from, to) => f.scored.filter((a) => inWindow(a.updated_date, from, to)).length;
+
+ const recent = applied(28, 0);
+ const previous = applied(56, 28);
+ const change = previous ? Math.round(((recent - previous) / previous) * 100) : null;
+
+ const weeks = [3, 2, 1, 0].map((w) => ({
+ week: w === 0 ? 'This week' : `${w} week${w === 1 ? '' : 's'} ago`,
+ applied: applied((w + 1) * 7, w * 7),
+ screened: screened((w + 1) * 7, w * 7),
+ }));
+
+ return doc(
+ heading('Hiring trend', 'Last 4 weeks against the 4 before'),
+ kpis([
+ { label: 'Applications', value: recent, sub: `${previous} in the prior period` },
+ {
+ label: 'Change',
+ value: change === null ? '—' : `${change > 0 ? '+' : ''}${change}%`,
+ tone: change === null ? undefined : change >= 0 ? 'success' : 'warning',
+ },
+ { label: 'Screened', value: screened(28, 0), sub: `${f.standardizedPct}% coverage` },
+ { label: 'Hires', value: f.hired.length, sub: f.timeToHire ? `${f.timeToHire}d to close` : 'none closed' },
+ ]),
+ heading('Week by week'),
+ table(
+ [
+ { key: 'week', label: 'Week' },
+ { key: 'applied', label: 'Applied', align: 'right' },
+ { key: 'screened', label: 'Screened', align: 'right' },
+ ],
+ weeks
+ ),
+ insights([
+ change !== null && {
+ tone: change >= 0 ? 'success' : 'warning',
+ title: change >= 0
+ ? `Applications up ${change}% on the previous four weeks`
+ : `Applications down ${Math.abs(change)}% on the previous four weeks`,
+ body: change >= 0
+ ? 'Supply is growing, so the constraint moves to screening throughput rather than reach.'
+ : 'Fewer people are arriving. Distribution and pay band are the two things that move this, not screening.',
+ },
+ f.unscreened.length && {
+ tone: 'warning',
+ title: `${plural(f.unscreened.length, 'application')} still unscored`,
+ body: 'The screened line lags the applied line by exactly this much — the gap is a queue, not a rejection rate.',
+ },
+ ]),
+ note('Windows are counted from today, so this is independent of the 7d/30d/90d control on the chart.')
+ );
+};
+
+/**
+ * Department performance — which role category is actually converting.
+ *
+ * The page's department section ranks by volume. Volume is demand, not
+ * performance: the category worth copying is the one turning applicants into
+ * qualified candidates, which is a rate.
+ */
+const departmentPerformance = (f) => {
+ if (!f.byRole.length) return answer('No open positions, so there are no departments to compare.');
+
+ const rows = Object.values(f.byRole.reduce((acc, r) => {
+ acc[r.category] ||= { category: r.category, roles: 0, applied: 0, qualified: 0, hired: 0 };
+ acc[r.category].roles += 1;
+ acc[r.category].applied += r.applied;
+ acc[r.category].qualified += r.qualified;
+ acc[r.category].hired += r.hired;
+ return acc;
+ }, {})).map((d) => ({ ...d, rate: d.applied ? Math.round((d.qualified / d.applied) * 100) : 0 }))
+ .sort((a, b) => b.rate - a.rate || b.applied - a.applied);
+
+ const best = rows.find((d) => d.applied > 0);
+ const starved = rows.filter((d) => d.applied === 0);
+
+ return doc(
+ heading('Department performance', `${plural(rows.length, 'category')}`),
+ table(
+ [
+ { key: 'category', label: 'Category' },
+ { key: 'applied', label: 'Applied', align: 'right' },
+ { key: 'qualified', label: '70+', align: 'right' },
+ { key: 'hired', label: 'Hired', align: 'right' },
+ { key: 'rate', label: 'Qualified', align: 'right' },
+ ],
+ rows.map((d) => ({
+ category: d.category,
+ applied: d.applied ? d.applied : { value: 0, tone: 'risk' },
+ qualified: d.qualified,
+ hired: d.hired || '—',
+ rate: { value: `${d.rate}%`, tone: d.rate >= 50 ? 'success' : d.rate > 0 ? 'warning' : 'risk' },
+ }))
+ ),
+ insights([
+ best && {
+ tone: 'success',
+ title: `${best.category} is performing best`,
+ body: `${best.qualified} of ${best.applied} applicants clear 70 — ${best.rate}%${best.hired ? `, and ${plural(best.hired, 'hire')} closed` : ''}. Whatever sourcing this category uses is the one worth copying.`,
+ },
+ starved.length && {
+ tone: 'risk',
+ title: `${plural(starved.length, 'category')} attracting nobody`,
+ body: `${starved.map((d) => d.category).join(', ')}. A conversion rate cannot be read off zero applicants.`,
+ },
+ ])
+ );
+};
+
/* ── Free-text responders ───────────────────────────────────────────────── */
/**
@@ -1151,6 +1281,16 @@ export const respondAdminCandidates = (question, f) => {
return recruitmentInsights(f);
};
+export const respondAdminAnalytics = (question, f) => {
+ const q = question.toLowerCase();
+ if (has(q, 'trend', 'over time', 'growing', 'slow', 'week', 'month')) return hiringTrend(f);
+ if (has(q, 'department', 'category', 'team', 'best perform')) return departmentPerformance(f);
+ if (has(q, 'bottleneck', 'funnel', 'drop', 'conversion', 'stuck', 'lose')) return pipelineHealth(f);
+ if (has(q, 'position', 'role', 'converting', 'strongest')) return positionStrength(f);
+ if (has(q, 'attention', 'urgent', 'wrong', 'fix', 'risk')) return attentionRequired(f);
+ return hiringOperations(f);
+};
+
export const respondActivity = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'security', 'breach', 'attributab', 'compliance', 'integrity', 'trace')) return securityInsights(f);
@@ -1196,6 +1336,15 @@ export const ADMIN_CANDIDATE_CAPABILITIES = [
{ id: 'top-candidates', label: 'Compare Top Candidates', icon: ArrowLeftRight, run: topCandidates },
];
+export const ADMIN_ANALYTICS_CAPABILITIES = [
+ { id: 'hiring-trend', label: 'Hiring Trend', icon: TrendingUp, run: hiringTrend },
+ { id: 'department-performance', label: 'Department Performance', icon: Layers, run: departmentPerformance },
+ { id: 'pipeline-health', label: 'Hiring Bottlenecks', icon: GitBranch, run: pipelineHealth },
+ { id: 'position-conversion', label: 'Position Conversion', icon: Target, run: positionStrength },
+ { id: 'hiring-operations', label: 'Hiring Performance', icon: Activity, run: hiringOperations },
+ { id: 'attention-required', label: 'Needs Attention', icon: BellRing, run: attentionRequired },
+];
+
export const ACTIVITY_CAPABILITIES = [
{ id: 'audit-summary', label: 'Audit Summary', icon: ScrollText, run: auditSummary },
{ id: 'user-activity', label: 'User Activity', icon: Eye, run: userActivityInsights },
diff --git a/src/components/ai-assistant/contexts.js b/src/components/ai-assistant/contexts.js
index 41d54a9..2e01544 100644
--- a/src/components/ai-assistant/contexts.js
+++ b/src/components/ai-assistant/contexts.js
@@ -3,10 +3,10 @@ import {
respondAnalytics, respondCandidates, respondOverview,
} from './capabilities/employer';
import {
- ACTIVITY_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES, CANDIDATE_LIST_CAPABILITIES,
- CONTROL_CENTER_CAPABILITIES, POSITIONS_CAPABILITIES,
- respondActivity, respondAdminCandidates, respondCandidateList, respondControlCenter,
- respondPositions,
+ ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
+ CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, POSITIONS_CAPABILITIES,
+ respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
+ respondControlCenter, respondPositions,
} from './capabilities/admin';
/**
@@ -66,6 +66,14 @@ export const ASSISTANT_CONTEXTS = {
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
respond: respondAdminCandidates,
},
+ /* Analytics reads the same records as the Control Center, but as performance
+ over time rather than as a state to act on — hence its own capabilities. */
+ 'admin.analytics': {
+ id: 'admin.analytics',
+ page: 'Analytics',
+ capabilities: ADMIN_ANALYTICS_CAPABILITIES,
+ respond: respondAdminAnalytics,
+ },
'admin.activity': {
id: 'admin.activity',
page: 'Activity',
diff --git a/src/components/ai-assistant/dynamic.js b/src/components/ai-assistant/dynamic.js
index feb6225..0c140f3 100644
--- a/src/components/ai-assistant/dynamic.js
+++ b/src/components/ai-assistant/dynamic.js
@@ -122,6 +122,17 @@ const GREETINGS = {
return `All ${f.total} candidates are scored, credentialed and comparable.`;
},
+ 'admin.analytics': (f) => {
+ if (!f.total) return 'Nothing has been applied for yet, so the charts have nothing to report.';
+ if (f.bottleneck && f.bottleneck.lost > 0) {
+ return `Conversion is weakest between ${f.bottleneck.from} and ${f.bottleneck.to}, at ${f.bottleneck.rate}%.`;
+ }
+ if (f.standardizedPct < 80) {
+ return `Screening covers ${f.standardizedPct}% of applications, so the rest of these figures rest on a partial pool.`;
+ }
+ return `${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')} — ${f.hireRate}% conversion.`;
+ },
+
'admin.activity': (f) => {
if (!f.activity.length) return 'No activity logged yet.';
const patterns = f.activitySignals.flags.length;
@@ -155,6 +166,7 @@ const TITLES = {
'admin.positions': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.candidatesList': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.candidates': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
+ 'admin.analytics': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
'admin.activity': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
};
@@ -172,6 +184,8 @@ const DESCRIPTIONS = {
`Reading ${plural(f.total, 'candidate')} across ${plural(f.postings.length, 'position')} — match quality, screening and hiring readiness.`,
'admin.candidates': (f) =>
`${plural(f.total, 'candidate')} and ${plural(f.profiles.length, 'talent profile')} — supply, quality and risk.`,
+ 'admin.analytics': (f) =>
+ `Reading hiring performance across ${plural(f.openPositions.length, 'position')}, ${plural(f.total, 'candidate')} and ${plural(new Set(f.byRole.map((r) => r.category)).size, 'department')} — conversion, speed and outcomes.`,
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
};
@@ -218,6 +232,11 @@ const PLACEHOLDERS = {
'Ask Owliver about screening gaps…',
'Ask Owliver where the risk is…',
],
+ 'admin.analytics': () => [
+ 'Ask Owliver about hiring performance…',
+ 'Ask Owliver about the hiring trend…',
+ 'Ask Owliver where the bottleneck is…',
+ ],
'admin.activity': () => [
'Ask Owliver about recent activity…',
'Ask Owliver about unusual activity…',
@@ -333,6 +352,19 @@ const PROMPTS = {
{ label: 'Recruitment insights', prompt: 'Show recruitment insights across the platform', capability: 'recruitment-insights' },
],
+ 'admin.analytics': (f) => [
+ { label: 'What is the hiring trend?', prompt: 'What is the hiring trend?', capability: 'hiring-trend' },
+ { label: 'Which department is performing best?', prompt: 'Which department is performing best?', capability: 'department-performance' },
+ f.bottleneck && f.bottleneck.lost > 0
+ ? { label: `Biggest drop at ${f.bottleneck.to}`, prompt: `Where is the biggest hiring bottleneck — why do candidates drop between ${f.bottleneck.from} and ${f.bottleneck.to}?`, capability: 'pipeline-health' }
+ : { label: 'Where is the biggest bottleneck?', prompt: 'Where is the biggest hiring bottleneck?', capability: 'pipeline-health' },
+ { label: 'Which positions are converting best?', prompt: 'Which positions are converting best?', capability: 'position-conversion' },
+ { label: 'Summarize hiring performance', prompt: 'Summarize hiring performance', capability: 'hiring-operations' },
+ f.unscreened.length
+ ? { label: `${f.unscreened.length} unscored applications`, prompt: 'What needs attention?', capability: 'attention-required' }
+ : { label: 'What needs attention?', prompt: 'What needs attention?', capability: 'attention-required' },
+ ],
+
'admin.activity': (f) => [
f.activitySignals.flags.length
? { label: `${f.activitySignals.flags.length} unusual patterns`, prompt: 'Show unusual activity on the platform', capability: 'unusual-activity' }
diff --git a/src/components/ai-assistant/placement.js b/src/components/ai-assistant/placement.js
index 31ed195..860127e 100644
--- a/src/components/ai-assistant/placement.js
+++ b/src/components/ai-assistant/placement.js
@@ -17,6 +17,7 @@ const PLACEMENT = {
'/admin/positions': 'admin.positions',
'/admin/candidates': 'admin.candidatesList',
'/admin/candidates-analysis': 'admin.candidates',
+ '/admin/analytics': 'admin.analytics',
'/admin/activity': 'admin.activity',
},
};
@@ -26,8 +27,8 @@ const PLACEMENT = {
* documented rather than implied by omission.
*
* Admin: the reporting and reference pages, plus login. The working surfaces —
- * Control Center, Positions, Candidates, Candidates Analysis and
- * Activity — all carry the panel.
+ * Control Center, Positions, Candidates, Candidates Analysis, Analytics
+ * and Activity — all carry the panel.
* Employer: every page. The contextual panel is an Admin capability.
* Talent: every page. The talent portal has the standalone Owliver product,
* with its own voice experience, branding and workflow.
@@ -39,8 +40,7 @@ export const EXCLUDED_ROUTES = [
'/university', '/university/:id', '/me', '/identity', '/employee',
'/owliver', '/design-system',
// Admin pages that are a report or a form rather than a workflow.
- '/admin/hired', '/admin/talent-pool', '/admin/analytics',
- '/admin/profile', '/admin/login',
+ '/admin/hired', '/admin/talent-pool', '/admin/profile', '/admin/login',
// The full candidate profile is one person's record, not a page-level question.
'/admin/candidates/:id',
];
diff --git a/src/components/krow/proving/ChallengeRunner.jsx b/src/components/krow/proving/ChallengeRunner.jsx
index 1da297c..66b9d16 100644
--- a/src/components/krow/proving/ChallengeRunner.jsx
+++ b/src/components/krow/proving/ChallengeRunner.jsx
@@ -1,5 +1,7 @@
import React, { useState } from 'react';
-import { Lock, Video, Camera, MessageSquare, ScanLine, Loader2, XCircle, CheckCircle2, ShieldCheck, Award, Eye, ArrowRight } from 'lucide-react';
+import { Lock, Video, Camera, MessageSquare, ScanLine, XCircle, CheckCircle2, ShieldCheck, Award, Eye, ArrowRight } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Button, Spinner, Surface } from '@/components/ds';
import { isUnlocked } from '@/lib/provingGround';
import { useSubmitChallenge, useVerifyEvidence } from '@/lib/krowHooks';
import RoleplayChallenge from './RoleplayChallenge';
@@ -8,23 +10,43 @@ import VideoRecorder from './VideoRecorder';
import HazardIdentifier from './HazardIdentifier';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
+/**
+ * The challenge itself: learn, demonstrate, verify, proof.
+ *
+ * Presentation only — every step, transition and mutation below is the original
+ * flow. Styling comes from the design system so the runner reads as part of the
+ * Forge workspace it is embedded in rather than a widget dropped into it.
+ */
+
const STEPS = ['Learn', 'Demonstrate', 'Verify', 'Proof'];
const TYPE_ICON = { roleplay: MessageSquare, photo: Camera, video: Video, photo_identify: ScanLine };
const TYPE_LABEL = { roleplay: 'Chat with Owliver', photo: 'Photo Proof', video: 'Record Yourself', photo_identify: 'Identify Hazards' };
function Stepper({ active }) {
return (
-
+
{STEPS.map((s, i) => {
const done = i < active;
const cur = i === active;
return (
-
-
{done ? '✓' : i + 1}
-
{s}
+
+
+ {done ? '✓' : i + 1}
+
+
+ {s}
+
- {i < STEPS.length - 1 &&
}
+ {i < STEPS.length - 1 && (
+
+ )}
);
})}
@@ -32,6 +54,15 @@ function Stepper({ active }) {
);
}
+/** The runner's shell — one surface, one padding, every step. */
+function Frame({ children, className }) {
+ return (
+
+ {children}
+
+ );
+}
+
export default function ChallengeRunner({ course, profile, onPassed }) {
const [step, setStep] = useState(0);
const [evaluating, setEvaluating] = useState(false);
@@ -78,62 +109,81 @@ export default function ChallengeRunner({ course, profile, onPassed }) {
// 0 — Learn
if (step === 0) {
return (
-
+
{!lock.unlocked ? (
<>
-
Locked — earn this one
-
Real work unlocks learning. Complete these first:
-
- {lock.reasons.map((r, i) => ( {r} ))}
+
+ Locked — earn this one
+
+ Real work unlocks learning. Complete these first:
+
+ {lock.reasons.map((r, i) => (
+
+
+ {r}
+
+ ))}
>
) : (
<>
-
-
-
-
Demonstration
-
{TYPE_LABEL[type]}
+
+
+
+
+
+
Demonstration
+
{TYPE_LABEL[type]}
-
{ch.prompt || course?.description}
-
setStep(1)} className="w-full h-11 rounded-xl bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[14px] font-semibold flex items-center justify-center gap-2">Show me
+
+ {ch.prompt || course?.description}
+
+
setStep(1)}>
+ Show me
+
>
)}
-
+
);
}
// Evaluating
if (evaluating) {
return (
-
+
-
-
Owliver is evaluating your proof…
-
Measuring whether you can actually perform.
-
+
+
+
Owliver is evaluating your proof…
+
Measuring whether you can actually perform.
+
+
);
}
// 1 — Demonstrate
if (step === 1) {
return (
-
+
-
-
-
-
Owliver
-
{ch.prompt || course?.description}
+ {/* The roleplay widget opens with this same prompt as its first message,
+ so repeating it above the transcript would say it twice. */}
+ {type !== 'roleplay' && (
+
+
+
+
Owliver
+
{ch.prompt || course?.description}
+
-
+ )}
{type === 'roleplay' &&
}
{type === 'photo' &&
}
{type === 'video' &&
}
{type === 'photo_identify' &&
}
-
+
);
}
@@ -141,69 +191,120 @@ export default function ChallengeRunner({ course, profile, onPassed }) {
if (step === 2) {
const rubric = result?.rubric || {};
return (
-
+
-
- {passed ?
:
}
-
-
{passed ? 'Verified by Owliver' : 'Needs work'}
-
AI confidence: {Math.round(evidence?.ai_score || 0)}/100
+
+ {passed
+ ?
+ :
}
+
+
{passed ? 'Verified by Owliver' : 'Needs work'}
+
+ AI confidence: {Math.round(evidence?.ai_score || 0)}/100
+
- {result?.feedback &&
{result.feedback}
}
+
+ {result?.feedback && (
+
{result.feedback}
+ )}
+
{Object.keys(rubric).length > 0 && (
-
+
{Object.entries(rubric).map(([k, v]) => (
-
{k.replace(/_/g, ' ')} {Math.round(v)}/100
-
+
+ {k.replace(/_/g, ' ')}
+ {Math.round(v)}/100
+
+
))}
)}
-
-
-
-
{supervisorVerified ? 'Supervisor verified' : 'Supervisor verification'}
-
{supervisorVerified ? 'A supervisor confirmed this demonstration in person.' : 'A supervisor can confirm this demonstration on the floor.'}
+
+
+
+
+
+ {supervisorVerified ? 'Supervisor verified' : 'Supervisor verification'}
+
+
+ {supervisorVerified
+ ? 'A supervisor confirmed this demonstration in person.'
+ : 'A supervisor can confirm this demonstration on the floor.'}
+
{!supervisorVerified ? (
-
{verify.isPending ? '…' : 'Verify'}
+
+ {verify.isPending ? '…' : 'Verify'}
+
) : (
-
+
)}
+
{passed ? (
-
setStep(3)} className="w-full h-11 rounded-xl bg-[#0A39DF] text-white text-[14px] font-semibold flex items-center justify-center gap-2">See your proof
+
setStep(3)}>
+ See your proof
+
) : (
-
setStep(1)} className="w-full h-10 rounded-xl border border-[#E5E7EB] text-[13px] font-semibold text-[#0F172A] hover:bg-[#F8FAFC]">Try the demonstration again
+
setStep(1)}>
+ Try the demonstration again
+
)}
-
+
);
}
// 3 — Proof
return (
-
+
-
-
-
-
Proof stored
-
Capability: {course.proof_skill || course.title}
+
+
+
+
+
+
Proof stored
+
Capability: {course.proof_skill || course.title}
-
-
AI verdict {evidence?.ai_verdict}
-
Score {Math.round(evidence?.ai_score || 0)}/100
-
Supervisor {supervisorVerified ? 'Verified' : 'Pending'}
-
Proof type {TYPE_LABEL[type]}
-
-
-
-
Employers can now see this proof in your Career Wallet. Every skill is backed by evidence.
-
-
setStep(0)} className="w-full h-10 rounded-xl border border-[#E5E7EB] text-[13px] font-semibold text-[#0F172A] hover:bg-[#F8FAFC]">Back to start
-
+
+
+ {[
+ { label: 'AI verdict', value: evidence?.ai_verdict, tone: 'text-success', capitalize: true },
+ { label: 'Score', value: `${Math.round(evidence?.ai_score || 0)}/100` },
+ { label: 'Supervisor', value: supervisorVerified ? 'Verified' : 'Pending' },
+ { label: 'Proof type', value: TYPE_LABEL[type] },
+ ].map((row) => (
+
+
{row.label}
+
+ {row.value}
+
+
+ ))}
+
+
+
+
+ Employers can now see this proof in your Career Wallet. Every skill is backed by evidence.
+
+
+
setStep(0)}>
+ Back to start
+
+
);
-}
\ No newline at end of file
+}
diff --git a/src/components/krow/proving/HazardIdentifier.jsx b/src/components/krow/proving/HazardIdentifier.jsx
index 33d450c..adaa565 100644
--- a/src/components/krow/proving/HazardIdentifier.jsx
+++ b/src/components/krow/proving/HazardIdentifier.jsx
@@ -38,43 +38,43 @@ export default function HazardIdentifier({ course, onSubmit, submitting }) {
return (
-
Tap anywhere on the kitchen photo to mark a hazard. Label each one, then submit for AI evaluation.
-
+
Tap anywhere on the kitchen photo to mark a hazard. Label each one, then submit for AI evaluation.
+
{imageUrl ? (
) : (
-
No scene image configured
+
No scene image configured
)}
{markers.map((m, i) => (
e.stopPropagation()}>
- {i + 1}
- {m.label}
- remove(i)} className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-white text-red-500 shadow flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
+ {i + 1}
+ {m.label}
+ remove(i)} className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-white text-destructive shadow flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
))}
{pending && (
-
+
)}
{pending && (
-
-
Label this hazard:
+
+
Label this hazard:
{PRESET_TAGS.map((t) => (
- confirm(t)} className="text-[11px] px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB] text-[#374151] hover:border-[#0A39DF]">{t}
+ confirm(t)} className="text-[11px] px-2.5 py-1 rounded-full bg-white border border-border text-ink-2 hover:border-krow-blue">{t}
))}
)}
-
onSubmit(markers)} disabled={markers.length === 0 || submitting || !!pending} className="w-full h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40">
+ onSubmit(markers)} disabled={markers.length === 0 || submitting || !!pending} className="w-full h-10 rounded-xl bg-krow-navy text-white text-[13px] font-semibold disabled:opacity-40">
Submit {markers.length} hazard{markers.length !== 1 ? 's' : ''} for AI evaluation
diff --git a/src/components/krow/proving/MediaChallenge.jsx b/src/components/krow/proving/MediaChallenge.jsx
index ac4b875..fa2c96a 100644
--- a/src/components/krow/proving/MediaChallenge.jsx
+++ b/src/components/krow/proving/MediaChallenge.jsx
@@ -28,11 +28,11 @@ export default function MediaChallenge({ course, onSubmit, submitting }) {
return (
-
+
{type === 'video' ? 'Record or upload a short video demonstrating the challenge.' : 'Take or upload a photo showing your work.'}
{mediaUrl ? (
-
+
{type === 'video' ? (
) : (
@@ -40,13 +40,13 @@ export default function MediaChallenge({ course, onSubmit, submitting }) {
)}
) : (
-
+
{uploading ? (
-
+
) : (
<>
-
- Tap to {type === 'video' ? 'record' : 'capture'}
+
+ Tap to {type === 'video' ? 'record' : 'capture'}
>
)}
@@ -54,12 +54,12 @@ export default function MediaChallenge({ course, onSubmit, submitting }) {
)}
{mediaUrl && (
-
setMediaUrl('')} className="h-10 px-4 rounded-xl border border-[#E5E7EB] text-[13px] font-medium text-[#64748B]">Retake
+
setMediaUrl('')} className="h-10 px-4 rounded-xl border border-border text-[13px] font-medium text-ink-3">Retake
)}
onSubmit(mediaUrl)}
disabled={!mediaUrl || uploading || submitting}
- className="flex-1 h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40"
+ className="flex-1 h-10 rounded-xl bg-krow-navy text-white text-[13px] font-semibold disabled:opacity-40"
>
Submit proof for AI evaluation
diff --git a/src/components/krow/proving/RoleplayChallenge.jsx b/src/components/krow/proving/RoleplayChallenge.jsx
index ac899e3..99a7139 100644
--- a/src/components/krow/proving/RoleplayChallenge.jsx
+++ b/src/components/krow/proving/RoleplayChallenge.jsx
@@ -39,8 +39,8 @@ export default function RoleplayChallenge({ course, onSubmit, submitting }) {
-
Owliver
-
Your AI skills coach
+
Owliver
+
Your AI skills coach
@@ -49,8 +49,8 @@ export default function RoleplayChallenge({ course, onSubmit, submitting }) {
return (
);
@@ -64,16 +64,16 @@ export default function RoleplayChallenge({ course, onSubmit, submitting }) {
onKeyDown={(e) => e.key === 'Enter' && send()}
disabled={submitting || busy}
placeholder="Type your response…"
- className="flex-1 h-9 bg-transparent px-1 text-[13px] focus:outline-none placeholder:text-[#9CA3AF]"
+ className="flex-1 h-9 bg-transparent px-1 text-[13px] focus:outline-none placeholder:text-ink-4"
/>
-
+
{busy ? : }
onSubmit(transcript)}
disabled={!hasResponded || submitting || busy}
- className="mt-3 w-full h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40"
+ className="mt-3 w-full h-10 rounded-xl bg-krow-navy text-white text-[13px] font-semibold disabled:opacity-40"
>
Submit proof for AI evaluation
diff --git a/src/components/krow/proving/VideoRecorder.jsx b/src/components/krow/proving/VideoRecorder.jsx
index 0d61338..0ebef98 100644
--- a/src/components/krow/proving/VideoRecorder.jsx
+++ b/src/components/krow/proving/VideoRecorder.jsx
@@ -72,8 +72,8 @@ export default function VideoRecorder({ onSubmit, submitting }) {
return (
- {error &&
{error}
}
-
+ {error &&
{error}
}
+
{recordedUrl ? (
) : (
@@ -81,19 +81,19 @@ export default function VideoRecorder({ onSubmit, submitting }) {
)}
{recording && (
-
+
{seconds}s
)}
{!recordedUrl ? (
-
+
{recording ? <> Stop recording> : <> Record yourself>}
) : (
- Retake
-
+ Retake
+
{uploading ? : null} Submit proof for AI evaluation
diff --git a/src/pages/admin/Candidates.jsx b/src/pages/admin/Candidates.jsx
index 50315f7..4ef7719 100644
--- a/src/pages/admin/Candidates.jsx
+++ b/src/pages/admin/Candidates.jsx
@@ -1,6 +1,5 @@
import React, { useMemo, useState } from 'react';
-import { Brain } from 'lucide-react';
-import { Button, SearchInput, toast } from '@/components/ds';
+import { Button, SearchInput } from '@/components/ds';
import { useApplications, useInterviews, useJobPostings } from '@/lib/krowHooks';
import { AdminPage, Toolbar } from '@/pages/admin/_shell';
import { FilterSelect } from '@/pages/admin/Positions';
@@ -43,8 +42,8 @@ export default function AdminCandidates() {
.filter((a) => {
if (q && !`${a.applicant_name} ${a.email} ${a.job_title} ${(a.skills || []).join(' ')}`.toLowerCase().includes(q)) return false;
if (position !== 'all' && a.job_title !== position) return false;
- if (stage !== 'all' && a.status !== stage) return false;
- if (!SCORE_BANDS[band](a.ai_score || 0)) return false;
+ if (stage !== 'all' && a.stage !== stage) return false;
+ if (band !== 'all' && !SCORE_BANDS[band](a.ai_score)) return false;
return true;
})
.sort(SORTS[sort].compare);
@@ -60,11 +59,6 @@ export default function AdminCandidates() {
toast.info('Batch screening is disabled in the demo')}>
- Screen unscored
-
- }
>
}
diff --git a/src/pages/admin/TalentPool.jsx b/src/pages/admin/TalentPool.jsx
index 21c940a..a993d52 100644
--- a/src/pages/admin/TalentPool.jsx
+++ b/src/pages/admin/TalentPool.jsx
@@ -1,8 +1,8 @@
import React, { useMemo, useState } from 'react';
-import { Bookmark, GitCompare, Star, UserRound } from 'lucide-react';
+import { Bookmark, Star, UserRound } from 'lucide-react';
+import { cn } from '@/lib/utils';
import {
- Avatar, Badge, Button, DataTable, IconButton, Modal, ProgressBar, ProgressRing,
- SearchInput, Surface, toast,
+ Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
} from '@/components/ds';
import { useWorkerProfiles } from '@/lib/krowHooks';
import { getScoreBand, toFICO } from '@/lib/talentHome';
@@ -44,7 +44,6 @@ export default function AdminTalentPool() {
const [availability, setAvailability] = useState('all');
const [sort, setSort] = useState('score');
const [saved, setSaved] = useState([]);
- const [compareOpen, setCompareOpen] = useState(false);
const skills = useMemo(
() => [...new Set(profiles.flatMap((p) => p.skills || []))].sort(),
@@ -109,15 +108,6 @@ export default function AdminTalentPool() {
}));
}, [profiles]);
- const maxSegment = Math.max(...segments.map((s) => s.count), 1);
-
- /* Whoever is saved, side by side. Saving is the selection mechanism, so the
- comparison needs no second one. */
- const savedProfiles = useMemo(
- () => profiles.filter((p) => saved.includes(p.id)).sort((a, b) => (b.krow_score || 0) - (a.krow_score || 0)),
- [profiles, saved]
- );
-
const toggleSave = (id, name) => {
setSaved((prev) => {
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id];
@@ -130,17 +120,6 @@ export default function AdminTalentPool() {
setCompareOpen(true)}
- >
- Compare{saved.length ? ` (${saved.length})` : ''}
-
- }
>
{/* Segments — the pool's shape, and how much of each band is available. */}
@@ -149,41 +128,62 @@ export default function AdminTalentPool() {
title="Talent segments"
meta={`${profiles.length} in the pool`}
/>
-
-
- {segments.map((s) => (
-
-
-
- {s.label} {s.hint}
-
-
- {s.count > 0 && (
- <>
- {s.available} available
- {s.avgExperience > 0 && {s.avgExperience}y avg }
- >
+ {/* Five compact cards, one per band, across the page's content width — the
+ same measure the directory below uses, so the two sections share a left
+ and right edge. A band is read one at a time, so each gets its own small
+ surface with the count directly above the detail that qualifies it. */}
+
- ))}
-
-
+
+
+
+ {s.count}
+
+
+
+ {empty ? 'No profiles' : [
+ `${s.available} available`,
+ actionable ? 'needs verification' : s.avgExperience > 0 ? `${s.avgExperience}y avg` : null,
+ ].filter(Boolean).join(' · ')}
+
+
+ );
+ })}
+
+
+
{segments[4].count > segments[0].count + segments[1].count
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}
-
+
@@ -302,83 +302,6 @@ export default function AdminTalentPool() {
},
]}
/>
-
- {/* Saved talent, side by side. A table, because the value is reading the
- same five fields across people — anything more elaborate would be a
- second product. */}
-
-
-
-
-
- {['Talent', 'Career score', 'Experience', 'Skills', 'Availability'].map((h) => (
-
- {h}
-
- ))}
-
-
-
- {savedProfiles.map((p) => (
-
-
-
-
-
-
{p.full_name}
-
- {p.desired_position || p.current_position || '—'}
-
-
-
-
-
- {p.krow_score ? (
-
- {toFICO(p.krow_score)}
-
- {getScoreBand(p.krow_score).label}
-
-
- ) : (
- Not scored
- )}
-
-
- {p.experience_years ? `${p.experience_years}y` : '—'}
-
-
- {p.skills?.length ? (
-
- {p.skills[0]}
- {p.skills.length > 1 && (
- +{p.skills.length - 1}
- )}
-
- ) : — }
-
-
- {p.availability?.length ? p.availability.join(', ') : (
- Not set
- )}
-
-
- ))}
-
-
-
-
);
}