From 7fa21a451350c155ffe68d93270773f70ec36aff Mon Sep 17 00:00:00 2001 From: Aravind Date: Mon, 17 Aug 2026 17:33:56 +0530 Subject: [PATCH] fix error line --- jsconfig.json | 2 +- scripts/skill-check.mjs | 412 ++++++++++++++++++ src/api/seed.js | 2 +- .../ai-assistant/AssistantMessage.jsx | 4 +- .../ai-assistant/AssistantTrigger.jsx | 3 +- src/components/ai-assistant/KrowAssistant.jsx | 52 ++- src/components/ai-assistant/PromptChips.jsx | 21 +- src/components/ai-assistant/PromptInput.jsx | 3 +- .../ai-assistant/ResponseBlocks.jsx | 30 +- src/components/ai-assistant/dynamic.js | 33 +- src/components/ai-assistant/history.js | 2 +- src/components/ai-assistant/insights.js | 2 +- .../charts/DepartmentPerformance.jsx | 3 +- src/components/charts/HiringFlow.jsx | 3 +- src/components/charts/HiringTrendChart.jsx | 3 +- src/components/ds/ActivityCard.jsx | 1 + src/components/ds/Alert.jsx | 1 + src/components/ds/Avatar.jsx | 2 + src/components/ds/ChartContainer.jsx | 2 + src/components/ds/DataTable.jsx | 1 + src/components/ds/Drawer.jsx | 1 + src/components/ds/EmptyState.jsx | 1 + src/components/ds/Field.jsx | 1 + src/components/ds/FilterBar.jsx | 13 +- src/components/ds/IconButton.jsx | 1 + src/components/ds/InsightRow.jsx | 14 +- src/components/ds/KpiCard.jsx | 2 + src/components/ds/Layout.jsx | 3 + src/components/ds/LoadingState.jsx | 12 +- src/components/ds/MetricStrip.jsx | 1 + src/components/ds/Modal.jsx | 5 +- src/components/ds/MultiSelect.jsx | 5 +- src/components/ds/PageHeader.jsx | 1 + src/components/ds/Pagination.jsx | 7 + src/components/ds/Progress.jsx | 6 +- src/components/ds/SearchInput.jsx | 1 + src/components/ds/SectionHeader.jsx | 1 + src/components/ds/Skeleton.jsx | 24 +- src/components/ds/StatusBadge.jsx | 1 + src/components/ds/Surface.jsx | 1 + src/components/ds/Tabs.jsx | 1 + src/components/ds/Timeline.jsx | 3 +- src/components/ds/Toggle.jsx | 10 +- src/components/ds/toast.js | 2 +- src/components/forge/AddSkillTraining.jsx | 3 +- src/components/forge/ForgeHeader.jsx | 3 +- src/components/forge/ForgeRail.jsx | 3 +- src/components/forge/SkillDetail.jsx | 1 + src/components/forge/SkillProgression.jsx | 5 +- src/components/krow/AIInterviewModal.jsx | 7 +- src/components/krow/CandidateCard.jsx | 7 +- .../krow/CandidateExpandedDetails.jsx | 3 +- src/components/krow/CareerTrajectory.jsx | 3 +- src/components/krow/MatchedCandidates.jsx | 3 +- src/components/krow/TalentDetailModal.jsx | 1 + src/components/krow/VerifiedSkills.jsx | 14 +- src/components/krow/WorkforceReputation.jsx | 3 +- .../krow/talent/IdentityCareerCard.jsx | 5 +- src/components/krow/talent/TalentHero.jsx | 6 +- src/components/skills/SkillSurface.jsx | 16 +- src/components/ui/badge.jsx | 1 + src/components/ui/breadcrumb.jsx | 7 + src/components/ui/button.jsx | 1 + src/components/ui/card.jsx | 6 + src/components/ui/checkbox.jsx | 1 + src/components/ui/dialog.jsx | 10 +- src/components/ui/dropdown-menu.jsx | 11 +- src/components/ui/hover-card.jsx | 1 + src/components/ui/image.jsx | 3 + src/components/ui/input.jsx | 1 + src/components/ui/label.jsx | 1 + src/components/ui/popover.jsx | 1 + src/components/ui/radio-group.jsx | 5 +- src/components/ui/select.jsx | 16 +- src/components/ui/sheet.jsx | 4 + src/components/ui/slider.jsx | 1 + src/components/ui/switch.jsx | 1 + src/components/ui/tabs.jsx | 3 + src/components/ui/textarea.jsx | 1 + src/components/ui/toast.jsx | 7 + src/components/ui/toaster.jsx | 3 +- src/components/ui/tooltip.jsx | 1 + src/lib/AuthContext.jsx | 3 +- src/lib/hiringRecords.js | 2 +- src/lib/krowHooks.js | 48 +- src/lib/skills/dataResolver.js | 6 +- src/lib/skills/registry.js | 276 ++++++++++-- src/lib/workforce.js | 12 +- src/pages/AdminDashboard.jsx | 4 +- src/pages/DesignSystem.jsx | 6 +- src/pages/HiredHistory.jsx | 5 +- src/pages/Owliver.jsx | 6 +- src/pages/admin/Activity.jsx | 2 +- src/pages/admin/CandidateProfile.jsx | 9 +- src/pages/admin/Candidates.jsx | 2 +- src/pages/admin/ControlCenter.jsx | 3 +- src/pages/admin/HiredHistory.jsx | 2 +- src/pages/admin/Login.jsx | 2 +- src/pages/admin/Positions.jsx | 130 +++--- src/pages/admin/RoleGlyph.jsx | 4 +- src/pages/admin/TalentPool.jsx | 2 +- src/pages/admin/Workspace.jsx | 176 ++++---- src/pages/admin/WorkspaceSkills.jsx | 38 +- src/pages/admin/_shell.jsx | 5 +- .../owliver/hiring-activity-assistant.md | 8 +- 105 files changed, 1296 insertions(+), 323 deletions(-) create mode 100644 scripts/skill-check.mjs diff --git a/jsconfig.json b/jsconfig.json index ccc6e7c..f5279e1 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -16,6 +16,6 @@ "resolveJsonModule": true, "types": [] }, - "include": ["src/components/**/*.js", "src/pages/**/*.jsx", "src/layouts/**/*.jsx"], + "include": ["src/**/*"], "exclude": ["node_modules", "dist", "src/components/ui", "src/api", "src/lib"] } diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs new file mode 100644 index 0000000..29f71cf --- /dev/null +++ b/scripts/skill-check.mjs @@ -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); +} diff --git a/src/api/seed.js b/src/api/seed.js index 07a41b3..d682ca5 100644 --- a/src/api/seed.js +++ b/src/api/seed.js @@ -1314,7 +1314,7 @@ const TRAINING_MODULES = MODULE_TABLE.map( type, prompt: PROMPT_FOR[type](title), ai_persona: type === 'roleplay' ? 'A guest or teammate in a live service situation.' : undefined, - rubric: criteria.map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })), + rubric: (Array.isArray(criteria) ? criteria : []).map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })), }, unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] }, quiz: [], diff --git a/src/components/ai-assistant/AssistantMessage.jsx b/src/components/ai-assistant/AssistantMessage.jsx index 404c0e2..b85a549 100644 --- a/src/components/ai-assistant/AssistantMessage.jsx +++ b/src/components/ai-assistant/AssistantMessage.jsx @@ -37,7 +37,9 @@ export function ThinkingIndicator() { * with tables and KPI tiles, and a chat bubble would waste a third of a 380px * column on padding around them. */ -export const Message = React.memo(({ role, text, blocks, streaming, stopped, onPrompt }) => { +export const Message = React.memo( + /** @param {any} props */ + ({ role, text, blocks, streaming, stopped, onPrompt }) => { if (role === 'user') { return (
diff --git a/src/components/ai-assistant/AssistantTrigger.jsx b/src/components/ai-assistant/AssistantTrigger.jsx index a01ac63..b1bf0b5 100644 --- a/src/components/ai-assistant/AssistantTrigger.jsx +++ b/src/components/ai-assistant/AssistantTrigger.jsx @@ -15,7 +15,8 @@ import { useAssistantPanel } from './AssistantPanelContext'; * It self-hides on pages with no assistant, so a page can render it * unconditionally and never needs to know the placement rules. */ -export function AssistantTrigger({ className, size = 'default' }) { +/** @param {any} props */ +export function AssistantTrigger({ className = '', size = 'default' }) { const { supported, isOpen, toggle, context } = useAssistantPanel(); if (!supported) return null; diff --git a/src/components/ai-assistant/KrowAssistant.jsx b/src/components/ai-assistant/KrowAssistant.jsx index 00d3054..1afb0fc 100644 --- a/src/components/ai-assistant/KrowAssistant.jsx +++ b/src/components/ai-assistant/KrowAssistant.jsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { useNavigate } from 'react-router-dom'; import { - ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles, Trash2, + ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Surface } from '@/components/ds/Surface'; @@ -13,7 +13,6 @@ import { usePreferences, useRoleCategories, } from '@/lib/krowHooks'; import { ROLE_CATEGORIES } from '@/lib/roleCategories'; -import { AddSkillDialog } from '@/components/skills/AddSkillDialog'; import { runAction } from '@/lib/skills/actions'; import { skillsForContext } from '@/lib/skills/registry'; import { owliverSuggestions } from '@/lib/skills/owliverResolver'; @@ -286,7 +285,6 @@ export default function KrowAssistant({ activity: facts.activity || [], trainingPaths, }), [pageContext, facts, assignments, trainingPaths]); - const [addSkillOpen, setAddSkillOpen] = React.useState(false); /** * Which face of the panel the body is showing. @@ -500,12 +498,33 @@ export default function KrowAssistant({ const ready = declared.filter((c) => !c.deferred); const asking = declared.filter((c) => c.deferred); + /** + * One chip per *intent*, not per wording. + * + * De-duplicating on the label alone let two chips through whenever the same + * answer was worded twice — a skill's "Show hiring activity" and its + * "Summarize hiring activity" both resolved to that skill's `summary` + * capability, so the reader was offered the same reading under two names and + * had no way to tell them apart. What a chip *resolves to* is the thing that + * must be unique: a skill capability, the page capability, or, for a chip + * that is neither, the question it sends. The label is compared too, so two + * differently-routed chips still cannot arrive reading identically. + */ const seen = new Set(); + const intentOf = (chip) => { + if (chip?.skillId && chip?.skillCapability) return `skill:${chip.skillId}:${chip.skillCapability}`; + if (chip?.capability) return `page:${chip.capability}`; + return `ask:${String(chip?.prompt ?? '').trim().toLowerCase()}`; + }; + return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking] .filter((chip) => { - const key = String(chip?.label ?? '').trim().toLowerCase(); - if (!key || seen.has(key)) return false; - seen.add(key); + const label = String(chip?.label ?? '').trim().toLowerCase(); + if (!label) return false; + const intent = intentOf(chip); + if (seen.has(intent) || seen.has(`label:${label}`)) return false; + seen.add(intent); + seen.add(`label:${label}`); return true; }); /* `pageContext` decides which suggestions can answer without asking, so the @@ -603,17 +622,16 @@ export default function KrowAssistant({

{context.page}

- {/* 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. */}
- {/* Opens the same dialog Settings does; there is one registry behind - both, so a skill added here is the skill added there. */} - setAddSkillOpen(true)} - /> {/* History lives with the other window controls rather than in the body, so the layout of the panel is unchanged whether or not there is anything to show. It toggles: pressing it again returns @@ -729,8 +747,6 @@ export default function KrowAssistant({ )}
- - {/* ── Composer: fixed to the bottom in both states ───────────────── */}
{/* All suggestions on the landing screen, where they teach what can be diff --git a/src/components/ai-assistant/PromptChips.jsx b/src/components/ai-assistant/PromptChips.jsx index b6368a1..d92fab9 100644 --- a/src/components/ai-assistant/PromptChips.jsx +++ b/src/components/ai-assistant/PromptChips.jsx @@ -15,9 +15,16 @@ import { cn } from '@/lib/utils'; * `max` caps how many are offered in a conversation, where vertical space belongs * to the thread rather than to suggestions. * + * A chip is shrink-to-fit up to the panel's width, so a long suggestion spends the + * whole row before it is allowed to take a second line. When it does take one, the + * radius stays at the single-line pill's 14px rather than following the height: + * `rounded-full` on a two-line chip resolves to half of 46px, and that corner is + * what turns the same component into something that reads as a card. + * * Arrow keys move between chips, so the whole set is one tab stop. */ -export function PromptChips({ prompts = [], onSelect, max, align = 'center', className }) { +/** @param {any} props */ +export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center', className = '' }) { const chipRefs = React.useRef([]); const visible = max ? prompts.slice(0, max) : prompts; @@ -54,15 +61,21 @@ export function PromptChips({ prompts = [], onSelect, max, align = 'center', cla onKeyDown={(e) => onKeyDown(e, i)} title={prompt.prompt} className={cn( - 'rounded-full border border-border bg-surface px-2.5 py-1', - 'text-caption font-medium text-ink-2 shadow-xs', + /* 14px is the radius `rounded-full` already produces on a one-line + chip, pinned so a wrapped chip keeps the same corner. */ + 'flex max-w-full items-center rounded-[14px] border border-border bg-surface px-2.5 py-1', + 'text-caption font-medium text-ink-2 shadow-xs text-left', 'transition-all duration-base ease-out', 'hover:-translate-y-px hover:border-krow-blue/40 hover:bg-krow-blue-tint hover:text-krow-blue hover:shadow-sm', 'active:translate-y-0 active:shadow-xs', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/60 focus-visible:ring-offset-1' )} > - {prompt.label} + {/* `pretty` rather than `balance`: the row should be spent before a + second line starts, and balancing would leave the pill wide with + two short lines inside it. Clamped so no suggestion can grow the + chip into a paragraph — the full text stays in `title`. */} + {prompt.label} ))}
diff --git a/src/components/ai-assistant/PromptInput.jsx b/src/components/ai-assistant/PromptInput.jsx index 7c84ee4..f2453da 100644 --- a/src/components/ai-assistant/PromptInput.jsx +++ b/src/components/ai-assistant/PromptInput.jsx @@ -17,6 +17,7 @@ import { cn } from '@/lib/utils'; * gain — the suggestion chips already teach the range of what can be asked, and * they do it without a timer or a rerender every four seconds. */ +/** @param {any} props */ export function PromptInput({ value, onChange, @@ -26,7 +27,7 @@ export function PromptInput({ placeholders = ['Ask anything…'], autoFocus = false, align = 'left', - className, + className = '', }) { const textareaRef = React.useRef(null); const [focused, setFocused] = React.useState(false); diff --git a/src/components/ai-assistant/ResponseBlocks.jsx b/src/components/ai-assistant/ResponseBlocks.jsx index 7bb5068..2ae3371 100644 --- a/src/components/ai-assistant/ResponseBlocks.jsx +++ b/src/components/ai-assistant/ResponseBlocks.jsx @@ -62,14 +62,14 @@ const TONE_BADGE = { /* ── Individual blocks ──────────────────────────────────────────────────── */ -const TextBlock = React.memo(({ block }) => ( +const TextBlock = React.memo(/** @param {any} props */ ({ block }) => (

)); TextBlock.displayName = 'TextBlock'; -const HeadingBlock = React.memo(({ block }) => ( +const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (

{block.text}

{block.sub &&

{block.sub}

} @@ -78,7 +78,7 @@ const HeadingBlock = React.memo(({ block }) => ( HeadingBlock.displayName = 'HeadingBlock'; /** Headline figures. Two columns keeps the numbers large in a 380px panel. */ -const KpisBlock = React.memo(({ block }) => ( +const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
{block.items.map((item) => (
( KpisBlock.displayName = 'KpisBlock'; /** Pass/warn checks. The icon carries the state so colour is not the only cue. */ -const StatusBlock = React.memo(({ block }) => ( +const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
    {block.items.map((item) => (
  • @@ -145,7 +145,7 @@ const StatusBlock = React.memo(({ block }) => ( )); StatusBlock.displayName = 'StatusBlock'; -const MetersBlock = React.memo(({ block }) => ( +const MetersBlock = React.memo(/** @param {any} props */ ({ block }) => (
    {block.items.map((item) => (
    @@ -167,7 +167,7 @@ MetersBlock.displayName = 'MetersBlock'; * A table. Scrolls horizontally inside its own container so a wide comparison * never widens the panel. */ -const TableBlock = React.memo(({ block }) => { +const TableBlock = React.memo(/** @param {any} props */ ({ block }) => { const cell = (value) => { if (value == null || value === '') return —; if (typeof value === 'object') { @@ -228,7 +228,7 @@ const TableBlock = React.memo(({ block }) => { TableBlock.displayName = 'TableBlock'; /** Stage progression, widths relative to the largest stage. */ -const FunnelBlock = React.memo(({ block }) => { +const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => { const max = Math.max(...block.steps.map((s) => s.count), 1); return ( @@ -255,7 +255,7 @@ const FunnelBlock = React.memo(({ block }) => { }); FunnelBlock.displayName = 'FunnelBlock'; -const ListBlock = React.memo(({ block }) => { +const ListBlock = React.memo(/** @param {any} props */ ({ block }) => { const Tag = block.ordered ? 'ol' : 'ul'; return ( @@ -298,7 +298,7 @@ ListBlock.displayName = 'ListBlock'; * clickable and doing nothing. An item may carry a hint *and* an action: the * hint explains the record, the action opens it. */ -const InsightsBlock = React.memo(({ block, onPrompt }) => ( +const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt }) => (
    {block.items.map((item, i) => { const surface = cn( @@ -375,7 +375,7 @@ const InsightsBlock = React.memo(({ block, onPrompt }) => ( InsightsBlock.displayName = 'InsightsBlock'; /** Recommended steps — numbered, because order is the recommendation. */ -const ActionsBlock = React.memo(({ block }) => ( +const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
      {block.items.map((item, i) => (
    1. @@ -394,7 +394,7 @@ const ActionsBlock = React.memo(({ block }) => ( )); ActionsBlock.displayName = 'ActionsBlock'; -const BadgesBlock = React.memo(({ block }) => ( +const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
      {block.items.map((item) => ( @@ -405,7 +405,7 @@ const BadgesBlock = React.memo(({ block }) => ( )); BadgesBlock.displayName = 'BadgesBlock'; -const TimelineBlock = React.memo(({ block }) => ( +const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
        {block.items.map((item, i) => (
      1. @@ -438,7 +438,7 @@ const TimelineBlock = React.memo(({ block }) => ( )); TimelineBlock.displayName = 'TimelineBlock'; -const NoteBlock = React.memo(({ block }) => ( +const NoteBlock = React.memo(/** @param {any} props */ ({ block }) => (

        @@ -455,7 +455,7 @@ NoteBlock.displayName = 'NoteBlock'; * shape the table does not carry renders nothing rather than something * improvised. */ -const SkillSectionBlock = React.memo(({ block }) => { +const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => { const section = block.section; const Component = SECTION_COMPONENTS[section?.shape || section?.type]; @@ -515,7 +515,7 @@ const RENDERERS = { * blocks has consistent rhythm — a heading hugs what follows it, everything * else breathes. */ -export const ResponseDocument = React.memo(({ blocks = [], streaming = false, onPrompt }) => ( +export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt }) => (
        {blocks.map((block, i) => { const Renderer = RENDERERS[block.type]; diff --git a/src/components/ai-assistant/dynamic.js b/src/components/ai-assistant/dynamic.js index 40de5bc..9cdf5e9 100644 --- a/src/components/ai-assistant/dynamic.js +++ b/src/components/ai-assistant/dynamic.js @@ -459,7 +459,7 @@ const PROMPTS = { prompt: `Continue the ${drafts[0].title} draft`, } : { - label: `${drafts.length} drafts to finish`, + label: `Which ${plural(drafts.length, 'position')} ${verb(drafts.length, 'is', 'are')} still in draft?`, prompt: 'Which positions are still in draft?', capability: 'position-drafts', }); @@ -496,8 +496,9 @@ const PROMPTS = { if (withStrong.length) { const best = withStrong.sort((a, b) => b.status.strong.length - a.status.strong.length)[0]; + const n = best.status.strong.length; out.push({ - label: `${best.status.strong.length} strong for ${best.position.title}`, + label: `Show the ${plural(n, 'strongest candidate')} for ${best.position.title}`, prompt: `Who matches ${best.position.title}?`, }); } @@ -505,10 +506,16 @@ const PROMPTS = { out.push({ label: 'Which position needs people first?', prompt: 'Which position needs people first?' }); } if (newToday) { - out.push({ label: `${newToday} applied today`, prompt: 'Who applied today?' }); + out.push({ + label: `Which ${plural(newToday, 'candidate')} applied today?`, + prompt: 'Who applied today?', + }); } if (readyCount) { - out.push({ label: `${readyCount} ready for interview`, prompt: 'Who is ready for interview?' }); + out.push({ + label: `Which ${plural(readyCount, 'candidate')} ${verb(readyCount, 'is', 'are')} ready for interview?`, + prompt: 'Who is ready for interview?', + }); } } @@ -516,13 +523,25 @@ const PROMPTS = { ones — they are still the right answer to "how is hiring going". */ out.push( f.starvedPositions.length - ? { label: `${f.starvedPositions.length} roles need attention`, prompt: 'What needs attention across my positions?', capability: 'positions-attention' } + ? { + label: `Which ${plural(f.starvedPositions.length, 'role')} ${verb(f.starvedPositions.length, 'needs', 'need')} attention?`, + prompt: 'What needs attention across my positions?', + capability: 'positions-attention', + } : { label: 'What needs attention?', prompt: 'What needs attention across my positions?', capability: 'positions-attention' }, { label: 'Which position has the strongest pipeline?', prompt: 'Which position has the strongest pipeline?', capability: 'position-strength' }, f.unscreened.length - ? { label: `${f.unscreened.length} waiting for review`, prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' } + ? { + label: `Show the ${plural(f.unscreened.length, 'application')} waiting for review`, + prompt: 'Show candidates waiting for review', + capability: 'candidates-waiting', + } : { label: 'Show candidates waiting for review', prompt: 'Show candidates waiting for review', capability: 'candidates-waiting' }, - { label: 'Summarize hiring activity', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' } + /* Named for the whole board. The Hiring Activity skill answers the same + question *for one position*, and the two used to arrive worded + identically — see the intent de-duplication in `KrowAssistant`, which + now also compares what a chip resolves to rather than only its words. */ + { label: 'Summarize hiring activity across all positions', prompt: 'Summarize hiring activity across my positions', capability: 'hiring-operations' } ); return out; diff --git a/src/components/ai-assistant/history.js b/src/components/ai-assistant/history.js index c69eb92..3655650 100644 --- a/src/components/ai-assistant/history.js +++ b/src/components/ai-assistant/history.js @@ -42,7 +42,7 @@ export function readHistory() { return list .filter((r) => r && r.id && Array.isArray(r.messages) && r.messages.length) .filter((r) => new Date(r.updatedAt || 0).getTime() >= cutoff) - .sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0)); + .sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime()); } catch { /* Corrupt or unavailable storage is an empty history, not an error the reader has to deal with. */ diff --git a/src/components/ai-assistant/insights.js b/src/components/ai-assistant/insights.js index 5cd06d4..5305b8a 100644 --- a/src/components/ai-assistant/insights.js +++ b/src/components/ai-assistant/insights.js @@ -60,7 +60,7 @@ export function buildFacts({ /* Time-to-hire — the created→updated span ImpactMetrics uses. */ const timeToHire = hired.length ? Math.round(avg(hired.map((a) => - Math.max(1, (new Date(a.updated_date) - new Date(a.created_date)) / DAY_MS)))) + Math.max(1, (new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / DAY_MS)))) : 0; /* Open roles attracting nobody. */ diff --git a/src/components/charts/DepartmentPerformance.jsx b/src/components/charts/DepartmentPerformance.jsx index a72c64b..da63309 100644 --- a/src/components/charts/DepartmentPerformance.jsx +++ b/src/components/charts/DepartmentPerformance.jsx @@ -103,7 +103,8 @@ function getDeptColor(index, avgScore) { }; } -export function DepartmentPerformance({ items = [], className }) { +/** @param {any} props */ +export function DepartmentPerformance({ items = [], className = '' }) { const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table' const [activeDept, setActiveDept] = useState(null); diff --git a/src/components/charts/HiringFlow.jsx b/src/components/charts/HiringFlow.jsx index 17ffa1b..56b05f2 100644 --- a/src/components/charts/HiringFlow.jsx +++ b/src/components/charts/HiringFlow.jsx @@ -18,7 +18,8 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme'; * `stages` and `transitions` come from the page's own fact sheet — this * component derives no figures of its own. */ -export function HiringFlow({ stages = [], transitions = [], weakestKey, className }) { +/** @param {any} props */ +export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }) { if (!stages.length) return null; const total = stages[0]?.count || 1; diff --git a/src/components/charts/HiringTrendChart.jsx b/src/components/charts/HiringTrendChart.jsx index d9073dc..0e8ab55 100644 --- a/src/components/charts/HiringTrendChart.jsx +++ b/src/components/charts/HiringTrendChart.jsx @@ -14,7 +14,8 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme'; * * `points: [{ label, hires, cumulative }]` — supplied by the page. */ -export function HiringTrendChart({ points = [], emptyState, className }) { +/** @param {any} props */ +export function HiringTrendChart({ points = [], emptyState, className = '' }) { const enough = points.length >= 2; return ( diff --git a/src/components/ds/ActivityCard.jsx b/src/components/ds/ActivityCard.jsx index f5c6d1b..eb3ef88 100644 --- a/src/components/ds/ActivityCard.jsx +++ b/src/components/ds/ActivityCard.jsx @@ -11,6 +11,7 @@ import { StatusBadge } from '@/components/ds/StatusBadge'; * `variant="card"`. Either way the anatomy is fixed — actor, sentence, meta, * trailing slot — so a feed built from mixed event types stays aligned. */ +/** @param {any} props */ export function ActivityCard({ /** Person or system that acted. */ actor, diff --git a/src/components/ds/Alert.jsx b/src/components/ds/Alert.jsx index 931499e..e251778 100644 --- a/src/components/ds/Alert.jsx +++ b/src/components/ds/Alert.jsx @@ -39,6 +39,7 @@ const ICON_COLORS = { * instead: an Alert that appears and disappears is easy to miss, and a toast * that persists is noise. */ +/** @param {any} props */ export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) { const Icon = icon || ICONS[tone]; diff --git a/src/components/ds/Avatar.jsx b/src/components/ds/Avatar.jsx index efef68c..8eae976 100644 --- a/src/components/ds/Avatar.jsx +++ b/src/components/ds/Avatar.jsx @@ -41,6 +41,7 @@ export function initialsFrom(name = '') { * first-class state rather than a fallback afterthought. A failed image load * degrades to initials instead of a broken-image icon. */ +/** @type {React.ForwardRefExoticComponent} */ export const Avatar = React.forwardRef( ({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => { const [failed, setFailed] = React.useState(false); @@ -89,6 +90,7 @@ Avatar.displayName = 'Avatar'; * AvatarGroup — overlapping avatars with a "+N" remainder, for showing several * people in a tight row (applicants on a position, endorsers on a skill). */ +/** @param {any} props */ export function AvatarGroup({ people = [], max = 4, size = 'sm', className }) { const visible = people.slice(0, max); const overflow = people.length - visible.length; diff --git a/src/components/ds/ChartContainer.jsx b/src/components/ds/ChartContainer.jsx index 02e437e..444150b 100644 --- a/src/components/ds/ChartContainer.jsx +++ b/src/components/ds/ChartContainer.jsx @@ -37,6 +37,7 @@ export const AXIS_PROPS = { * A tooltip matching the design system's surfaces. Recharts' default is a plain * white box that ignores the token set. */ +/** @param {any} props */ export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) { if (!active || !payload?.length) return null; @@ -97,6 +98,7 @@ export function ChartLegend({ items = [], className }) { * state, and an optional headline figure. Charts inside it are pure Recharts * children, so swapping a bar chart for a line chart changes nothing else. */ +/** @param {any} props */ export function ChartContainer({ title, subtitle, diff --git a/src/components/ds/DataTable.jsx b/src/components/ds/DataTable.jsx index a0f69cf..abe88c2 100644 --- a/src/components/ds/DataTable.jsx +++ b/src/components/ds/DataTable.jsx @@ -23,6 +23,7 @@ import { usePreferences } from '@/lib/krowHooks'; * `hideBelow: 'md'` drops a column on small screens, which is what keeps the * table usable on mobile without a horizontal scroll for every view. */ +/** @param {any} props */ export function DataTable({ columns = [], rows = [], diff --git a/src/components/ds/Drawer.jsx b/src/components/ds/Drawer.jsx index 4f31865..ec6f883 100644 --- a/src/components/ds/Drawer.jsx +++ b/src/components/ds/Drawer.jsx @@ -27,6 +27,7 @@ const WIDTHS = { * a detail view can be a Drawer on desktop and a bottom sheet on mobile without * its contents changing. */ +/** @param {any} props */ export function Drawer({ open, onOpenChange, diff --git a/src/components/ds/EmptyState.jsx b/src/components/ds/EmptyState.jsx index 1a48a29..136a51f 100644 --- a/src/components/ds/EmptyState.jsx +++ b/src/components/ds/EmptyState.jsx @@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button'; * Pages that conflate them end up telling a user with 22 candidates that they * have no candidates. */ +/** @param {any} props */ export function EmptyState({ icon: Icon, title, diff --git a/src/components/ds/Field.jsx b/src/components/ds/Field.jsx index c996bb9..dbbf3f4 100644 --- a/src/components/ds/Field.jsx +++ b/src/components/ds/Field.jsx @@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'; * spacing, required markers and error styling are identical across the app. * It wires `id`, `aria-describedby` and `aria-invalid` onto its child. */ +/** @param {any} props */ export function Field({ label, hint, diff --git a/src/components/ds/FilterBar.jsx b/src/components/ds/FilterBar.jsx index f0fb714..d16a26c 100644 --- a/src/components/ds/FilterBar.jsx +++ b/src/components/ds/FilterBar.jsx @@ -18,24 +18,25 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ * On mobile the filters collapse behind a "Filters" toggle with an active count, * because four side-by-side selects are unusable at 375px. */ +/** @param {any} props */ export function FilterBar({ - search, - onSearchChange, + search = null, + onSearchChange = null, searchPlaceholder = 'Search...', filters = [], /** `{ [key]: string | string[] }` */ values = {}, - onChange, + onChange = null, /** Right-aligned actions — primary buttons, view switchers. */ - actions, + actions = null, /** Rendered under the bar, e.g. a result count. */ - meta, + meta = null, /** Value that means "no filter" for select-type filters. */ allValue = 'all', - className, + className = '', }) { const [expanded, setExpanded] = React.useState(false); diff --git a/src/components/ds/IconButton.jsx b/src/components/ds/IconButton.jsx index 8792d27..8410cdb 100644 --- a/src/components/ds/IconButton.jsx +++ b/src/components/ds/IconButton.jsx @@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp * `label`: an icon-only control with no accessible name is a bug, so the API * makes it impossible to omit. The label doubles as the tooltip. */ +/** @type {React.ForwardRefExoticComponent} */ export const IconButton = React.forwardRef( ({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => { const sizeMap = { diff --git a/src/components/ds/InsightRow.jsx b/src/components/ds/InsightRow.jsx index 6a9e9fd..37d15c3 100644 --- a/src/components/ds/InsightRow.jsx +++ b/src/components/ds/InsightRow.jsx @@ -45,18 +45,19 @@ const SEVERITY = { }, }; +/** @param {any} props */ export function InsightRow({ severity = 'info', title, detail, /** Short right-aligned figure — a count, an age, a percentage. */ - metric, - metricLabel, + metric = null, + metricLabel = null, /** Entity the item concerns, e.g. a position or candidate name. */ - entity, - onClick, + entity = null, + onClick = null, actionLabel = 'Review', - className, + className = '', }) { const s = SEVERITY[severity] || SEVERITY.info; const Comp = onClick ? 'button' : 'div'; @@ -105,6 +106,7 @@ export function InsightRow({ } /** The list container — dividers between rows, nothing else. */ -export function InsightList({ children, className }) { +/** @param {any} props */ +export function InsightList({ children, className = '' }) { return
        {children}
        ; } diff --git a/src/components/ds/KpiCard.jsx b/src/components/ds/KpiCard.jsx index b5da046..eca5794 100644 --- a/src/components/ds/KpiCard.jsx +++ b/src/components/ds/KpiCard.jsx @@ -21,6 +21,7 @@ const TONES = { * (a target, a breakdown, a sparkline) use `MetricCard`; keeping the two * separate stops KPI rows from drifting into inconsistent heights. */ +/** @param {any} props */ export function KpiCard({ icon: Icon, value, @@ -94,6 +95,7 @@ const formatDelta = (delta, suffix = '%') => * `invertTrend` exists because "up" is not always good — time-to-hire rising is * bad, and colouring it green would be actively misleading. */ +/** @param {any} props */ export function MetricCard({ label, value, diff --git a/src/components/ds/Layout.jsx b/src/components/ds/Layout.jsx index df738f1..fcc9f5f 100644 --- a/src/components/ds/Layout.jsx +++ b/src/components/ds/Layout.jsx @@ -18,6 +18,7 @@ const GAPS = { sm: 'gap-3', default: 'gap-4', lg: 'gap-6' }; * `cols` is the count at the largest breakpoint; the intermediate steps are * chosen to avoid orphan cards (6 → 2/3/6 rather than 1/3/6). */ +/** @param {any} props */ export function Grid({ cols = 3, gap = 'default', className, children, ...props }) { const columns = { 1: 'grid-cols-1', @@ -36,6 +37,7 @@ export function Grid({ cols = 3, gap = 'default', className, children, ...props } /** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */ +/** @param {any} props */ export function Stack({ space = 'default', className, children, ...props }) { const spacing = { xs: 'space-y-2', @@ -56,6 +58,7 @@ export function Stack({ space = 'default', className, children, ...props }) { * PageSection — a titled region of a page. Pairs a SectionHeader-style label * with its content at consistent spacing. */ +/** @param {any} props */ export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) { return (
        diff --git a/src/components/ds/LoadingState.jsx b/src/components/ds/LoadingState.jsx index 0f96a95..bdde408 100644 --- a/src/components/ds/LoadingState.jsx +++ b/src/components/ds/LoadingState.jsx @@ -5,7 +5,8 @@ import { cn } from '@/lib/utils'; /** * Spinner — the only spinner in the system. */ -export function Spinner({ size = 'default', className, label = 'Loading' }) { +/** @param {any} props */ +export function Spinner({ size = 'default', className = '', label = 'Loading' }) { const sizes = { xs: 'w-3.5 h-3.5', sm: 'w-4 h-4', default: 'w-6 h-6', lg: 'w-8 h-8' }; return ( @@ -55,7 +58,8 @@ export function InlineLoading({ message = 'Loading…', className }) { * LoadingOverlay — covers content that is being replaced, keeping layout * height stable so the page does not jump. Used by DataTable when refetching. */ -export function LoadingOverlay({ show, message, className }) { +/** @param {any} props */ +export function LoadingOverlay({ show = false, message = '', className = '' }) { if (!show) return null; return (
        i + 1); @@ -30,6 +36,7 @@ export function buildPageRange(current, total, siblings = 1) { * Pagination — page navigation with an optional result summary and page-size * control. Renders nothing when there is only one page. */ +/** @param {any} props */ export function Pagination({ page = 1, pageCount = 1, diff --git a/src/components/ds/Progress.jsx b/src/components/ds/Progress.jsx index 73e405f..efa4f1f 100644 --- a/src/components/ds/Progress.jsx +++ b/src/components/ds/Progress.jsx @@ -34,6 +34,7 @@ export function toneForScore(score) { * `tone="score"` picks the colour from the value, which is what most usages in * this app want. */ +/** @param {any} props */ export function ProgressBar({ value, max = 100, @@ -94,6 +95,7 @@ export function ProgressBar({ * score, via `min`/`max`, so the KROW ID gauge and a completion ring are the * same component. */ +/** @param {any} props */ export function ProgressRing({ value, min = 0, @@ -175,7 +177,8 @@ export function ProgressRing({ * Completed steps show a check rather than their number, so "done" is readable * at a glance without relying on colour. */ -export function ProgressSteps({ steps = [], current = 0, className, onStepClick }) { +/** @param {any} props */ +export function ProgressSteps({ steps = [], current = 0, className = '', onStepClick = null }) { const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s)); return ( @@ -239,6 +242,7 @@ export function ProgressSteps({ steps = [], current = 0, className, onStepClick * ScoreMeter — a labelled bar row used in score breakdowns, where many * dimensions are listed with a weight and a value. */ +/** @param {any} props */ export function ScoreMeter({ label, value, weight, tone = 'score', className }) { return (
        diff --git a/src/components/ds/SearchInput.jsx b/src/components/ds/SearchInput.jsx index ece2efd..e35d025 100644 --- a/src/components/ds/SearchInput.jsx +++ b/src/components/ds/SearchInput.jsx @@ -10,6 +10,7 @@ import { inputVariants } from '@/components/ui/input'; * without re-running expensive work, while the field itself stays instantly * responsive. Pass `debounce={0}` for immediate updates. */ +/** @type {React.ForwardRefExoticComponent} */ export const SearchInput = React.forwardRef( ( { diff --git a/src/components/ds/SectionHeader.jsx b/src/components/ds/SectionHeader.jsx index 73feca4..2fb256b 100644 --- a/src/components/ds/SectionHeader.jsx +++ b/src/components/ds/SectionHeader.jsx @@ -10,6 +10,7 @@ import { cn } from '@/lib/utils'; * - `card` — a card's own title row * - `section` — a page-level section title */ +/** @param {any} props */ export function SectionHeader({ title, subtitle, diff --git a/src/components/ds/Skeleton.jsx b/src/components/ds/Skeleton.jsx index 3cd3509..22040c1 100644 --- a/src/components/ds/Skeleton.jsx +++ b/src/components/ds/Skeleton.jsx @@ -9,7 +9,8 @@ import { cn } from '@/lib/utils'; * progress. The shimmer is disabled automatically under * `prefers-reduced-motion` (see index.css). */ -export function Skeleton({ className, radius = 'md', ...props }) { +/** @param {any} props */ +export function Skeleton({ className = '', radius = 'md', ...props }) { return (