chore(ts-migration): migrate components/forge and components/admin to TypeScript
Phase 8, second batch: 16 files. All 16 emit byte-identical JavaScript and the
production bundle is byte-identical to e7e1e98.
`ProfileView` and `CourseView` move from `components/krow/types.ts` to
`@/types/views`, because `components/forge` reads the same `jsonb` columns off
the same courses and profiles. `components/krow/types.ts` stays as a re-export
so that folder's imports are untouched. Two copies of one narrowing would be
two things to keep in step.
SCOPE, decided by imports rather than by folder name. `components/skills` was in
the batch as requested and is DEFERRED: it imports `lib/skills/registry`,
`lib/agents/runtime`, `ai-assistant/PageContext` and `ai-assistant/AgentContext`,
which makes it agent region by the same test this batch used. `ui-tree` and
`ui-editor` are deferred for the same reason - all eleven files drive the UI-node
system, which is Owliver's "move this card" capability. `forge` was checked and
kept: despite the name, it imports nothing from `lib/skills`, `lib/agents` or
`ai-assistant`. It is the worker learning product.
Twenty-six components now declare real props. Two corrections to the pattern
came out of this batch, both from call sites:
- Optionality. Batch 1 made a prop required when it had no default. That is
wrong here: `AdminPage` has seventeen call sites and most pass only `title`.
Required is now reserved for entity-typed props and `children` - what a
component genuinely cannot render without - and everything else is optional,
which is what the JavaScript always allowed.
- Callback arity. `() => void` was too strict: `onOpen`, `onCreate` and
`onBrowse` are called WITH arguments, and `University` passes a `useState`
setter straight through, which has one parameter and is therefore not
assignable to a zero-parameter type. Callbacks take `(...args: any[])`.
`RoleGlyph`'s `GLYPHS` table gets `[RegExp, ComponentType<any>][]` - the same
widening `aiEngine`'s router had, where the element becomes the union of both
positions and neither `pattern.test` nor `<Icon />` works. The author had
already written that exact type as a JSDoc comment; it is now the real
annotation and the comment is gone.
`ForgeHeader` receives `onBrowse` and destructures `_onBrowse`, so the prop is
passed and silently dropped - the same shape as `TalentHero`'s `jobRecs` in the
previous batch. Recorded rather than changed.
Two automated passes were reverted rather than shipped. One added `?` to object
members inside component bodies, not just interface fields, producing
`TS1162: An object member cannot be declared optional` - it was rerun scoped to
`interface XProps` blocks. The other was the generator itself, which annotated
only the FIRST component in each file and so missed `SectionTitle` in
`PageShell`; rewritten to walk every match and splice in reverse, it went from
14 components to 26 and took the error count from 93 to 38.
Two runtime imports were caught by the emitted-JavaScript check and would not
have been caught any other way. The generator added `import * as React` to
`RoleGlyph` for a type-only reference - a real import in the bundle - now
`import type { ComponentType }`. Fixing that, I then removed the React import
`PageShell` genuinely had; restored.
Verified: tsc 22 -> 22, set-difference showing zero introduced and zero removed;
zero errors in any of the 16 files; all 16 emit byte-identical JavaScript;
production bundle byte-identical; npm test 1684/1691 with the same seven
failures; Owliver baseline 59/59; lint 0 errors; baseline artifacts untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
217
src/components/forge/SkillCard.tsx
Normal file
217
src/components/forge/SkillCard.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import type { CourseView } from '@/types/views';
|
||||
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';
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' },
|
||||
};
|
||||
|
||||
interface SkillCardProps {
|
||||
course: CourseView;
|
||||
onOpen?: (...args: any[]) => void;
|
||||
moduleState?: any;
|
||||
}
|
||||
|
||||
export function SkillCard({ course, onOpen, moduleState }: SkillCardProps) {
|
||||
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 unlocks = unlocksLabel(course);
|
||||
|
||||
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>
|
||||
|
||||
{/* 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 */}
|
||||
<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">
|
||||
{/* 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"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user