/** * What a skill definition is allowed to say about the product. * * A skill can extend a KROW page: name a surface, declare a section, and the * page renders it. That only stays safe — and only stays a *product* rather * than a scripting host — because the vocabulary is closed. Everything a * definition may name is in this file: the surfaces, the placements on each * surface, the component types, and the data sources. * * The rule that makes it safe: **nothing here is code, and nothing here is * looked up dynamically from the file.** A definition names a key; this module * says whether that key exists; the renderer maps it to a component the app * already ships. A definition that names something absent is rejected with a * message, never rendered as an unknown thing and never executed. */ /** * The surfaces a skill can extend. * * `aliases` keep the page keys the existing skills already use — `hired`, * `university` — working under the names the product now shows, so the eight * definitions on disk did not have to be rewritten to gain this feature. */ export const SKILL_SURFACES = [ { id: 'control-center', label: 'Control Center', route: '/admin', placements: ['after-header', 'before-footer'], provides: {}, }, { id: 'positions', label: 'Positions', route: '/admin/positions', /* Mounted in three places, because Positions is three experiences: the page that lists the roles, the card for each one, and the drawer "View position" opens. `after-position-list-summary` and `after-position-list` are the *page*: they render once, above and below the grid, with no position in context. `after-position-card` renders inside each card. The rest render inside the drawer and on the full position page, where one position is being read. Every one of these is mounted — a placement the vocabulary offers and no page provides is a definition that validates and then silently does nothing. */ placements: [ 'after-position-list-summary', 'after-position-list', 'after-header', 'after-position-card', 'after-position-summary', 'before-candidates', 'after-candidates', 'before-footer', ], /** * What each of those placements actually hands a section, read off the * `` 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 to say there is about the position being specified, not about the ones that already exist. Its placements follow the form's own three parts, so a definition can sit beside the field group it is about. */ id: 'create-position', label: 'Create Position', route: '/admin/positions/new', aliases: ['new-position'], placements: [ 'after-header', 'after-job-description', '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', label: 'Hired History', 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', label: 'KROW Forge', 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. */ { id: 'profile', 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: {}, }, ]; const BY_KEY = new Map(); for (const surface of SKILL_SURFACES) { BY_KEY.set(surface.id, surface); for (const alias of surface.aliases || []) BY_KEY.set(alias, surface); } /** 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. * * 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`. * * The surfaces already carry their routes, so this reads the answer off the * table rather than deriving a page key from the path a second time. That * matters for the surfaces whose key is not their path tail: without it, * `/admin/positions/new` would key as `positions/new`, which is a page nothing * declares and no definition could attach to. */ export const surfaceForRoute = (route) => SKILL_SURFACES.find((s) => s.route === String(route || '').trim()) || null; /** * The section types a definition may ask for. * * Each entry names a component the application already ships. A type is a key * in this table and nothing else: there is no path from a definition to a * component that is not listed here, which is what stops `type:` from being an * import statement in disguise. */ export const SECTION_TYPES = [ { id: 'card', label: 'Card', summary: 'A titled panel of prose and figures.' }, { id: 'stats', label: 'Stats', summary: 'A row of counted figures.' }, { id: 'list', label: 'List', summary: 'A ranked or plain list of records.' }, { id: 'timeline', label: 'Timeline', summary: 'Dated events, most recent first.' }, { id: 'flow', label: 'Flow', summary: 'A sequence of stages or periods.' }, { id: 'table', label: 'Table', summary: 'Rows and columns.' }, { id: 'progress', label: 'Progress', summary: 'Bars against a total.' }, { id: 'insight', label: 'Insight', summary: 'One finding, stated plainly.' }, /* Weighted criteria that share a budget. Distinct from `progress`, which is bars against an independent maximum: these bars are shares of one total, and the total is a fact about the set rather than about any one row. */ { id: 'weights', label: 'Weights', summary: 'Weighted criteria as shares of one total.' }, ]; export const SUPPORTED_SECTION_TYPES = SECTION_TYPES.map((t) => t.id); /** * What a definition may ask *Owliver* to do with the same reading. * * A skill declares `ui:` for the page and `owliver:` for the panel, and both * name the same data source. A capability is the second half of that: the shape * the answer takes when it is asked for in conversation rather than rendered on * the page. * * `shape` is the section type the answer is drawn with, so `flow` in a chat * reply is the *same* component the page renders — there is one flow renderer, * not one per consumer. `summary` has no shape because a summary is prose: the * figures are read back as sentences rather than drawn. * * `terms` are how a question is recognised as asking for this shape. They are * deliberately about the *shape* and never about a subject: "as a flow" belongs * here, "hiring activity" belongs in a definition's `triggers`. That split is * what keeps this table closed while the skills stay open. */ export const OWLIVER_CAPABILITIES = [ { id: 'summary', label: 'Summary', shape: null, summary: 'Reads the figures back as sentences.', terms: ['summary', 'summarise', 'summarize', 'summarised', 'summarized', 'summarising', 'summarizing', 'sum up', 'recap', 'overview', 'brief me', 'in short', 'tell me about', 'what is the', 'how is'], }, { id: 'flow', label: 'Flow', shape: 'flow', summary: 'Draws the stages or periods as a sequence.', terms: ['flow', 'as a flow', 'chart', 'graph', 'diagram', 'funnel', 'stages', 'visual', 'visualise', 'visualize', 'step by step'], }, { id: 'stats', label: 'Stats', shape: 'stats', summary: 'A row of counted figures.', terms: ['stats', 'statistics', 'figures', 'numbers', 'counts', 'how many'], }, { id: 'list', label: 'List', shape: 'list', summary: 'A ranked or plain list of records.', terms: ['list', 'who are', 'which ones', 'show me the records'], }, { id: 'table', label: 'Table', shape: 'table', summary: 'Rows and columns.', terms: ['table', 'as a table', 'rows', 'grid', 'spreadsheet'], }, { id: 'timeline', label: 'Timeline', shape: 'timeline', summary: 'Dated events, most recent first.', terms: ['timeline', 'history', 'chronology', 'over time', 'what happened'], }, { id: 'progress', label: 'Progress', shape: 'progress', summary: 'Bars against a total.', terms: ['progress', 'bars', 'completion', 'how far'], }, { id: 'weights', label: 'Weights', shape: 'weights', summary: 'The weighted criteria, adjustable when the page accepts the write.', terms: ['weight', 'weights', 'weighting', 'weightings', 'importance', 'balance', 'set the weights', 'adjust the weights', 'screening weight', 'vetting weight', 'criteria'], }, { id: 'insight', label: 'Insight', shape: 'insight', summary: 'One finding, stated plainly.', terms: ['insight', 'finding', 'takeaway', 'headline', 'what stands out'], }, { id: 'card', label: 'Card', shape: 'card', summary: 'A titled panel of figures.', terms: ['card', 'panel', 'at a glance'], }, ]; export const SUPPORTED_OWLIVER_CAPABILITIES = OWLIVER_CAPABILITIES.map((c) => c.id); /** The capability a declared name refers to, or null. */ export const owliverCapabilityFor = (id) => OWLIVER_CAPABILITIES.find((c) => c.id === String(id || '').trim()) || null; /** `flow` → `Flow`. */ export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label || id; /** * The data a section may ask for. * * Each source is a named reading of data the application already holds, and * `context` says what a page must know for the reading to be possible — a * position id, a candidate id, or nothing. A source is resolved by * `dataResolver.js`; a definition cannot reach a store directly, cannot write, * and cannot name a field that is not offered here. */ export const DATA_SOURCES = [ { id: 'position.activity', label: 'Position activity', context: 'positionId', summary: 'Applications to this position, counted over time.', shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'], options: ['periods'], }, { id: 'position.pipeline', label: 'Position pipeline', context: 'positionId', summary: 'Applied → screened → shortlisted → interviewed → hired.', shapes: ['flow', 'stats', 'progress', 'table', 'card'], }, { id: 'position.candidates', label: 'Position candidates', context: 'positionId', summary: 'Candidates matched to this position, best first.', shapes: ['list', 'table', 'stats', 'card'], options: ['limit'], }, { /** * Who could actually do this work, ranked. * * Distinct from `position.candidates`, which lists the people who *applied * here*. This one reads the whole candidate pool against what the position * states — so a role published a minute ago, with no applications at all, * still has an answer. The reading is `poolFor` in `lib/workforce.js`, the * same engine the panel's workforce conversation and the position page use; * nothing about matching is decided in the resolver. */ id: 'position.matches', label: 'Position candidate matches', context: 'positionId', summary: 'The candidate pool scored against this position, best first.', shapes: ['list', 'table', 'stats', 'card', 'insight'], options: ['limit'], }, { id: 'position.requirements', label: 'Position requirements', context: 'positionId', summary: 'What this position states it needs.', shapes: ['list', 'table', 'card'], }, { id: 'candidate.readiness', label: 'Candidate readiness', context: 'candidateId', summary: 'Screening dimensions for one candidate.', shapes: ['progress', 'stats', 'list', 'table', 'card'], }, { id: 'candidate.activity', label: 'Candidate activity', context: 'candidateId', summary: 'What has happened on this candidate’s record.', shapes: ['timeline', 'list', 'table', 'card'], }, { id: 'candidates.pipeline', label: 'Candidate pipeline', context: null, summary: 'Every candidate, counted by stage.', shapes: ['flow', 'stats', 'progress', 'table', 'card'], }, { id: 'candidates.activity', label: 'Candidate activity', context: null, summary: 'Applications across the workspace, counted over time.', shapes: ['flow', 'stats', 'timeline', 'table', 'card'], options: ['periods'], }, { id: 'positions.demand', label: 'Position demand', context: null, summary: 'Open positions and what they still need.', shapes: ['list', 'table', 'stats', 'card'], options: ['limit'], }, { id: 'workforce.training', label: 'Workforce training', context: null, summary: 'Training paths and progress against them.', shapes: ['progress', 'list', 'stats', 'table', 'card'], options: ['limit'], }, { /* The vetting weights a position is being specified with. `positionId` context, but the "position" on Create Position is the draft in the form rather than a saved record — which is the point: the resolver reads the same field either way, so one source serves the form and the position it becomes. */ id: 'position.vetting', label: 'Position vetting weights', context: 'positionId', summary: 'How this position weights each screening criterion.', shapes: ['weights', 'flow', 'progress', 'stats', 'table', 'card', 'insight'], /** * This reading can be written back. * * `writable` is what lets a definition declare `editable: true` and get * controls instead of a read-out. It is a property of the *source*, not of * the definition — a skill cannot make a reading writable by asking, and a * source with no page willing to accept the write simply renders read-only. * That keeps the closed vocabulary closed in both directions. */ writable: true, writeSummary: 'Sets the screening weights on the position being specified.', }, { id: 'hires.recent', label: 'Recent hires', context: null, summary: 'Who was hired, for which role, and when.', shapes: ['list', 'table', 'timeline', 'stats', 'card'], options: ['limit'], }, { id: 'hires.performance', label: 'Hiring performance', context: null, summary: 'Hires, time-to-hire, quality and conversion, counted together.', shapes: ['stats', 'card', 'table', 'flow', 'insight'], }, { id: 'activity.events', label: 'Workspace activity', context: null, summary: 'What has happened across the workspace, most recent first.', shapes: ['timeline', 'list', 'table', 'stats', 'card'], options: ['limit'], }, ]; export const SUPPORTED_DATA_SOURCES = DATA_SOURCES.map((s) => s.id); export const dataSourceFor = (id) => DATA_SOURCES.find((s) => s.id === id) || null; /* ── Source / shape compatibility ─────────────────────────────────────────── One resolver, three consumers: `normalizeSection` refuses on it, the Board editor's source picker filters on it, and the Owliver editor's per-capability picker filters on it. They used to be three readings of `source.shapes` — inline in the normalizer, absent from one editor and approximated in the other — which is how a form could compose `list` against a source that has no list in it and only find out at save. */ /** * The section type a capability is drawn with, or null when it is prose. * * `summary` is the one capability with no component, so it is compatible with * every source: the figures are read back as sentences rather than drawn. */ export const shapeForCapability = (capability) => owliverCapabilityFor(capability)?.shape || null; /** Can this source be drawn as this shape? A null shape is prose, and always can. */ export function sourceSupportsShape(sourceId, shape) { const source = dataSourceFor(sourceId); if (!source) return false; if (!shape) return true; return Boolean(source.shapes?.includes(shape)); } /** * The sources that can fill this shape, optionally narrowed to what a placement * can supply context for. * * `context` is the list a placement provides — `contextSuppliedBy(pages, * placement)`. Passing it is how the Board editor offers only sources that can * actually resolve where the section is mounted; the Owliver editor passes * nothing, because a response with an unmet need asks which record is meant * rather than rendering dead. */ export function sourcesForShape(shape, { context = null } = {}) { return DATA_SOURCES.filter((source) => { if (!sourceSupportsShape(source.id, shape)) return false; if (!context) return true; return !source.context || context.includes(source.context); }); } /** * Whether a source reads an option a form can offer — `periods` or `limit`. * * Declared per source rather than inferred from its shapes, because the two do * not line up: `positions.demand` is a list that honours `limit` and has no * periods at all, while `candidates.activity` is the reverse. The editors used * to guess from the shape list and offered period checkboxes that changed * nothing. */ export const sourceSupportsOption = (sourceId, option) => Boolean(dataSourceFor(sourceId)?.options?.includes(option)); /** * Can this reading be written back? * * The one question `editable:` is checked against. A page opts in by publishing * a handler for the source (see `usePublishPageActions`); a definition opts in * by declaring `editable: true`. Both have to hold before a control is drawn, * so neither the author nor the page can enable editing on its own. */ export const isSourceWritable = (id) => Boolean(dataSourceFor(id)?.writable); /** * The periods a time-based section may ask for. * * Only the ones the data layer can actually compute from record timestamps. * A period is a window over `created_date`, resolved at read time against the * current date — never a stored figure and never a hard-coded date. */ export const PERIODS = [ { id: 'today', label: 'Today' }, { id: 'yesterday', label: 'Yesterday' }, { id: 'last-7-days', label: 'Last 7 days' }, { id: 'last-week', label: 'Last week' }, { id: 'this-month', label: 'This month' }, { id: 'previous-month', label: 'Previous month' }, ]; export const SUPPORTED_PERIODS = PERIODS.map((p) => p.id); export const periodLabel = (id) => PERIODS.find((p) => p.id === id)?.label || id; /* ── Human labels ─────────────────────────────────────────────────────────── The vocabulary is written in kebab-case because it is configuration; it is read by people, so every id has a label. One place, so the preview, the rendered section and any future surface all say the same words. */ /** `flow` → `Flow`. */ export const sectionTypeLabel = (id) => SECTION_TYPES.find((t) => t.id === id)?.label || id; /** `position.activity` → `Position activity`. */ export const dataSourceLabel = (id) => dataSourceFor(id)?.label || id; /** `after-position-summary` → `After position summary`. */ export const placementLabel = (id) => { const words = String(id || '').replace(/-/g, ' ').trim(); return words ? words[0].toUpperCase() + words.slice(1) : id; };