fix error line

This commit is contained in:
2026-08-17 17:33:56 +05:30
parent 69b38abced
commit 7fa21a4513
105 changed files with 1296 additions and 323 deletions

View File

@@ -1314,7 +1314,7 @@ const TRAINING_MODULES = MODULE_TABLE.map(
type,
prompt: PROMPT_FOR[type](title),
ai_persona: type === 'roleplay' ? 'A guest or teammate in a live service situation.' : undefined,
rubric: criteria.map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })),
rubric: (Array.isArray(criteria) ? criteria : []).map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })),
},
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
quiz: [],

View File

@@ -37,7 +37,9 @@ export function ThinkingIndicator() {
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
* column on padding around them.
*/
export const Message = React.memo(({ role, text, blocks, streaming, stopped, onPrompt }) => {
export const Message = React.memo(
/** @param {any} props */
({ role, text, blocks, streaming, stopped, onPrompt }) => {
if (role === 'user') {
return (
<div className="flex justify-end">

View File

@@ -15,7 +15,8 @@ import { useAssistantPanel } from './AssistantPanelContext';
* It self-hides on pages with no assistant, so a page can render it
* unconditionally and never needs to know the placement rules.
*/
export function AssistantTrigger({ className, size = 'default' }) {
/** @param {any} props */
export function AssistantTrigger({ className = '', size = 'default' }) {
const { supported, isOpen, toggle, context } = useAssistantPanel();
if (!supported) return null;

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import {
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles, Trash2,
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
@@ -13,7 +13,6 @@ import {
usePreferences, useRoleCategories,
} from '@/lib/krowHooks';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
import { runAction } from '@/lib/skills/actions';
import { skillsForContext } from '@/lib/skills/registry';
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
@@ -286,7 +285,6 @@ export default function KrowAssistant({
activity: facts.activity || [],
trainingPaths,
}), [pageContext, facts, assignments, trainingPaths]);
const [addSkillOpen, setAddSkillOpen] = React.useState(false);
/**
* Which face of the panel the body is showing.
@@ -500,12 +498,33 @@ export default function KrowAssistant({
const ready = declared.filter((c) => !c.deferred);
const asking = declared.filter((c) => c.deferred);
/**
* One chip per *intent*, not per wording.
*
* De-duplicating on the label alone let two chips through whenever the same
* answer was worded twice — a skill's "Show hiring activity" and its
* "Summarize hiring activity" both resolved to that skill's `summary`
* capability, so the reader was offered the same reading under two names and
* had no way to tell them apart. What a chip *resolves to* is the thing that
* must be unique: a skill capability, the page capability, or, for a chip
* that is neither, the question it sends. The label is compared too, so two
* differently-routed chips still cannot arrive reading identically.
*/
const seen = new Set();
const intentOf = (chip) => {
if (chip?.skillId && chip?.skillCapability) return `skill:${chip.skillId}:${chip.skillCapability}`;
if (chip?.capability) return `page:${chip.capability}`;
return `ask:${String(chip?.prompt ?? '').trim().toLowerCase()}`;
};
return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking]
.filter((chip) => {
const key = String(chip?.label ?? '').trim().toLowerCase();
if (!key || seen.has(key)) return false;
seen.add(key);
const label = String(chip?.label ?? '').trim().toLowerCase();
if (!label) return false;
const intent = intentOf(chip);
if (seen.has(intent) || seen.has(`label:${label}`)) return false;
seen.add(intent);
seen.add(`label:${label}`);
return true;
});
/* `pageContext` decides which suggestions can answer without asking, so the
@@ -603,17 +622,16 @@ export default function KrowAssistant({
<p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
</div>
{/* Window controls */}
{/* Window controls.
Authoring a skill is deliberately not among them. Skills are a
registry with a lifecycle — authored, enabled, edited, removed — and
Workspace → Skills is where that lifecycle lives. A second entry
point here meant the panel you *use* Owliver from was also a place
you *configured* it from, and the two lists could be reached from
different places with different affordances. The panel does the
first job only; the registry behind it is unchanged. */}
<div className="flex items-center gap-0.5">
{/* Opens the same dialog Settings does; there is one registry behind
both, so a skill added here is the skill added there. */}
<IconButton
icon={Sparkles}
label="Add an Owliver skill"
variant="ghost"
size="sm"
onClick={() => setAddSkillOpen(true)}
/>
{/* History lives with the other window controls rather than in the
body, so the layout of the panel is unchanged whether or not
there is anything to show. It toggles: pressing it again returns
@@ -729,8 +747,6 @@ export default function KrowAssistant({
)}
</div>
<AddSkillDialog open={addSkillOpen} onOpenChange={setAddSkillOpen} />
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
{/* All suggestions on the landing screen, where they teach what can be

View File

@@ -15,9 +15,16 @@ import { cn } from '@/lib/utils';
* `max` caps how many are offered in a conversation, where vertical space belongs
* to the thread rather than to suggestions.
*
* A chip is shrink-to-fit up to the panel's width, so a long suggestion spends the
* whole row before it is allowed to take a second line. When it does take one, the
* radius stays at the single-line pill's 14px rather than following the height:
* `rounded-full` on a two-line chip resolves to half of 46px, and that corner is
* what turns the same component into something that reads as a card.
*
* Arrow keys move between chips, so the whole set is one tab stop.
*/
export function PromptChips({ prompts = [], onSelect, max, align = 'center', className }) {
/** @param {any} props */
export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center', className = '' }) {
const chipRefs = React.useRef([]);
const visible = max ? prompts.slice(0, max) : prompts;
@@ -54,15 +61,21 @@ export function PromptChips({ prompts = [], onSelect, max, align = 'center', cla
onKeyDown={(e) => onKeyDown(e, i)}
title={prompt.prompt}
className={cn(
'rounded-full border border-border bg-surface px-2.5 py-1',
'text-caption font-medium text-ink-2 shadow-xs',
/* 14px is the radius `rounded-full` already produces on a one-line
chip, pinned so a wrapped chip keeps the same corner. */
'flex max-w-full items-center rounded-[14px] border border-border bg-surface px-2.5 py-1',
'text-caption font-medium text-ink-2 shadow-xs text-left',
'transition-all duration-base ease-out',
'hover:-translate-y-px hover:border-krow-blue/40 hover:bg-krow-blue-tint hover:text-krow-blue hover:shadow-sm',
'active:translate-y-0 active:shadow-xs',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/60 focus-visible:ring-offset-1'
)}
>
{prompt.label}
{/* `pretty` rather than `balance`: the row should be spent before a
second line starts, and balancing would leave the pill wide with
two short lines inside it. Clamped so no suggestion can grow the
chip into a paragraph — the full text stays in `title`. */}
<span className="line-clamp-3 text-pretty">{prompt.label}</span>
</button>
))}
</div>

View File

@@ -17,6 +17,7 @@ import { cn } from '@/lib/utils';
* gain — the suggestion chips already teach the range of what can be asked, and
* they do it without a timer or a rerender every four seconds.
*/
/** @param {any} props */
export function PromptInput({
value,
onChange,
@@ -26,7 +27,7 @@ export function PromptInput({
placeholders = ['Ask anything…'],
autoFocus = false,
align = 'left',
className,
className = '',
}) {
const textareaRef = React.useRef(null);
const [focused, setFocused] = React.useState(false);

View File

@@ -62,14 +62,14 @@ const TONE_BADGE = {
/* ── Individual blocks ──────────────────────────────────────────────────── */
const TextBlock = React.memo(({ block }) => (
const TextBlock = React.memo(/** @param {any} props */ ({ block }) => (
<p className="text-body-sm leading-relaxed text-ink-2">
<Inline value={block.text} />
</p>
));
TextBlock.displayName = 'TextBlock';
const HeadingBlock = React.memo(({ block }) => (
const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
<div className="space-y-0.5">
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
@@ -78,7 +78,7 @@ const HeadingBlock = React.memo(({ block }) => (
HeadingBlock.displayName = 'HeadingBlock';
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
const KpisBlock = React.memo(({ block }) => (
const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
{block.items.map((item) => (
<div
@@ -113,7 +113,7 @@ const KpisBlock = React.memo(({ block }) => (
KpisBlock.displayName = 'KpisBlock';
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
const StatusBlock = React.memo(({ block }) => (
const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
<ul className="space-y-1.5">
{block.items.map((item) => (
<li key={item.label} className="flex items-start gap-2">
@@ -145,7 +145,7 @@ const StatusBlock = React.memo(({ block }) => (
));
StatusBlock.displayName = 'StatusBlock';
const MetersBlock = React.memo(({ block }) => (
const MetersBlock = React.memo(/** @param {any} props */ ({ block }) => (
<div className="space-y-2.5">
{block.items.map((item) => (
<div key={item.label} className="space-y-1">
@@ -167,7 +167,7 @@ MetersBlock.displayName = 'MetersBlock';
* A table. Scrolls horizontally inside its own container so a wide comparison
* never widens the panel.
*/
const TableBlock = React.memo(({ block }) => {
const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
const cell = (value) => {
if (value == null || value === '') return <span className="text-ink-4">—</span>;
if (typeof value === 'object') {
@@ -228,7 +228,7 @@ const TableBlock = React.memo(({ block }) => {
TableBlock.displayName = 'TableBlock';
/** Stage progression, widths relative to the largest stage. */
const FunnelBlock = React.memo(({ block }) => {
const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
const max = Math.max(...block.steps.map((s) => s.count), 1);
return (
@@ -255,7 +255,7 @@ const FunnelBlock = React.memo(({ block }) => {
});
FunnelBlock.displayName = 'FunnelBlock';
const ListBlock = React.memo(({ block }) => {
const ListBlock = React.memo(/** @param {any} props */ ({ block }) => {
const Tag = block.ordered ? 'ol' : 'ul';
return (
<Tag className="space-y-1.5">
@@ -298,7 +298,7 @@ ListBlock.displayName = 'ListBlock';
* clickable and doing nothing. An item may carry a hint *and* an action: the
* hint explains the record, the action opens it.
*/
const InsightsBlock = React.memo(({ block, onPrompt }) => (
const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt }) => (
<div className="space-y-2">
{block.items.map((item, i) => {
const surface = cn(
@@ -375,7 +375,7 @@ const InsightsBlock = React.memo(({ block, onPrompt }) => (
InsightsBlock.displayName = 'InsightsBlock';
/** Recommended steps — numbered, because order is the recommendation. */
const ActionsBlock = React.memo(({ block }) => (
const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
<ol className="space-y-2">
{block.items.map((item, i) => (
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
@@ -394,7 +394,7 @@ const ActionsBlock = React.memo(({ block }) => (
));
ActionsBlock.displayName = 'ActionsBlock';
const BadgesBlock = React.memo(({ block }) => (
const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
<div className="flex flex-wrap gap-1.5">
{block.items.map((item) => (
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
@@ -405,7 +405,7 @@ const BadgesBlock = React.memo(({ block }) => (
));
BadgesBlock.displayName = 'BadgesBlock';
const TimelineBlock = React.memo(({ block }) => (
const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
<ol className="relative space-y-3">
{block.items.map((item, i) => (
<li key={i} className="flex gap-3">
@@ -438,7 +438,7 @@ const TimelineBlock = React.memo(({ block }) => (
));
TimelineBlock.displayName = 'TimelineBlock';
const NoteBlock = React.memo(({ block }) => (
const NoteBlock = React.memo(/** @param {any} props */ ({ block }) => (
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
<Inline value={block.text} />
</p>
@@ -455,7 +455,7 @@ NoteBlock.displayName = 'NoteBlock';
* shape the table does not carry renders nothing rather than something
* improvised.
*/
const SkillSectionBlock = React.memo(({ block }) => {
const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
const section = block.section;
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
@@ -515,7 +515,7 @@ const RENDERERS = {
* blocks has consistent rhythm — a heading hugs what follows it, everything
* else breathes.
*/
export const ResponseDocument = React.memo(({ blocks = [], streaming = false, onPrompt }) => (
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt }) => (
<div className="space-y-3">
{blocks.map((block, i) => {
const Renderer = RENDERERS[block.type];

View File

@@ -459,7 +459,7 @@ const PROMPTS = {
prompt: `Continue the ${drafts[0].title} draft`,
}
: {
label: `${drafts.length} drafts to finish`,
label: `Which ${plural(drafts.length, 'position')} ${verb(drafts.length, 'is', 'are')} still in draft?`,
prompt: 'Which positions are still in draft?',
capability: 'position-drafts',
});
@@ -496,8 +496,9 @@ const PROMPTS = {
if (withStrong.length) {
const best = withStrong.sort((a, b) => b.status.strong.length - a.status.strong.length)[0];
const n = best.status.strong.length;
out.push({
label: `${best.status.strong.length} strong for ${best.position.title}`,
label: `Show the ${plural(n, 'strongest candidate')} for ${best.position.title}`,
prompt: `Who matches ${best.position.title}?`,
});
}
@@ -505,10 +506,16 @@ const PROMPTS = {
out.push({ label: 'Which position needs people first?', prompt: 'Which position needs people first?' });
}
if (newToday) {
out.push({ label: `${newToday} applied today`, prompt: 'Who applied today?' });
out.push({
label: `Which ${plural(newToday, 'candidate')} applied today?`,
prompt: 'Who applied today?',
});
}
if (readyCount) {
out.push({ label: `${readyCount} ready for interview`, prompt: 'Who is ready for interview?' });
out.push({
label: `Which ${plural(readyCount, 'candidate')} ${verb(readyCount, 'is', 'are')} ready for interview?`,
prompt: 'Who is ready for interview?',
});
}
}
@@ -516,13 +523,25 @@ const PROMPTS = {
ones — they are still the right answer to "how is hiring going". */
out.push(
f.starvedPositions.length
? { label: `${f.starvedPositions.length} roles need attention`, prompt: 'What needs attention across my positions?', capability: 'positions-attention' }
? {
label: `Which ${plural(f.starvedPositions.length, 'role')} ${verb(f.starvedPositions.length, 'needs', 'need')} attention?`,
prompt: 'What needs attention across my positions?',
capability: 'positions-attention',
}
: { label: 'What needs attention?', prompt: 'What needs attention across my positions?', capability: 'positions-attention' },
{ label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' },
f.unscreened.length
? { label: `${f.unscreened.length} waiting for review`, prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' }
? {
label: `Show the ${plural(f.unscreened.length, 'application')} waiting for review`,
prompt: 'Show candidates waiting for review',
capability: 'candidates-waiting',
}
: { label: 'Show candidates waiting for review', prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' },
{ label: 'Summarize hiring activity', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' }
/* Named for the whole board. The Hiring Activity skill answers the same
question *for one position*, and the two used to arrive worded
identically — see the intent de-duplication in `KrowAssistant`, which
now also compares what a chip resolves to rather than only its words. */
{ label: 'Summarize hiring activity across all positions', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' }
);
return out;

View File

@@ -42,7 +42,7 @@ export function readHistory() {
return list
.filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length)
.filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff)
.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
} catch {
/* Corrupt or unavailable storage is an empty history, not an error the
reader has to deal with. */

View File

@@ -60,7 +60,7 @@ export function buildFacts({
/* Time-to-hire — the created→updated span ImpactMetrics uses. */
const timeToHire = hired.length
? Math.round(avg(hired.map((a) =>
Math.max(1, (new Date(a.updated_date) - new Date(a.created_date)) / DAY_MS))))
Math.max(1, (new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / DAY_MS))))
: 0;
/* Open roles attracting nobody. */

View File

@@ -103,7 +103,8 @@ function getDeptColor(index, avgScore) {
};
}
export function DepartmentPerformance({ items = [], className }) {
/** @param {any} props */
export function DepartmentPerformance({ items = [], className = '' }) {
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
const [activeDept, setActiveDept] = useState(null);

View File

@@ -18,7 +18,8 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
* `stages` and `transitions` come from the page's own fact sheet — this
* component derives no figures of its own.
*/
export function HiringFlow({ stages = [], transitions = [], weakestKey, className }) {
/** @param {any} props */
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }) {
if (!stages.length) return null;
const total = stages[0]?.count || 1;

View File

@@ -14,7 +14,8 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
*
* `points: [{ label, hires, cumulative }]` — supplied by the page.
*/
export function HiringTrendChart({ points = [], emptyState, className }) {
/** @param {any} props */
export function HiringTrendChart({ points = [], emptyState, className = '' }) {
const enough = points.length >= 2;
return (

View File

@@ -11,6 +11,7 @@ import { StatusBadge } from '@/components/ds/StatusBadge';
* `variant="card"`. Either way the anatomy is fixed — actor, sentence, meta,
* trailing slot — so a feed built from mixed event types stays aligned.
*/
/** @param {any} props */
export function ActivityCard({
/** Person or system that acted. */
actor,

View File

@@ -39,6 +39,7 @@ const ICON_COLORS = {
* instead: an Alert that appears and disappears is easy to miss, and a toast
* that persists is noise.
*/
/** @param {any} props */
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) {
const Icon = icon || ICONS[tone];

View File

@@ -41,6 +41,7 @@ export function initialsFrom(name = '') {
* first-class state rather than a fallback afterthought. A failed image load
* degrades to initials instead of a broken-image icon.
*/
/** @type {React.ForwardRefExoticComponent<any>} */
export const Avatar = React.forwardRef(
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
const [failed, setFailed] = React.useState(false);
@@ -89,6 +90,7 @@ Avatar.displayName = 'Avatar';
* AvatarGroup — overlapping avatars with a "+N" remainder, for showing several
* people in a tight row (applicants on a position, endorsers on a skill).
*/
/** @param {any} props */
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }) {
const visible = people.slice(0, max);
const overflow = people.length - visible.length;

View File

@@ -37,6 +37,7 @@ export const AXIS_PROPS = {
* A tooltip matching the design system's surfaces. Recharts' default is a plain
* white box that ignores the token set.
*/
/** @param {any} props */
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) {
if (!active || !payload?.length) return null;
@@ -97,6 +98,7 @@ export function ChartLegend({ items = [], className }) {
* state, and an optional headline figure. Charts inside it are pure Recharts
* children, so swapping a bar chart for a line chart changes nothing else.
*/
/** @param {any} props */
export function ChartContainer({
title,
subtitle,

View File

@@ -23,6 +23,7 @@ import { usePreferences } from '@/lib/krowHooks';
* `hideBelow: 'md'` drops a column on small screens, which is what keeps the
* table usable on mobile without a horizontal scroll for every view.
*/
/** @param {any} props */
export function DataTable({
columns = [],
rows = [],

View File

@@ -27,6 +27,7 @@ const WIDTHS = {
* a detail view can be a Drawer on desktop and a bottom sheet on mobile without
* its contents changing.
*/
/** @param {any} props */
export function Drawer({
open,
onOpenChange,

View File

@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button';
* Pages that conflate them end up telling a user with 22 candidates that they
* have no candidates.
*/
/** @param {any} props */
export function EmptyState({
icon: Icon,
title,

View File

@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
* spacing, required markers and error styling are identical across the app.
* It wires `id`, `aria-describedby` and `aria-invalid` onto its child.
*/
/** @param {any} props */
export function Field({
label,
hint,

View File

@@ -18,24 +18,25 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
* On mobile the filters collapse behind a "Filters" toggle with an active count,
* because four side-by-side selects are unusable at 375px.
*/
/** @param {any} props */
export function FilterBar({
search,
onSearchChange,
search = null,
onSearchChange = null,
searchPlaceholder = 'Search...',
filters = [],
/** `{ [key]: string | string[] }` */
values = {},
onChange,
onChange = null,
/** Right-aligned actions — primary buttons, view switchers. */
actions,
actions = null,
/** Rendered under the bar, e.g. a result count. */
meta,
meta = null,
/** Value that means "no filter" for select-type filters. */
allValue = 'all',
className,
className = '',
}) {
const [expanded, setExpanded] = React.useState(false);

View File

@@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
* `label`: an icon-only control with no accessible name is a bug, so the API
* makes it impossible to omit. The label doubles as the tooltip.
*/
/** @type {React.ForwardRefExoticComponent<any>} */
export const IconButton = React.forwardRef(
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
const sizeMap = {

View File

@@ -45,18 +45,19 @@ const SEVERITY = {
},
};
/** @param {any} props */
export function InsightRow({
severity = 'info',
title,
detail,
/** Short right-aligned figure — a count, an age, a percentage. */
metric,
metricLabel,
metric = null,
metricLabel = null,
/** Entity the item concerns, e.g. a position or candidate name. */
entity,
onClick,
entity = null,
onClick = null,
actionLabel = 'Review',
className,
className = '',
}) {
const s = SEVERITY[severity] || SEVERITY.info;
const Comp = onClick ? 'button' : 'div';
@@ -105,6 +106,7 @@ export function InsightRow({
}
/** The list container — dividers between rows, nothing else. */
export function InsightList({ children, className }) {
/** @param {any} props */
export function InsightList({ children, className = '' }) {
return <div className={cn('divide-y divide-border', className)}>{children}</div>;
}

View File

@@ -21,6 +21,7 @@ const TONES = {
* (a target, a breakdown, a sparkline) use `MetricCard`; keeping the two
* separate stops KPI rows from drifting into inconsistent heights.
*/
/** @param {any} props */
export function KpiCard({
icon: Icon,
value,
@@ -94,6 +95,7 @@ const formatDelta = (delta, suffix = '%') =>
* `invertTrend` exists because "up" is not always good — time-to-hire rising is
* bad, and colouring it green would be actively misleading.
*/
/** @param {any} props */
export function MetricCard({
label,
value,

View File

@@ -18,6 +18,7 @@ const GAPS = { sm: 'gap-3', default: 'gap-4', lg: 'gap-6' };
* `cols` is the count at the largest breakpoint; the intermediate steps are
* chosen to avoid orphan cards (6 → 2/3/6 rather than 1/3/6).
*/
/** @param {any} props */
export function Grid({ cols = 3, gap = 'default', className, children, ...props }) {
const columns = {
1: 'grid-cols-1',
@@ -36,6 +37,7 @@ export function Grid({ cols = 3, gap = 'default', className, children, ...props
}
/** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */
/** @param {any} props */
export function Stack({ space = 'default', className, children, ...props }) {
const spacing = {
xs: 'space-y-2',
@@ -56,6 +58,7 @@ export function Stack({ space = 'default', className, children, ...props }) {
* PageSection — a titled region of a page. Pairs a SectionHeader-style label
* with its content at consistent spacing.
*/
/** @param {any} props */
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) {
return (
<section className={cn('space-y-3', className)}>

View File

@@ -5,7 +5,8 @@ import { cn } from '@/lib/utils';
/**
* Spinner — the only spinner in the system.
*/
export function Spinner({ size = 'default', className, label = 'Loading' }) {
/** @param {any} props */
export function Spinner({ size = 'default', className = '', label = 'Loading' }) {
const sizes = { xs: 'w-3.5 h-3.5', sm: 'w-4 h-4', default: 'w-6 h-6', lg: 'w-8 h-8' };
return (
<Loader2
@@ -23,7 +24,8 @@ export function Spinner({ size = 'default', className, label = 'Loading' }) {
* Prefer `Skeleton*` when the shape *is* known: a skeleton that matches the
* final layout prevents the content jump a spinner always causes.
*/
export function LoadingState({ message, size = 'default', className }) {
/** @param {any} props */
export function LoadingState({ message = '', size = 'default', className = '' }) {
return (
<div
className={cn(
@@ -42,7 +44,8 @@ export function LoadingState({ message, size = 'default', className }) {
* InlineLoading — a spinner and label on one line, for inside buttons rows,
* card headers, or beside a control that is refreshing.
*/
export function InlineLoading({ message = 'Loading…', className }) {
/** @param {any} props */
export function InlineLoading({ message = 'Loading…', className = '' }) {
return (
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
<Spinner size="sm" />
@@ -55,7 +58,8 @@ export function InlineLoading({ message = 'Loading…', className }) {
* LoadingOverlay — covers content that is being replaced, keeping layout
* height stable so the page does not jump. Used by DataTable when refetching.
*/
export function LoadingOverlay({ show, message, className }) {
/** @param {any} props */
export function LoadingOverlay({ show = false, message = '', className = '' }) {
if (!show) return null;
return (
<div

View File

@@ -57,6 +57,7 @@ function Delta({ delta, label, invert }) {
);
}
/** @param {any} props */
export function MetricStrip({ items = [], columns, className, loading = false }) {
const count = columns ?? Math.min(items.length, 6);

View File

@@ -24,6 +24,8 @@ const WIDTHS = {
* body scroll pushes the title and the confirm button off-screen on a laptop;
* this keeps both pinned and scrolls only the middle.
*/
/** @param {any} props */
/** @param {any} props */
export function Modal({
open,
onOpenChange,
@@ -82,6 +84,7 @@ export function Modal({
* always for, and it labels the action with the verb ("Delete") rather than
* "OK", so the button says what will happen.
*/
/** @param {any} props */
export function ConfirmModal({
open,
onOpenChange,
@@ -91,7 +94,7 @@ export function ConfirmModal({
cancelLabel = 'Cancel',
onConfirm,
tone = 'destructive',
icon,
icon = null,
busy = false,
}) {
return (

View File

@@ -12,6 +12,7 @@ import { SearchInput } from '@/components/ds/SearchInput';
* options a filter field appears, so short lists stay uncluttered and long ones
* stay usable. Options may be strings or `{ value, label, count }`.
*/
/** @param {any} props */
export function MultiSelect({
options = [],
value = [],
@@ -20,8 +21,8 @@ export function MultiSelect({
/** Chips shown before collapsing into "+N more". */
maxChips = 2,
searchThreshold = 8,
disabled,
className,
disabled = false,
className = '',
align = 'start',
emptyMessage = 'No options',
}) {

View File

@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils';
* right-aligned action slot. Owning the entrance animation here is what keeps
* page transitions feeling like one product.
*/
/** @param {any} props */
export function PageHeader({
title,
subtitle,

View File

@@ -9,6 +9,12 @@ import { Button } from '@/components/ui/button';
* Always keeps the first page, the last page, and a window around the current
* one, so the control's width stays stable as the user pages through.
*/
/**
* @param {number} current
* @param {number} total
* @param {number} [siblings]
* @returns {(number | string)[]}
*/
export function buildPageRange(current, total, siblings = 1) {
const totalSlots = siblings * 2 + 5;
if (total <= totalSlots) return Array.from({ length: total }, (_, i) => i + 1);
@@ -30,6 +36,7 @@ export function buildPageRange(current, total, siblings = 1) {
* Pagination — page navigation with an optional result summary and page-size
* control. Renders nothing when there is only one page.
*/
/** @param {any} props */
export function Pagination({
page = 1,
pageCount = 1,

View File

@@ -34,6 +34,7 @@ export function toneForScore(score) {
* `tone="score"` picks the colour from the value, which is what most usages in
* this app want.
*/
/** @param {any} props */
export function ProgressBar({
value,
max = 100,
@@ -94,6 +95,7 @@ export function ProgressBar({
* score, via `min`/`max`, so the KROW ID gauge and a completion ring are the
* same component.
*/
/** @param {any} props */
export function ProgressRing({
value,
min = 0,
@@ -175,7 +177,8 @@ export function ProgressRing({
* Completed steps show a check rather than their number, so "done" is readable
* at a glance without relying on colour.
*/
export function ProgressSteps({ steps = [], current = 0, className, onStepClick }) {
/** @param {any} props */
export function ProgressSteps({ steps = [], current = 0, className = '', onStepClick = null }) {
const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
return (
@@ -239,6 +242,7 @@ export function ProgressSteps({ steps = [], current = 0, className, onStepClick
* ScoreMeter — a labelled bar row used in score breakdowns, where many
* dimensions are listed with a weight and a value.
*/
/** @param {any} props */
export function ScoreMeter({ label, value, weight, tone = 'score', className }) {
return (
<div className={cn('space-y-1.5', className)}>

View File

@@ -10,6 +10,7 @@ import { inputVariants } from '@/components/ui/input';
* without re-running expensive work, while the field itself stays instantly
* responsive. Pass `debounce={0}` for immediate updates.
*/
/** @type {React.ForwardRefExoticComponent<any>} */
export const SearchInput = React.forwardRef(
(
{

View File

@@ -10,6 +10,7 @@ import { cn } from '@/lib/utils';
* - `card` — a card's own title row
* - `section` — a page-level section title
*/
/** @param {any} props */
export function SectionHeader({
title,
subtitle,

View File

@@ -9,7 +9,8 @@ import { cn } from '@/lib/utils';
* progress. The shimmer is disabled automatically under
* `prefers-reduced-motion` (see index.css).
*/
export function Skeleton({ className, radius = 'md', ...props }) {
/** @param {any} props */
export function Skeleton({ className = '', radius = 'md', ...props }) {
return (
<div
aria-hidden="true"
@@ -31,7 +32,8 @@ export function Skeleton({ className, radius = 'md', ...props }) {
*/
/** Paragraph placeholder. The last line is short, as real text is. */
export function SkeletonText({ lines = 3, className }) {
/** @param {any} props */
export function SkeletonText({ lines = 3, className = '' }) {
return (
<div className={cn('space-y-2', className)} role="status" aria-label="Loading content">
{Array.from({ length: lines }).map((_, i) => (
@@ -45,7 +47,8 @@ export function SkeletonText({ lines = 3, className }) {
}
/** Matches KpiCard: icon tile, big number, caption. */
export function SkeletonKpi({ className }) {
/** @param {any} props */
export function SkeletonKpi({ className = '' }) {
return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-4 shadow-sm', className)}>
<Skeleton className="w-10 h-10 rounded-xl mb-3" />
@@ -56,7 +59,8 @@ export function SkeletonKpi({ className }) {
}
/** Matches a list row: avatar, two lines of text, trailing value. */
export function SkeletonRow({ className }) {
/** @param {any} props */
export function SkeletonRow({ className = '' }) {
return (
<div className={cn('flex items-center gap-3 py-3', className)}>
<Skeleton className="w-9 h-9" radius="full" />
@@ -70,7 +74,8 @@ export function SkeletonRow({ className }) {
}
/** Matches a card in a grid: title, meta, stat row. */
export function SkeletonCard({ className }) {
/** @param {any} props */
export function SkeletonCard({ className = '' }) {
return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-5 shadow-sm', className)}>
<div className="flex items-center gap-3 mb-4">
@@ -94,7 +99,8 @@ export function SkeletonCard({ className }) {
}
/** Matches DataTable: header row plus n body rows. */
export function SkeletonTable({ rows = 5, columns = 4, className }) {
/** @param {any} props */
export function SkeletonTable({ rows = 5, columns = 4, className = '' }) {
return (
<div className={cn('overflow-hidden', className)} role="status" aria-label="Loading table">
<div className="flex items-center gap-4 px-4 py-3 border-b border-border bg-surface-subtle">
@@ -114,7 +120,8 @@ export function SkeletonTable({ rows = 5, columns = 4, className }) {
}
/** Matches ChartContainer: title, legend, plot area. */
export function SkeletonChart({ className, height = 240 }) {
/** @param {any} props */
export function SkeletonChart({ className = '', height = 240 }) {
return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-6 shadow-sm', className)}>
<Skeleton className="h-3.5 w-40 mb-2" />
@@ -129,7 +136,8 @@ export function SkeletonChart({ className, height = 240 }) {
}
/** n SkeletonRows inside a surface — the common "loading a list" case. */
export function SkeletonList({ rows = 5, className }) {
/** @param {any} props */
export function SkeletonList({ rows = 5, className = '' }) {
return (
<div className={cn('divide-y divide-border', className)} role="status" aria-label="Loading list">
{Array.from({ length: rows }).map((_, i) => (

View File

@@ -77,6 +77,7 @@ const DOT_COLOR = {
* `dot` adds a leading indicator for dense rows where colour alone is doing too
* much work; it also gives colour-blind users a second cue alongside the label.
*/
/** @param {any} props */
export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }) {
const entry = STATUS_MAP[status] || {};
const resolvedTone = tone || entry.tone || 'neutral';

View File

@@ -55,6 +55,7 @@ const surfaceVariants = cva(
}
);
/** @type {React.ForwardRefExoticComponent<any>} */
const Surface = React.forwardRef(
({ className, variant, elevation, radius, padding, interactive, as, ...props }, ref) => {
const Comp = as || 'div';

View File

@@ -15,6 +15,7 @@ import { Badge } from '@/components/ui/badge';
* unique when two tab sets are on screen at once, otherwise the indicator
* animates between them.
*/
/** @param {any} props */
export function Tabs({
tabs = [],
value,

View File

@@ -20,7 +20,8 @@ const TONE_DOT = {
* The connecting rail is drawn per item rather than as one absolute line, so a
* timeline can be nested inside a scrolling card without the rail detaching.
*/
export function Timeline({ items = [], className, compact = false }) {
/** @param {any} props */
export function Timeline({ items = [], className = '', compact = false }) {
return (
<ol className={cn('relative', className)}>
{items.map((item, i) => {

View File

@@ -9,6 +9,7 @@ import { Switch } from '@/components/ui/switch';
* always used with, and makes the whole row a label so the hit target is the
* full width rather than the 36px control.
*/
/** @param {any} props */
export function Toggle({
checked,
onCheckedChange,
@@ -97,8 +98,15 @@ const SEGMENT_VARIANTS = {
},
};
/** @param {any} props */
export function SegmentedToggle({
options = [], value, onChange, size = 'default', variant = 'brand', className, ariaLabel,
options = [],
value = null,
onChange = null,
size = 'default',
variant = 'brand',
className = '',
ariaLabel = '',
}) {
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
const tone = SEGMENT_VARIANTS[variant] || SEGMENT_VARIANTS.brand;

View File

@@ -28,7 +28,7 @@ const BASE_STYLE = {
const DURATIONS = { success: 2600, error: 4200, info: 3000, loading: Infinity };
const withDefaults = (options = {}) => ({
position: 'bottom-center',
position: /** @type {import('react-hot-toast').ToastPosition} */ ('bottom-center'),
...options,
style: { ...BASE_STYLE, ...(options.style || {}) },
});

View File

@@ -465,7 +465,8 @@ function Choice({ active, onClick, className, children }) {
}
/** A growable list of short text lines — training steps, evaluation criteria. */
function ListBuilder({ label, hint, placeholder, values, onChange, addLabel }) {
/** @param {any} props */
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));

View File

@@ -13,7 +13,8 @@ import { Plus, Sparkles } from 'lucide-react';
* where a worker actually is one; putting it here made the console read as
* somebody's personal training dashboard.
*/
export function ForgeHeader({ onCreate }) {
/** @param {any} props */
export function ForgeHeader({ onCreate = null, _onBrowse = null }) {
return (
<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">

View File

@@ -19,7 +19,8 @@ const TIPS = [
'Keep training concise.',
];
export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver }) {
/** @param {any} props */
export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver: _onAskOwliver }) {
return (
<aside
className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3 items-stretch"

View File

@@ -168,6 +168,7 @@ export function SkillDetail({ course, open, onOpenChange, usage }) {
}
/** A titled region of the record. */
/** @param {any} props */
function Block({ title, meta, icon: Icon, children }) {
return (
<section className="border-t border-border pt-4 first:border-0 first:pt-0">

View File

@@ -32,7 +32,8 @@ const STATUS_META = {
* 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 }) {
/** @param {any} props */
export function SkillLadder({ state, className = '' }) {
return (
<ol className={cn('flex items-start', className)} aria-label={`${state.name} levels`}>
{state.levels.map((rung, i) => {
@@ -87,6 +88,7 @@ export function SkillLadder({ state, className }) {
}
/** One skill: where this person stands on it, and the next thing to do. */
/** @param {any} props */
export function SkillProgressCard({ state, onOpen }) {
const status = STATUS_META[state.status] || STATUS_META.not_started;
const atTop = !state.nextLevel;
@@ -163,6 +165,7 @@ export function SkillProgressCard({ state, onOpen }) {
* hidden: the evidence behind a level is the reason to trust the level, and
* folding it away would leave the label standing on nothing.
*/
/** @param {any} props */
export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining }) {
if (!state) return null;

View File

@@ -95,13 +95,14 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
setMessages(newMessages);
lastUserMessageTime.current = Date.now();
await speak(question);
} catch (e) {
} catch (_e) {
setError('Interview connection issue. Please retry.');
}
}, [jobTitle, speak, language]);
const startListening = useCallback(() => {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const win = /** @type {any} */ (window);
const SpeechRecognition = win.SpeechRecognition || win.webkitSpeechRecognition;
if (!SpeechRecognition) {
setError('Voice input not supported in this browser. Use text mode below.');
return;
@@ -181,7 +182,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
} });
setEvaluation(result);
setPhase('done');
} catch (e) {
} catch (_e) {
setError('Evaluation failed. Please try again.');
setPhase('active');
}

View File

@@ -20,7 +20,7 @@ const STATUS_STYLES = {
rejected: { badge: 'bg-[#FEE2E2] text-[#991B1B]', label: 'Rejected', card: 'bg-white border-[#E5E7EB]' },
};
function getInitials(name) {
function _getInitials(name) {
return name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
}
@@ -67,6 +67,7 @@ function ScoreRing({ score }) {
);
}
/** @param {any} props */
export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }) {
const navigate = useNavigate();
const position = jobTitle || application.job_title || '';
@@ -116,7 +117,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
try {
await base44.entities.JobApplication.update(application.id, { status: 'rejected' });
toast.success(`${application.applicant_name} declined`);
} catch (err) {
} catch (_err) {
toast.error('Failed to decline candidate');
}
}
@@ -130,7 +131,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
try {
await base44.entities.JobApplication.delete(application.id);
toast.success(`${application.applicant_name} deleted`);
} catch (err) {
} catch (_err) {
toast.error('Failed to delete candidate');
}
}

View File

@@ -21,7 +21,8 @@ const METRIC_CONFIG = [
{ key: 'employer_requirements', label: 'Req Match', Icon: Check },
];
function MetricPill({ label, value, Icon }) {
/** @param {any} props */
function _MetricPill({ label, value, Icon }) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB]">
<Icon className="w-3 h-3 text-[#9CA3AF]" strokeWidth={2} />

View File

@@ -2,7 +2,8 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight } from 'lucide-react';
function Stage({ label, value, sub, accent, to }) {
/** @param {any} props */
function Stage({ label, value, sub = '', accent = false, to = null }) {
const body = (
<div className="text-center sm:text-left">
<div className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8]">{label}</div>

View File

@@ -332,7 +332,8 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio
* `MatchedCandidates` rather than inside it: two audiences, two components, one
* scoring function that cannot drift between them.
*/
export function YourSkillFit({ job, held, className }) {
/** @param {any} props */
export function YourSkillFit({ job, held, className = '' }) {
const match = matchPosition(job, held);
if (!match) return null;
const tone = TONE[match.tone] || TONE.warning;

View File

@@ -83,6 +83,7 @@ function ScoreFactor({ label, value }) {
);
}
/** @param {any} props */
export default function TalentDetailModal({ open, onClose, profile, onContact, onSchedule, onHire, matchScore, matchReasons }) {
if (!profile) return null;
const badges = profile.earned_badges || [];

View File

@@ -92,9 +92,17 @@ function SkillPath({ state }) {
);
}
/** @param {any} props */
export function VerifiedSkills({
pageId, profile, profiles = [], courses = [], positions = [], email, customSources = [],
onOpenTraining, className,
pageId = '',
profile = null,
profiles = [],
courses = [],
positions = [],
email = '',
customSources = [],
onOpenTraining = null,
className = '',
}) {
/* Accepts a profile or the email of one, so a candidate record — which is an
application, not a worker profile — can be matched to the person behind it. */
@@ -126,7 +134,7 @@ export function VerifiedSkills({
if (!worker) return [];
const byId = new Map(courses.map((c) => [c.id, c]));
return [...(worker.completed_courses || [])]
.sort((a, b) => new Date(b.completed_date) - new Date(a.completed_date))
.sort((a, b) => new Date(b.completed_date).getTime() - new Date(a.completed_date).getTime())
.slice(0, 3)
.map((entry) => {
const course = byId.get(entry.course_id);

View File

@@ -172,7 +172,8 @@ const initialsFor = (name) => {
* hierarchy this card is meant to establish. Values share one ink colour so no
* single signal shouts louder than the rest.
*/
function SupportingSignal({ label, value, suffix, icon: Icon }) {
/** @param {any} props */
function SupportingSignal({ label, value, suffix = '', icon: Icon = null }) {
const shown = value != null && value !== '' ? value : '—';
return (
<div className="flex items-baseline justify-between gap-3">

View File

@@ -96,7 +96,8 @@ function FactorPill({ label, value }) {
);
}
function ReportRow({ icon: Icon, label, value, tone }) {
/** @param {any} props */
function ReportRow({ icon: Icon, label, value, tone = 'neutral' }) {
const valColor = tone === 'verified' ? 'text-[#10B981]' : tone === 'amber' ? 'text-[#D97706]' : 'text-[#0F172A]';
return (
<div className="flex items-center justify-between gap-3 py-2 border-b border-white/60 last:border-0">
@@ -117,7 +118,7 @@ export default function IdentityCareerCard({ profile }) {
const score = getTalentScore(profile);
const band = getScoreBand(score);
const growth = getGrowth(profile);
const _growth = getGrowth(profile);
const esat = getESAT(profile);
const fico = toFICO(score);
const shortId = (profile.id || '').slice(-6).toUpperCase();

View File

@@ -29,7 +29,8 @@ function marketRange(profile = {}) {
return `$${low}-$${low + 4}/hr`;
}
function Field({ label, value, sub, accent }) {
/** @param {any} props */
function Field({ label, value, sub = '', accent = false }) {
return (
<div>
<p className="text-[11px] uppercase tracking-wide text-[#6B7280]">{label}</p>
@@ -68,7 +69,8 @@ function Factor({ label, value, weight }) {
);
}
export default function TalentHero({ profile, jobRecs = [], nextCourse, openOwliver }) {
/** @param {any} props */
export default function TalentHero({ profile, _jobRecs = [], nextCourse = null, openOwliver = null }) {
const score = getTalentScore(profile);
const factors = getFactors(profile);
const first = (profile.full_name || 'there').split(' ')[0];

View File

@@ -160,7 +160,21 @@ function SkillSection({ skill, section, context }) {
);
}
export function SkillSurface({ page, placement, context = null, className }) {
/** @param {any} props */
export function SkillSurface({
page = '',
placement = '',
context = null,
className = '',
_pageId = '',
_profile = null,
_profiles = [],
_courses = [],
_positions = [],
_customSources = [],
_email = '',
_onOpenTraining = null,
}) {
const sections = useSkillSections(page, placement);
const resolved = useSkillDataContext(context);

View File

@@ -42,6 +42,7 @@ const badgeVariants = cva(
}
)
/** @type {React.ForwardRefExoticComponent<any>} */
const Badge = React.forwardRef(({ className, variant, size, shape, ...props }, ref) => (
<div ref={ref} className={cn(badgeVariants({ variant, size, shape }), className)} {...props} />
))

View File

@@ -4,11 +4,13 @@ import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
/** @type {React.ForwardRefExoticComponent<any>} */
const Breadcrumb = React.forwardRef(
({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />
)
Breadcrumb.displayName = "Breadcrumb"
/** @type {React.ForwardRefExoticComponent<any>} */
const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (
<ol
ref={ref}
@@ -20,6 +22,7 @@ const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (
))
BreadcrumbList.displayName = "BreadcrumbList"
/** @type {React.ForwardRefExoticComponent<any>} */
const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (
<li
ref={ref}
@@ -28,6 +31,7 @@ const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (
))
BreadcrumbItem.displayName = "BreadcrumbItem"
/** @type {React.ForwardRefExoticComponent<any>} */
const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
@@ -40,6 +44,7 @@ const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
/** @type {React.ForwardRefExoticComponent<any>} */
const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (
<span
ref={ref}
@@ -51,6 +56,7 @@ const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (
))
BreadcrumbPage.displayName = "BreadcrumbPage"
/** @param {any} props */
const BreadcrumbSeparator = ({
children,
className,
@@ -66,6 +72,7 @@ const BreadcrumbSeparator = ({
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
/** @param {any} props */
const BreadcrumbEllipsis = ({
className,
...props

View File

@@ -82,6 +82,7 @@ const buttonVariants = cva(
}
)
/** @type {React.ForwardRefExoticComponent<any>} */
const Button = React.forwardRef((
{ className, variant, size, shape, block, asChild = false, loading = false, disabled, children, ...props },
ref

View File

@@ -2,6 +2,7 @@ import * as React from "react"
import { cn } from "@/lib/utils"
/** @type {React.ForwardRefExoticComponent<any>} */
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
@@ -10,6 +11,7 @@ const Card = React.forwardRef(({ className, ...props }, ref) => (
))
Card.displayName = "Card"
/** @type {React.ForwardRefExoticComponent<any>} */
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
@@ -18,6 +20,7 @@ const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
))
CardHeader.displayName = "CardHeader"
/** @type {React.ForwardRefExoticComponent<any>} */
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
@@ -26,6 +29,7 @@ const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
))
CardTitle.displayName = "CardTitle"
/** @type {React.ForwardRefExoticComponent<any>} */
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
@@ -34,11 +38,13 @@ const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
))
CardDescription.displayName = "CardDescription"
/** @type {React.ForwardRefExoticComponent<any>} */
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
/** @type {React.ForwardRefExoticComponent<any>} */
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}

View File

@@ -4,6 +4,7 @@ import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
/** @type {React.ForwardRefExoticComponent<any>} */
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}

View File

@@ -14,6 +14,7 @@ const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
/** @type {React.ForwardRefExoticComponent<any>} */
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
@@ -25,6 +26,7 @@ const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
@@ -46,8 +48,9 @@ const DialogContent = React.forwardRef(({ className, children, ...props }, ref)
))
DialogContent.displayName = DialogPrimitive.Content.displayName
/** @param {any} props */
const DialogHeader = ({
className,
className = '',
...props
}) => (
<div
@@ -56,8 +59,9 @@ const DialogHeader = ({
)
DialogHeader.displayName = "DialogHeader"
/** @param {any} props */
const DialogFooter = ({
className,
className = '',
...props
}) => (
<div
@@ -66,6 +70,7 @@ const DialogFooter = ({
)
DialogFooter.displayName = "DialogFooter"
/** @type {React.ForwardRefExoticComponent<any>} */
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
@@ -74,6 +79,7 @@ const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}

View File

@@ -16,6 +16,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
@@ -32,6 +33,7 @@ const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, .
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
@@ -44,6 +46,7 @@ const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) =
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
@@ -59,6 +62,7 @@ const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...pr
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
@@ -71,6 +75,7 @@ const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref)
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
@@ -91,9 +96,11 @@ const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checke
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuRadioItem = React.forwardRef(({ className, children, value, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
value={value}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
@@ -109,6 +116,7 @@ const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
@@ -117,6 +125,7 @@ const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref)
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}

View File

@@ -9,6 +9,7 @@ const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
/** @type {React.ForwardRefExoticComponent<any>} */
const HoverCardContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}

View File

@@ -72,6 +72,7 @@ function buildSrcSet(parsed, options) {
).join(", ")
}
/** @type {React.ForwardRefExoticComponent<any>} */
const ImageWrapper = React.forwardRef(({ aspectRatio, className, style, children }, ref) => (
<span
ref={ref}
@@ -83,6 +84,7 @@ const ImageWrapper = React.forwardRef(({ aspectRatio, className, style, children
))
ImageWrapper.displayName = "ImageWrapper"
/** @type {React.ForwardRefExoticComponent<any>} */
const ResponsiveImage = React.forwardRef(
({ parsed, fittingType, focalPoint, quality, className, style, aspectRatio, onLoad, ...props }, parentRef) => {
const wrapperRef = React.useRef(null)
@@ -172,6 +174,7 @@ ResponsiveImage.displayName = "ResponsiveImage"
* server-side, optionally anchored at a focal point. Other URLs render as a
* plain <img>. Failed loads swap to a fallback image.
*/
/** @type {React.ForwardRefExoticComponent<any>} */
const Image = React.forwardRef(
(
{

View File

@@ -34,6 +34,7 @@ const inputVariants = cva(
}
)
/** @type {React.ForwardRefExoticComponent<any>} */
const Input = React.forwardRef(({ className, type, size, shape, ...props }, ref) => (
<input
type={type}

View File

@@ -8,6 +8,7 @@ const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
/** @type {React.ForwardRefExoticComponent<any>} */
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))

View File

@@ -9,6 +9,7 @@ const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
/** @type {React.ForwardRefExoticComponent<any>} */
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content

View File

@@ -4,15 +4,18 @@ import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
/** @type {React.ForwardRefExoticComponent<any>} */
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => {
return (<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />);
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => {
/** @type {React.ForwardRefExoticComponent<any>} */
const RadioGroupItem = React.forwardRef(({ className = '', value, ...props }, ref) => {
return (
(<RadioGroupPrimitive.Item
ref={ref}
value={value}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-border data-[state=checked]:border-krow-blue text-krow-blue shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-krow-blue/60 disabled:cursor-not-allowed disabled:opacity-50",
className

View File

@@ -12,6 +12,7 @@ const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
/** @type {React.ForwardRefExoticComponent<any>} */
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
@@ -28,6 +29,7 @@ const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref)
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
@@ -38,6 +40,7 @@ const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) =>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
@@ -49,6 +52,7 @@ const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) =
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
@@ -73,6 +77,7 @@ const SelectContent = React.forwardRef(({ className, children, position = "poppe
))
SelectContent.displayName = SelectPrimitive.Content.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
@@ -81,9 +86,17 @@ const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
/** @type {React.ForwardRefExoticComponent<any>} */
/* `value` is named and forwarded explicitly — the props type here is `any`, so a
value arriving only through `...props` is invisible to the checker and it flags
the required prop as missing. Naming it without passing it back down is the
trap: Radix then has no value to match against, no item is ever `isSelected`,
the trigger's value node never receives any item text, and every closed select
renders as an empty pill with nothing but a chevron. */
const SelectItem = React.forwardRef(({ className = '', children, value, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
value={value}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
@@ -99,6 +112,7 @@ const SelectItem = React.forwardRef(({ className, children, ...props }, ref) =>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}

View File

@@ -14,6 +14,7 @@ const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
/** @type {React.ForwardRefExoticComponent<any>} */
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
@@ -44,6 +45,7 @@ const sheetVariants = cva(
}
)
/** @type {React.ForwardRefExoticComponent<any>} */
const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
@@ -79,6 +81,7 @@ const SheetFooter = ({
)
SheetFooter.displayName = "SheetFooter"
/** @type {React.ForwardRefExoticComponent<any>} */
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
@@ -87,6 +90,7 @@ const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}

View File

@@ -3,6 +3,7 @@ import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
/** @type {React.ForwardRefExoticComponent<any>} */
const Slider = React.forwardRef(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}

View File

@@ -3,6 +3,7 @@ import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
/** @type {React.ForwardRefExoticComponent<any>} */
const Switch = React.forwardRef(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(

View File

@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
/** @type {React.ForwardRefExoticComponent<any>} */
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
@@ -16,6 +17,7 @@ const TabsList = React.forwardRef(({ className, ...props }, ref) => (
))
TabsList.displayName = TabsPrimitive.List.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
@@ -27,6 +29,7 @@ const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}

View File

@@ -2,6 +2,7 @@ import * as React from "react"
import { cn } from "@/lib/utils"
/** @type {React.ForwardRefExoticComponent<any>} */
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
return (
(<textarea

View File

@@ -3,6 +3,7 @@ import { cva } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
/** @type {React.ForwardRefExoticComponent<any>} */
const ToastProvider = React.forwardRef(({ ...props }, ref) => (
<div
ref={ref}
@@ -12,6 +13,7 @@ const ToastProvider = React.forwardRef(({ ...props }, ref) => (
));
ToastProvider.displayName = "ToastProvider";
/** @type {React.ForwardRefExoticComponent<any>} */
const ToastViewport = React.forwardRef(({ ...props }, ref) => (
<div
ref={ref}
@@ -37,6 +39,7 @@ const toastVariants = cva(
}
);
/** @type {React.ForwardRefExoticComponent<any>} */
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
return (
<div
@@ -48,6 +51,7 @@ const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
});
Toast.displayName = "Toast";
/** @type {React.ForwardRefExoticComponent<any>} */
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
@@ -60,6 +64,7 @@ const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
));
ToastAction.displayName = "ToastAction";
/** @type {React.ForwardRefExoticComponent<any>} */
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
<button
ref={ref}
@@ -75,6 +80,7 @@ const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
));
ToastClose.displayName = "ToastClose";
/** @type {React.ForwardRefExoticComponent<any>} */
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
@@ -84,6 +90,7 @@ const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
));
ToastTitle.displayName = "ToastTitle";
/** @type {React.ForwardRefExoticComponent<any>} */
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}

View File

@@ -8,7 +8,8 @@ import {
ToastViewport,
} from "@/components/ui/toast";
export function Toaster() {
/** @param {any} [props] */
export function Toaster(props) {
const { toasts } = useToast();
return (

View File

@@ -11,6 +11,7 @@ const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
/** @type {React.ForwardRefExoticComponent<any>} */
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content

View File

@@ -11,8 +11,9 @@ import { base44 } from '@/api/base44Client';
* consumer stay unchanged.
*/
const AuthContext = createContext();
const AuthContext = createContext(/** @type {any} */ (null));
/** @param {any} props */
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);

View File

@@ -31,7 +31,7 @@ export function buildHires({ staff = [], applications = [], postings = [] }) {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
const days = app
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
? Math.max(1, Math.round((new Date(app.updated_date).getTime() - new Date(app.created_date).getTime()) / 86400000))
: null;
return {

View File

@@ -23,7 +23,7 @@ export function useCurrentUser() {
export function useUpdateProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (patch) => base44.auth.updateMe(patch),
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updateMe(patch),
onSuccess: (user) => {
queryClient.setQueryData(['user'], user);
queryClient.invalidateQueries({ queryKey: ['user'] });
@@ -46,7 +46,7 @@ export function usePreferences() {
export function useUpdatePreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (patch) => base44.auth.updatePreferences(patch),
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updatePreferences(patch),
onSuccess: (user) => {
queryClient.setQueryData(['user'], user);
queryClient.invalidateQueries({ queryKey: ['user'] });
@@ -96,7 +96,7 @@ export function useStaff() {
export function useUpdateStaff() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }) => base44.entities.Staff.update(id, data),
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Staff.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staff'] });
},
@@ -120,7 +120,7 @@ export function useCertifications() {
export function useCreateCertification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => base44.entities.Certification.create(data),
mutationFn: /** @param {any} data */ (data) => base44.entities.Certification.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certifications'] });
},
@@ -130,7 +130,7 @@ export function useCreateCertification() {
export function useDeleteCertification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id) => base44.entities.Certification.delete(id),
mutationFn: /** @param {any} id */ (id) => base44.entities.Certification.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certifications'] });
},
@@ -140,7 +140,7 @@ export function useDeleteCertification() {
export function useCreateRoleCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => base44.entities.RoleCategory.create(data),
mutationFn: /** @param {any} data */ (data) => base44.entities.RoleCategory.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roleCategories'] });
},
@@ -157,7 +157,7 @@ export function useUserActivity() {
export function useCreateJobPosting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => base44.entities.JobPosting.create(data),
mutationFn: /** @param {any} data */ (data) => base44.entities.JobPosting.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
logActivity('create_position');
@@ -168,7 +168,7 @@ export function useCreateJobPosting() {
export function useUpdateJobPosting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }) => base44.entities.JobPosting.update(id, data),
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobPosting.update(id, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
queryClient.invalidateQueries({ queryKey: ['jobPosting', variables.id] });
@@ -179,7 +179,7 @@ export function useUpdateJobPosting() {
export function useCreateApplication() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => base44.entities.JobApplication.create(data),
mutationFn: /** @param {any} data */ (data) => base44.entities.JobApplication.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
logActivity('apply_job');
@@ -190,7 +190,7 @@ export function useCreateApplication() {
export function useUpdateApplication() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }) => base44.entities.JobApplication.update(id, data),
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobApplication.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
},
@@ -200,7 +200,7 @@ export function useUpdateApplication() {
export function useScreenCandidate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ application, job }) => {
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
const result = await screenCandidate(application, job);
return base44.entities.JobApplication.update(application.id, {
status: 'ai_screened',
@@ -223,7 +223,7 @@ export function useScreenCandidate() {
export function useScreenAllCandidates() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ applications, job }) => {
mutationFn: /** @param {any} vars */ async ({ applications, job }) => {
const results = [];
for (const app of applications) {
if (app.status === 'applied' || app.status === 'ai_screened') {
@@ -253,7 +253,7 @@ export function useScreenAllCandidates() {
export function useGenerateJobDescription() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ data, draftId }) => {
mutationFn: /** @param {any} vars */ async ({ data, draftId }) => {
const result = await generateJobDescription(data);
if (draftId) {
await base44.entities.JobPosting.update(draftId, {
@@ -274,7 +274,7 @@ export function useGenerateJobDescription() {
export function useHireCandidate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ application, job }) => {
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
const tier = application.ai_score >= 80 ? 'Skilled' : application.ai_score >= 60 ? 'Cross-Trained' : 'Beginner';
const updated = await base44.entities.JobApplication.update(application.id, { status: 'hired' });
await base44.entities.Staff.create({
@@ -301,14 +301,14 @@ export function useHireCandidate() {
export function useMatchTalentForJob() {
return useMutation({
mutationFn: async ({ job, profiles }) => matchTalentForJob(job, profiles),
mutationFn: /** @param {any} vars */ async ({ job, profiles }) => matchTalentForJob(job, profiles),
});
}
export function useCreateInterview() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => base44.entities.AIInterview.create(data),
mutationFn: /** @param {any} data */ (data) => base44.entities.AIInterview.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interviews'] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
@@ -339,7 +339,7 @@ export function useCourse(id) {
export function useCreateCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => base44.entities.Course.create(data),
mutationFn: /** @param {any} data */ (data) => base44.entities.Course.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['courses'] });
logActivity('create_skill_training');
@@ -351,7 +351,7 @@ export function useCreateCourse() {
export function useUpdateCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }) => base44.entities.Course.update(id, data),
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Course.update(id, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['courses'] });
queryClient.invalidateQueries({ queryKey: ['course', variables.id] });
@@ -385,7 +385,7 @@ export function useAssignments() {
export function useAssignWorkers() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ position, workers = [], applications = [] }) => {
mutationFn: /** @param {any} vars */ async ({ position, workers = [], applications = [] }) => {
const startsAt = position.start_date
? new Date(position.start_date).toISOString()
: new Date().toISOString();
@@ -484,7 +484,7 @@ export function useAssignWorkers() {
export function useMarkInterviewReady() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ application, position }) => {
mutationFn: /** @param {any} vars */ async ({ application, position }) => {
if (!application?.id) throw new Error('No application to schedule against');
const updated = await base44.entities.JobApplication.update(application.id, {
@@ -564,7 +564,7 @@ export function useWorkerProfiles() {
export function useUpdateWorkerProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }) => base44.entities.WorkerProfile.update(id, data),
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.WorkerProfile.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
@@ -575,7 +575,7 @@ export function useUpdateWorkerProfile() {
export function useCompleteCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ profile, course, quizScore }) => {
mutationFn: /** @param {any} vars */ async ({ profile, course, quizScore }) => {
const already = (profile.completed_courses || []).some(c => c.course_id === course.id);
const completed_courses = already ? profile.completed_courses : [
...profile.completed_courses,
@@ -616,7 +616,7 @@ export function useEvidenceList(workerEmail) {
export function useSubmitChallenge() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ profile, course, type, mediaUrl, transcript, identified }) => {
mutationFn: /** @param {any} vars */ async ({ profile, course, type, mediaUrl, transcript, identified }) => {
const result = await evaluateChallenge(course, { type, mediaUrl, transcript, identified, workerName: profile?.full_name });
const verdict = result.verdict || 'needs_work';
const score = Math.round(Number(result.score) || 0);
@@ -674,7 +674,7 @@ export function useSubmitChallenge() {
export function useVerifyEvidence() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ evidenceId }) =>
mutationFn: /** @param {any} vars */ async ({ evidenceId }) =>
base44.entities.Evidence.update(evidenceId, {
supervisor_verified: true,
supervisor_name: 'Supervisor',

View File

@@ -397,7 +397,7 @@ const RESOLVERS = {
/** Everyone hired, most recent first — the record rather than the analysis. */
'hires.recent': ({ staff = [], applications = [], positions = [] }, section) => {
const items = [...staff]
.sort((a, b) => new Date(b.hire_date || b.created_date || 0) - new Date(a.hire_date || a.created_date || 0))
.sort((a, b) => new Date(b.hire_date || b.created_date || 0).getTime() - new Date(a.hire_date || a.created_date || 0).getTime())
.slice(0, section.limit || 10)
.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
@@ -432,7 +432,7 @@ const RESOLVERS = {
const hired = applications.filter((a) => a.status === 'hired');
const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
const days = hired
.map((a) => Math.round((new Date(a.updated_date) - new Date(a.created_date)) / 86400000))
.map((a) => Math.round((new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / 86400000))
.filter((n) => Number.isFinite(n) && n >= 0);
const mean = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
@@ -466,7 +466,7 @@ const RESOLVERS = {
/** The workspace audit trail, most recent first. */
'activity.events': ({ activity = [] }, section) => {
const items = [...activity]
.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0))
.sort((a, b) => new Date(b.created_date || 0).getTime() - new Date(a.created_date || 0).getTime())
.slice(0, section.limit || 10)
.map((event) => ({
id: event.id,

View File

@@ -41,14 +41,68 @@ function parseFrontmatter(raw) {
};
}
/** Bullets under a `## Heading`, for the capability list shown in Settings. */
/**
* The text under a `## Heading`, up to the next one.
*
* The heading is escaped before it becomes a pattern. It used to be
* interpolated raw, so a heading containing regular-expression punctuation —
* `## Capabilities (v2)` is the obvious one — compiled to a pattern that could
* not match the words it was built from, and the whole section silently read as
* absent. A section that is present and unreadable is the failure this parser
* must never have: it looks exactly like a section the author did not write.
*/
function sectionSource(body, heading) {
const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const section = new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
return section ? section[1] : null;
}
/**
* List items under a `## Heading`, for the capability list shown in Settings.
*
* Two kinds of content were being dropped without a word, and both are the same
* mistake — treating "a line I do not recognise" as "a line that is not there":
*
* - **Wrapped items.** A bullet long enough to run onto a second line kept
* only its first line. `create-position.md` documented reading a request
* "out of a single sentence" and the registry held the sentence without its
* last three words. An item is now everything up to the next item or the
* blank line that ends the list.
* - **Numbered items.** Only `-` counted, so a `1.` list — the natural way to
* write ordered instructions — parsed as an empty section. Both markers are
* list items in Markdown and both are read as one here.
*
* Neither change alters any definition currently on disk: every one of them
* uses single-line `-` bullets, so this widens what can be written without
* moving what already was.
*/
const LIST_ITEM = /^\s*(?:-|\*|\d+[.)])\s+(.*)$/;
function sectionBullets(body, heading) {
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
if (!section) return [];
return section[1]
.split(/\r?\n/)
.map((l) => /^\s*-\s+(.*)$/.exec(l)?.[1]?.trim())
.filter(Boolean);
const source = sectionSource(body, heading);
if (source == null) return [];
const items = [];
for (const line of source.split(/\r?\n/)) {
const item = LIST_ITEM.exec(line);
if (item) {
items.push(item[1].trim());
continue;
}
/* A blank line closes the current item; anything else indented under one is
its continuation and belongs to it. Prose before the first item — the
explanatory paragraph `## Conversation` opens with — matches neither and
is ignored, exactly as before. */
if (!line.trim()) {
if (items.length) items.push('');
continue;
}
if (items.length && items[items.length - 1] !== '' && /^\s+/.test(line)) {
items[items.length - 1] += ` ${line.trim()}`;
}
}
return items.map((i) => i.trim()).filter(Boolean);
}
/**
@@ -83,11 +137,11 @@ function sectionSteps(body, heading) {
* rather than a list.
*/
function sectionText(body, heading) {
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
if (!section) return '';
return section[1]
const source = sectionSource(body, heading);
if (source == null) return '';
return source
.split(/\r?\n/)
.map((l) => l.replace(/^\s*[-*]\s+/, '').trim())
.map((l) => l.replace(/^\s*(?:[-*]|\d+[.)])\s+/, '').trim())
.filter(Boolean)
.join(' ')
.trim();
@@ -323,6 +377,139 @@ export const SKILLS = Object.entries(FILES)
.map(([path, raw]) => parseSkill(raw, { path }))
.sort((a, b) => a.name.localeCompare(b.name));
/**
* The registry as actually assembled, with everything that went wrong assembling
* it.
*
* This function exists because the three ways a definition could fail to arrive
* intact were all *silent*, and between them they are the whole of the
* "sometimes a skill only half works" report:
*
* 1. **A stored definition that will not parse was dropped.** The `catch`
* here returned `null` and the entry vanished. Nothing anywhere said a
* skill had been discarded, so the skill simply stopped answering and the
* registry looked healthy.
* 2. **A definition that parses can still register incomplete.**
* `normalizeSkillOwliver` keeps the capabilities that resolve and reports
* the rest as errors — correct, and the reason a single bad response does
* not cost an author their whole file. But only `validateSkillSource`
* reads those errors, and that runs in the authoring dialog. A definition
* arriving from storage was registered with its broken capability quietly
* missing, which is exactly "some checks work and others do not".
* 3. **A custom definition silently replaces a built-in of the same id.**
* Intended — it is how a shipped skill is overridden — but indistinguishable
* from the built-in having broken, because nothing reported the override.
*
* Why deployment exposed all three: custom definitions live in per-account
* `preferences`, not in the repository. The bundled `.md` files are identical
* everywhere — verified — so a definition that behaves differently in a
* deployed workspace differs because of what that *account* has stored, and
* because stored Markdown is validated when it is written and never again.
* Ship a change to the supported vocabulary and yesterday's valid definition
* silently loses a capability on next load.
*
* Nothing is dropped that used to register, and nothing new registers. The only
* change is that the failures now have somewhere to be read from.
*/
export function readSkillRegistry(customSources = []) {
const diagnostics = [];
const custom = [];
customSources.forEach((entry, i) => {
const path = entry?.path || `custom/${i}.md`;
let skill = null;
try {
skill = parseSkill(entry?.raw ?? entry, { path, custom: true });
} catch (error) {
diagnostics.push({
level: 'error',
kind: 'unreadable',
path,
skillId: null,
message: `A stored skill could not be read and is not registered. ${error?.message || ''}`.trim(),
});
return;
}
if (!skill?.id) {
diagnostics.push({
level: 'error',
kind: 'unreadable',
path,
skillId: null,
message: 'A stored skill has no `id` and is not registered.',
});
return;
}
custom.push(skill);
});
const byId = new Map(SKILLS.map((s) => [s.id, s]));
for (const skill of custom) {
if (byId.has(skill.id) && !byId.get(skill.id).custom) {
diagnostics.push({
level: 'warning',
kind: 'shadowed',
path: skill.path,
skillId: skill.id,
message: `\`${skill.id}\` replaces the built-in skill of the same id. The built-in definition is not registered.`,
});
}
byId.set(skill.id, skill);
}
const skills = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
/* A registered definition that lost part of itself on the way in. Reported
for every skill, not only custom ones, so a file on disk that stops
resolving after a vocabulary change is just as visible. */
for (const skill of skills) {
for (const message of [...(skill.owliverErrors || []), ...(skill.uiErrors || [])]) {
diagnostics.push({
level: 'error',
kind: 'incomplete',
path: skill.path,
skillId: skill.id,
message,
});
}
}
/**
* Two definitions claiming one phrase on one page.
*
* Only the first is ever consulted, and "first" means alphabetically by name —
* so the losing definition answers nothing, with no way to tell that from it
* being switched off. Reported per page, because a shared trigger on two
* different pages is not a contest.
*/
const claims = new Map();
for (const skill of skills) {
if (skill.status !== 'active' || !skill.declaredTriggers) continue;
for (const page of skill.pages) {
const key = canonicalPage(page) || page;
for (const trigger of new Set(skill.triggers)) {
const at = `${key}::${trigger}`;
if (!claims.has(at)) claims.set(at, []);
claims.get(at).push(skill);
}
}
}
for (const [at, claimants] of claims) {
if (claimants.length < 2) continue;
const [page, trigger] = at.split('::');
diagnostics.push({
level: 'warning',
kind: 'trigger-collision',
path: claimants[0].path,
skillId: claimants[0].id,
message: `On ${page}, "${trigger}" is claimed by ${claimants.map((s) => s.id).join(' and ')}. `
+ `Only ${claimants[0].id} is consulted.`,
});
}
return { skills, diagnostics };
}
/**
* The built-in skills plus any the account has added.
*
@@ -330,21 +517,17 @@ export const SKILLS = Object.entries(FILES)
* is the same artefact a file on disk would be — nothing is half-parsed into a
* bespoke record. A custom skill sharing an id with a built-in replaces it,
* which is how one would be overridden without editing the repository.
*
* Unchanged in what it returns; `readSkillRegistry` is where the same work is
* done with its failures kept.
*/
export function allSkills(customSources = []) {
const custom = customSources
.map((entry, i) => {
try {
return parseSkill(entry.raw ?? entry, { path: entry.path || `custom/${i}.md`, custom: true });
} catch {
return null;
}
})
.filter((s) => s && s.id);
return readSkillRegistry(customSources).skills;
}
const byId = new Map(SKILLS.map((s) => [s.id, s]));
custom.forEach((s) => byId.set(s.id, s));
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
/** Everything that went wrong assembling the registry, for the Skills page. */
export function skillDiagnostics(customSources = []) {
return readSkillRegistry(customSources).diagnostics;
}
/**
@@ -472,11 +655,27 @@ function triggerMatches(trigger, question) {
*/
const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver');
export function matchSkill(question, contextId, disabled = [], customSources = []) {
/**
* Every skill on this page whose triggers match — in registry order.
*
* `matchSkill` answers with the first and is what routing uses, because one
* question gets one answer. The full list is what makes the choice *auditable*:
* when two definitions claim the same phrase, the loser was previously
* invisible, and the winner was decided by `allSkills`'s alphabetical sort —
* a definition renamed from "Hiring…" to "Activity…" could take over a phrase
* without either file's triggers changing. Nothing here changes which skill
* answers; it makes the fact that there was a contest something a caller can
* see and report.
*/
export function matchSkills(question, contextId, disabled = [], customSources = []) {
const q = String(question).toLowerCase();
return skillsForContext(contextId, disabled, customSources).find(
return skillsForContext(contextId, disabled, customSources).filter(
(s) => addressable(s) && s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
) ?? null;
);
}
export function matchSkill(question, contextId, disabled = [], customSources = []) {
return matchSkills(question, contextId, disabled, customSources)[0] ?? null;
}
/**
@@ -489,3 +688,28 @@ export function matchSkill(question, contextId, disabled = [], customSources = [
*/
export const skillsWithFacet = (skills = [], facet) =>
skills.filter((s) => s.facets?.includes(facet));
/**
* AI agent skills — the ones Workspace & Skills governs.
*
* Two different things live in this registry, and they belong to two different
* products:
*
* - **AI agent skills** (`kind: 'assistant'`) are capabilities Owliver gains.
* An Owliver skill teaches it what it can be asked; a Board skill draws a
* section on a page. Both are governed in Workspace & Skills.
* - **Workforce training** (`kind: 'workforce'`) is what a *person* learns and
* proves — Bartending, Food Safety, Customer Service. That belongs to KROW
* Forge, and is read through `workforceSkillStates`.
*
* This exists so no surface has to reach for `allSkills()` and hope. A page that
* calls `allSkills()` gets both kinds, which is how five training paths ended up
* counted as Owliver capabilities on the Workspace landing page — the registry
* was right and the reading was not. Asking for agent skills by name means a
* training path added tomorrow cannot appear there by default.
*/
export const aiAgentSkills = (skills = []) => skills.filter((s) => s.kind === 'assistant');
/** Workforce training definitions — KROW Forge's half of the same registry. */
export const workforceTrainingSkills = (skills = []) =>
skills.filter((s) => s.kind === 'workforce');

View File

@@ -47,7 +47,7 @@ export function endsAt(position, today = new Date()) {
/** Days from now until the work starts. Negative or zero means it is live. */
export const daysUntilStart = (position, today = new Date()) =>
Math.round((startsAt(position, today) - today) / DAY_MS);
Math.round((startsAt(position, today).getTime() - today.getTime()) / DAY_MS);
/** "Immediately", "in 6 days", "in 3 weeks" — the phrasing answers use. */
export function startLabel(position, today = new Date()) {
@@ -160,7 +160,7 @@ export function durationFitOf(profile, position, { today = new Date() } = {}) {
partial fit for it, and anyone without is a full one. */
const free = profile?.available_until ? new Date(profile.available_until) : null;
if (!free) return { covers: true, ratio: 1, note: null };
const months = Math.max(0, (free - startsAt(position, today)) / MONTH_MS);
const months = Math.max(0, (free.getTime() - startsAt(position, today).getTime()) / MONTH_MS);
return {
covers: false,
ratio: Math.min(1, months / 12),
@@ -171,8 +171,8 @@ export function durationFitOf(profile, position, { today = new Date() } = {}) {
const free = profile?.available_until ? new Date(profile.available_until) : null;
if (!free || free >= end) return { covers: true, ratio: 1, note: null };
const needed = end - startsAt(position, today);
const have = Math.max(0, free - startsAt(position, today));
const needed = end.getTime() - startsAt(position, today).getTime();
const have = Math.max(0, free.getTime() - startsAt(position, today).getTime());
const ratio = needed ? Math.max(0, Math.min(1, have / needed)) : 1;
return {
@@ -545,7 +545,9 @@ export function prioritise(positions = [], context = {}) {
* function existing separately from the mutation is what makes "show me first"
* the only possible path rather than a convention someone has to remember.
*/
export function prepareAssignment(position, { count, ...context } = {}) {
/** @param {any} position @param {any} [options] */
export function prepareAssignment(position, options = {}) {
const { count, ...context } = options || {};
const status = workforceStatusFor(position, context);
const take = Math.min(
count ?? status.demand.remaining,

View File

@@ -68,7 +68,7 @@ export default function AdminDashboard() {
return { applied: apps.length, screened: apps.filter(a => a.status !== 'applied').length, hired: apps.filter(a => a.status === 'hired').length };
};
const recentStaff = [...activeStaff].sort((a, b) => new Date(b.hire_date || 0) - new Date(a.hire_date || 0)).slice(0, 6);
const recentStaff = [...activeStaff].sort((a, b) => new Date(b.hire_date || 0).getTime() - new Date(a.hire_date || 0).getTime()).slice(0, 6);
return (
<div className="space-y-8">
@@ -140,7 +140,7 @@ export default function AdminDashboard() {
<p className="text-[13px] text-[#9CA3AF]">No hired staff yet</p>
</div>
)}
{recentStaff.map((s, idx) => (
{recentStaff.map((s, _idx) => (
<div key={s.id} className="flex items-center gap-3 p-2.5 rounded-xl hover:bg-white/50 transition-colors">
<div className="w-9 h-9 rounded-full bg-[#333F48] flex items-center justify-center text-white text-[12px] font-semibold shrink-0">
{s.name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()}

View File

@@ -30,7 +30,8 @@ import { AXIS_PROPS, CHART_TONES } from '@/components/ds';
* place. Not linked from the product navigation — reach it at /design-system.
*/
function Spec({ title, description, children, className }) {
/** @param {any} props */
function Spec({ title, description, children, className = '' }) {
return (
<Surface variant="solid" radius="xl" padding="lg" className={className}>
<SectionHeader title={title} subtitle={description} />
@@ -39,7 +40,8 @@ function Spec({ title, description, children, className }) {
);
}
function Row({ label, children }) {
/** @param {any} props */
function Row({ label = '', children }) {
return (
<div className="flex flex-wrap items-center gap-3">
{label && <span className="text-caption text-ink-4 w-24 shrink-0">{label}</span>}

View File

@@ -66,7 +66,7 @@ export default function HiredHistory() {
return matchesSearch && matchesTier && matchesReview;
});
const sorted = [...filtered].sort((a, b) => new Date(b.created_date) - new Date(a.created_date));
const sorted = [...filtered].sort((a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime());
const ratedCount = staff.filter((s) => (s.client_rating || 0) > 0).length;
const avgRating = ratedCount > 0
@@ -215,7 +215,8 @@ export default function HiredHistory() {
);
}
function StatPill({ label, value, accent }) {
/** @param {any} props */
function StatPill({ label, value, accent = false }) {
return (
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[#F0F4F8] border border-[#E5E7EB]">
<span className="text-[11px] font-semibold text-[#1E56C1]">{label}</span>

View File

@@ -175,7 +175,8 @@ export default function Owliver({ compact = false }) {
}, [workerName, speak, language]);
const startListening = useCallback(() => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
const win = /** @type {any} */ (window);
const SR = win.SpeechRecognition || win.webkitSpeechRecognition;
if (!SR) { setError('Voice input not supported here — type your answer below.'); return; }
// Yield Owliver's voice so the mic picks up the user, not Owliver.
if (window.speechSynthesis) window.speechSynthesis.cancel();
@@ -541,7 +542,8 @@ export default function Owliver({ compact = false }) {
);
}
function SummaryField({ label, value, full, empty = '—' }) {
/** @param {any} props */
function SummaryField({ label, value, full = false, empty = '—' }) {
const has = value && String(value).trim();
return (
<div className={cn('rounded-lg bg-[#F9FAFB] p-2.5', full && 'col-span-2')}>

View File

@@ -121,7 +121,7 @@ export default function AdminActivity() {
const recent = useMemo(
() => events
.slice()
.sort((a, b) => new Date(b.created_date) - new Date(a.created_date))
.sort((a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime())
.slice(0, TIMELINE_LIMIT),
[events]
);

View File

@@ -85,7 +85,8 @@ const PRESSABLE = 'transition-transform duration-150 hover:-translate-y-px activ
/* ── Small shared pieces ────────────────────────────────────────────────── */
/** A label inside a section — a rung below the section headings the rail draws. */
function SubLabel({ children, className }) {
/** @param {any} props */
function SubLabel({ children, className = '' }) {
return (
<p className={cn('mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-ink-4', className)}>
{children}
@@ -115,7 +116,8 @@ function Signal({ label, value }) {
}
/** A reputation figure. Value large, label quiet, no card around it. */
function Metric({ label, value, suffix, tone }) {
/** @param {any} props */
function Metric({ label, value, suffix = '', tone = '' }) {
if (value == null || value === '') return null;
return (
<div className="min-w-0 px-4 py-3">
@@ -163,7 +165,8 @@ function Fact({ label, children }) {
* Staggered by index, once. The delay is capped so a candidate with twenty
* skills does not make the reader watch a queue form.
*/
function Pill({ index = 0, className, children }) {
/** @param {any} props */
function Pill({ index = 0, className = '', children }) {
const reduced = useReducedMotion();
return (
<motion.span

View File

@@ -21,7 +21,7 @@ const SCORE_BANDS = {
const SORTS = {
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) },
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date) - new Date(a.updated_date) },
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime() },
name: { label: 'Name A–Z', compare: (a, b) => a.applicant_name.localeCompare(b.applicant_name) },
experience: { label: 'Most experience', compare: (a, b) => (b.years_experience || 0) - (a.years_experience || 0) },
};

View File

@@ -31,7 +31,8 @@ const RANGES = {
const WEAK_TRANSITION = 60;
function Band({ id, title, meta, children, action }) {
/** @param {any} props */
function Band({ id, title, meta = '', children, action = null }) {
return (
<section aria-labelledby={id} className="space-y-3">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2">

View File

@@ -166,7 +166,7 @@ export default function AdminHiredHistory() {
if (cutoff && new Date(h.hire_date || 0).getTime() < cutoff) return false;
return true;
})
.sort((a, b) => new Date(b.hire_date || 0) - new Date(a.hire_date || 0));
.sort((a, b) => new Date(b.hire_date || 0).getTime() - new Date(a.hire_date || 0).getTime());
}, [hires, search, filters]);
const summary = useMemo(() => summarise(filtered), [filtered]);

View File

@@ -20,7 +20,7 @@ export default function AdminLogin() {
const [form, setForm] = useState({ email: DEMO_EMAIL, password: '', remember: true });
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState({});
const [errors, setErrors] = useState(/** @type {{ email?: string, password?: string }} */ ({}));
const [authError, setAuthError] = useState('');
const [status, setStatus] = useState('idle');

View File

@@ -5,7 +5,7 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput, SegmentedToggle,
Avatar, Badge, Button, Drawer, EmptyState, SearchInput, SegmentedToggle,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, StatusBadge, toast,
} from '@/components/ds';
import {
@@ -59,7 +59,7 @@ const SORTS = {
starved: { label: 'Fewest applicants', compare: (a, b) => a.stats.applied - b.stats.applied },
updated: {
label: 'Recently updated',
compare: (a, b) => new Date(b.updated_date || b.created_date) - new Date(a.updated_date || a.created_date),
compare: (a, b) => new Date(b.updated_date || b.created_date).getTime() - new Date(a.updated_date || a.created_date).getTime(),
},
title: { label: 'Title A–Z', compare: (a, b) => a.title.localeCompare(b.title) },
};
@@ -72,7 +72,8 @@ const HEALTH_TONE = {
};
/** Hiring health as a small filled chip — it is a state, so it reads as one. */
function HealthChip({ health, className }) {
/** @param {any} props */
function HealthChip({ health, className = '' }) {
return (
<span
className={cn(
@@ -740,43 +741,48 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
</section>
)}
{/* Hiring status */}
<section>
<SectionTitle title="Hiring status" />
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
{[
{ label: 'Applicants', value: s.applied },
{ label: 'Shortlisted', value: s.shortlisted },
{ label: 'Interviews', value: s.interviews },
{ label: 'Hired', value: s.hired },
{ label: 'Qualified (70+)', value: s.qualified },
{ label: 'Avg score', value: s.avgScore || '—' },
].map((m) => (
<div key={m.label}>
<dt className="text-[10px] font-medium uppercase tracking-wide text-ink-4">{m.label}</dt>
<dd className="font-heading text-title font-bold tabular-nums text-ink-1">{m.value}</dd>
</div>
))}
</dl>
<div className="mt-4">
<div className="mb-1.5 flex items-baseline justify-between">
<span className="text-caption text-ink-2">Screening progress</span>
<span className="text-caption font-semibold tabular-nums text-ink-1">{s.coverage}%</span>
</div>
<ProgressBar
value={s.coverage}
tone={s.coverage === 100 ? 'success' : s.coverage >= 60 ? 'brand' : 'warning'}
size="sm"
/>
<p className="mt-1 text-[11px] text-ink-4">
{s.applied - s.unscreened} of {s.applied} applicants scored
</p>
</div>
</section>
{/* Hiring progression — the single source of truth for the pipeline.
A separate "Hiring status" block used to sit above this one repeating
Shortlisted, Interviews and Hired as figures, which is exactly what the
progression already draws. Two readings of the same funnel invite the
reader to check whether they agree, so the duplicated stage counts are
gone and the three figures the progression does *not* carry — supply,
quality and the screening average — lead it as one line. */}
<section>
<SectionTitle title="Hiring progression" />
<dl className="mb-3 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-body-sm">
<div className="flex items-baseline gap-1.5">
<dt className="sr-only">Total applicants</dt>
<dd className="font-semibold tabular-nums text-ink-1">{s.applied}</dd>
<span className="text-ink-3">Applicant{s.applied === 1 ? '' : 's'}</span>
</div>
<span className="text-ink-4" aria-hidden="true">·</span>
<div className="flex items-baseline gap-1.5">
<dt className="sr-only">Qualified, scoring 70 or above</dt>
<dd className="font-semibold tabular-nums text-ink-1">{s.qualified}</dd>
<span className="text-ink-3">Qualified (70+)</span>
</div>
<span className="text-ink-4" aria-hidden="true">·</span>
<div className="flex items-baseline gap-1.5">
<dt className="text-ink-3">Avg Score</dt>
<dd className="font-semibold tabular-nums text-ink-1">{s.avgScore || '—'}</dd>
</div>
{/* Screening coverage was the one figure in the old block that the
progression cannot show — a stage count says how many reached
Screened, not what share of the intake that is. */}
{s.applied > 0 && (
<>
<span className="text-ink-4" aria-hidden="true">·</span>
<div className="flex items-baseline gap-1.5">
<dt className="sr-only">Screening coverage</dt>
<dd className="font-semibold tabular-nums text-ink-1">{s.coverage}%</dd>
<span className="text-ink-3">screened</span>
</div>
</>
)}
</dl>
<Progression steps={p.progression} />
</section>
@@ -811,22 +817,45 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
))}
</ul>
) : (
<p className="text-body-sm text-ink-3">
Nobody has been screened for this role yet, so there is no ranking to show.
</p>
/* Two short lines, not a paragraph. The reason nobody is ranked is
always the same — screening has not run — so it needs stating once,
plainly, rather than explained. */
<div className="rounded-lg border border-dashed border-border px-3 py-4">
<p className="text-body-sm font-medium text-ink-2">No screened candidates yet</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
Candidates will appear here once screening is completed.
</p>
</div>
)}
</section>
{/* The timeline sits beside Top candidates, so it earns a rail and nodes:
two plain label/date lists side by side read as one table split in
half. The dots say "this one is chronological" without a heavier
treatment. */}
<section>
<SectionTitle title="Hiring timeline" />
<ol className="space-y-2">
<ol className="space-y-0">
{timeline.map((e, i) => (
<li key={i} className="flex items-baseline justify-between gap-3 text-body-sm">
<span className="text-ink-2">{e.label}</span>
<span className="shrink-0 text-caption text-ink-4">
{e.date
? new Date(e.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: '—'}
<li key={i} className="flex gap-2.5 pb-3 last:pb-0">
<span className="flex flex-col items-center" aria-hidden="true">
<span
className={cn(
'mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full',
i === timeline.length - 1 ? 'bg-krow-blue' : 'bg-ink-4'
)}
/>
{i < timeline.length - 1 && <span className="mt-1 w-px flex-1 bg-border" />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-body-sm leading-snug text-ink-2">{e.label}</span>
<span className="block text-caption text-ink-4">
{e.date
? new Date(e.date).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
})
: '—'}
</span>
</span>
</li>
))}
@@ -841,7 +870,7 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
{p.applications.length ? (
<ol className="space-y-2">
{[...p.applications]
.sort((a, b) => new Date(b.updated_date) - new Date(a.updated_date))
.sort((a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime())
.slice(0, 5)
.map((a) => (
<li key={a.id} className="flex items-baseline justify-between gap-3 text-body-sm">
@@ -1161,7 +1190,8 @@ export default function AdminPositions() {
}
/** A compact labelled select pill — consistent on every Admin management page. */
export function FilterSelect({ value, onChange, label, options, className }) {
/** @param {any} props */
export function FilterSelect({ value, onChange, label, options, className = '' }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger

View File

@@ -19,6 +19,7 @@ import { cn } from '@/lib/utils';
*/
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
/** @type {[RegExp, React.ComponentType<any>][]} */
const GLYPHS = [
[/chef|cook|kitchen/i, ChefHat],
[/bartend|bar|mixolog/i, Wine],
@@ -41,7 +42,8 @@ const SIZES = {
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
};
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className }) {
/** @param {any} props */
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }) {
const Icon = iconForRole(category, title);
const s = SIZES[size] || SIZES.md;

View File

@@ -18,7 +18,7 @@ const SORTS = {
score: { label: 'Highest score', compare: (a, b) => (b.krow_score || 0) - (a.krow_score || 0) },
experience: { label: 'Most experience', compare: (a, b) => (b.experience_years || 0) - (a.experience_years || 0) },
name: { label: 'Name A–Z', compare: (a, b) => a.full_name.localeCompare(b.full_name) },
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date) - new Date(a.created_date) },
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime() },
};
const BANDS = {

View File

@@ -1,38 +1,62 @@
import React, { useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link } from 'react-router-dom';
import {
GraduationCap, Sparkles, TrendingUp,
LayoutTemplate, Sparkles,
BookOpen, ArrowRight
} from 'lucide-react';
import { usePreferences } from '@/lib/krowHooks';
import { allSkills } from '@/lib/skills/registry';
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
import { aiAgentSkills, allSkills, skillsWithFacet } from '@/lib/skills/registry';
import { AdminPage } from '@/pages/admin/_shell';
import { cn } from '@/lib/utils';
/**
* Workspace & Skills — Full-width workforce capabilities & AI governance hub.
* Workspace & Skills — AI agent capability governance.
*
* This page governs what *Owliver* can do. It used to govern workforce training
* as well — Training Paths and Skill Progression sat in the right-hand column —
* and that conflated two unrelated things under one word. A "skill" here is a
* capability the assistant gains; a "skill" in KROW Forge is something a person
* learns, proves and is verified in. They have different owners, different
* lifecycles and different audiences, and putting them side by side made the page
* read as though enabling a Bartending path taught Owliver to tend bar.
*
* Workforce training was not removed, only returned to where it belongs: the
* definitions, the routes and the pages are untouched, and KROW Forge
* (`/admin/university`) and Skill Development still read them. See
* `workforceTrainingSkills` in the registry for the other half of the split.
*/
export default function AdminWorkspace() {
const navigate = useNavigate();
const preferences = usePreferences();
/* Owliver's skills from Markdown registry */
/**
* AI agent skills only.
*
* Deliberately `aiAgentSkills(...)` rather than `allSkills(...)`. The registry
* holds both kinds, so the unfiltered read counted five workforce training
* paths among Owliver's capabilities — the hero said ten registered skills when
* the assistant had five. Naming the kind is also what stops a training path
* added later from reappearing here on its own.
*/
const skills = useMemo(
() => allSkills(preferences.customSkills || []),
() => aiAgentSkills(allSkills(preferences.customSkills || [])),
[preferences.customSkills]
);
const disabledSkills = preferences.disabledSkills || [];
const activeSkills = skills.filter((s) => !disabledSkills.includes(s.id)).length;
/* Workforce training paths from registry */
const { paths } = useWorkforcePaths();
/* The two kinds of agent skill — the same two lists the management page is
split into, read here from the same filtered registry so the landing page and
the manager can never disagree about what is registered. */
const owliverSkills = useMemo(() => skillsWithFacet(skills, 'owliver'), [skills]);
const boardSkills = useMemo(() => skillsWithFacet(skills, 'ui'), [skills]);
const owliverCount = owliverSkills.length;
const boardCount = boardSkills.length;
return (
<AdminPage
title="Workspace & Skills"
subtitle="Manage workforce capabilities, Owliver skills and training."
subtitle="Govern the AI agent capabilities that extend Owliver and KROW pages."
>
<div className="space-y-6 w-full">
{/* ── 1. Hero Banner (Full Width) ───────────────────────────────── */}
@@ -42,11 +66,12 @@ export default function AdminWorkspace() {
<div className="space-y-2">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/20 backdrop-blur-md text-white text-[12px] font-semibold border border-white/30">
<Sparkles className="w-3.5 h-3.5 text-sky-300" />
Workforce Intelligence Hub
AI Capability Governance
</div>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">Capabilities & Skill Governance</h1>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white">AI Agent Capabilities</h1>
<p className="text-[14px] text-blue-100 font-normal max-w-2xl">
Govern Owliver AI agent capabilities, configure workforce training paths, and track talent progression across your organization.
Govern what Owliver can understand, analyze and execute. Workforce training
paths and talent progression are managed in KROW Forge.
</p>
</div>
@@ -56,10 +81,13 @@ export default function AdminWorkspace() {
<p className="text-2xl font-bold text-white mt-0.5">{skills.length}</p>
<span className="text-[10px] text-emerald-300 font-medium">{activeSkills} Active</span>
</div>
{/* Was "Training Paths". Replaced rather than dropped: the slot is
useful, and the split of agent skills across the two lists is
what this page actually governs. */}
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 border border-white/15 min-w-[130px] text-center">
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Training Paths</p>
<p className="text-2xl font-bold text-sky-300 mt-0.5">{paths.length}</p>
<span className="text-[10px] text-blue-200">Verified Ladders</span>
<p className="text-[11px] uppercase tracking-wider text-blue-200 font-semibold">Skill Kinds</p>
<p className="text-2xl font-bold text-sky-300 mt-0.5">{owliverCount + boardCount}</p>
<span className="text-[10px] text-blue-200">{owliverCount} Owliver · {boardCount} Board</span>
</div>
</div>
</div>
@@ -73,20 +101,22 @@ export default function AdminWorkspace() {
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-[#0838E0]" />
<h2 className="text-[16px] font-bold text-[#101820]">Owliver AI Capabilities</h2>
<h2 className="text-[16px] font-bold text-[#101820]">Owliver Skills</h2>
</div>
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">
{skills.length} Registered · {activeSkills} Active
{owliverCount} Registered
</span>
</div>
<p className="text-[13px] text-[#687386]">
Owliver capabilities define what the AI assistant can execute autonomously during candidate screening, background matching, and contextual analysis.
Owliver skills teach the assistant what it can be asked on a page and how to answer —
screening, matching and contextual analysis. They define what the AI understands, not
what a candidate learns.
</p>
{/* Skills Sample List */}
<div className="space-y-3">
{skills.slice(0, 4).map((s) => {
{owliverSkills.slice(0, 4).map((s) => {
const isActive = !disabledSkills.includes(s.id);
return (
<div key={s.id} className="p-3.5 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 flex items-center justify-between gap-3">
@@ -108,86 +138,78 @@ export default function AdminWorkspace() {
{/* CTA Button */}
<div className="pt-2">
<Link
to="/admin/workspace/skills"
to="/admin/workspace/skills?tab=owliver"
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold transition-all shadow-md shadow-blue-500/20 hover:scale-[1.01]"
>
<Sparkles className="w-4 h-4" />
Manage Owliver Skills & Agent Capabilities
Manage Owliver Skills
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
</div>
{/* ── Right Column: Workforce Training & Skill Development ──────── */}
{/* ── Right Column: Board Skills ─────────────────────────────────
The other kind of AI agent skill. This column previously held
Workforce Training Paths and Skill Progression Tracking, both of
which are KROW Forge concerns — see the note at the top of the file.
Board skills belong here because, like Owliver skills, they are
capabilities the product gains rather than something a person
learns. */}
<div className="space-y-6">
{/* Workforce Training */}
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-5">
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
<div className="flex items-center gap-2">
<GraduationCap className="w-5 h-5 text-indigo-600" />
<h2 className="text-[16px] font-bold text-[#101820]">Workforce Training Paths</h2>
<LayoutTemplate className="w-5 h-5 text-[#0838E0]" />
<h2 className="text-[16px] font-bold text-[#101820]">Board Skills</h2>
</div>
<span className="text-[12px] font-semibold text-indigo-600 bg-indigo-50 px-3 py-1 rounded-full border border-indigo-200/60">
{paths.length} Active Paths
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">
{boardCount} Registered
</span>
</div>
<p className="text-[13px] text-[#687386]">
Structured learning challenges and skill verification ladders where workforce candidates prove their real-world capabilities.
Board skills add dynamic sections to KROW pages, read from real page data. They
extend what a page shows, not what a candidate learns.
</p>
{/* Paths Sample List */}
{/* Sample list, from the same filtered registry read as the left
column — so the two columns can never disagree about what is
registered. */}
<div className="space-y-3">
{paths.slice(0, 3).map((p) => (
<div key={p.id} className="p-3.5 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-[13px] font-bold text-[#101820]">{p.name || p.id}</p>
{p.category && <p className="text-[12px] text-[#687386] capitalize mt-0.5">{p.category} Path</p>}
{boardSkills.slice(0, 4).map((s) => {
const isActive = !disabledSkills.includes(s.id);
return (
<div key={s.id} className="p-3.5 rounded-xl border border-[#E0E4EA] bg-[#F8F8F8]/50 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-[13px] font-bold text-[#101820]">{s.name || s.id}</p>
{s.description && <p className="text-[12px] text-[#687386] truncate mt-0.5">{s.description}</p>}
</div>
<span className={cn(
'px-2.5 py-0.5 rounded-full text-[11px] font-semibold shrink-0',
isActive ? 'bg-emerald-50 text-[#16A765] border border-emerald-200' : 'bg-gray-100 text-gray-500'
)}>
{isActive ? 'Active' : 'Disabled'}
</span>
</div>
<span className="px-2.5 py-0.5 rounded-full bg-indigo-50 text-indigo-700 text-[11px] font-semibold border border-indigo-200">
Verified Path
</span>
</div>
))}
</div>
);
})}
{/* CTA Button */}
<div className="pt-2">
<Link
to="/admin/university"
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] text-[#101820] text-[13px] font-semibold transition-all shadow-xs"
>
<GraduationCap className="w-4 h-4 text-indigo-600" />
Manage Training Paths & Proving Challenges
<ArrowRight className="w-4 h-4" />
</Link>
{boardSkills.length === 0 && (
<p className="text-[13px] text-[#687386] py-2">
No Board skills registered yet. A Board skill declares a section, where it
sits and what it reads.
</p>
)}
</div>
</div>
{/* Skill Development */}
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-4">
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
<div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-[#16A765]" />
<h2 className="text-[16px] font-bold text-[#101820]">Skill Progression Tracking</h2>
</div>
<span className="text-[12px] font-semibold text-[#16A765] bg-emerald-50 px-3 py-1 rounded-full border border-emerald-200/60">
Live Analytics
</span>
</div>
<p className="text-[13px] text-[#687386]">
Track talent advancement across training paths, verified levels, and candidate endorsement histories.
</p>
<div className="pt-2">
<Link
to="/admin/workspace/skill-development"
to="/admin/workspace/skills?tab=ui"
className="w-full inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] text-[#101820] text-[13px] font-semibold transition-all shadow-xs"
>
<TrendingUp className="w-4 h-4 text-[#16A765]" />
View Skill Progression & Talent Growth
<LayoutTemplate className="w-4 h-4 text-[#0838E0]" />
Manage Board Skills
<ArrowRight className="w-4 h-4" />
</Link>
</div>
@@ -199,9 +221,15 @@ export default function AdminWorkspace() {
<div className="flex items-start gap-3.5 p-5 rounded-2xl bg-blue-50/60 border border-blue-200/50 text-[#0838E0] text-[13px] leading-relaxed">
<BookOpen className="w-5 h-5 shrink-0 text-[#0838E0] mt-0.5" />
<div>
<p className="font-bold text-[#101820]">Unified Markdown Registry Architecture</p>
<p className="font-bold text-[#101820]">AI skills and workforce training are different things</p>
<p className="text-[#687386] mt-0.5">
Skills define what Owliver can execute autonomously. Training paths define what the human workforce can be verified in. Both are defined as structured Markdown in one central registry, keeping candidate evaluation and skill progression seamlessly synchronized.
This page governs <span className="font-semibold">AI agent skills</span> — what Owliver
understands and what a Board skill draws on a page. <span className="font-semibold">Workforce
training</span> — training paths, proving challenges, skill verification and talent
progression — is what a person learns, and it is managed in{' '}
<Link to="/admin/university" className="font-semibold underline hover:no-underline">KROW Forge</Link>.
Both are Markdown in one registry, read through separate queries so neither appears in the
other’s surface.
</p>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More