update ui design for all pages

This commit is contained in:
2026-08-12 18:56:56 +05:30
parent 25c4ee4a6a
commit 3beb66059b
58 changed files with 7892 additions and 1891 deletions

View File

@@ -0,0 +1,522 @@
import React from 'react';
import { Check, Plus, Sparkles, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button, Field, Input, Modal, Textarea, toast } from '@/components/ds';
import { TYPE_HINT, TYPE_LABEL } from './challengeMeta';
/**
* Authoring a workforce skill, in the order the concept runs:
*
* define the skill → build the training → define the proof →
* configure Owliver → review and publish
*
* The wizard writes one Course record, because that is what a skill already is
* in this system: the skill fields, the training, the proof type and the rubric
* Owliver scores against all live on the same entity. Nothing here creates a
* parallel store, and nothing is written until the last step is confirmed.
*
* Every capability offered is one the application actually has. There is no file
* upload step, because there is no upload for training material to attach to —
* offering one would be a button that lies.
*/
const STEPS = [
{ key: 'skill', title: 'Define the skill', hint: 'What the workforce must be able to do.' },
{ key: 'training', title: 'Build training', hint: 'What they learn before they are tested.' },
{ key: 'proof', title: 'Define the proof', hint: 'How they demonstrate it.' },
{ key: 'owliver', title: 'Configure Owliver', hint: 'What the evaluation checks.' },
{ key: 'review', title: 'Review & publish', hint: 'Check it, then decide.' },
];
/** Proof methods, each one a challenge type the challenge runner can actually run. */
const PROOF_TYPES = ['roleplay', 'video', 'photo', 'photo_identify'];
const DIFFICULTIES = ['beginner', 'intermediate', 'advanced'];
const EMPTY = {
title: '',
category: '',
difficulty: 'beginner',
description: '',
proof_skill: '',
outline: [''],
estimated_minutes: '15',
quiz: false,
challengeType: 'roleplay',
prompt: '',
criteria: ['', '', ''],
pass_score: '70',
badge_reward: '',
};
export function AddSkillTraining({
open, onOpenChange, categories = [], onCreate, onUpdate, busy, prefill, startStep, courseId,
}) {
const [step, setStep] = React.useState(0);
const [form, setForm] = React.useState(EMPTY);
const [confirming, setConfirming] = React.useState(false);
/* `courseId` turns this from authoring into editing: the flow saves back to
the skill it was opened from rather than writing a second one. */
const editing = Boolean(courseId);
/* Owliver can open this with fields already answered, and on the step the
request was about. Resetting on open is what keeps a second creation from
inheriting the first one's answers. */
React.useEffect(() => {
if (!open) return;
const index = STEPS.findIndex((s) => s.key === startStep);
setStep(index > 0 ? index : 0);
setConfirming(false);
setForm({ ...EMPTY, ...(prefill || {}) });
}, [open, prefill, startStep]);
const set = (patch) => setForm((f) => ({ ...f, ...patch }));
const criteria = form.criteria.map((c) => c.trim()).filter(Boolean);
const outline = form.outline.map((s) => s.trim()).filter(Boolean);
/* Each step names what it still needs, so "Next" is never disabled without
the reader being told which field is missing. */
const missing = [
!form.title.trim() && 'a skill name',
!form.category.trim() && 'a category',
!form.description.trim() && 'a description',
].filter(Boolean);
const canAdvance = step === 0 ? missing.length === 0 : true;
/**
* The Course record this wizard describes.
*
* On an edit only the fields the flow owns are sent — the entity merges, so
* anything the flow does not ask about (a quiz written elsewhere, the original
* created date, XP) survives rather than being blanked by omission.
*/
const build = (status) => ({
title: form.title.trim(),
description: form.description.trim(),
category: form.category.trim(),
difficulty: form.difficulty,
proof_skill: form.proof_skill.trim() || form.title.trim(),
estimated_minutes: Number(form.estimated_minutes) || 0,
training_outline: outline,
challenge: {
type: form.challengeType,
prompt: form.prompt.trim(),
rubric: criteria.length
? criteria.map((criterion) => ({
criterion,
/* Even weights unless the author says otherwise — the last one
absorbs the rounding so the rubric always totals 100. */
weight: Math.round(100 / criteria.length),
}))
: [],
},
pass_score: Number(form.pass_score) || 70,
badge_reward: form.badge_reward.trim(),
status,
...(editing ? {} : {
quiz: [],
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
xp: 0,
created_date: new Date().toISOString(),
}),
});
const submit = async (status) => {
const name = form.title.trim() || 'The skill';
try {
if (editing) {
await onUpdate(courseId, build(status));
toast.success(
status === 'active' ? `${name} is updated and published.` : `${name} is saved as a draft.`
);
} else {
await onCreate(build(status));
toast.success(
status === 'active'
? `${name} is published to the workforce.`
: `${name} is saved as a draft.`
);
}
onOpenChange(false);
} catch {
toast.error('That could not be saved. Try again in a moment.');
}
};
const current = STEPS[step];
const isLast = step === STEPS.length - 1;
return (
<Modal
open={open}
onOpenChange={onOpenChange}
size="lg"
busy={busy}
icon={Sparkles}
title={editing ? `Edit ${form.title.trim() || 'skill training'}` : 'Add Skill Training'}
description={`Step ${step + 1} of ${STEPS.length} — ${current.title}. ${current.hint}`}
footer={(
<div className="flex w-full flex-wrap items-center justify-between gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => (step === 0 ? onOpenChange(false) : setStep(step - 1))}
disabled={busy}
>
{step === 0 ? 'Cancel' : 'Back'}
</Button>
{isLast ? (
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => submit('draft')} disabled={busy}>
Save draft
</Button>
{/* Publishing puts a skill in front of the workforce, so it asks
first rather than firing on the same click that reveals it. */}
{confirming ? (
<Button size="sm" onClick={() => submit('active')} disabled={busy}>
<Check aria-hidden="true" />
{editing ? 'Yes, save and publish' : 'Yes, publish it'}
</Button>
) : (
<Button size="sm" onClick={() => setConfirming(true)} disabled={busy}>
{editing ? 'Save & publish' : 'Publish skill'}
</Button>
)}
</div>
) : (
<Button size="sm" onClick={() => setStep(step + 1)} disabled={!canAdvance}>
Next
</Button>
)}
</div>
)}
>
<Stepper step={step} onStep={setStep} />
{step === 0 && (
<div className="space-y-4">
<Field label="Skill name" required hint="What the workforce must be able to do.">
<Input
value={form.title}
onChange={(e) => set({ title: e.target.value })}
placeholder="Knife Skills — Basic Cuts"
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Category" required hint={categories.length ? 'Reuse one, or name a new one.' : undefined}>
<Input
value={form.category}
onChange={(e) => set({ category: e.target.value })}
placeholder="Kitchen"
list="forge-categories"
/>
</Field>
<datalist id="forge-categories">
{categories.map((c) => <option key={c} value={c} />)}
</datalist>
<Field label="Difficulty">
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Difficulty">
{DIFFICULTIES.map((d) => (
<Choice
key={d}
active={form.difficulty === d}
onClick={() => set({ difficulty: d })}
className="capitalize"
>
{d}
</Choice>
))}
</div>
</Field>
</div>
<Field label="Description" required hint="One or two sentences a worker would recognise the job in.">
<Textarea
rows={3}
value={form.description}
onChange={(e) => set({ description: e.target.value })}
placeholder="Learn safe and consistent basic knife techniques used in professional kitchens."
/>
</Field>
<Field label="Verified skill name" hint="The capability this writes onto a worker's profile. Defaults to the skill name.">
<Input
value={form.proof_skill}
onChange={(e) => set({ proof_skill: e.target.value })}
placeholder="Basic Knife Cuts"
/>
</Field>
{missing.length > 0 && (
<p className="text-caption text-ink-4">
Still needs {missing.join(', ')}.
</p>
)}
</div>
)}
{step === 1 && (
<div className="space-y-4">
<ListBuilder
label="Training steps"
hint="What the workforce is taught, in order."
placeholder="Knife grip"
values={form.outline}
onChange={(outline) => set({ outline })}
addLabel="Add step"
/>
<Field label="Estimated duration" hint="Minutes, as shown on the skill card.">
<Input
type="number"
min="1"
value={form.estimated_minutes}
onChange={(e) => set({ estimated_minutes: e.target.value })}
className="max-w-[9rem]"
/>
</Field>
<p className="rounded-lg border border-border bg-surface-subtle p-3 text-caption leading-relaxed text-ink-3">
Training in Forge is written instruction plus the practice challenge on the next
step. Video and attachments are not part of this deployment, so they are not
offered here.
</p>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<Field label="Proof method" hint="How a worker demonstrates the skill. Each one is graded by Owliver.">
<div className="grid gap-2 sm:grid-cols-2">
{PROOF_TYPES.map((t) => (
<button
key={t}
type="button"
onClick={() => set({ challengeType: t })}
className={cn(
'rounded-xl border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
form.challengeType === t
? 'border-krow-blue bg-krow-blue-tint/60'
: 'border-border hover:bg-surface-subtle'
)}
>
<span className="block text-body-sm font-semibold text-ink-1">{TYPE_LABEL[t]}</span>
<span className="mt-0.5 block text-caption leading-relaxed text-ink-3">{TYPE_HINT[t]}</span>
</button>
))}
</div>
</Field>
<Field
label="The task"
hint="What you are asking the worker to do. This is what they read when the challenge opens."
>
<Textarea
rows={3}
value={form.prompt}
onChange={(e) => set({ prompt: e.target.value })}
placeholder="Record yourself preparing a julienne, a dice and a chop from one onion."
/>
</Field>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div className="rounded-lg border border-krow-blue/30 bg-krow-blue-tint/40 p-3">
<p className="inline-flex items-center gap-1.5 text-caption font-bold uppercase tracking-wider text-krow-blue">
<Sparkles className="h-3 w-3" aria-hidden="true" />
Owliver evaluates
</p>
<p className="mt-1 text-caption leading-relaxed text-ink-3">
Each line is scored against the submission. Write them as things you could point at
in a video — “safe hand position”, not “does it well”.
</p>
</div>
<ListBuilder
label="Evaluation criteria"
placeholder="Safe hand position"
values={form.criteria}
onChange={(c) => set({ criteria: c })}
addLabel="Add criterion"
/>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Pass score" hint="The score at or above which the skill is verified.">
<Input
type="number"
min="1"
max="100"
value={form.pass_score}
onChange={(e) => set({ pass_score: e.target.value })}
/>
</Field>
<Field label="Badge awarded" hint="Optional. Written onto the profile on a pass.">
<Input
value={form.badge_reward}
onChange={(e) => set({ badge_reward: e.target.value })}
placeholder="Knife Certified"
/>
</Field>
</div>
</div>
)}
{step === 4 && (
<div className="space-y-4">
<Review label="Skill" value={form.title.trim() || '—'} meta={[form.category, form.difficulty].filter(Boolean).join(' · ')} />
<Review
label="Training"
value={outline.length ? `${outline.length} ${outline.length === 1 ? 'step' : 'steps'}` : 'No written outline'}
meta={outline.join(' · ')}
/>
<Review label="Proof method" value={TYPE_LABEL[form.challengeType]} meta={form.prompt.trim()} />
<Review
label="Owliver evaluation"
value={criteria.length ? `${criteria.length} ${criteria.length === 1 ? 'criterion' : 'criteria'}` : 'None set'}
meta={criteria.join(' · ')}
/>
<Review
label="Verification"
value={`Passes at ${Number(form.pass_score) || 70}`}
meta={form.badge_reward.trim() ? `Earns ${form.badge_reward.trim()}` : 'No badge'}
/>
{confirming ? (
<p className="rounded-lg border border-warning/40 bg-warning-muted/40 p-3 text-body-sm leading-relaxed text-ink-2">
Publishing {editing ? 'keeps' : 'puts'} <span className="font-semibold">{form.title.trim() || 'this skill'}</span>
{' '}in front of the workforce straight away. Confirm below, or save it as a draft instead.
</p>
) : (
<p className="text-caption leading-relaxed text-ink-4">
Nothing is saved until you choose. A draft stays out of the workforce&apos;s view until
you publish it.
</p>
)}
</div>
)}
</Modal>
);
}
/* ── Pieces ─────────────────────────────────────────────────────────────── */
/** Where the reader is, and how to step back to anything already answered. */
function Stepper({ step, onStep }) {
return (
<ol className="mb-5 flex flex-wrap items-center gap-x-1.5 gap-y-2">
{STEPS.map((s, i) => {
const done = i < step;
const active = i === step;
return (
<li key={s.key} className="flex items-center gap-1.5">
<button
type="button"
onClick={() => i <= step && onStep(i)}
disabled={i > step}
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-caption font-semibold transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
active ? 'bg-krow-blue text-white'
: done ? 'bg-krow-blue-tint text-krow-blue hover:bg-krow-blue-tint/70'
: 'text-ink-4'
)}
>
<span className="tabular-nums">{done ? <Check className="h-3 w-3" aria-hidden="true" /> : i + 1}</span>
<span className="hidden sm:inline">{s.title}</span>
</button>
{i < STEPS.length - 1 && (
<span className="h-px w-3 bg-border sm:w-4" aria-hidden="true" />
)}
</li>
);
})}
</ol>
);
}
/** One option in a small single-choice row. */
function Choice({ active, onClick, className, children }) {
return (
<button
type="button"
role="radio"
aria-checked={active}
onClick={onClick}
className={cn(
'rounded-lg border px-3 py-1.5 text-body-sm transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
active
? 'border-krow-blue bg-krow-blue-tint/60 font-semibold text-krow-blue'
: 'border-border text-ink-2 hover:bg-surface-subtle',
className
)}
>
{children}
</button>
);
}
/** A growable list of short text lines — training steps, evaluation criteria. */
function ListBuilder({ label, hint, placeholder, values, onChange, addLabel }) {
const setAt = (i, v) => onChange(values.map((x, j) => (j === i ? v : x)));
const removeAt = (i) => onChange(values.length === 1 ? [''] : values.filter((_, j) => j !== i));
return (
<Field label={label} hint={hint}>
<div className="space-y-2">
{values.map((value, i) => (
<div key={i} className="flex items-center gap-2">
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-surface-sunken text-[10px] font-bold tabular-nums text-ink-3">
{i + 1}
</span>
<Input
value={value}
onChange={(e) => setAt(i, e.target.value)}
placeholder={i === 0 ? placeholder : ''}
/>
<button
type="button"
onClick={() => removeAt(i)}
aria-label={`Remove line ${i + 1}`}
className="grid h-7 w-7 shrink-0 place-items-center rounded-lg text-ink-4 transition-colors hover:bg-surface-sunken hover:text-ink-1
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
))}
<button
type="button"
onClick={() => onChange([...values, ''])}
className="inline-flex items-center gap-1.5 rounded text-caption font-semibold text-krow-blue hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<Plus className="h-3 w-3" aria-hidden="true" />
{addLabel}
</button>
</div>
</Field>
);
}
/** One line of the final read-through. */
function Review({ label, value, meta }) {
return (
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-border pb-3 last:border-0">
<div className="min-w-0">
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-4">{label}</p>
<p className="mt-0.5 text-body-sm font-semibold text-ink-1">{value}</p>
{meta && <p className="mt-0.5 text-caption leading-relaxed text-ink-3">{meta}</p>}
</div>
</div>
);
}

