update position page design

This commit is contained in:
2026-08-13 21:41:14 +05:30
parent 6340962907
commit 1c653264b5
124 changed files with 5746 additions and 596 deletions

View File

@@ -37,7 +37,7 @@ export function ThinkingIndicator() {
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
* column on padding around them.
*/
export const Message = React.memo(({ role, text, blocks, streaming, stopped }) => {
export const Message = React.memo(({ role, text, blocks, streaming, stopped, onPrompt }) => {
if (role === 'user') {
return (
<div className="flex justify-end">
@@ -57,7 +57,7 @@ export const Message = React.memo(({ role, text, blocks, streaming, stopped }) =
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
</div>
<ResponseDocument blocks={blocks} streaming={streaming} />
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} />
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
</div>

View File

@@ -5,7 +5,10 @@ import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import { useCreateJobPosting, usePreferences, useRoleCategories } from '@/lib/krowHooks';
import {
useAssignments, useAssignWorkers, useCreateJobPosting, useMarkInterviewReady,
usePreferences, useRoleCategories,
} from '@/lib/krowHooks';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
import { runAction } from '@/lib/skills/actions';
@@ -114,12 +117,67 @@ export default function KrowAssistant({
return createJob.mutateAsync(result.data);
}, [createJob]);
/**
* The workforce picture the panel reasons over.
*
* The same collections the Positions page reads, so a recommendation and the
* card behind it are looking at one moment. Assembled here rather than in the
* routing layer because this is where the app's data already is; routing stays
* pure and testable.
*/
const { data: assignments = [] } = useAssignments();
const workforce = React.useMemo(() => ({
positions: facts.postings || [],
currentPositionId: null,
context: {
profiles: facts.profiles || [],
applications: facts.applications || [],
assignments,
courses: facts.forge?.library || [],
staff: facts.staff || [],
},
}), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments]);
/**
* The workforce write, through the mutation the app already has.
*
* `useAssignWorkers` creates the assignment, moves the application and logs
* the activity — one path, reused rather than reimplemented. It only ever runs
* on a turn the admin explicitly confirmed.
*/
const assignWorkers = useAssignWorkers();
const onAssignWorkers = React.useCallback(async (plan) => {
const created = await assignWorkers.mutateAsync({
position: plan.position,
workers: plan.workers,
applications: facts.applications || [],
});
return created?.length ? created : null;
}, [assignWorkers, facts.applications]);
/**
* Move an assigned candidate to interview stage.
*
* The status transition the Position and Candidates pages already perform —
* not a new interview record. `AIInterviewModal` writes the AIInterview when
* the interview is actually conducted, and creating an empty one here would be
* a second interview store claiming something that has not happened.
*/
const markInterviewReady = useMarkInterviewReady();
const onScheduleInterview = React.useCallback(
(plan) => markInterviewReady.mutateAsync({ application: plan.application, position: plan.position }),
[markInterviewReady]
);
const { messages, pending, error, busy, send, stop, reset } = useConversation({
contextId: context.id,
facts,
onNavigate: goToPage,
onAction: performAction,
onCreatePosition: createPosition,
onAssignWorkers,
onScheduleInterview,
workforce,
disabledSkills,
customSkills,
roles,
@@ -127,6 +185,13 @@ export default function KrowAssistant({
courses: facts.forge?.library || [],
});
/* A block inside an answer asking the next question, in place. Same entry
point as the composer and the chips, so an in-panel action is an ordinary
turn rather than a second way for the panel to change. */
const askOwliver = React.useCallback((question) => {
if (!busy) send({ question });
}, [busy, send]);
const [input, setInput] = React.useState('');
const scrollRef = React.useRef(null);
const isEmpty = messages.length === 0 && !pending;
@@ -146,8 +211,8 @@ export default function KrowAssistant({
const skillPrompts = skills
.filter((s) => s.prompt)
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
return [...skillPrompts, ...buildPrompts(context.id, facts)];
}, [followUp, skills, context.id, facts]);
return [...skillPrompts, ...buildPrompts(context.id, facts, workforce)];
}, [followUp, skills, context.id, facts, workforce]);
/* Follow the newest content. Direct `scrollTop` rather than smooth scrolling:
at streaming frequency a smooth scroll never catches up and the thread
@@ -281,6 +346,7 @@ export default function KrowAssistant({
text={message.text}
blocks={message.blocks}
stopped={message.stopped}
onPrompt={askOwliver}
/>
</React.Fragment>
))}

View File

@@ -1,5 +1,6 @@
import * as React from 'react';
import { ArrowDownRight, ArrowUpRight, Check, TriangleAlert } from 'lucide-react';
import { Link } from 'react-router-dom';
import { ArrowDownRight, ArrowUpRight, Check, ChevronRight, TriangleAlert } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
@@ -269,20 +270,63 @@ const ListBlock = React.memo(({ block }) => {
});
ListBlock.displayName = 'ListBlock';
/** Findings, each in a tinted card so severity is scannable. */
const InsightsBlock = React.memo(({ block }) => (
/**
* Findings, and — where the item carries one — a way to act on it.
*
* Two kinds of action, and the distinction is the point:
*
* `prompt` asks Owliver the next question, in place. The panel stays open,
* the thread keeps its history, and the reader never loses the
* position they were working on. This is the default for a
* recommended person: inspecting a candidate is part of the
* conversation, not a trip to another page.
* `to` leaves for a route the app already has. Reserved for the explicit
* "open the full record" case.
*
* `hint` is for an item that can do neither — it says why, instead of looking
* clickable and doing nothing.
*/
const InsightsBlock = React.memo(({ block, onPrompt }) => (
<div className="space-y-2">
{block.items.map((item, i) => (
<div
key={i}
className={cn('rounded-xl border px-3 py-2.5', TONE_SURFACE[item.tone] || TONE_SURFACE.neutral)}
>
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
{item.body && (
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
)}
</div>
))}
{block.items.map((item, i) => {
const surface = cn(
'block w-full text-left rounded-xl border px-3 py-2.5',
TONE_SURFACE[item.tone] || TONE_SURFACE.neutral
);
const interactive = cn(
surface,
'transition-colors hover:border-krow-blue/50',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50'
);
const actionable = Boolean(item.prompt && onPrompt) || Boolean(item.to);
const body = (
<>
<p className="flex items-center gap-1 text-body-sm font-semibold text-ink-1">
{item.title}
{actionable && <ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden="true" />}
</p>
{item.body && (
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
)}
{item.hint && (
<p className="mt-1 text-[11px] italic leading-relaxed text-ink-4">{item.hint}</p>
)}
</>
);
if (item.prompt && onPrompt) {
return (
<button key={i} type="button" onClick={() => onPrompt(item.prompt)} className={interactive}>
{body}
</button>
);
}
if (item.to) {
return <Link key={i} to={item.to} className={interactive}>{body}</Link>;
}
return <div key={i} className={surface}>{body}</div>;
})}
</div>
));
InsightsBlock.displayName = 'InsightsBlock';
@@ -381,7 +425,7 @@ const RENDERERS = {
* blocks has consistent rhythm — a heading hugs what follows it, everything
* else breathes.
*/
export const ResponseDocument = React.memo(({ blocks = [], streaming = false }) => (
export const ResponseDocument = React.memo(({ blocks = [], streaming = false, onPrompt }) => (
<div className="space-y-3">
{blocks.map((block, i) => {
const Renderer = RENDERERS[block.type];
@@ -391,7 +435,7 @@ export const ResponseDocument = React.memo(({ blocks = [], streaming = false })
return (
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
<Renderer block={block} />
<Renderer block={block} onPrompt={onPrompt} />
{/* The cursor trails the final block only while text is still arriving. */}
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
<span

View File

@@ -47,6 +47,14 @@ export const list = (items, options = {}) =>
items?.filter(Boolean).length && { type: 'list', items: items.filter(Boolean), ...options };
/** Findings. `items: [{ tone, title, body }]` — tone: info|success|warning|risk */
/**
* Findings. `items: [{ title, body, tone?, to?, hint? }]`
*
* `to` makes the card a link into the app — a recommended person opening their
* own record, rather than a name the reader has to go and look up. It is a route
* the app already has; nothing here invents an address, and an item without one
* renders exactly as it always did.
*/
export const insights = (items) =>
items?.filter(Boolean).length && { type: 'insights', items: items.filter(Boolean) };

View File

@@ -1,4 +1,5 @@
import { plural, verb } from './insights';
import { prioritise, workforceStatusFor } from '@/lib/workforce';
import { PERMISSIONS } from '@/pages/admin/permissions';
/**
@@ -390,18 +391,71 @@ const PROMPTS = {
{ label: 'Identify operational risks', prompt: 'Identify operational risks and what I should fix first', capability: 'recommendations' },
],
'admin.positions': (f) => [
f.starvedPositions.length
? { label: `${f.starvedPositions.length} roles need attention`, prompt: 'What needs attention across my positions?', capability: 'positions-attention' }
: { label: 'What needs attention?', prompt: 'What needs attention across my positions?', capability: 'positions-attention' },
{ label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' },
f.unscreened.length
? { label: `${f.unscreened.length} waiting for review`, prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' }
: { label: 'Show candidates waiting for review', prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' },
{ label: 'Which position should I hire for first?', prompt: 'Which position should I hire for first?', capability: 'hiring-priority' },
{ label: 'Summarize hiring activity', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' },
{ label: 'Identify hiring bottlenecks', prompt: 'Identify hiring bottlenecks', capability: 'pipeline-health' },
],
/**
* Positions suggestions are the current workforce situation, phrased as
* questions.
*
* Each one is offered only when the data behind it exists — a prompt about
* strong matches on a board with none is an invitation to a dead end. None of
* them names a person: the question is asked generically and the candidates
* are resolved live, so a suggestion cannot go stale between being rendered
* and being clicked.
*
* Workforce prompts carry no `capability`, which is what sends them through
* intent routing to the engine rather than to a page analytics responder.
*/
'admin.positions': (f, workforce) => {
const out = [];
if (workforce?.positions?.length) {
const { positions, context } = workforce;
const ranked = prioritise(positions, context);
const board = positions
.filter((p) => p.status === 'active')
.map((p) => ({ position: p, status: workforceStatusFor(p, context) }));
const withStrong = board.filter((r) => r.status.strong.length);
const newToday = board.reduce((n, r) => n + r.status.newToday.length, 0);
const readyCount = board.reduce(
(n, r) => n + r.status.applicants.filter(
(a) => ['ai_screened', 'shortlisted'].includes(a.application.status)
).length,
0
);
if (withStrong.length) {
const best = withStrong.sort((a, b) => b.status.strong.length - a.status.strong.length)[0];
out.push({
label: `${best.status.strong.length} strong for ${best.position.title}`,
prompt: `Who matches ${best.position.title}?`,
});
}
if (ranked.length) {
out.push({ label: 'Which position needs people first?', prompt: 'Which position needs people first?' });
}
if (newToday) {
out.push({ label: `${newToday} applied today`, prompt: 'Who applied today?' });
}
if (readyCount) {
out.push({ label: `${readyCount} ready for interview`, prompt: 'Who is ready for interview?' });
}
}
/* The page's own analytics questions keep their place behind the workforce
ones — they are still the right answer to "how is hiring going". */
out.push(
f.starvedPositions.length
? { label: `${f.starvedPositions.length} roles need attention`, prompt: 'What needs attention across my positions?', capability: 'positions-attention' }
: { label: 'What needs attention?', prompt: 'What needs attention across my positions?', capability: 'positions-attention' },
{ label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' },
f.unscreened.length
? { label: `${f.unscreened.length} waiting for review`, prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' }
: { label: 'Show candidates waiting for review', prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' },
{ label: 'Summarize hiring activity', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' }
);
return out;
},
'admin.candidatesList': (f) => [
f.stalled.length
@@ -548,6 +602,6 @@ export function buildIntro(contextId, facts, userName) {
}
/** Suggested prompts for the current page and data. */
export function buildPrompts(contextId, facts) {
return PROMPTS[contextId]?.(facts) ?? [];
export function buildPrompts(contextId, facts, workforce = null) {
return PROMPTS[contextId]?.(facts, workforce) ?? [];
}

View File

@@ -1,6 +1,14 @@
import { doc, list, note, text } from './blocks';
import { doc, insights, list, note, text } from './blocks';
import { ASSISTANT_CONTEXTS } from './contexts';
import { poolFor } from '@/lib/workforce';
import { matchSkill } from '@/lib/skills/registry';
import {
appliedToday, askWhichPosition, assignmentPreview, assignmentToExecute, availability,
candidateDetail, candidateMatches, candidateRoute, interviewReady, matchWorkforceIntent,
namedAssignmentPreview,
interviewPreview, interviewToExecute,
namedAssignmentToExecute, positionPriority, resolveCandidate, resolvePosition,
} from '@/lib/skills/workforceFlow';
import {
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
extractSkillName, findCourseByName,
@@ -168,6 +176,168 @@ export function outOfScopeAnswer(context) {
* { kind: 'navigate', destination, doc } — other page; go there
* { kind: 'outOfScope', doc } — nothing here can answer it
*/
/* ── Workforce ──────────────────────────────────────────────────────────── */
/** The pages that carry enough context to answer a workforce question. */
const WORKFORCE_CONTEXTS = new Set(['admin.positions']);
/**
* A workforce question, resolved against the engine.
*
* Reading questions return their answer directly. The two that lead to a write
* are shaped differently on purpose:
*
* `assign` builds a preview and returns it as a normal answer, with a
* confirmation offered as a follow-up. Nothing is written.
* `confirm_assign` re-derives the plan from live data and hands it back as
* `{ kind: 'workforce', assign }` for `useAssistant` to
* execute through the existing mutation — the same shape the
* position-creation flow already uses.
*
* A question naming no position, on a page listing many, asks which rather than
* choosing one.
*/
function resolveWorkforce(question, workforce) {
const intent = matchWorkforceIntent(question);
if (!intent) return null;
const { positions = [], context = {}, currentPositionId = null } = workforce;
const position = resolvePosition(question, positions, currentPositionId);
/* Board-wide questions answer without a position. */
if (intent === 'priority') {
return { kind: 'answer-doc', ...positionPriority(positions, context) };
}
if (intent === 'applied_today') {
return { kind: 'answer-doc', ...appliedToday(position, context, positions) };
}
if (intent === 'interview_ready') {
return { kind: 'answer-doc', ...interviewReady(position, context, positions) };
}
/* Everything below is about one role, and guessing which is not acceptable. */
if (!position) return { kind: 'answer-doc', ...askWhichPosition(positions) };
if (intent === 'matches') return { kind: 'answer-doc', ...candidateMatches(position, context) };
if (intent === 'availability') return { kind: 'answer-doc', ...availability(position, context) };
/**
* Inspecting, explaining and opening a record are all about one person, so
* they resolve the name first and say so plainly when they cannot.
*/
if (intent === 'candidate_detail' || intent === 'eligibility' || intent === 'open_profile') {
const found = resolveCandidate(question, poolFor(position, context));
if (found.ambiguous) {
return {
kind: 'answer-doc',
doc: doc(text('More than one person matches that name.'), list(found.ambiguous.map((r) => r.name))),
followUp: found.ambiguous.slice(0, 4).map((r) => ({
label: r.name, prompt: `Show ${r.name} for ${position.title}`,
})),
};
}
if (!found.row) {
return { kind: 'answer-doc', ...candidateMatches(position, context) };
}
/* The one case that deliberately leaves the panel — and only because the
admin asked for the full record by name. */
if (intent === 'open_profile') {
const to = candidateRoute(found.row, context.applications || [], position);
return {
kind: 'answer-doc',
doc: to
? doc(
text(`Opening the full record for **${found.row.name}**.`),
insights([{ tone: 'info', title: `${found.row.name} — full profile`, body: 'Match analysis, reputation, experience, endorsements and activity.', to }])
)
: doc(
text(`**${found.row.name}** has no application on file, so there is no candidate record to open.`),
note('Assigning them creates one, and the full profile becomes available after that.')
),
followUp: [{ label: '← Back to candidate', prompt: `Show ${found.row.name} for ${position.title}` }],
};
}
return { kind: 'answer-doc', ...candidateDetail(position, found.row, context) };
}
/**
* A named person is a different request from "the best available": the admin
* has already chosen, and the job is to validate that choice rather than to
* rank. Both preview and confirmation check for a name first, so
* "assign Maria" and "confirm assignment of Maria" stay one conversation.
*/
const named = (intent === 'assign' || intent === 'confirm_assign')
? resolveCandidate(question, poolFor(position, context))
: {};
if (named.ambiguous) {
return {
kind: 'answer-doc',
doc: doc(
text('More than one person matches that name.'),
list(named.ambiguous.map((r) => r.name)),
note('Give me the full name and I will check them against this role.')
),
followUp: named.ambiguous.slice(0, 4).map((r) => ({
label: r.name,
prompt: `Assign ${r.name} to ${position.title}`,
})),
};
}
if (intent === 'assign') {
return named.row
? { kind: 'answer-doc', ...namedAssignmentPreview(position, named.row, context) }
: { kind: 'answer-doc', ...assignmentPreview(position, context) };
}
/**
* Interview setup, for one named person against this role. Both steps resolve
* the name the same way assignment does, so the conversation stays on the same
* candidate without the admin repeating themselves.
*/
if (intent === 'interview_setup' || intent === 'confirm_interview') {
const found = resolveCandidate(question, poolFor(position, context));
if (!found.row) {
return { kind: 'answer-doc', ...interviewReady(position, context, positions) };
}
if (intent === 'interview_setup') {
return { kind: 'answer-doc', ...interviewPreview(position, found.row, context) };
}
const plan = interviewToExecute(position, found.row, context);
if (!plan) {
/* Re-checked at confirmation: if it can no longer proceed, say why rather
than reporting a step that did not happen. */
return { kind: 'answer-doc', ...interviewPreview(position, found.row, context) };
}
return { kind: 'workforce', interview: plan };
}
if (intent === 'confirm_assign') {
/* Re-derived from live data at the moment of confirmation, so the people
written are the people who are still eligible. */
const plan = named.row
? namedAssignmentToExecute(position, named.row, context)
: assignmentToExecute(position, context);
if (!plan) {
/* The preview no longer holds — say so rather than writing something
different from what was agreed to. */
return named.row
? { kind: 'answer-doc', ...namedAssignmentPreview(position, named.row, context) }
: { kind: 'answer-doc', ...assignmentPreview(position, context) };
}
return { kind: 'workforce', assign: plan };
}
return null;
}
/* ── Forge authoring ────────────────────────────────────────────────────── */
/** The five steps the Forge authoring flow actually walks through. */
@@ -368,7 +538,7 @@ function resolveSkill({ question, contextId, disabledSkills, customSkills, roles
export function resolveIntent({
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
courses = [],
courses = [], workforce = null,
}) {
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
@@ -378,6 +548,18 @@ export function resolveIntent({
});
if (skill) return skill;
/**
* 3. Workforce questions, on the pages that hold workforce context.
*
* Ahead of the page responder because these are the same words it answers
* with analytics — "which position needs people" would otherwise return a
* pipeline report rather than the ranked answer the engine can give.
*/
const workforceIntent = workforce && WORKFORCE_CONTEXTS.has(contextId)
? resolveWorkforce(question, workforce)
: null;
if (workforceIntent) return workforceIntent;
/* The page you are on wins. Several subjects live on more than one page —
departments are on both Analytics and Hired History — and moving someone
off a page that can already answer them is worse than answering. Routing is

View File

@@ -9,6 +9,9 @@ import { skillsForContext } from '@/lib/skills/registry';
import {
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
} from '@/lib/skills/positionFlow';
import {
assignmentDone, assignmentFailed, assignmentFollowUp, interviewDone, interviewFailed,
} from '@/lib/skills/workforceFlow';
import { buildFacts } from './insights';
import { createAssistantProvider } from './provider';
import { resolveIntent } from './routing';
@@ -74,14 +77,33 @@ export function useCurrentUserName() {
*/
async function streamDocument({ document, signal, onFrame, onDone }) {
const snapshots = toSnapshots(document);
const complete = snapshots[snapshots.length - 1] || [];
let latest = [];
let interrupted = false;
for (const snapshot of snapshots) {
if (signal?.aborted) break;
if (signal?.aborted) { interrupted = true; break; }
latest = snapshot;
onFrame(snapshot);
await new Promise((resolve) => setTimeout(resolve, 26));
}
if (latest.length) onDone(latest);
/**
* An interrupted local document still settles complete.
*
* These documents are already fully computed before the first frame — the
* reveal is presentation, not generation. So an interruption should not leave
* a half-written sentence in the thread the way stopping a real generation
* would: there is nothing partial about the answer, only about the animation.
*
* This matters most after a write. The mutation refreshes every query it
* touched, the panel re-renders on the new data, and the reveal can be cut
* short — leaving the confirmation of something that definitely happened
* truncated mid-word.
*/
if (interrupted) onDone(complete);
else if (latest.length) onDone(latest);
}
/**
@@ -97,7 +119,8 @@ async function streamDocument({ document, signal, onFrame, onDone }) {
* routing applies to both without either knowing it exists.
*/
export function useConversation({
contextId, facts, onNavigate, onAction, onCreatePosition, disabledSkills = [], customSkills = [],
contextId, facts, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview,
workforce = null, disabledSkills = [], customSkills = [],
roles = [], skillCategories = [], courses = [],
}) {
const storageKey = `krow_assistant:${contextId}`;
@@ -200,7 +223,8 @@ export function useConversation({
intent = capability
? { kind: 'answer' }
: resolveIntent({
question: text, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
courses, workforce,
});
}
@@ -235,6 +259,51 @@ export function useConversation({
}
}
/**
* The workforce write, on the same terms as the one above: it happens
* before the reply, because the reply states the new counts and those have
* to be true when they are read.
*
* `intent.assign` only exists on a turn the admin explicitly confirmed, and
* the plan inside it was re-derived from live data at that moment — so this
* writes what was agreed to or reports that it could not.
*/
if (intent.kind === 'workforce' && intent.assign) {
let done = null;
try {
done = await onAssignWorkers?.(intent.assign);
} catch {
done = null;
}
intent = {
...intent,
kind: 'answer-doc',
doc: done ? assignmentDone(intent.assign) : assignmentFailed(),
followUp: done ? assignmentFollowUp(intent.assign) : undefined,
};
}
/**
* The interview step. Same contract as the assignment above: it runs before
* the reply, only on a confirmed turn, and through the app's existing
* status transition rather than a second interview store.
*/
if (intent.kind === 'workforce' && intent.interview) {
let moved = null;
try {
moved = await onScheduleInterview?.(intent.interview);
} catch {
moved = null;
}
intent = {
...intent,
kind: 'answer-doc',
doc: moved ? interviewDone(intent.interview) : interviewFailed(),
};
}
/* A skill that collects its input in the chat carries the state of that
collection on every turn — including the turn that starts it. */
if ('flow' in intent) setFlow(intent.flow);
@@ -300,7 +369,9 @@ export function useConversation({
setPending(null);
abortRef.current = null;
}
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, setFlow, disabledSkills,
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses]);
const stop = React.useCallback(() => abortRef.current?.abort(), []);

View File

@@ -12,11 +12,11 @@ import { BarChart3 } from 'lucide-react';
* the brand values are mirrored here — this array is the only place a chart
* should get a colour from.
*/
export const CHART_COLORS = ['#0A39DF', '#F9E547', '#333F48', '#F8E08E', '#D1E0D7', '#082EB4'];
export const CHART_COLORS = ['#0838E0', '#F9E547', '#333F48', '#F8E08E', '#D1E0D7', '#062BAF'];
export const CHART_TONES = {
brand: '#0A39DF',
brandDark: '#082EB4',
brand: '#0838E0',
brandDark: '#062BAF',
accent: '#F9E547',
accentPale: '#F8E08E',
navy: '#333F48',

View File

@@ -115,7 +115,7 @@ export function ProgressRing({
const resolvedTone = tone === 'score' ? toneForScore(ratio * 100) : tone;
const strokeColor = {
brand: '#0A39DF',
brand: '#0838E0',
navy: '#333F48',
accent: '#F9E547',
success: 'hsl(142 71% 36%)',

View File

@@ -43,7 +43,7 @@ const surfaceVariants = cva(
lg: 'p-6',
},
interactive: {
true: 'cursor-pointer hover:shadow-lg hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0A39DF]/60 focus-visible:ring-offset-2',
true: 'cursor-pointer hover:shadow-lg hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/60 focus-visible:ring-offset-2',
},
},
defaultVariants: {

View File

@@ -74,35 +74,67 @@ export function Toggle({
* SegmentedToggle — a small pill group for switching between 2–4 mutually
* exclusive views. Used where Tabs would be too heavy (chart ranges, EN/ES).
*/
export function SegmentedToggle({ options = [], value, onChange, size = 'default', className, ariaLabel }) {
/**
* Two treatments for the same control.
*
* `brand` states a choice that changes what the data *says* — a date range, a
* scope — so the selection carries the page's accent colour and reads as a
* filter. `surface` states a choice that changes only how the same data is
* *drawn*, so the selection is a raised white chip on a sunken track: present
* enough to show which mode is on, quiet enough not to compete with the real
* filters sitting beside it in the same toolbar.
*/
const SEGMENT_VARIANTS = {
brand: {
track: 'bg-surface-sunken border-border',
active: 'bg-krow-blue text-white shadow-sm',
inactive: 'text-ink-3 hover:text-ink-1',
},
surface: {
track: 'bg-surface-sunken border-border',
active: 'bg-surface text-ink-1 shadow-xs',
inactive: 'text-ink-4 hover:text-ink-2',
},
};
export function SegmentedToggle({
options = [], value, onChange, size = 'default', variant = 'brand', className, ariaLabel,
}) {
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
const tone = SEGMENT_VARIANTS[variant] || SEGMENT_VARIANTS.brand;
return (
<div
role="radiogroup"
aria-label={ariaLabel}
className={cn(
'inline-flex items-center gap-0.5 rounded-full bg-surface-sunken p-0.5 border border-border',
className
)}
className={cn('inline-flex items-center gap-0.5 rounded-full border p-0.5', tone.track, className)}
>
{normalized.map((option) => {
const active = option.value === value;
const Icon = option.icon;
/* Icon-only options carry their name on the button instead of beside
it, so the control stays compact without becoming unlabelled. */
const iconOnly = Boolean(Icon) && !option.label;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={active}
aria-label={option.ariaLabel || (iconOnly ? String(option.value) : undefined)}
title={option.ariaLabel}
onClick={() => onChange?.(option.value)}
className={cn(
'rounded-full font-medium transition-all duration-base ease-out focus-ring-inset',
size === 'sm' ? 'px-2.5 py-1 text-caption' : 'px-3 py-1.5 text-body-sm',
active
? 'bg-krow-blue text-white shadow-sm'
: 'text-ink-3 hover:text-ink-1'
'inline-flex items-center justify-center gap-1.5 rounded-full font-medium',
'transition-all duration-base ease-out focus-ring-inset',
iconOnly
? (size === 'sm' ? 'h-6 w-7' : 'h-7 w-8')
: (size === 'sm' ? 'px-2.5 py-1 text-caption' : 'px-3 py-1.5 text-body-sm'),
active ? tone.active : tone.inactive
)}
>
{Icon && <Icon className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} aria-hidden="true" />}
{option.label}
</button>
);

View File

@@ -1,14 +1,17 @@
import React from 'react';
import { BookOpen, Layers, ShieldCheck, Target } from 'lucide-react';
import { BookOpen, Layers, ShieldCheck, Target, TrendingUp } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Skill overview — the state of the library, in four figures.
* Skill overview — progression, in counted figures.
*
* Each figure counts records that exist: skills in the library, skills carrying
* training material, skills carrying a challenge, and verifications the
* workforce has actually earned. Nothing here is a target or a projection, so
* there is nothing on this strip that cannot be traced back to a row.
* Every figure is a count of records or of states derived from them: skills on
* the ladder, skills being worked on, levels held, levels one module away, and
* modules queued next. Nothing here is a target or a projection, so there is
* nothing on this strip that cannot be traced back to a row.
*
* The page supplies the figures and their order; this only styles them, which is
* why adding a fifth needed no change to the layout.
*/
const STAT_CONFIG = {
@@ -19,6 +22,34 @@ const STAT_CONFIG = {
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
accent: 'from-blue-600 to-indigo-600',
},
'Skills in progress': {
icon: BookOpen,
bg: 'bg-indigo-50 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
text: 'text-indigo-600 dark:text-indigo-400',
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
accent: 'from-indigo-500 to-purple-500',
},
'Skills verified': {
icon: ShieldCheck,
bg: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
text: 'text-emerald-600 dark:text-emerald-400',
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
accent: 'from-emerald-500 to-teal-500',
},
'Next level ready': {
icon: TrendingUp,
bg: 'bg-amber-50 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
text: 'text-amber-600 dark:text-amber-400',
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
accent: 'from-amber-500 to-orange-500',
},
'Recommended training': {
icon: Target,
bg: 'bg-violet-50 text-violet-600 dark:bg-violet-950/60 dark:text-violet-400',
text: 'text-violet-600 dark:text-violet-400',
border: 'border-violet-200/50 dark:border-violet-800/40 hover:border-violet-400',
accent: 'from-violet-500 to-fuchsia-500',
},
'Training modules': {
icon: BookOpen,
bg: 'bg-indigo-50 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
@@ -54,8 +85,9 @@ export function ForgeProgress({ figures = [], loading = false }) {
</div>
</div>
{/* 4 Modern Stat Cards */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
{/* Sized off this column rather than the viewport: with Owliver open the
page is wide while this strip is not. */}
<div className="grid grid-cols-2 gap-3.5 [grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr))]">
{figures.map((f) => {
const cfg = STAT_CONFIG[f.label] || STAT_CONFIG['Active skills'];
const Icon = cfg.icon;

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { ArrowRight, Clock, Sparkles, BookOpen, ShieldCheck, Flame, Wine, Users, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ds';
import { unlocksLabel } from '@/lib/skillGraph';
import {
DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, TYPE_ICON, TYPE_LABEL, trainingType, typeOf,
} from './challengeMeta';
@@ -41,7 +42,17 @@ function getCategoryMeta(category = '') {
};
}
export function SkillCard({ course, onOpen }) {
/**
* The state of this module for the person being viewed — the four states a
* training card can be in, named the way a learner would name them.
*/
const MODULE_STATE = {
completed: { label: 'Completed', className: 'bg-success-muted text-success border-success/30' },
available: { label: 'Available', className: 'bg-krow-blue-tint text-krow-blue border-krow-blue/30' },
locked: { label: 'Locked', className: 'bg-surface-sunken text-ink-4 border-border' },
};
export function SkillCard({ course, onOpen, moduleState }) {
const status = statusOf(course);
const type = typeOf(course);
const ProofIcon = TYPE_ICON[type] || TYPE_ICON.roleplay;
@@ -49,6 +60,7 @@ export function SkillCard({ course, onOpen }) {
const hasChallenge = Boolean(course.challenge);
const catMeta = getCategoryMeta(course.category);
const CatIcon = catMeta.icon;
const unlocks = unlocksLabel(course);
const isPublished = status === 'published';
@@ -74,16 +86,30 @@ export function SkillCard({ course, onOpen }) {
</span>
</div>
<Badge
variant={STATUS_TONE[status] || 'neutral'}
size="sm"
className={cn(
'shrink-0 font-bold text-[10px] shadow-2xs',
isPublished && 'bg-emerald-500/10 text-emerald-600 border-emerald-500/30 dark:bg-emerald-950/60 dark:text-emerald-400'
)}
>
{status}
</Badge>
{/* What this module is *to the reader* leads: completed, available or
locked. Publication state is an authoring fact and only shows when
there is no learner state to report. */}
{moduleState ? (
<span
className={cn(
'shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-bold',
(MODULE_STATE[moduleState] || MODULE_STATE.available).className
)}
>
{(MODULE_STATE[moduleState] || MODULE_STATE.available).label}
</span>
) : (
<Badge
variant={STATUS_TONE[status] || 'neutral'}
size="sm"
className={cn(
'shrink-0 font-bold text-[10px] shadow-2xs',
isPublished && 'bg-emerald-500/10 text-emerald-600 border-emerald-500/30 dark:bg-emerald-950/60 dark:text-emerald-400'
)}
>
{status}
</Badge>
)}
</div>
{/* Title & Difficulty Tag */}
@@ -143,7 +169,18 @@ export function SkillCard({ course, onOpen }) {
{/* Footer Divider & Action Button */}
<div className="relative z-10 mt-4 flex items-center justify-between gap-2 border-t border-border/60 pt-3 font-medium">
{course.proof_skill ? (
{/* What completing this does to your record — the reason to take it.
"Verifies <skill>" is the fallback for library material that no level
depends on, so those cards still say what they prove. */}
{unlocks ? (
<span
title={`Unlocks ${unlocks}`}
className="flex min-w-0 flex-1 items-center gap-1 truncate text-[10px] font-bold uppercase tracking-wider text-ink-4"
>
<CheckCircle2 className="h-3 w-3 shrink-0 text-emerald-500" />
<span className="truncate">Unlocks {unlocks}</span>
</span>
) : course.proof_skill ? (
<span
title={`Verifies ${course.proof_skill}`}
className="min-w-0 flex-1 truncate text-[10px] font-bold uppercase tracking-wider text-ink-4 flex items-center gap-1"

View File

@@ -38,6 +38,7 @@ export function SkillLibrary({
onOpen,
onCreate,
sectionRef,
moduleStateFor,
}) {
return (
<section aria-labelledby="skill-library" className="min-w-0" ref={sectionRef}>
@@ -132,7 +133,12 @@ export function SkillLibrary({
) : (
<div className={CARD_GRID}>
{results.map((course) => (
<SkillCard key={course.id} course={course} onOpen={onOpen} />
<SkillCard
key={course.id}
course={course}
onOpen={onOpen}
moduleState={moduleStateFor?.(course)}
/>
))}
</div>
)}

View File

@@ -0,0 +1,285 @@
import React from 'react';
import { ArrowRight, Check, Circle, Lock, ShieldCheck, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge, Button, Drawer, ProgressBar } from '@/components/ds';
import { levelLabel } from '@/lib/skillGraph';
/**
* Skill progression — a skill as a ladder rather than a pile of courses.
*
* The old Forge answered "what training exists". These answer the question a
* person actually arrives with: *where am I, and what is the next thing.* That
* is one rail (the levels), one number (progress to the next level) and one
* named module (the thing to do now) — and nothing else, because a progression
* view that lists everything is a course catalogue again.
*
* Nothing here derives anything. `lib/skillGraph.js` computes the state and
* these arrange it, which is what keeps the card, the drawer and the profile
* page from ever disagreeing about someone's level.
*/
const STATUS_META = {
verified: { label: 'Verified', variant: 'success' },
advancing: { label: 'Advancing', variant: 'soft' },
in_progress: { label: 'In progress', variant: 'warning' },
not_started: { label: 'Not started', variant: 'neutral' },
};
/**
* The level rail: ✓ earned, ● in progress, ○ not reached.
*
* Deliberately a single line. The distance between where someone is and the top
* of their ladder is the one thing this component exists to make obvious, and a
* stack of rows makes that a reading exercise instead of a glance.
*/
export function SkillLadder({ state, className }) {
return (
<ol className={cn('flex items-start', className)} aria-label={`${state.name} levels`}>
{state.levels.map((rung, i) => {
const earned = Boolean(rung.earned);
const current = !earned && state.nextLevel === rung.level;
const started = current && rung.completed.length > 0;
return (
<li key={rung.level} className="flex min-w-0 flex-1 flex-col items-center gap-1.5">
<div className="flex w-full items-center">
{/* Connectors are drawn by the rungs either side, so the rail is
continuous without a separate track element to keep aligned. */}
<span className={cn('h-px flex-1', i === 0 ? 'bg-transparent' : earned ? 'bg-success' : 'bg-border')} aria-hidden="true" />
<span
className={cn(
'grid h-5 w-5 shrink-0 place-items-center rounded-full border text-[10px] font-bold',
earned && 'border-success bg-success text-white',
current && 'border-krow-blue bg-krow-blue-tint text-krow-blue',
!earned && !current && 'border-border bg-surface text-ink-4'
)}
>
{earned ? <Check className="h-3 w-3" /> : current ? <Circle className="h-2 w-2 fill-current" /> : <Circle className="h-2 w-2" />}
</span>
<span
className={cn(
'h-px flex-1',
i === state.levels.length - 1 ? 'bg-transparent' : state.levels[i + 1]?.earned ? 'bg-success' : 'bg-border'
)}
aria-hidden="true"
/>
</div>
<span
className={cn(
'w-full truncate text-center text-[10px] font-semibold uppercase tracking-wide',
earned ? 'text-success' : current ? 'text-krow-blue' : 'text-ink-4'
)}
>
{rung.label}
</span>
{started && (
<span className="text-[10px] tabular-nums text-ink-4">
{rung.completed.length}/{rung.modules.length}
</span>
)}
</li>
);
})}
</ol>
);
}
/** One skill: where this person stands on it, and the next thing to do. */
export function SkillProgressCard({ state, onOpen }) {
const status = STATUS_META[state.status] || STATUS_META.not_started;
const atTop = !state.nextLevel;
return (
<article
role="button"
tabIndex={0}
onClick={() => onOpen(state)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(state);
}
}}
className="group flex h-full cursor-pointer flex-col rounded-2xl border border-border bg-surface p-5 text-left
transition-[border-color,box-shadow] duration-base ease-out hover:border-krow-blue/40 hover:shadow-md
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate font-heading text-body font-semibold text-ink-1">{state.name}</h3>
<p className="mt-0.5 flex items-center gap-1 text-caption text-ink-3">
{state.verifiedLevel && <ShieldCheck className="h-3 w-3 text-success" aria-hidden="true" />}
{state.verifiedLabel}
{state.verifiedByOverride && <span className="text-ink-4">· admin verified</span>}
</p>
</div>
<Badge variant={status.variant} size="sm">{status.label}</Badge>
</div>
<SkillLadder state={state} className="mt-4" />
<div className="mb-4 mt-4">
{atTop ? (
<p className="text-caption text-ink-3">Top of the ladder — every module verified.</p>
) : (
<>
<div className="mb-1 flex items-baseline justify-between gap-2">
<span className="truncate text-caption text-ink-2">Progress to {levelLabel(state.nextLevel)}</span>
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-1">
{state.completedInNext}/{state.requiredForNext}
</span>
</div>
<ProgressBar value={state.progressToNext} tone={state.nearlyThere ? 'success' : 'brand'} size="sm" />
</>
)}
</div>
{/* `mt-auto` pins the footer down; the spacing above it comes from the
block before, so the two never fight over a margin. */}
<div className="mt-auto flex items-end justify-between gap-3 border-t border-border pt-3">
<p className="min-w-0 text-caption text-ink-3">
{state.nextRequirement ? (
<>
<span className="text-ink-4">Next</span>{' '}
<span className="font-medium text-ink-2">{state.nextRequirement.title}</span>
</>
) : atTop ? 'Nothing outstanding' : 'No training authored for this level yet'}
</p>
<span className="inline-flex shrink-0 items-center gap-1 text-caption font-semibold text-krow-blue">
View
<ArrowRight className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5" aria-hidden="true" />
</span>
</div>
</article>
);
}
/**
* The whole ladder, level by level, with every module and its state.
*
* Modules below the person's verified level are shown complete rather than
* hidden: the evidence behind a level is the reason to trust the level, and
* folding it away would leave the label standing on nothing.
*/
export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining }) {
if (!state) return null;
const done = new Set(state.levels.flatMap((l) => l.completed.map((m) => m.id)));
const next = state.nextRequirement;
return (
<Drawer
open={open}
onOpenChange={onOpenChange}
title={`${state.name} skill`}
description={`Current level: ${state.verifiedLabel}${state.nextLevel ? ` · Next: ${levelLabel(state.nextLevel)}` : ''}`}
size="lg"
footer={
next ? (
<>
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>Close</Button>
<Button size="sm" onClick={() => onStartTraining(next)}>Start training</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>Close</Button>
)
}
>
<div className="space-y-5">
{/* Where they stand */}
<div className="rounded-xl border border-border bg-surface-subtle/60 p-4">
<SkillLadder state={state} />
{state.nextLevel && (
<div className="mt-4">
<div className="mb-1 flex items-baseline justify-between">
<span className="text-caption text-ink-2">Progress to {levelLabel(state.nextLevel)}</span>
<span className="text-caption font-semibold tabular-nums text-ink-1">{state.progressToNext}%</span>
</div>
<ProgressBar value={state.progressToNext} tone="brand" size="sm" />
</div>
)}
{state.verifiedByOverride && (
<p className="mt-3 flex items-center gap-1.5 text-caption text-ink-3">
<ShieldCheck className="h-3.5 w-3.5 text-krow-blue" aria-hidden="true" />
{levelLabel(state.verifiedByOverride.level)} verified manually by {state.verifiedByOverride.verified_by || 'an administrator'}.
</p>
)}
</div>
{/* The ladder itself */}
{state.levels.map((rung, i) => (
<section key={rung.level}>
<div className="mb-2 flex items-baseline justify-between gap-2">
<h3 className="font-heading text-caption font-bold uppercase tracking-wide text-ink-4">
Level {i + 1} — {rung.label}
</h3>
<span className="text-caption text-ink-4 tabular-nums">
{rung.completed.length}/{rung.modules.length}
</span>
</div>
{rung.modules.length ? (
<ul className="divide-y divide-border rounded-lg border border-border">
{rung.modules.map((module) => {
const complete = done.has(module.id);
const isNext = next?.id === module.id;
/* Locked means the rung below is unfinished — the same rule the
level derivation uses, so nothing can look startable here and
be refused there. */
const locked = !complete && !isNext && i > 0 && !state.levels[i - 1].earned;
return (
<li key={module.id} className="flex items-center gap-2.5 px-3 py-2.5">
<span
className={cn(
'grid h-5 w-5 shrink-0 place-items-center rounded-full border',
complete ? 'border-success bg-success text-white' : locked ? 'border-border text-ink-4' : 'border-krow-blue text-krow-blue'
)}
>
{complete ? <Check className="h-3 w-3" /> : locked ? <Lock className="h-2.5 w-2.5" /> : <Circle className="h-2 w-2" />}
</span>
<div className="min-w-0 flex-1">
<p className={cn('truncate text-body-sm', complete ? 'text-ink-2' : 'font-medium text-ink-1')}>
{module.title}
</p>
<p className="truncate text-[11px] text-ink-4">
{module.estimated_minutes} min · {(module.verification_criteria || []).length} verification criteria
</p>
</div>
{complete ? (
<span className="shrink-0 text-[11px] font-semibold text-success">Verified</span>
) : isNext ? (
<Button size="xs" variant="outline" onClick={() => onStartTraining(module)}>Start</Button>
) : (
<span className="shrink-0 text-[11px] text-ink-4">{locked ? 'Locked' : 'Available'}</span>
)}
</li>
);
})}
</ul>
) : (
<p className="text-body-sm text-ink-3">No training authored for this level yet.</p>
)}
</section>
))}
{next && (
<div className="flex items-start gap-2.5 rounded-xl border border-krow-blue/30 bg-krow-blue-tint/50 p-4">
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-krow-blue" aria-hidden="true" />
<div className="min-w-0">
<p className="text-body-sm font-semibold text-ink-1">Next milestone</p>
<p className="text-caption text-ink-2">
Complete {next.title} to reach {state.name} — {levelLabel(next.target_level)}.
Owliver verifies the evidence against {(next.verification_criteria || []).length} criteria.
</p>
</div>
</div>
)}
</div>
</Drawer>
);
}

View File

@@ -10,6 +10,7 @@
* SkillLibrary every skill, filterable
* SkillCard one skill and its verification journey
* SkillDetail one skill read end to end
* SkillProgression a skill as a ladder: levels, modules, and what is next
* AddSkillTraining skill → training → proof → Owliver → publish
* ForgeRail the authoring job, the rules, and the way to Owliver
*
@@ -31,6 +32,7 @@ export { ForgeProgress } from './ForgeProgress';
export { SkillLibrary } from './SkillLibrary';
export { SkillCard } from './SkillCard';
export { SkillDetail } from './SkillDetail';
export { SkillLadder, SkillProgressCard, SkillProgressDetail } from './SkillProgression';
export { AddSkillTraining } from './AddSkillTraining';
export { ForgeRail } from './ForgeRail';

View File

@@ -245,7 +245,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
{isOwliver ? (
<OwliverAvatar className="w-8 h-8" />
) : (
<div className="w-8 h-8 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-[13px] font-bold">K</div>
<div className="w-8 h-8 rounded-full bg-[#0838E0] flex items-center justify-center text-white text-[13px] font-bold">K</div>
)}
AI Interview with {conductor}
</span>
@@ -262,7 +262,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
{isOwliver ? (
<OwliverAvatar className="w-16 h-16 mx-auto mb-4" />
) : (
<div className="w-16 h-16 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-2xl font-bold mx-auto mb-4">K</div>
<div className="w-16 h-16 rounded-full bg-[#0838E0] flex items-center justify-center text-white text-2xl font-bold mx-auto mb-4">K</div>
)}
<h3 className="text-lg font-semibold text-[#111827] mb-2">Hi {candidateName.split(' ')[0]}, I'm {conductor}</h3>
<p className="text-[14px] text-[#6B7280] max-w-md mx-auto mb-6">
@@ -272,7 +272,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
</p>
<div className="flex items-center justify-center gap-3 mb-4 text-[12px] text-[#6B7280]">
<span className="inline-flex items-center gap-1"><Volume2 className="w-3.5 h-3.5" /> Voice enabled</span>
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="text-[#0A39DF] underline">
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="text-[#0838E0] underline">
{ttsEnabled ? 'Mute' : 'Unmute'}
</button>
</div>
@@ -285,7 +285,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
onClick={() => setLanguage('en')}
className={cn(
'px-3 py-1.5 text-[12px] font-medium transition-colors',
language === 'en' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
language === 'en' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
)}
>
English
@@ -294,7 +294,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
onClick={() => setLanguage('es')}
className={cn(
'px-3 py-1.5 text-[12px] font-medium transition-colors',
language === 'es' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
language === 'es' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
)}
>
Español
@@ -302,7 +302,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
</div>
</div>
<Button onClick={startInterview} className="bg-[#0A39DF] hover:bg-[#082EB4] px-8">
<Button onClick={startInterview} className="bg-[#0838E0] hover:bg-[#062BAF] px-8">
<Mic className="w-4 h-4 mr-2" /> Start Interview
</Button>
</div>
@@ -316,7 +316,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
<div className={cn(
'max-w-[80%] rounded-2xl px-4 py-2.5 text-[14px]',
msg.role === 'user'
? 'bg-[#0A39DF] text-white rounded-br-sm'
? 'bg-[#0838E0] text-white rounded-br-sm'
: 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
)}>
<p>{msg.content}</p>
@@ -347,7 +347,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
value={transcript}
onChange={(e) => setTranscript(e.target.value)}
placeholder={isListening ? 'Listening...' : 'Type your answer or click mic to speak'}
className="w-full min-h-[60px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
className="w-full min-h-[60px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -369,7 +369,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
<Button
onClick={submitAnswer}
disabled={!transcript.trim()}
className="bg-[#0A39DF] hover:bg-[#082EB4]"
className="bg-[#0838E0] hover:bg-[#062BAF]"
>
Send Answer
</Button>
@@ -380,7 +380,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
{phase === 'evaluating' && (
<div className="py-12 text-center">
<Loader2 className="w-10 h-10 text-[#0A39DF] animate-spin mx-auto mb-4" />
<Loader2 className="w-10 h-10 text-[#0838E0] animate-spin mx-auto mb-4" />
<p className="text-[14px] text-[#6B7280]">{conductor} is evaluating your interview...</p>
</div>
)}
@@ -442,7 +442,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
</div>
)}
<Button onClick={handleClose} className="w-full bg-[#0A39DF] hover:bg-[#082EB4]">Done</Button>
<Button onClick={handleClose} className="w-full bg-[#0838E0] hover:bg-[#062BAF]">Done</Button>
</div>
)}
</DialogContent>

View File

@@ -15,7 +15,7 @@ import {
Legend,
} from 'recharts';
const PIE_COLORS = ['#0A39DF', '#059669', '#333F48', '#D97706', '#DC2626', '#0891B2', '#DB2777'];
const PIE_COLORS = ['#0838E0', '#059669', '#333F48', '#D97706', '#DC2626', '#0891B2', '#DB2777'];
function last7Days() {
const days = [];
@@ -68,7 +68,7 @@ export default function ActivityCharts({ activities }) {
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="monotone" dataKey="signups" stroke="#0A39DF" strokeWidth={2} />
<Line type="monotone" dataKey="signups" stroke="#0838E0" strokeWidth={2} />
<Line type="monotone" dataKey="logins" stroke="#059669" strokeWidth={2} />
<Line type="monotone" dataKey="actions" stroke="#D97706" strokeWidth={2} />
</LineChart>
@@ -83,7 +83,7 @@ export default function ActivityCharts({ activities }) {
<XAxis dataKey="name" tick={{ fontSize: 10 }} stroke="#9CA3AF" interval={0} angle={-20} textAnchor="end" height={50} />
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
<Tooltip />
<Bar dataKey="value" fill="#0A39DF" radius={[4, 4, 0, 0]} />
<Bar dataKey="value" fill="#0838E0" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>

View File

@@ -13,13 +13,13 @@ const EVENT_LABELS = {
};
const EVENT_COLORS = {
signup: 'bg-[#EFF6FF] text-[#1E40AF]',
signup: 'bg-[#EEF3FE] text-[#1E40AF]',
login: 'bg-[#ECFDF5] text-[#065F46]',
logout: 'bg-[#F3F4F6] text-[#374151]',
create_position: 'bg-[#F5F3FF] text-[#5B21B6]',
screen_candidate: 'bg-[#FFFBEB] text-[#92400E]',
hire_candidate: 'bg-[#ECFDF5] text-[#065F46]',
apply_job: 'bg-[#EFF6FF] text-[#1E40AF]',
apply_job: 'bg-[#EEF3FE] text-[#1E40AF]',
start_interview: 'bg-[#FEF2F2] text-[#991B1B]',
};

View File

@@ -14,7 +14,7 @@ export default function ActivityMetrics({ activities }) {
const activeUsers24h = new Set(recentUsers.map((a) => a.user_email)).size;
const metrics = [
{ label: 'Total Sign-ups', value: signups.length, icon: UserCheck, color: '#0A39DF', bg: '#EFF6FF' },
{ label: 'Total Sign-ups', value: signups.length, icon: UserCheck, color: '#0838E0', bg: '#EEF3FE' },
{ label: 'Logins (24h)', value: logins24h.length, icon: LogIn, color: '#059669', bg: '#ECFDF5' },
{ label: 'Active Users (24h)', value: activeUsers24h, icon: Users, color: '#333F48', bg: '#F3F4F6' },
{ label: 'Total Actions', value: activities.length, icon: Activity, color: '#D97706', bg: '#FFFBEB' },

View File

@@ -16,7 +16,7 @@ export default function ActivitySnapshot({ profile }) {
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
{SNAPSHOTS.map((s) => (
<div key={s.key} className="bg-white rounded-2xl shadow-sm p-4 flex flex-col items-center justify-center aspect-square">
<span className="text-[34px] font-bold leading-none" style={{ color: '#0A39DF' }}>
<span className="text-[34px] font-bold leading-none" style={{ color: '#0838E0' }}>
{s.get(profile)}
</span>
<span className="text-[12px] text-[#333F48] mt-2 text-center">{s.label}</span>

View File

@@ -11,11 +11,11 @@ export default function AnalyticsMetrics({ applications, interviews, staff }) {
const hireRate = totalApplicants ? Math.round((hired.length / totalApplicants) * 100) : 0;
const metrics = [
{ label: 'Total Applicants', value: totalApplicants, icon: Users, color: '#0A39DF', bg: '#EFF6FF' },
{ label: 'Total Applicants', value: totalApplicants, icon: Users, color: '#0838E0', bg: '#EEF3FE' },
{ label: 'Avg AI Score', value: avgScore, icon: Brain, color: '#333F48', bg: '#F3F4F6' },
{ label: 'Interviews Done', value: interviewed.length, icon: Mic, color: '#0A39DF', bg: '#FEFCE8' },
{ label: 'Interviews Done', value: interviewed.length, icon: Mic, color: '#0838E0', bg: '#FEFCE8' },
{ label: 'Hired', value: hired.length, icon: UserCheck, color: '#333F48', bg: '#F3F4F6' },
{ label: 'Hire Rate', value: `${hireRate}%`, icon: Target, color: '#0A39DF', bg: '#EFF6FF' },
{ label: 'Hire Rate', value: `${hireRate}%`, icon: Target, color: '#0838E0', bg: '#EEF3FE' },
{ label: 'On Staff', value: staff.length, icon: TrendingUp, color: '#333F48', bg: '#D1E0D7' },
];

View File

@@ -121,7 +121,7 @@ export default function ApplicantForm({ onSubmit, submitting }) {
</div>
<div>
<Label className="text-[13px] text-[#374151]">English Level</Label>
<select value={form.english_level} onChange={(e) => update('english_level', e.target.value)} className="w-full mt-1.5 h-9 rounded-md border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none">
<select value={form.english_level} onChange={(e) => update('english_level', e.target.value)} className="w-full mt-1.5 h-9 rounded-md border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none">
{ENGLISH_LEVELS.map(l => <option key={l} value={l}>{l.charAt(0).toUpperCase() + l.slice(1)}</option>)}
</select>
</div>
@@ -154,7 +154,7 @@ export default function ApplicantForm({ onSubmit, submitting }) {
{form.companies_worked.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{form.companies_worked.map((c, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[#EFF6FF] text-[12px] text-[#1E40AF] font-medium border border-[#DBEAFE]">
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[#EEF3FE] text-[12px] text-[#1E40AF] font-medium border border-[#DBEAFE]">
{c}
<button type="button" onClick={() => update('companies_worked', form.companies_worked.filter((_, idx) => idx !== i))} className="text-[#9CA3AF] hover:text-[#EF4444]">×</button>
</span>
@@ -172,7 +172,7 @@ export default function ApplicantForm({ onSubmit, submitting }) {
type="button"
onClick={() => toggleCert(cert)}
className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${
form.certifications.includes(cert) ? 'bg-[#0A39DF] border-[#0A39DF] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
form.certifications.includes(cert) ? 'bg-[#0838E0] border-[#0838E0] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
}`}
>
{cert}
@@ -183,9 +183,9 @@ export default function ApplicantForm({ onSubmit, submitting }) {
{/* Certification file upload */}
<div className="mt-3">
<Label className="text-[12px] text-[#6B7280]">Upload certification documents</Label>
<label className="mt-1.5 flex flex-col items-center justify-center gap-1.5 border-2 border-dashed border-[#E5E7EB] rounded-lg py-4 cursor-pointer hover:border-[#0A39DF] hover:bg-[#EFF6FF]/40 transition-colors">
<label className="mt-1.5 flex flex-col items-center justify-center gap-1.5 border-2 border-dashed border-[#E5E7EB] rounded-lg py-4 cursor-pointer hover:border-[#0838E0] hover:bg-[#EEF3FE]/40 transition-colors">
{uploadingCert ? (
<Loader2 className="w-5 h-5 text-[#0A39DF] animate-spin" />
<Loader2 className="w-5 h-5 text-[#0838E0] animate-spin" />
) : (
<UploadCloud className="w-5 h-5 text-[#9CA3AF]" />
)}
@@ -238,12 +238,12 @@ export default function ApplicantForm({ onSubmit, submitting }) {
</button>
</div>
) : (
<label className="flex items-center gap-3 border-2 border-dashed border-[#E5E7EB] rounded-xl p-4 cursor-pointer hover:border-[#0A39DF] hover:bg-[#EFF6FF]/40 transition-colors">
<label className="flex items-center gap-3 border-2 border-dashed border-[#E5E7EB] rounded-xl p-4 cursor-pointer hover:border-[#0838E0] hover:bg-[#EEF3FE]/40 transition-colors">
{uploadingSelfie ? (
<Loader2 className="w-8 h-8 text-[#0A39DF] animate-spin shrink-0" />
<Loader2 className="w-8 h-8 text-[#0838E0] animate-spin shrink-0" />
) : (
<div className="w-12 h-12 rounded-full bg-[#EFF6FF] flex items-center justify-center shrink-0">
<Camera className="w-6 h-6 text-[#0A39DF]" />
<div className="w-12 h-12 rounded-full bg-[#EEF3FE] flex items-center justify-center shrink-0">
<Camera className="w-6 h-6 text-[#0838E0]" />
</div>
)}
<div className="flex-1">
@@ -284,10 +284,10 @@ export default function ApplicantForm({ onSubmit, submitting }) {
<div>
<Label className="text-[13px] text-[#374151]">Professional Summary</Label>
<textarea value={form.professional_summary} onChange={(e) => update('professional_summary', e.target.value)} className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none" />
<textarea value={form.professional_summary} onChange={(e) => update('professional_summary', e.target.value)} className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none" />
</div>
<Button type="submit" disabled={submitting} className="w-full bg-[#0A39DF] hover:bg-[#082EB4] h-11 text-[14px]">
<Button type="submit" disabled={submitting} className="w-full bg-[#0838E0] hover:bg-[#062BAF] h-11 text-[14px]">
{submitting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Submit Application
</Button>

View File

@@ -51,7 +51,7 @@ export default function ApplyIdentityCard({ profile, job, onStart, submitting, o
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-[17px] font-bold text-[#111827] truncate">{profile?.full_name || 'Your KROW Identity'}</h3>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#1E40AF]">Verified</span>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#1E40AF]">Verified</span>
</div>
<p className="text-[12px] text-[#6B7280] mt-1">
Your KROW Identity is loaded — no form needed. Owliver will run a short AI interview to confirm fit for this role.
@@ -110,7 +110,7 @@ export default function ApplyIdentityCard({ profile, job, onStart, submitting, o
onClick={onStart}
disabled={!ready || submitting}
className={cn(
'flex-1 inline-flex items-center justify-center gap-2 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[14px] font-semibold px-5 py-3 rounded-xl transition-colors disabled:opacity-60 disabled:cursor-not-allowed'
'flex-1 inline-flex items-center justify-center gap-2 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[14px] font-semibold px-5 py-3 rounded-xl transition-colors disabled:opacity-60 disabled:cursor-not-allowed'
)}
>
<Mic className="w-4 h-4" />

View File

@@ -6,7 +6,7 @@ const LEVEL_STYLES = {
bronze: 'bg-[#FEF3C7] text-[#92400E] border-[#FCD34D]',
silver: 'bg-[#F3F4F6] text-[#4B5563] border-[#D1D5DB]',
gold: 'bg-[#FEF9E7] text-[#92400E] border-[#F9E547]',
platinum: 'bg-[#EFF6FF] text-[#1E40AF] border-[#BFDBFE]',
platinum: 'bg-[#EEF3FE] text-[#1E40AF] border-[#BFDBFE]',
};
export default function BadgeChip({ name, level = 'bronze', earned_date, size = 'md' }) {

View File

@@ -6,6 +6,10 @@ import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Brie
import { Image } from '@/components/ui/image';
import { getScoreBand } from '@/lib/talentHome';
import CandidateExpandedDetails from '@/components/krow/CandidateExpandedDetails';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
import { toast } from 'react-hot-toast';
import { base44 } from '@/api/base44Client';
const STATUS_STYLES = {
hired: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Hired', card: 'bg-white border-[#E5E7EB]' },
@@ -40,7 +44,7 @@ function getPortraitSeed(name) {
}
function getScoreColor(score) {
if (score > 0) return '#0A39DF';
if (score > 0) return '#0838E0';
return '#EF4444';
}
@@ -72,6 +76,66 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
const canTakeAction = status !== 'hired' && status !== 'rejected';
const [expanded, setExpanded] = useState(false);
const [internalMessageOpen, setInternalMessageOpen] = useState(false);
const [internalScheduleOpen, setInternalScheduleOpen] = useState(false);
const handleMessage = (e) => {
e?.stopPropagation();
if (onMessage) {
onMessage(application);
} else {
setInternalMessageOpen(true);
}
};
const handleCall = (e) => {
e?.stopPropagation();
if (onCall) {
onCall(application);
} else if (application.phone) {
window.location.href = `tel:${application.phone}`;
} else {
setInternalMessageOpen(true);
}
};
const handleSchedule = (e) => {
e?.stopPropagation();
if (onSchedule) {
onSchedule(application);
} else {
setInternalScheduleOpen(true);
}
};
const handleDecline = async (e) => {
e?.stopPropagation();
if (onDecline) {
onDecline(application);
} else {
try {
await base44.entities.JobApplication.update(application.id, { status: 'rejected' });
toast.success(`${application.applicant_name} declined`);
} catch (err) {
toast.error('Failed to decline candidate');
}
}
};
const handleDelete = async (e) => {
e?.stopPropagation();
if (onDelete) {
onDelete(application);
} else {
try {
await base44.entities.JobApplication.delete(application.id);
toast.success(`${application.applicant_name} deleted`);
} catch (err) {
toast.error('Failed to delete candidate');
}
}
};
return (
<div className={cn('border rounded-xl p-4 transition-all hover:shadow-sm', style.card)}>
<div
@@ -80,7 +144,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
>
{/* Rank */}
<div className="flex flex-col items-center justify-center gap-0.5 shrink-0 w-8">
<span className="text-[18px] font-bold text-[#0A39DF]">{rank}</span>
<span className="text-[18px] font-bold text-[#0838E0]">{rank}</span>
</div>
{/* Avatar — 88-110px target size */}
@@ -112,7 +176,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-[#6B7280] flex-wrap">
{position && (
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
<span className="inline-flex items-center gap-1 font-medium text-[#0838E0]">
<Briefcase className="w-3 h-3" />
{position}
</span>
@@ -129,7 +193,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
</div>
{application.ai_match_label && (
<div className="mt-1.5">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[#0A39DF] text-white text-[10px] font-bold">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[#0838E0] text-white text-[10px] font-bold">
+ {application.ai_match_label}
</span>
</div>
@@ -144,7 +208,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
{onAction && canTakeAction && (
<button
onClick={() => onAction(application)}
className="px-3 py-1.5 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
className="px-3 py-1.5 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
>
{status === 'interview' ? 'AI Interview' : 'Interview'}
</button>
@@ -156,48 +220,50 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
<ChevronDown className="w-3.5 h-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{onMessage && (
<DropdownMenuItem onClick={() => onMessage(application)} className="cursor-pointer py-2">
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0A39DF]" />
<span className="text-[#374151]">Message</span>
</DropdownMenuItem>
)}
{onCall && (
<DropdownMenuItem onClick={() => onCall(application)} className="cursor-pointer py-2">
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669]">Call</span>
</DropdownMenuItem>
)}
{onSchedule && (
<DropdownMenuItem onClick={() => onSchedule(application)} className="cursor-pointer py-2">
<Calendar className="w-4 h-4 mr-2.5 text-[#0A39DF]" />
<span className="text-[#0A39DF]">Set up interview</span>
</DropdownMenuItem>
)}
<DropdownMenuContent align="end" className="w-52 p-1.5 bg-white border border-[#E5E7EB] shadow-xl rounded-xl">
<DropdownMenuItem onClick={handleMessage} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0838E0]" />
<span className="text-[#374151] text-[13px] font-medium">Message</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCall} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669] text-[13px] font-medium">Call</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSchedule} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Calendar className="w-4 h-4 mr-2.5 text-[#0838E0]" />
<span className="text-[#0838E0] text-[13px] font-medium">Set up interview</span>
</DropdownMenuItem>
{onHire && (
<DropdownMenuItem onClick={() => onHire(application)} className="cursor-pointer py-2">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onHire(application); }} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
<span className="text-[#059669]">Hire candidate</span>
</DropdownMenuItem>
)}
{onDecline && (
<DropdownMenuItem onClick={() => onDecline(application)} className="cursor-pointer py-2">
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626]">Decline candidate</span>
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem onClick={() => onDelete(application)} className="cursor-pointer py-2">
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626]">Delete candidate</span>
<span className="text-[#059669] text-[13px] font-medium">Hire candidate</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleDecline} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626] text-[13px] font-medium">Decline candidate</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDelete} className="cursor-pointer py-2 px-3 focus:bg-[#F3F4F6] rounded-lg">
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
<span className="text-[#DC2626] text-[13px] font-medium">Delete candidate</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{expanded && <CandidateExpandedDetails application={application} />}
{internalScheduleOpen && (
<ScheduleInterviewModal open={true} onClose={() => setInternalScheduleOpen(false)} application={application} />
)}
{internalMessageOpen && (
<MessageCandidateModal open={true} onClose={() => setInternalMessageOpen(false)} application={application} />
)}
</div>
);
}

View File

@@ -26,7 +26,7 @@ function MetricPill({ label, value, Icon }) {
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB]">
<Icon className="w-3 h-3 text-[#9CA3AF]" strokeWidth={2} />
<span className="text-[11px] text-[#6B7280] font-medium">{label}</span>
<span className="text-[12px] font-bold text-[#0A39DF] tabular-nums">{value || 0}</span>
<span className="text-[12px] font-bold text-[#0838E0] tabular-nums">{value || 0}</span>
</span>
);
}
@@ -65,7 +65,7 @@ export default function CandidateExpandedDetails({ application }) {
<Icon className="w-3.5 h-3.5 text-[#94A3B8]" />
{label}
</span>
<span className="font-bold text-[#0A39DF] tabular-nums">{val}</span>
<span className="font-bold text-[#0838E0] tabular-nums">{val}</span>
</div>
);
})}
@@ -85,15 +85,15 @@ export default function CandidateExpandedDetails({ application }) {
{(strengths.length > 0 || gaps.length > 0) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{strengths.length > 0 && (
<div className="bg-[#EFF6FF] rounded-xl p-3.5 border border-[#DBEAFE]">
<div className="bg-[#EEF3FE] rounded-xl p-3.5 border border-[#DBEAFE]">
<div className="flex items-center gap-1.5 mb-2">
<Check className="w-3.5 h-3.5 text-[#0A39DF]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#0A39DF] tracking-wide">STRENGTHS</h4>
<Check className="w-3.5 h-3.5 text-[#0838E0]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#0838E0] tracking-wide">STRENGTHS</h4>
</div>
<ul className="space-y-1.5">
{strengths.map((s, i) => (
<li key={i} className="text-[12px] text-[#1E40AF] flex gap-2">
<span className="text-[#0A39DF] font-bold">✓</span>
<span className="text-[#0838E0] font-bold">✓</span>
<span>{s}</span>
</li>
))}
@@ -123,7 +123,7 @@ export default function CandidateExpandedDetails({ application }) {
{certs.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{certs.map((cert, i) => (
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium border border-[#DBEAFE]">
{cert}
</span>
))}

View File

@@ -28,13 +28,13 @@ export default function CareerJourney({ path, completedIds = [] }) {
<div key={i} className="relative flex items-center gap-3 py-2">
<div
className={`absolute -left-6 w-3.5 h-3.5 rounded-full ${
r.done ? 'bg-[#0A39DF]' : 'bg-white border-2 border-[#E2E8F0]'
r.done ? 'bg-[#0838E0]' : 'bg-white border-2 border-[#E2E8F0]'
}`}
/>
<span
className={`text-[15px] ${
r.isTarget
? 'font-semibold text-[#0A39DF]'
? 'font-semibold text-[#0838E0]'
: r.done
? 'text-[#0F172A] font-medium'
: 'text-[#64748B]'

View File

@@ -6,7 +6,7 @@ function Stage({ label, value, sub, accent, to }) {
const body = (
<div className="text-center sm:text-left">
<div className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8]">{label}</div>
<div className={`text-[20px] sm:text-[26px] font-semibold mt-1 tracking-tight ${accent ? 'text-[#0A39DF]' : 'text-[#0F172A]'}`}>
<div className={`text-[20px] sm:text-[26px] font-semibold mt-1 tracking-tight ${accent ? 'text-[#0838E0]' : 'text-[#0F172A]'}`}>
{value}
</div>
{sub && <div className="text-[13px] text-[#64748B] mt-0.5">{sub}</div>}
@@ -22,7 +22,7 @@ export default function CareerTrajectory({ profile, nextPath, nextReady, futureR
const next = nextPath?.target_role || nextPath?.name || 'Next Role';
return (
<section className="text-center pt-4 pb-12 sm:pt-8 sm:pb-20">
<span className="text-[12px] font-medium tracking-wide text-[#0A39DF] uppercase">Your Career</span>
<span className="text-[12px] font-medium tracking-wide text-[#0838E0] uppercase">Your Career</span>
<h1 className="text-[32px] sm:text-[44px] font-bold tracking-tight text-[#0F172A] mt-4 leading-[1.05]">
Everything revolves around your future.
</h1>

View File

@@ -38,7 +38,7 @@ export default function CertificationManager() {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button className="inline-flex items-center gap-1.5 bg-white border border-[#E5E7EB] hover:border-[#D1D5DB] text-[#374151] text-[13px] font-medium px-4 h-10 rounded-lg transition-colors">
<ShieldCheck className="w-4 h-4 text-[#0A39DF]" />
<ShieldCheck className="w-4 h-4 text-[#0838E0]" />
<span className="hidden sm:inline">Manage Certifications</span>
<span className="sm:hidden">Certs</span>
</button>
@@ -64,7 +64,7 @@ export default function CertificationManager() {
type="button"
onClick={handleAdd}
disabled={!newCert.trim() || createCert.isPending}
className="bg-[#0A39DF] hover:bg-[#082EB4]"
className="bg-[#0838E0] hover:bg-[#062BAF]"
>
{createCert.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Add

View File

@@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
const TIER_COLORS = { Skilled: '#0A39DF', 'Cross-Trained': '#333F48', Beginner: '#D1E0D7' };
const TIER_COLORS = { Skilled: '#0838E0', 'Cross-Trained': '#333F48', Beginner: '#D1E0D7' };
export default function HiringAnalytics({ applications, staff }) {
const byTier = useMemo(() => {
@@ -44,7 +44,7 @@ export default function HiringAnalytics({ applications, staff }) {
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
<div className="text-2xl font-heading font-bold text-[#0A39DF]">{hireRate}%</div>
<div className="text-2xl font-heading font-bold text-[#0838E0]">{hireRate}%</div>
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">HIRE RATE</div>
</div>
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
@@ -52,7 +52,7 @@ export default function HiringAnalytics({ applications, staff }) {
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">AVG SCORE</div>
</div>
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
<div className="text-2xl font-heading font-bold text-[#0A39DF]">{recentHires}</div>
<div className="text-2xl font-heading font-bold text-[#0838E0]">{recentHires}</div>
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">LAST 7 DAYS</div>
</div>
</div>
@@ -83,7 +83,7 @@ export default function HiringAnalytics({ applications, staff }) {
<XAxis type="number" tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
<YAxis type="category" dataKey="role" tick={{ fontSize: 9 }} stroke="#9CA3AF" width={100} />
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }} />
<Bar dataKey="count" fill="#0A39DF" radius={[0, 4, 4, 0]} />
<Bar dataKey="count" fill="#0838E0" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>

View File

@@ -46,8 +46,8 @@ export default function ImpactMetrics({ applications, interviews, staff }) {
label: 'Time-to-Hire',
value: tthDisplay,
sub: hired.length ? `${hired.length} hires tracked` : 'AI screens instantly',
color: '#0A39DF',
bg: '#EFF6FF',
color: '#0838E0',
bg: '#EEF3FE',
},
{
icon: DollarSign,
@@ -62,7 +62,7 @@ export default function ImpactMetrics({ applications, interviews, staff }) {
label: 'Quality of Hire',
value: qualityOfHire ? `${qualityOfHire}/100` : '—',
sub: hired.length ? `${hired.length} hired candidates` : 'Awaiting first hire',
color: '#0A39DF',
color: '#0838E0',
bg: '#FEFCE8',
},
{
@@ -78,8 +78,8 @@ export default function ImpactMetrics({ applications, interviews, staff }) {
label: 'Decisions Standardized',
value: `${standardizedPct}%`,
sub: `${screened.length} of ${applications.length} applicants scored`,
color: '#0A39DF',
bg: '#EFF6FF',
color: '#0838E0',
bg: '#EEF3FE',
},
{
icon: Heart,

View File

@@ -2,7 +2,7 @@ import React, { useMemo } from 'react';
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
import { Mic, CheckCircle2, Clock, AlertTriangle } from 'lucide-react';
const VERDICT_COLORS = { hire: '#0A39DF', maybe: '#F9E547', no: '#333F48' };
const VERDICT_COLORS = { hire: '#0838E0', maybe: '#F9E547', no: '#333F48' };
export default function InterviewAnalytics({ applications, interviews }) {
const stats = useMemo(() => {
@@ -38,9 +38,9 @@ export default function InterviewAnalytics({ applications, interviews }) {
}, [interviews]);
const cards = [
{ label: 'Started', value: stats.started, icon: Mic, color: '#0A39DF', bg: '#EFF6FF' },
{ label: 'Started', value: stats.started, icon: Mic, color: '#0838E0', bg: '#EEF3FE' },
{ label: 'Completed', value: stats.completed, icon: CheckCircle2, color: '#333F48', bg: '#F3F4F6' },
{ label: 'Completion Rate', value: `${stats.completionRate}%`, icon: Clock, color: '#0A39DF', bg: '#FEFCE8' },
{ label: 'Completion Rate', value: `${stats.completionRate}%`, icon: Clock, color: '#0838E0', bg: '#FEFCE8' },
{ label: 'Avg Interview Score', value: stats.avgScore, icon: AlertTriangle, color: '#333F48', bg: '#D1E0D7' },
];

View File

@@ -26,7 +26,7 @@ const CATEGORY_LABELS = {
};
function getCategoryColor(val) {
if (val >= 80) return '#0A39DF';
if (val >= 80) return '#0838E0';
if (val >= 60) return '#333F48';
if (val >= 40) return '#6B7280';
return '#EF4444';
@@ -59,7 +59,7 @@ export default function InterviewResults({ application }) {
{/* Header */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#0A39DF] flex items-center justify-center">
<div className="w-7 h-7 rounded-lg bg-[#0838E0] flex items-center justify-center">
<MessageSquare className="w-3.5 h-3.5 text-white" />
</div>
<h4 className="text-[12px] font-bold text-[#111827] tracking-wide">AI INTERVIEW RESULTS</h4>
@@ -68,7 +68,7 @@ export default function InterviewResults({ application }) {
<span className={`px-2.5 py-1 rounded-full text-[11px] font-semibold ${verdictStyle.bg} ${verdictStyle.text}`}>
{verdictStyle.label}
</span>
<span className="text-[18px] font-bold text-[#0A39DF]">{interview.overall_interview_score || 0}<span className="text-[11px] text-[#9CA3AF]">/100</span></span>
<span className="text-[18px] font-bold text-[#0838E0]">{interview.overall_interview_score || 0}<span className="text-[11px] text-[#9CA3AF]">/100</span></span>
</div>
</div>
@@ -101,15 +101,15 @@ export default function InterviewResults({ application }) {
{/* Strengths & Concerns */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{interview.strengths?.length > 0 && (
<div className="bg-[#EFF6FF] rounded-xl p-4">
<div className="bg-[#EEF3FE] rounded-xl p-4">
<div className="flex items-center gap-1.5 mb-2">
<CheckCircle2 className="w-3.5 h-3.5 text-[#0A39DF]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#0A39DF] tracking-wide">STRENGTHS</h4>
<CheckCircle2 className="w-3.5 h-3.5 text-[#0838E0]" strokeWidth={2.5} />
<h4 className="text-[11px] font-bold text-[#0838E0] tracking-wide">STRENGTHS</h4>
</div>
<ul className="space-y-1.5">
{interview.strengths.map((s, i) => (
<li key={i} className="text-[12px] text-[#1E40AF] flex gap-2">
<span className="text-[#0A39DF] font-bold">+</span>
<span className="text-[#0838E0] font-bold">+</span>
<span>{s}</span>
</li>
))}
@@ -153,7 +153,7 @@ export default function InterviewResults({ application }) {
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5 flex items-center gap-1"><Star className="w-3 h-3" /> BEST FIT ROLES</h4>
<div className="flex items-center gap-2 flex-wrap">
{interview.best_fit_roles.map((role, i) => (
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium border border-[#DBEAFE]">
{role}
</span>
))}
@@ -170,7 +170,7 @@ export default function InterviewResults({ application }) {
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] rounded-2xl px-3.5 py-2 text-[13px] ${
msg.role === 'user'
? 'bg-[#0A39DF] text-white rounded-br-sm'
? 'bg-[#0838E0] text-white rounded-br-sm'
: 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
}`}>
<p className="whitespace-pre-wrap">{msg.content}</p>

View File

@@ -61,7 +61,7 @@ export default function InviteApplicantsModal({ open, onClose, job }) {
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="w-5 h-5 text-[#0A39DF]" />
<Users className="w-5 h-5 text-[#0838E0]" />
Invite Applicants
</DialogTitle>
</DialogHeader>
@@ -72,7 +72,7 @@ export default function InviteApplicantsModal({ open, onClose, job }) {
<p className="text-[13px] font-semibold text-[#111827]">{jobTitle}</p>
{job?.location && <p className="text-[12px] text-[#6B7280]">{job.location}</p>}
{job?.pay_range_min != null && (
<p className="text-[12px] text-[#0A39DF] font-medium">${job.pay_range_min}–${job.pay_range_max}/hr</p>
<p className="text-[12px] text-[#0838E0] font-medium">${job.pay_range_min}–${job.pay_range_max}/hr</p>
)}
</div>
@@ -89,7 +89,7 @@ export default function InviteApplicantsModal({ open, onClose, job }) {
/>
<button
onClick={handleCopyLink}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-[#0A39DF] text-white text-[12px] font-medium hover:bg-[#082EB4] transition-colors"
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-[#0838E0] text-white text-[12px] font-medium hover:bg-[#062BAF] transition-colors"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? 'Copied' : 'Copy'}
@@ -115,12 +115,12 @@ export default function InviteApplicantsModal({ open, onClose, job }) {
value={emailRecipients}
onChange={(e) => setEmailRecipients(e.target.value)}
placeholder="applicant1@email.com, applicant2@email.com"
className="w-full min-h-[48px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
className="w-full min-h-[48px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
/>
<button
onClick={handleEmailInvite}
disabled={!emailRecipients.trim() || sending}
className="w-full mt-2 inline-flex items-center justify-center gap-2 py-2.5 rounded-lg bg-[#0A39DF] text-white text-[13px] font-medium hover:bg-[#082EB4] disabled:opacity-50 transition-colors"
className="w-full mt-2 inline-flex items-center justify-center gap-2 py-2.5 rounded-lg bg-[#0838E0] text-white text-[13px] font-medium hover:bg-[#062BAF] disabled:opacity-50 transition-colors"
>
<Mail className="w-4 h-4" />
Send Email Invitations
@@ -128,8 +128,8 @@ export default function InviteApplicantsModal({ open, onClose, job }) {
</div>
{/* Mobile app note */}
<div className="flex items-start gap-2 p-3 bg-[#EFF6FF] rounded-lg">
<Smartphone className="w-4 h-4 text-[#0A39DF] mt-0.5 shrink-0" />
<div className="flex items-start gap-2 p-3 bg-[#EEF3FE] rounded-lg">
<Smartphone className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<p className="text-[11px] text-[#374151] leading-relaxed">
Applicants open the link on any phone — no app download needed. They see open positions and complete the AI interview right in their mobile browser.
</p>

View File

@@ -52,7 +52,7 @@ export default function KROWCandidateLeaderboard({ applications, maxBars = 4 })
{sorted.slice(0, 5).map((app, idx) => (
<div key={app.id} className="flex items-center gap-3 py-2">
<span className="text-[12px] font-bold text-[#9CA3AF] w-6">#{idx + 1}</span>
<div className="w-7 h-7 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-[10px] font-semibold shrink-0">
<div className="w-7 h-7 rounded-full bg-[#0838E0] flex items-center justify-center text-white text-[10px] font-semibold shrink-0">
{app.applicant_name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()}
</div>
<div className="flex-1 min-w-0">

View File

@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { cn } from '@/lib/utils';
const STATUS_STYLES = {
active: 'bg-[#0A39DF] text-white',
active: 'bg-[#0838E0] text-white',
draft: 'bg-[#F3F4F6] text-[#6B7280]',
paused: 'bg-[#FEF3C7] text-[#92400E]',
closed: 'bg-[#FEE2E2] text-[#991B1B]',
@@ -31,7 +31,7 @@ export default function KROWJobPostingCard({ posting, stats }) {
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex items-start gap-3 min-w-0">
<div className="w-10 h-10 rounded-lg bg-[#0A39DF] flex items-center justify-center text-white text-[13px] font-bold shrink-0">
<div className="w-10 h-10 rounded-lg bg-[#0838E0] flex items-center justify-center text-white text-[13px] font-bold shrink-0">
{getCategoryAbbr(posting.role_category)}
</div>
<div className="min-w-0">
@@ -71,7 +71,7 @@ export default function KROWJobPostingCard({ posting, stats }) {
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-[#F3F4F6]">
{posting.ai_generated ? (
<span className="px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium">
<span className="px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium">
AI Built
</span>
) : (

View File

@@ -73,15 +73,15 @@ export default function KrowIdPreview() {
<div className="relative p-5">
{/* header row */}
<div className="flex items-center gap-3">
<div className="w-14 h-14 rounded-full overflow-hidden border-2 border-white shadow bg-[#EFF6FF] flex items-center justify-center shrink-0">
<div className="w-14 h-14 rounded-full overflow-hidden border-2 border-white shadow bg-[#EEF3FE] flex items-center justify-center shrink-0">
{profile.selfie_url ? (
<Image src={profile.selfie_url} alt={name} className="w-full h-full object-cover" fittingType="fill" />
) : (
<span className="text-[18px] font-bold text-[#0A39DF]">{initials}</span>
<span className="text-[18px] font-bold text-[#0838E0]">{initials}</span>
)}
</div>
<div className="min-w-0 text-left">
<p className="text-[9px] font-semibold uppercase tracking-[0.12em] text-[#0A39DF]">KROW ID · {shortId}</p>
<p className="text-[9px] font-semibold uppercase tracking-[0.12em] text-[#0838E0]">KROW ID · {shortId}</p>
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight leading-tight truncate">{name}</h2>
<p className="text-[11px] text-[#6B7280] truncate">{title}</p>
</div>
@@ -107,7 +107,7 @@ export default function KrowIdPreview() {
].map(([label, value]) => (
<span key={label} className="inline-flex items-center justify-between gap-1 rounded-lg bg-white/70 border border-[#E6ECF5] px-2 py-1 text-[10px]">
<span className="text-[#9CA3AF]">{label}</span>
<span className="font-semibold text-[#0A39DF]">{value}</span>
<span className="font-semibold text-[#0838E0]">{value}</span>
</span>
))}
</div>
@@ -119,11 +119,11 @@ export default function KrowIdPreview() {
<img src={qrSrc} alt="KROW Identity QR" width={48} height={48} className="block" />
</div>
<div className="flex-1 grid grid-cols-2 gap-1.5">
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-[#0A39DF] text-white text-[10px] font-semibold hover:bg-[#082EB4] transition-colors">
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-[#0838E0] text-white text-[10px] font-semibold hover:bg-[#062BAF] transition-colors">
<Smartphone className="w-3 h-3" /> Contacts
</button>
<button onClick={() => toast('Open My KROW ID page to save the full pass.', { icon: 'ℹ️' })} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0A39DF] transition-colors">
<ImageIcon className="w-3 h-3 text-[#0A39DF]" /> Save
<button onClick={() => toast('Open My KROW ID page to save the full pass.', { icon: 'ℹ️' })} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0838E0] transition-colors">
<ImageIcon className="w-3 h-3 text-[#0838E0]" /> Save
</button>
</div>
</div>

View File

@@ -16,7 +16,7 @@ export default function KrowIdentityCard({ form }) {
const pct = Math.round((done / parts.length) * 100);
return (
<div className="rounded-2xl bg-[#0A39DF] p-5 sm:p-6 text-white overflow-hidden relative">
<div className="rounded-2xl bg-[#0838E0] p-5 sm:p-6 text-white overflow-hidden relative">
<div className="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-[#F9E547]/10" />
<div className="relative flex items-center gap-4">
<div className="shrink-0">

View File

@@ -4,7 +4,7 @@ export default function KrowScoreRing({ score = 0, size = 64, label = 'KROW Scor
const radius = (size - 8) / 2;
const circ = 2 * Math.PI * radius;
const offset = circ - (Math.min(100, score) / 100) * circ;
const color = score >= 80 ? '#0A39DF' : score >= 60 ? '#333F48' : score >= 40 ? '#6B7280' : '#EF4444';
const color = score >= 80 ? '#0838E0' : score >= 60 ? '#333F48' : score >= 40 ? '#6B7280' : '#EF4444';
return (
<div className="flex flex-col items-center">
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>

View File

@@ -7,9 +7,9 @@ export default function LearningPathCard({ path, completedIds = [] }) {
const done = steps.filter(s => completedIds.has(s.course_id)).length;
const pct = steps.length ? Math.round((done / steps.length) * 100) : 0;
return (
<Link to="/university" className="block bg-white border border-[#E5E7EB] rounded-xl p-5 hover:border-[#0A39DF] hover:shadow-sm transition-all">
<Link to="/university" className="block bg-white border border-[#E5E7EB] rounded-xl p-5 hover:border-[#0838E0] hover:shadow-sm transition-all">
<div className="flex items-center gap-2 mb-1">
<div className="w-8 h-8 rounded-lg bg-[#0A39DF] flex items-center justify-center shrink-0"><Route className="w-4 h-4 text-white" /></div>
<div className="w-8 h-8 rounded-lg bg-[#0838E0] flex items-center justify-center shrink-0"><Route className="w-4 h-4 text-white" /></div>
<h3 className="font-semibold text-[#111827] text-[15px]">{path.name}</h3>
</div>
<p className="text-[12px] text-[#6B7280] mb-3">{path.description || `Career track to ${path.target_role || path.name}`}</p>
@@ -20,7 +20,7 @@ export default function LearningPathCard({ path, completedIds = [] }) {
])}
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded-full bg-[#F3F4F6] overflow-hidden"><div className="h-full bg-[#0A39DF]" style={{ width: `${pct}%` }} /></div>
<div className="flex-1 h-2 rounded-full bg-[#F3F4F6] overflow-hidden"><div className="h-full bg-[#0838E0]" style={{ width: `${pct}%` }} /></div>
<span className="text-[11px] font-medium text-[#6B7280]">{done}/{steps.length}</span>
</div>
</Link>

View File

@@ -0,0 +1,311 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowRight, Check, GraduationCap, Minus, Sparkles, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { LEVEL_LABEL, matchPosition, rankWorkforce, skillName } from '@/lib/skillGraph';
import { usePageSkills } from '@/lib/skills/usePageSkills';
/**
* Who is qualified for this position, and who is one module away.
*
* This is the admin half of the skill system, and it exists to answer one
* question without interpretation: *which people can do this work.* Every row is
* a person scored against this position's own skill requirements, and every
* score can be opened to show the arithmetic behind it — requirement by
* requirement, weight by weight. Nothing here is a model's opinion.
*
* Applicants and eligible workers are one list rather than two. A recruiter
* comparing candidates does not care whether the person arrived by applying or
* by qualifying, and splitting the list means the best match for a role can sit
* in the section nobody opened.
*
* Internal only: match scores, gaps, counts and comparisons never appear on the
* candidate-facing Positions page.
*/
const TONE = {
success: { chip: 'bg-[#ECFDF5] text-[#047857] border-[#A7F3D0]', bar: 'bg-[#059669]' },
brand: { chip: 'bg-[#EEF3FE] text-[#0838E0] border-[#BFDBFE]', bar: 'bg-[#0838E0]' },
warning: { chip: 'bg-[#FFFBEB] text-[#B45309] border-[#FDE68A]', bar: 'bg-[#D97706]' },
risk: { chip: 'bg-[#FEF2F2] text-[#B91C1C] border-[#FECACA]', bar: 'bg-[#DC2626]' },
};
/** One requirement, met or not, with the level on each side of the comparison. */
function RequirementRow({ line }) {
const Icon = line.met ? Check : line.close ? Minus : X;
const tone = line.met ? 'text-[#059669]' : line.close ? 'text-[#D97706]' : 'text-[#DC2626]';
return (
<li className="flex items-center gap-2 text-[12px]">
<Icon className={cn('h-3.5 w-3.5 shrink-0', tone)} aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-[#374151]">
{line.name} — {line.heldLabel}
{!line.met && <span className="text-[#9CA3AF]"> · needs {line.requiredLabel}</span>}
</span>
<span className="shrink-0 tabular-nums text-[#9CA3AF]">
{line.earned}/{line.weight}
</span>
</li>
);
}
function CandidateRow({ row, job, onView, onInterview, busy, attaches }) {
const [open, setOpen] = useState(false);
const { profile, match, application } = row;
const tone = TONE[match.tone] || TONE.warning;
/* The training prompt is shown only when Forge attached that path to
Positions. The score, the requirement rows and the gaps are unaffected —
what a position requires is the position's business, and attachment governs
only whether this page may point at the training behind a gap. */
const recommendation = row.recommendation && attaches(row.recommendation.skillId)
? row.recommendation
: null;
const applied = application
? new Date(application.created_date).toDateString() === new Date().toDateString()
? 'Applied today'
: `Applied ${new Date(application.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`
: 'Not applied — eligible from verified skills';
return (
<div className="rounded-xl border border-[#E5E7EB] p-4 transition-colors hover:border-[#0838E0]/40">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
{profile.selfie_url
? <img src={profile.selfie_url} alt="" className="h-10 w-10 shrink-0 rounded-full object-cover" />
: <div className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[#EEF3FE] text-[13px] font-semibold text-[#0838E0]">{profile.full_name?.[0]}</div>}
<div className="min-w-0">
<p className="truncate text-[14px] font-semibold text-[#111827]">{profile.full_name}</p>
<p className="truncate text-[12px] text-[#6B7280]">
{profile.current_position || profile.desired_position || 'Worker'} · {applied}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className={cn('rounded-full border px-2.5 py-1 text-[12px] font-semibold', tone.chip)}>
{match.score}% · {match.band}
</span>
</div>
</div>
{/* The score, as the requirements that produced it. */}
<ul className="mt-3 space-y-1.5">
{match.lines.map((line) => <RequirementRow key={line.skillId} line={line} />)}
</ul>
{/* The gap and the training that closes it — the Forge loop, stated on the
recruiter's page so "not ready" comes with what to do about it. */}
{recommendation?.improves && (
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-lg border border-[#DBEAFE] bg-[#EEF3FE] px-3 py-2">
<GraduationCap className="h-3.5 w-3.5 shrink-0 text-[#0838E0]" aria-hidden="true" />
<p className="min-w-0 flex-1 text-[12px] text-[#1E3A8A]">
<span className="font-semibold">{recommendation.module.title}</span> would take{' '}
{profile.full_name.split(' ')[0]} to {recommendation.unlocks} — match{' '}
<span className="font-semibold tabular-nums">{recommendation.currentScore}% → {recommendation.projectedScore}%</span>
</p>
</div>
)}
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-[#F3F4F6] pt-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="text-[12px] font-medium text-[#6B7280] hover:text-[#111827]"
>
{open ? 'Hide scoring' : 'How this score is calculated'}
</button>
<div className="flex flex-wrap items-center gap-2">
{application && application.status !== 'interview' && application.status !== 'hired' && (
<button
type="button"
disabled={busy}
onClick={() => onInterview(application)}
className="rounded-lg border border-[#E5E7EB] px-3 py-1.5 text-[12px] font-medium text-[#374151] transition-colors hover:border-[#0838E0] hover:text-[#0838E0] disabled:opacity-50"
>
Move to Interview
</button>
)}
<button
type="button"
onClick={() => onView(row)}
className="inline-flex items-center gap-1 rounded-lg bg-[#0838E0] px-3 py-1.5 text-[12px] font-medium text-white transition-colors hover:bg-[#062BAF]"
>
View Candidate <ArrowRight className="h-3 w-3" />
</button>
</div>
</div>
{open && (
<div className="mt-3 rounded-lg bg-[#F9FAFB] p-3">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-[#9CA3AF]">
{job.title} — weighted requirements
</p>
<ul className="space-y-1 text-[12px] text-[#374151]">
{match.lines.map((line) => (
<li key={line.skillId} className="flex items-baseline justify-between gap-3">
<span className="min-w-0 truncate">
{line.name} — {LEVEL_LABEL[line.required]} required, holds {line.heldLabel}
</span>
<span className="shrink-0 tabular-nums font-medium">
{line.earned} of {line.weight}
</span>
</li>
))}
</ul>
<p className="mt-2 border-t border-[#E5E7EB] pt-2 text-[12px] font-semibold text-[#111827]">
Total {match.score}% — {match.band}
</p>
{match.gaps.length > 0 && (
<p className="mt-1 text-[12px] text-[#B45309]">{match.gaps[0].note}</p>
)}
<p className="mt-1 text-[11px] text-[#9CA3AF]">
A skill one level below its requirement earns half its weight; two or more levels below earns none.
</p>
</div>
)}
</div>
);
}
export function MatchedCandidates({ job, profiles = [], courses = [], applications = [], onInterview, busy }) {
const navigate = useNavigate();
const [showAll, setShowAll] = useState(false);
const positionSkills = usePageSkills('positions');
const ranked = useMemo(
() => rankWorkforce(job, profiles, courses, applications),
[job, profiles, courses, applications]
);
/* Position-level counts, from the same scores the rows show. Internal: these
are hiring metrics, and they stay on this page. */
const counts = useMemo(() => {
const jobApps = applications.filter((a) => a.job_posting_id === job?.id);
const scoredApplicants = ranked.filter((r) => r.application);
return {
applicants: jobApps.length,
strong: scoredApplicants.filter((r) => r.match.score >= 75).length,
potential: scoredApplicants.filter((r) => r.match.score >= 50 && r.match.score < 75).length,
interview: jobApps.filter((a) => a.status === 'interview').length,
hired: jobApps.filter((a) => a.status === 'hired').length,
eligible: ranked.filter((r) => r.match.score >= 75).length,
};
}, [ranked, applications, job]);
if (!job?.skill_requirements?.length) {
return (
<div className="rounded-2xl border border-dashed border-[#E5E7EB] bg-white p-6">
<h3 className="text-[15px] font-semibold text-[#111827]">Skill matching not configured</h3>
<p className="mt-1 max-w-lg text-[13px] text-[#6B7280]">
This position has no required skills, so candidates cannot be scored against verified
training. Add them by editing the position — every requirement is a skill, a level and a
weight.
</p>
</div>
);
}
const shown = showAll ? ranked : ranked.slice(0, 5);
return (
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-[15px] font-semibold text-[#111827]">Matched candidates</h3>
<p className="mt-0.5 text-[13px] text-[#6B7280]">
{counts.eligible} {counts.eligible === 1 ? 'person meets' : 'people meet'} this position at
75% or better, scored against verified skills. Best match first.
</p>
</div>
<ul className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-[#6B7280]">
{[
['Applicants', counts.applicants],
['Strong matches', counts.strong],
['Potential', counts.potential],
['Interview', counts.interview],
['Hired', counts.hired],
].map(([label, value]) => (
<li key={label} className="whitespace-nowrap">
<span className="font-semibold tabular-nums text-[#111827]">{value}</span> {label}
</li>
))}
</ul>
</div>
{/* What the position asks for, so the scores below have a stated basis. */}
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg bg-[#F9FAFB] px-3 py-2">
<Sparkles className="h-3.5 w-3.5 shrink-0 text-[#0838E0]" aria-hidden="true" />
<span className="text-[12px] text-[#6B7280]">Requires</span>
{job.skill_requirements.map((req) => (
<span key={req.skill_id} className="rounded-full border border-[#E5E7EB] bg-white px-2 py-0.5 text-[12px] text-[#374151]">
{skillName(req.skill_id)} — {LEVEL_LABEL[req.level]}
<span className="ml-1 text-[#9CA3AF]">{req.weight}%</span>
</span>
))}
</div>
<div className="mt-4 space-y-3">
{shown.map((row) => (
<CandidateRow
key={row.profile.id}
row={row}
job={job}
busy={busy}
onView={(r) => navigate(
r.application ? `/admin/candidates/${r.application.id}` : `/admin/talent-pool?search=${encodeURIComponent(r.profile.full_name)}`
)}
onInterview={onInterview}
attaches={positionSkills.attaches}
/>
))}
</div>
{ranked.length > 5 && (
<button
type="button"
onClick={() => setShowAll((v) => !v)}
className="mt-3 text-[13px] font-medium text-[#0838E0] hover:underline"
>
{showAll ? 'Show top 5' : `Show all ${ranked.length} scored`}
</button>
)}
</div>
);
}
/**
* The candidate's own fit, for a candidate audience.
*
* Same arithmetic, one person, and nothing about anyone else — no ranking, no
* counts, no other applicants. That separation is the reason this lives beside
* `MatchedCandidates` rather than inside it: two audiences, two components, one
* scoring function that cannot drift between them.
*/
export function YourSkillFit({ job, held, className }) {
const match = matchPosition(job, held);
if (!match) return null;
const tone = TONE[match.tone] || TONE.warning;
return (
<div className={cn('rounded-2xl border border-[#E5E7EB] bg-white p-6', className)}>
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-[15px] font-semibold text-[#111827]">Your skill fit</h3>
<span className={cn('rounded-full border px-2.5 py-1 text-[12px] font-semibold', tone.chip)}>
{match.score}% · {match.band}
</span>
</div>
<ul className="mt-3 space-y-1.5">
{match.lines.map((line) => <RequirementRow key={line.skillId} line={line} />)}
</ul>
{match.gaps.length > 0 && (
<p className="mt-3 text-[12px] text-[#B45309]">{match.gaps[0].note}</p>
)}
</div>
);
}
export default MatchedCandidates;

View File

@@ -37,7 +37,7 @@ export default function MessageCandidateModal({ open, onClose, application }) {
<button
onClick={() => setChannel('message')}
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg border text-[13px] font-medium transition-all ${
channel === 'message' ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280]'
channel === 'message' ? 'border-[#0838E0] bg-[#EEF3FE] text-[#0838E0]' : 'border-[#E5E7EB] text-[#6B7280]'
}`}
>
<Mail className="w-4 h-4" /> Message
@@ -45,7 +45,7 @@ export default function MessageCandidateModal({ open, onClose, application }) {
<button
onClick={() => setChannel('call')}
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg border text-[13px] font-medium transition-all ${
channel === 'call' ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280]'
channel === 'call' ? 'border-[#0838E0] bg-[#EEF3FE] text-[#0838E0]' : 'border-[#E5E7EB] text-[#6B7280]'
}`}
>
<Phone className="w-4 h-4" /> Call
@@ -54,7 +54,7 @@ export default function MessageCandidateModal({ open, onClose, application }) {
{channel === 'call' ? (
<div className="py-6 text-center text-[13px] text-[#6B7280]">
<Phone className="w-8 h-8 text-[#0A39DF] mx-auto mb-2" />
<Phone className="w-8 h-8 text-[#0838E0] mx-auto mb-2" />
Calling {application?.phone || 'candidate'}...
</div>
) : (
@@ -62,13 +62,13 @@ export default function MessageCandidateModal({ open, onClose, application }) {
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={`Hi ${application?.applicant_name?.split(' ')[0]}, ...`}
className="w-full min-h-[100px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
className="w-full min-h-[100px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
/>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={handleSend} disabled={channel === 'message' && !message.trim()} className="bg-[#0A39DF] hover:bg-[#082EB4]">
<Button onClick={handleSend} disabled={channel === 'message' && !message.trim()} className="bg-[#0838E0] hover:bg-[#062BAF]">
<Send className="w-4 h-4 mr-2" /> Send
</Button>
</DialogFooter>

View File

@@ -30,11 +30,11 @@ export default function OwliverChatBubble({ open: externalOpen, onOpenChange, sh
shadow-[0_10px_30px_rgba(10,57,223,0.22),0_2px_6px_rgba(51,63,72,0.08)]
ring-1 ring-black/5 transition-transform duration-300 hover:scale-[1.01]"
>
<Sparkles className="w-4 h-4 text-[#0A39DF] shrink-0" />
<span className="text-[15px] text-[#B5B8D3] font-medium flex-1 truncate text-left group-hover:text-[#0A39DF] transition-colors">
<Sparkles className="w-4 h-4 text-[#0838E0] shrink-0" />
<span className="text-[15px] text-[#B5B8D3] font-medium flex-1 truncate text-left group-hover:text-[#0838E0] transition-colors">
Build your profile
</span>
<span className="relative block w-12 h-12 rounded-full overflow-hidden ring-2 ring-[#0A39DF]/15 shadow-[0_4px_12px_rgba(10,57,223,0.35)] shrink-0">
<span className="relative block w-12 h-12 rounded-full overflow-hidden ring-2 ring-[#0838E0]/15 shadow-[0_4px_12px_rgba(10,57,223,0.35)] shrink-0">
<Image src={OWLIVER_URL} alt="Owliver" fittingType="fill" className="w-full h-full" />
</span>
</motion.button>
@@ -48,7 +48,7 @@ export default function OwliverChatBubble({ open: externalOpen, onOpenChange, sh
className="flex flex-col w-[min(94vw,400px)] h-[min(82vh,620px)] rounded-2xl bg-white overflow-hidden
shadow-[0_18px_50px_rgba(10,57,223,0.28),0_4px_12px_rgba(51,63,72,0.12)] ring-1 ring-black/5"
>
<div className="flex items-center gap-2.5 px-4 py-3 bg-[#0A39DF] text-white shrink-0">
<div className="flex items-center gap-2.5 px-4 py-3 bg-[#0838E0] text-white shrink-0">
<span className="block w-8 h-8 rounded-full overflow-hidden ring-2 ring-white/40">
<Image src={OWLIVER_URL} alt="Owliver" fittingType="fill" className="w-full h-full" />
</span>

View File

@@ -12,7 +12,7 @@ export default function OwliverCoachCard({ nextCourse, matchCount, avgPay }) {
<p className="text-[18px] sm:text-[22px] font-medium text-[#0F172A] mt-3 leading-snug">
{nextCourse ? (
<>
Complete <span className="text-[#0A39DF]">{nextCourse.title}</span> to unlock stronger matches.
Complete <span className="text-[#0838E0]">{nextCourse.title}</span> to unlock stronger matches.
</>
) : (
<>Keep building your profile and I'll map your fastest path forward.</>
@@ -26,7 +26,7 @@ export default function OwliverCoachCard({ nextCourse, matchCount, avgPay }) {
{nextCourse && (
<Link
to={`/university/${nextCourse.id}`}
className="mt-7 inline-flex items-center gap-1.5 bg-[#0A39DF] text-white text-[14px] font-medium px-6 py-3 rounded-full hover:bg-[#082EB4] transition-colors"
className="mt-7 inline-flex items-center gap-1.5 bg-[#0838E0] text-white text-[14px] font-medium px-6 py-3 rounded-full hover:bg-[#062BAF] transition-colors"
>
Start Learning <ArrowRight className="w-4 h-4" />
</Link>

View File

@@ -4,9 +4,9 @@ import { cn } from '@/lib/utils';
const STAGES = [
{ key: 'applied', label: 'Applied', color: 'bg-[#D1E0D7]', text: 'text-[#333F48]' },
{ key: 'ai_screened', label: 'AI Screened', color: 'bg-[#F8E08E]', text: 'text-[#333F48]' },
{ key: 'shortlisted', label: 'Shortlisted', color: 'bg-[#F9E547]', text: 'text-[#0A39DF]' },
{ key: 'shortlisted', label: 'Shortlisted', color: 'bg-[#F9E547]', text: 'text-[#0838E0]' },
{ key: 'interview', label: 'Interviewed', color: 'bg-[#333F48]', text: 'text-white' },
{ key: 'hired', label: 'Hired', color: 'bg-[#0A39DF]', text: 'text-white' },
{ key: 'hired', label: 'Hired', color: 'bg-[#0838E0]', text: 'text-white' },
];
export default function PipelineFunnel({ applications }) {

View File

@@ -102,7 +102,7 @@ export default function RateEndorseModal({ open, onClose, staff, reviewerName })
onChange={(e) => setText(e.target.value)}
rows={3}
placeholder="Share what made them great to work with..."
className="w-full rounded-lg border border-[#E5E7EB] bg-white px-3 py-2 text-[13px] focus:border-[#0A39DF] focus:outline-none resize-none"
className="w-full rounded-lg border border-[#E5E7EB] bg-white px-3 py-2 text-[13px] focus:border-[#0838E0] focus:outline-none resize-none"
/>
</div>
@@ -141,7 +141,7 @@ export default function RateEndorseModal({ open, onClose, staff, reviewerName })
<button
onClick={submit}
disabled={update.isPending}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-50 text-white text-[13px] font-medium"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-[#0838E0] hover:bg-[#062BAF] disabled:opacity-50 text-white text-[13px] font-medium"
>
<Award className="w-4 h-4" />
{update.isPending ? 'Saving...' : 'Save Rating'}

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Quote, BadgeCheck, Star, ChevronDown } from 'lucide-react';
const COMPANY_COLORS = ['bg-[#0A39DF]', 'bg-[#333F48]', 'bg-[#082EB4]'];
const COMPANY_COLORS = ['bg-[#0838E0]', 'bg-[#333F48]', 'bg-[#062BAF]'];
const colorFor = (name) => {
let hash = 0;
@@ -86,10 +86,10 @@ export default function ReferencesEndorsements({ application }) {
</div>
<p className="text-[12px] text-[#374151] mt-2 leading-relaxed">"{v.quote}"</p>
<div className="flex items-center gap-2 mt-2.5">
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors">
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0838E0] text-[#0838E0] text-[11px] font-semibold hover:bg-[#EEF3FE] transition-colors">
Request Reference
</button>
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors">
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0838E0] text-[#0838E0] text-[11px] font-semibold hover:bg-[#EEF3FE] transition-colors">
Endorse Skill
</button>
</div>
@@ -105,7 +105,7 @@ export default function ReferencesEndorsements({ application }) {
</p>
<div className="flex flex-wrap gap-2">
{strengths.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[11px] font-medium border border-[#DBEAFE]">
<BadgeCheck className="w-3 h-3" />
{typeof s === 'string' ? s : s.skill || s}
</span>

View File

@@ -20,10 +20,10 @@ export default function RoleSelector({ value, onChange }) {
onClick={() => onChange(r.value)}
className={cn(
'p-4 rounded-xl border-2 text-left transition-all',
active ? 'border-[#0A39DF] bg-[#EFF6FF]' : 'border-[#E5E7EB] hover:border-[#D1D5DB]'
active ? 'border-[#0838E0] bg-[#EEF3FE]' : 'border-[#E5E7EB] hover:border-[#D1D5DB]'
)}
>
<Icon className={cn('w-5 h-5 mb-2', active ? 'text-[#0A39DF]' : 'text-[#9CA3AF]')} />
<Icon className={cn('w-5 h-5 mb-2', active ? 'text-[#0838E0]' : 'text-[#9CA3AF]')} />
<p className={cn('text-[13px] font-semibold', active ? 'text-[#1E40AF]' : 'text-[#374151]')}>{r.label}</p>
<p className="text-[11px] text-[#6B7280] mt-0.5">{r.desc}</p>
</button>

View File

@@ -49,7 +49,7 @@ export default function ScheduleInterviewModal({ open, onClose, application }) {
key={opt.value}
onClick={() => setType(opt.value)}
className={`flex flex-col items-center gap-1 py-2.5 rounded-lg border text-[12px] font-medium transition-all ${
type === opt.value ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280] hover:bg-[#F9FAFB]'
type === opt.value ? 'border-[#0838E0] bg-[#EEF3FE] text-[#0838E0]' : 'border-[#E5E7EB] text-[#6B7280] hover:bg-[#F9FAFB]'
}`}
>
<opt.icon className="w-4 h-4" />
@@ -76,13 +76,13 @@ export default function ScheduleInterviewModal({ open, onClose, application }) {
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add any prep notes..."
className="w-full mt-1.5 min-h-[60px] p-2.5 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
className="w-full mt-1.5 min-h-[60px] p-2.5 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={handleSchedule} disabled={!date || !time} className="bg-[#0A39DF] hover:bg-[#082EB4]">
<Button onClick={handleSchedule} disabled={!date || !time} className="bg-[#0838E0] hover:bg-[#062BAF]">
<Clock className="w-4 h-4 mr-2" /> Schedule
</Button>
</DialogFooter>

View File

@@ -5,7 +5,7 @@ const TIERS = [
{ label: '0-39', range: [0, 39], color: '#333F48' },
{ label: '40-59', range: [40, 59], color: '#D1E0D7' },
{ label: '60-79', range: [60, 79], color: '#F8E08E' },
{ label: '80-100', range: [80, 100], color: '#0A39DF' },
{ label: '80-100', range: [80, 100], color: '#0838E0' },
];
export default function ScoreDistribution({ applications }) {

View File

@@ -4,7 +4,7 @@ import React from 'react';
const LEVELS = [
{ name: 'Claimed', fill: 35, bar: 'bg-[#CBD5E1]', text: 'text-[#94A3B8]' },
{ name: 'Practiced', fill: 62, bar: 'bg-[#93C5FD]', text: 'text-[#3B82F6]' },
{ name: 'Verified', fill: 84, bar: 'bg-[#0A39DF]', text: 'text-[#0A39DF]' },
{ name: 'Verified', fill: 84, bar: 'bg-[#0838E0]', text: 'text-[#0838E0]' },
{ name: 'Expert', fill: 94, bar: 'bg-[#6366F1]', text: 'text-[#6366F1]' },
{ name: 'Elite', fill: 100, bar: 'bg-[#F9E547]', text: 'text-[#92400E]' },
];

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { LEVEL_LABEL, SKILLS, skillById } from '@/lib/skillGraph';
/**
* Required skills on a position — the recruiter's half of the matching engine.
*
* A requirement is three things and no more: which skill, the level the work
* actually needs, and how much of the role that skill carries. Everything the
* candidate list later shows — the percentage, the band, the gap, the training
* recommendation — is computed from these rows, so this is the only place a
* position says what it needs.
*
* Weights are shown as a running total but not enforced to 100: they are
* normalised when scoring, so a position with 40/25/20/15 and one with 4/2/2/1
* produce the same match. Reporting the total is still worth it — it is how a
* recruiter sees which skill they have made the deciding one.
*
* Styled to match the surrounding Create Position form rather than the Admin
* design system, because that is the form this lives in.
*/
export function SkillRequirementsField({ value = [], onChange }) {
const rows = Array.isArray(value) ? value : [];
const used = new Set(rows.map((r) => r.skill_id));
const available = SKILLS.filter((s) => !used.has(s.id));
const total = rows.reduce((sum, r) => sum + (Number(r.weight) || 0), 0);
const update = (index, patch) =>
onChange(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
const add = () => {
const skill = available[0];
if (!skill) return;
/* A flat 25 rather than "whatever is left of 100": weights are normalised
when scoring, so the total need not land on 100, and a predictable default
beats one that changes depending on what was added before it. */
onChange([...rows, { skill_id: skill.id, level: skill.levels[0], weight: 25 }]);
};
return (
<div>
<div className="flex items-center justify-between gap-3">
<label className="text-[13px] font-medium text-[#374151]">
Required Skills
<span className="ml-1.5 font-normal text-[#6B7280]">— what a candidate must have verified in KROW Forge</span>
</label>
{rows.length > 0 && (
<span className={`text-[12px] font-medium ${total === 100 ? 'text-[#059669]' : 'text-[#6B7280]'}`}>
{total}% weighted
</span>
)}
</div>
{rows.length === 0 ? (
<p className="mt-1.5 text-[12px] text-[#6B7280]">
No skill requirements yet. Without them this position cannot be matched against verified skills.
</p>
) : (
<div className="mt-2 space-y-2">
{rows.map((row, i) => {
const skill = skillById(row.skill_id);
const levels = skill?.levels || [];
return (
<div key={`${row.skill_id}-${i}`} className="flex flex-wrap items-center gap-2">
<select
value={row.skill_id}
onChange={(e) => {
const nextSkill = skillById(e.target.value);
update(i, {
skill_id: e.target.value,
/* A skill with a shorter ladder cannot hold the old level. */
level: nextSkill?.levels.includes(row.level) ? row.level : nextSkill?.levels[0],
});
}}
className="h-10 min-w-[9.5rem] flex-1 rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none"
>
{[skill, ...available].filter(Boolean).map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<select
value={row.level}
onChange={(e) => update(i, { level: e.target.value })}
aria-label="Required level"
className="h-10 min-w-[8rem] rounded-lg border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0838E0] focus:outline-none"
>
{levels.map((l) => <option key={l} value={l}>{LEVEL_LABEL[l]}</option>)}
</select>
<div className="flex items-center gap-1.5">
<input
type="number"
min="0"
max="100"
value={row.weight}
onChange={(e) => update(i, { weight: Number(e.target.value) || 0 })}
aria-label={`${skill?.name || 'Skill'} weight`}
className="h-10 w-20 rounded-lg border border-[#E5E7EB] px-3 text-[13px] focus:border-[#0838E0] focus:outline-none"
/>
<span className="text-[13px] text-[#6B7280]">%</span>
</div>
<button
type="button"
onClick={() => onChange(rows.filter((_, n) => n !== i))}
aria-label={`Remove ${skill?.name || 'requirement'}`}
className="grid h-10 w-10 place-items-center rounded-lg border border-[#E5E7EB] text-[#6B7280] transition-colors hover:border-[#FCA5A5] hover:text-[#DC2626]"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
})}
</div>
)}
{available.length > 0 && (
<button
type="button"
onClick={add}
className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-[#E5E7EB] px-3 py-2 text-[13px] font-medium text-[#374151] transition-colors hover:border-[#0838E0] hover:text-[#0838E0]"
>
<Plus className="h-3.5 w-3.5" /> Add required skill
</button>
)}
</div>
);
}
export default SkillRequirementsField;

View File

@@ -25,7 +25,7 @@ function getPortraitSeed(name) {
}
const MATCH_LABEL_STYLES = {
'Strong Match': 'bg-[#EFF6FF] text-[#0A39DF]',
'Strong Match': 'bg-[#EEF3FE] text-[#0838E0]',
'Good Match': 'bg-[#F0FDF4] text-[#16A34A]',
'Possible Fit': 'bg-[#FFFBEB] text-[#D97706]',
'Weak Fit': 'bg-[#F3F4F6] text-[#6B7280]',
@@ -33,7 +33,7 @@ const MATCH_LABEL_STYLES = {
const RECO_STYLES = {
Hire: 'bg-[#16A34A] text-white',
Interview: 'bg-[#0A39DF] text-white',
Interview: 'bg-[#0838E0] text-white',
Maybe: 'bg-[#D97706] text-white',
Pass: 'bg-[#9CA3AF] text-white',
};
@@ -76,13 +76,13 @@ export default function SuggestedTalent({ job, onContact, onSchedule, onHire })
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-[#0A39DF]" />
<div className="w-7 h-7 rounded-lg bg-[#EEF3FE] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-[#0838E0]" />
</div>
<h3 className="text-[15px] font-semibold text-[#111827]">Suggested Talent</h3>
</div>
{matches.length > 0 && (
<button onClick={runMatch} disabled={matchTalent.isPending} className="text-[12px] font-medium text-[#0A39DF] hover:underline disabled:opacity-50">
<button onClick={runMatch} disabled={matchTalent.isPending} className="text-[12px] font-medium text-[#0838E0] hover:underline disabled:opacity-50">
{matchTalent.isPending ? 'Re-matching…' : 'Re-run match'}
</button>
)}
@@ -104,7 +104,7 @@ export default function SuggestedTalent({ job, onContact, onSchedule, onHire })
const p = m.profile;
const band = getScoreBand(p.krow_score || 0);
return (
<div key={m.profile_id} className="flex items-start gap-3 p-3 rounded-xl border border-[#E5E7EB] hover:border-[#0A39DF] hover:bg-[#FAFBFF] transition-colors cursor-pointer" onClick={() => setSelected(p)}>
<div key={m.profile_id} className="flex items-start gap-3 p-3 rounded-xl border border-[#E5E7EB] hover:border-[#0838E0] hover:bg-[#FAFBFF] transition-colors cursor-pointer" onClick={() => setSelected(p)}>
<div className="w-11 h-11 rounded-full bg-[#E5E7EB] overflow-hidden shrink-0">
<Image
src={p.selfie_url || `https://images.unsplash.com/${getPortraitSeed(p.full_name)}?w=120&h=120&fit=crop&crop=faces`}
@@ -121,7 +121,7 @@ export default function SuggestedTalent({ job, onContact, onSchedule, onHire })
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-[#6B7280] flex-wrap">
{(p.desired_position || p.current_position) && (
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
<span className="inline-flex items-center gap-1 font-medium text-[#0838E0]">
<Briefcase className="w-3 h-3" />{p.desired_position || p.current_position}
</span>
)}
@@ -137,7 +137,7 @@ export default function SuggestedTalent({ job, onContact, onSchedule, onHire })
)}
</div>
<div className="flex flex-col items-center justify-center shrink-0 px-2">
<span className="text-[20px] font-bold text-[#0A39DF] leading-none">{Math.round(m.match_score)}</span>
<span className="text-[20px] font-bold text-[#0838E0] leading-none">{Math.round(m.match_score)}</span>
<span className="text-[9px] text-[#9CA3AF] tracking-wide">MATCH</span>
</div>
</div>

View File

@@ -50,7 +50,7 @@ const BADGE_LEVEL_STYLES = {
bronze: 'bg-[#FEF3C7] text-[#92400E] border-[#FCD34D]',
silver: 'bg-[#F3F4F6] text-[#4B5563] border-[#D1D5DB]',
gold: 'bg-[#FEF9E7] text-[#92400E] border-[#F9E547]',
platinum: 'bg-[#EFF6FF] text-[#1E40AF] border-[#BFDBFE]',
platinum: 'bg-[#EEF3FE] text-[#1E40AF] border-[#BFDBFE]',
};
function Section({ label, children }) {
@@ -104,7 +104,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
const identityVerified = capabilities.length > 0 || badges.length > 0;
const identityTag = identityVerified ? 'Verified' : 'Pending';
const identityClass = identityVerified ? 'bg-[#EFF6FF] text-[#1E40AF]' : 'bg-[#FFF7ED] text-[#B45309]';
const identityClass = identityVerified ? 'bg-[#EEF3FE] text-[#1E40AF]' : 'bg-[#FFF7ED] text-[#B45309]';
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-0">
@@ -169,10 +169,10 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
{/* Match banner */}
{matchScore != null && (
<div className="rounded-xl border border-[#DBEAFE] bg-[#EFF6FF] p-4">
<div className="rounded-xl border border-[#DBEAFE] bg-[#EEF3FE] p-4">
<div className="flex items-center gap-2 mb-1.5">
<Sparkles className="w-4 h-4 text-[#0A39DF]" />
<span className="text-[13px] font-bold text-[#0A39DF]">Match Score: {matchScore}/100</span>
<Sparkles className="w-4 h-4 text-[#0838E0]" />
<span className="text-[13px] font-bold text-[#0838E0]">Match Score: {matchScore}/100</span>
</div>
{matchReasons?.length > 0 && (
<ul className="text-[12px] text-[#374151] list-disc list-inside space-y-0.5">
@@ -250,8 +250,8 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{profile.personality && (
<div className="flex gap-2.5">
<span className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center shrink-0">
<Brain className="w-3.5 h-3.5 text-[#0A39DF]" />
<span className="w-7 h-7 rounded-lg bg-[#EEF3FE] flex items-center justify-center shrink-0">
<Brain className="w-3.5 h-3.5 text-[#0838E0]" />
</span>
<div className="min-w-0">
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1">PERSONALITY</p>
@@ -261,8 +261,8 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
)}
{profile.communication_style && (
<div className="flex gap-2.5">
<span className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center shrink-0">
<MessageSquare className="w-3.5 h-3.5 text-[#0A39DF]" />
<span className="w-7 h-7 rounded-lg bg-[#EEF3FE] flex items-center justify-center shrink-0">
<MessageSquare className="w-3.5 h-3.5 text-[#0838E0]" />
</span>
<div className="min-w-0">
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1">COMMUNICATION STYLE</p>
@@ -274,7 +274,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
{strengths.length > 0 && (
<div className="pt-3 border-t border-[#F3F4F6]">
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2">STRENGTHS</p>
<PillList items={strengths} className="bg-[#EFF6FF] text-[#0A39DF]" />
<PillList items={strengths} className="bg-[#EEF3FE] text-[#0838E0]" />
</div>
)}
</div>
@@ -288,7 +288,7 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
</div>
<div className="flex items-center gap-2 flex-wrap">
{onContact && (
<button onClick={() => onContact(profile)} className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
<button onClick={() => onContact(profile)} className="inline-flex items-center gap-1.5 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
<Mail className="w-4 h-4" /> Contact
</button>
)}

View File

@@ -52,7 +52,7 @@ export default function TalentLoop({ profile, applications = [] }) {
<section className="glass-card border border-white/60 rounded-2xl p-6 sm:p-8 shadow-sm">
<div className="flex items-end justify-between flex-wrap gap-3 mb-1">
<div>
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-[#0A39DF]">The Talent Loop</span>
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-[#0838E0]">The Talent Loop</span>
<h2 className="text-[22px] font-heading font-bold text-[#0F172A] mt-1 tracking-tight">Build your reputation. Not just your resume.</h2>
</div>
<span className="text-[12px] text-[#64748B]">{reached}/{STEPS.length - 1} stages</span>
@@ -63,7 +63,7 @@ export default function TalentLoop({ profile, applications = [] }) {
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.8, ease: 'easeOut' }}
className="h-full bg-[#0A39DF] rounded-full"
className="h-full bg-[#0838E0] rounded-full"
/>
</div>
@@ -78,18 +78,18 @@ export default function TalentLoop({ profile, applications = [] }) {
to={s.to}
className={`group relative flex flex-col items-start gap-2 rounded-xl border p-3 transition-all ${
isDone
? 'bg-[#0A39DF] border-[#0A39DF] text-white'
? 'bg-[#0838E0] border-[#0838E0] text-white'
: isCurrent
? 'bg-white border-[#0A39DF] ring-2 ring-[#0A39DF]/20'
: 'bg-white border-[#E5E7EB] hover:border-[#0A39DF]/50'
? 'bg-white border-[#0838E0] ring-2 ring-[#0838E0]/20'
: 'bg-white border-[#E5E7EB] hover:border-[#0838E0]/50'
}`}
>
<div className="flex items-center justify-between w-full">
<span className={`text-[10px] font-semibold ${isDone ? 'text-white/70' : 'text-[#94A3B8]'}`}>{String(i + 1).padStart(2, '0')}</span>
{s.loop && <Repeat className={`w-3.5 h-3.5 ${isDone ? 'text-white/70' : 'text-[#94A3B8]'}`} />}
</div>
<Icon className={`w-5 h-5 ${isDone ? 'text-white' : isCurrent ? 'text-[#0A39DF]' : 'text-[#64748B]'}`} />
<span className={`text-[12px] font-medium leading-tight ${isDone ? 'text-white' : isCurrent ? 'text-[#0A39DF]' : 'text-[#334155]'}`}>
<Icon className={`w-5 h-5 ${isDone ? 'text-white' : isCurrent ? 'text-[#0838E0]' : 'text-[#64748B]'}`} />
<span className={`text-[12px] font-medium leading-tight ${isDone ? 'text-white' : isCurrent ? 'text-[#0838E0]' : 'text-[#334155]'}`}>
{s.label}
</span>
</Link>
@@ -104,7 +104,7 @@ export default function TalentLoop({ profile, applications = [] }) {
</div>
<Link
to={current.to}
className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[#0A39DF] hover:gap-2.5 transition-all"
className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[#0838E0] hover:gap-2.5 transition-all"
>
Continue <ArrowRight className="w-4 h-4" />
</Link>

View File

@@ -35,7 +35,7 @@ function CareerGauge({ fico }) {
const theta = (180 - frac * 180) * (Math.PI / 180);
const xv = cx + r * Math.cos(theta);
const yv = cy - r * Math.sin(theta);
const color = value > MIN ? '#0A39DF' : '#CBD5E1';
const color = value > MIN ? '#0838E0' : '#CBD5E1';
return (
<div className="flex flex-col items-center shrink-0">
<span className="text-[8px] font-semibold text-[#8A94A4] tracking-[0.12em] mb-0.5">CAREER SCORE</span>
@@ -47,7 +47,7 @@ function CareerGauge({ fico }) {
<circle cx={xv} cy={yv} r="5.5" fill={color} stroke="#fff" strokeWidth="1.5" />
</>
)}
<text x={cx} y={cy - 6} textAnchor="middle" className="fill-[#0A39DF]" style={{ fontSize: 20, fontWeight: 700 }}>{value}</text>
<text x={cx} y={cy - 6} textAnchor="middle" className="fill-[#0838E0]" style={{ fontSize: 20, fontWeight: 700 }}>{value}</text>
<text x={cx} y={cy + 9} textAnchor="middle" fill="#8A94A4" style={{ fontSize: 8, fontWeight: 600 }}>300-850</text>
</svg>
</div>
@@ -58,7 +58,7 @@ const BADGE_LEVEL_STYLES = {
bronze: 'bg-[#FEF3C7] text-[#92400E]',
silver: 'bg-[#F3F4F6] text-[#4B5563]',
gold: 'bg-[#FEF9E7] text-[#92400E]',
platinum: 'bg-[#EFF6FF] text-[#1E40AF]',
platinum: 'bg-[#EEF3FE] text-[#1E40AF]',
};
export default function TalentPoolCard({ profile, onContact, onView }) {
@@ -85,7 +85,7 @@ export default function TalentPoolCard({ profile, onContact, onView }) {
<div
className={cn(
'bg-white border border-[#E5E7EB] rounded-xl transition-all overflow-hidden',
expanded ? 'shadow-sm border-[#0A39DF]' : 'hover:shadow-sm hover:border-[#0A39DF]'
expanded ? 'shadow-sm border-[#0838E0]' : 'hover:shadow-sm hover:border-[#0838E0]'
)}
>
{/* Clickable header → opens full profile modal */}
@@ -130,7 +130,7 @@ export default function TalentPoolCard({ profile, onContact, onView }) {
{/* Quick meta line */}
<div className="flex items-center gap-3 text-[11px] text-[#6B7280] flex-wrap">
{profile.desired_position && (
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
<span className="inline-flex items-center gap-1 font-medium text-[#0838E0]">
<Briefcase className="w-3 h-3" />
{profile.desired_position}
</span>
@@ -155,7 +155,7 @@ export default function TalentPoolCard({ profile, onContact, onView }) {
{skills.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{skills.map((s) => (
<span key={s} className="px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[10px] font-medium">{s}</span>
<span key={s} className="px-2 py-0.5 rounded-full bg-[#EEF3FE] text-[#0838E0] text-[10px] font-medium">{s}</span>
))}
{extraSkills > 0 && (
<span className="px-2 py-0.5 rounded-full bg-[#F3F4F6] text-[#6B7280] text-[10px] font-medium">+{extraSkills}</span>
@@ -200,7 +200,7 @@ export default function TalentPoolCard({ profile, onContact, onView }) {
</p>
<div className="flex items-center gap-1.5 flex-wrap">
{endorsements.map((e, i) => (
<span key={i} className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border', e.status === 'verified' ? 'bg-[#EFF6FF] text-[#0A39DF] border-[#DBEAFE]' : 'bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]')}>
<span key={i} className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border', e.status === 'verified' ? 'bg-[#EEF3FE] text-[#0838E0] border-[#DBEAFE]' : 'bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]')}>
<BadgeCheck className="w-2.5 h-2.5" />
{e.skill}
{e.level && <span className="opacity-70">· {e.level}</span>}
@@ -226,7 +226,7 @@ export default function TalentPoolCard({ profile, onContact, onView }) {
{onContact && (
<button
onClick={(e) => { e.stopPropagation(); onContact(profile); }}
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-medium px-3 py-1.5 rounded-lg transition-colors whitespace-nowrap"
className="inline-flex items-center gap-1.5 bg-[#0838E0] hover:bg-[#062BAF] text-white text-[12px] font-medium px-3 py-1.5 rounded-lg transition-colors whitespace-nowrap"
>
<Mail className="w-3.5 h-3.5" />
Contact

View File

@@ -41,7 +41,7 @@ export default function TrustLayer({ profile }) {
<section className="glass-card border border-white/60 rounded-2xl p-6 sm:p-8 shadow-sm">
<div className="flex items-start justify-between gap-4 flex-wrap mb-6">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-[#0A39DF] flex items-center justify-center shrink-0">
<div className="w-10 h-10 rounded-xl bg-[#0838E0] flex items-center justify-center shrink-0">
<ShieldCheck className="w-5 h-5 text-white" />
</div>
<div>
@@ -51,7 +51,7 @@ export default function TrustLayer({ profile }) {
</div>
</div>
<div className="text-right">
<div className="text-[28px] font-heading font-bold text-[#0A39DF] leading-none">{verified}<span className="text-[#94A3B8] text-[18px]">/{ITEMS.length}</span></div>
<div className="text-[28px] font-heading font-bold text-[#0838E0] leading-none">{verified}<span className="text-[#94A3B8] text-[18px]">/{ITEMS.length}</span></div>
<div className="text-[11px] text-[#94A3B8] font-medium mt-1">verified</div>
</div>
</div>
@@ -62,7 +62,7 @@ export default function TrustLayer({ profile }) {
return (
<div key={item.key} className="flex items-center gap-2.5 py-1.5 border-b border-[#F1F5F9]">
{st === 'verified' ? (
<span className="w-5 h-5 rounded-full bg-[#0A39DF] flex items-center justify-center shrink-0">
<span className="w-5 h-5 rounded-full bg-[#0838E0] flex items-center justify-center shrink-0">
<Check className="w-3 h-3 text-white" />
</span>
) : (

View File

@@ -6,7 +6,7 @@ import { isUnlocked } from '@/lib/provingGround';
const DIFF_STYLES = {
beginner: { label: 'Beginner', bg: 'bg-[#F0FDF4]', text: 'text-[#16A34A]', border: 'border-[#DCFCE7]' },
intermediate: { label: 'Intermediate', bg: 'bg-[#FEF3C7]', text: 'text-[#D97706]', border: 'border-[#FDE68A]' },
advanced: { label: 'Advanced', bg: 'bg-[#EFF6FF]', text: 'text-[#0A39DF]', border: 'border-[#DBEAFE]' },
advanced: { label: 'Advanced', bg: 'bg-[#EEF3FE]', text: 'text-[#0838E0]', border: 'border-[#DBEAFE]' },
};
export default function UniversityCourseCard({ course, completed = false, profile }) {
@@ -17,13 +17,13 @@ export default function UniversityCourseCard({ course, completed = false, profil
return (
<Link
to={`/university/${course.id}`}
className="group relative flex flex-col justify-between rounded-xl border border-[#E5E7EB] bg-white p-4 hover:border-[#0A39DF] hover:shadow-md transition-all overflow-hidden font-sans"
className="group relative flex flex-col justify-between rounded-xl border border-[#E5E7EB] bg-white p-4 hover:border-[#0838E0] hover:shadow-md transition-all overflow-hidden font-sans"
>
<div>
{/* Top Meta Header */}
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className="w-2 h-2 rounded-full bg-[#0A39DF] shrink-0" />
<span className="w-2 h-2 rounded-full bg-[#0838E0] shrink-0" />
<span className="text-[11px] font-bold text-[#475569] truncate tracking-wide uppercase">
{proofSkill}
</span>
@@ -47,7 +47,7 @@ export default function UniversityCourseCard({ course, completed = false, profil
</div>
{/* Title & Description */}
<h3 className="text-[14px] font-bold text-[#0F172A] group-hover:text-[#0A39DF] tracking-tight leading-snug">
<h3 className="text-[14px] font-bold text-[#0F172A] group-hover:text-[#0838E0] tracking-tight leading-snug">
{course.title}
</h3>
@@ -78,7 +78,7 @@ export default function UniversityCourseCard({ course, completed = false, profil
)}
</div>
<div className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0A39DF] group-hover:underline">
<div className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0838E0] group-hover:underline">
<span>{completed ? 'Review' : lock.unlocked ? 'Start challenge' : 'Details'}</span>
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
</div>

View File

@@ -0,0 +1,226 @@
import React, { useMemo } from 'react';
import { ArrowRight, GraduationCap, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
levelLabel, matchingPositions, profileForEmail,
} from '@/lib/skillGraph';
import { skillStatesForPage } from '@/lib/skills/pageSkills';
/**
* What a person can actually do, according to the training they completed.
*
* Three things, in the order they matter to whoever is reading: the skills and
* the level each is verified at, what was earned most recently, and the one
* piece of training that would move them next. Every line is derived from
* completed modules — there is no self-reported skill on this panel, which is
* the whole point of it sitting next to the self-reported one.
*
* Rendered on the recruiter's candidate profile and on a worker's own profile:
* the facts are the same either way, so the component is.
*
* **Which skills appear is not this component's decision.** `pageId` is passed
* in and the Forge definitions attached to that page decide the list, so the
* candidate view and the profile view can legitimately show different skills for
* the same person without either being special-cased here. A page with nothing
* attached renders nothing at all — see the null return below.
*/
const STATUS_STYLE = {
verified: 'bg-success-muted text-success',
advancing: 'bg-krow-blue-tint text-krow-blue',
in_progress: 'bg-warning-muted text-warning',
not_started: 'bg-surface-sunken text-ink-4',
};
const STATUS_LABEL = {
verified: 'Verified',
advancing: 'Advancing',
in_progress: 'In progress',
not_started: 'Not started',
};
export function VerifiedSkills({
pageId, profile, profiles = [], courses = [], positions = [], email, customSources = [],
onOpenTraining, className,
}) {
/* Accepts a profile or the email of one, so a candidate record — which is an
application, not a worker profile — can be matched to the person behind it. */
const worker = profile || profileForEmail(profiles, email);
/**
* The page's attachments decide the list. Nothing below names a skill.
*
* Computed whether or not a worker is linked, because "is this page allowed to
* talk about skills" and "do we know who this person is" are different
* questions and answering them together produces the wrong empty state for
* one of them.
*/
const attached = useMemo(
() => skillStatesForPage(pageId, courses, worker, { customSources }),
[pageId, courses, worker, customSources]
);
const states = useMemo(() => attached.map((row) => row.state), [attached]);
/* skill id → the Forge definition governing it, so a row can name its own
training path without looking one up by name. */
const byId = useMemo(
() => new Map(attached.map((row) => [row.state.skillId, row.definition])),
[attached]
);
/** Most recent completions, newest first — what they just earned. */
const recent = useMemo(() => {
if (!worker) return [];
const byId = new Map(courses.map((c) => [c.id, c]));
return [...(worker.completed_courses || [])]
.sort((a, b) => new Date(b.completed_date) - new Date(a.completed_date))
.slice(0, 3)
.map((entry) => {
const course = byId.get(entry.course_id);
return {
id: entry.course_id,
title: entry.title,
unlocks: course?.skill_id
? `${states.find((s) => s.skillId === course.skill_id)?.name} ${levelLabel(course.target_level)}`
: null,
date: entry.completed_date,
};
});
}, [worker, courses, states]);
/** The training to take next, and the role it opens. */
const nextUp = useMemo(() => {
const candidates = states.filter((s) => s.nextRequirement);
if (!candidates.length) return null;
/* Prefer the skill closest to its next level — finishing something beats
starting something. */
const best = [...candidates].sort((a, b) => b.progressToNext - a.progressToNext)[0];
const opens = matchingPositions(worker, positions, courses)
.find((row) => row.match.gaps.some((g) => g.skillId === best.skillId));
return {
state: best,
module: best.nextRequirement,
opens: opens?.position || null,
opensScore: opens?.match.score ?? null,
};
}, [states, worker, positions, courses]);
/**
* Nothing attached to this page means no section — not an empty one.
*
* A heading over a "no skills found" card teaches the reader that the feature
* is broken. A page an author has attached nothing to should look exactly like
* a page the feature was never added to.
*/
if (!attached.length) return null;
if (!worker) {
return (
<section className={className}>
<p className="text-body-sm text-ink-3">
No worker profile is linked to this person, so there is no verified training to show.
</p>
</section>
);
}
const held = states.filter((s) => s.verifiedLevel || s.status !== 'not_started');
return (
<section className={cn('space-y-5', className)}>
<div>
<ul className="divide-y divide-border rounded-xl border border-border">
{(held.length ? held : states).map((state) => (
<li key={state.skillId} className="flex items-center gap-3 px-4 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-ink-1">
{state.name}
{/* The training path behind this level, named from the Forge
definition attached to this page. The pathway wording is
the level being worked towards, so a reader can see both
where the person is and what they are on. */}
{byId.get(state.skillId) && state.nextLevel && (
<span className="font-normal text-ink-4">
{' · '}{byId.get(state.skillId).name}
{' · '}{levelLabel(state.nextLevel)} pathway
</span>
)}
</p>
<p className="truncate text-caption text-ink-3">
{state.verifiedLabel}
{state.nextLevel && state.requiredForNext > 0 && (
<span className="text-ink-4">
{' · '}{state.completedInNext}/{state.requiredForNext} towards {levelLabel(state.nextLevel)}
{' · '}{state.progressToNext}%
</span>
)}
</p>
</div>
<span
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold',
STATUS_STYLE[state.status]
)}
>
{state.verifiedLevel && <ShieldCheck className="h-3 w-3" aria-hidden="true" />}
{STATUS_LABEL[state.status]}
</span>
</li>
))}
</ul>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<div>
<h4 className="mb-2 font-heading text-caption font-bold uppercase tracking-wide text-ink-4">
Recently earned
</h4>
{recent.length ? (
<ul className="space-y-1.5">
{recent.map((r) => (
<li key={r.id} className="text-body-sm text-ink-2">
<span className="font-medium text-ink-1">{r.unlocks || r.title}</span>
{r.unlocks && <span className="text-ink-4"> · {r.title}</span>}
</li>
))}
</ul>
) : (
<p className="text-body-sm text-ink-3">No training completed yet.</p>
)}
</div>
<div>
<h4 className="mb-2 font-heading text-caption font-bold uppercase tracking-wide text-ink-4">
Next recommended training
</h4>
{nextUp ? (
<div className="rounded-xl border border-krow-blue/30 bg-krow-blue-tint/40 p-3">
<p className="flex items-center gap-1.5 text-body-sm font-semibold text-ink-1">
<GraduationCap className="h-3.5 w-3.5 shrink-0 text-krow-blue" aria-hidden="true" />
{nextUp.module.title}
</p>
<p className="mt-0.5 text-caption text-ink-2">
Unlocks {nextUp.state.name} — {levelLabel(nextUp.module.target_level)}
{nextUp.opens && <> · would improve the match for {nextUp.opens.title}</>}
</p>
{onOpenTraining && (
<button
type="button"
onClick={() => onOpenTraining(nextUp.module, worker)}
className="mt-2 inline-flex items-center gap-1 text-caption font-semibold text-krow-blue hover:underline"
>
Start training <ArrowRight className="h-3 w-3" aria-hidden="true" />
</button>
)}
</div>
) : (
<p className="text-body-sm text-ink-3">Nothing outstanding — every ladder is complete.</p>
)}
</div>
</div>
</section>
);
}
export default VerifiedSkills;

View File

@@ -73,7 +73,7 @@ function CareerScorePrimary({ application }) {
/* Brand blue for the score it earned, a hairline-weight neutral
for the rest of the range — the same pairing the rest of the
product uses for "achieved against available". */
[`& .${gaugeClasses.valueArc}`]: { fill: '#0A39DF' },
[`& .${gaugeClasses.valueArc}`]: { fill: '#0838E0' },
[`& .${gaugeClasses.referenceArc}`]: { fill: 'hsl(var(--border))' },
}}
/>
@@ -147,9 +147,9 @@ const commLabel = (score) => {
};
const COMPANY_COLORS = [
'bg-[#0A39DF]',
'bg-[#0838E0]',
'bg-[#333F48]',
'bg-[#082EB4]',
'bg-[#062BAF]',
];
const colorFor = (name) => {

View File

@@ -17,12 +17,12 @@ export default function CareerGrowth({ profile }) {
return (
<div key={step} className="flex gap-3">
<div className="flex flex-col items-center">
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-[12px] font-bold shrink-0 ${current ? 'bg-[#0A39DF] text-white' : done ? 'bg-[#ECFDF5] text-[#16A34A] border border-[#A7F3D0]' : 'bg-[#F3F4F6] text-[#9CA3AF]'}`}>
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-[12px] font-bold shrink-0 ${current ? 'bg-[#0838E0] text-white' : done ? 'bg-[#ECFDF5] text-[#16A34A] border border-[#A7F3D0]' : 'bg-[#F3F4F6] text-[#9CA3AF]'}`}>
{done ? '✓' : i + 1}
</div>
{!last && <div className="w-0.5 flex-1 bg-[#E5E7EB] my-1 min-h-[16px]" />}
</div>
<div className={`pb-4 pt-1.5 text-[14px] font-medium ${current ? 'text-[#0A39DF]' : done ? 'text-[#0F172A]' : 'text-[#9CA3AF]'}`}>
<div className={`pb-4 pt-1.5 text-[14px] font-medium ${current ? 'text-[#0838E0]' : done ? 'text-[#0F172A]' : 'text-[#9CA3AF]'}`}>
{step}
{current && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">You are here</span>}
{last && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">Target</span>}

View File

@@ -47,7 +47,7 @@ export default function CareerPassport({ profile }) {
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
<div className="flex items-center justify-between">
<h2 className="text-[15px] font-semibold text-[#0F172A]">Career Passport</h2>
<span className="text-[12px] font-semibold text-[#0A39DF]">{pct}% Complete</span>
<span className="text-[12px] font-semibold text-[#0838E0]">{pct}% Complete</span>
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
{ITEMS.map((label) => {

View File

@@ -5,7 +5,7 @@ import { getTalentScore, toFICO, getScoreBand } from '@/lib/talentHome';
const FICO_MIN = 300;
const FICO_MAX = 850;
const BLUE = '#0A39DF';
const BLUE = '#0838E0';
export default function CareerScoreGauge({ profile }) {
const raw = getTalentScore(profile);
@@ -43,7 +43,7 @@ export default function CareerScoreGauge({ profile }) {
aria-label={`Career Score: ${score}, Range: ${FICO_MIN}-${FICO_MAX}`}
>
<span className="text-[9px] uppercase tracking-wider text-[#94A3B8] font-semibold">CAREER SCORE</span>
<span className="text-[24px] font-extrabold text-[#0A39DF] leading-none mt-0.5 tabular-nums">{score}</span>
<span className="text-[24px] font-extrabold text-[#0838E0] leading-none mt-0.5 tabular-nums">{score}</span>
<span className="text-[9px] text-[#8690A1] font-medium mt-0.5">{FICO_MIN}–{FICO_MAX}</span>
</div>
</div>

View File

@@ -8,7 +8,7 @@ function WalletTile({ icon: Icon, label, value, tone = 'neutral' }) {
neutral: 'text-[#0F172A]',
}[tone];
return (
<div className="rounded-xl bg-white border border-[#E6E8EB] p-4 transition-colors hover:border-[#0A39DF]">
<div className="rounded-xl bg-white border border-[#E6E8EB] p-4 transition-colors hover:border-[#0838E0]">
<div className="flex items-center gap-1.5 text-[#7C818A] mb-2">
<Icon className="w-3.5 h-3.5" />
<span className="text-[10px] font-semibold uppercase tracking-[0.06em]">{label}</span>
@@ -52,7 +52,7 @@ export default function CareerWallet({ profile }) {
<div className="flex flex-wrap gap-2 mt-5">
{certs.map((c, i) => (
<span key={i} className="inline-flex items-center gap-1.5 rounded-full bg-white border border-[#E6E8EB] text-[#1A1D21] text-[12px] font-medium px-3 py-1.5">
<Award className="w-3.5 h-3.5 text-[#0A39DF]" />{c}
<Award className="w-3.5 h-3.5 text-[#0838E0]" />{c}
</span>
))}
</div>

View File

@@ -4,7 +4,7 @@ import { Building2, MapPin, Check, X, ArrowLeftRight, Sparkles, Hand } from 'luc
const STATUS = {
accepted: { txt: 'Accepted', cls: 'bg-[#ECFDF5] text-[#16A34A]', icon: Check },
declined: { txt: 'Declined', cls: 'bg-[#F3F4F6] text-[#6B7280]', icon: X },
countered: { txt: 'Counter sent', cls: 'bg-[#EFF6FF] text-[#0A39DF]', icon: ArrowLeftRight },
countered: { txt: 'Counter sent', cls: 'bg-[#EEF3FE] text-[#0838E0]', icon: ArrowLeftRight },
};
function DemandCard({ rec, decision, counterAsk, onAccept, onDecline, onCounter, onSend }) {
@@ -16,18 +16,18 @@ function DemandCard({ rec, decision, counterAsk, onAccept, onDecline, onCounter,
return (
<div className="rounded-2xl bg-white border border-[#E5E7EB] p-5">
<div className="flex items-start gap-3">
<div className="w-11 h-11 rounded-xl bg-[#EFF6FF] text-[#0A39DF] flex items-center justify-center shrink-0">
<div className="w-11 h-11 rounded-xl bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center shrink-0">
<Building2 className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-[15px] font-bold text-[#0F172A] truncate">{job.title}</h3>
<span className="inline-flex items-center gap-0.5 text-[11px] font-bold text-[#0A39DF] bg-[#EFF6FF] px-2 py-0.5 rounded-full">{match}% match</span>
<span className="inline-flex items-center gap-0.5 text-[11px] font-bold text-[#0838E0] bg-[#EEF3FE] px-2 py-0.5 rounded-full">{match}% match</span>
</div>
<p className="text-[13px] text-[#6B7280] mt-0.5 flex items-center gap-1.5 flex-wrap">
<span className="inline-flex items-center gap-0.5"><MapPin className="w-3 h-3" />{job.location || 'Remote'}</span>
{pay && <span>· {pay}</span>}
<span className="text-[#0A39DF] font-medium">wants to interview you</span>
<span className="text-[#0838E0] font-medium">wants to interview you</span>
</p>
</div>
</div>
@@ -43,13 +43,13 @@ function DemandCard({ rec, decision, counterAsk, onAccept, onDecline, onCounter,
) : (
<>
<div className="flex items-center gap-2 mt-4">
<button onClick={() => onAccept(rec)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl bg-[#0A39DF] text-white text-[13px] font-semibold hover:bg-[#082EB4] transition-colors">
<button onClick={() => onAccept(rec)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl bg-[#0838E0] text-white text-[13px] font-semibold hover:bg-[#062BAF] transition-colors">
<Check className="w-4 h-4" /> Accept
</button>
<button onClick={() => onDecline(rec)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl border border-[#E5E7EB] text-[#6B7280] text-[13px] font-semibold hover:bg-[#F9FAFB] transition-colors">
<X className="w-4 h-4" /> Decline
</button>
<button onClick={() => setCountering(v => !v)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl border border-[#0A39DF] text-[#0A39DF] text-[13px] font-semibold hover:bg-[#EFF6FF] transition-colors">
<button onClick={() => setCountering(v => !v)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl border border-[#0838E0] text-[#0838E0] text-[13px] font-semibold hover:bg-[#EEF3FE] transition-colors">
<ArrowLeftRight className="w-4 h-4" /> Counter
</button>
</div>
@@ -57,9 +57,9 @@ function DemandCard({ rec, decision, counterAsk, onAccept, onDecline, onCounter,
{countering && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-[#F3F4F6]">
<span className="text-[12px] text-[#6B7280]">Counter at</span>
<input type="number" value={ask} onChange={(e) => setAsk(e.target.value)} className="w-24 h-8 px-2 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none" />
<input type="number" value={ask} onChange={(e) => setAsk(e.target.value)} className="w-24 h-8 px-2 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none" />
<span className="text-[12px] text-[#6B7280]">/hr</span>
<button onClick={() => onSend(rec, ask)} className="ml-auto h-8 px-3 rounded-lg bg-[#0A39DF] text-white text-[12px] font-semibold hover:bg-[#082EB4]">Send</button>
<button onClick={() => onSend(rec, ask)} className="ml-auto h-8 px-3 rounded-lg bg-[#0838E0] text-white text-[12px] font-semibold hover:bg-[#062BAF]">Send</button>
</div>
)}
</>
@@ -84,7 +84,7 @@ export default function CompaniesCompeting({ jobRecs = [] }) {
<div className="flex items-center justify-between mb-5">
<div>
<h2 className="text-[18px] font-bold text-[#0F172A] flex items-center gap-2">
<Sparkles className="w-4 h-4 text-[#0A39DF]" /> Companies are competing for you
<Sparkles className="w-4 h-4 text-[#0838E0]" /> Companies are competing for you
</h2>
<p className="text-[13px] text-[#6B7280] mt-0.5">You're in demand. Accept, decline, or counter.</p>
</div>

View File

@@ -21,8 +21,8 @@ export default function CompetingOffers({ jobRecs = [] }) {
<div>
<p className="text-[15px] font-semibold text-[#0F172A]">{title}</p>
<div className="mt-1 flex flex-wrap gap-x-3 text-[12px]">
<span className="inline-flex items-center gap-1 text-[#0A39DF] font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-[#0A39DF]" /> {state}
<span className="inline-flex items-center gap-1 text-[#0838E0] font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-[#0838E0]" /> {state}
</span>
<span className="text-[#0F172A] font-medium">${pay}/hr</span>
<span className="text-[#6B7280]">{shift}</span>
@@ -31,10 +31,10 @@ export default function CompetingOffers({ jobRecs = [] }) {
{i === 1 && <span className="text-[11px] text-[#94A3B8]">Tomorrow</span>}
</div>
<div className="mt-3 flex gap-2">
<button className="flex-1 text-[12px] font-semibold text-white bg-[#0A39DF] rounded-full py-1.5 hover:bg-[#082EB4] transition-colors">
<button className="flex-1 text-[12px] font-semibold text-white bg-[#0838E0] rounded-full py-1.5 hover:bg-[#062BAF] transition-colors">
Accept
</button>
<button className="flex-1 text-[12px] font-semibold text-[#0A39DF] border border-[#0A39DF] rounded-full py-1.5 hover:bg-[#EFF6FF] transition-colors">
<button className="flex-1 text-[12px] font-semibold text-[#0838E0] border border-[#0838E0] rounded-full py-1.5 hover:bg-[#EEF3FE] transition-colors">
Counter
</button>
<button className="text-[12px] font-semibold text-[#94A3B8] border border-[#E5E7EB] rounded-full px-3 py-1.5 hover:text-[#EF4444] hover:border-[#FECACA] transition-colors">

View File

@@ -50,7 +50,7 @@ export default function ExperienceEndorsements({ profile, onReference }) {
<Stars count={x.stars} />
<button
onClick={() => onReference?.(x)}
className="hidden sm:inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors"
className="hidden sm:inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-[#0838E0] text-[#0838E0] text-[11px] font-semibold hover:bg-[#EEF3FE] transition-colors"
>
Request Reference
</button>

View File

@@ -36,7 +36,7 @@ export default function ExperienceNetwork({ profile }) {
<Star key={s} className="w-3.5 h-3.5 text-[#F59E0B] fill-[#F59E0B]" />
))}
</div>
<button className="text-[12px] font-semibold text-[#0A39DF] border border-[#0A39DF] rounded-full px-3 py-1.5 hover:bg-[#EFF6FF] transition-colors whitespace-nowrap">
<button className="text-[12px] font-semibold text-[#0838E0] border border-[#0838E0] rounded-full px-3 py-1.5 hover:bg-[#EEF3FE] transition-colors whitespace-nowrap">
{x.action}
</button>
</div>

View File

@@ -48,7 +48,7 @@ function download(filename, content, type) {
URL.revokeObjectURL(url);
}
const BLUE = '#0A39DF';
const BLUE = '#0838E0';
function Gauge({ fico }) {
return (
@@ -80,7 +80,7 @@ function Gauge({ fico }) {
aria-label={`Career Score: ${fico}, Range: 300-850`}
>
<span className="text-[9px] uppercase tracking-wider text-[#94A3B8] font-semibold">CAREER SCORE</span>
<span className="text-[24px] font-extrabold text-[#0A39DF] leading-none mt-0.5 tabular-nums">{fico}</span>
<span className="text-[24px] font-extrabold text-[#0838E0] leading-none mt-0.5 tabular-nums">{fico}</span>
<span className="text-[9px] text-[#8690A1] font-medium mt-0.5">300–850</span>
</div>
</div>
@@ -91,7 +91,7 @@ function FactorPill({ label, value }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/70 border border-[#E6ECF5] px-3 py-1.5 text-[11px] font-medium text-[#374151] backdrop-blur-sm">
<span className="text-[#9CA3AF]">{label}</span>
<span className="font-semibold text-[#0A39DF]">{value}</span>
<span className="font-semibold text-[#0838E0]">{value}</span>
</span>
);
}
@@ -170,14 +170,14 @@ export default function IdentityCareerCard({ profile }) {
<div className="relative p-5 text-center">
{/* Identity header */}
<div className="mx-auto w-16 h-16 rounded-full overflow-hidden border-4 border-white shadow-md bg-[#EFF6FF] flex items-center justify-center mb-2">
<div className="mx-auto w-16 h-16 rounded-full overflow-hidden border-4 border-white shadow-md bg-[#EEF3FE] flex items-center justify-center mb-2">
{profile.selfie_url ? (
<Image src={profile.selfie_url} alt={name} className="w-full h-full object-cover" fittingType="fill" />
) : (
<span className="text-[20px] font-bold text-[#0A39DF]">{initials}</span>
<span className="text-[20px] font-bold text-[#0838E0]">{initials}</span>
)}
</div>
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#0A39DF]">KROW ID · {shortId}</p>
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#0838E0]">KROW ID · {shortId}</p>
<h2 className="text-[20px] font-bold text-[#0F172A] tracking-tight mt-1">{name}</h2>
<p className="text-[12px] text-[#6B7280]">{title}</p>
<div className="mt-1.5 inline-flex items-center gap-1 text-[10px] text-[#9CA3AF]">
@@ -227,7 +227,7 @@ export default function IdentityCareerCard({ profile }) {
<p className="text-[12px] font-medium text-[#0F172A] truncate">{job.role || 'Role'}</p>
<p className="text-[10px] text-[#9CA3AF]">{job.company || 'Company'}</p>
</div>
<span className="text-[10px] font-semibold text-[#0A39DF] whitespace-nowrap">{job.years ? `${job.years} yr${job.years === 1 ? '' : 's'}` : '—'}</span>
<span className="text-[10px] font-semibold text-[#0838E0] whitespace-nowrap">{job.years ? `${job.years} yr${job.years === 1 ? '' : 's'}` : '—'}</span>
</div>
))}
</div>
@@ -242,12 +242,12 @@ export default function IdentityCareerCard({ profile }) {
</div>
<div className="flex-1 flex flex-col gap-1.5">
<div className="grid grid-cols-2 gap-1.5">
<button onClick={walletNotice} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-[#0A39DF] text-white text-[10px] font-semibold hover:bg-[#082EB4] transition-colors"><Wallet className="w-3 h-3" /> Apple</button>
<button onClick={walletNotice} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0A39DF] transition-colors"><Wallet className="w-3 h-3 text-[#0A39DF]" /> Google</button>
<button onClick={walletNotice} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-[#0838E0] text-white text-[10px] font-semibold hover:bg-[#062BAF] transition-colors"><Wallet className="w-3 h-3" /> Apple</button>
<button onClick={walletNotice} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0838E0] transition-colors"><Wallet className="w-3 h-3 text-[#0838E0]" /> Google</button>
</div>
<div className="grid grid-cols-2 gap-1.5">
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0A39DF] transition-colors"><Smartphone className="w-3 h-3 text-[#0A39DF]" /> Contacts</button>
<button onClick={saveReport} disabled={busy} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0A39DF] transition-colors disabled:opacity-60"><ImageIcon className="w-3 h-3 text-[#0A39DF]" /> {busy ? '…' : 'Save'}</button>
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0838E0] transition-colors"><Smartphone className="w-3 h-3 text-[#0838E0]" /> Contacts</button>
<button onClick={saveReport} disabled={busy} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0838E0] transition-colors disabled:opacity-60"><ImageIcon className="w-3 h-3 text-[#0838E0]" /> {busy ? '…' : 'Save'}</button>
</div>
</div>
</div>

View File

@@ -23,7 +23,7 @@ export default function IncreaseMarketValue({ courses = [] }) {
<Link
key={c.id}
to={`/university/${c.id}`}
className="block rounded-xl border border-[#E5E7EB] p-4 hover:border-[#0A39DF] transition-colors"
className="block rounded-xl border border-[#E5E7EB] p-4 hover:border-[#0838E0] transition-colors"
>
<div className="flex items-center justify-between">
<p className="text-[14px] font-semibold text-[#0F172A]">{c.title}</p>
@@ -31,7 +31,7 @@ export default function IncreaseMarketValue({ courses = [] }) {
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[12px]">
<span className="text-[#16A34A] font-semibold">+{score} Score</span>
<span className="text-[#0A39DF] font-semibold">+${pay.toFixed(2)}/hr</span>
<span className="text-[#0838E0] font-semibold">+${pay.toFixed(2)}/hr</span>
<span className="inline-flex items-center gap-1 text-[#6B7280]">
<Clock className="w-3 h-3" /> {c.estimated_minutes || 15} min
</span>

View File

@@ -15,13 +15,13 @@ export default function LearnToday({ courses = [] }) {
{recs.map((c) => (
<Link key={c.id} to={`/university/${c.id}`} className="flex items-center justify-between gap-3 p-3 rounded-xl hover:bg-[#F9FAFB] border border-transparent hover:border-[#E5E7EB] transition-colors">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-[#EFF6FF] text-[#0A39DF] flex items-center justify-center"><BookOpen className="w-4 h-4" /></div>
<div className="w-9 h-9 rounded-lg bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center"><BookOpen className="w-4 h-4" /></div>
<div>
<div className="text-[14px] font-medium text-[#0F172A]">{c.title}</div>
<div className="text-[11px] text-[#6B7280]">{c.estimated_minutes} min · {c.category}</div>
</div>
</div>
<span className="text-[12px] font-bold text-[#0A39DF] whitespace-nowrap">+{Math.max(1, Math.round((c.xp || 100) / 20))} Score</span>
<span className="text-[12px] font-bold text-[#0838E0] whitespace-nowrap">+{Math.max(1, Math.round((c.xp || 100) / 20))} Score</span>
</Link>
))}
</div>

View File

@@ -63,8 +63,8 @@ export default function MarketOffers({ profile, jobRecs = [] }) {
<div>
<p className="text-[15px] font-semibold text-[#0F172A]">{title}</p>
<div className="mt-1 flex flex-wrap gap-x-3 text-[12px]">
<span className="inline-flex items-center gap-1 text-[#0A39DF] font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-[#0A39DF]" /> {state}
<span className="inline-flex items-center gap-1 text-[#0838E0] font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-[#0838E0]" /> {state}
</span>
<span className="text-[#0F172A] font-medium">${pay}/hr</span>
<span className="text-[#6B7280]">{shift}</span>
@@ -73,10 +73,10 @@ export default function MarketOffers({ profile, jobRecs = [] }) {
{i === 1 && <span className="text-[11px] text-[#94A3B8]">Tomorrow</span>}
</div>
<div className="mt-3 flex gap-2">
<button className="flex-1 text-[12px] font-semibold text-white bg-[#0A39DF] rounded-full py-1.5 hover:bg-[#082EB4] transition-colors">
<button className="flex-1 text-[12px] font-semibold text-white bg-[#0838E0] rounded-full py-1.5 hover:bg-[#062BAF] transition-colors">
Accept
</button>
<button className="flex-1 text-[12px] font-semibold text-[#0A39DF] border border-[#0A39DF] rounded-full py-1.5 hover:bg-[#EFF6FF] transition-colors">
<button className="flex-1 text-[12px] font-semibold text-[#0838E0] border border-[#0838E0] rounded-full py-1.5 hover:bg-[#EEF3FE] transition-colors">
Counter
</button>
<button className="text-[12px] font-semibold text-[#94A3B8] border border-[#E5E7EB] rounded-full px-3 py-1.5 hover:text-[#EF4444] hover:border-[#FECACA] transition-colors">

View File

@@ -23,9 +23,9 @@ export default function ReputationHistory({ profile }) {
</span>
</div>
<svg viewBox={`0 0 ${w} ${h}`} className="mt-3 w-full">
<path d={path} fill="none" stroke="#0A39DF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
<path d={path} fill="none" stroke="#0838E0" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
{pts.map((v, i) => (
<circle key={i} cx={x(i)} cy={y(v)} r={3} fill="#0A39DF" />
<circle key={i} cx={x(i)} cy={y(v)} r={3} fill="#0838E0" />
))}
</svg>
<div className="flex justify-between text-[11px] text-[#94A3B8] mt-1">

View File

@@ -3,7 +3,7 @@ import { getFactors } from '@/lib/talentHome';
function statusFor(v) {
if (v >= 80) return { txt: 'Excellent', color: 'text-[#16A34A]', dot: 'bg-[#16A34A]' };
if (v >= 60) return { txt: 'Good', color: 'text-[#0A39DF]', dot: 'bg-[#0A39DF]' };
if (v >= 60) return { txt: 'Good', color: 'text-[#0838E0]', dot: 'bg-[#0838E0]' };
if (v >= 40) return { txt: 'Fair', color: 'text-[#D97706]', dot: 'bg-[#D97706]' };
if (v > 0) return { txt: 'Poor', color: 'text-[#DC2626]', dot: 'bg-[#DC2626]' };
return { txt: 'No data', color: 'text-[#9CA3AF]', dot: 'bg-[#D1D5DB]' };

View File

@@ -35,7 +35,7 @@ function Field({ label, value, sub, accent }) {
<p className="text-[11px] uppercase tracking-wide text-[#6B7280]">{label}</p>
<div className="flex items-baseline gap-2">
<p className={`text-[18px] font-semibold ${accent || 'text-[#0F172A]'}`}>{value}</p>
{sub && <span className="text-[12px] font-medium text-[#0A39DF]">{sub}</span>}
{sub && <span className="text-[12px] font-medium text-[#0838E0]">{sub}</span>}
</div>
</div>
);
@@ -43,7 +43,7 @@ function Field({ label, value, sub, accent }) {
function statusFor(v) {
if (v >= 80) return { txt: 'Excellent', color: 'text-[#16A34A]', dot: 'bg-[#16A34A]' };
if (v >= 60) return { txt: 'Good', color: 'text-[#0A39DF]', dot: 'bg-[#0A39DF]' };
if (v >= 60) return { txt: 'Good', color: 'text-[#0838E0]', dot: 'bg-[#0838E0]' };
if (v >= 40) return { txt: 'Fair', color: 'text-[#D97706]', dot: 'bg-[#D97706]' };
if (v > 0) return { txt: 'Poor', color: 'text-[#DC2626]', dot: 'bg-[#DC2626]' };
return { txt: 'No data', color: 'text-[#9CA3AF]', dot: 'bg-[#D1D5DB]' };
@@ -82,11 +82,11 @@ export default function TalentHero({ profile, jobRecs = [], nextCourse, openOwli
: { label: 'Browse missions', sub: 'Keep leveling up', to: '/university' };
const StartButton = mission.to ? (
<Link to={mission.to} className="inline-flex items-center gap-2 bg-[#0A39DF] text-white text-[13px] font-bold px-5 py-2.5 rounded-full hover:bg-[#082EB4] transition-colors">
<Link to={mission.to} className="inline-flex items-center gap-2 bg-[#0838E0] text-white text-[13px] font-bold px-5 py-2.5 rounded-full hover:bg-[#062BAF] transition-colors">
START NOW <ArrowRight className="w-4 h-4" />
</Link>
) : (
<button onClick={mission.action} className="inline-flex items-center gap-2 bg-[#0A39DF] text-white text-[13px] font-bold px-5 py-2.5 rounded-full hover:bg-[#082EB4] transition-colors">
<button onClick={mission.action} className="inline-flex items-center gap-2 bg-[#0838E0] text-white text-[13px] font-bold px-5 py-2.5 rounded-full hover:bg-[#062BAF] transition-colors">
START NOW <ArrowRight className="w-4 h-4" />
</button>
);

View File

@@ -79,7 +79,7 @@ export default function VerifiedExperienceList({ profile, onReference, onEndorse
<Stars count={x.stars} />
<button
onClick={() => onReference?.(x)}
className="hidden sm:inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors"
className="hidden sm:inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-[#0838E0] text-[#0838E0] text-[11px] font-semibold hover:bg-[#EEF3FE] transition-colors"
>
Request Reference
</button>

View File

@@ -32,7 +32,7 @@ export default function YourNetwork({ profile }) {
))}
</div>
</div>
<button className="text-[12px] font-semibold text-[#0A39DF] border border-[#0A39DF] rounded-full px-3 py-1.5 hover:bg-[#EFF6FF] transition-colors">
<button className="text-[12px] font-semibold text-[#0838E0] border border-[#0838E0] rounded-full px-3 py-1.5 hover:bg-[#EEF3FE] transition-colors">
{n.action}
</button>
</div>

View File

@@ -16,7 +16,7 @@ const buttonVariants = cva(
[
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium",
"transition-all duration-base ease-out",
"outline-none focus-visible:ring-2 focus-visible:ring-[#0A39DF]/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
],

View File

@@ -50,7 +50,7 @@ const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...pr
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"z-[100] min-w-[8rem] overflow-hidden rounded-xl border border-[#E5E7EB] bg-white p-1 text-popover-foreground shadow-lg",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}