Files
krow_talent_app/src/components/forge/ChallengeDirectory.tsx
Aravind 3d5f54bd5b 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
2026-09-18 11:54:53 +05:30

208 lines
8.4 KiB
TypeScript

import type { CourseView, ProfileView } from '@/types/views';
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, Lock, Search, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import { isUnlocked } from '@/lib/provingGround';
import { DIFF_TONE, TYPE_ICON, TYPE_LABEL, typeOf } from './challengeMeta';
/**
* Every other challenge, as a directory.
*
* Rows on hairlines rather than a wall of cards: these are secondary to the
* featured skill above, and a grid of bordered tiles would compete with it. The
* controls sit on the heading line so the section needs no toolbar surface of
* its own, and the row still carries everything a card did — skill, title,
* description, difficulty, proof type, duration, XP, reward, state and reason.
*/
interface ChallengeDirectoryProps {
courses: CourseView[];
results?: any;
search?: any;
onSearch?: (...args: any[]) => void;
difficulty?: any;
onDifficulty?: (...args: any[]) => void;
difficulties?: any;
loading?: boolean;
filtered?: any;
onClear?: (...args: any[]) => void;
completedIds?: any;
profile: ProfileView;
}
export function ChallengeDirectory({
courses,
results,
search,
onSearch,
difficulty,
onDifficulty,
difficulties,
loading,
filtered,
onClear,
completedIds,
profile,
}: ChallengeDirectoryProps) {
return (
<section aria-labelledby="forge-directory" className="min-w-0">
<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="forge-directory" className="text-overline uppercase text-ink-4">
Other challenges
</h2>
<p className="mt-1 text-body-sm text-ink-3">
Practical challenges that turn a claim into verified evidence.
</p>
</div>
<div className="flex 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 challenges"
aria-label="Search challenges"
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>
<div className="flex flex-wrap items-center gap-3" role="radiogroup" aria-label="Challenge difficulty">
{difficulties.map((d) => {
const active = d.v === difficulty;
return (
<button
key={d.v}
type="button"
role="radio"
aria-checked={active}
onClick={() => onDifficulty(d.v)}
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'
)}
>
{d.label}
</button>
);
})}
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3 py-2.5">
<p className="text-caption tabular-nums text-ink-4">
{results.length} of {courses.length} challenges{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="divide-y divide-border border-t border-border">
{[...Array(3)].map((_, i) => (
<div key={i} className="py-5">
<div className="h-10 w-full animate-pulse rounded bg-surface-sunken" />
</div>
))}
</div>
) : results.length === 0 ? (
<div className="border-t border-border py-10 text-center">
<p className="text-body-sm text-ink-3">No challenges match your filters.</p>
<button
type="button"
onClick={onClear}
className="mt-2 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"
>
Clear filters
</button>
</div>
) : (
<ul className="divide-y divide-border border-t border-border">
{results.map((course) => {
const lock = isUnlocked(course, profile);
const completed = completedIds.has(course.id);
const locked = !lock.unlocked && !completed;
const type = typeOf(course);
const Icon = TYPE_ICON[type] || TYPE_ICON.roleplay;
return (
<li key={course.id}>
<Link
to={`/university/${course.id}`}
className="group grid gap-x-6 gap-y-2 py-4 transition-colors hover:bg-surface-subtle
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/50
lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)_auto] lg:items-baseline lg:px-2"
>
<span className="min-w-0">
<span className="flex items-center gap-2">
{completed ? (
<ShieldCheck className="h-3.5 w-3.5 shrink-0 text-success" aria-hidden="true" />
) : locked ? (
<Lock className="h-3.5 w-3.5 shrink-0 text-warning" aria-hidden="true" />
) : (
<Icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
)}
<span className="truncate text-overline uppercase text-ink-4">
{course.proof_skill || course.category}
</span>
</span>
<span className="mt-1 block text-body-lg font-semibold leading-snug text-ink-1 group-hover:text-krow-blue">
{course.title}
</span>
</span>
<span className="min-w-0">
<span className="block line-clamp-2 text-body-sm leading-relaxed text-ink-3 lg:line-clamp-1">
{course.description}
</span>
<span className="mt-1 block text-caption tabular-nums text-ink-4">
<span className={cn('font-semibold capitalize', DIFF_TONE[course.difficulty] || 'text-ink-3')}>
{course.difficulty}
</span>
{' · '}{TYPE_LABEL[type]}
{' · '}{course.estimated_minutes} min
{' · '}{course.xp} XP
{course.badge_reward && ` · Earns ${course.badge_reward}`}
</span>
{locked && lock.reasons?.length > 0 && (
<span className="mt-1 block truncate text-caption text-warning">
Requires {lock.reasons[0]}
</span>
)}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-body-sm font-semibold text-krow-blue">
{completed ? 'Review' : locked ? 'Details' : 'Start'}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</span>
</Link>
</li>
);
})}
</ul>
)}
</section>
);
}