View File

@@ -1,44 +1,48 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight } from 'lucide-react';
import { Plus, Sparkles } from 'lucide-react';
/**
* The Forge masthead.
* The Forge masthead, written for the person who builds the library.
*
* An editorial header rather than the marketing banner it replaces: an eyebrow
* that names the product category, the title, one line of standfirst, and the
* single action worth taking. The rule underneath is the only decoration, and
* the page reaches the actual skill work within one screen.
* An administrator does not complete challenges — they define what the workforce
* must be able to do, and what counts as proof of it. So the header states the
* job (create skills workers can learn, practise and prove) and offers the two
* things that job starts with: author a skill, or read the ones already there.
*
* The employee-facing "continue challenge" call belongs to the challenge page,
* where a worker actually is one; putting it here made the console read as
* somebody's personal training dashboard.
*/
export function ForgeHeader({ course, actionLabel = 'Continue challenge' }) {
export function ForgeHeader({ onCreate }) {
return (
<header className="flex flex-col gap-4 border-b border-border pb-5 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<p className="text-overline uppercase tracking-[0.14em] text-krow-blue">
Workforce skill verification
</p>
<h1 className="mt-2 font-heading text-display-lg leading-none tracking-tight text-ink-1 sm:text-[2.375rem]">
<header className="flex flex-col gap-4 border-b border-border/60 pb-6 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0 space-y-1.5">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-bold text-blue-600 dark:bg-blue-950 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
<Sparkles className="h-3 w-3" />
Workforce Skill Verification
</span>
</div>
<h1 className="font-heading text-display-lg font-bold leading-none tracking-tight text-ink-1 sm:text-[2.25rem]">
KROW Forge
</h1>
<p className="mt-2.5 max-w-xl text-body-lg leading-relaxed text-ink-3">
Prove real skills through real-world challenges and build a verified workforce reputation.
<p className="max-w-xl text-body leading-relaxed text-ink-3">
Create skills your workforce can learn, practice and prove. Build practical training
and evidence-based challenges that turn real-world work into verified workforce skills.
</p>
</div>
{course && (
<Link
to={`/university/${course.id}`}
className="group inline-flex shrink-0 items-center gap-2 self-start rounded-lg bg-krow-blue px-4 py-2.5 text-body-sm font-semibold text-white
transition-colors hover:bg-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 sm:self-auto"
<div className="flex shrink-0 flex-wrap items-center gap-2 self-start sm:self-auto">
<button
type="button"
onClick={onCreate}
className="group inline-flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-body-sm font-semibold text-white shadow-md shadow-blue-500/20 transition-all hover:bg-blue-700 active:scale-[0.98]
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{actionLabel}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</Link>
)}
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
<span>Add Skill Training</span>
</button>
</div>
</header>
);
}

View File

@@ -1,37 +1,34 @@
import React from 'react';
import { ArrowRight } from 'lucide-react';
import { ArrowRight, Sparkles } from 'lucide-react';
/**
* The page's closing remark.
*
* A rule and an accent rather than a filled panel: this is commentary on
* everything above it, and commentary that arrives in its own coloured card
* claims the same weight as the work.
* Forge Insight — AI Career Insight Card
*/
export function ForgeInsight({ headline, detail, highlight, actionLabel, onAction }) {
return (
<aside className="border-l-2 border-krow-blue pl-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-overline uppercase tracking-[0.12em] text-krow-blue">
KROW Forge insight
</p>
<p className="mt-1.5 font-heading text-body-lg font-semibold text-ink-1">{headline}</p>
<p className="mt-0.5 max-w-2xl text-body-sm leading-relaxed text-ink-3">
Next best skill: <span className="font-semibold text-ink-2">{highlight}</span>. {detail}
<aside className="relative overflow-hidden rounded-2xl border border-blue-500/30 bg-gradient-to-r from-blue-50/80 via-surface to-indigo-50/30 dark:from-blue-950/40 dark:via-surface dark:to-indigo-950/30 p-5 shadow-xs">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-[11px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">
KROW Forge Insight
</span>
</div>
<p className="font-heading text-body-lg font-bold text-ink-1">{headline}</p>
<p className="max-w-2xl text-body-sm leading-relaxed text-ink-3">
Next best skill: <span className="font-semibold text-blue-600 dark:text-blue-400">{highlight}</span>. {detail}
</p>
</div>
<button
type="button"
onClick={onAction}
className="group inline-flex shrink-0 items-center gap-1.5 self-start rounded text-body-sm font-semibold text-krow-blue
transition-colors hover:text-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 sm:self-auto"
className="group inline-flex shrink-0 items-center gap-2 self-start rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 transition-all hover:bg-blue-700 active:scale-[0.98] sm:self-auto"
>
{actionLabel}
<span>{actionLabel}</span>
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-1"
aria-hidden="true"
/>
</button>

View File

@@ -1,96 +1,110 @@
import React from 'react';
import { BookOpen, Layers, ShieldCheck, Target } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Forge progress — the four figures, and the journey they describe.
* Skill overview — the state of the library, in four figures.
*
* The figures on their own were four disconnected numbers. Underneath them the
* same state is drawn as a single line: where the worker started, what they are
* proving now, the skill in front of them, and what is still gated. One rule,
* four nodes, no surfaces — competency progression rather than a scoreboard.
*
* Every node's state is derived from live counts by the page; this component
* only draws them.
* 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.
*/
const NODE = {
done: 'border-krow-blue bg-krow-blue',
current: 'border-krow-blue bg-surface ring-4 ring-krow-blue/15',
upcoming: 'border-border bg-surface',
locked: 'border-warning/60 bg-surface',
const STAT_CONFIG = {
'Active skills': {
icon: Layers,
bg: 'bg-blue-50 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
text: 'text-blue-600 dark:text-blue-400',
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
accent: 'from-blue-600 to-indigo-600',
},
'Training modules': {
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',
},
Challenges: {
icon: Target,
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',
},
'Verified skills': {
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',
},
};
const CAPTION_TONE = {
done: 'text-ink-2',
current: 'text-krow-blue',
upcoming: 'text-ink-4',
locked: 'text-warning',
};
export function ForgeProgress({ figures = [], stages = [], loading = false }) {
export function ForgeProgress({ figures = [], loading = false }) {
return (
<section aria-labelledby="forge-progress" className="border-b border-border pb-6">
<h2 id="forge-progress" className="text-overline uppercase text-ink-4">
Forge progress
</h2>
<section aria-labelledby="forge-progress" className="space-y-4 rounded-2xl border border-border/80 bg-surface p-5 sm:p-6 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-2 w-2 rounded-full bg-blue-600" />
<h2 id="forge-progress" className="font-heading text-caption font-bold uppercase tracking-wider text-ink-4">
Skill Overview
</h2>
</div>
</div>
{/* The figures. */}
<dl className="mt-3.5 flex flex-wrap items-baseline gap-x-10 gap-y-4">
{figures.map((f) => (
<div key={f.label} className="min-w-0">
<dd
{/* 4 Modern Stat Cards */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
{figures.map((f) => {
const cfg = STAT_CONFIG[f.label] || STAT_CONFIG['Active skills'];
const Icon = cfg.icon;
return (
<div
key={f.label}
className={cn(
'font-heading text-[1.75rem] font-bold leading-none tabular-nums',
f.tone || 'text-ink-1'
'group relative flex flex-col justify-between rounded-xl border bg-surface-subtle/50 p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:bg-surface',
cfg.border
)}
>
{loading ? <span className="inline-block h-6 w-10 animate-pulse rounded bg-surface-sunken" /> : f.value}
</dd>
<dt className="mt-1.5 text-body-sm text-ink-3">{f.label}</dt>
</div>
))}
</dl>
{/* The journey. */}
{stages.length > 0 && (
<ol className="mt-6 grid gap-y-4 sm:flex sm:items-start">
{stages.map((stage, i) => (
<li key={stage.label} className="relative flex min-w-0 flex-1 gap-3 sm:block">
{/* Connector — horizontal on desktop, vertical while stacked. */}
{i < stages.length - 1 && (
<span
className={cn(
'pointer-events-none absolute bg-border',
'left-[0.3125rem] top-4 h-[calc(100%-0.25rem)] w-px',
'sm:left-3 sm:top-[0.3125rem] sm:h-px sm:w-[calc(100%-0.75rem)]',
stage.state === 'done' && 'bg-krow-blue/40'
)}
aria-hidden="true"
/>
)}
<span
className={cn(
'relative z-10 mt-0.5 block h-2.5 w-2.5 shrink-0 rounded-full border-2 sm:mt-0',
NODE[stage.state] || NODE.upcoming
)}
aria-hidden="true"
/>
<span className="min-w-0 pb-1 sm:block sm:pr-6 sm:pt-2.5">
<span className="block truncate text-body-sm font-semibold text-ink-1">
{stage.label}
</span>
{stage.caption && (
<span className={cn('block truncate text-caption', CAPTION_TONE[stage.state])}>
{stage.caption}
<div>
<div className="flex items-center justify-between gap-1.5">
{/* Wraps rather than truncates: "Training modules" does not
fit one line in a two-up mobile grid, and a label reading
"TRAINING …" tells the reader nothing. */}
<span className="min-w-0 text-[10px] font-bold uppercase leading-tight tracking-wider text-ink-4">
{f.label}
</span>
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg)}>
<Icon className="h-3.5 w-3.5" />
</div>
</div>
{loading ? (
<div className="mt-2.5 h-8 w-16 animate-pulse rounded-lg bg-surface-sunken" />
) : (
<div className="mt-2 flex items-baseline gap-1.5">
<span className={cn('font-heading text-title-xl font-bold leading-none tabular-nums', cfg.text)}>
{f.value}
</span>
</div>
)}
</span>
</li>
))}
</ol>
)}
{/* Says what the number counted, so a figure is never a bare
total the reader has to guess the basis of. */}
{!loading && f.sub && (
<p className="mt-1.5 text-[11px] leading-snug text-ink-4">{f.sub}</p>
)}
</div>
<div className="mt-3">
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
</div>
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { Plus, CheckCircle2, Lightbulb, History, Layers } from 'lucide-react';
import { cn } from '@/lib/utils';
import { statusOf } from './challengeMeta';
const CREATE_STEPS = [
'Define skill',
'Add training',
'Define proof',
'Configure Owliver',
'Publish',
];
const TIPS = [
'Keep training focused on one skill.',
'Prefer practical evidence over recall.',
'Define clear verification criteria.',
'Use real workplace scenarios.',
'Keep training concise.',
];
export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver }) {
return (
<aside
className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3 items-stretch"
aria-label="Forge guidance"
>
{/* 1. Create Skill Card */}
<Panel
title="Create Skill"
icon={Layers}
iconColor="text-blue-600"
>
<ol className="space-y-2.5">
{CREATE_STEPS.map((label, i) => (
<li key={label} className="flex items-center gap-2.5 text-body-sm text-ink-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950 text-[10px] font-bold tabular-nums text-blue-600 dark:text-blue-400 border border-blue-200/50">
{i + 1}
</span>
<span className="min-w-0 font-medium">{label}</span>
</li>
))}
</ol>
<button
type="button"
onClick={onCreate}
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-caption font-bold text-white shadow-md shadow-blue-500/20 transition-all hover:bg-blue-700 active:scale-[0.98]"
>
<Plus className="h-4 w-4" />
Start a new skill
</button>
</Panel>
{/* 3. Quick Tips Card */}
<Panel
title="Quick Tips"
icon={Lightbulb}
iconColor="text-amber-500"
>
<ul className="space-y-2.5">
{TIPS.map((tip) => (
<li key={tip} className="flex items-start gap-2 text-caption leading-relaxed text-ink-2">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500 mt-0.5" aria-hidden="true" />
<span className="min-w-0 font-medium">{tip}</span>
</li>
))}
</ul>
</Panel>
{/* 4. Recent Skills Card */}
{recent.length > 0 && (
<Panel
title="Recent Skills"
icon={History}
iconColor="text-purple-600"
>
<ul className="divide-y divide-border/60">
{recent.map((course) => (
<li key={course.id}>
<button
type="button"
onClick={() => onOpen(course)}
className="group flex w-full items-center justify-between gap-3 py-2.5 text-left transition-colors hover:text-blue-600"
>
<span className="min-w-0">
<span className="block truncate text-body-sm font-semibold text-ink-1 group-hover:text-blue-600">
{course.title}
</span>
<span className="block truncate text-[10px] text-ink-4">{course.category}</span>
</span>
<span className="shrink-0 rounded-full bg-surface-subtle px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider text-ink-3">
{statusOf(course)}
</span>
</button>
</li>
))}
</ul>
</Panel>
)}
</aside>
);
}
function Panel({ title, icon: Icon, iconColor, children }) {
return (
<section className="rounded-2xl border border-border/80 bg-surface p-5 shadow-xs transition-all hover:border-blue-500/30">
<div className="flex items-center gap-2 mb-3.5 pb-2 border-b border-border/60">
{Icon && <Icon className={cn('h-4 w-4', iconColor)} />}
<h2 className="font-heading text-[11px] font-bold uppercase tracking-wider text-ink-3">
{title}
</h2>
</div>
{children}
</section>
);
}

View File

@@ -0,0 +1,173 @@
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 {
DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, TYPE_ICON, TYPE_LABEL, trainingType, typeOf,
} from './challengeMeta';
// Category color and icon resolver
function getCategoryMeta(category = '') {
const cat = category.toLowerCase();
if (cat.includes('leadership') || cat.includes('manager')) {
return {
bg: 'from-blue-500/10 via-indigo-500/5 to-transparent',
accent: 'text-blue-600 dark:text-blue-400',
border: 'border-blue-500/20',
icon: Users,
};
}
if (cat.includes('kitchen') || cat.includes('chef') || cat.includes('food')) {
return {
bg: 'from-amber-500/10 via-orange-500/5 to-transparent',
accent: 'text-amber-600 dark:text-amber-400',
border: 'border-amber-500/20',
icon: Flame,
};
}
if (cat.includes('bar') || cat.includes('beverage') || cat.includes('wine')) {
return {
bg: 'from-purple-500/10 via-violet-500/5 to-transparent',
accent: 'text-purple-600 dark:text-purple-400',
border: 'border-purple-500/20',
icon: Wine,
};
}
return {
bg: 'from-emerald-500/10 via-teal-500/5 to-transparent',
accent: 'text-emerald-600 dark:text-emerald-400',
border: 'border-emerald-500/20',
icon: ShieldCheck,
};
}
export function SkillCard({ course, onOpen }) {
const status = statusOf(course);
const type = typeOf(course);
const ProofIcon = TYPE_ICON[type] || TYPE_ICON.roleplay;
const criteria = owliverCriteria(course);
const hasChallenge = Boolean(course.challenge);
const catMeta = getCategoryMeta(course.category);
const CatIcon = catMeta.icon;
const isPublished = status === 'published';
return (
<article
onClick={() => onOpen(course)}
className="group relative flex h-full min-w-0 flex-col justify-between rounded-2xl border border-border/80 bg-surface p-4 sm:p-5 shadow-xs transition-all duration-300
hover:-translate-y-1 hover:border-blue-500/40 hover:shadow-xl cursor-pointer overflow-hidden"
>
{/* Top Background Gradient Highlight */}
<div className={cn('absolute inset-x-0 top-0 h-24 bg-gradient-to-b opacity-60 pointer-events-none transition-opacity group-hover:opacity-100', catMeta.bg)} />
{/* Main Body Area */}
<div className="relative z-10 space-y-3">
{/* Top Header Row: Category Badge (Left) & Publication Status Badge (Right) */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-surface border shadow-2xs', catMeta.border, catMeta.accent)}>
<CatIcon className="h-3 w-3" />
</span>
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-3">
{course.category}
</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>
</div>
{/* Title & Difficulty Tag */}
<div className="space-y-1">
<h3 className="font-heading text-body-lg font-bold leading-snug text-ink-1 group-hover:text-blue-600 transition-colors">
{course.title}
</h3>
{course.difficulty && (
<div className="flex items-center gap-1">
<span className={cn('inline-flex items-center gap-1 text-[11px] font-semibold capitalize', DIFF_TONE[course.difficulty] || 'text-ink-3')}>
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
{course.difficulty}
</span>
</div>
)}
</div>
{/* Description */}
<p className="line-clamp-2 text-body-sm leading-relaxed text-ink-3">
{course.description}
</p>
{/* Course Stats Pills */}
<ul className="flex flex-wrap items-center gap-1.5 text-caption">
{course.estimated_minutes > 0 && (
<li className="inline-flex items-center gap-1 rounded-md bg-surface-subtle px-2 py-0.5 text-ink-2 font-medium border border-border/50 text-[11px]">
<Clock className="h-3 w-3 text-ink-4" aria-hidden="true" />
<span>{course.estimated_minutes} min</span>
</li>
)}
<li className="inline-flex items-center gap-1 rounded-md bg-surface-subtle px-2 py-0.5 text-ink-2 font-medium border border-border/50 text-[11px]">
<BookOpen className="h-3 w-3 text-ink-4" aria-hidden="true" />
<span>{trainingType(course)}</span>
</li>
{hasChallenge && (
<li className="inline-flex items-center gap-1 rounded-md bg-blue-50/60 dark:bg-blue-950/40 px-2 py-0.5 text-blue-700 dark:text-blue-400 font-semibold border border-blue-200/60 text-[11px]">
<ProofIcon className="h-3 w-3" aria-hidden="true" />
<span>{TYPE_LABEL[type]}</span>
</li>
)}
</ul>
{/* Owliver Criteria AI Badge */}
{criteria.length > 0 && (
<div className="inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-blue-50/80 via-indigo-50/50 to-purple-50/30 dark:from-blue-950/40 dark:via-indigo-950/30 dark:to-purple-950/20 px-2 py-1 border border-blue-200/80 dark:border-blue-800/60">
<Sparkles className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" aria-hidden="true" />
<span className="text-[11px] font-semibold text-blue-900 dark:text-blue-300">
Owliver evaluated
</span>
<span className="text-[11px] font-normal text-blue-700/80 dark:text-blue-400/80">
· {criteria.length} {criteria.length === 1 ? 'criterion' : 'criteria'}
</span>
</div>
)}
</div>
{/* 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 ? (
<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"
>
<CheckCircle2 className="h-3 w-3 text-emerald-500 shrink-0" />
<span className="truncate">Verifies {course.proof_skill}</span>
</span>
) : <span />}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpen(course);
}}
className="inline-flex shrink-0 items-center gap-1 rounded-lg bg-blue-50 dark:bg-blue-950 px-2.5 py-1 text-caption font-bold text-blue-600 dark:text-blue-400 transition-all hover:bg-blue-600 hover:text-white group-hover:shadow-sm"
>
View training
<ArrowRight
className="h-3 w-3 transition-transform duration-200 group-hover:translate-x-1"
aria-hidden="true"
/>
</button>
</div>
</article>
);
}

View File

@@ -0,0 +1,184 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, Check, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge, Drawer } from '@/components/ds';
import {
DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, trainingKinds, trainingOutline,
TYPE_HINT, TYPE_LABEL, typeOf,
} from './challengeMeta';
/**
* One skill, read end to end: what it teaches, how it is proven, what Owliver
* checks, what passing means, and how the workforce has done with it.
*
* A drawer rather than a route, because this is the managing view of a record
* whose route already belongs to something else — `/admin/university/:id` runs
* the challenge itself, which is the worker's experience and stays untouched.
* The link out to it is offered at the bottom for an admin who wants to see what
* the workforce sees.
*
* Sections appear only when the record has something to put in them. Reading a
* heading with nothing under it teaches the reader to distrust the rest.
*/
export function SkillDetail({ course, open, onOpenChange, usage }) {
if (!course) return null;
const status = statusOf(course);
const type = typeOf(course);
const criteria = owliverCriteria(course);
const outline = trainingOutline(course);
const kinds = trainingKinds(course);
const rubric = course.challenge?.rubric || [];
return (
<Drawer
open={open}
onOpenChange={onOpenChange}
size="lg"
title={course.title}
description={`${course.category}${course.difficulty ? ` · ${course.difficulty}` : ''}`}
>
<div className="space-y-6 px-5 py-5">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={STATUS_TONE[status] || 'neutral'} size="sm">{status}</Badge>
{course.difficulty && (
<span className={cn('text-caption font-semibold capitalize', DIFF_TONE[course.difficulty] || 'text-ink-3')}>
{course.difficulty}
</span>
)}
{course.estimated_minutes > 0 && (
<span className="text-caption text-ink-4">{course.estimated_minutes} min</span>
)}
{course.proof_skill && (
<span className="text-caption text-ink-4">· Verifies {course.proof_skill}</span>
)}
</div>
{course.description && (
<p className="text-body-sm leading-relaxed text-ink-2">{course.description}</p>
)}
<Block title="Training" meta="What the workforce learns">
{outline.length > 0 ? (
<ol className="space-y-2">
{outline.map((step, i) => (
<li key={step} className="flex gap-3 text-body-sm text-ink-2">
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold tabular-nums text-krow-blue">
{i + 1}
</span>
<span className="min-w-0">{step}</span>
</li>
))}
</ol>
) : (
<p className="text-body-sm leading-relaxed text-ink-3">
This skill carries no written outline. It teaches through
{' '}
{kinds.length ? kinds.join(' and ').toLowerCase() : 'the challenge itself'}.
</p>
)}
{course.quiz?.length > 0 && (
<p className="mt-3 text-caption text-ink-4">
Includes a {course.quiz.length}-question knowledge check.
</p>
)}
</Block>
{course.challenge && (
<Block title="Proof" meta="How the workforce demonstrates the skill">
<p className="text-body-sm font-semibold text-ink-1">{TYPE_LABEL[type]}</p>
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">{TYPE_HINT[type]}</p>
{course.challenge.prompt && (
<p className="mt-3 rounded-lg border border-border bg-surface-subtle p-3 text-body-sm italic leading-relaxed text-ink-2">
“{course.challenge.prompt}”
</p>
)}
</Block>
)}
{criteria.length > 0 && (
<Block
title="Owliver evaluation"
meta="What Owliver checks"
icon={Sparkles}
>
<ul className="space-y-2">
{rubric.map((r) => (
<li key={r.criterion} className="flex items-baseline justify-between gap-3 text-body-sm">
<span className="flex min-w-0 items-baseline gap-2 text-ink-2">
<Check className="h-3.5 w-3.5 shrink-0 translate-y-0.5 text-krow-blue" aria-hidden="true" />
<span className="min-w-0">{r.criterion}</span>
</span>
{r.weight != null && (
<span className="shrink-0 text-caption tabular-nums text-ink-4">{r.weight}%</span>
)}
</li>
))}
</ul>
</Block>
)}
{course.pass_score > 0 && (
<Block title="Verification" meta="What qualifies as passing">
<p className="text-body-sm leading-relaxed text-ink-2">
A submission verifies the skill at{' '}
<span className="font-semibold text-ink-1">{course.pass_score} or above</span>
{course.badge_reward
? <>, and earns the <span className="font-semibold text-ink-1">{course.badge_reward}</span> badge.</>
: '.'}
</p>
</Block>
)}
{/* Usage is counted from worker profiles. Assignment and "in progress"
have no field behind them, so the two states the records do carry are
the two states shown. */}
{usage && (usage.completed > 0 || usage.verified > 0) && (
<Block title="Workforce usage" meta="From worker profiles">
<dl className="grid grid-cols-2 gap-3">
{[
{ label: 'Completed', value: usage.completed },
{ label: 'Verified', value: usage.verified },
].map((row) => (
<div key={row.label} className="rounded-lg border border-border bg-surface-subtle px-3 py-2.5">
<dt className="text-[10px] font-bold uppercase tracking-wider text-ink-4">{row.label}</dt>
<dd className="mt-1 font-heading text-title font-bold tabular-nums text-ink-1">{row.value}</dd>
</div>
))}
</dl>
</Block>
)}
<Link
to={`/admin/university/${course.id}`}
className="group inline-flex items-center gap-1.5 rounded text-body-sm font-semibold text-krow-blue hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Open the challenge as the workforce sees it
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</Link>
</div>
</Drawer>
);
}
/** A titled region of the record. */
function Block({ title, meta, icon: Icon, children }) {
return (
<section className="border-t border-border pt-4 first:border-0 first:pt-0">
<div className="mb-2.5 flex items-baseline gap-2">
<h3 className="inline-flex items-center gap-1.5 font-heading text-overline uppercase tracking-wider text-ink-4">
{Icon && <Icon className="h-3 w-3 text-krow-blue" aria-hidden="true" />}
{title}
</h3>
{meta && <span className="min-w-0 truncate text-caption text-ink-4">{meta}</span>}
</div>
{children}
</section>
);
}

View File

@@ -0,0 +1,171 @@
import React from 'react';
import { Plus, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { SkillCard } from './SkillCard';
/**
* The Skill Training Library — the working surface of admin Forge.
*
* A grid of skills rather than a queue of challenges: the reader is choosing
* which skill to inspect or extend, not which one to attempt next. Filters use
* the same underlined-control language as the rest of Forge, so the section
* needs no toolbar surface of its own.
*
* The category and status options are computed by the page from the records that
* exist, so the library never offers a filter that can only return nothing.
*/
/**
* Card columns are sized off this column, not the viewport. With Owliver open
* the page is wide while the library is not, and a viewport breakpoint put three
* cards into seven hundred pixels — `auto-fill` asks the container instead.
*/
const CARD_GRID = 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4';
export function SkillLibrary({
results,
total,
search,
onSearch,
category,
onCategory,
categories,
status,
onStatus,
statuses,
loading,
filtered,
onClear,
onOpen,
onCreate,
sectionRef,
}) {
return (
<section aria-labelledby="skill-library" className="min-w-0" ref={sectionRef}>
<div className="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<h2 id="skill-library" className="font-heading text-title font-bold text-ink-1">
Skill Training Library
</h2>
<p className="mt-1 text-body-sm text-ink-3">
Build practical training and challenges for the skills your workforce needs.
</p>
</div>
<div className="flex min-w-0 flex-col gap-x-4 gap-y-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="relative min-w-0 sm:w-52">
<Search
className="pointer-events-none absolute left-0 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4"
aria-hidden="true"
/>
<input
type="search"
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder="Search skills"
aria-label="Search skills"
className="h-8 w-full border-0 border-b border-border bg-transparent pl-6 pr-2 text-body-sm text-ink-1
placeholder:text-ink-4 focus:border-krow-blue focus:outline-none"
/>
</div>
<FilterGroup
label="Skill category"
options={categories}
value={category}
onChange={onCategory}
/>
<FilterGroup
label="Publication status"
options={statuses}
value={status}
onChange={onStatus}
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 py-2.5">
<p className="text-caption tabular-nums text-ink-4">
{results.length} of {total} skills{filtered ? ' · filtered' : ''}
</p>
{filtered && (
<button
type="button"
onClick={onClear}
className="rounded text-caption font-semibold text-krow-blue hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Clear filters
</button>
)}
</div>
{loading ? (
<div className={CARD_GRID}>
{[...Array(3)].map((_, i) => (
<div key={i} className="h-56 animate-pulse rounded-xl bg-surface-sunken" />
))}
</div>
) : results.length === 0 ? (
<div className="rounded-xl border border-dashed border-border py-12 text-center">
<p className="font-heading text-body font-semibold text-ink-1">
{filtered ? 'No skills match these filters' : 'No skills in the library yet'}
</p>
<p className="mx-auto mt-1 max-w-sm text-body-sm leading-relaxed text-ink-3">
{filtered
? 'Clear the filters to see the full library.'
: 'A skill defines what the workforce learns, how they prove it, and what Owliver checks.'}
</p>
<button
type="button"
onClick={filtered ? onClear : onCreate}
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 transition-all hover:bg-blue-700 active:scale-[0.98]
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{filtered ? 'Clear filters' : (
<>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
Add Skill Training
</>
)}
</button>
</div>
) : (
<div className={CARD_GRID}>
{results.map((course) => (
<SkillCard key={course.id} course={course} onOpen={onOpen} />
))}
</div>
)}
</section>
);
}
/** One radio row of filter options, styled as the Forge underline control. */
function FilterGroup({ label, options, value, onChange }) {
if (options.length <= 1) return null;
return (
<div className="flex flex-wrap items-center gap-3" role="radiogroup" aria-label={label}>
{options.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(o.value)}
className={cn(
'rounded text-body-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
active
? 'font-semibold text-krow-blue underline decoration-krow-blue decoration-2 underline-offset-[6px]'
: 'text-ink-3 hover:text-ink-1'
)}
>
{o.label}
</button>
);
})}
</div>
);
}

View File

@@ -1,127 +1,132 @@
import React from 'react';
import { ArrowRight, Check } from 'lucide-react';
import { ArrowRight, Award, Check, ChevronRight, Compass, FileText, ShieldCheck, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Your Forge — the record, or the process that creates it.
*
* With nothing verified yet this used to be the largest and emptiest object on
* the page. Here the empty state is the thing worth explaining instead: the four
* steps that turn a challenge into a verified skill, laid along one line. It is
* a process, not a placeholder, and it takes a fifth of the height.
*
* Once skills exist the same band lists them, so the section grows into a record
* rather than being swapped for a different component.
* Your Forge — Modern Process Workflow Cards & Skill Verification Roster
*/
const STEPS = ['Take a challenge', 'Submit proof', 'Owliver evaluates', 'Skill verified'];
const STEP_ITEMS = [
{ step: '01', title: 'Take a challenge', sub: 'Pick a skill challenge', icon: Compass },
{ step: '02', title: 'Submit proof', sub: 'Upload work evidence', icon: FileText },
{ step: '03', title: 'Owliver evaluates', sub: 'AI scoring & review', icon: Sparkles },
{ step: '04', title: 'Skill verified', sub: 'Earn verified badge', icon: ShieldCheck },
];
export function YourForge({ skills = [], evidenceCount = 0, onBrowse, formatDate }) {
const empty = skills.length === 0;
return (
<section aria-labelledby="your-forge" className="border-b border-border pb-6">
<div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
<h2 id="your-forge" className="text-overline uppercase text-ink-4">
Your Forge
</h2>
<p className="text-caption text-ink-4">
{empty
? 'Your first verified skill starts with a real-world challenge.'
: `${evidenceCount} ${evidenceCount === 1 ? 'piece' : 'pieces'} of evidence on file`}
</p>
<section aria-labelledby="your-forge" className="space-y-4 rounded-2xl border border-border/80 bg-surface p-5 sm:p-6 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/50 pb-4">
<div>
<div className="flex items-center gap-2.5">
<h2 id="your-forge" className="font-heading text-title font-bold text-ink-1">
Your Verified Skills
</h2>
<span className="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-bold text-blue-600 dark:bg-blue-950 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
{skills.length} {skills.length === 1 ? 'Skill' : 'Skills'}
</span>
</div>
<p className="mt-1 text-caption text-ink-3">
{empty
? 'Your first verified skill starts with a real-world challenge.'
: `${evidenceCount} ${evidenceCount === 1 ? 'piece' : 'pieces'} of evidence on file`}
</p>
</div>
<button
type="button"
onClick={onBrowse}
className="group inline-flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 hover:bg-blue-700 transition-all active:scale-[0.98]"
>
<span>Browse Challenges</span>
<ArrowRight className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-1" />
</button>
</div>
<p className="mt-2 font-heading text-title text-ink-1">
<span className="tabular-nums">{skills.length}</span>{' '}
{skills.length === 1 ? 'verified skill' : 'verified skills'}
</p>
{empty ? (
/* How a skill becomes verified — one line, four steps. */
<div className="mt-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:gap-0">
<ol className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row sm:items-center">
{STEPS.map((step, i) => (
<li key={step} className="flex min-w-0 items-center gap-3 sm:flex-1">
<span className="flex min-w-0 items-center gap-2.5">
<span
className={cn(
'grid h-6 w-6 shrink-0 place-items-center rounded-full border text-caption font-semibold tabular-nums',
i === 0
? 'border-krow-blue bg-krow-blue-tint text-krow-blue'
: 'border-border bg-surface text-ink-4'
)}
>
{i + 1}
</span>
<span
className={cn(
'truncate text-body-sm',
i === 0 ? 'font-semibold text-ink-1' : 'text-ink-3'
)}
>
{step}
</span>
</span>
/* 4 Process Cards Workflow */
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 pt-2">
{STEP_ITEMS.map((item, i) => {
const Icon = item.icon;
const isFirst = i === 0;
{i < STEPS.length - 1 && (
<span
className="hidden h-px flex-1 bg-border sm:block sm:min-w-[1rem]"
aria-hidden="true"
/>
return (
<div
key={item.step}
className={cn(
'group relative flex flex-col justify-between rounded-xl border p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isFirst
? 'border-blue-500/40 bg-gradient-to-br from-blue-50/70 via-surface to-blue-50/20 dark:from-blue-950/40 dark:to-surface'
: 'border-border/80 bg-surface-subtle/50 hover:bg-surface'
)}
</li>
))}
</ol>
>
<div>
<div className="flex items-center justify-between">
<span
className={cn(
'text-[10px] font-extrabold uppercase tracking-wider',
isFirst ? 'text-blue-600 dark:text-blue-400' : 'text-ink-4'
)}
>
Step {item.step}
</span>
<div
className={cn(
'flex h-7 w-7 items-center justify-center rounded-lg shadow-xs',
isFirst
? 'bg-blue-600 text-white shadow-blue-500/25'
: 'bg-surface text-ink-3 border border-border'
)}
>
<Icon className="h-3.5 w-3.5" />
</div>
</div>
<button
type="button"
onClick={onBrowse}
className="group inline-flex shrink-0 items-center gap-1.5 self-start rounded text-body-sm font-semibold text-krow-blue
transition-colors hover:text-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 lg:self-auto lg:pl-6"
>
Browse challenges
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
<h4 className="mt-3 font-heading text-body-sm font-bold text-ink-1">
{item.title}
</h4>
<p className="mt-0.5 text-caption text-ink-3">{item.sub}</p>
</div>
<div className="mt-4 flex items-center justify-between text-caption font-semibold">
<span className={isFirst ? 'text-blue-600 dark:text-blue-400' : 'text-ink-4'}>
{isFirst ? 'Start Here' : 'Upcoming'}
</span>
{i < STEP_ITEMS.length - 1 && (
<ChevronRight className="h-4 w-4 text-ink-4 hidden lg:block group-hover:translate-x-1 transition-transform" />
)}
</div>
</div>
);
})}
</div>
) : (
<ul className="mt-4 flex flex-wrap items-center gap-x-8 gap-y-3">
/* Verified Skills List */
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 pt-2">
{skills.map((badge, i) => (
<li key={badge.name || i} className="flex min-w-0 items-center gap-2.5">
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-success text-white">
<Check className="h-3 w-3" strokeWidth={3} aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="block truncate text-body-sm font-semibold text-ink-1">
{badge.name}
</span>
<span className="block truncate text-caption capitalize text-ink-4">
{badge.level || 'Standard'} · verified {formatDate?.(badge.earned_date)}
</span>
</span>
</li>
))}
<li>
<button
type="button"
onClick={onBrowse}
className="group inline-flex items-center gap-1.5 rounded text-body-sm font-semibold text-krow-blue
transition-colors hover:text-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
<div
key={badge.name || i}
className="flex items-center justify-between rounded-xl border border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20 p-3.5 shadow-xs"
>
Browse challenges
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
</ul>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-600 text-white shadow-md shadow-emerald-500/20">
<Award className="h-5 w-5" />
</div>
<div>
<h4 className="font-heading text-body-sm font-bold text-ink-1">
{badge.name}
</h4>
<p className="text-caption text-emerald-700 dark:text-emerald-400 font-medium capitalize">
{badge.level || 'Verified'} · {formatDate?.(badge.earned_date) || 'Earned'}
</p>
</div>
</div>
<Check className="h-4 w-4 text-emerald-600" />
</div>
))}
</div>
)}
</section>
);

View File

@@ -90,3 +90,85 @@ export function proofSteps(course) {
const criteria = (course?.challenge?.rubric || []).map((r) => r.criterion).filter(Boolean);
return criteria.length ? [...criteria, 'Submit your proof'] : [];
}
/* ── The same records, read as an administrator ─────────────────────────── */
/**
* Everything below reads the Course entity from the managing side rather than
* the doing side. The worker asks "what do I have to prove"; the admin asks
* "what does this skill teach, how is it proven, what does Owliver check, and is
* it live". Same fields, different question — which is why these are derivations
* here rather than a second shape stored somewhere.
*/
/**
* Publication state.
*
* The seed calls a live skill `active`; the admin vocabulary is Draft /
* Published / Archived. Mapping rather than renaming keeps every existing record
* and every existing `status === 'active'` filter in the app working.
*/
export const STATUS_LABEL = {
active: 'Published',
published: 'Published',
draft: 'Draft',
archived: 'Archived',
inactive: 'Archived',
};
export const STATUS_TONE = {
Published: 'success',
Draft: 'warning',
Archived: 'neutral',
};
export const statusOf = (course) => STATUS_LABEL[course?.status] || 'Draft';
/** The criteria Owliver scores a submission against. */
export const owliverCriteria = (course) =>
(course?.challenge?.rubric || []).map((r) => r.criterion).filter(Boolean);
/**
* What the workforce actually studies.
*
* `training_outline` is written by the Forge authoring flow. Skills that predate
* it have none, and the detail view says so rather than inventing a curriculum
* from the description.
*/
export const trainingOutline = (course) =>
(Array.isArray(course?.training_outline) ? course.training_outline : []).filter(Boolean);
/**
* The kinds of training material a skill carries, named only where the record
* actually has it. Nothing is listed on the strength of the entity *supporting*
* a field — only on it being filled in.
*/
export function trainingKinds(course) {
const kinds = [];
if (trainingOutline(course).length) kinds.push('Instructions');
if (course?.quiz?.length) kinds.push('Quiz');
if (course?.challenge?.prompt) kinds.push('Practice');
return kinds;
}
/** The one-word training type a card shows. */
export const trainingType = (course) => trainingKinds(course)[0] || 'Practice';
/**
* How a skill is doing across the workforce, counted from the worker profiles
* the platform already holds.
*
* Only completion and verification are counted, because those are the two states
* a profile records. Assignment and "started" have no field behind them, so they
* are absent here rather than estimated.
*/
export function workforceUsage(course, profiles = []) {
if (!course) return { completed: 0, verified: 0 };
let completed = 0;
let verified = 0;
for (const p of profiles) {
if ((p.completed_courses || []).some((c) => c.course_id === course.id)) completed += 1;
if ((p.earned_badges || []).some((b) => b.course_id === course.id)) verified += 1;
}
return { completed, verified };
}

View File

@@ -1,24 +1,47 @@
/**
* Forge components — the pieces the KROW Forge workspace is composed from.
* Forge components.
*
* The page tells one story and each part has a different shape, so the reader is
* never looking at the same rectangle twice:
* Two audiences read the same records, so the folder holds two sets of pieces.
*
* ForgeHeader editorial masthead
* ForgeProgress four figures, and the journey they describe
* YourForge the record, or the process that creates it
* NextSkill the one surface on the page: the skill to prove now
* ChallengeDirectory everything else, as rows on hairlines
* ForgeInsight a rule and a remark
* Admin — building and managing workforce skill verification:
*
* All state, data and the unlock gate stay with the page; these only arrange it.
* ForgeHeader the masthead and the two ways in
* ForgeProgress the state of the library, in four counted figures
* SkillLibrary every skill, filterable
* SkillCard one skill and its verification journey
* SkillDetail one skill read end to end
* AddSkillTraining skill → training → proof → Owliver → publish
* ForgeRail the authoring job, the rules, and the way to Owliver
*
* Worker — proving a skill. Kept whole: these are what the employee experience
* is built from, and reframing the admin console is no reason to remove them.
*
* YourForge the record, or the process that creates it
* NextSkill the skill to prove now
* ChallengeDirectory every other challenge, searchable
* ForgeInsight a rule and a remark
*
* State and derivation stay with the page; these only arrange what they are
* given. `challengeMeta` holds the lookups both audiences share.
*/
/* Admin */
export { ForgeHeader } from './ForgeHeader';
export { ForgeProgress } from './ForgeProgress';
export { SkillLibrary } from './SkillLibrary';
export { SkillCard } from './SkillCard';
export { SkillDetail } from './SkillDetail';
export { AddSkillTraining } from './AddSkillTraining';
export { ForgeRail } from './ForgeRail';
/* Worker */
export { YourForge } from './YourForge';
export { NextSkill } from './NextSkill';
export { ChallengeDirectory } from './ChallengeDirectory';
export { ForgeInsight } from './ForgeInsight';
export {
buildRequirements, DIFF_TONE, proofSteps, TYPE_HINT, TYPE_ICON, TYPE_LABEL, typeOf,
buildRequirements, DIFF_TONE, owliverCriteria, proofSteps, statusOf, STATUS_LABEL, STATUS_TONE,
trainingKinds, trainingOutline, trainingType, TYPE_HINT, TYPE_ICON, TYPE_LABEL, typeOf,
workforceUsage,
} from './challengeMeta';