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

@@ -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';