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 }) => (
{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 (