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

@@ -16,6 +16,6 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"types": [] "types": []
}, },
"include": ["src/components/**/*.js", "src/pages/**/*.jsx", "src/layouts/**/*.jsx"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/components/ui", "src/api", "src/lib"] "exclude": ["node_modules", "dist", "src/components/ui", "src/api", "src/lib"]
} }

412
scripts/skill-check.mjs Normal file
View File

@@ -0,0 +1,412 @@
/**
* Skill pipeline check — Owliver skills and Board (UI) skills.
*
* Runs the *real* module graph through Vite, so `import.meta.glob`, the `@/`
* alias and the Markdown loading all behave exactly as they do in the app. That
* matters: the reliability problem this script exists for was never in the
* Markdown, it was in what the pipeline did with a definition it could only
* partly read, and a mock of that pipeline would have reproduced none of it.
*
* node scripts/skill-check.mjs # against source, as the dev server sees it
* node scripts/skill-check.mjs --dist # also assert the built bundle carries every definition
*
* Exits non-zero on failure, so it can gate a build.
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { createServer } from 'vite';
const ROOT = process.cwd();
const results = [];
const record = (name, pass, detail = '') => {
results.push({ name, pass, detail });
const mark = pass ? ' ok ' : ' FAIL ';
console.log(`[${mark}] ${name}${detail ? ` — ${detail}` : ''}`);
};
const server = await createServer({
root: ROOT,
server: { middlewareMode: true },
appType: 'custom',
logLevel: 'error',
});
const reg = await server.ssrLoadModule('/src/lib/skills/registry.js');
const placement = await server.ssrLoadModule('/src/components/ai-assistant/placement.js');
const contextFor = (pageKey) => Object.entries(placement.PLACEMENT_ROUTES)
.find(([route]) => reg.pageKeyForRoute(route) === pageKey)?.[1] ?? null;
/* ── 1. Discovery ─────────────────────────────────────────────────────────── */
console.log('\n── Discovery ──');
const onDisk = readdirSync(join(ROOT, 'src/skills'))
.flatMap((dir) => readdirSync(join(ROOT, 'src/skills', dir)).map((f) => `${dir}/${f}`))
.filter((f) => f.endsWith('.md'));
record(
'every .md under src/skills registers',
reg.SKILLS.length === onDisk.length,
`${reg.SKILLS.length} registered / ${onDisk.length} files`
);
record(
'no duplicate skill ids',
new Set(reg.SKILLS.map((s) => s.id)).size === reg.SKILLS.length,
`${new Set(reg.SKILLS.map((s) => s.id)).size} unique ids`
);
record(
'every skill declares at least one page',
reg.SKILLS.every((s) => s.pages.length > 0)
);
/* Both managed lists are non-empty — the Workspace → Skills tabs. */
const uiFacet = reg.skillsWithFacet(reg.SKILLS, 'ui');
const owliverFacet = reg.skillsWithFacet(reg.SKILLS, 'owliver');
record('Owliver Skills list is populated', owliverFacet.length > 0, `${owliverFacet.length} skills`);
record(
'Board Skills list resolves without error',
Array.isArray(uiFacet),
`${uiFacet.length} skills declare a \`ui:\` block`
);
/* ── 2. Registration integrity: nothing half-loads in silence ─────────────── */
console.log('\n── Registration integrity ──');
const clean = reg.readSkillRegistry([]);
record(
'shipped registry reports no diagnostics',
clean.diagnostics.length === 0,
clean.diagnostics.map((d) => d.message).join(' | ') || 'none'
);
record(
'every shipped skill registered every capability it declared',
reg.SKILLS.every((s) => !(s.owliverErrors?.length || s.uiErrors?.length))
);
/* An unreadable stored definition must be REPORTED, not swallowed. */
const brokenYaml = `---\nid: broken\n name: bad indent\n---\n# Broken\n`;
const withBroken = reg.readSkillRegistry([{ path: 'custom/broken.md', raw: brokenYaml }]);
record(
'unreadable stored skill is reported, not silently dropped',
withBroken.diagnostics.some((d) => d.kind === 'unreadable'),
withBroken.diagnostics.find((d) => d.kind === 'unreadable')?.message ?? 'NO DIAGNOSTIC'
);
/* A definition with one bad capability keeps the good one AND says so. */
const partial = `---
id: partial-check
name: Partial Check
description: One resolvable capability and one that names no source.
pages:
- positions
status: active
owliver:
enabled: true
capabilities:
- summary
- flow
responses:
summary:
source: position.activity
periods:
- today
---
# Partial Check
`;
const withPartial = reg.readSkillRegistry([{ path: 'custom/partial.md', raw: partial }]);
const partialSkill = withPartial.skills.find((s) => s.id === 'partial-check');
record(
'partly-resolvable skill still registers its good capability',
partialSkill?.owliver.capabilities.includes('summary'),
`capabilities = ${JSON.stringify(partialSkill?.owliver.capabilities)}`
);
record(
'and the capability it LOST is reported',
withPartial.diagnostics.some((d) => d.kind === 'incomplete' && d.skillId === 'partial-check'),
withPartial.diagnostics.find((d) => d.kind === 'incomplete')?.message ?? 'NO DIAGNOSTIC'
);
/* Shadowing a built-in is allowed, but must be visible. */
const shadow = `---\nid: create-position\nname: Shadow\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n# Shadow\n`;
const withShadow = reg.readSkillRegistry([{ path: 'custom/shadow.md', raw: shadow }]);
record(
'a custom skill overriding a built-in is reported',
withShadow.diagnostics.some((d) => d.kind === 'shadowed' && d.skillId === 'create-position'),
withShadow.diagnostics.find((d) => d.kind === 'shadowed')?.message ?? 'NO DIAGNOSTIC'
);
/* ── 3. Markdown parsing: no silent content loss ──────────────────────────── */
console.log('\n── Markdown parsing ──');
const cp = reg.SKILLS.find((s) => s.id === 'create-position');
record(
'wrapped bullets are read whole',
cp.capabilities.some((c) => c.endsWith('out of a single sentence.')),
cp.capabilities.find((c) => c.includes('single sentence')) ?? 'TRUNCATED'
);
record(
'conversation steps all parse',
cp.conversation.length === 7 && cp.conversation.every((s) => s.field && s.question),
`${cp.conversation.length} steps`
);
const numbered = reg.parseSkill(
`---\nid: numbered\nname: Numbered\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n`
+ `## Capabilities\n\n1. First instruction.\n2. Second instruction.\n3. Third instruction.\n`,
{ custom: true }
);
record(
'numbered instructions are preserved',
numbered.capabilities.length === 3,
JSON.stringify(numbered.capabilities)
);
/* A section ends at the next `##`, and a heading that merely starts with the
same word is a different heading. Both halves matter: the first is what stops
`Capabilities` swallowing the rest of the file, the second is what stops a
near-miss heading being read as the real one. */
const bounded = reg.parseSkill(
`---\nid: bounded\nname: Bounded\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n`
+ `## Capabilities\n\n- one\n- two\n\n## Capabilities (v2)\n\n- three\n\n## Actions\n\n- four\n`,
{ custom: true }
);
record(
'a section stops at the next heading',
bounded.capabilities.length === 2,
JSON.stringify(bounded.capabilities)
);
/* Sub-headings (`###`) belong to the section they sit under, and must not end
it — `\n##\s` would match `### ` if the space were not required. */
const nested = reg.parseSkill(
`---\nid: nested\nname: Nested\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n`
+ `## Capabilities\n\n- one\n\n### Detail\n\n- two\n\n## Actions\n\n- three\n`,
{ custom: true }
);
record(
'a `###` sub-heading does not truncate its section',
nested.capabilities.length === 2,
JSON.stringify(nested.capabilities)
);
/* Frontmatter of every shipped file parses and carries its required fields. */
let frontmatterOk = true;
const frontmatterDetail = [];
for (const s of reg.SKILLS) {
if (!s.id || !s.name || !s.pages.length) {
frontmatterOk = false;
frontmatterDetail.push(s.path);
}
}
record('every shipped skill has id, name and pages', frontmatterOk, frontmatterDetail.join(', '));
/* ── 4. Trigger / matching ────────────────────────────────────────────────── */
console.log('\n── Trigger matching ──');
const positions = contextFor('positions');
record('positions context resolves', Boolean(positions), String(positions));
const MATRIX = [
['create a position', 'create-position'],
['i want to hire', 'create-position'],
['post a job', 'create-position'],
['add a client', 'create-position'],
['show hiring activity', 'hiring-activity-assistant'],
['hiring flow', 'hiring-activity-assistant'],
['applications over time', 'hiring-activity-assistant'],
];
for (const [question, expected] of MATRIX) {
const got = reg.matchSkill(question, positions)?.id ?? null;
record(`trigger "${question}" -> ${expected}`, got === expected, got ?? 'no match');
}
/* Every claimant is enumerable, so a contested phrase is not invisible. */
const contender = `---
id: zz-contender
name: ZZ Contender
description: Also claims a phrase create-position owns.
pages:
- positions
status: active
triggers:
- create a position
owliver:
enabled: true
capabilities:
- summary
responses:
summary:
source: position.activity
periods:
- today
---
# ZZ Contender
`;
const contested = [{ path: 'custom/zz.md', raw: contender }];
const claimants = reg.matchSkills('create a position', positions, [], contested);
record(
'all trigger claimants are enumerable',
claimants.length === 2,
claimants.map((s) => s.id).join(', ')
);
record(
'a contested trigger is reported as a collision',
reg.readSkillRegistry(contested).diagnostics.some((d) => d.kind === 'trigger-collision'),
reg.readSkillRegistry(contested).diagnostics.find((d) => d.kind === 'trigger-collision')?.message ?? 'NO DIAGNOSTIC'
);
/* ── 5. Owliver suggestions ───────────────────────────────────────────────── */
console.log('\n── Suggestions ──');
const resolver = await server.ssrLoadModule('/src/lib/skills/owliverResolver.js');
const declared = resolver.owliverSuggestions(positions, [], [], {});
record(
'every declared suggestion names a capability the skill offers',
declared.every((c) => !c.skillCapability
|| reg.SKILLS.find((s) => s.id === c.skillId)?.owliver.capabilities.includes(c.skillCapability))
);
const byIntent = declared.map((c) => `${c.skillId}:${c.skillCapability}`);
record(
'no two declared suggestions resolve to one capability',
new Set(byIntent).size === byIntent.length,
byIntent.join(' | ')
);
const dynamic = await server.ssrLoadModule('/src/components/ai-assistant/dynamic.js');
/* The generator is exercised at three different data shapes: the labels must
track the data, and must never appear when the count behind them is zero. */
const factsFor = (n) => ({
postings: [], starvedPositions: new Array(n).fill({}), unscreened: new Array(n).fill({}),
scored: [], stalled: [], ranked: [], activity: [], missingCredentials: [],
});
const shapes = [0, 1, 7, 12];
let labelsOk = true;
const offenders = [];
for (const n of shapes) {
for (const chip of dynamic.buildPrompts('admin.positions', factsFor(n), null)) {
/* A complete intent asks or instructs. A raw fragment — "4 ready for
interview" — does neither, and is what this check exists to catch. */
const isIntent = /\?$/.test(chip.label) || /^(show|summari[sz]e|create|continue|compare|find|explain|what|which|who)\b/i.test(chip.label);
if (!isIntent) { labelsOk = false; offenders.push(`n=${n}: "${chip.label}"`); }
if (n === 0 && /\b0\b/.test(chip.label)) { labelsOk = false; offenders.push(`n=0 leaked a zero count: "${chip.label}"`); }
}
}
record('every generated label is a complete intent', labelsOk, offenders.join(' ; ') || 'all labels are intents');
/* Counts must move with the data rather than being baked in. */
const at7 = dynamic.buildPrompts('admin.positions', factsFor(7), null).map((c) => c.label);
const at12 = dynamic.buildPrompts('admin.positions', factsFor(12), null).map((c) => c.label);
record(
'labels are dynamic, not hardcoded',
at7.some((l) => l.includes('7')) && at12.some((l) => l.includes('12')) && !at7.some((l) => l.includes('12')),
`7 -> ${at7.find((l) => l.includes('7'))} | 12 -> ${at12.find((l) => l.includes('12'))}`
);
const atZero = dynamic.buildPrompts('admin.positions', factsFor(0), null).map((c) => c.label);
record(
'no count-bearing suggestion when the count is zero',
!atZero.some((l) => /\d/.test(l)),
atZero.join(' | ')
);
/**
* The workforce branch — the one the fragments came from.
*
* "4 ready for interview" and "2 strong for Event Server" were generated here,
* so a suggestion check that passes `null` for workforce proves nothing about
* them. Applications are dated today so the "applied today" branch is live too.
*/
const workforceAt = (readyCount) => {
const position = { id: 'p1', title: 'Event Server – Fine Dining', status: 'active', headcount: 5 };
const today = new Date();
const applications = Array.from({ length: readyCount }, (_, i) => ({
id: `a${i}`,
job_posting_id: 'p1',
email: `c${i}@example.com`,
applicant_name: `Candidate ${i}`,
status: i % 2 ? 'ai_screened' : 'shortlisted',
ai_score: 90,
created_date: today.toISOString(),
updated_date: today.toISOString(),
}));
return {
positions: [position],
currentPositionId: null,
context: { applications, today, profiles: [], staff: [], assignments: [] },
};
};
for (const n of [1, 4, 9]) {
const labels = dynamic
.buildPrompts('admin.positions', factsFor(2), workforceAt(n))
.map((c) => c.label);
const ready = labels.find((l) => /ready for interview/i.test(l));
record(
`workforce branch at ${n}: "ready for interview" is a complete question`,
Boolean(ready) && /\?$/.test(ready) && ready.includes(String(n)) && /^Which\b/.test(ready),
ready ?? 'NOT GENERATED'
);
/* Singular/plural has to track the count, or the fix trades one awkward
label for another. */
if (n === 1) {
record(
'a single candidate reads as one candidate',
ready === 'Which 1 candidate is ready for interview?',
ready ?? 'NOT GENERATED'
);
}
const fragment = labels.find((l) => /^\d+\s/.test(l));
record(
`workforce branch at ${n}: no bare status fragments`,
!fragment,
fragment ?? 'none'
);
}
await server.close();
/* ── 6. Production bundle ─────────────────────────────────────────────────── */
if (process.argv.includes('--dist')) {
console.log('\n── Production bundle ──');
const dir = join(ROOT, 'dist/assets');
if (!existsSync(dir)) {
record('dist/assets exists', false, 'run `npm run build` first');
} else {
const bundle = readdirSync(dir)
.filter((f) => f.endsWith('.js'))
.map((f) => readFileSync(join(dir, f), 'utf8'))
.join('\n');
for (const s of reg.SKILLS) {
record(`bundle carries \`${s.id}\``, bundle.includes(s.id));
}
/* Body content, not just the id — a manifest naming a skill whose Markdown
did not survive is the failure this is looking for. */
record(
'bundle carries skill body text',
bundle.includes('Which client is this role for'),
'create-position conversation step found in bundle'
);
}
}
/* ── Summary ──────────────────────────────────────────────────────────────── */
const failed = results.filter((r) => !r.pass);
console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
if (failed.length) {
console.log('\nFailed:');
failed.forEach((r) => console.log(` - ${r.name}${r.detail ? ` (${r.detail})` : ''}`));
process.exit(1);
}

