update Markdown skills

This commit is contained in:
2026-08-19 17:36:27 +05:30
parent aa2fb41871
commit d3f7f439f6
30 changed files with 2341 additions and 221 deletions

View File

@@ -8,6 +8,7 @@
"build": "vite build",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"test": "node scripts/skill-check.mjs",
"typecheck": "tsc -p ./jsconfig.json",
"preview": "vite preview"
},

BIN
public/owliver-avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -375,9 +375,432 @@ for (const n of [1, 4, 9]) {
);
}
/* ── 6. Authoring: what the editors save, and what they refuse ────────────── */
console.log('\n── Authoring ──');
const fields = await server.ssrLoadModule('/src/lib/skills/skillFields.js');
const templates = await server.ssrLoadModule('/src/lib/skills/customSkills.js');
const surfaces = await server.ssrLoadModule('/src/lib/skills/surfaces.js');
/* A template must produce something that works before it is edited. The Board
template used to default to a source its own default placement cannot read. */
record(
'the Board template validates as written',
reg.validateSkillSource(templates.uiSkillTemplate({
id: 'template-check', name: 'Template Check', pages: ['positions'],
})) === null,
reg.validateSkillSource(templates.uiSkillTemplate({
id: 'template-check', name: 'Template Check', pages: ['positions'],
})) || 'valid'
);
/* An Owliver block that can answer nothing is refused rather than saved and
then reported as a skill that does not work. */
record(
'an Owliver skill with no capabilities is refused',
Boolean(reg.validateSkillSource(templates.owliverSkillTemplate({
id: 'empty-owliver', name: 'Empty Owliver', pages: ['positions'],
}))),
reg.validateSkillSource(templates.owliverSkillTemplate({
id: 'empty-owliver', name: 'Empty Owliver', pages: ['positions'],
})) || 'ACCEPTED — should have been refused'
);
/* The section-can-never-resolve check, in both directions. */
const boardOn = (page, placement) => `---
id: context-check
name: Context Check
description: Reads position activity.
pages:
- ${page}
status: active
ui:
type: flow
placement: ${placement}
source: position.activity
---
# Context Check
`;
record(
'a position source on a page with no position is refused',
Boolean(reg.validateSkillSource(boardOn('analytics', 'after-header'))),
reg.validateSkillSource(boardOn('analytics', 'after-header')) || 'ACCEPTED — should have been refused'
);
record(
'the same source is refused above the Positions grid',
Boolean(reg.validateSkillSource(boardOn('positions', 'after-position-list'))),
reg.validateSkillSource(boardOn('positions', 'after-position-list')) || 'ACCEPTED — should have been refused'
);
record(
'and accepted inside a position card',
reg.validateSkillSource(boardOn('positions', 'after-position-card')) === null,
reg.validateSkillSource(boardOn('positions', 'after-position-card')) || 'accepted'
);
/* Owliver responses are deliberately NOT subject to that rule: an unmet need is
a question back, not a dead card. `hiring-activity-assistant` depends on it. */
record(
'a shipped Owliver skill reading one position still validates',
reg.validateSkillSource(
reg.SKILLS.find((s) => s.id === 'hiring-activity-assistant').markdown
) === null
);
record(
'every shipped definition still validates',
reg.SKILLS.every((s) => reg.validateSkillSource(s.markdown) === null),
reg.SKILLS.filter((s) => reg.validateSkillSource(s.markdown)).map((s) => s.id).join(', ') || 'all valid'
);
/* Reading a definition into the editor fields — the upload path. */
for (const skill of reg.SKILLS.filter((s) => s.kind === 'assistant')) {
const read = fields.owliverFieldsFromSource(skill.markdown);
record(
`\`${skill.id}\` reads back into the Owliver fields`,
read.id === skill.id && read.name === skill.name
&& read.pages.join(',') === skill.pages.join(','),
`${read.name} / ${read.pages.join(', ')}`
);
}
record(
'an unparseable file leaves the fields empty rather than throwing',
fields.owliverFieldsFromSource('not a definition').id === ''
);
/* Writing a field back — the half that used to do nothing. */
const original = reg.SKILLS.find((s) => s.id === 'hiring-activity-assistant').markdown;
const renamed = fields.patchFrontmatter(original, { name: 'Renamed Assistant' });
record(
'patching a field changes what the registry reads',
reg.parseSkill(renamed, { custom: true }).name === 'Renamed Assistant',
reg.parseSkill(renamed, { custom: true }).name
);
record(
'patching a field leaves the body untouched',
reg.parseSkill(renamed, { custom: true }).body === reg.parseSkill(original, { custom: true }).body
);
record(
'patching a field preserves frontmatter comments',
renamed.includes('# One suggestion per capability.')
);
record(
'patching a field changes nothing else',
original.split('\n').filter((l) => !l.startsWith('name:')).join('\n')
=== renamed.split('\n').filter((l) => !l.startsWith('name:')).join('\n')
);
record(
'a patched definition still validates',
reg.validateSkillSource(renamed) === null,
reg.validateSkillSource(renamed) || 'valid'
);
const repaged = fields.patchFrontmatter(original, { pages: ['analytics', 'activity'] });
record(
'patching a list replaces the whole block',
reg.parseSkill(repaged, { custom: true }).pages.join(',') === 'analytics,activity',
reg.parseSkill(repaged, { custom: true }).pages.join(',')
);
const restatused = fields.patchFrontmatter(original, { status: 'inactive' });
record(
'patching a key the file never declared adds it',
reg.parseSkill(restatused, { custom: true }).status === 'inactive'
);
/* Both keys, because that is what the editor's own field handler writes: the
registry reads capabilities as the union of the declared list and the keys of
`responses:`, so patching one without the other changes nothing. */
const recapped = fields.patchFrontmatter(original, {
'owliver.capabilities': ['summary'],
'owliver.responses': { summary: { source: 'position.activity', periods: ['today'] } },
});
record(
'patching a nested block rewrites only that block',
reg.parseSkill(recapped, { custom: true }).owliver.capabilities.join(',') === 'summary',
reg.parseSkill(recapped, { custom: true }).owliver.capabilities.join(',')
);
/* Asserted on the text, not on the parse: with `flow` no longer offered, the
registry correctly drops the suggestion that names it — which is the rule
working, not the patch reaching a sibling key it should not have. */
record(
'and leaves its siblings in the same block alone',
recapped.includes('Summarize hiring activity for this position')
&& recapped.includes('Show hiring activity as a flow')
&& recapped.includes(' enabled: true')
);
/* The two `ui:` shapes, told apart — the per-page form must not be overwritten
from four single-valued fields. */
record(
'the shorthand `ui:` form is recognised',
fields.uiShape(boardOn('positions', 'after-position-card')) === 'shorthand'
);
record(
'a definition with no `ui:` block reports none',
fields.uiShape(original) === 'none'
);
/* The picker and the validator read the same table. */
record(
'a position card supplies a position',
surfaces.contextSuppliedBy(['positions'], 'after-position-card').includes('positionId')
);
record(
'the Positions list supplies nothing',
surfaces.contextSuppliedBy(['positions'], 'after-position-list').length === 0
);
record(
'Analytics supplies nothing',
surfaces.contextSuppliedBy(['analytics'], 'after-header').length === 0
);
record(
'every surface declares what its placements provide',
surfaces.SKILL_SURFACES.every((s) => s.provides && typeof s.provides === 'object'),
surfaces.SKILL_SURFACES.filter((s) => !s.provides).map((s) => s.id).join(', ') || 'all declared'
);
/* ── 7. Upload hydration: a file, into the fields ─────────────────────────── */
console.log('\n── Upload hydration ──');
/**
* The shapes people actually upload.
*
* Every case here is a definition that arrived from outside the editors, which
* is the only way most definitions arrive. What is being asserted is not that
* the parser is lenient — it is that the three identity fields, the pages and
* the section a reader can see in the file are the ones the form shows.
*/
const COMPLETE = `---
id: hiring-activity-assistant
name: Hiring Activity Assistant
description: Answer questions about recent hiring activity on a position.
type: board
ui:
- page: Positions
placement: grid-card
source: position.activity
---
# Hiring Activity Assistant
`;
const NO_ID = `---
name: Hiring Activity Assistant
description: Answer questions about recent hiring activity.
pages:
- positions
---
# Hiring Activity Assistant
`;
const MULTI_PAGE = `---
id: hiring-activity
name: Hiring Activity
description: Hiring activity across the workspace.
ui:
- page: Positions
placement: grid-card
source: candidates.activity
- page: Analytics
placement: panel
source: hires.performance
---
# Hiring Activity
`;
/* Case 1 — a complete definition hydrates every field it declares. */
{
const f = fields.boardFieldsFromSource(COMPLETE);
record('upload: name hydrates', f.name === 'Hiring Activity Assistant', f.name || 'EMPTY');
record('upload: id hydrates', f.id === 'hiring-activity-assistant', f.id || 'EMPTY');
record(
'upload: description hydrates',
f.description === 'Answer questions about recent hiring activity on a position.',
f.description || 'EMPTY'
);
record('upload: pages come from the `ui:` entries', f.pages.join(',') === 'positions', f.pages.join(',') || 'EMPTY');
record('upload: `grid-card` resolves to a real placement', f.placement === 'after-position-card', f.placement || 'EMPTY');
record('upload: source hydrates', f.source === 'position.activity', f.source || 'EMPTY');
record('upload: an undeclared type is inferred from the source', f.type === 'flow', f.type || 'EMPTY');
record('upload: the definition validates as written', reg.validateSkillSource(COMPLETE) === null,
reg.validateSkillSource(COMPLETE) || 'valid');
record('upload: it is classified as a Board skill',
fields.facetsFromSource(COMPLETE).join(',') === 'ui', fields.facetsFromSource(COMPLETE).join(','));
/* The same file read by the other editor's reader — one pipeline, two views. */
const o = fields.owliverFieldsFromSource(COMPLETE);
record('upload: the Owliver reader hydrates the same identity',
o.id === f.id && o.name === f.name && o.description === f.description);
}
/* Case 2 — no `id:`, so it is slugged from the name and never invented. */
{
const f = fields.boardFieldsFromSource(NO_ID);
record('upload: a missing id slugs the name', f.id === 'hiring-activity-assistant', f.id || 'EMPTY');
record('upload: a missing id does not become the placeholder path', f.id !== 'custom');
record('upload: an explicit id is never replaced by a generated one',
fields.boardFieldsFromSource(COMPLETE).id === 'hiring-activity-assistant');
record('upload: a file with no frontmatter hydrates nothing',
fields.boardFieldsFromSource('# Just a heading\n').id === '');
}
/* Case 3 — several pages, and every entry survives a field edit. */
{
const f = fields.boardFieldsFromSource(MULTI_PAGE);
record('upload: every page in the list is reported', f.pages.join(',') === 'positions,analytics', f.pages.join(','));
const parsed = reg.parseSkill(MULTI_PAGE, { custom: true });
record('upload: every entry becomes a section',
parsed.ui.positions.sections.length === 1 && parsed.ui.analytics.sections.length === 1);
record('upload: each entry keeps its own source',
parsed.ui.positions.sections[0].source === 'candidates.activity'
&& parsed.ui.analytics.sections[0].source === 'hires.performance');
record('upload: a multi-page definition validates', reg.validateSkillSource(MULTI_PAGE) === null,
reg.validateSkillSource(MULTI_PAGE) || 'valid');
/* The fields must not be able to flatten it. */
record('upload: the section fields are read-only against a list', !fields.uiIsEditableFromFields(MULTI_PAGE));
record('upload: a single-section definition stays field-editable',
fields.uiIsEditableFromFields(templates.uiSkillTemplate({ id: 'x', name: 'X', pages: ['positions'] })));
/* Renaming is identity, not structure: it must still work, and must not
touch either entry. */
const renamedMulti = fields.patchFrontmatter(MULTI_PAGE, { name: 'Renamed Multi' });
const after = reg.parseSkill(renamedMulti, { custom: true });
record('upload: renaming a multi-page definition keeps both entries',
after.ui.positions?.sections.length === 1 && after.ui.analytics?.sections.length === 1,
Object.keys(after.ui).join(','));
record('upload: ...and actually renames it', after.name === 'Renamed Multi', after.name);
record('upload: ...and leaves the `ui:` text byte-identical',
renamedMulti.slice(renamedMulti.indexOf('ui:')) === MULTI_PAGE.slice(MULTI_PAGE.indexOf('ui:')));
}
/* Files as they actually arrive: from Windows, from a download, from paste. */
{
const dirty = {
'a byte-order mark': `${COMPLETE}`,
'CRLF line endings': COMPLETE.replace(/\n/g, '\r\n'),
'a BOM and CRLF': `${COMPLETE.replace(/\n/g, '\r\n')}`,
'a blank line above the fence': `\n\n${COMPLETE}`,
'trailing spaces on the fence': COMPLETE.replace(/^---$/gm, '--- '),
};
for (const [what, md] of Object.entries(dirty)) {
const f = fields.boardFieldsFromSource(md);
record(
`upload: a file with ${what} still hydrates`,
f.name === 'Hiring Activity Assistant' && f.id === 'hiring-activity-assistant'
&& f.pages.join(',') === 'positions',
`${f.name || 'EMPTY'} / ${f.id || 'EMPTY'} / ${f.pages.join(',') || 'EMPTY'}`
);
record(
`upload: ...and the registry reads it the same way`,
reg.parseSkill(md, { custom: true }).name === 'Hiring Activity Assistant',
reg.parseSkill(md, { custom: true }).name
);
record(`upload: ...and it validates`, reg.validateSkillSource(md) === null,
reg.validateSkillSource(md) || 'valid');
}
/* Normalising on the way in is what keeps `patchFrontmatter` safe: an
unrecognised fence would have it write a second one above the first. */
const patchedDirty = fields.patchFrontmatter(fields.normalizeUpload(`${COMPLETE}`), { name: 'Clean' });
record('upload: patching a normalised file writes one frontmatter block',
(patchedDirty.match(/^---$/gm) || []).length === 2,
`${(patchedDirty.match(/^---$/gm) || []).length} fences`);
record('upload: ...and it still parses', reg.parseSkill(patchedDirty, { custom: true }).name === 'Clean');
/* A file with nothing to read must be refused, not read as a blank skill. */
record('upload: a file with no fence is not readable', !fields.isReadableDefinition('# Just prose\n'));
record('upload: a real definition is readable', fields.isReadableDefinition(COMPLETE));
}
/* Page names and placements as they are written in the product, not as the
vocabulary spells them internally. */
record('upload: `Positions` resolves to the positions surface', surfaces.canonicalPage('Positions') === 'positions');
record('upload: `Talent Pool` resolves to the talent-pool surface', surfaces.canonicalPage('Talent Pool') === 'talent-pool');
record('upload: `panel` on Analytics resolves to a real placement',
surfaces.placementFor('analytics', 'panel') === 'after-header');
record('upload: an alias never resolves onto a surface that lacks it',
surfaces.placementFor('analytics', 'grid-card') === null);
record('upload: a canonical placement still resolves to itself',
surfaces.placementFor('positions', 'after-position-card') === 'after-position-card');
/* Case 4 — an existing definition reopened for editing. */
for (const skill of reg.SKILLS) {
const f = skill.facets?.includes('ui')
? fields.boardFieldsFromSource(skill.markdown)
: fields.owliverFieldsFromSource(skill.markdown);
record(
`reopening \`${skill.id}\` loads its identity unchanged`,
f.id === skill.id && f.name === skill.name && f.description === skill.description
&& f.pages.join(',') === skill.pages.join(','),
`${f.id} / ${f.name} / ${f.pages.join(', ')}`
);
}
/* Case 5 — a manual edit is not reverted by later synchronisation. */
{
/* The editor's own handler, in miniature: hydrate from the file, edit one
field, then edit an unrelated one. The first edit must survive the second. */
let draft = fields.owliverFieldsFromSource(COMPLETE);
let src = COMPLETE;
const edit = (patch) => {
draft = { ...draft, ...patch };
src = fields.patchFrontmatter(src, {
id: draft.id || undefined,
name: draft.name || undefined,
description: draft.description || undefined,
pages: draft.pages?.length && !fields.pagesAreDerived(src) ? draft.pages : undefined,
});
};
edit({ name: 'My Own Name' });
record('edit: a manual name reaches the artefact',
reg.parseSkill(src, { custom: true }).name === 'My Own Name');
edit({ description: 'My own description.' });
record('edit: a later edit does not revert the earlier one',
reg.parseSkill(src, { custom: true }).name === 'My Own Name',
reg.parseSkill(src, { custom: true }).name);
record('edit: ...and applies itself',
reg.parseSkill(src, { custom: true }).description === 'My own description.');
record('edit: ...and the `ui:` block is untouched throughout',
src.slice(src.indexOf('ui:')) === COMPLETE.slice(COMPLETE.indexOf('ui:')));
/* A second upload replaces the draft outright — it is a new source. */
const rehydrated = fields.owliverFieldsFromSource(MULTI_PAGE);
record('edit: a derived `pages:` is never written back',
!src.includes('\npages:'), src.includes('\npages:') ? 'pages: was inserted' : 'not written');
record('edit: a declared `pages:` still patches normally',
reg.parseSkill(
fields.patchFrontmatter(NO_ID, { pages: ['analytics'] }), { custom: true }
).pages.join(',') === 'analytics');
record('edit: an inherited trigger is not written into the file',
!src.includes('triggers:'), src.includes('triggers:') ? 'triggers: was materialised' : 'not written');
record('edit: a declared trigger is still read into the fields',
fields.owliverFieldsFromSource(
reg.SKILLS.find((x) => x.id === 'hiring-activity-assistant').markdown
).triggers.includes('hiring activity'));
record('edit: a definition with no triggers reads none',
fields.owliverFieldsFromSource(COMPLETE).triggers.length === 0,
JSON.stringify(fields.owliverFieldsFromSource(COMPLETE).triggers));
record('edit: a second upload hydrates from the new file',
rehydrated.id === 'hiring-activity' && rehydrated.name === 'Hiring Activity',
`${rehydrated.id} / ${rehydrated.name}`);
}
await server.close();
/* ── 6. Production bundle ─────────────────────────────────────────────────── */
/* ── 8. Production bundle ─────────────────────────────────────────────────── */
if (process.argv.includes('--dist')) {
console.log('\n── Production bundle ──');
const dir = join(ROOT, 'dist/assets');

View File

@@ -0,0 +1,26 @@
---
id: board-invalid-context
name: Board (invalid context)
description: Negative test — a position-scoped source on a placement that supplies no position.
pages:
- positions
status: active
ui:
type: card
placement: after-position-list-summary
title: Board
source: position.activity
periods:
- today
- last-7-days
- previous-month
---
# Board (invalid context)
## Purpose
Deliberately wrong, and kept so the refusal stays tested. `position.activity`
needs one position to read; `after-position-list-summary` renders once above the
grid with no position in context. Saving this must be refused with a message that
names both halves of the mismatch.

View File

@@ -0,0 +1,31 @@
---
id: board
name: Board
description: Helps Owliver understand, analyze, and act on the current task board.
pages:
- positions
status: active
ui:
type: card
placement: after-position-list-summary
title: Board
source: candidates.activity
periods:
- today
- last-7-days
- previous-month
---
# Board
## Purpose
Helps Owliver understand the current task board by summarizing relevant work activity, identifying items that need attention, and explaining the current status of tasks shown on the board.
## Capabilities
- Summarize relevant activity from the current board.
- Identify tasks or work items that need attention.
- Explain the current status of tasks and work items.
- Highlight recent activity and meaningful changes.
- Help Owliver analyze board activity using the available board data.

View File

@@ -0,0 +1,31 @@
---
id: board-position-card
name: Board (per position)
description: Control test — the same Board card, rendered inside every position card.
pages:
- positions
status: active
ui:
type: card
placement: grid-card
title: Board
source: position.activity
periods:
- today
- last-7-days
- previous-month
---
# Board (per position)
## Purpose
The position-aware control for the page-level Board card. `grid-card` resolves to
`after-position-card`, which renders inside each position card and supplies that
position as context — so `position.activity` reads and the card is drawn once per
role rather than once for the page.
## Capabilities
- Summarize activity for the position the card belongs to.
- Show that activity across today, the last 7 days and the previous month.

View File

@@ -0,0 +1,40 @@
---
id: board-ui-test
name: Board
description: Renders the Board card once above the position list on the Positions page.
pages:
- positions
status: active
ui:
type: card
placement: after-position-list-summary
title: Board
source: candidates.activity
periods:
- today
- last-7-days
- previous-month
---
# Board
## Purpose
The page half of the Board, and only that half. One card, above the position
grid, reading activity across the workspace.
`after-position-list-summary` renders once for the page and supplies no
position — `surfaces.js` records that placement as providing nothing — so the
card reads `candidates.activity`, which declares `context: null` and counts
applications across the workspace over the periods named here.
`position.activity` is refused at this placement by `unresolvableSections`, and
correctly: it needs one position, and this placement has none to give.
This definition declares no `owliver:` block, so it contributes no capability,
no suggestion chip and no conversational answer. It is a page extension and
nothing else.
## Capabilities
- Show workspace application activity above the position list.
- Compare today, the last 7 days and the previous month in one card.

View File

@@ -0,0 +1,63 @@
---
id: owliver-conversation-test
name: Workspace Activity Assistant
description: Answer questions about applications across the workspace and the roles still open.
pages:
- positions
status: active
triggers:
- workspace applications
- applications across the workspace
- roles still open
- roles needing attention
owliver:
enabled: true
# Every suggestion names the capability it asks for, so a chip and an answer
# are one-to-one rather than both falling through to the first declared
# capability.
suggestions:
- label: Summarize workspace applications
capability: summary
- label: List the roles still open
capability: list
capabilities:
- summary
- list
responses:
summary:
title: Workspace Applications
source: candidates.activity
periods:
- today
- last-7-days
- previous-month
list:
title: Roles Still Open
source: positions.demand
limit: 5
---
# Workspace Activity Assistant
## Purpose
The panel half, and only that half. No `ui:` block, so nothing is drawn on the
Positions page and no card appears above or inside the grid.
Each response names its own `source:` directly, which is what a definition
without a `ui:` section must do — `normalizeSkillOwliver` inherits a source from
the first `ui:` section only when one exists, and reports
`owliver.responses.<capability>: a response needs a source, or a ui: section to
read from` when neither is present.
Both sources declare `context: null`, so neither answer asks which position is
meant: `resolveEntity` returns immediately and no
"This section needs a position to read" is possible. The shapes match what each
source offers — `summary` is prose and is exempt from the shape check, and
`list` is a shape `positions.demand` declares.
## Capabilities
- Summarize applications across the workspace over today, the last 7 days and
the previous month.
- List the open positions and how many applicants each has.

View File

@@ -43,6 +43,37 @@ function loadUser() {
let currentUser = loadUser();
/**
* Writes the session user, and says whether it actually landed.
*
* The old version was `try { setItem } catch {}` — a swallowed
* `QuotaExceededError` or a private-browsing refusal, and the caller was handed
* a user object indistinguishable from a successful write. For preferences that
* is invisible; for `customSkills`, which is where every account-authored skill
* definition lives, it is the whole "I saved it and it was gone" report: the
* toast said added, the list showed it, the reload did not.
*
* The read-back matters as much as the catch. A write can be accepted and then
* evicted, and a serialisation can land truncated; comparing what came back
* with what went in is the only way to know the record is really there.
*/
function persistUser() {
const payload = JSON.stringify(currentUser);
try {
localStorage.setItem(SESSION_KEY, payload);
} catch (error) {
return { persisted: false, error };
}
try {
if (localStorage.getItem(SESSION_KEY) !== payload) {
return { persisted: false, error: new Error('The session record did not survive the write.') };
}
} catch (error) {
return { persisted: false, error };
}
return { persisted: true, error: null };
}
const auth = {
/** The demo is always signed in as the seeded employer/admin user. */
async me() {
@@ -51,11 +82,7 @@ const auth = {
async updateMe(patch) {
currentUser = { ...currentUser, ...patch };
try {
localStorage.setItem(SESSION_KEY, JSON.stringify(currentUser));
} catch {
// Non-persistent session is fine.
}
persistUser();
return { ...currentUser };
},
@@ -72,9 +99,18 @@ const auth = {
return { ...DEMO_USER.preferences, ...(currentUser.preferences || {}) };
},
/** Merges into the stored preferences and persists with the rest of the user. */
/**
* Merges into the stored preferences and persists with the rest of the user.
*
* Returns the write's outcome alongside the record, rather than the record
* alone. Preferences are where account-authored skills live, so "did this
* survive the reload" is a question the caller has to be able to answer —
* see `persistUser`.
*/
async updatePreferences(patch) {
return auth.updateMe({ preferences: { ...auth.preferences(), ...patch } });
currentUser = { ...currentUser, preferences: { ...auth.preferences(), ...patch } };
const write = persistUser();
return { user: { ...currentUser }, ...write };
},
isAuthenticated() {

View File

@@ -2,6 +2,7 @@ import * as React from 'react';
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ResponseDocument } from './ResponseBlocks';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
/**
* The thinking state, shown between sending and the first block arriving.
@@ -12,9 +13,7 @@ import { ResponseDocument } from './ResponseBlocks';
export function ThinkingIndicator() {
return (
<div className="flex items-center gap-2">
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-krow-blue-tint text-krow-blue">
<Sparkles className="h-3 w-3" aria-hidden="true" />
</span>
<OwliverAvatar className="h-6 w-6" rounded="rounded-full" />
<span className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-2.5 py-2 shadow-xs">
{[0, 1, 2].map((i) => (
<span
@@ -53,9 +52,7 @@ export const Message = React.memo(
return (
<div className="animate-slide-up">
<div className="mb-2 flex items-center gap-1.5">
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-krow-blue-tint text-krow-blue">
<Sparkles className="h-2.5 w-2.5" aria-hidden="true" />
</span>
<OwliverAvatar className="h-5 w-5" rounded="rounded-full" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
</div>

View File

@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils';
import KrowAssistant from './KrowAssistant';
import { useAssistantPanel } from './AssistantPanelContext';
import { ResizeDivider } from './ResizeDivider';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
const EXPANDED_WIDTH = 620;
/**
@@ -68,9 +69,7 @@ function CollapsedTrigger({ page, onRestore }) {
hover:border-krow-blue/40 hover:shadow-lg
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-white overflow-hidden shadow-brand border border-border">
<img src="/krow logo.jpeg" alt="KROW Logo" className="h-full w-full object-cover" />
</span>
<OwliverAvatar className="h-7 w-7" rounded="rounded-full" />
<span className="text-body-sm font-semibold text-ink-1">Owliver</span>
<PanelLeftOpen className="h-3.5 w-3.5 shrink-0 text-ink-4 group-hover:text-krow-blue" aria-hidden="true" />
</button>

View File

@@ -23,6 +23,7 @@ import { useAssistantPanel } from './AssistantPanelContext';
import { usePageContext } from './PageContext';
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
import { buildIntro, buildPrompts } from './dynamic';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
import { PromptChips } from './PromptChips';
@@ -613,9 +614,7 @@ export default function KrowAssistant({
>
{/* Header */}
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-white overflow-hidden shadow-brand border border-border">
<img src="/krow logo.jpeg" alt="KROW Logo" className="h-full w-full object-cover" />
</span>
<OwliverAvatar className="h-7 w-7" rounded="rounded-lg" />
<div className="min-w-0 flex-1">
<h2 className="font-heading text-body-sm font-semibold leading-tight text-ink-1">Owliver</h2>

View File

@@ -12,8 +12,9 @@ import {
resolveDraft, weightsFromQuestion, weightsReply, weightsUnchangedReply, whichDraftReply,
} from '@/lib/skills/draftFlow';
import {
appliedToday, askWhichPosition, assignmentPreview, assignmentToExecute, availability,
candidateDetail, candidateMatches, candidateRoute, interviewReady, matchWorkforceIntent,
appliedToday, askHeadcount, askWhichPosition, assignmentPreview, assignmentToExecute,
availability, candidateDetail, candidateMatches, candidateRoute, headcountFrom,
interviewReady, matchWorkforceIntent,
namedAssignmentPreview,
interviewPreview, interviewToExecute,
namedAssignmentToExecute, positionPriority, resolveCandidate, resolvePosition,
@@ -303,6 +304,22 @@ function resolveWorkforce(question, workforce) {
: { kind: 'answer-doc', ...assignmentPreview(position, context) };
}
/**
* The answer to "how many people do you need?".
*
* The only write in the workforce path that is not an assignment, and it
* follows the same contract: the routing layer decides *what* should happen
* and hands it back, and `useAssistant` performs it through the mutation the
* Create Position form already uses. A sentence with no number in it is not a
* failure — it is the question being asked again.
*/
if (intent === 'set_headcount') {
const count = headcountFrom(question);
return count
? { kind: 'workforce', headcount: { position, count } }
: { kind: 'answer-doc', ...askHeadcount(position, context) };
}
/**
* Interview setup, for one named person against this role. Both steps resolve
* the name the same way assignment does, so the conversation stays on the same

View File

@@ -15,7 +15,8 @@ import {
publishedReply, weightsSetReply, weightsUnchangedReply,
} from '@/lib/skills/draftFlow';
import {
assignmentDone, assignmentFailed, assignmentFollowUp, interviewDone, interviewFailed,
assignmentDone, assignmentFailed, assignmentFollowUp, headcountFailed, headcountSet,
interviewDone, interviewFailed,
} from '@/lib/skills/workforceFlow';
import {
newConversationId, readHistory, removeConversation, saveConversation,
@@ -517,6 +518,34 @@ export function useConversation({
};
}
/**
* The headcount a position never stated, supplied by the reader.
*
* Written through `useUpdateJobPosting` — the same mutation the Create
* Position form and the Positions page call — so there is one position
* record and one demand figure, not a number the panel remembers. The reply
* is the assignment proposal the reader originally asked for, re-derived
* against the saved record rather than against the number they typed, so
* what they see is what was actually stored.
*/
if (intent.kind === 'workforce' && intent.headcount) {
const { position: target, count } = intent.headcount;
let saved = null;
try {
saved = await onUpdatePosition?.(target.id, { headcount: count });
} catch {
saved = null;
}
intent = {
...intent,
kind: 'answer-doc',
...(saved
? headcountSet(saved, workforce?.context || {})
: headcountFailed(target)),
};
}
/**
* The interview step. Same contract as the assignment above: it runs before
* the reply, only on a confirmed turn, and through the app's existing

View File

@@ -1,14 +1,13 @@
import { Image } from '@/components/ui/image';
import React from 'react';
const OWLIVER_URL = 'https://media.base44.com/images/public/6a653ab9436c05c43a00994f/620b75f07_WhatsAppImage2026-07-31at35919PM.jpeg';
const OWLIVER_URL = '/owliver-avatar.png';
export default function OwliverAvatar({ className = 'w-16 h-16', rounded = 'rounded-full' }) {
return (
<Image
<img
src={OWLIVER_URL}
alt="Owliver"
fittingType="fill"
className={`shrink-0 overflow-hidden ${rounded} ${className}`}
className={`shrink-0 overflow-hidden object-cover ${rounded} ${className}`}
/>
);
}

View File

@@ -4,7 +4,7 @@ import { Image } from '@/components/ui/image';
import { Sparkles, X } from 'lucide-react';
import Owliver from '@/pages/Owliver';
const OWLIVER_URL = 'https://media.base44.com/images/public/6a653ab9436c05c43a00994f/2005ea00e_WhatsAppImage2026-07-31at35919PM.jpeg';
const OWLIVER_URL = '/owliver-avatar.png';
export default function OwliverChatBubble({ open: externalOpen, onOpenChange, showTrigger = true }) {
const [internalOpen, setInternalOpen] = useState(false);

View File

@@ -1,9 +1,13 @@
import React from 'react';
import { Sparkles, Upload } from 'lucide-react';
import { Button, Field, Input, Modal, Textarea, toast } from '@/components/ds';
import { Button, Field, Input, Modal, Textarea } from '@/components/ds';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import { PAGE_KEYS, validateSkillSource } from '@/lib/skills/registry';
import { skillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
import {
isReadableDefinition, normalizeUpload, owliverFieldsFromSource, patchFrontmatter,
} from '@/lib/skills/skillFields';
import { reportSave } from '@/lib/skills/saveFeedback';
/**
* Add an Owliver skill.
@@ -41,27 +45,71 @@ export function AddSkillDialog({ open, onOpenChange }) {
setError('');
}, [open]);
/* The fields drive the Markdown until the author edits the Markdown, after
which the Markdown wins — it is the thing being saved. */
/**
* A field edit, written into the definition.
*
* A fresh draft is composed from the template; once there is a definition to
* preserve — an uploaded file, or Markdown typed by hand — the field patches
* the key it owns and leaves everything else alone. It used to stop here
* entirely, so after an upload these four boxes edited nothing that was saved.
*/
const syncFromMeta = (next) => {
setMeta(next);
if (touched) return;
setSource(skillTemplate({
id: next.id,
name: next.name,
description: next.description,
pages: next.pages.split(',').map((p) => p.trim()).filter(Boolean),
const pages = next.pages.split(',').map((p) => p.trim()).filter(Boolean);
if (!touched) {
setSource(skillTemplate({
id: next.id,
name: next.name,
description: next.description,
pages,
}));
return;
}
setSource(patchFrontmatter(source, {
id: next.id || undefined,
name: next.name || undefined,
description: next.description || undefined,
pages: pages.length ? pages : undefined,
}));
};
/**
* A definition uploaded from a file.
*
* Filled the Markdown box and left every field beside it empty, and said
* nothing about whether the file was usable until Add was pressed. Both are
* fixed here: the frontmatter fills the fields, and an unusable file says so
* at the moment it arrives.
*/
const readFile = (event) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onerror = () => setError('That file could not be read.');
reader.onload = () => {
setSource(String(reader.result));
const raw = normalizeUpload(reader.result);
setSource(raw);
setTouched(true);
setError('');
/* Nothing readable: the fields stay as they were rather than emptying to
a row of placeholders that read as values. */
if (!isReadableDefinition(raw)) {
setError('That file has no frontmatter block, so there is nothing to read into the fields. '
+ 'A definition starts with `---` on its own line.');
return;
}
setError(validateSkillSource(raw) || '');
const fields = owliverFieldsFromSource(raw);
setMeta({
id: fields.id,
name: fields.name,
description: fields.description,
pages: fields.pages.join(', '),
});
};
reader.readAsText(file);
event.target.value = '';
@@ -76,8 +124,7 @@ export function AddSkillDialog({ open, onOpenChange }) {
const { skill, next } = upsertCustomSkill(preferences.customSkills || [], source);
updatePreferences.mutate({ customSkills: next });
toast.success(`${skill.name} added`);
updatePreferences.mutate({ customSkills: next }, reportSave(`${skill.name} added`));
onOpenChange(false);
};
@@ -101,14 +148,14 @@ export function AddSkillDialog({ open, onOpenChange }) {
<Field label="Skill name">
<Input
value={meta.name}
placeholder="Chef Training"
placeholder="e.g. Chef Training"
onChange={(e) => syncFromMeta({ ...meta, name: e.target.value })}
/>
</Field>
<Field label="Skill ID" hint="Lower-case, dashes.">
<Input
value={meta.id}
placeholder="chef-training"
placeholder="e.g. chef-training"
onChange={(e) => syncFromMeta({ ...meta, id: e.target.value })}
/>
</Field>
@@ -117,7 +164,7 @@ export function AddSkillDialog({ open, onOpenChange }) {
<Field label="Description">
<Input
value={meta.description}
placeholder="Helps Owliver explain chef training paths."
placeholder="e.g. Helps Owliver explain chef training paths."
onChange={(e) => syncFromMeta({ ...meta, description: e.target.value })}
/>
</Field>
@@ -142,8 +189,26 @@ export function AddSkillDialog({ open, onOpenChange }) {
className="font-mono text-[12px] leading-relaxed"
placeholder="---&#10;id: my-skill&#10;name: My Skill&#10;pages:&#10; - positions&#10;---"
onChange={(e) => {
setSource(e.target.value);
const next = e.target.value;
setSource(next);
setTouched(true);
/* Markdown → fields, the direction that was missing. Same
extractor the upload path uses; guarded so a half-typed
definition cannot wipe fields that are already filled in.
See the fuller note in the two skill editors. */
if (isReadableDefinition(next)) {
const fields = owliverFieldsFromSource(next);
if (fields.name) {
setMeta({
id: fields.id,
name: fields.name,
description: fields.description,
pages: fields.pages.join(', '),
});
}
}
if (error) setError('');
}}
/>
@@ -154,7 +219,9 @@ export function AddSkillDialog({ open, onOpenChange }) {
<Button variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
<Upload aria-hidden="true" /> Upload .md
</Button>
<p className="text-caption text-ink-4">Markdown is stored as data. It is never executed.</p>
<p className="text-caption text-ink-4">
Stored as data in this browser, for this workspace. It is never executed.
</p>
</div>
</div>
</Modal>

View File

@@ -42,13 +42,21 @@ export function usePreferences() {
return { ...base44.auth.preferences(), ...(user?.preferences || {}) };
}
/** Writes one or more preferences and refreshes every consumer of `['user']`. */
/**
* Writes one or more preferences and refreshes every consumer of `['user']`.
*
* The mutation resolves to `{ user, persisted, error }`, not to the user — a
* caller storing something it needs back after a reload, which is every caller
* writing `customSkills`, has to be able to tell a write that landed from one
* the browser refused. The cache is updated either way: the change is real for
* this session even when it could not be stored.
*/
export function useUpdatePreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updatePreferences(patch),
onSuccess: (user) => {
queryClient.setQueryData(['user'], user);
onSuccess: /** @param {any} result */ (result) => {
queryClient.setQueryData(['user'], result.user);
queryClient.invalidateQueries({ queryKey: ['user'] });
},
});

View File

@@ -31,7 +31,16 @@ const frontMatter = ({ id, name, description, pages, fallback }) => [
/** A definition that draws a section on the pages it names. */
export const uiSkillTemplate = ({
id = '', name = '', description = '', pages = [],
type = 'flow', placement = '', source = 'position.activity', periods = [],
/**
* `candidates.activity`, not `position.activity`.
*
* The default placement on the default page is `after-position-list-summary`
* — above the grid, with no position in context — so the old default composed
* a definition that drew a titled card and then reported that it needed a
* record the page never had. A template must produce something that works
* before it is edited, so the default reads a source that needs nothing.
*/
type = 'flow', placement = '', source = 'candidates.activity', periods = [],
} = {}) => `---
${frontMatter({
id,

View File

@@ -1,8 +1,11 @@
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
import { parseYaml } from './yaml';
import { normalizeSkillUi } from './uiConfig';
import { normalizeSkillUi, slugify } from './uiConfig';
import { normalizeSkillOwliver } from './owliverConfig';
import { SUPPORTED_SKILL_PAGES, canonicalPage, surfaceFor, surfaceForRoute } from './surfaces';
import {
SUPPORTED_SKILL_PAGES, canonicalPage, contextLabel, placementProvides, surfaceFor,
surfaceForRoute,
} from './surfaces';
/**
* Owliver skill registry.
@@ -30,14 +33,39 @@ const FILES = import.meta.glob('/src/skills/**/*.md', { query: '?raw', import: '
* A file whose frontmatter cannot be read raises rather than registering a
* half-understood definition; `parseSkill` decides what to do with that.
*/
function parseFrontmatter(raw) {
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
if (!match) return { data: {}, body: raw };
/**
* A definition's text, as the parser needs to see it.
*
* Files arrive from editors, from Windows, from copy-paste and from downloads,
* and four of the things they arrive with used to take the entire frontmatter
* block down: a UTF-8 byte-order mark before the opening fence, a blank line
* above it, `\r\n` line endings, and trailing spaces after `---`. In every one
* of those cases the fence did not match, `parseFrontmatter` returned an empty
* record, and the definition registered as `Untitled skill` with no pages —
* the file was read, and none of it was believed.
*
* None of this is a lenient parser: the YAML subset inside the fences is as
* strict as it ever was. This is only about recognising that a fence is a
* fence.
*/
export const normalizeDefinition = (raw) => String(raw ?? '')
.replace(/^\uFEFF/, '')
.replace(/\r\n?/g, '\n')
.replace(/^\s*\n+/, '');
/** Whether this text opens with a frontmatter block at all. */
export const hasFrontmatter = (raw) => /^---[ \t]*\n[\s\S]*?\n---[ \t]*(?=\n|$)/
.test(normalizeDefinition(raw));
export function parseFrontmatter(raw) {
const text = normalizeDefinition(raw);
const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?=\n|$)/.exec(text);
if (!match) return { data: {}, body: text };
const data = parseYaml(match[1]);
return {
data: data && typeof data === 'object' && !Array.isArray(data) ? data : {},
body: raw.slice(match[0].length).trim(),
body: text.slice(match[0].length).trim(),
};
}
@@ -261,8 +289,21 @@ export function skillFacets({ data = {}, kind, ui = {}, owliver, conversation =
export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
{
const { data, body } = parseFrontmatter(raw);
const pages = Array.isArray(data.pages) ? data.pages : [];
const id = data.id || path.split('/').pop().replace(/\.md$/, '');
const declaredPages = Array.isArray(data.pages) ? data.pages : [];
/**
* The definition's id.
*
* `id:` when it is written, and it always wins — an explicit id is an
* address other definitions and stored preferences refer to, and deriving
* over the top of one would silently rename a skill.
*
* The fallback used to be the filename, which is right for a file in
* `src/skills/` and wrong for everything else: an uploaded definition is
* parsed with the placeholder path `custom`, so a file omitting `id:` was
* registered as the skill `custom` and the ID field filled in with the word
* "custom". Slugging the name is what an author means by leaving it out.
*/
const id = data.id || slugify(data.name) || path.split('/').pop().replace(/\.md$/, '');
const levels = sectionLevels(body);
/**
@@ -280,10 +321,21 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
closed vocabulary in `surfaces.js`. A definition with no `ui:` block is
exactly what it was before this existed. */
const { ui, errors: uiErrors } = normalizeSkillUi(data.ui, {
declaredPages: pages,
declaredPages,
skillId: id,
});
/**
* Where this definition applies.
*
* `pages:` when it is written. When it is not, the pages its `ui:` entries
* name — because a definition that says "put this on Positions and that on
* Analytics" has already declared its reach, and making it repeat the list
* above the block is the format asking twice. A definition that declares
* neither still has none, which is what `validateSkillSource` refuses on.
*/
const pages = declaredPages.length ? declaredPages : Object.keys(ui);
/* The same definition's second consumer. `owliver:` declares what can be
asked for in the panel, reading the source the page section already
names — so one file answers "what does this page show" and "what can
@@ -474,6 +526,27 @@ export function readSkillRegistry(customSources = []) {
}
}
/**
* A section that registered perfectly and can never draw anything.
*
* The same check `validateSkillSource` refuses on, run again at load — because
* a definition stored before the rule existed was validated under the old one
* and is never re-checked. Without this it stays in the workspace as a titled
* card reporting that it needs a record the page has no way of giving it, and
* the Skills page calls the workspace healthy.
*/
for (const skill of skills) {
for (const problem of unresolvableSections(skill)) {
diagnostics.push({
level: 'error',
kind: 'unresolvable',
path: skill.path,
skillId: skill.id,
message: problem.message,
});
}
}
/**
* Two definitions claiming one phrase on one page.
*
@@ -530,6 +603,44 @@ export function skillDiagnostics(customSources = []) {
return readSkillRegistry(customSources).diagnostics;
}
/**
* Sections that can never resolve where they are attached.
*
* A source declares the record it needs; a placement either hands one over or
* does not. Nothing compared the two, so the commonest authoring mistake in the
* product — `position.activity`, the Board editor's own default, on a page with
* no position — validated cleanly, registered, drew its title and then reported
* "This section needs a position to read" for good. The definition was never
* wrong about anything the product had told it to care about.
*
* Deliberately *only* the `ui:` half. An Owliver response with an unmet need is
* not a dead panel: `resolveEntity` asks which position is meant, and answers
* once told — which is why `hiring-activity-assistant` reads `position.activity`
* on Positions and works. A card cannot ask. That asymmetry is the reason one
* is refused and the other is left alone.
*/
export function unresolvableSections(skill) {
const problems = [];
for (const [page, config] of Object.entries(skill?.ui || {})) {
for (const section of config.sections || []) {
if (!section.context) continue;
if (placementProvides(page, section.placement).includes(section.context)) continue;
problems.push({
page,
placement: section.placement,
source: section.source,
message: `\`${section.source}\` needs ${contextLabel(section.context)} to read, and `
+ `\`${page}\` supplies none at \`${section.placement}\`. `
+ `Attach it to a placement that does, or read a source that needs nothing.`,
});
}
}
return problems;
}
/**
* Validates a definition before it is stored. Returns an error string or null.
*
@@ -565,6 +676,29 @@ export function validateSkillSource(raw) {
something Owliver offers and then cannot answer. */
if (skill.owliverErrors?.length) return skill.owliverErrors[0];
/* A section attached where its source can never be read. See
`unresolvableSections` — this is the difference between a definition that
is wrong and one that merely looks right. */
const unresolvable = unresolvableSections(skill);
if (unresolvable.length) return unresolvable[0].message;
/**
* An Owliver block that can answer nothing.
*
* `owliver: enabled: true` with no capabilities is what the template produces
* before an author fills anything in. It registers, claims its own name as a
* trigger, can take a question from a definition written to answer it, and
* then reads its own description back. Refused here rather than saved and
* reported later as "the skill does not work".
*/
if (skill.owliver?.enabled
&& !skill.owliver.capabilities.length
&& !skill.conversation.length
&& !skill.actions.length) {
return 'This skill declares no capabilities, so Owliver could only read its description '
+ 'back. Add a capability, or remove the `owliver:` block.';
}
return null;
}

View File

@@ -0,0 +1,32 @@
import { toast } from '@/components/ds';
/**
* Mutation options that tell the truth about whether a write survived.
*
* Every skill save reported success the moment the in-memory record changed,
* because that is all `updatePreferences` used to be able to report. A browser
* that refused the write — quota, private browsing, an eviction — produced a
* green toast, a populated list, and nothing at all after a reload. A definition
* an author spent ten minutes on disappeared with no event they could point at.
*
* The change is not made conditional on the write: it is real for this session
* either way, and refusing to apply it would be worse. What changes is that the
* author is told which of the two happened while they can still do something
* about it.
*/
export function reportSave(message) {
return {
/** @param {any} result */
onSuccess: (result) => {
if (result?.persisted === false) {
toast.error(
`${message} — but this browser would not store it, so it will be gone on reload. `
+ 'Export it from the Skills page first.'
);
return;
}
toast.success(message);
},
onError: () => toast.error('That could not be saved.'),
};
}

View File

@@ -0,0 +1,406 @@
import { hasFrontmatter, normalizeDefinition, parseFrontmatter, parseSkill } from './registry';
/**
* A definition, read into editor fields — and edited fields, written back.
*
* Two things used to be missing here, and between them they are the whole of
* "uploading a `.md` does nothing" and "the form fields do not work":
*
* - **Nothing read a file into the fields.** All three upload buttons set the
* Markdown and stopped, so an author who uploaded a complete definition was
* looking at a form full of empty boxes beside it. The two editors each had
* a reader that did exactly this job — `metaFromSource` and
* `draftFromSource` — and neither was reachable from the upload path. They
* live here now, so one file is read the same way wherever it arrives.
* - **Nothing wrote the fields back.** Both editors regenerated the whole
* definition from a template while the Markdown was untouched, and stopped
* the moment it was touched — which is every edit of an existing skill,
* because `touched` starts true there. Typing a new name updated React
* state that nothing saved. `patchFrontmatter` is the missing half: a field
* edits the key it owns, in place, and the body is never rewritten.
*
* Everything here reads the *registered* form of a definition — what
* `parseSkill` made of it — rather than the raw text. The form therefore shows
* what the product will actually do, including the placement an author left out
* and the vocabulary that filled it in.
*/
/* ── Reading a definition into fields ───────────────────────────────────── */
/**
* A definition, or nothing.
*
* `parseSkill` is deliberately forgiving: a file with no frontmatter at all
* still parses, taking its id from the path it was given. That is right for the
* registry, and wrong here — a text file dropped on the upload button would
* otherwise fill the ID field with `custom` and the name with `Untitled skill`,
* which reads as a definition the product understood. A form is filled from a
* definition or it is left alone.
*/
function readSkill(source) {
/* `hasFrontmatter`, not a second regex of this module's own. The two used to
be written out separately and drifted: a file the registry was willing to
read could be one this rejected, and rejecting it here empties a form the
preview beside it has just filled in. One detector, one answer. */
if (!hasFrontmatter(source)) return null;
return parseSkill(source, { custom: true });
}
/**
* A definition, cleaned up on the way in.
*
* Re-exported under the name the editors use, so an uploaded file is stored in
* the form every reader already agrees on rather than carrying a byte-order
* mark into `patchFrontmatter`, which would not recognise the fence and would
* write a second one above it.
*/
export const normalizeUpload = normalizeDefinition;
/** Whether a definition can be read into the fields at all. */
export const isReadableDefinition = (source) => hasFrontmatter(source);
/** The Board (UI) editor's fields, before a definition is loaded into them. */
export const EMPTY_BOARD_FIELDS = {
id: '', name: '', description: '', pages: [],
type: 'flow', placement: '', source: 'candidates.activity', periods: [],
};
/** The Owliver editor's fields, before a definition is loaded into them. */
export const EMPTY_OWLIVER_FIELDS = {
id: '', name: '', description: '', pages: [],
triggers: [], suggestions: [], capabilities: [], source: '', periods: [],
};
/**
* A Board skill, read back into the fields that compose one.
*
* The section comes from the registry's reading rather than from the Markdown
* text, so a definition that omitted `placement:` shows the placement it will
* actually render at.
*/
export function boardFieldsFromSource(source) {
try {
const skill = readSkill(source);
if (!skill) throw new Error('no frontmatter');
/* The first section, in declaration order — what the single-valued fields
can show. `pages` still carries every page the definition reaches, and
`uiIsEditableFromFields` is what stops those fields writing over the
ones this record cannot represent. */
const section = Object.values(skill.ui || {}).flatMap((p) => p.sections || [])[0];
return {
id: skill.id,
name: skill.name,
description: skill.description,
pages: skill.pages,
type: section?.type || EMPTY_BOARD_FIELDS.type,
placement: section?.placement || '',
source: section?.source || EMPTY_BOARD_FIELDS.source,
periods: section?.periods || [],
};
} catch {
return EMPTY_BOARD_FIELDS;
}
}
/**
* An Owliver skill, read back into the fields that compose one.
*
* Reads the resolved capabilities, which includes the ones whose source was
* inherited from a `ui:` block rather than written out — so the form shows what
* Owliver can be asked for, not what the file happened to spell.
*/
export function owliverFieldsFromSource(source) {
try {
const skill = readSkill(source);
if (!skill) throw new Error('no frontmatter');
const first = skill.owliver.capabilities[0];
const response = first ? skill.owliver.responses[first] : null;
return {
id: skill.id,
name: skill.name,
description: skill.description,
pages: skill.pages,
/**
* Only the triggers the definition actually claimed.
*
* `skill.triggers` falls back to the skill's own name when none are
* declared, which is right for matching and wrong to put in a form: the
* field would fill with an inherited phrase, and the next edit would
* write it into the file as a declared one. That flips
* `declaredTriggers`, which the matcher weighs differently — and it would
* pin the *old* name's phrase the moment the skill is renamed.
*/
triggers: skill.declaredTriggers ? skill.triggers : [],
suggestions: skill.owliver.suggestions.map((s) => s.label),
capabilities: skill.owliver.capabilities,
source: response?.source || '',
periods: response?.periods || [],
};
} catch {
return EMPTY_OWLIVER_FIELDS;
}
}
/**
* Which management surfaces this definition belongs to, for the upload handoff.
*
* A file dropped into the Board editor that declares only an `owliver:` block
* is being edited in the wrong half of the product. Knowing that lets the
* editor offer the other one rather than showing a form none of whose fields
* apply.
*/
export function facetsFromSource(source) {
try {
return readSkill(source)?.facets || [];
} catch {
return [];
}
}
/**
* Which of the `ui:` shapes a definition uses.
*
* `shorthand` is one section applying to every declared page — the form the
* editors compose, and the only one whose four fields can represent the whole
* block. `list` and `per-page` can each hold several sections across several
* pages, so the fields would have to throw away everything but the first to
* write them back. Telling them apart is what lets the editor show a multi-page
* definition without being able to flatten it.
*/
export function uiShape(source) {
let data;
try {
({ data } = parseFrontmatter(String(source)));
} catch {
return 'none';
}
const ui = data?.ui;
if (!ui || typeof ui !== 'object') return 'none';
if (Array.isArray(ui)) return ui.length ? 'list' : 'none';
const SECTION_KEYS = ['type', 'source', 'data', 'placement', 'position', 'periods', 'title'];
return SECTION_KEYS.some((key) => key in ui) ? 'shorthand' : 'per-page';
}
/** Whether the section fields can write this definition's `ui:` block back. */
export const uiIsEditableFromFields = (source) => uiShape(source) !== 'list'
&& uiShape(source) !== 'per-page';
/**
* Whether this definition's pages come from its `ui:` block rather than a
* `pages:` key of its own.
*
* When they do, the Pages field is a read-out and must not be written back.
* Writing it would add a `pages:` list that agrees with the block today and
* silently governs it tomorrow: `normalizeSkillUi` refuses an entry naming a
* page that `pages:` does not list, so the next entry the author adds to the
* block would be rejected by a key they never wrote. A derived value is shown,
* not owned.
*/
export function pagesAreDerived(source) {
let data;
try {
({ data } = parseFrontmatter(String(source)));
} catch {
return false;
}
if (Array.isArray(data?.pages) && data.pages.length) return false;
return uiShape(source) !== 'none';
}
/* ── Writing fields back into the frontmatter ───────────────────────────── */
/**
* A scalar, written so the parser reads back what was meant.
*
* Quoted whenever the plain form would be read as something else — a value
* containing `:` or `#`, one with edge whitespace, or one that looks like a
* number, a boolean or null but is a string.
*/
function writeScalar(value) {
if (value === null || value === undefined) return 'null';
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
const text = String(value);
const ambiguous = text === ''
|| /[:#]/.test(text)
|| text !== text.trim()
|| /^(true|false|null|~)$/.test(text)
|| /^-?\d+$/.test(text)
|| /^-?\d*\.\d+$/.test(text)
|| /^['"-]/.test(text);
return ambiguous ? `'${text.replace(/'/g, "''")}'` : text;
}
/**
* A value, as the block lines that follow its key.
*
* Returns `null` for a scalar, which is written on the key's own line instead.
* Only the subset in `yaml.js` is emitted — block maps and block sequences,
* nested to any depth — because that is the only subset the parser reads back.
*/
function writeBlock(value, indent) {
const pad = ' '.repeat(indent);
if (Array.isArray(value)) {
return value.map((item) => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
const entries = Object.entries(item).filter(([, v]) => v !== undefined);
if (!entries.length) return `${pad}- {}`;
return entries
.map(([k, v], i) => {
const prefix = i === 0 ? `${pad}- ` : `${pad} `;
const nested = writeBlock(v, indent + 4);
return nested === null ? `${prefix}${k}: ${writeScalar(v)}` : `${prefix}${k}:\n${nested}`;
})
.join('\n');
}
return `${pad}- ${writeScalar(item)}`;
}).join('\n');
}
if (value && typeof value === 'object') {
return Object.entries(value)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => {
const nested = writeBlock(v, indent + 2);
return nested === null ? `${pad}${k}: ${writeScalar(v)}` : `${pad}${k}:\n${nested}`;
})
.join('\n');
}
return null;
}
/** The lines belonging to the key at `start`: everything indented under it. */
function blockEnd(lines, start, indent) {
let end = start + 1;
while (end < lines.length) {
const line = lines[end];
if (line.trim() === '' || /^\s*#/.test(line)) { end += 1; continue; }
if (line.match(/^\s*/)[0].replace(/\t/g, ' ').length <= indent) break;
end += 1;
}
/* Trailing blanks and comments belong to whatever comes next, not to this
key — a comment written above the following key must not be swallowed by
the block above it. */
while (end > start + 1 && lines[end - 1].trim() === '') end -= 1;
return end;
}
/** The index of `key` at `indent` within `[from, to)`, or -1. */
function findKey(lines, key, indent, from, to) {
const pattern = new RegExp(`^${' '.repeat(indent)}${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
for (let i = from; i < to; i += 1) {
if (pattern.test(lines[i])) return i;
}
return -1;
}
/**
* One dotted path, set in a frontmatter line array.
*
* Scalars replace the value on their own line; anything else replaces the block
* beneath it. A key that is not there is appended to the end of its parent, so
* a definition that never declared `status:` gains one rather than being
* refused. Every line the path does not touch — including comments and key
* order — is left exactly as written, which is why this is a patch and not a
* re-serialisation.
*/
function setPath(lines, path, value) {
const parts = path.split('.');
let from = 0;
let to = lines.length;
let indent = 0;
for (let depth = 0; depth < parts.length - 1; depth += 1) {
const at = findKey(lines, parts[depth], indent, from, to);
/* A parent that does not exist is created empty, then descended into. */
if (at === -1) {
lines.splice(to, 0, `${' '.repeat(indent)}${parts[depth]}:`);
from = to + 1;
to = from;
indent += 2;
continue;
}
const end = blockEnd(lines, at, indent);
from = at + 1;
to = end;
indent += 2;
}
const key = parts[parts.length - 1];
const at = findKey(lines, key, indent, from, to);
const block = writeBlock(value, indent + 2);
const replacement = block === null
? [`${' '.repeat(indent)}${key}: ${writeScalar(value)}`]
: [`${' '.repeat(indent)}${key}:`, ...block.split('\n')];
if (at === -1) {
lines.splice(to, 0, ...replacement);
return replacement.length;
}
const end = blockEnd(lines, at, indent);
lines.splice(at, end - at, ...replacement);
return replacement.length - (end - at);
}
/**
* A definition with some frontmatter keys changed, and nothing else touched.
*
* `patch` is keyed by dotted path — `name`, `pages`, `ui.source`,
* `owliver.capabilities` — and a value of `undefined` leaves that path alone.
* `null` writes `null`; to remove a key, pass `REMOVE`.
*
* The body below the closing `---` is never read and never rewritten. That is
* the property that makes this safe to run on every keystroke: an author's
* prose, their comments and the order they wrote their keys in all survive a
* change to a field they did not write.
*/
export const REMOVE = Symbol('remove');
export function patchFrontmatter(source, patch = {}) {
/* Normalized first, for the same reason the parser is: a fence this does not
recognise is a fence it writes a second copy of. */
const raw = normalizeDefinition(source);
const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?=\n|$)/.exec(raw);
/* No frontmatter to patch: give the definition one rather than silently
dropping the edit. */
if (!match) {
const lines = [];
for (const [path, value] of Object.entries(patch)) {
if (value === undefined || value === REMOVE) continue;
setPath(lines, path, value);
}
return `---\n${lines.join('\n')}\n---\n\n${raw.trim()}\n`;
}
const lines = match[1].split('\n');
for (const [path, value] of Object.entries(patch)) {
if (value === undefined) continue;
if (value === REMOVE) {
const parts = path.split('.');
const indent = (parts.length - 1) * 2;
/* Only a top-level or one-deep key is removable, which is every key the
editors own. */
let from = 0;
let to = lines.length;
if (parts.length > 1) {
const parent = findKey(lines, parts[0], 0, 0, lines.length);
if (parent === -1) continue;
from = parent + 1;
to = blockEnd(lines, parent, 0);
}
const at = findKey(lines, parts[parts.length - 1], indent, from, to);
if (at !== -1) lines.splice(at, blockEnd(lines, at, indent) - at);
continue;
}
setPath(lines, path, value);
}
return `---\n${lines.join('\n')}\n---${raw.slice(match[0].length)}`;
}

View File

@@ -27,6 +27,7 @@ export const SKILL_SURFACES = [
label: 'Control Center',
route: '/admin',
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'positions',
@@ -53,6 +54,26 @@ export const SKILL_SURFACES = [
'after-candidates',
'before-footer',
],
/**
* What each of those placements actually hands a section, read off the
* `<SkillSurface>` call sites rather than assumed from the surface.
*
* The distinction is the whole point: the two list placements render above
* and below the grid with no position in context, while every other one
* renders inside a card, a drawer or the position page and passes the
* record. A section reading `position.*` is answerable at one and not at
* the other, and until this was written down both validated identically.
*/
provides: {
'after-position-list-summary': [],
'after-position-list': [],
'after-header': ['positionId'],
'after-position-card': ['positionId'],
'after-position-summary': ['positionId'],
'before-candidates': ['positionId'],
'after-candidates': ['positionId'],
'before-footer': ['positionId'],
},
},
{
/* The authoring form, which is a surface in its own right: what a skill has
@@ -69,12 +90,24 @@ export const SKILL_SURFACES = [
'after-vetting-weights',
'before-footer',
],
/* The draft in the form is the position, so every placement here supplies
one — which is what lets `position.vetting` be read and written while the
role is still being specified. */
provides: {
'after-header': ['positionId'],
'after-job-description': ['positionId'],
'after-vetting-weights': ['positionId'],
'before-footer': ['positionId'],
},
},
{
id: 'candidates',
label: 'Candidates',
route: '/admin/candidates',
placements: ['after-header', 'after-candidate-summary', 'before-footer'],
/* Only the summary placement renders against one person — it is mounted on
the candidate profile, not on the list. */
provides: { 'after-candidate-summary': ['candidateId'] },
},
{
id: 'hired-history',
@@ -82,12 +115,15 @@ export const SKILL_SURFACES = [
route: '/admin/hired',
aliases: ['hired'],
placements: ['after-header', 'before-footer'],
/* `before-footer` is inside the record drawer; `after-header` is the page. */
provides: { 'before-footer': ['candidateId'] },
},
{
id: 'talent-pool',
label: 'Talent Pool',
route: '/admin/talent-pool',
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'krow-forge',
@@ -95,18 +131,21 @@ export const SKILL_SURFACES = [
route: '/admin/university',
aliases: ['university', 'forge'],
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'analytics',
label: 'Analytics',
route: '/admin/analytics',
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'activity',
label: 'Activity',
route: '/admin/activity',
placements: ['after-header', 'before-footer'],
provides: {},
},
/* Not in the eight product surfaces, but skills already attach to it and the
account page reads them. Kept so nothing that works today stops working. */
@@ -115,12 +154,14 @@ export const SKILL_SURFACES = [
label: 'Profile',
route: '/admin/profile',
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'candidates-analysis',
label: 'Candidate Analysis',
route: '/admin/candidates-analysis',
placements: ['after-header', 'before-footer'],
provides: {},
},
];
@@ -133,12 +174,111 @@ for (const surface of SKILL_SURFACES) {
/** Every name a definition may use for a surface, for error messages. */
export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
/** The surface a declared page name refers to, or null. */
export const surfaceFor = (page) => BY_KEY.get(String(page || '').trim()) || null;
/**
* The surface a declared page name refers to, or null.
*
* Matched on the normalized name, not the literal one. A definition writing
* `Positions` or `Talent Pool` — which is how the page is spelled everywhere in
* the product — used to resolve to nothing, and a page name that resolves to
* nothing takes the whole `ui:` block with it. The keys stay canonical; only
* what an author may type to reach them widens.
*/
const normalizeKey = (page) => String(page ?? '')
.trim()
.toLowerCase()
.replace(/[\s_]+/g, '-');
export const surfaceFor = (page) => BY_KEY.get(normalizeKey(page)) || null;
/**
* Placement names a definition may use, beyond the canonical ones.
*
* A placement is a position in a page's layout, and the canonical names are
* written from the page's point of view — `after-position-card`. Authors write
* them from the definition's: `grid-card` is where it goes, `panel` is what it
* looks like. An alias only ever resolves to a placement the surface really
* offers, so the vocabulary stays exactly as closed as it was; what changes is
* how many ways there are to name a member of it.
*/
const PLACEMENT_ALIASES = {
'grid-card': 'after-position-card',
card: 'after-position-card',
panel: 'after-header',
top: 'after-header',
header: 'after-header',
footer: 'before-footer',
bottom: 'before-footer',
list: 'after-position-list',
summary: 'after-position-summary',
};
/**
* The canonical placement a declared name refers to on this surface, or null.
*
* An alias that points at a placement this surface does not offer resolves to
* null rather than to some other surface's placement — `panel` means
* `after-header` where there is one and nothing where there is not.
*/
export function placementFor(page, placement) {
const surface = surfaceFor(page);
if (!surface) return null;
const declared = normalizeKey(placement);
if (!declared) return null;
if (surface.placements.includes(declared)) return declared;
const aliased = PLACEMENT_ALIASES[declared];
return aliased && surface.placements.includes(aliased) ? aliased : null;
}
/** The canonical id for a declared page name — `hired` → `hired-history`. */
export const canonicalPage = (page) => surfaceFor(page)?.id || null;
/**
* What a placement hands a section, as the page actually mounts it.
*
* A source declares the record it needs — a position, a candidate, or nothing —
* and until this existed nothing compared that need against the pages a
* definition named. So a section reading `position.activity` could be attached
* to Analytics, validate cleanly, register, draw its title, and then report
* "This section needs a position to read" forever. That is the whole of the
* "the skill saved but does nothing" report, and it is an authoring mistake the
* product can catch rather than one the reader has to discover.
*
* Read per *placement*, never per surface: the Positions grid renders one
* section inside each card, with a position, and two more above and below the
* grid, with none. Both are `positions`.
*
* With no placement named, a definition is answered for the surface's default —
* `surface.placements[0]`, which is what `normalizeSection` fills in.
*/
export function placementProvides(page, placement = null) {
const surface = surfaceFor(page);
if (!surface) return [];
const at = String(placement || '').trim() || surface.placements[0];
return surface.provides?.[at] || [];
}
/**
* Everything the declared pages can supply at this placement, deduplicated.
*
* One page supplying a position is enough — a definition naming several pages
* is saying it belongs on all of them, and refusing it because one cannot
* answer would refuse the definition for the pages that can. The load-time
* diagnostic is where the partial case is reported.
*/
export function contextSuppliedBy(pages = [], placement = null) {
return [...new Set(
(Array.isArray(pages) ? pages : [pages])
.flatMap((page) => placementProvides(page, placement))
)];
}
/** "a position" / "a candidate", for a message an author can act on. */
export const contextLabel = (need) => (
need === 'positionId' ? 'a position' : need === 'candidateId' ? 'a candidate' : 'nothing'
);
/**
* The surface an Admin route belongs to — `/admin/positions/new` →
* `create-position`.

View File

@@ -1,8 +1,20 @@
import {
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES,
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, surfaceFor,
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, placementFor,
surfaceFor,
} from './surfaces';
/**
* A name, as an id.
*
* Exported because two places need the same rule and were carrying their own:
* a section falling back to its title, and a definition falling back to its
* name when it declares no `id:`. Two slug functions that agree today is a
* definition whose id changes the day they stop agreeing.
*/
export const slugify = (value) => String(value || '')
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
/**
* The `ui:` block of a skill definition, checked and normalized.
*
@@ -53,9 +65,7 @@ export function normalizeSection(raw, {
/* An id is how a section is keyed and de-duplicated, not something an author
should have to invent for a definition that declares exactly one. Falls
back to the title, then to the skill's own id. */
const slug = (value) => String(value || '')
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const id = slug(raw.id) || slug(raw.title) || slug(fallbackId);
const id = slugify(raw.id) || slugify(raw.title) || slugify(fallbackId);
if (!id) {
errors.push(`${where}: a section needs an \`id\`.`);
return null;
@@ -70,7 +80,20 @@ export function normalizeSection(raw, {
}
seen.add(id);
const type = String(raw.type || '').trim();
/**
* The shape this section is drawn with.
*
* Inferred from the source when it is not written, the same way a placement
* left out is filled in from the surface. A source declares the shapes it can
* fill, in the order they suit it, so the first is the reading its author
* would have chosen — and a definition naming a page, a placement and a
* source has already said everything that needs saying. Refusing it for the
* one field it can derive would be the format asking for ceremony.
*/
const declaredSource = String(raw.data?.source ?? raw.source ?? '').trim();
const type = String(raw.type || '').trim()
|| dataSourceFor(declaredSource)?.shapes?.find((shape) => types.includes(shape))
|| '';
if (!type) {
errors.push(`${at || `${where}.${id}`}: a section needs a \`type\`.`);
return null;
@@ -89,10 +112,16 @@ export function normalizeSection(raw, {
if (wantPlacement) {
const surface = surfaceFor(page);
const declaredPlacement = String(raw.position || raw.placement || '').trim();
placement = declaredPlacement || surface.placements[0];
if (!surface.placements.includes(placement)) {
/* `placementFor` reads the surface's own list and the alias table, so
`grid-card` and `after-position-card` are one placement written two ways
and neither resolves to something the surface does not offer. */
placement = declaredPlacement
? placementFor(page, declaredPlacement)
: surface.placements[0];
if (!placement) {
errors.push(
`Unsupported placement: ${placement} on ${page}. Supported placements: ${surface.placements.join(', ')}.`
`Unsupported placement: ${declaredPlacement} on ${page}. `
+ `Supported placements: ${surface.placements.join(', ')}.`
);
return null;
}
@@ -102,7 +131,7 @@ export function normalizeSection(raw, {
groups options when a section grows; the flat form is what a one-section
definition actually reads like, and refusing it would be the format being
precious about punctuation. */
const source = String(raw.data?.source ?? raw.source ?? '').trim();
const source = declaredSource;
if (!source) {
errors.push(`${at || `${where}.${id}`}: a section needs \`data.source\`.`);
return null;
@@ -190,6 +219,22 @@ export function normalizeSection(raw, {
* and the difference is structural — an object carrying `type` or `source` is a
* section, an object whose keys are page names is a page map. Nothing is
* decided by a skill id, and neither shape is privileged.
*
* There is a third, and it is the one people actually write:
*
* ui:
* - page: Positions
* placement: grid-card
* source: position.activity
* - page: Analytics
* placement: panel
* source: hires.performance
*
* A list of sections, each naming its own page. It reads the way the thing
* reads — "this skill puts this here, and that there" — and it was the one
* shape the parser refused, with a single error that took the whole block down
* and left the definition declaring no pages at all. Several entries may name
* the same page; they become several sections on it, in the order written.
*/
const SECTION_KEYS = new Set([
'id', 'type', 'title', 'description', 'placement', 'position', 'data', 'source', 'periods',
@@ -223,12 +268,68 @@ export function normalizeSkillUi(rawUi, { declaredPages = [], skillId = '' } = {
if (rawUi == null) return { ui, errors };
if (typeof rawUi !== 'object' || Array.isArray(rawUi)) {
return { ui, errors: ['`ui` must be a section, or a mapping of page names to sections.'] };
if (typeof rawUi !== 'object') {
return {
ui,
errors: ['`ui` must be a section, a list of sections, or a mapping of page names to sections.'],
};
}
const pages = declaredPages.map(canonicalPage).filter(Boolean);
/**
* A list, where every entry names the page it belongs to.
*
* Grouped rather than keyed, so two entries naming one page are two sections
* on it rather than the second quietly replacing the first — which is what a
* page map would have done. `seen` is shared across the whole list because
* section ids are unique per definition, not per page.
*/
if (Array.isArray(rawUi)) {
const seen = new Set();
rawUi.forEach((entry, index) => {
const where = `ui[${index}]`;
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
errors.push(`${where}: each entry must be a mapping of options.`);
return;
}
const rawPage = entry.page ?? entry.pages ?? entry.surface;
if (rawPage == null || Array.isArray(rawPage)) {
errors.push(`${where}: an entry needs a \`page\`.`);
return;
}
const page = canonicalPage(rawPage);
if (!page) {
errors.push(
`Unsupported page: ${rawPage}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`
);
return;
}
/* Declared `pages:` still governs reach when it is written: a definition
may not render onto a surface it did not say it applied to. When it is
not written, the list is the declaration — see `parseSkill`. */
if (pages.length && !pages.includes(page)) {
errors.push(`\`${where}\` names \`${rawPage}\`, which is not listed under \`pages\`.`);
return;
}
const section = normalizeSection(entry, {
page, errors, seen, fallbackId: `${skillId}-${index + 1}`, where,
});
if (!section) return;
if (!ui[page]) ui[page] = { sections: [] };
ui[page].sections.push(section);
});
return { ui, errors };
}
/* Shorthand: one section, applied to every page the skill declares. The keys
inside it are section options and are never read as page names — which is
exactly what this branch exists to prevent. */

View File

@@ -45,6 +45,12 @@ export function matchWorkforceIntent(question) {
if (has(q, 'set up an interview', 'set up interview', 'schedule an interview', 'schedule interview',
'interview for ', 'set up their interview')) return 'interview_setup';
/* Answering "how many people do you need?" — checked before assignment,
because the reply that carries the number also carries the position title
and would otherwise read as a fresh request to assign against it. */
if (has(q, 'set headcount', 'set the headcount', 'headcount for', 'headcount to',
'headcount of')) return 'set_headcount';
if (has(q, 'confirm assignment', 'yes, assign', 'confirm the assignment')) return 'confirm_assign';
if (has(q, 'assign ', 'fill this position', 'fill the position', 'assign the best', 'assign them')) return 'assign';
if (has(q, 'ready for interview', 'who should i interview', 'interview ready')) return 'interview_ready';
@@ -57,6 +63,26 @@ export function matchWorkforceIntent(question) {
return null;
}
/**
* The headcount a sentence states, or null.
*
* Deliberately narrow. A bare integer anywhere in the question would also match
* the digits in a position title, a pay band or a date, and setting demand from
* a misread number is not a recoverable mistake — the whole point of asking is
* that nobody wants a figure nobody chose. So the number has to be attached to
* a phrase that means "this many people".
*/
export function headcountFrom(question) {
const q = String(question).toLowerCase();
const match = /\bto\s+(\d{1,3})\b/.exec(q)
|| /\bheadcount\s+(?:of\s+|to\s+)?(\d{1,3})\b/.exec(q)
|| /\b(\d{1,3})\s+(?:people|person|staff|workers?)\b/.exec(q);
if (!match) return null;
const count = Number(match[1]);
return Number.isFinite(count) && count >= 1 && count <= 999 ? count : null;
}
/* ── Which position? ───────────────────────────────────────────────────── */
/**
@@ -423,6 +449,83 @@ export function positionPriority(positions, context) {
};
}
/* ── The missing headcount ─────────────────────────────────────────────── */
/**
* What to say when a position never stated how many people it wants.
*
* This used to be the end of the conversation: a sentence explaining that there
* was no gap to fill, and no follow-up, on a request the reader had just been
* offered a chip for. The engine had in fact already picked somebody — the
* refusal was thrown away in front of a viable plan — but the count it would
* have been proposing against was the schema's fallback of 1, not a number the
* employer had given. Presenting that as their answer is the thing the demand
* model exists to avoid.
*
* So the missing value is asked for rather than assumed. What is offered is
* read off this position: one, two, and the number of people who are actually
* qualified and free for it, so the shortcut with the most information behind it
* is on the list. Nothing is written until one is chosen, and typing a different
* number works exactly as well as clicking a chip.
*/
export function askHeadcount(position, context) {
const demand = demandFor(position, context);
const status = workforceStatusFor(position, context);
const strong = status.strong.length;
const options = [...new Set([1, 2, strong])]
.filter((n) => n >= 1 && n <= 99)
.sort((a, b) => a - b);
const onIt = demand.assigned
? `${demand.assigned} ${demand.assigned === 1 ? 'person is' : 'people are'} already on it`
: 'Nobody is on it yet';
const free = strong
? `${strong} ${strong === 1 ? 'person is' : 'people are'} qualified and free when it starts`
: 'nobody is currently both qualified and free';
return {
doc: doc(
text(`How many people do you need for **${position.title}**?`),
note(`${onIt}, and ${free}. It has never stated a headcount, and I will not `
+ 'guess one — tell me the number and I will propose against it.')
),
followUp: options.map((count) => ({
label: `${count} ${count === 1 ? 'person' : 'people'}`,
prompt: `Set headcount for ${position.title} to ${count}`,
})),
};
}
/**
* The turn after the number is written: the proposal it was asked for.
*
* Composed from `assignmentPreview` rather than restating it, so the answer the
* reader gets here is the same answer the same question gives from then on.
*/
export function headcountSet(position, context) {
const count = Number(position.headcount);
const preview = assignmentPreview(position, context);
return {
doc: doc(
text(`**${position.title}** now asks for **${count}** ${count === 1 ? 'person' : 'people'}.`),
preview.doc.blocks
),
followUp: preview.followUp,
};
}
/** The write did not land. Nothing is claimed that did not happen. */
export function headcountFailed(position) {
return {
doc: doc(
text(`I could not set the headcount on **${position.title}**.`),
note('Nothing was changed. The position still states no demand.')
),
};
}
/* ── Proposing an assignment ───────────────────────────────────────────── */
/**
@@ -437,21 +540,60 @@ export function assignmentPreview(position, context) {
const plan = prepareAssignment(position, context);
const demand = demandFor(position, context);
if (!demand.declared) {
return {
doc: doc(
text(`**${position.title}** does not state how many people it needs, so there is no gap for me to fill.`),
note('Set a headcount on the position and I can propose an assignment against it.')
),
};
}
if (!demand.declared) return askHeadcount(position, context);
if (plan.blocked === 'full') {
return { doc: doc(text(`**${position.title}** is already fully staffed — ${demand.assigned} of ${demand.required}.`)) };
}
if (plan.blocked === 'no_available_matches') {
const blocked = poolFor(position, context).filter((r) => !r.availability.available);
const pool = poolFor(position, context);
/**
* People the engine will not propose automatically, but would accept.
*
* `prepareAssignment` selects from `strong`, and `strong` requires
* *verified* evidence — a deliberate rule, and the right one: a stated
* requirement is a claim on an application, not proof somebody can do the
* work. What was wrong was the sentence it produced. On Bartender, three
* people meet every stated requirement, have no gaps and are free when it
* starts, and the reply said nobody was qualified or free. That is not a
* cautious answer, it is an untrue one — and it ended the conversation,
* because the branch offered no follow-up at all.
*
* So the rule stands and the reply changes: say which kind of evidence is
* missing, show who is actually there, and offer them by name. Naming one
* goes through `namedAssignmentPreview`, which already re-checks them and
* asks for confirmation before anything is written — the admin makes the
* judgement the verified/stated distinction exists to protect, instead of
* the distinction quietly making it for them.
*/
const ready = pool.filter(
(row) => row.availability.available && row.match && !row.match.gaps.length
);
if (ready.length) {
const shown = ready.slice(0, 3);
return {
doc: doc(
text(`Nobody with **verified** skills is free for **${position.title}**, so I will not `
+ 'propose an assignment on my own.'),
text(`${ready.length} ${ready.length === 1 ? 'person meets' : 'people meet'} every stated `
+ `requirement and ${ready.length === 1 ? 'is' : 'are'} free when it starts:`),
...shown.map((row, i) => candidateBlock(row, i + 1, {
applications: context.applications, position,
})),
note('Scored from their applications rather than verified training. Name one and I will '
+ 'check them again before anything is written.')
),
followUp: shown.map((row) => ({
label: `Assign ${row.name}`,
prompt: `Assign ${row.name} to ${position.title}`,
})),
};
}
const blocked = pool.filter((r) => !r.availability.available);
return {
doc: doc(
text(`I cannot propose anyone for **${position.title}** — nobody is both qualified and free when it starts.`),
@@ -464,6 +606,11 @@ export function assignmentPreview(position, context) {
: null,
note(`${demand.remaining} of ${demand.required} still unfilled.`)
),
/* Even with nobody to offer, the conversation has somewhere to go. */
followUp: [
{ label: 'Who is available next?', prompt: `Who is available next for ${position.title}?` },
{ label: 'Who matches this role?', prompt: `Who matches ${position.title}?` },
],
};
}

View File

@@ -7,6 +7,7 @@ import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { CERT_OPTIONS, CRITERIA_LABELS, ENGLISH_LEVELS, defaultPosition } from '@/lib/positionModel';
import { SkillRequirementsField } from '@/components/krow/SkillRequirementsField';
import { SkillSurface } from '@/components/skills/SkillSurface';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { usePublishPageActions, usePublishPageContext } from '@/components/ai-assistant';
/**
@@ -204,9 +205,7 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
: 'bg-white border border-[#E5E7EB] rounded-2xl p-6 md:p-8 space-y-6'}>
{/* Section Header */}
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#EEF3FE] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-[#0838E0]" />
</div>
<OwliverAvatar className="w-7 h-7" rounded="rounded-lg" />
<h2 className="text-[16px] font-semibold text-[#111827]">Create Job Posting</h2>
</div>

View File

@@ -1,14 +1,19 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { ChevronLeft, LayoutTemplate, Upload, X } from 'lucide-react';
import {
Badge, Button, Checkbox, Field, Input, MultiSelect, Select, SelectContent, SelectItem,
SelectTrigger, SelectValue, Surface, Switch, Textarea, toast,
SelectTrigger, SelectValue, Surface, Switch, Textarea,
} from '@/components/ds';
import { cn } from '@/lib/utils';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import { PAGE_KEYS, allSkills, parseSkill, validateSkillSource } from '@/lib/skills/registry';
import { customSkillSource, owliverSkillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
import { reportSave } from '@/lib/skills/saveFeedback';
import {
EMPTY_OWLIVER_FIELDS, facetsFromSource, isReadableDefinition, normalizeUpload,
owliverFieldsFromSource, pagesAreDerived, patchFrontmatter,
} from '@/lib/skills/skillFields';
import {
DATA_SOURCES, OWLIVER_CAPABILITIES, PERIODS, dataSourceFor, dataSourceLabel,
owliverCapabilityLabel, periodLabel, sectionTypeLabel, surfaceFor,
@@ -31,48 +36,16 @@ import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
* door onto one system, never a second system.
*/
/** The fields this editor composes, before the Markdown is edited by hand. */
const emptyDraft = {
id: '',
name: '',
description: '',
pages: [],
triggers: [],
suggestions: [],
capabilities: [],
source: '',
periods: [],
};
/**
* A definition, read back into the fields.
* The fields this editor composes, and the reader that fills them.
*
* Editing starts from what the registry made of the file rather than from the
* Markdown text, so the form shows what is actually registered — including the
* capabilities whose reading was inherited from a `ui:` block rather than
* written out.
* Both moved to `lib/skills/skillFields.js` so the Board editor, the Add Skill
* dialog and every upload path reach the same reading. Two private copies of
* "read a definition into a form" was how uploading a file could populate
* neither of them.
*/
function draftFromSource(source) {
try {
const skill = parseSkill(source, { custom: true });
const first = skill.owliver.capabilities[0];
const response = first ? skill.owliver.responses[first] : null;
return {
id: skill.id,
name: skill.name,
description: skill.description,
pages: skill.pages,
triggers: skill.triggers,
suggestions: skill.owliver.suggestions.map((s) => s.label),
capabilities: skill.owliver.capabilities,
source: response?.source || '',
periods: response?.periods || [],
};
} catch {
return emptyDraft;
}
}
const emptyDraft = EMPTY_OWLIVER_FIELDS;
const draftFromSource = owliverFieldsFromSource;
/** A list of short lines an author edits one at a time. */
function LineList({ items, onChange, placeholder, addLabel, ariaLabel }) {
@@ -133,6 +106,7 @@ function LineList({ items, onChange, placeholder, addLabel, ariaLabel }) {
export default function OwliverSkillEditor() {
const navigate = useNavigate();
const location = useLocation();
const { id: routeId } = useParams();
const editingId = routeId && routeId !== 'new' ? routeId : null;
@@ -149,7 +123,9 @@ export default function OwliverSkillEditor() {
* understands, not a duplicate.
*/
const initial = useMemo(() => {
if (!editingId) return owliverSkillTemplate({});
/* A definition handed over from the Board editor, which noticed the file it
was given belongs here. Arrives whole — nothing is re-derived. */
if (!editingId) return location.state?.source || owliverSkillTemplate({});
const stored = customSkillSource(customSkills, editingId);
if (stored) return stored;
const shipped = allSkills(customSkills).find((s) => s.id === editingId);
@@ -158,10 +134,19 @@ export default function OwliverSkillEditor() {
by a preferences write elsewhere in the app. */
}, [editingId]);
const handedOver = Boolean(!editingId && location.state?.source);
const [source, setSource] = useState(initial);
const [draft, setDraft] = useState(() => (editingId ? draftFromSource(initial) : emptyDraft));
const [touched, setTouched] = useState(Boolean(editingId));
const [draft, setDraft] = useState(
() => (editingId || handedOver ? draftFromSource(initial) : emptyDraft)
);
const [touched, setTouched] = useState(Boolean(editingId) || handedOver);
const [error, setError] = useState('');
/* An uploaded file that declares a page section and no `owliver:` block
belongs in the Board editor. */
const [offerBoard, setOfferBoard] = useState(false);
/* The file the fields were last read from, so "did the upload work" is
answered on screen rather than by comparing a value against a placeholder. */
const [loadedFrom, setLoadedFrom] = useState('');
const fileRef = React.useRef(null);
/* What the registry will make of what is in the editor right now, so the
@@ -179,14 +164,54 @@ export default function OwliverSkillEditor() {
const active = declaredActive && !(preview?.id && disabledSkills.includes(preview.id));
/**
* Typing in the fields composes the Markdown until the Markdown itself is
* edited, after which it wins — it is the thing being saved, and silently
* regenerating over an author's own text would lose work they can see.
* A field edit, written into the definition.
*
* This used to stop dead once `touched` was set — and `touched` starts true
* for every existing skill, and is set by every upload. So on the two paths
* an author is most likely to be on, typing a new name changed React state
* that nothing ever saved: the Markdown is the artefact, and the Markdown had
* not moved. It looked exactly like a form that does not work, because it was.
*
* A fresh draft still regenerates from the template, because the body is
* being composed and there is nothing to lose. Everything else patches the
* keys the fields own and leaves the body, the comments and the key order
* exactly as written.
*/
const update = (patch) => {
const next = { ...draft, ...patch };
setDraft(next);
if (!touched) setSource(owliverSkillTemplate(next));
if (!touched) {
setSource(owliverSkillTemplate(next));
return;
}
/* Responses are only rewritten when this form has enough to say what they
are. A definition whose capabilities read from an inherited `ui:` section
declares no `responses:` block, and inventing one here would pin a
reading the author deliberately left to be inherited. */
const responses = next.capabilities.length && next.source
? Object.fromEntries(next.capabilities.map((capability) => [
capability,
next.periods.length
? { source: next.source, periods: next.periods }
: { source: next.source },
]))
: undefined;
setSource(patchFrontmatter(source, {
id: next.id || undefined,
name: next.name || undefined,
description: next.description || undefined,
/* A definition whose reach comes from its `ui:` entries shows those pages
as a read-out; writing them back would pin a list that then governs the
block. Same rule as the Board editor, for the same reason. */
pages: next.pages?.length && !pagesAreDerived(source) ? next.pages : undefined,
triggers: next.triggers?.length ? next.triggers : undefined,
'owliver.suggestions': next.suggestions?.length ? next.suggestions : undefined,
'owliver.capabilities': next.capabilities?.length ? next.capabilities : undefined,
'owliver.responses': responses,
}));
};
const setStatus = (nextActive) => {
@@ -197,14 +222,50 @@ export default function OwliverSkillEditor() {
setTouched(true);
};
/**
* A definition uploaded from a file.
*
* Filled the Markdown pane and nothing else, said nothing about whether the
* file was valid, and never noticed a file belonging in the other editor —
* so an upload looked like it had failed until Save refused it.
*/
const readFile = (event) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onerror = () => setError('That file could not be read.');
reader.onload = () => {
setSource(String(reader.result));
/* Normalized before anything looks at it: a byte-order mark, `\r\n`, or a
blank line above the opening fence are all things a real file arrives
with, and all three used to stop the frontmatter being seen at all. */
const raw = normalizeUpload(reader.result);
setSource(raw);
setTouched(true);
setError('');
/**
* A file with no readable frontmatter leaves the fields as they were.
*
* Hydrating from it would blank every box, and a blank box in this form
* shows a placeholder — which reads as a populated field. "It filled in
* the wrong values" and "it filled in nothing" would look identical, and
* the second is the one that happened.
*/
if (!isReadableDefinition(raw)) {
setError('That file has no frontmatter block, so there is nothing to read into the fields. '
+ 'A definition starts with `---` on its own line.');
setOfferBoard(false);
return;
}
/* Filled in either way past this point: a definition that will not save
is still one the author has to see in order to fix it. */
setDraft(draftFromSource(raw));
setLoadedFrom(file.name);
setError(validateSkillSource(raw) || '');
const facets = facetsFromSource(raw);
setOfferBoard(facets.length > 0 && !facets.includes('owliver'));
};
reader.readAsText(file);
event.target.value = '';
@@ -220,8 +281,10 @@ export default function OwliverSkillEditor() {
const nextDisabled = skill.status === 'inactive'
? [...new Set([...disabledSkills, skill.id])]
: disabledSkills.filter((id) => id !== skill.id);
updatePreferences.mutate({ customSkills: next, disabledSkills: nextDisabled });
toast.success(editingId ? `${skill.name} updated` : `${skill.name} added`);
updatePreferences.mutate(
{ customSkills: next, disabledSkills: nextDisabled },
reportSave(editingId ? `${skill.name} updated` : `${skill.name} added`)
);
navigate('/admin/workspace/skills?tab=owliver');
};
@@ -265,14 +328,14 @@ export default function OwliverSkillEditor() {
<Field label="Skill name" required>
<Input
value={draft.name}
placeholder="Hiring Activity Assistant"
placeholder="e.g. Hiring Activity Assistant"
onChange={(e) => update({ name: e.target.value })}
/>
</Field>
<Field label="Skill ID" required hint="Lower-case letters, numbers and dashes.">
<Input
value={draft.id}
placeholder="hiring-activity-assistant"
placeholder="e.g. hiring-activity-assistant"
onChange={(e) => update({ id: e.target.value })}
/>
</Field>
@@ -281,7 +344,7 @@ export default function OwliverSkillEditor() {
<Field label="Description" hint="Owliver reads this when deciding whether a question is for this skill.">
<Input
value={draft.description}
placeholder="Answer questions about recent hiring activity on a position."
placeholder="e.g. Answer questions about recent hiring activity on a position."
onChange={(e) => update({ description: e.target.value })}
/>
</Field>
@@ -468,9 +531,16 @@ export default function OwliverSkillEditor() {
<section className="space-y-3">
<SectionTitle title="Definition" meta="Markdown" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
{offerBoard && (
<p className="rounded-lg border border-krow-blue/30 bg-krow-blue-tint px-3 py-2 text-caption leading-relaxed text-krow-blue">
This file declares a page section and no Owliver block. It saves correctly from
here, but the fields that configure it are in the Board Skill Editor.
</p>
)}
<Field
label="Skill definition"
hint="This is what gets saved. Edit it directly and it stops being regenerated from the fields above."
hint="This is what gets saved. The fields above edit its frontmatter; the body is yours."
error={error}
>
<Textarea
@@ -479,8 +549,38 @@ export default function OwliverSkillEditor() {
spellCheck={false}
className="rounded-lg border-border/80 bg-surface-sunken/40 p-3.5 font-mono text-[12.5px] leading-relaxed transition-all focus:border-krow-blue"
onChange={(e) => {
setSource(e.target.value);
const next = e.target.value;
setSource(next);
setTouched(true);
/**
* Markdown → fields, the direction that was missing.
*
* The fields have always written into the definition; the
* definition never wrote back. So a reader who pastes a
* complete `.md` into this box — the other half of how this
* editor is actually used — got a correct Preview beside a
* form of empty boxes, because `source` had moved and
* nothing had read it. Same extractor the upload path uses,
* so the two ways of supplying a definition cannot disagree.
*
* Two conditions before hydrating, and both exist to keep a
* half-typed definition from wiping fields that are already
* filled in. `isReadableDefinition` is the fence: no `---`,
* nothing to read. The name check is the parse: the YAML
* subset raises on an indentation error, which is what
* every definition looks like for the keystroke between
* `ui:` and its first indented line, and the extractor
* reports that by returning its empty record. A definition
* that really parsed always carries a name, even if the
* registry had to supply `Untitled skill` — so an empty one
* means the read failed, not that the author cleared it.
*/
if (isReadableDefinition(next)) {
const fields = draftFromSource(next);
if (fields.name) setDraft(fields);
}
if (error) setError('');
}}
/>
@@ -498,8 +598,25 @@ export default function OwliverSkillEditor() {
<Button variant="outline" size="sm" shape="rounded" onClick={() => fileRef.current?.click()}>
<Upload aria-hidden="true" /> Upload .md
</Button>
{loadedFrom && (
<span className="text-caption text-ink-4">
Fields read from <span className="font-medium text-ink-2">{loadedFrom}</span>
</span>
)}
{offerBoard && (
<Button
variant="outline"
size="sm"
shape="rounded"
onClick={() => navigate('/admin/workspace/skills/new', { state: { source } })}
>
Open in Board editor
</Button>
)}
</div>
<p className="text-caption text-ink-4">Markdown is stored as data. It is never executed.</p>
<p className="text-caption text-ink-4">
Stored as data in this browser, for this workspace. It is never executed.
</p>
</div>
</Surface>
</section>

View File

@@ -1,17 +1,22 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { ChevronLeft, Sparkles, Upload } from 'lucide-react';
import {
Badge, Button, Field, Input, MultiSelect, Select, SelectContent, SelectItem,
SelectTrigger, SelectValue, Surface, Switch, Textarea, toast,
SelectTrigger, SelectValue, Surface, Switch, Textarea,
} from '@/components/ds';
import { cn } from '@/lib/utils';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import { PAGE_KEYS, allSkills, parseSkill, validateSkillSource } from '@/lib/skills/registry';
import { customSkillSource, uiSkillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
import { reportSave } from '@/lib/skills/saveFeedback';
import {
DATA_SOURCES, PERIODS, SECTION_TYPES, dataSourceLabel, owliverCapabilityLabel, periodLabel,
placementLabel, sectionTypeLabel, surfaceFor,
EMPTY_BOARD_FIELDS, boardFieldsFromSource, facetsFromSource, isReadableDefinition,
normalizeUpload, pagesAreDerived, patchFrontmatter, uiIsEditableFromFields,
} from '@/lib/skills/skillFields';
import {
DATA_SOURCES, PERIODS, SECTION_TYPES, contextLabel, contextSuppliedBy, dataSourceLabel,
owliverCapabilityLabel, periodLabel, placementLabel, sectionTypeLabel, surfaceFor,
} from '@/lib/skills/surfaces';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
@@ -39,40 +44,20 @@ import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
* door, not a second system.
*/
/** The fields this editor composes, kept in step with the Markdown until it is edited. */
const emptyMeta = {
id: '', name: '', description: '', pages: [],
type: 'flow', placement: '', source: 'position.activity', periods: [],
};
/**
* Reads the fields back out of a definition, for editing an existing skill.
* The fields this editor composes, and the reader that fills them.
*
* The section is read from what the registry made of the file rather than from
* the Markdown text, so the form shows what is actually registered — including
* a placement the author left out and the vocabulary filled in.
* Both moved to `lib/skills/skillFields.js`: the Owliver editor and the Add
* Skill dialog need the same reading, and an upload has to reach it from all
* three. Two private copies of "read a definition into a form" was how uploading
* a file could populate none of them.
*/
function metaFromSource(source) {
try {
const skill = parseSkill(source, { custom: true });
const section = Object.values(skill.ui || {}).flatMap((p) => p.sections || [])[0];
return {
id: skill.id,
name: skill.name,
description: skill.description,
pages: skill.pages,
type: section?.type || emptyMeta.type,
placement: section?.placement || '',
source: section?.source || emptyMeta.source,
periods: section?.periods || [],
};
} catch {
return emptyMeta;
}
}
const emptyMeta = EMPTY_BOARD_FIELDS;
const metaFromSource = boardFieldsFromSource;
export default function SkillEditor() {
const navigate = useNavigate();
const location = useLocation();
/* `new` is the add route; anything else is a skill id being edited. */
const { id: routeId } = useParams();
const editingId = routeId && routeId !== 'new' ? routeId : null;
@@ -90,7 +75,9 @@ export default function SkillEditor() {
* override rather than a duplicate.
*/
const initial = useMemo(() => {
if (!editingId) return uiSkillTemplate({});
/* A definition handed over from the Owliver editor, which noticed the file
it was given belongs here. Arrives whole — nothing is re-derived. */
if (!editingId) return location.state?.source || uiSkillTemplate({});
const stored = customSkillSource(customSkills, editingId);
if (stored) return stored;
const shipped = allSkills(customSkills).find((s) => s.id === editingId);
@@ -99,11 +86,22 @@ export default function SkillEditor() {
overwritten by a preferences write elsewhere in the app. */
}, [editingId]);
const handedOver = Boolean(!editingId && location.state?.source);
const loaded = editingId || handedOver;
const [source, setSource] = useState(initial);
const [meta, setMeta] = useState(() => (editingId ? metaFromSource(initial) : emptyMeta));
const [pagesText, setPagesText] = useState(() => (editingId ? metaFromSource(initial).pages.join(', ') : ''));
const [touched, setTouched] = useState(Boolean(editingId));
const [meta, setMeta] = useState(() => (loaded ? metaFromSource(initial) : emptyMeta));
const [pagesText, setPagesText] = useState(
() => (loaded ? metaFromSource(initial).pages.join(', ') : '')
);
const [touched, setTouched] = useState(loaded);
const [error, setError] = useState('');
/* An uploaded file that declares no `ui:` block belongs in the Owliver
editor. Set by the upload handler, cleared as soon as the definition gains
a section here. */
const [offerOwliver, setOfferOwliver] = useState(false);
/* The file the fields were last read from, so "did the upload work" is
answered on screen rather than by comparing a value against a placeholder. */
const [loadedFrom, setLoadedFrom] = useState('');
const fileRef = React.useRef(null);
const selectedPages = useMemo(
@@ -119,6 +117,28 @@ export default function SkillEditor() {
[selectedPages]
);
/**
* The sources the chosen surface can actually resolve here.
*
* A source declares the record it needs and a placement either supplies one
* or does not — so offering all fifteen regardless is how a definition ends
* up reading `position.activity` above a grid with no position in it. The
* unavailable ones stay visible and disabled, with the reason: an author who
* simply cannot find a source they know exists will assume it was removed.
*
* `contextSuppliedBy` is the same helper `validateSkillSource` refuses on, so
* the picker and the validator cannot disagree about what is possible.
*/
const supplied = useMemo(
() => contextSuppliedBy(selectedPages, meta.placement),
[selectedPages, meta.placement]
);
const sourceUnavailable = (src) => (
!src.context || supplied.includes(src.context)
? null
: `needs ${contextLabel(src.context)}; this placement supplies none`
);
/* The parsed view of what is in the editor right now — what actually gets
registered, so the preview cannot flatter the definition. */
const preview = useMemo(() => {
@@ -145,14 +165,51 @@ export default function SkillEditor() {
for both the type line and the configuration block. */
const uiSections = Object.values(preview?.ui || {}).flatMap((page) => page.sections || []);
/* Typing in the fields composes frontmatter until the Markdown is edited by
hand, after which the Markdown wins — it is the thing being saved. */
/**
* A field edit, written into the definition.
*
* This used to stop dead once `touched` was set — and `touched` starts true
* for every existing skill, and is set by every upload. So on the two paths
* an author is most likely to be on, typing a new name changed React state
* that nothing ever saved: the Markdown is the artefact, and the Markdown had
* not moved. It looked exactly like a form that does not work, because it was.
*
* Now a fresh draft still regenerates from the template — the body is being
* composed and there is nothing to preserve — and everything else patches the
* key the field owns and leaves the rest of the file alone. `touched` stops
* meaning "the fields are dead" and means only "do not rewrite the body".
*/
const syncFromMeta = (next, nextPagesText = pagesText) => {
setMeta(next);
if (touched) return;
setSource(uiSkillTemplate({
...next,
pages: nextPagesText.split(',').map((p) => p.trim()).filter(Boolean),
const pages = nextPagesText.split(',').map((p) => p.trim()).filter(Boolean);
if (!touched) {
setSource(uiSkillTemplate({ ...next, pages }));
return;
}
/* Only the section keys this form can faithfully represent. A definition
written as a list, or addressing pages by name, may hold several sections
across several pages; overwriting that from four single-valued fields
would throw away everything but the first, so those fields are read-only
against it. Identity and reach still patch normally — a multi-page
definition must still be renameable. */
const section = uiIsEditableFromFields(source) ? {
'ui.type': next.type || undefined,
'ui.placement': next.placement || undefined,
'ui.source': next.source || undefined,
'ui.periods': next.periods?.length ? next.periods : undefined,
} : {};
setSource(patchFrontmatter(source, {
id: next.id || undefined,
name: next.name || undefined,
description: next.description || undefined,
/* Only when the definition owns a `pages:` key. A definition whose reach
comes from its `ui:` entries is showing a read-out here, and writing it
back would pin a list that governs the block from then on. */
pages: pages.length && !pagesAreDerived(source) ? pages : undefined,
...section,
}));
};
@@ -166,14 +223,53 @@ export default function SkillEditor() {
setTouched(true);
};
/**
* A definition uploaded from a file.
*
* Three things this did not do, and all three are why uploading felt like it
* had not worked: it never filled in a single field, it never said whether
* the file was valid, and it never noticed a file that belongs in the other
* editor. The Markdown appeared, the form stayed empty beside it, and the
* first sign of trouble was a refusal on Save.
*/
const readFile = (event) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onerror = () => setError('That file could not be read.');
reader.onload = () => {
setSource(String(reader.result));
/* Normalized before anything looks at it: a byte-order mark, `\r\n`, or a
blank line above the opening fence are all things a real file arrives
with, and all three used to stop the frontmatter being seen at all. */
const raw = normalizeUpload(reader.result);
setSource(raw);
setTouched(true);
setError('');
/* A file with no readable frontmatter leaves the fields as they were —
blanking them shows placeholders, and a placeholder reads as a value. */
if (!isReadableDefinition(raw)) {
setError('That file has no frontmatter block, so there is nothing to read into the fields. '
+ 'A definition starts with `---` on its own line.');
setOfferOwliver(false);
return;
}
/* The fields are filled in either way past this point. A definition that
will not save is still one the author has to see in order to fix it,
and an empty form beside a rejected file says nothing about which field
is wrong. */
const fields = metaFromSource(raw);
setMeta(fields);
setPagesText(fields.pages.join(', '));
setLoadedFrom(file.name);
setError(validateSkillSource(raw) || '');
/* A file declaring only the assistant half is being edited in the wrong
half of the product. Offered, never forced: both editors save the whole
definition, so staying here is a legitimate choice. */
const facets = facetsFromSource(raw);
setOfferOwliver(facets.length > 0 && !facets.includes('ui'));
};
reader.readAsText(file);
event.target.value = '';
@@ -191,8 +287,10 @@ export default function SkillEditor() {
const nextDisabled = skill.status === 'inactive'
? [...new Set([...disabledSkills, skill.id])]
: disabledSkills.filter((id) => id !== skill.id);
updatePreferences.mutate({ customSkills: next, disabledSkills: nextDisabled });
toast.success(editingId ? `${skill.name} updated` : `${skill.name} added`);
updatePreferences.mutate(
{ customSkills: next, disabledSkills: nextDisabled },
reportSave(editingId ? `${skill.name} updated` : `${skill.name} added`)
);
navigate('/admin/workspace/skills');
};
@@ -225,14 +323,14 @@ export default function SkillEditor() {
<Field label="Skill name" required>
<Input
value={meta.name}
placeholder="Chef Training"
placeholder="e.g. Chef Training"
onChange={(e) => syncFromMeta({ ...meta, name: e.target.value })}
/>
</Field>
<Field label="Skill ID" required hint="Lower-case letters, numbers and dashes.">
<Input
value={meta.id}
placeholder="chef-training"
placeholder="e.g. chef-training"
onChange={(e) => syncFromMeta({ ...meta, id: e.target.value })}
/>
</Field>
@@ -241,7 +339,7 @@ export default function SkillEditor() {
<Field label="Description">
<Input
value={meta.description}
placeholder="Helps Owliver explain chef training paths."
placeholder="e.g. Helps Owliver explain chef training paths."
onChange={(e) => syncFromMeta({ ...meta, description: e.target.value })}
/>
</Field>
@@ -249,7 +347,9 @@ export default function SkillEditor() {
<Field
label="Surfaces"
required
hint="Select the pages this skill attaches to from the options below or dropdown list."
hint={pagesAreDerived(source)
? 'Read from the `ui:` entries below, which name their own pages.'
: 'Select the pages this skill attaches to from the options below or dropdown list.'}
>
<div className="space-y-2.5">
<MultiSelect
@@ -357,9 +457,14 @@ export default function SkillEditor() {
<Select value={meta.source} onValueChange={(source) => syncFromMeta({ ...meta, source })}>
<SelectTrigger><SelectValue placeholder="Select a data source" /></SelectTrigger>
<SelectContent>
{DATA_SOURCES.map((src) => (
<SelectItem key={src.id} value={src.id}>{src.label} — {src.summary}</SelectItem>
))}
{DATA_SOURCES.map((src) => {
const why = sourceUnavailable(src);
return (
<SelectItem key={src.id} value={src.id} disabled={Boolean(why)}>
{src.label} — {why || src.summary}
</SelectItem>
);
})}
</SelectContent>
</Select>
</Field>
@@ -393,10 +498,12 @@ export default function SkillEditor() {
</div>
</Field>
{touched && (
{!uiIsEditableFromFields(source) && (
<p className="border-t border-border/50 pt-3 text-caption leading-relaxed text-ink-4">
The definition below has been edited by hand, so it is what gets saved — these
fields no longer compose it.
This definition declares a section per page, and its surfaces are read from
those entries. The single fields above cannot represent that, so they are left
out of what gets saved and every entry is preserved exactly as written — edit
the definition below directly. Name, ID and description still apply.
</p>
)}
</Surface>
@@ -405,6 +512,13 @@ export default function SkillEditor() {
<section className="space-y-3">
<SectionTitle title="Instructions" meta="Markdown" />
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="space-y-4">
{offerOwliver && (
<p className="rounded-lg border border-krow-blue/30 bg-krow-blue-tint px-3 py-2 text-caption leading-relaxed text-krow-blue">
This file declares an Owliver skill and no page section. It saves correctly from
here, but the fields that configure it are in the Owliver Skill Editor.
</p>
)}
<Field
label="Skill definition"
hint="This is what gets saved. Frontmatter declares the skill; the body documents what it can do."
@@ -416,8 +530,41 @@ export default function SkillEditor() {
spellCheck={false}
className="font-mono text-[12.5px] leading-relaxed bg-surface-sunken/40 border-border/80 focus:border-krow-blue rounded-lg p-3.5 transition-all"
onChange={(e) => {
setSource(e.target.value);
const next = e.target.value;
setSource(next);
setTouched(true);
/**
* Markdown → fields, the direction that was missing.
*
* The fields have always written into the definition; the
* definition never wrote back. So a reader who pastes a
* complete `.md` into this box — the other half of how this
* editor is actually used — got a correct Preview beside a
* form of empty boxes, because `source` had moved and
* nothing had read it. Same extractor the upload path uses,
* so the two ways of supplying a definition cannot disagree.
*
* Two conditions before hydrating, and both exist to keep a
* half-typed definition from wiping fields that are already
* filled in. `isReadableDefinition` is the fence: no `---`,
* nothing to read. The name check is the parse: the YAML
* subset raises on an indentation error, which is what
* every definition looks like for the keystroke between
* `ui:` and its first indented line, and the extractor
* reports that by returning its empty record. A definition
* that really parsed always carries a name, even if the
* registry had to supply `Untitled skill` — so an empty one
* means the read failed, not that the author cleared it.
*/
if (isReadableDefinition(next)) {
const fields = metaFromSource(next);
if (fields.name) {
setMeta(fields);
setPagesText(fields.pages.join(', '));
}
}
if (error) setError('');
}}
/>
@@ -435,6 +582,21 @@ export default function SkillEditor() {
<Button variant="outline" size="sm" shape="rounded" onClick={() => fileRef.current?.click()}>
<Upload aria-hidden="true" /> Upload .md
</Button>
{loadedFrom && (
<span className="text-caption text-ink-4">
Fields read from <span className="font-medium text-ink-2">{loadedFrom}</span>
</span>
)}
{offerOwliver && (
<Button
variant="outline"
size="sm"
shape="rounded"
onClick={() => navigate('/admin/workspace/skills/owliver/new', { state: { source } })}
>
Open in Owliver editor
</Button>
)}
</div>
<p className="text-caption text-ink-4">Markdown is stored as data. It is never executed.</p>
</div>

View File

@@ -7,6 +7,7 @@ import {
import { usePreferences } from '@/lib/krowHooks';
import { aiAgentSkills, allSkills, skillsWithFacet } from '@/lib/skills/registry';
import { AdminPage } from '@/pages/admin/_shell';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { cn } from '@/lib/utils';
/**
@@ -100,7 +101,7 @@ export default function AdminWorkspace() {
<div className="bg-white rounded-2xl border border-[#E0E4EA] p-6 shadow-xs space-y-5">
<div className="flex items-center justify-between border-b border-[#E0E4EA] pb-3">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-[#0838E0]" />
<OwliverAvatar className="w-6 h-6" rounded="rounded-md" />
<h2 className="text-[16px] font-bold text-[#101820]">Owliver Skills</h2>
</div>
<span className="text-[12px] font-semibold text-[#0838E0] bg-blue-50 px-3 py-1 rounded-full border border-blue-200/60">

View File

@@ -1,7 +1,8 @@
import React, { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
ChevronDown, ChevronLeft, LayoutTemplate, MoreHorizontal, Pencil, Plus, Sparkles, Trash2,
ChevronDown, ChevronLeft, Download, LayoutTemplate, MoreHorizontal, Pencil, Plus, Sparkles,
Trash2, Upload,
} from 'lucide-react';
import {
Alert, Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
@@ -10,8 +11,12 @@ import {
} from '@/components/ds';
import { cn } from '@/lib/utils';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import { PAGE_KEYS, parseSkill, readSkillRegistry, skillsWithFacet } from '@/lib/skills/registry';
import {
PAGE_KEYS, parseSkill, readSkillRegistry, skillsWithFacet, validateSkillSource,
} from '@/lib/skills/registry';
import { removeCustomSkill, upsertCustomSkill } from '@/lib/skills/customSkills';
import { reportSave } from '@/lib/skills/saveFeedback';
import { normalizeUpload } from '@/lib/skills/skillFields';
import { countSections } from '@/lib/skills/uiConfig';
import {
owliverCapabilityLabel, placementLabel, sectionTypeLabel, surfaceFor,
@@ -100,7 +105,7 @@ function contribution(skill, facet) {
}
/** One skill, as a row in the management list. */
function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete }) {
function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete, onExport }) {
const pages = surfaceLabels(skill);
const { type, detail } = contribution(skill, facet);
/* A definition doing both jobs appears on both lists. Saying so on the row is
@@ -161,6 +166,14 @@ function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete }) {
<Pencil className="mr-2 h-4 w-4" />
{skill.custom ? 'Edit skill' : 'Duplicate & edit'}
</DropdownMenuItem>
{/* The definition, back out as the file it is. An account skill
lives in this browser and nowhere else; without a way to take
one out, "stored locally" means "stored until something goes
wrong". */}
<DropdownMenuItem onClick={() => onExport(skill)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Export .md
</DropdownMenuItem>
{/* Offered for every definition. What it does differs — an account
definition is deleted, a shipped one is taken out of this
workspace — and the dialog says which. */}
@@ -194,6 +207,7 @@ export default function WorkspaceSkills() {
const [page, setPage] = useState('all');
const [pendingDelete, setPendingDelete] = useState(null);
const [showRemoved, setShowRemoved] = useState(false);
const importRef = React.useRef(null);
const disabledSkills = preferences.disabledSkills || [];
/**
@@ -296,8 +310,7 @@ export default function WorkspaceSkills() {
patch.customSkills = upsertCustomSkill(customSkills, source).next;
}
updatePreferences.mutate(patch);
toast.success(enabled ? 'Skill enabled' : 'Skill disabled');
updatePreferences.mutate(patch, reportSave(enabled ? 'Skill enabled' : 'Skill disabled'));
};
/**
@@ -319,9 +332,8 @@ export default function WorkspaceSkills() {
disabledSkills: [...new Set([...disabledSkills, skill.id])],
};
updatePreferences.mutate(patch);
updatePreferences.mutate(patch, reportSave(`${skill.name} removed`));
setPendingDelete(null);
toast.success(`${skill.name} removed`);
};
/**
@@ -337,10 +349,86 @@ export default function WorkspaceSkills() {
* list with its switch off, and one click is the whole cost.
*/
const restoreSkill = (skill) => {
updatePreferences.mutate({
removedSkills: removedSkills.filter((id) => id !== skill.id),
updatePreferences.mutate(
{ removedSkills: removedSkills.filter((id) => id !== skill.id) },
reportSave(`${skill.name} restored — switched off`)
);
};
/**
* A definition, downloaded as the file it already is.
*
* `skill.markdown` is the artefact — the same text a file in `src/skills/`
* holds — so this is a copy, not an export format. That is what makes it
* useful: the file that comes out can be uploaded into another browser,
* committed to the repository, or read by a person.
*/
const exportSkill = (skill) => {
const blob = new Blob([skill.markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${skill.id}.md`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
/** Every definition this account authored, one file at a time. */
const exportAll = () => {
const mine = skills.filter((s) => s.custom);
if (!mine.length) {
toast.error('This workspace has no account-authored skills to export.');
return;
}
mine.forEach(exportSkill);
toast.success(`Exported ${mine.length} skill${mine.length === 1 ? '' : 's'}`);
};
/**
* Definitions read back in from files.
*
* The same validator and the same writer the editors use, so an imported file
* cannot enter the registry by a route that checks less than authoring does.
* Each file is reported on its own: one bad file in a selection of six must
* not cost the other five.
*/
const importFiles = (event) => {
const files = [...(event.target.files || [])];
event.target.value = '';
if (!files.length) return;
Promise.all(files.map((file) => file.text().then(
(raw) => ({ name: file.name, raw }),
() => ({ name: file.name, raw: null })
))).then((read) => {
let next = customSkills;
const added = [];
const refused = [];
for (const { name, raw: text } of read) {
/* Same normalisation the editors apply, so a file imported here and the
same file uploaded there become the identical stored definition. */
const raw = text === null ? null : normalizeUpload(text);
const problem = raw === null ? 'could not be read' : validateSkillSource(raw);
if (problem) {
refused.push(`${name}: ${problem}`);
continue;
}
const result = upsertCustomSkill(next, raw);
next = result.next;
added.push(result.skill.name);
}
if (added.length) {
updatePreferences.mutate(
{ customSkills: next },
reportSave(`Imported ${added.length} skill${added.length === 1 ? '' : 's'}`)
);
}
refused.forEach((message) => toast.error(message));
});
toast.success(`${skill.name} restored — switched off`);
};
const handleFixDiagnostic = (d) => {
@@ -356,15 +444,16 @@ export default function WorkspaceSkills() {
if (isCustom) {
const nextCustom = removeCustomSkill(customSkills, d.skillId);
updatePreferences.mutate({ customSkills: nextCustom });
toast.success(`Removed custom skill "${d.skillId}"`);
updatePreferences.mutate(
{ customSkills: nextCustom },
reportSave(`Removed custom skill "${d.skillId}"`)
);
} else {
const patch = {
removedSkills: [...new Set([...removedSkills, d.skillId])],
disabledSkills: [...new Set([...disabledSkills, d.skillId])],
};
updatePreferences.mutate(patch);
toast.success(`Removed "${d.skillId}" from workspace`);
updatePreferences.mutate(patch, reportSave(`Removed "${d.skillId}" from workspace`));
}
};
@@ -380,12 +469,10 @@ export default function WorkspaceSkills() {
nextDisabled = [...new Set([...nextDisabled, d.skillId])];
});
updatePreferences.mutate({
customSkills: nextCustom,
removedSkills: nextRemoved,
disabledSkills: nextDisabled,
});
toast.success('Resolved all skill problems');
updatePreferences.mutate(
{ customSkills: nextCustom, removedSkills: nextRemoved, disabledSkills: nextDisabled },
reportSave('Resolved all skill problems')
);
};
/** A row opens the editor its own kind is authored in. */
@@ -419,6 +506,24 @@ export default function WorkspaceSkills() {
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/workspace')}>
<ChevronLeft aria-hidden="true" /> Workspace
</Button>
{/* Account definitions live in this browser's storage and nowhere
else. Import and export are what make that a place rather than a
trap: a skill can be moved to another machine, handed to someone,
or committed to `src/skills/`. */}
<input
ref={importRef}
type="file"
accept=".md,text/markdown"
multiple
onChange={importFiles}
className="hidden"
/>
<Button variant="outline" size="sm" shape="rounded" onClick={() => importRef.current?.click()}>
<Upload aria-hidden="true" /> Import .md
</Button>
<Button variant="outline" size="sm" shape="rounded" onClick={exportAll}>
<Download aria-hidden="true" /> Export all
</Button>
<Button size="sm" shape="rounded" onClick={() => navigate(current.route)}>
<Plus aria-hidden="true" /> {current.add}
</Button>
@@ -533,6 +638,7 @@ export default function WorkspaceSkills() {
onToggle={toggleSkill}
onEdit={(s) => navigate(editRoute(s))}
onDelete={setPendingDelete}
onExport={exportSkill}
/>
))}
</Surface>