View File

@@ -1314,7 +1314,7 @@ const TRAINING_MODULES = MODULE_TABLE.map(
type, type,
prompt: PROMPT_FOR[type](title), prompt: PROMPT_FOR[type](title),
ai_persona: type === 'roleplay' ? 'A guest or teammate in a live service situation.' : undefined, 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: [] }, unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
quiz: [], 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 * with tables and KPI tiles, and a chat bubble would waste a third of a 380px
* column on padding around them. * 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') { if (role === 'user') {
return ( return (
<div className="flex justify-end"> <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 * It self-hides on pages with no assistant, so a page can render it
* unconditionally and never needs to know the placement rules. * 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(); const { supported, isOpen, toggle, context } = useAssistantPanel();
if (!supported) return null; if (!supported) return null;

View File

@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles, Trash2, ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface'; import { Surface } from '@/components/ds/Surface';
@@ -13,7 +13,6 @@ import {
usePreferences, useRoleCategories, usePreferences, useRoleCategories,
} from '@/lib/krowHooks'; } from '@/lib/krowHooks';
import { ROLE_CATEGORIES } from '@/lib/roleCategories'; import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
import { runAction } from '@/lib/skills/actions'; import { runAction } from '@/lib/skills/actions';
import { skillsForContext } from '@/lib/skills/registry'; import { skillsForContext } from '@/lib/skills/registry';
import { owliverSuggestions } from '@/lib/skills/owliverResolver'; import { owliverSuggestions } from '@/lib/skills/owliverResolver';
@@ -286,7 +285,6 @@ export default function KrowAssistant({
activity: facts.activity || [], activity: facts.activity || [],
trainingPaths, trainingPaths,
}), [pageContext, facts, assignments, trainingPaths]); }), [pageContext, facts, assignments, trainingPaths]);
const [addSkillOpen, setAddSkillOpen] = React.useState(false);
/** /**
* Which face of the panel the body is showing. * 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 ready = declared.filter((c) => !c.deferred);
const asking = 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 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] return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking]
.filter((chip) => { .filter((chip) => {
const key = String(chip?.label ?? '').trim().toLowerCase(); const label = String(chip?.label ?? '').trim().toLowerCase();
if (!key || seen.has(key)) return false; if (!label) return false;
seen.add(key); const intent = intentOf(chip);
if (seen.has(intent) || seen.has(`label:${label}`)) return false;
seen.add(intent);
seen.add(`label:${label}`);
return true; return true;
}); });
/* `pageContext` decides which suggestions can answer without asking, so the /* `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> <p className="truncate text-caption leading-tight text-ink-3">{context.page}</p>
</div> </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"> <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 {/* History lives with the other window controls rather than in the
body, so the layout of the panel is unchanged whether or not body, so the layout of the panel is unchanged whether or not
there is anything to show. It toggles: pressing it again returns there is anything to show. It toggles: pressing it again returns
@@ -729,8 +747,6 @@ export default function KrowAssistant({
)} )}
</div> </div>
<AddSkillDialog open={addSkillOpen} onOpenChange={setAddSkillOpen} />
{/* ── Composer: fixed to the bottom in both states ───────────────── */} {/* ── 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"> <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 {/* 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 * `max` caps how many are offered in a conversation, where vertical space belongs
* to the thread rather than to suggestions. * 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. * 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 chipRefs = React.useRef([]);
const visible = max ? prompts.slice(0, max) : prompts; 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)} onKeyDown={(e) => onKeyDown(e, i)}
title={prompt.prompt} title={prompt.prompt}
className={cn( className={cn(
'rounded-full border border-border bg-surface px-2.5 py-1', /* 14px is the radius `rounded-full` already produces on a one-line
'text-caption font-medium text-ink-2 shadow-xs', 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', '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', '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', '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' '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> </button>
))} ))}
</div> </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 * 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. * they do it without a timer or a rerender every four seconds.
*/ */
/** @param {any} props */
export function PromptInput({ export function PromptInput({
value, value,
onChange, onChange,
@@ -26,7 +27,7 @@ export function PromptInput({
placeholders = ['Ask anything…'], placeholders = ['Ask anything…'],
autoFocus = false, autoFocus = false,
align = 'left', align = 'left',
className, className = '',
}) { }) {
const textareaRef = React.useRef(null); const textareaRef = React.useRef(null);
const [focused, setFocused] = React.useState(false); const [focused, setFocused] = React.useState(false);

View File

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

View File

@@ -459,7 +459,7 @@ const PROMPTS = {
prompt: `Continue the ${drafts[0].title} draft`, 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?', prompt: 'Which positions are still in draft?',
capability: 'position-drafts', capability: 'position-drafts',
}); });
@@ -496,8 +496,9 @@ const PROMPTS = {
if (withStrong.length) { if (withStrong.length) {
const best = withStrong.sort((a, b) => b.status.strong.length - a.status.strong.length)[0]; const best = withStrong.sort((a, b) => b.status.strong.length - a.status.strong.length)[0];
const n = best.status.strong.length;
out.push({ 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}?`, 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?' }); out.push({ label: 'Which position needs people first?', prompt: 'Which position needs people first?' });
} }
if (newToday) { 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) { 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". */ ones — they are still the right answer to "how is hiring going". */
out.push( out.push(
f.starvedPositions.length 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: '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' }, { label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' },
f.unscreened.length 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: '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; return out;

View File

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

View File

@@ -60,7 +60,7 @@ export function buildFacts({
/* Time-to-hire — the created→updated span ImpactMetrics uses. */ /* Time-to-hire — the created→updated span ImpactMetrics uses. */
const timeToHire = hired.length const timeToHire = hired.length
? Math.round(avg(hired.map((a) => ? 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; : 0;
/* Open roles attracting nobody. */ /* 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 [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
const [activeDept, setActiveDept] = useState(null); 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 * `stages` and `transitions` come from the page's own fact sheet — this
* component derives no figures of its own. * 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; if (!stages.length) return null;
const total = stages[0]?.count || 1; 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. * `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; const enough = points.length >= 2;
return ( return (

View File

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

View File

@@ -39,6 +39,7 @@ const ICON_COLORS = {
* instead: an Alert that appears and disappears is easy to miss, and a toast * instead: an Alert that appears and disappears is easy to miss, and a toast
* that persists is noise. * that persists is noise.
*/ */
/** @param {any} props */
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) { export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) {
const Icon = icon || ICONS[tone]; 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 * first-class state rather than a fallback afterthought. A failed image load
* degrades to initials instead of a broken-image icon. * degrades to initials instead of a broken-image icon.
*/ */
/** @type {React.ForwardRefExoticComponent<any>} */
export const Avatar = React.forwardRef( export const Avatar = React.forwardRef(
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => { ({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
const [failed, setFailed] = React.useState(false); const [failed, setFailed] = React.useState(false);
@@ -89,6 +90,7 @@ Avatar.displayName = 'Avatar';
* AvatarGroup — overlapping avatars with a "+N" remainder, for showing several * AvatarGroup — overlapping avatars with a "+N" remainder, for showing several
* people in a tight row (applicants on a position, endorsers on a skill). * 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 }) { export function AvatarGroup({ people = [], max = 4, size = 'sm', className }) {
const visible = people.slice(0, max); const visible = people.slice(0, max);
const overflow = people.length - visible.length; 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 * A tooltip matching the design system's surfaces. Recharts' default is a plain
* white box that ignores the token set. * white box that ignores the token set.
*/ */
/** @param {any} props */
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) { export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) {
if (!active || !payload?.length) return null; 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 * 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. * children, so swapping a bar chart for a line chart changes nothing else.
*/ */
/** @param {any} props */
export function ChartContainer({ export function ChartContainer({
title, title,
subtitle, 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 * `hideBelow: 'md'` drops a column on small screens, which is what keeps the
* table usable on mobile without a horizontal scroll for every view. * table usable on mobile without a horizontal scroll for every view.
*/ */
/** @param {any} props */
export function DataTable({ export function DataTable({
columns = [], columns = [],
rows = [], 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 * a detail view can be a Drawer on desktop and a bottom sheet on mobile without
* its contents changing. * its contents changing.
*/ */
/** @param {any} props */
export function Drawer({ export function Drawer({
open, open,
onOpenChange, 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 * Pages that conflate them end up telling a user with 22 candidates that they
* have no candidates. * have no candidates.
*/ */
/** @param {any} props */
export function EmptyState({ export function EmptyState({
icon: Icon, icon: Icon,
title, title,

View File

@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
* spacing, required markers and error styling are identical across the app. * spacing, required markers and error styling are identical across the app.
* It wires `id`, `aria-describedby` and `aria-invalid` onto its child. * It wires `id`, `aria-describedby` and `aria-invalid` onto its child.
*/ */
/** @param {any} props */
export function Field({ export function Field({
label, label,
hint, 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, * On mobile the filters collapse behind a "Filters" toggle with an active count,
* because four side-by-side selects are unusable at 375px. * because four side-by-side selects are unusable at 375px.
*/ */
/** @param {any} props */
export function FilterBar({ export function FilterBar({
search, search = null,
onSearchChange, onSearchChange = null,
searchPlaceholder = 'Search...', searchPlaceholder = 'Search...',
filters = [], filters = [],
/** `{ [key]: string | string[] }` */ /** `{ [key]: string | string[] }` */
values = {}, values = {},
onChange, onChange = null,
/** Right-aligned actions — primary buttons, view switchers. */ /** Right-aligned actions — primary buttons, view switchers. */
actions, actions = null,
/** Rendered under the bar, e.g. a result count. */ /** Rendered under the bar, e.g. a result count. */
meta, meta = null,
/** Value that means "no filter" for select-type filters. */ /** Value that means "no filter" for select-type filters. */
allValue = 'all', allValue = 'all',
className, className = '',
}) { }) {
const [expanded, setExpanded] = React.useState(false); 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 * `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. * makes it impossible to omit. The label doubles as the tooltip.
*/ */
/** @type {React.ForwardRefExoticComponent<any>} */
export const IconButton = React.forwardRef( export const IconButton = React.forwardRef(
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => { ({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
const sizeMap = { const sizeMap = {

View File

@@ -45,18 +45,19 @@ const SEVERITY = {
}, },
}; };
/** @param {any} props */
export function InsightRow({ export function InsightRow({
severity = 'info', severity = 'info',
title, title,
detail, detail,
/** Short right-aligned figure — a count, an age, a percentage. */ /** Short right-aligned figure — a count, an age, a percentage. */
metric, metric = null,
metricLabel, metricLabel = null,
/** Entity the item concerns, e.g. a position or candidate name. */ /** Entity the item concerns, e.g. a position or candidate name. */
entity, entity = null,
onClick, onClick = null,
actionLabel = 'Review', actionLabel = 'Review',
className, className = '',
}) { }) {
const s = SEVERITY[severity] || SEVERITY.info; const s = SEVERITY[severity] || SEVERITY.info;
const Comp = onClick ? 'button' : 'div'; const Comp = onClick ? 'button' : 'div';
@@ -105,6 +106,7 @@ export function InsightRow({
} }
/** The list container — dividers between rows, nothing else. */ /** 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>; 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 * (a target, a breakdown, a sparkline) use `MetricCard`; keeping the two
* separate stops KPI rows from drifting into inconsistent heights. * separate stops KPI rows from drifting into inconsistent heights.
*/ */
/** @param {any} props */
export function KpiCard({ export function KpiCard({
icon: Icon, icon: Icon,
value, value,
@@ -94,6 +95,7 @@ const formatDelta = (delta, suffix = '%') =>
* `invertTrend` exists because "up" is not always good — time-to-hire rising is * `invertTrend` exists because "up" is not always good — time-to-hire rising is
* bad, and colouring it green would be actively misleading. * bad, and colouring it green would be actively misleading.
*/ */
/** @param {any} props */
export function MetricCard({ export function MetricCard({
label, label,
value, 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 * `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). * 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 }) { export function Grid({ cols = 3, gap = 'default', className, children, ...props }) {
const columns = { const columns = {
1: 'grid-cols-1', 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. */ /** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */
/** @param {any} props */
export function Stack({ space = 'default', className, children, ...props }) { export function Stack({ space = 'default', className, children, ...props }) {
const spacing = { const spacing = {
xs: 'space-y-2', 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 * PageSection — a titled region of a page. Pairs a SectionHeader-style label
* with its content at consistent spacing. * with its content at consistent spacing.
*/ */
/** @param {any} props */
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) { export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) {
return ( return (
<section className={cn('space-y-3', className)}> <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. * 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' }; 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 ( return (
<Loader2 <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 * Prefer `Skeleton*` when the shape *is* known: a skeleton that matches the
* final layout prevents the content jump a spinner always causes. * 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 ( return (
<div <div
className={cn( 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, * InlineLoading — a spinner and label on one line, for inside buttons rows,
* card headers, or beside a control that is refreshing. * 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 ( return (
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}> <span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
<Spinner size="sm" /> <Spinner size="sm" />
@@ -55,7 +58,8 @@ export function InlineLoading({ message = 'Loading…', className }) {
* LoadingOverlay — covers content that is being replaced, keeping layout * LoadingOverlay — covers content that is being replaced, keeping layout
* height stable so the page does not jump. Used by DataTable when refetching. * 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; if (!show) return null;
return ( return (
<div <div

View File

@@ -57,6 +57,7 @@ function Delta({ delta, label, invert }) {
); );
} }
/** @param {any} props */
export function MetricStrip({ items = [], columns, className, loading = false }) { export function MetricStrip({ items = [], columns, className, loading = false }) {
const count = columns ?? Math.min(items.length, 6); 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; * body scroll pushes the title and the confirm button off-screen on a laptop;
* this keeps both pinned and scrolls only the middle. * this keeps both pinned and scrolls only the middle.
*/ */
/** @param {any} props */
/** @param {any} props */
export function Modal({ export function Modal({
open, open,
onOpenChange, onOpenChange,
@@ -82,6 +84,7 @@ export function Modal({
* always for, and it labels the action with the verb ("Delete") rather than * always for, and it labels the action with the verb ("Delete") rather than
* "OK", so the button says what will happen. * "OK", so the button says what will happen.
*/ */
/** @param {any} props */
export function ConfirmModal({ export function ConfirmModal({
open, open,
onOpenChange, onOpenChange,
@@ -91,7 +94,7 @@ export function ConfirmModal({
cancelLabel = 'Cancel', cancelLabel = 'Cancel',
onConfirm, onConfirm,
tone = 'destructive', tone = 'destructive',
icon, icon = null,
busy = false, busy = false,
}) { }) {
return ( 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 * options a filter field appears, so short lists stay uncluttered and long ones
* stay usable. Options may be strings or `{ value, label, count }`. * stay usable. Options may be strings or `{ value, label, count }`.
*/ */
/** @param {any} props */
export function MultiSelect({ export function MultiSelect({
options = [], options = [],
value = [], value = [],
@@ -20,8 +21,8 @@ export function MultiSelect({
/** Chips shown before collapsing into "+N more". */ /** Chips shown before collapsing into "+N more". */
maxChips = 2, maxChips = 2,
searchThreshold = 8, searchThreshold = 8,
disabled, disabled = false,
className, className = '',
align = 'start', align = 'start',
emptyMessage = 'No options', 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 * right-aligned action slot. Owning the entrance animation here is what keeps
* page transitions feeling like one product. * page transitions feeling like one product.
*/ */
/** @param {any} props */
export function PageHeader({ export function PageHeader({
title, title,
subtitle, 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 * 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. * 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) { export function buildPageRange(current, total, siblings = 1) {
const totalSlots = siblings * 2 + 5; const totalSlots = siblings * 2 + 5;
if (total <= totalSlots) return Array.from({ length: total }, (_, i) => i + 1); 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 * Pagination — page navigation with an optional result summary and page-size
* control. Renders nothing when there is only one page. * control. Renders nothing when there is only one page.
*/ */
/** @param {any} props */
export function Pagination({ export function Pagination({
page = 1, page = 1,
pageCount = 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 * `tone="score"` picks the colour from the value, which is what most usages in
* this app want. * this app want.
*/ */
/** @param {any} props */
export function ProgressBar({ export function ProgressBar({
value, value,
max = 100, max = 100,
@@ -94,6 +95,7 @@ export function ProgressBar({
* score, via `min`/`max`, so the KROW ID gauge and a completion ring are the * score, via `min`/`max`, so the KROW ID gauge and a completion ring are the
* same component. * same component.
*/ */
/** @param {any} props */
export function ProgressRing({ export function ProgressRing({
value, value,
min = 0, min = 0,
@@ -175,7 +177,8 @@ export function ProgressRing({
* Completed steps show a check rather than their number, so "done" is readable * Completed steps show a check rather than their number, so "done" is readable
* at a glance without relying on colour. * 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)); const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
return ( return (
@@ -239,6 +242,7 @@ export function ProgressSteps({ steps = [], current = 0, className, onStepClick
* ScoreMeter — a labelled bar row used in score breakdowns, where many * ScoreMeter — a labelled bar row used in score breakdowns, where many
* dimensions are listed with a weight and a value. * dimensions are listed with a weight and a value.
*/ */
/** @param {any} props */
export function ScoreMeter({ label, value, weight, tone = 'score', className }) { export function ScoreMeter({ label, value, weight, tone = 'score', className }) {
return ( return (
<div className={cn('space-y-1.5', className)}> <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 * without re-running expensive work, while the field itself stays instantly
* responsive. Pass `debounce={0}` for immediate updates. * responsive. Pass `debounce={0}` for immediate updates.
*/ */
/** @type {React.ForwardRefExoticComponent<any>} */
export const SearchInput = React.forwardRef( export const SearchInput = React.forwardRef(
( (
{ {

View File

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

View File

@@ -9,7 +9,8 @@ import { cn } from '@/lib/utils';
* progress. The shimmer is disabled automatically under * progress. The shimmer is disabled automatically under
* `prefers-reduced-motion` (see index.css). * `prefers-reduced-motion` (see index.css).
*/ */
export function Skeleton({ className, radius = 'md', ...props }) { /** @param {any} props */
export function Skeleton({ className = '', radius = 'md', ...props }) {
return ( return (
<div <div
aria-hidden="true" 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. */ /** 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 ( return (
<div className={cn('space-y-2', className)} role="status" aria-label="Loading content"> <div className={cn('space-y-2', className)} role="status" aria-label="Loading content">
{Array.from({ length: lines }).map((_, i) => ( {Array.from({ length: lines }).map((_, i) => (
@@ -45,7 +47,8 @@ export function SkeletonText({ lines = 3, className }) {
} }
/** Matches KpiCard: icon tile, big number, caption. */ /** Matches KpiCard: icon tile, big number, caption. */
export function SkeletonKpi({ className }) { /** @param {any} props */
export function SkeletonKpi({ className = '' }) {
return ( return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-4 shadow-sm', className)}> <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" /> <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. */ /** Matches a list row: avatar, two lines of text, trailing value. */
export function SkeletonRow({ className }) { /** @param {any} props */
export function SkeletonRow({ className = '' }) {
return ( return (
<div className={cn('flex items-center gap-3 py-3', className)}> <div className={cn('flex items-center gap-3 py-3', className)}>
<Skeleton className="w-9 h-9" radius="full" /> <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. */ /** Matches a card in a grid: title, meta, stat row. */
export function SkeletonCard({ className }) { /** @param {any} props */
export function SkeletonCard({ className = '' }) {
return ( return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-5 shadow-sm', className)}> <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"> <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. */ /** 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 ( return (
<div className={cn('overflow-hidden', className)} role="status" aria-label="Loading table"> <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"> <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. */ /** Matches ChartContainer: title, legend, plot area. */
export function SkeletonChart({ className, height = 240 }) { /** @param {any} props */
export function SkeletonChart({ className = '', height = 240 }) {
return ( return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-6 shadow-sm', className)}> <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" /> <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. */ /** 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 ( return (
<div className={cn('divide-y divide-border', className)} role="status" aria-label="Loading list"> <div className={cn('divide-y divide-border', className)} role="status" aria-label="Loading list">
{Array.from({ length: rows }).map((_, i) => ( {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 * `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. * 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 }) { export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }) {
const entry = STATUS_MAP[status] || {}; const entry = STATUS_MAP[status] || {};
const resolvedTone = tone || entry.tone || 'neutral'; const resolvedTone = tone || entry.tone || 'neutral';

View File

@@ -55,6 +55,7 @@ const surfaceVariants = cva(
} }
); );
/** @type {React.ForwardRefExoticComponent<any>} */
const Surface = React.forwardRef( const Surface = React.forwardRef(
({ className, variant, elevation, radius, padding, interactive, as, ...props }, ref) => { ({ className, variant, elevation, radius, padding, interactive, as, ...props }, ref) => {
const Comp = as || 'div'; 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 * unique when two tab sets are on screen at once, otherwise the indicator
* animates between them. * animates between them.
*/ */
/** @param {any} props */
export function Tabs({ export function Tabs({
tabs = [], tabs = [],
value, 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 * 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. * 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 ( return (
<ol className={cn('relative', className)}> <ol className={cn('relative', className)}>
{items.map((item, i) => { {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 * always used with, and makes the whole row a label so the hit target is the
* full width rather than the 36px control. * full width rather than the 36px control.
*/ */
/** @param {any} props */
export function Toggle({ export function Toggle({
checked, checked,
onCheckedChange, onCheckedChange,
@@ -97,8 +98,15 @@ const SEGMENT_VARIANTS = {
}, },
}; };
/** @param {any} props */
export function SegmentedToggle({ 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 normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
const tone = SEGMENT_VARIANTS[variant] || SEGMENT_VARIANTS.brand; 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 DURATIONS = { success: 2600, error: 4200, info: 3000, loading: Infinity };
const withDefaults = (options = {}) => ({ const withDefaults = (options = {}) => ({
position: 'bottom-center', position: /** @type {import('react-hot-toast').ToastPosition} */ ('bottom-center'),
...options, ...options,
style: { ...BASE_STYLE, ...(options.style || {}) }, 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. */ /** 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 setAt = (i, v) => onChange(values.map((x, j) => (j === i ? v : x)));
const removeAt = (i) => onChange(values.length === 1 ? [''] : values.filter((_, j) => j !== i)); 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 * where a worker actually is one; putting it here made the console read as
* somebody's personal training dashboard. * somebody's personal training dashboard.
*/ */
export function ForgeHeader({ onCreate }) { /** @param {any} props */
export function ForgeHeader({ onCreate = null, _onBrowse = null }) {
return ( return (
<header className="flex flex-col gap-4 border-b border-border/60 pb-6 sm:flex-row sm:items-end sm:justify-between"> <header className="flex flex-col gap-4 border-b border-border/60 pb-6 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0 space-y-1.5"> <div className="min-w-0 space-y-1.5">

View File

@@ -19,7 +19,8 @@ const TIPS = [
'Keep training concise.', 'Keep training concise.',
]; ];
export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver }) { /** @param {any} props */
export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver: _onAskOwliver }) {
return ( return (
<aside <aside
className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3 items-stretch" 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. */ /** A titled region of the record. */
/** @param {any} props */
function Block({ title, meta, icon: Icon, children }) { function Block({ title, meta, icon: Icon, children }) {
return ( return (
<section className="border-t border-border pt-4 first:border-0 first:pt-0"> <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 * 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. * 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 ( return (
<ol className={cn('flex items-start', className)} aria-label={`${state.name} levels`}> <ol className={cn('flex items-start', className)} aria-label={`${state.name} levels`}>
{state.levels.map((rung, i) => { {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. */ /** One skill: where this person stands on it, and the next thing to do. */
/** @param {any} props */
export function SkillProgressCard({ state, onOpen }) { export function SkillProgressCard({ state, onOpen }) {
const status = STATUS_META[state.status] || STATUS_META.not_started; const status = STATUS_META[state.status] || STATUS_META.not_started;
const atTop = !state.nextLevel; 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 * hidden: the evidence behind a level is the reason to trust the level, and
* folding it away would leave the label standing on nothing. * folding it away would leave the label standing on nothing.
*/ */
/** @param {any} props */
export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining }) { export function SkillProgressDetail({ state, open, onOpenChange, onStartTraining }) {
if (!state) return null; if (!state) return null;

View File

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

View File

@@ -20,7 +20,7 @@ const STATUS_STYLES = {
rejected: { badge: 'bg-[#FEE2E2] text-[#991B1B]', label: 'Rejected', card: 'bg-white border-[#E5E7EB]' }, 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() || '??'; 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 }) { export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }) {
const navigate = useNavigate(); const navigate = useNavigate();
const position = jobTitle || application.job_title || ''; const position = jobTitle || application.job_title || '';
@@ -116,7 +117,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
try { try {
await base44.entities.JobApplication.update(application.id, { status: 'rejected' }); await base44.entities.JobApplication.update(application.id, { status: 'rejected' });
toast.success(`${application.applicant_name} declined`); toast.success(`${application.applicant_name} declined`);
} catch (err) { } catch (_err) {
toast.error('Failed to decline candidate'); toast.error('Failed to decline candidate');
} }
} }
@@ -130,7 +131,7 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
try { try {
await base44.entities.JobApplication.delete(application.id); await base44.entities.JobApplication.delete(application.id);
toast.success(`${application.applicant_name} deleted`); toast.success(`${application.applicant_name} deleted`);
} catch (err) { } catch (_err) {
toast.error('Failed to delete candidate'); toast.error('Failed to delete candidate');
} }
} }

View File

@@ -21,7 +21,8 @@ const METRIC_CONFIG = [
{ key: 'employer_requirements', label: 'Req Match', Icon: Check }, { key: 'employer_requirements', label: 'Req Match', Icon: Check },
]; ];
function MetricPill({ label, value, Icon }) { /** @param {any} props */
function _MetricPill({ label, value, Icon }) {
return ( return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB]"> <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} /> <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 { Link } from 'react-router-dom';
import { ArrowRight } from 'lucide-react'; 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 = ( const body = (
<div className="text-center sm:text-left"> <div className="text-center sm:text-left">
<div className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8]">{label}</div> <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 * `MatchedCandidates` rather than inside it: two audiences, two components, one
* scoring function that cannot drift between them. * 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); const match = matchPosition(job, held);
if (!match) return null; if (!match) return null;
const tone = TONE[match.tone] || TONE.warning; 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 }) { export default function TalentDetailModal({ open, onClose, profile, onContact, onSchedule, onHire, matchScore, matchReasons }) {
if (!profile) return null; if (!profile) return null;
const badges = profile.earned_badges || []; const badges = profile.earned_badges || [];

View File

@@ -92,9 +92,17 @@ function SkillPath({ state }) {
); );
} }
/** @param {any} props */
export function VerifiedSkills({ export function VerifiedSkills({
pageId, profile, profiles = [], courses = [], positions = [], email, customSources = [], pageId = '',
onOpenTraining, className, 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 /* 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. */ application, not a worker profile — can be matched to the person behind it. */
@@ -126,7 +134,7 @@ export function VerifiedSkills({
if (!worker) return []; if (!worker) return [];
const byId = new Map(courses.map((c) => [c.id, c])); const byId = new Map(courses.map((c) => [c.id, c]));
return [...(worker.completed_courses || [])] 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) .slice(0, 3)
.map((entry) => { .map((entry) => {
const course = byId.get(entry.course_id); 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 * hierarchy this card is meant to establish. Values share one ink colour so no
* single signal shouts louder than the rest. * 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 : '—'; const shown = value != null && value !== '' ? value : '—';
return ( return (
<div className="flex items-baseline justify-between gap-3"> <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]'; const valColor = tone === 'verified' ? 'text-[#10B981]' : tone === 'amber' ? 'text-[#D97706]' : 'text-[#0F172A]';
return ( return (
<div className="flex items-center justify-between gap-3 py-2 border-b border-white/60 last:border-0"> <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 score = getTalentScore(profile);
const band = getScoreBand(score); const band = getScoreBand(score);
const growth = getGrowth(profile); const _growth = getGrowth(profile);
const esat = getESAT(profile); const esat = getESAT(profile);
const fico = toFICO(score); const fico = toFICO(score);
const shortId = (profile.id || '').slice(-6).toUpperCase(); const shortId = (profile.id || '').slice(-6).toUpperCase();

View File

@@ -29,7 +29,8 @@ function marketRange(profile = {}) {
return `$${low}-$${low + 4}/hr`; return `$${low}-$${low + 4}/hr`;
} }
function Field({ label, value, sub, accent }) { /** @param {any} props */
function Field({ label, value, sub = '', accent = false }) {
return ( return (
<div> <div>
<p className="text-[11px] uppercase tracking-wide text-[#6B7280]">{label}</p> <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 score = getTalentScore(profile);
const factors = getFactors(profile); const factors = getFactors(profile);
const first = (profile.full_name || 'there').split(' ')[0]; 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 sections = useSkillSections(page, placement);
const resolved = useSkillDataContext(context); 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) => ( const Badge = React.forwardRef(({ className, variant, size, shape, ...props }, ref) => (
<div ref={ref} className={cn(badgeVariants({ variant, size, shape }), className)} {...props} /> <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" import { cn } from "@/lib/utils"
/** @type {React.ForwardRefExoticComponent<any>} */
const Breadcrumb = React.forwardRef( const Breadcrumb = React.forwardRef(
({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} /> ({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />
) )
Breadcrumb.displayName = "Breadcrumb" Breadcrumb.displayName = "Breadcrumb"
/** @type {React.ForwardRefExoticComponent<any>} */
const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => ( const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (
<ol <ol
ref={ref} ref={ref}
@@ -20,6 +22,7 @@ const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (
)) ))
BreadcrumbList.displayName = "BreadcrumbList" BreadcrumbList.displayName = "BreadcrumbList"
/** @type {React.ForwardRefExoticComponent<any>} */
const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => ( const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (
<li <li
ref={ref} ref={ref}
@@ -28,6 +31,7 @@ const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (
)) ))
BreadcrumbItem.displayName = "BreadcrumbItem" BreadcrumbItem.displayName = "BreadcrumbItem"
/** @type {React.ForwardRefExoticComponent<any>} */
const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref) => { const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a" const Comp = asChild ? Slot : "a"
@@ -40,6 +44,7 @@ const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref)
}) })
BreadcrumbLink.displayName = "BreadcrumbLink" BreadcrumbLink.displayName = "BreadcrumbLink"
/** @type {React.ForwardRefExoticComponent<any>} */
const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => ( const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (
<span <span
ref={ref} ref={ref}
@@ -51,6 +56,7 @@ const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (
)) ))
BreadcrumbPage.displayName = "BreadcrumbPage" BreadcrumbPage.displayName = "BreadcrumbPage"
/** @param {any} props */
const BreadcrumbSeparator = ({ const BreadcrumbSeparator = ({
children, children,
className, className,
@@ -66,6 +72,7 @@ const BreadcrumbSeparator = ({
) )
BreadcrumbSeparator.displayName = "BreadcrumbSeparator" BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
/** @param {any} props */
const BreadcrumbEllipsis = ({ const BreadcrumbEllipsis = ({
className, className,
...props ...props

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => ( const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
@@ -32,6 +33,7 @@ const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, .
DropdownMenuSubTrigger.displayName = DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName DropdownMenuPrimitive.SubTrigger.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => ( const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
@@ -44,6 +46,7 @@ const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) =
DropdownMenuSubContent.displayName = DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName DropdownMenuPrimitive.SubContent.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => ( const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
@@ -59,6 +62,7 @@ const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...pr
)) ))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => ( const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
@@ -71,6 +75,7 @@ const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref)
)) ))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => ( const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
@@ -91,9 +96,11 @@ const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checke
DropdownMenuCheckboxItem.displayName = DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.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 <DropdownMenuPrimitive.RadioItem
ref={ref} ref={ref}
value={value}
className={cn( 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", "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 className
@@ -109,6 +116,7 @@ const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props
)) ))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => ( const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
ref={ref} ref={ref}
@@ -117,6 +125,7 @@ const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref)
)) ))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => ( const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
ref={ref} ref={ref}

View File

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

View File

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

View File

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

View File

@@ -4,15 +4,18 @@ import { Circle } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
/** @type {React.ForwardRefExoticComponent<any>} */
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => { const RadioGroup = React.forwardRef(({ className, ...props }, ref) => {
return (<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />); return (<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />);
}) })
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName 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 ( return (
(<RadioGroupPrimitive.Item (<RadioGroupPrimitive.Item
ref={ref} ref={ref}
value={value}
className={cn( 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", "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 className

View File

@@ -12,6 +12,7 @@ const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value const SelectValue = SelectPrimitive.Value
/** @type {React.ForwardRefExoticComponent<any>} */
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => ( const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
@@ -28,6 +29,7 @@ const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref)
)) ))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => ( const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
ref={ref} ref={ref}
@@ -38,6 +40,7 @@ const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) =>
)) ))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => ( const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
ref={ref} ref={ref}
@@ -49,6 +52,7 @@ const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) =
SelectScrollDownButton.displayName = SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName SelectPrimitive.ScrollDownButton.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => ( const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
@@ -73,6 +77,7 @@ const SelectContent = React.forwardRef(({ className, children, position = "poppe
)) ))
SelectContent.displayName = SelectPrimitive.Content.displayName SelectContent.displayName = SelectPrimitive.Content.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => ( const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Label <SelectPrimitive.Label
ref={ref} ref={ref}
@@ -81,9 +86,17 @@ const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
)) ))
SelectLabel.displayName = SelectPrimitive.Label.displayName 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 <SelectPrimitive.Item
ref={ref} ref={ref}
value={value}
className={cn( 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", "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 className
@@ -99,6 +112,7 @@ const SelectItem = React.forwardRef(({ className, children, ...props }, ref) =>
)) ))
SelectItem.displayName = SelectPrimitive.Item.displayName SelectItem.displayName = SelectPrimitive.Item.displayName
/** @type {React.ForwardRefExoticComponent<any>} */
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => ( const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Separator <SelectPrimitive.Separator
ref={ref} ref={ref}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,8 +11,9 @@ import { base44 } from '@/api/base44Client';
* consumer stay unchanged. * consumer stay unchanged.
*/ */
const AuthContext = createContext(); const AuthContext = createContext(/** @type {any} */ (null));
/** @param {any} props */
export const AuthProvider = ({ children }) => { export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false); 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 app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id); const posting = postings.find((p) => p.id === s.job_posting_id);
const days = app 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; : null;
return { return {

View File

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

View File

@@ -397,7 +397,7 @@ const RESOLVERS = {
/** Everyone hired, most recent first — the record rather than the analysis. */ /** Everyone hired, most recent first — the record rather than the analysis. */
'hires.recent': ({ staff = [], applications = [], positions = [] }, section) => { 'hires.recent': ({ staff = [], applications = [], positions = [] }, section) => {
const items = [...staff] 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) .slice(0, section.limit || 10)
.map((s) => { .map((s) => {
const app = applications.find((a) => a.id === s.application_id); 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 hired = applications.filter((a) => a.status === 'hired');
const scores = applications.map((a) => a.ai_score).filter((n) => n > 0); const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
const days = hired 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); .filter((n) => Number.isFinite(n) && n >= 0);
const mean = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 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. */ /** The workspace audit trail, most recent first. */
'activity.events': ({ activity = [] }, section) => { 'activity.events': ({ activity = [] }, section) => {
const items = [...activity] 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) .slice(0, section.limit || 10)
.map((event) => ({ .map((event) => ({
id: event.id, 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) { function sectionBullets(body, heading) {
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body); const source = sectionSource(body, heading);
if (!section) return []; if (source == null) return [];
return section[1]
.split(/\r?\n/) const items = [];
.map((l) => /^\s*-\s+(.*)$/.exec(l)?.[1]?.trim()) for (const line of source.split(/\r?\n/)) {
.filter(Boolean); 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. * rather than a list.
*/ */
function sectionText(body, heading) { function sectionText(body, heading) {
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body); const source = sectionSource(body, heading);
if (!section) return ''; if (source == null) return '';
return section[1] return source
.split(/\r?\n/) .split(/\r?\n/)
.map((l) => l.replace(/^\s*[-*]\s+/, '').trim()) .map((l) => l.replace(/^\s*(?:[-*]|\d+[.)])\s+/, '').trim())
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
.trim(); .trim();
@@ -323,6 +377,139 @@ export const SKILLS = Object.entries(FILES)
.map(([path, raw]) => parseSkill(raw, { path })) .map(([path, raw]) => parseSkill(raw, { path }))
.sort((a, b) => a.name.localeCompare(b.name)); .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. * 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 * 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, * 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. * 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 = []) { export function allSkills(customSources = []) {
const custom = customSources return readSkillRegistry(customSources).skills;
.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);
const byId = new Map(SKILLS.map((s) => [s.id, s])); /** Everything that went wrong assembling the registry, for the Skills page. */
custom.forEach((s) => byId.set(s.id, s)); export function skillDiagnostics(customSources = []) {
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)); return readSkillRegistry(customSources).diagnostics;
} }
/** /**
@@ -472,11 +655,27 @@ function triggerMatches(trigger, question) {
*/ */
const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver'); 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(); 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)) (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) => export const skillsWithFacet = (skills = [], facet) =>
skills.filter((s) => s.facets?.includes(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. */ /** Days from now until the work starts. Negative or zero means it is live. */
export const daysUntilStart = (position, today = new Date()) => 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. */ /** "Immediately", "in 6 days", "in 3 weeks" — the phrasing answers use. */
export function startLabel(position, today = new Date()) { 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. */ partial fit for it, and anyone without is a full one. */
const free = profile?.available_until ? new Date(profile.available_until) : null; const free = profile?.available_until ? new Date(profile.available_until) : null;
if (!free) return { covers: true, ratio: 1, note: 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 { return {
covers: false, covers: false,
ratio: Math.min(1, months / 12), 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; const free = profile?.available_until ? new Date(profile.available_until) : null;
if (!free || free >= end) return { covers: true, ratio: 1, note: null }; if (!free || free >= end) return { covers: true, ratio: 1, note: null };
const needed = end - startsAt(position, today); const needed = end.getTime() - startsAt(position, today).getTime();
const have = Math.max(0, free - startsAt(position, today)); const have = Math.max(0, free.getTime() - startsAt(position, today).getTime());
const ratio = needed ? Math.max(0, Math.min(1, have / needed)) : 1; const ratio = needed ? Math.max(0, Math.min(1, have / needed)) : 1;
return { return {
@@ -545,7 +545,9 @@ export function prioritise(positions = [], context = {}) {
* function existing separately from the mutation is what makes "show me first" * function existing separately from the mutation is what makes "show me first"
* the only possible path rather than a convention someone has to remember. * 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 status = workforceStatusFor(position, context);
const take = Math.min( const take = Math.min(
count ?? status.demand.remaining, 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 }; 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -140,7 +140,7 @@ export default function AdminDashboard() {
<p className="text-[13px] text-[#9CA3AF]">No hired staff yet</p> <p className="text-[13px] text-[#9CA3AF]">No hired staff yet</p>
</div> </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 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"> <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()} {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. * 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 ( return (
<Surface variant="solid" radius="xl" padding="lg" className={className}> <Surface variant="solid" radius="xl" padding="lg" className={className}>
<SectionHeader title={title} subtitle={description} /> <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 ( return (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{label && <span className="text-caption text-ink-4 w-24 shrink-0">{label}</span>} {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; 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 ratedCount = staff.filter((s) => (s.client_rating || 0) > 0).length;
const avgRating = ratedCount > 0 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 ( return (
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[#F0F4F8] border border-[#E5E7EB]"> <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> <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]); }, [workerName, speak, language]);
const startListening = useCallback(() => { 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; } 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. // Yield Owliver's voice so the mic picks up the user, not Owliver.
if (window.speechSynthesis) window.speechSynthesis.cancel(); 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(); const has = value && String(value).trim();
return ( return (
<div className={cn('rounded-lg bg-[#F9FAFB] p-2.5', full && 'col-span-2')}> <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( const recent = useMemo(
() => events () => events
.slice() .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), .slice(0, TIMELINE_LIMIT),
[events] [events]
); );

View File

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

View File

@@ -21,7 +21,7 @@ const SCORE_BANDS = {
const SORTS = { const SORTS = {
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) }, 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) }, 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) }, 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; const WEAK_TRANSITION = 60;
function Band({ id, title, meta, children, action }) { /** @param {any} props */
function Band({ id, title, meta = '', children, action = null }) {
return ( return (
<section aria-labelledby={id} className="space-y-3"> <section aria-labelledby={id} className="space-y-3">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2"> <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; if (cutoff && new Date(h.hire_date || 0).getTime() < cutoff) return false;
return true; 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]); }, [hires, search, filters]);
const summary = useMemo(() => summarise(filtered), [filtered]); 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 [form, setForm] = useState({ email: DEMO_EMAIL, password: '', remember: true });
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState(/** @type {{ email?: string, password?: string }} */ ({}));
const [authError, setAuthError] = useState(''); const [authError, setAuthError] = useState('');
const [status, setStatus] = useState('idle'); const [status, setStatus] = useState('idle');

View File

@@ -5,7 +5,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
Avatar, Badge, Button, Drawer, EmptyState, ProgressBar, SearchInput, SegmentedToggle, Avatar, Badge, Button, Drawer, EmptyState, SearchInput, SegmentedToggle,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, StatusBadge, toast, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Skeleton, StatusBadge, toast,
} from '@/components/ds'; } from '@/components/ds';
import { import {
@@ -59,7 +59,7 @@ const SORTS = {
starved: { label: 'Fewest applicants', compare: (a, b) => a.stats.applied - b.stats.applied }, starved: { label: 'Fewest applicants', compare: (a, b) => a.stats.applied - b.stats.applied },
updated: { updated: {
label: 'Recently 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) }, 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. */ /** 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 ( return (
<span <span
className={cn( className={cn(
@@ -740,43 +741,48 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
</section> </section>
)} )}
{/* Hiring status */} {/* Hiring progression — the single source of truth for the pipeline.
<section> A separate "Hiring status" block used to sit above this one repeating
<SectionTitle title="Hiring status" /> Shortlisted, Interviews and Hired as figures, which is exactly what the
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3"> progression already draws. Two readings of the same funnel invite the
{[ reader to check whether they agree, so the duplicated stage counts are
{ label: 'Applicants', value: s.applied }, gone and the three figures the progression does *not* carry — supply,
{ label: 'Shortlisted', value: s.shortlisted }, quality and the screening average — lead it as one line. */}
{ 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>
<section> <section>
<SectionTitle title="Hiring progression" /> <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} /> <Progression steps={p.progression} />
</section> </section>
@@ -811,22 +817,45 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
))} ))}
</ul> </ul>
) : ( ) : (
<p className="text-body-sm text-ink-3"> /* Two short lines, not a paragraph. The reason nobody is ranked is
Nobody has been screened for this role yet, so there is no ranking to show. always the same — screening has not run — so it needs stating once,
</p> 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> </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> <section>
<SectionTitle title="Hiring timeline" /> <SectionTitle title="Hiring timeline" />
<ol className="space-y-2"> <ol className="space-y-0">
{timeline.map((e, i) => ( {timeline.map((e, i) => (
<li key={i} className="flex items-baseline justify-between gap-3 text-body-sm"> <li key={i} className="flex gap-2.5 pb-3 last:pb-0">
<span className="text-ink-2">{e.label}</span> <span className="flex flex-col items-center" aria-hidden="true">
<span className="shrink-0 text-caption text-ink-4"> <span
{e.date className={cn(
? new Date(e.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) '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> </span>
</li> </li>
))} ))}
@@ -841,7 +870,7 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
{p.applications.length ? ( {p.applications.length ? (
<ol className="space-y-2"> <ol className="space-y-2">
{[...p.applications] {[...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) .slice(0, 5)
.map((a) => ( .map((a) => (
<li key={a.id} className="flex items-baseline justify-between gap-3 text-body-sm"> <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. */ /** 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 ( return (
<Select value={value} onValueChange={onChange}> <Select value={value} onValueChange={onChange}>
<SelectTrigger <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. */ /** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
/** @type {[RegExp, React.ComponentType<any>][]} */
const GLYPHS = [ const GLYPHS = [
[/chef|cook|kitchen/i, ChefHat], [/chef|cook|kitchen/i, ChefHat],
[/bartend|bar|mixolog/i, Wine], [/bartend|bar|mixolog/i, Wine],
@@ -41,7 +42,8 @@ const SIZES = {
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' }, 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 Icon = iconForRole(category, title);
const s = SIZES[size] || SIZES.md; const s = SIZES[size] || SIZES.md;

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