This commit is contained in:
@@ -3840,6 +3840,192 @@ record('every published agent offers at least one starter where it applies',
|
||||
.every((a) => a.pages.length === 0 || runtime.agentStarters(a, null).length > 0),
|
||||
ALL_AGENTS.map((a) => `${a.id}:${a.starters.length}`).join(' '));
|
||||
|
||||
/* ── Removing an agent ───────────────────────────────────────────────────
|
||||
*
|
||||
* Removal is permanent, and the two things that make it safe are both
|
||||
* structural rather than visible: WHICH agents offer it, and that archiving
|
||||
* never quietly becomes it.
|
||||
*
|
||||
* The menu is duplicated between the card view and the table row — two blocks
|
||||
* of the same JSX — so every property here is asserted against BOTH. A guard
|
||||
* added to one and forgotten in the other is exactly the shape of bug that
|
||||
* would ship a delete button on a built-in agent.
|
||||
*
|
||||
* `shipped` is the source of truth, from the ids this build bundles. Nothing
|
||||
* here reads a display name: two agents may legitimately share one.
|
||||
*/
|
||||
const removeItems = agentsListSource.match(
|
||||
/onAction\('remove', agent\)[\s\S]{0,400}?<\/DropdownMenuItem>/g) || [];
|
||||
record('both agent menus offer removal, and neither offers it unconditionally',
|
||||
removeItems.length === 2
|
||||
&& (agentsListSource.match(/\{\(overridden \|\| !shipped\) && \(/g) || []).length === 2,
|
||||
`${removeItems.length} remove item(s), each behind (overridden || !shipped)`);
|
||||
|
||||
record('a custom agent is offered Remove, and a built-in one is only offered a revert',
|
||||
removeItems.every((block) => / Remove</.test(block) && /Revert to shipped/.test(block)
|
||||
&& /shipped\s*\?/.test(block)),
|
||||
'the label is chosen by `shipped`, not by name');
|
||||
|
||||
/* Archive is reversible; Remove is not. The menu must not make removal depend
|
||||
on status, or an archived custom agent becomes unremovable — which would
|
||||
silently turn Archive into the only exit and leave the row forever. */
|
||||
record('removal does not depend on an agent status, so an archived custom agent stays removable',
|
||||
removeItems.every((block) => !/status/.test(block)),
|
||||
'the remove item reads `shipped`/`overridden` only');
|
||||
|
||||
/* Removal is REVERSIBLE, and the confirmation is where a reader learns that.
|
||||
Copy that promised permanence would be a false statement about what the
|
||||
button does — the agent is archived, not destroyed — so the wording is
|
||||
asserted, including the absence of the two phrases that would be lies. */
|
||||
record('removing a custom agent confirms first, in words that say it is reversible',
|
||||
/'Remove agent\?'/.test(agentsListSource)
|
||||
&& /This agent will be removed from the active Agents list\./.test(agentsListSource)
|
||||
&& /can be restored later from Archived/.test(agentsListSource)
|
||||
&& /'Remove Agent'/.test(agentsListSource),
|
||||
'title, description and confirm label');
|
||||
|
||||
record('...and the confirmation never claims the removal is permanent',
|
||||
!/cannot be undone/i.test(agentsListSource)
|
||||
&& !/permanently (remove|delete)/i.test(agentsListSource),
|
||||
'no irreversibility language anywhere on the screen');
|
||||
|
||||
/* The substance behind that copy: Remove on an authored agent runs ARCHIVE.
|
||||
Only a shipped agent's own override is deleted, and deleting an override is
|
||||
how "Revert to shipped" restores the product's definition — it destroys no
|
||||
agent. If this resolution is ever removed, Remove becomes a hard delete
|
||||
again and every promise above becomes false. */
|
||||
record('Remove archives an authored agent rather than deleting it',
|
||||
/const resolved = action === 'remove' && !isShipped\(agent\.id\) \? 'archive' : action;/
|
||||
.test(agentsListSource),
|
||||
'run() resolves remove -> archive for anything not shipped');
|
||||
|
||||
record('...and the default list stops showing what has been removed',
|
||||
/useState\('active'\)/.test(agentsListSource)
|
||||
&& /if \(status === 'active'\) return found\.filter\(\(a\) => a\.status !== 'archived'\);/
|
||||
.test(agentsListSource),
|
||||
'the default filter is active, which excludes archived');
|
||||
|
||||
record('...while Archived stays reachable as the place removal is undone',
|
||||
/setStatus\('archived'\)/.test(agentsListSource)
|
||||
&& /onAction\('restore', agent\)/.test(agentsListSource),
|
||||
'the Archived tab and Restore as draft both survive');
|
||||
|
||||
/* The tab must be there BEFORE anything is in it. It was rendered behind
|
||||
`archivedCount > 0`, so the one screen that says where a removed agent went
|
||||
only appeared once a reader had already removed one and gone looking — and
|
||||
the confirmation names "Archived" while no such tab is on screen. */
|
||||
record('...and Archived is offered even when it is empty',
|
||||
!/\{archivedCount > 0 && \(/.test(agentsListSource)
|
||||
&& /Archived <span[^>]*>\(\{archivedCount\}\)/.test(agentsListSource),
|
||||
'the tab is unconditional and shows its count');
|
||||
|
||||
record('...and reverting a built-in agent is not worded as a removal',
|
||||
/'Revert to the shipped definition\?'/.test(agentsListSource)
|
||||
&& /isShipped\(confirming\?\.agent\?\.id\) \? 'Confirm' : 'Remove Agent'/.test(agentsListSource),
|
||||
'the shipped path keeps its own copy');
|
||||
|
||||
record('a removal that fails is reported rather than swallowed',
|
||||
/if \(result\?\.error\) toast\.error\(result\.error\)/.test(agentsListSource),
|
||||
'run() surfaces the error the mutation returned');
|
||||
|
||||
/* The gate itself, exercised rather than read: every id this build ships is
|
||||
`shipped`, so none of them reaches the branch that renders Remove. This is
|
||||
the same set `useAgents().isShipped` tests against — `AGENTS`, bundled from
|
||||
the agent definitions under src/agents — so the check moves with the
|
||||
product. */
|
||||
const shippedAgentIds = new Set(ALL_AGENTS.map((a) => a.id));
|
||||
record('every agent this build ships is classified as built-in',
|
||||
ALL_AGENTS.every((a) => shippedAgentIds.has(a.id)),
|
||||
`${shippedAgentIds.size} shipped id(s)`);
|
||||
record('...and an authored agent is not, whatever its status',
|
||||
['draft', 'published', 'archived'].every((status) => {
|
||||
const id = `authored-${status}-agent`;
|
||||
return !shippedAgentIds.has(id);
|
||||
}),
|
||||
'draft, published and archived authored ids are all removable');
|
||||
|
||||
/* ── Remove and restore, exercised end to end ────────────────────────────
|
||||
*
|
||||
* The claim the confirmation makes — "its configuration and skills will be
|
||||
* preserved and it can be rmRestored later" — is a claim about these two
|
||||
* functions, so it is tested against them rather than against the sentence.
|
||||
*
|
||||
* Both are pure over Markdown and the Markdown is the authoritative artefact,
|
||||
* so "nothing was lost" is checkable exactly: the source may differ in the
|
||||
* status line and in nothing else, and the parsed agent may differ in `status`
|
||||
* and in nothing else. A patch that dropped a tool, renumbered a version or
|
||||
* rewrote an id would fail here rather than in somebody's account.
|
||||
*/
|
||||
const rmLifecycle = await server.ssrLoadModule('/src/lib/agents/agentLifecycle.js');
|
||||
|
||||
const rmAuthored = [
|
||||
'---',
|
||||
'id: coverage-helper',
|
||||
'name: Coverage Helper',
|
||||
'description: Finds and offers cover for open shifts.',
|
||||
'status: published',
|
||||
'version: 3',
|
||||
'pages:',
|
||||
' - control-center',
|
||||
' - positions',
|
||||
'tools:',
|
||||
' - workforce.coverage',
|
||||
'skills:',
|
||||
' - create-position',
|
||||
'---',
|
||||
'',
|
||||
'## Instructions',
|
||||
'Find the shifts nobody has taken and say who could take them.',
|
||||
].join('\n');
|
||||
|
||||
const rmStatusless = (src) => src.replace(/^status:.*$/m, 'status: <redacted>');
|
||||
/* `markdown` is excluded because it IS the source, status line and all — the
|
||||
line above already asserts that source byte-for-byte, so comparing it again
|
||||
here would only re-report the one difference both checks expect. Every other
|
||||
parsed field, including tools, skills, pages, instructions and version, is
|
||||
compared. */
|
||||
const rmWithoutStatus = (agent) => {
|
||||
const { status, markdown, ...rest } = agent;
|
||||
return JSON.stringify(rest);
|
||||
};
|
||||
|
||||
const rmRemoved = rmLifecycle.archiveAgent(rmAuthored);
|
||||
const rmRestored = rmLifecycle.restoreAgent(rmRemoved);
|
||||
|
||||
const rmBefore = agentReg.parseAgent(rmAuthored, { custom: true });
|
||||
const rmAfterRemove = agentReg.parseAgent(rmRemoved, { custom: true });
|
||||
const rmAfterRestore = agentReg.parseAgent(rmRestored, { custom: true });
|
||||
|
||||
record('remove: an authored agent becomes archived rather than disappearing',
|
||||
rmAfterRemove.status === 'archived',
|
||||
`status ${rmBefore.status} -> ${rmAfterRemove.status}`);
|
||||
|
||||
record('remove: the definition keeps its identity',
|
||||
rmAfterRemove.id === rmBefore.id && rmAfterRemove.id === 'coverage-helper',
|
||||
rmAfterRemove.id);
|
||||
|
||||
record('remove: instructions, tools, skills, pages and version all survive',
|
||||
rmStatusless(rmRemoved) === rmStatusless(rmAuthored)
|
||||
&& rmWithoutStatus(rmAfterRemove) === rmWithoutStatus(rmBefore),
|
||||
'the source differs in the status line and nothing else');
|
||||
|
||||
record('restore: the SAME agent comes back, as a draft',
|
||||
rmAfterRestore.id === rmBefore.id && rmAfterRestore.status === 'draft',
|
||||
`${rmAfterRestore.id} @ ${rmAfterRestore.status}`);
|
||||
|
||||
record('restore: nothing was lost on the round trip',
|
||||
rmStatusless(rmRestored) === rmStatusless(rmAuthored)
|
||||
&& rmWithoutStatus(rmAfterRestore) === rmWithoutStatus(rmBefore),
|
||||
'archive -> restore is lossless apart from status');
|
||||
|
||||
/* Removing something already archived must not escalate into anything else:
|
||||
it is still that same archived definition afterwards. */
|
||||
const rmRemovedTwice = rmLifecycle.archiveAgent(rmRemoved);
|
||||
record('remove: removing an already-archived agent leaves it archived and whole',
|
||||
agentReg.parseAgent(rmRemovedTwice, { custom: true }).status === 'archived'
|
||||
&& rmStatusless(rmRemovedTwice) === rmStatusless(rmAuthored),
|
||||
'idempotent, and still non-destructive');
|
||||
|
||||
/* ── 18. Conversation records and insights ───────────────────────────────
|
||||
*
|
||||
* Conversations gained fields: which agent answered, where, what it used, and
|
||||
@@ -8774,6 +8960,7 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
const skillE = await server.ssrLoadModule('/src/lib/ui/skillNodes.js');
|
||||
const uiCfgE = await server.ssrLoadModule('/src/lib/skills/uiConfig.js');
|
||||
const editorOps = await server.ssrLoadModule('/src/components/ui-editor/ops.js');
|
||||
const validateE = await server.ssrLoadModule('/src/lib/ui/validate.js');
|
||||
await server.ssrLoadModule('/src/components/ui-tree/nodeTypes.jsx');
|
||||
await server.ssrLoadModule('/src/pages/admin/hired-history/nodes.jsx');
|
||||
await server.ssrLoadModule('/src/pages/admin/positions/nodes.js');
|
||||
@@ -9207,7 +9394,7 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
})().pass);
|
||||
|
||||
record('a failed node renders in place, named, and still addressable', (() => {
|
||||
const boundary = new boundaryE.UiNodeBoundary({ node: { id: 'bad', type: 'chart', props: {} } });
|
||||
const boundary = new boundaryE.UiNodeBoundary({ node: { id: 'bad', type: 'bar-chart', props: {} } });
|
||||
boundary.state = { failed: true, forNode: 'bad' };
|
||||
const html = draw(boundary.render());
|
||||
return {
|
||||
@@ -9552,7 +9739,8 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
})().pass);
|
||||
|
||||
record('replace: an unregistered type is refused', (() => {
|
||||
const r = replaceWith('pie-chart');
|
||||
/* A name nobody registers, and nothing here should ever register. */
|
||||
const r = replaceWith('sculpture');
|
||||
return { pass: !r.ok && /Unsupported UI type/i.test(JSON.stringify(r.problems)),
|
||||
detail: JSON.stringify(r.problems).slice(0, 120) };
|
||||
})().pass);
|
||||
@@ -9612,21 +9800,41 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
})().pass);
|
||||
|
||||
|
||||
/* ── Chart and pie ────────────────────────────────────────────────────
|
||||
/* ── Visualizations ───────────────────────────────────────────────────
|
||||
*
|
||||
* Two node types that are deliberately *not* skill section types, drawing
|
||||
* Five node types that are deliberately *not* skill section types, drawing
|
||||
* the payload a `flow` reading already resolves to. The tests that matter
|
||||
* are the ones about compatibility: nothing may become a chart unless its
|
||||
* reading really carries a series.
|
||||
* are about compatibility, and there are now two kinds: whether a component
|
||||
* *can* draw a reading, and whether doing so would be true.
|
||||
*/
|
||||
|
||||
const VIZ = ['bar-chart', 'line-chart', 'area-chart', 'pie-chart', 'donut-chart'];
|
||||
|
||||
/* What a reading means, asked the same way every consumer asks it. */
|
||||
const seriesMod = await server.ssrLoadModule('/src/lib/ui/series.js');
|
||||
|
||||
const seriesNode = (type, source, extra = {}) => nodeE.makeNode({
|
||||
id: 'viz', type, origin: 'user', props: { title: 'Hiring performance' },
|
||||
data: { source }, layout: { spacingBefore: 'lg' },
|
||||
presentation: { variant: 'emphasis', density: 'compact' }, ...extra,
|
||||
});
|
||||
|
||||
for (const type of ['chart', 'pie']) {
|
||||
/**
|
||||
* A reading whose meaning this visualization can honestly draw.
|
||||
*
|
||||
* There is no single source that fits all five, and that is the point: a
|
||||
* line may draw a reading that is ordered, a pie may draw one whose points
|
||||
* are parts of a whole, and no reading is both. Picked from what the source
|
||||
* itself declares rather than assumed.
|
||||
*/
|
||||
const READING_OF_KIND = {
|
||||
periodic: 'candidates.activity',
|
||||
cumulative: 'position.pipeline',
|
||||
parts: 'activity.breakdown',
|
||||
};
|
||||
const readingFor = (type) => READING_OF_KIND[nreg.get(type).seriesKinds[0]];
|
||||
|
||||
for (const type of VIZ) {
|
||||
record(`${type}: is a registered node type with a real component`, (() => {
|
||||
const entry = nreg.get(type);
|
||||
return { pass: Boolean(entry) && typeof entry.component === 'function' && entry.dataRequired,
|
||||
@@ -9639,6 +9847,12 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
detail: entry.dataShapes.join(', ') };
|
||||
})().pass);
|
||||
|
||||
record(`${type}: declares which series meanings it can honestly draw`, (() => {
|
||||
const entry = nreg.get(type);
|
||||
const known = entry.seriesKinds.every((k) => nodeE.SERIES_KINDS.includes(k));
|
||||
return { pass: entry.seriesKinds.length > 0 && known, detail: entry.seriesKinds.join(', ') };
|
||||
})().pass);
|
||||
|
||||
record(`${type}: supports the presentation vocabulary it honours`, (() => {
|
||||
const entry = nreg.get(type);
|
||||
return { pass: entry.variants.length === 3 && entry.densities.length === 2,
|
||||
@@ -9646,15 +9860,28 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
})().pass);
|
||||
|
||||
record(`${type}: a card on a series reading can become one`, (() => {
|
||||
const r = opsE.applyOperation([seriesNode('card', 'hires.performance')],
|
||||
const source = readingFor(type);
|
||||
const r = opsE.applyOperation([seriesNode('card', source)],
|
||||
{ op: 'replace', target: 'viz', type }, ectx);
|
||||
const after = r.ok ? nodeE.findNode(r.tree, 'viz') : null;
|
||||
return { pass: r.ok && after.type === type && after.id === 'viz'
|
||||
&& after.data.source === 'hires.performance', detail: JSON.stringify(r.problems) };
|
||||
&& after.data.source === source, detail: JSON.stringify(r.problems) };
|
||||
})().pass);
|
||||
|
||||
/* Gap 4: a reading whose meaning was never declared is refused, not
|
||||
guessed at. `hires.performance` draws `flow` and `stats` — structurally
|
||||
every chart could take it — and says nothing about what its figures
|
||||
mean, which used to be read as consent. */
|
||||
record(`${type}: a reading of unknown meaning is refused, not guessed`, (() => {
|
||||
const before = [seriesNode('card', 'hires.performance')];
|
||||
const r = opsE.applyOperation(before, { op: 'replace', target: 'viz', type }, ectx);
|
||||
const said = JSON.stringify(r.problems);
|
||||
return { pass: !r.ok && r.tree === before && /does not say what its figures mean/i.test(said),
|
||||
detail: said.slice(0, 170) };
|
||||
})().pass);
|
||||
|
||||
record(`${type}: replacement keeps layout, presentation and title`, (() => {
|
||||
const after = opsE.applyOperation([seriesNode('card', 'hires.performance')],
|
||||
const after = opsE.applyOperation([seriesNode('card', readingFor(type))],
|
||||
{ op: 'replace', target: 'viz', type }, ectx).tree[0];
|
||||
return { pass: after.layout.spacingBefore === 'lg' && after.presentation.variant === 'emphasis'
|
||||
&& after.presentation.density === 'compact' && after.props.title === 'Hiring performance',
|
||||
@@ -9678,42 +9905,571 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
})().pass);
|
||||
|
||||
record(`${type}: the editor offers it only where the reading fits`, (() => {
|
||||
const fits = nreg.replacements('card',
|
||||
{ shapes: surfaces.dataSourceFor('hires.performance').shapes, page: 'analytics' });
|
||||
const doesNot = nreg.replacements('list',
|
||||
{ shapes: surfaces.dataSourceFor('position.requirements').shapes, page: 'analytics' });
|
||||
return { pass: fits.includes(type) && !doesNot.includes(type),
|
||||
detail: `fits: ${fits.join(',')} | not: ${doesNot.join(',')}` };
|
||||
const source = readingFor(type);
|
||||
const fits = nreg.replacements('card', {
|
||||
shapes: surfaces.dataSourceFor(source).shapes, page: 'analytics',
|
||||
seriesKind: seriesMod.kindOfBinding({ source }), bound: true,
|
||||
});
|
||||
const doesNot = nreg.replacements('list', {
|
||||
shapes: surfaces.dataSourceFor('position.requirements').shapes, page: 'analytics',
|
||||
seriesKind: seriesMod.kindOfBinding({ source: 'position.requirements' }), bound: true,
|
||||
});
|
||||
/* And the one gap 4 is about: structurally able, meaning undeclared. */
|
||||
const unknown = nreg.replacements('card', {
|
||||
shapes: surfaces.dataSourceFor('hires.performance').shapes, page: 'analytics',
|
||||
seriesKind: seriesMod.kindOfBinding({ source: 'hires.performance' }), bound: true,
|
||||
});
|
||||
return { pass: fits.includes(type) && !doesNot.includes(type) && !unknown.includes(type),
|
||||
detail: `fits: ${fits.join(',')} | not: ${doesNot.join(',')} | unknown: ${unknown.join(',') || 'none'}` };
|
||||
})().pass);
|
||||
|
||||
record(`${type}: Owliver plans the same operation the editor emits`, (() => {
|
||||
const tree = [seriesNode('card', 'hires.performance')];
|
||||
const tree = [seriesNode('card', readingFor(type))];
|
||||
const node = inspectE.inspectTree(tree, ectx)[0];
|
||||
const fromEditor = editorOps.replaceOp(node, type);
|
||||
const phrase = type === 'pie' ? 'change the Hiring performance to a pie' : 'change the Hiring performance to a chart';
|
||||
const fromOwliver = intentE.matchUiEdit(phrase, { tree, registry: nreg, page: 'analytics' });
|
||||
const label = nreg.get(type).label.toLowerCase();
|
||||
const fromOwliver = intentE.matchUiEdit(
|
||||
`change the Hiring performance to a ${label}`, { tree, registry: nreg, page: 'analytics' });
|
||||
return { pass: JSON.stringify(fromEditor) === JSON.stringify(fromOwliver?.op),
|
||||
detail: `${JSON.stringify(fromEditor)} vs ${JSON.stringify(fromOwliver?.op)}` };
|
||||
})().pass);
|
||||
}
|
||||
|
||||
record('chart/pie: are not skill section types', (() => {
|
||||
record('visualizations: none of them is a skill section type', (() => {
|
||||
/* The MD vocabulary is untouched, so the Go parser and its oracle are too. */
|
||||
const inMd = surfaces.SUPPORTED_SECTION_TYPES.filter((t) => t === 'chart' || t === 'pie');
|
||||
const inMd = surfaces.SUPPORTED_SECTION_TYPES.filter((t) => VIZ.includes(t));
|
||||
return { pass: inMd.length === 0, detail: surfaces.SUPPORTED_SECTION_TYPES.join(',') };
|
||||
})().pass);
|
||||
|
||||
record('chart/pie: ordinary data questions do not become visualisations', (() => {
|
||||
record('visualizations: ordinary data questions do not become visualisations', (() => {
|
||||
const tree = [seriesNode('card', 'hires.performance')];
|
||||
const ordinary = ['What is on this page?', 'Show recent hires', 'How many candidates applied?',
|
||||
'What is the hiring trend?', 'Compare the top candidates'];
|
||||
const taken = ordinary.filter((q) => {
|
||||
const m = intentE.matchUiEdit(q, { tree, registry: nreg, page: 'analytics' });
|
||||
return m?.kind === 'plan' && (m.op?.type === 'chart' || m.op?.type === 'pie');
|
||||
return m?.kind === 'plan' && VIZ.includes(m.op?.type);
|
||||
});
|
||||
return { pass: taken.length === 0, detail: taken.join(' | ') || 'none taken' };
|
||||
})().pass);
|
||||
|
||||
/* ── Series registry and meaning ──────────────────────────────────────
|
||||
*
|
||||
* The half `dataShapes` could not express. A `flow` is, in the section
|
||||
* vocabulary's own words, "a sequence of stages *or* periods" — so these
|
||||
* check that the difference is now declared, enforced, and explained.
|
||||
*/
|
||||
{
|
||||
const seriesE = await server.ssrLoadModule('/src/lib/ui/series.js');
|
||||
|
||||
record('series: the kind vocabulary is closed', () => true);
|
||||
record('series: the kind vocabulary is closed',
|
||||
JSON.stringify(nodeE.SERIES_KINDS) === JSON.stringify(['periodic', 'cumulative', 'parts']),
|
||||
nodeE.SERIES_KINDS.join(', '));
|
||||
|
||||
record('series: Control Center publishes its hiring activity', (() => {
|
||||
const entry = seriesE.seriesFor('control-center.hiring-activity');
|
||||
return { pass: Boolean(entry) && entry.kind === 'periodic' && entry.measures.length === 4,
|
||||
detail: entry ? `${entry.kind} · ${entry.measures.map((m) => m.key).join(',')}` : 'missing' };
|
||||
})().pass);
|
||||
|
||||
record('series: a registration with an unknown kind is refused', (() => {
|
||||
try {
|
||||
seriesE.registerSeries({ id: 'x.y', kind: 'vibes', measures: [{ key: 'a', label: 'A' }], read: () => [] });
|
||||
return false;
|
||||
} catch (e) { return /unknown kind/i.test(e.message); }
|
||||
})());
|
||||
|
||||
record('series: a registration with no measure is refused', (() => {
|
||||
try {
|
||||
seriesE.registerSeries({ id: 'x.z', kind: 'parts', measures: [], read: () => [] });
|
||||
return false;
|
||||
} catch (e) { return /at least one measure/i.test(e.message); }
|
||||
})());
|
||||
|
||||
record('series: a registration with no read is refused', (() => {
|
||||
try {
|
||||
seriesE.registerSeries({ id: 'x.w', kind: 'parts', measures: [{ key: 'a', label: 'A' }] });
|
||||
return false;
|
||||
} catch (e) { return /needs a `read/i.test(e.message); }
|
||||
})());
|
||||
|
||||
record('series: a reading bound to periods is periodic', (() => {
|
||||
const kind = seriesE.kindOfBinding({ source: 'candidates.activity', params: { periods: ['today'] } });
|
||||
return { pass: kind === 'periodic', detail: String(kind) };
|
||||
})().pass);
|
||||
|
||||
record('series: a binding that cannot say vetoes nothing', (() => {
|
||||
const kind = seriesE.kindOfBinding({ source: 'hires.performance', params: {} });
|
||||
return { pass: kind === null, detail: String(kind) };
|
||||
})().pass);
|
||||
|
||||
record('series: a page series resolves the page\u2019s own rows, unchanged', (() => {
|
||||
const rows = [{ label: '9/1', applications: 3, screened: 2, interviews: 1, hires: 0 }];
|
||||
const out = seriesE.readSeries({ series: 'control-center.hiring-activity' },
|
||||
{ context: { activitySeries: rows } });
|
||||
return { pass: out.rows.length === 1 && out.rows[0].applications === 3
|
||||
&& out.rows[0].label === '9/1' && out.measures.length === 4,
|
||||
detail: JSON.stringify(out.rows) };
|
||||
})().pass);
|
||||
|
||||
record('series: nothing is invented when the page published nothing', (() => {
|
||||
const out = seriesE.readSeries({ series: 'control-center.hiring-activity' }, { context: {} });
|
||||
return { pass: out.rows.length === 0, detail: JSON.stringify(out.rows) };
|
||||
})().pass);
|
||||
}
|
||||
|
||||
/* ── Built-in chart → another visualization ───────────────────────────
|
||||
*
|
||||
* The whole point, end to end on the real page composition: the section
|
||||
* Control Center ships is bound to a series it publishes, and that is the
|
||||
* only reason it can be drawn another way.
|
||||
*/
|
||||
{
|
||||
const seriesE = await server.ssrLoadModule('/src/lib/ui/series.js');
|
||||
await server.ssrLoadModule('/src/pages/admin/control-center/nodes.jsx');
|
||||
const ccTree = () => compE.composePage('control-center', ectx).tree;
|
||||
const activity = () => ccTree().find((n) => n.id === 'cc-activity');
|
||||
|
||||
record('built-in: Hiring activity is bound to the page\u2019s own series', (() => {
|
||||
const node = activity();
|
||||
return { pass: node?.data?.series === 'control-center.hiring-activity',
|
||||
detail: JSON.stringify(node?.data) };
|
||||
})().pass);
|
||||
|
||||
{
|
||||
const verdict = validateE.validateTree(ccTree(), ectx);
|
||||
record('built-in: it composes and validates as it ships',
|
||||
verdict.ok === true, JSON.stringify(verdict.problems).slice(0, 260));
|
||||
}
|
||||
|
||||
for (const type of ['bar-chart', 'line-chart', 'area-chart']) {
|
||||
record(`built-in: Hiring activity can become a ${type}`, (() => {
|
||||
const r = opsE.applyOperation(ccTree(), { op: 'replace', target: 'cc-activity', type }, ectx);
|
||||
const after = r.ok ? nodeE.findNode(r.tree, 'cc-activity') : null;
|
||||
return { pass: r.ok && after.type === type
|
||||
&& after.id === 'cc-activity'
|
||||
&& after.data.series === 'control-center.hiring-activity'
|
||||
&& after.origin === 'builtin',
|
||||
detail: JSON.stringify(r.problems).slice(0, 160) };
|
||||
})().pass);
|
||||
}
|
||||
|
||||
for (const type of ['pie-chart', 'donut-chart']) {
|
||||
record(`built-in: Hiring activity refuses ${type}, and says why`, (() => {
|
||||
const r = opsE.applyOperation(ccTree(), { op: 'replace', target: 'cc-activity', type }, ectx);
|
||||
const said = JSON.stringify(r.problems);
|
||||
return { pass: !r.ok && /ordered in time/i.test(said), detail: said.slice(0, 170) };
|
||||
})().pass);
|
||||
}
|
||||
|
||||
record('built-in: a refused visualization mutates nothing', (() => {
|
||||
const before = ccTree();
|
||||
const r = opsE.applyOperation(before, { op: 'replace', target: 'cc-activity', type: 'pie-chart' }, ectx);
|
||||
return { pass: !r.ok && r.tree === before, detail: String(r.ok) };
|
||||
})().pass);
|
||||
|
||||
record('built-in: the offering is the registry\u2019s, and excludes the untrue ones', (() => {
|
||||
const node = activity();
|
||||
const offered = nreg.replacements(node.type, {
|
||||
page: 'control-center',
|
||||
seriesKind: seriesE.kindOfBinding(node.data),
|
||||
seriesBound: true,
|
||||
bound: true,
|
||||
});
|
||||
return { pass: offered.includes('bar-chart') && offered.includes('line-chart')
|
||||
&& offered.includes('area-chart') && !offered.includes('pie-chart')
|
||||
&& !offered.includes('donut-chart'),
|
||||
detail: offered.join(', ') };
|
||||
})().pass);
|
||||
|
||||
record('built-in: Owliver and the editor emit the same operation', (() => {
|
||||
const tree = ccTree();
|
||||
const node = inspectE.inspectTree(tree, { ...ectx, page: 'control-center' })
|
||||
.find((n) => n.id === 'cc-activity');
|
||||
const fromEditor = editorOps.replaceOp(node, 'bar-chart');
|
||||
const fromOwliver = intentE.matchUiEdit('Change Hiring activity to a bar chart',
|
||||
{ tree, registry: nreg, page: 'control-center' });
|
||||
return { pass: JSON.stringify(fromEditor) === JSON.stringify(fromOwliver?.op),
|
||||
detail: `${JSON.stringify(fromEditor)} vs ${JSON.stringify(fromOwliver?.op)}` };
|
||||
})().pass);
|
||||
|
||||
record('built-in: a bar chart can be changed back to a line chart', (() => {
|
||||
const barred = opsE.applyOperation(ccTree(),
|
||||
{ op: 'replace', target: 'cc-activity', type: 'bar-chart' }, ectx).tree;
|
||||
const r = opsE.applyOperation(barred, { op: 'replace', target: 'cc-activity', type: 'line-chart' }, ectx);
|
||||
const after = r.ok ? nodeE.findNode(r.tree, 'cc-activity') : null;
|
||||
return { pass: r.ok && after.type === 'line-chart'
|
||||
&& after.data.series === 'control-center.hiring-activity',
|
||||
detail: JSON.stringify(r.problems).slice(0, 140) };
|
||||
})().pass);
|
||||
|
||||
record('built-in: a stored patch replays the change from cold', (() => {
|
||||
const patch = { ops: [{ op: 'replace', target: 'cc-activity', type: 'bar-chart' }] };
|
||||
const round = JSON.parse(JSON.stringify(patch));
|
||||
const out = compE.composePage('control-center', { patch: round, ...ectx });
|
||||
const after = out.tree.find((n) => n.id === 'cc-activity');
|
||||
return { pass: after?.type === 'bar-chart' && out.skipped.length === 0,
|
||||
detail: `${after?.type} · skipped ${out.skipped.length}` };
|
||||
})().pass);
|
||||
|
||||
record('built-in: a stored patch that became untrue is refused on load', (() => {
|
||||
const patch = { ops: [{ op: 'replace', target: 'cc-activity', type: 'pie-chart' }] };
|
||||
const out = compE.composePage('control-center', { patch, ...ectx });
|
||||
const after = out.tree.find((n) => n.id === 'cc-activity');
|
||||
return { pass: after?.type === 'cc-activity' && out.skipped.length === 1,
|
||||
detail: `${after?.type} · skipped ${out.skipped.length}` };
|
||||
})().pass);
|
||||
}
|
||||
|
||||
/* ── Continuity, controls, and knowing what a reading means ───────────
|
||||
*
|
||||
* The four gaps the live verification found, each as the smallest test
|
||||
* that would have caught it.
|
||||
*/
|
||||
{
|
||||
const seriesE = await server.ssrLoadModule('/src/lib/ui/series.js');
|
||||
const ccTree = () => compE.composePage('control-center', ectx).tree;
|
||||
const barred = () => opsE.applyOperation(ccTree(),
|
||||
{ op: 'replace', target: 'cc-activity', type: 'bar-chart' }, ectx).tree;
|
||||
const ask = (q, opts = {}) => intentE.matchUiEdit(q,
|
||||
{ tree: ccTree(), registry: nreg, page: 'control-center', ...opts });
|
||||
|
||||
/* ── A. "Change it back to a line chart." ─────────────────────────── */
|
||||
|
||||
record('continuity: a replaced node keeps the name of what it reads', (() => {
|
||||
const node = inspectE.inspectTree(barred(), ectx).find((n) => n.id === 'cc-activity');
|
||||
return { pass: node?.type === 'bar-chart' && node?.title === 'Hiring activity',
|
||||
detail: `${node?.type} · ${node?.title}` };
|
||||
})().pass);
|
||||
|
||||
record('continuity: it is still addressable by that name after replacement', (() => {
|
||||
const m = intentE.matchUiEdit('Change Hiring activity to a line chart',
|
||||
{ tree: barred(), registry: nreg, page: 'control-center' });
|
||||
return { pass: m?.kind === 'plan' && m.op.target === 'cc-activity' && m.op.type === 'line-chart',
|
||||
detail: `${m?.kind}: ${JSON.stringify(m?.op)}` };
|
||||
})().pass);
|
||||
|
||||
record('continuity: "change it back to a line chart" resolves to what was changed', (() => {
|
||||
const m = intentE.matchUiEdit('Change it back to a line chart',
|
||||
{ tree: barred(), registry: nreg, page: 'control-center', focus: 'cc-activity' });
|
||||
return { pass: m?.kind === 'plan' && m.op.op === 'replace'
|
||||
&& m.op.target === 'cc-activity' && m.op.type === 'line-chart',
|
||||
detail: `${m?.kind}: ${JSON.stringify(m?.op)}` };
|
||||
})().pass);
|
||||
|
||||
record('continuity: the same sentence with no subject is still unknown', (() => {
|
||||
const m = intentE.matchUiEdit('Change it back to a line chart',
|
||||
{ tree: barred(), registry: nreg, page: 'control-center' });
|
||||
return { pass: m?.kind === 'unknown', detail: String(m?.kind) };
|
||||
})().pass);
|
||||
|
||||
record('continuity: a request that names something else is never redirected', (() => {
|
||||
/* Focus is a last resort, not a preference: the words still win. */
|
||||
const m = intentE.matchUiEdit('Hide the pipeline intelligence',
|
||||
{ tree: ccTree(), registry: nreg, page: 'control-center', focus: 'cc-activity' });
|
||||
return { pass: m?.kind === 'plan' && m.op.target === 'cc-pipeline',
|
||||
detail: JSON.stringify(m?.op) };
|
||||
})().pass);
|
||||
|
||||
record('continuity: a focus that has left the page resolves to nothing', (() => {
|
||||
const m = intentE.matchUiEdit('Change it back to a line chart',
|
||||
{ tree: barred(), registry: nreg, page: 'control-center', focus: 'no-such-node' });
|
||||
return { pass: m?.kind === 'unknown', detail: String(m?.kind) };
|
||||
})().pass);
|
||||
|
||||
record('continuity: a phrase with no pronoun does not reach for the focus', (() => {
|
||||
const m = intentE.matchUiEdit('Change the audit log to a table',
|
||||
{ tree: ccTree(), registry: nreg, page: 'control-center', focus: 'cc-activity' });
|
||||
return { pass: m?.kind !== 'plan' || m.op.target !== 'cc-activity', detail: String(m?.kind) };
|
||||
})().pass);
|
||||
|
||||
record('continuity: the data is untouched across bar → it → line', (() => {
|
||||
const rows = [{ label: '9/1', applications: 4, screened: 2, interviews: 1, hires: 1 }];
|
||||
const read = (tree) => seriesE.readSeries(tree.find((n) => n.id === 'cc-activity').data,
|
||||
{ context: { activitySeries: rows } });
|
||||
const before = read(ccTree());
|
||||
const asBar = read(barred());
|
||||
const m = intentE.matchUiEdit('Change it back to a line chart',
|
||||
{ tree: barred(), registry: nreg, page: 'control-center', focus: 'cc-activity' });
|
||||
const asLine = read(opsE.applyOperation(barred(), m.op, ectx).tree);
|
||||
const same = JSON.stringify(before.rows) === JSON.stringify(asBar.rows)
|
||||
&& JSON.stringify(asBar.rows) === JSON.stringify(asLine.rows);
|
||||
return { pass: same && before.rows.length === 1 && before.rows[0].applications === 4,
|
||||
detail: JSON.stringify(asLine.rows) };
|
||||
})().pass);
|
||||
|
||||
/* ── B. The control survives the replacement ──────────────────────── */
|
||||
|
||||
record('control: the reading publishes the range, not the component', (() => {
|
||||
const entry = seriesE.seriesFor('control-center.hiring-activity');
|
||||
const values = (entry?.control?.options || []).map((o) => o.value);
|
||||
return { pass: JSON.stringify(values) === JSON.stringify(['7d', '30d', '90d']),
|
||||
detail: values.join(', ') || 'none' };
|
||||
})().pass);
|
||||
|
||||
for (const type of ['bar-chart', 'line-chart', 'area-chart']) {
|
||||
record(`control: 7D/30D/90D is still there after becoming a ${type}`, (() => {
|
||||
const tree = opsE.applyOperation(ccTree(),
|
||||
{ op: 'replace', target: 'cc-activity', type }, ectx).tree;
|
||||
const node = tree.find((n) => n.id === 'cc-activity');
|
||||
const control = seriesE.controlOfBinding(node.data, { range: '30d', setRange: () => {} });
|
||||
return { pass: Boolean(control) && control.value === '30d'
|
||||
&& control.options.length === 3 && control.label.length > 0,
|
||||
detail: control ? `${control.label} · ${control.options.map((o) => o.label).join('/')}` : 'lost' };
|
||||
})().pass);
|
||||
}
|
||||
|
||||
record('control: choosing a range calls the page back, and nothing else', (() => {
|
||||
const seen = [];
|
||||
const control = seriesE.controlOfBinding({ series: 'control-center.hiring-activity' },
|
||||
{ range: '7d', setRange: (v) => seen.push(v) });
|
||||
control.set('90d');
|
||||
return { pass: seen.length === 1 && seen[0] === '90d', detail: seen.join(',') };
|
||||
})().pass);
|
||||
|
||||
record('control: none is invented when the page publishes none', (() => {
|
||||
const control = seriesE.controlOfBinding({ series: 'control-center.hiring-activity' }, {});
|
||||
return { pass: control === null, detail: JSON.stringify(control) };
|
||||
})().pass);
|
||||
|
||||
record('control: a reading that declares none has none', (() => {
|
||||
const control = seriesE.controlOfBinding({ source: 'candidates.activity' }, { range: '7d' });
|
||||
return { pass: control === null, detail: JSON.stringify(control) };
|
||||
})().pass);
|
||||
|
||||
record('control: a control with no option is refused at registration', (() => {
|
||||
try {
|
||||
seriesE.registerSeries({ id: 'x.control', kind: 'parts', measures: [{ key: 'a', label: 'A' }],
|
||||
read: () => [], control: { id: 'r', options: [], read: () => null, write: () => {} } });
|
||||
return false;
|
||||
} catch (e) { return /at least one option/i.test(e.message); }
|
||||
})());
|
||||
|
||||
record('control: a control with no writer is refused at registration', (() => {
|
||||
try {
|
||||
seriesE.registerSeries({ id: 'x.control2', kind: 'parts', measures: [{ key: 'a', label: 'A' }],
|
||||
read: () => [], control: { id: 'r', options: [{ value: 'a', label: 'A' }], read: () => null } });
|
||||
return false;
|
||||
} catch (e) { return /needs a `read` and a `write`/i.test(e.message); }
|
||||
})());
|
||||
|
||||
record('replace: identity, binding, origin and visibility all survive', (() => {
|
||||
const before = ccTree().find((n) => n.id === 'cc-activity');
|
||||
const after = barred().find((n) => n.id === 'cc-activity');
|
||||
return { pass: after.id === before.id && after.origin === before.origin
|
||||
&& Boolean(after.hidden) === Boolean(before.hidden)
|
||||
&& JSON.stringify(after.data) === JSON.stringify(before.data),
|
||||
detail: JSON.stringify({ id: after.id, origin: after.origin, data: after.data }) };
|
||||
})().pass);
|
||||
|
||||
/* ── C. Asking for alternatives, in the words people use ──────────── */
|
||||
|
||||
for (const phrase of [
|
||||
"I don't like this design. Show me other options.",
|
||||
'Can you give me another visualization?',
|
||||
'What else can I use?',
|
||||
'Give me some alternatives.',
|
||||
'I want a different chart.',
|
||||
]) {
|
||||
record(`alternatives: "${phrase.slice(0, 40)}…" is answered without a model`, (() => {
|
||||
/* `matchUiEdit` is a pure function of the tree and the registry: there
|
||||
is no provider to reach and nothing to await, so an answer here is
|
||||
an answer with zero model calls by construction. */
|
||||
const m = ask(phrase, { focus: 'cc-activity' });
|
||||
const types = (m?.options || []).map((o) => o.type);
|
||||
return { pass: m?.kind === 'options' && types.length > 0
|
||||
&& types.every((t) => Boolean(nreg.get(t))),
|
||||
detail: `${m?.kind}: ${types.join(', ') || '—'}` };
|
||||
})().pass);
|
||||
}
|
||||
|
||||
record('alternatives: the answer is the registry\u2019s, and excludes the untrue', (() => {
|
||||
const m = ask('Show me other options.', { focus: 'cc-activity' });
|
||||
const types = (m?.options || []).map((o) => o.type);
|
||||
return { pass: types.includes('bar-chart') && types.includes('area-chart')
|
||||
&& !types.includes('pie-chart') && !types.includes('donut-chart'),
|
||||
detail: types.join(', ') };
|
||||
})().pass);
|
||||
|
||||
record('alternatives: every one offered is one replace would accept', (() => {
|
||||
const m = ask('I want a different chart.', { focus: 'cc-activity' });
|
||||
const bad = (m?.options || []).filter((o) => (
|
||||
!opsE.applyOperation(ccTree(), { op: 'replace', target: 'cc-activity', type: o.type }, ectx).ok
|
||||
));
|
||||
return { pass: (m?.options || []).length > 0 && bad.length === 0,
|
||||
detail: bad.map((o) => o.type).join(', ') || 'none' };
|
||||
})().pass);
|
||||
|
||||
record('alternatives: an ordinary question is still left alone', (() => {
|
||||
const left = [
|
||||
'What are my options for this position?',
|
||||
'What other candidates applied?',
|
||||
'Give me an alternative candidate',
|
||||
'Show me another worker for Friday',
|
||||
'Is there a different applicant for this shift?',
|
||||
].filter((q) => ask(q, { focus: 'cc-activity' }) !== null);
|
||||
return { pass: left.length === 0, detail: left.join(' | ') || 'none taken' };
|
||||
})().pass);
|
||||
|
||||
/* ── D. Meaning that was never established ────────────────────────── */
|
||||
|
||||
record('meaning: a reading that declares one is believed', (() => {
|
||||
const declared = ['position.activity', 'candidates.activity', 'position.pipeline',
|
||||
'candidates.pipeline', 'activity.breakdown']
|
||||
.map((id) => [id, seriesE.kindOfBinding({ source: id })]);
|
||||
const bad = declared.filter(([, kind]) => !nodeE.SERIES_KINDS.includes(kind));
|
||||
return { pass: bad.length === 0, detail: declared.map(([i, k]) => `${i}=${k}`).join(' ') };
|
||||
})().pass);
|
||||
|
||||
record('meaning: every declared kind is one of the closed three', (() => {
|
||||
const bad = surfaces.DATA_SOURCES
|
||||
.filter((source) => source.series != null)
|
||||
.filter((source) => !nodeE.SERIES_KINDS.includes(source.series));
|
||||
return { pass: bad.length === 0, detail: bad.map((s) => `${s.id}=${s.series}`).join(', ') || 'all known' };
|
||||
})().pass);
|
||||
|
||||
record('meaning: a reading that declares none is refused rather than guessed', (() => {
|
||||
const node = nodeE.makeNode({ id: 'viz', type: 'card', origin: 'user',
|
||||
data: { source: 'hires.performance' } });
|
||||
const m = intentE.matchUiEdit('Change the Hiring performance card to a pie chart',
|
||||
{ tree: [node], registry: nreg, page: 'analytics' });
|
||||
return { pass: m?.kind === 'refused' && /does not say what its figures mean/i.test(m.message),
|
||||
detail: `${m?.kind}: ${m?.message}` };
|
||||
})().pass);
|
||||
|
||||
record('meaning: nor is it offered in the first place', (() => {
|
||||
const node = nodeE.makeNode({ id: 'viz', type: 'card', origin: 'user',
|
||||
data: { source: 'hires.performance' } });
|
||||
const m = intentE.matchUiEdit('Show me other options for the card',
|
||||
{ tree: [node], registry: nreg, page: 'analytics', focus: 'viz' });
|
||||
const types = (m?.options || []).map((o) => o.type);
|
||||
const charts = types.filter((t) => nreg.get(t).seriesKinds.length > 0);
|
||||
return { pass: charts.length === 0, detail: types.join(', ') || 'nothing offered' };
|
||||
})().pass);
|
||||
|
||||
record('meaning: a stored patch that never established one is refused on load', (() => {
|
||||
const tree = [nodeE.makeNode({ id: 'viz', type: 'bar-chart', origin: 'user',
|
||||
data: { source: 'hires.performance' } })];
|
||||
const verdict = validateE.validateTree(tree, ectx);
|
||||
return { pass: !verdict.ok, detail: JSON.stringify(verdict.problems).slice(0, 170) };
|
||||
})().pass);
|
||||
|
||||
record('meaning: components that claim no meaning are untouched by the rule', (() => {
|
||||
/* The Card → Table path, which says nothing about what its rows mean
|
||||
and must keep working exactly as it did. */
|
||||
const node = nodeE.makeNode({ id: 'viz', type: 'card', origin: 'user',
|
||||
props: { title: 'Recent hires' }, data: { source: 'hires.recent' } });
|
||||
const r = opsE.applyOperation([node], { op: 'replace', target: 'viz', type: 'table' }, ectx);
|
||||
const after = r.ok ? nodeE.findNode(r.tree, 'viz') : null;
|
||||
return { pass: r.ok && after.type === 'table' && after.data.source === 'hires.recent'
|
||||
&& after.props.title === 'Recent hires',
|
||||
detail: JSON.stringify(r.problems).slice(0, 150) };
|
||||
})().pass);
|
||||
}
|
||||
|
||||
/* ── Asking for the alternatives ──────────────────────────────────────
|
||||
*
|
||||
* The request that most invites a model to invent something. Every answer
|
||||
* here is the registry's, and it is the same list the replace path checks
|
||||
* a single name against.
|
||||
*/
|
||||
{
|
||||
const ccTree = () => compE.composePage('control-center', ectx).tree;
|
||||
const ask = (q) => intentE.matchUiEdit(q, { tree: ccTree(), registry: nreg, page: 'control-center' });
|
||||
|
||||
for (const phrase of [
|
||||
'Show me other options for the Hiring activity',
|
||||
'I do not like this design, show me other designs for Hiring activity',
|
||||
'What else could the Hiring activity be',
|
||||
'Give me alternatives for the Hiring activity',
|
||||
]) {
|
||||
record(`options: "${phrase.slice(0, 44)}…" is answered from the registry`, (() => {
|
||||
const m = ask(phrase);
|
||||
const types = (m?.options || []).map((o) => o.type);
|
||||
return { pass: m?.kind === 'options' && types.length > 0
|
||||
&& types.every((t) => Boolean(nreg.get(t)))
|
||||
&& !types.includes('pie-chart'),
|
||||
detail: `${m?.kind}: ${types.join(', ')}` };
|
||||
})().pass);
|
||||
}
|
||||
|
||||
record('options: at most four are offered', (() => {
|
||||
const m = ask('Show me other options for the Hiring activity');
|
||||
return { pass: (m?.options || []).length <= 4, detail: String(m?.options?.length) };
|
||||
})().pass);
|
||||
|
||||
record('options: every option is one replace would accept', (() => {
|
||||
const m = ask('Show me other options for the Hiring activity');
|
||||
const bad = (m?.options || []).filter((o) => (
|
||||
!opsE.applyOperation(ccTree(), { op: 'replace', target: 'cc-activity', type: o.type }, ectx).ok
|
||||
));
|
||||
return { pass: bad.length === 0, detail: bad.map((o) => o.type).join(', ') || 'none' };
|
||||
})().pass);
|
||||
|
||||
record('options: an ordinary question about options is left alone', (() => {
|
||||
const left = ['What are my options for this position?', 'Show me the candidates',
|
||||
'What other candidates applied?', 'Give me an alternative candidate']
|
||||
.filter((q) => ask(q) !== null);
|
||||
return { pass: left.length === 0, detail: left.join(' | ') || 'none taken' };
|
||||
})().pass);
|
||||
|
||||
record('options: a refusal offers the alternatives', (() => {
|
||||
const m = ask('Change Hiring activity to a pie chart');
|
||||
const labels = (m?.alternatives || []).map((a) => a.type);
|
||||
return { pass: m?.kind === 'refused' && /ordered in time/i.test(m.message)
|
||||
&& labels.includes('bar-chart') && !labels.includes('pie-chart'),
|
||||
detail: `${m?.message} → ${labels.join(', ')}` };
|
||||
})().pass);
|
||||
}
|
||||
|
||||
/* ── Safety ───────────────────────────────────────────────────────────
|
||||
*
|
||||
* Nothing a person can type produces markup, a class name or a component.
|
||||
*/
|
||||
{
|
||||
const ccTree = () => compE.composePage('control-center', ectx).tree;
|
||||
const ask = (q) => intentE.matchUiEdit(q, { tree: ccTree(), registry: nreg, page: 'control-center' });
|
||||
|
||||
record('safety: an invented visualization type is refused, not improvised', (() => {
|
||||
const m = ask('Turn the Hiring activity into a neon 3D holographic chart');
|
||||
const planned = m?.kind === 'plan';
|
||||
return { pass: !planned, detail: `${m?.kind}` };
|
||||
})().pass);
|
||||
|
||||
record('safety: an unregistered type never reaches the tree', (() => {
|
||||
const r = opsE.applyOperation(ccTree(),
|
||||
{ op: 'replace', target: 'cc-activity', type: 'holograph' }, ectx);
|
||||
return { pass: !r.ok && /Unsupported UI type/i.test(JSON.stringify(r.problems)),
|
||||
detail: JSON.stringify(r.problems).slice(0, 120) };
|
||||
})().pass);
|
||||
|
||||
record('safety: a request for custom CSS produces no operation', (() => {
|
||||
const bad = ['Give this card some custom CSS', 'Add class bg-red-500 to the Hiring activity',
|
||||
'Set the style of the Hiring activity to display:none',
|
||||
'Inject <script>alert(1)</script> into the Hiring activity']
|
||||
.filter((q) => ask(q)?.kind === 'plan');
|
||||
return { pass: bad.length === 0, detail: bad.join(' | ') || 'none planned' };
|
||||
})().pass);
|
||||
|
||||
record('safety: a class name in props is dropped by the schema', (() => {
|
||||
const r = opsE.applyOperation(
|
||||
[seriesNode('bar-chart', 'hires.performance')],
|
||||
{ op: 'update', target: 'viz', props: { className: 'bg-red-500', style: 'color:red' } },
|
||||
ectx
|
||||
);
|
||||
const props = r.ok ? nodeE.findNode(r.tree, 'viz').props : null;
|
||||
return { pass: !r.ok || (!('className' in props) && !('style' in props)),
|
||||
detail: JSON.stringify(props) };
|
||||
})().pass);
|
||||
|
||||
record('safety: an ambiguous request asks rather than guesses', (() => {
|
||||
const m = ask('Change this');
|
||||
return { pass: m === null || m.kind === 'ambiguous' || m.kind === 'unknown'
|
||||
|| m.kind === 'unknown-type' || m.kind === 'refused',
|
||||
detail: String(m?.kind) };
|
||||
})().pass);
|
||||
}
|
||||
|
||||
if (!hadWindow) delete globalThis.window;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import WorkerProfile from '@/pages/WorkerProfile';
|
||||
import KrowIdentity from '@/pages/KrowIdentity';
|
||||
import Owliver from '@/pages/Owliver';
|
||||
import EmployeeDashboard from '@/pages/EmployeeDashboard';
|
||||
import OpportunityDetail from '@/pages/OpportunityDetail';
|
||||
import DesignSystem from '@/pages/DesignSystem';
|
||||
|
||||
/* Admin product — its own layout and route tree, so the Admin redesign can
|
||||
@@ -137,6 +138,12 @@ const AuthenticatedApp = () => {
|
||||
</Route>
|
||||
|
||||
{/* Redirect top-level legacy aliases to the unified global shell */}
|
||||
{/* Employee (talent) opportunity flow — full-page routes, no drawer.
|
||||
Standalone (outside AdminLayout/AdminRoute, which are the employer
|
||||
shell and gate); authenticated via ProtectedRoute. */}
|
||||
<Route path="/employee" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-5xl mx-auto px-4 py-8"><EmployeeDashboard /></div></div>} />
|
||||
<Route path="/opportunities/:id" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-3xl mx-auto px-4 py-8"><OpportunityDetail /></div></div>} />
|
||||
<Route path="/apply" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-3xl mx-auto px-4 py-8"><Apply /></div></div>} />
|
||||
<Route path="/overview" element={<Navigate to="/admin" replace />} />
|
||||
<Route path="/positions" element={<Navigate to="/admin/positions" replace />} />
|
||||
{/* `/positions` only matches the exact path, so the sub-routes need
|
||||
|
||||
@@ -45,6 +45,9 @@ export function resolveUiEdit({ question, ui }) {
|
||||
page can actually hold — the same scope the visual editor's picker uses,
|
||||
so the two can never disagree about what is addable here. */
|
||||
page: ui.page,
|
||||
/* The node this conversation last changed. Nothing is remembered inside the
|
||||
matcher: continuity is a fact the caller holds and passes in. */
|
||||
focus: ui.focus || null,
|
||||
});
|
||||
if (!match) return null;
|
||||
|
||||
@@ -155,8 +158,52 @@ export function resolveUiEdit({ question, ui }) {
|
||||
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
|
||||
};
|
||||
|
||||
case 'refused':
|
||||
return { kind: 'ui-answer', doc: doc(text(match.message)) };
|
||||
/**
|
||||
* Understood, and not possible — with the reason and the way forward.
|
||||
*
|
||||
* A refusal that only says no leaves a person guessing at a vocabulary they
|
||||
* cannot see. When the engine knows what this reading *could* be drawn as,
|
||||
* it says so and offers the choices as chips, so "no, but here" costs one
|
||||
* click rather than another round of guessing.
|
||||
*/
|
||||
case 'refused': {
|
||||
const offered = match.alternatives || [];
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: offered.length
|
||||
? doc(
|
||||
text(match.message),
|
||||
text(`It can be shown as: ${offered.map((o) => o.label).join(', ')}.`)
|
||||
)
|
||||
: doc(text(match.message)),
|
||||
followUp: offered.slice(0, 3).map((option) => ({
|
||||
label: option.label,
|
||||
prompt: `Show ${match.node?.title || match.node?.label || 'it'} as a ${option.label}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way this reading could honestly be drawn.
|
||||
*
|
||||
* The options are the registry's answer, not a suggestion: each is a
|
||||
* component the application ships, each will draw this node's own figures,
|
||||
* and choosing one produces exactly the operation `planReplace` would have
|
||||
* produced from the same words. Nothing here is generated.
|
||||
*/
|
||||
case 'options':
|
||||
return {
|
||||
kind: 'ui-answer',
|
||||
doc: doc(
|
||||
text(`${match.subject} can be drawn these ways. Each one uses the same figures.`),
|
||||
list(match.options.map((option) => `${option.label} — ${option.summary}`)),
|
||||
note('Pick one to preview it. Nothing is saved until you apply.')
|
||||
),
|
||||
followUp: match.options.map((option) => ({
|
||||
label: option.label,
|
||||
prompt: `Show ${match.node.title || match.node.label} as a ${option.label}`,
|
||||
})),
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -588,6 +588,10 @@ export function useConversation({
|
||||
property of the page, not of the registry, and this is how
|
||||
the conversation learns it. */
|
||||
page: uiEditing.page || null,
|
||||
/* What this session last changed, so "change it back" has an
|
||||
"it". A node id and nothing else — see `focus` on the
|
||||
editing provider. */
|
||||
focus: uiEditing.focus || null,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
|
||||
107
src/components/krow/talent/Opportunities.jsx
Normal file
107
src/components/krow/talent/Opportunities.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Briefcase, MapPin, Wallet, Sparkles, Compass, ArrowRight } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Opportunities — the Employee opportunity-discovery grid.
|
||||
*
|
||||
* Replaces the old demand-feed cards on the Employee jobs surface. Each card is
|
||||
* an opportunity, identified by its JOB TITLE — never a company/client name.
|
||||
* "View Position" navigates (route-based, no drawer) to the full-page detail at
|
||||
* /opportunities/:id.
|
||||
*
|
||||
* It takes the same `{ job, match }` records the dashboard already computes with
|
||||
* recommendJobs(profile, postings, 6); nothing about the matching changes here.
|
||||
*
|
||||
* COMPANY PRIVACY: this component reads only the fields the Employee experience
|
||||
* needs — id, title, role_category, location, pay_range_min/max, status, match —
|
||||
* and never `job.company`.
|
||||
*/
|
||||
function OpportunityCard({ rec }) {
|
||||
const navigate = useNavigate();
|
||||
const { job, match } = rec;
|
||||
const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : null;
|
||||
const isHiring = job.status === 'active';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/opportunities/${job.id}`)}
|
||||
className="group text-left w-full rounded-2xl bg-white border border-[#E5E7EB] p-5 transition-all
|
||||
hover:border-[#0838E0]/40 hover:shadow-lg hover:shadow-[#0838E0]/5 hover:-translate-y-0.5
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="w-11 h-11 rounded-xl bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center shrink-0">
|
||||
<Briefcase className="w-5 h-5" />
|
||||
</div>
|
||||
{isHiring && (
|
||||
<span className="inline-flex items-center text-[11px] font-semibold text-white bg-[#0838E0] px-2.5 py-1 rounded-full">
|
||||
Hiring
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The job title is the opportunity's identity — no company name. */}
|
||||
<h3 className="mt-3 text-[16px] font-bold text-[#0F172A] leading-snug line-clamp-2">{job.title}</h3>
|
||||
{job.role_category && (
|
||||
<p className="mt-0.5 text-[13px] text-[#6B7280] capitalize">{job.role_category}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<p className="text-[13px] text-[#6B7280] flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-[#9CA3AF]" /> {job.location || 'Remote'}
|
||||
</p>
|
||||
{pay && (
|
||||
<p className="text-[13px] font-medium text-[#111827] flex items-center gap-1.5">
|
||||
<Wallet className="w-3.5 h-3.5 text-[#9CA3AF]" /> {pay}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between pt-4 border-t border-[#F3F4F6]">
|
||||
<span className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0838E0] bg-[#EEF3FE] px-2.5 py-1 rounded-full">
|
||||
<Sparkles className="w-3 h-3" /> {match}% match
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[13px] font-semibold text-[#0838E0] group-hover:gap-1.5 transition-all">
|
||||
View Position <ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Opportunities({ jobRecs = [] }) {
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h2 className="text-[18px] font-bold text-[#0F172A] flex items-center gap-2">
|
||||
<Compass className="w-4 h-4 text-[#0838E0]" /> Opportunities for you
|
||||
</h2>
|
||||
<p className="text-[13px] text-[#6B7280] mt-0.5">Matched to your profile. Open one to see why you fit.</p>
|
||||
</div>
|
||||
{jobRecs.length > 0 && (
|
||||
<span className="inline-flex items-center text-[12px] font-semibold text-[#0838E0] bg-[#EEF3FE] px-3 py-1.5 rounded-full">
|
||||
{jobRecs.length} match{jobRecs.length === 1 ? '' : 'es'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{jobRecs.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3">
|
||||
<Compass className="w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-[14px] font-medium text-[#0F172A]">No matched opportunities yet — keep leveling up.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{jobRecs.map((rec) => (
|
||||
<OpportunityCard key={rec.job.id} rec={rec} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ALIGN_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, SPACING_VALUES,
|
||||
} from '@/lib/ui/node';
|
||||
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
import { kindOfBinding } from '@/lib/ui/series';
|
||||
import { nodeRegistry } from '@/lib/ui/registry';
|
||||
import { hideOp, layoutOp, nudgeOp, presentationOp, propOp, removeOp, replaceOp } from './ops';
|
||||
|
||||
@@ -43,8 +44,25 @@ export function NodeInspector({ node, siblings, page = null, onOperate, registry
|
||||
if (op) onOperate(op);
|
||||
};
|
||||
|
||||
const shapes = node.data ? dataSourceFor(node.data.source)?.shapes || [] : null;
|
||||
const replacements = can('replace') ? registry.replacements(node.type, { shapes, page }) : [];
|
||||
/**
|
||||
* What this node could be shown as.
|
||||
*
|
||||
* The same five facts the conversation passes, in the same order, to the same
|
||||
* registry call — the editor holds no compatibility rule of its own, so a
|
||||
* type in this list is exactly a type "show this as a …" would accept. Two
|
||||
* readings of the rule is how a picker comes to offer something the operation
|
||||
* then refuses.
|
||||
*/
|
||||
const shapes = node.data?.source ? dataSourceFor(node.data.source)?.shapes || [] : null;
|
||||
const replacements = can('replace')
|
||||
? registry.replacements(node.type, {
|
||||
shapes,
|
||||
page,
|
||||
seriesKind: kindOfBinding(node.data),
|
||||
seriesBound: Boolean(node.data?.series),
|
||||
bound: Boolean(node.data),
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -33,6 +33,20 @@ const UiEditingContext = React.createContext(null);
|
||||
/** The editing session for the page around it. Null outside a provider. */
|
||||
export const useUiEditing = () => React.useContext(UiEditingContext);
|
||||
|
||||
/**
|
||||
* The node an operation is about, or null.
|
||||
*
|
||||
* Read from the operation's own shape rather than from a table of operation
|
||||
* names with special cases: every operation that concerns one node names it in
|
||||
* `target`, and `add` names the node it is creating. `reorder` concerns a whole
|
||||
* list and has no single subject, so it leaves the previous one standing —
|
||||
* which is right: reordering the page does not change what "it" means.
|
||||
*/
|
||||
function subjectOf(op) {
|
||||
const named = String(op?.target ?? op?.node?.id ?? '').trim();
|
||||
return named || null;
|
||||
}
|
||||
|
||||
export function UiEditingProvider({ page, role = null, registry = nodeRegistry, children }) {
|
||||
const { layouts, save, saving } = useUiLayouts();
|
||||
|
||||
@@ -44,6 +58,14 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
|
||||
*/
|
||||
const [preview, setPreview] = React.useState(() => emptyPatch(page));
|
||||
const [problems, setProblems] = React.useState([]);
|
||||
/**
|
||||
* The subject of the conversation, as a node id.
|
||||
*
|
||||
* What makes "change it back to a line chart" answerable. Recorded from the
|
||||
* operation that stood, so it is always a node that really exists and really
|
||||
* changed — never a guess, and never something the model supplied.
|
||||
*/
|
||||
const [focus, setFocus] = React.useState(null);
|
||||
|
||||
/* A page change is a new editing session. Anything unsaved was about the page
|
||||
that is no longer on screen. */
|
||||
@@ -91,6 +113,10 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
|
||||
}
|
||||
setProblems([]);
|
||||
setPreview((current) => pushOp(current, op));
|
||||
/* Only once the operation stood. A refused change never becomes the thing
|
||||
"it" refers to. */
|
||||
const named = subjectOf(op);
|
||||
if (named) setFocus(named);
|
||||
return result;
|
||||
}, [composed.tree, registry, role]);
|
||||
|
||||
@@ -98,6 +124,7 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
|
||||
const discard = React.useCallback(() => {
|
||||
setPreview(emptyPatch(page));
|
||||
setProblems([]);
|
||||
setFocus(null);
|
||||
}, [page]);
|
||||
|
||||
/**
|
||||
@@ -141,6 +168,7 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
|
||||
const reset = React.useCallback(async () => {
|
||||
setPreview(emptyPatch(page));
|
||||
setProblems([]);
|
||||
setFocus(null);
|
||||
if (saved.ops.length) await save(page, clearOps(saved));
|
||||
return { ok: true };
|
||||
}, [page, saved, save]);
|
||||
@@ -156,6 +184,15 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
|
||||
preview,
|
||||
problems,
|
||||
previewing: preview.ops.length > 0,
|
||||
/**
|
||||
* The node this session last changed, and therefore what "it" means.
|
||||
*
|
||||
* Session state, not stored state: it is a fact about the conversation
|
||||
* rather than about the layout, so it is deliberately not persisted and is
|
||||
* gone on reload. Kept across an Apply — applying does not end the subject —
|
||||
* and cleared by Discard and Reset, which do.
|
||||
*/
|
||||
focus,
|
||||
customised: saved.ops.length > 0,
|
||||
saving,
|
||||
propose,
|
||||
@@ -164,7 +201,7 @@ export function UiEditingProvider({ page, role = null, registry = nodeRegistry,
|
||||
undo,
|
||||
reset,
|
||||
}), [
|
||||
page, composed, saved, preview, problems, saving, propose, discard, apply, undo, reset,
|
||||
page, composed, saved, preview, problems, saving, focus, propose, discard, apply, undo, reset,
|
||||
]);
|
||||
|
||||
return <UiEditingContext.Provider value={value}>{children}</UiEditingContext.Provider>;
|
||||
|
||||
@@ -1,74 +1,79 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Bar, BarChart, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, Pie, PieChart,
|
||||
ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AXIS_PROPS, CHART_COLORS, CHART_TONES } from '@/components/ds/ChartContainer';
|
||||
import { SECTION_TYPES } from '@/lib/skills/surfaces';
|
||||
import { SegmentedToggle } from '@/components/ds/Toggle';
|
||||
import { useSkillDataContext } from '@/components/skills/SkillSurface';
|
||||
import { resolveSkillData } from '@/lib/skills/dataResolver';
|
||||
import { DENSITY_VALUES, VARIANT_VALUES } from '@/lib/ui/node';
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { layoutClasses, presentationClasses } from './UiTreeRenderer';
|
||||
import { controlOfBinding, readSeries } from '@/lib/ui/series';
|
||||
import { layoutClasses, presentationClasses, useUiContext } from './UiTreeRenderer';
|
||||
|
||||
/**
|
||||
* Two ways of drawing a series: as bars, and as parts of a whole.
|
||||
* Five ways of drawing one series, and one table that says which is which.
|
||||
*
|
||||
* These are the first node types that are **not** skill section types. The nine
|
||||
* readings exist in `surfaces.js` because a Markdown definition may name them;
|
||||
* chart and pie are named by nobody but this registry, so adding them here adds
|
||||
* nothing to the MD vocabulary, obliges no change in the Go parser, and cannot
|
||||
* drift from its conformance oracle. A person reaches them by turning an
|
||||
* existing node into one.
|
||||
* These are the node types a person reaches by asking for them — "show this as
|
||||
* a bar chart" — and everything that decides whether they may is declared here
|
||||
* as data. There is no branch anywhere on which chart is being drawn: the
|
||||
* registry compares `seriesKinds` to what the binding means, and a sixth chart
|
||||
* added to `VISUALIZATIONS` below is addressable by name, in conversation and
|
||||
* in the editor, with no other file touched.
|
||||
*
|
||||
* **Nothing is transformed and nothing is invented.** A `flow` or a `stats`
|
||||
* reading already resolves to `{ steps: [{ id, label, value }] }` — a labelled
|
||||
* numeric series, which is precisely what a bar chart and a pie chart each
|
||||
* need. So these draw the same payload the Flow section draws, from the same
|
||||
* resolver, keyed by the same source. There is no adapter layer here because
|
||||
* there is nothing to adapt.
|
||||
* **Nothing is transformed and nothing is invented.** Every one of these reads
|
||||
* `readSeries`, which either resolves a skill reading through the ordinary
|
||||
* resolver or calls a page's own registered `read` — and then relabels neither.
|
||||
* A bar chart of hiring activity draws the same four numbers per day that the
|
||||
* page's own composed chart draws, because it is literally the same array.
|
||||
*
|
||||
* The consequence is the compatibility rule, and it is the existing one: a
|
||||
* source can become a chart exactly when it declares `flow` or `stats`, decided
|
||||
* by `sourceSupportsShape` like every other replacement. No new shape, no new
|
||||
* vocabulary, no per-source table.
|
||||
* The interesting column is `seriesKinds`, and it is the whole reason a person
|
||||
* can be told *why* rather than merely *no*. `flow` cannot express it — it is,
|
||||
* in `SECTION_TYPES`' own words, "a sequence of stages **or** periods" — so a
|
||||
* pie was structurally free to slice twelve days into twelve wedges. Saying
|
||||
* `parts` here is what makes that impossible, and the refusal explains itself
|
||||
* in terms of the reading rather than the component.
|
||||
*/
|
||||
|
||||
/** The series a reading resolved to, in the one shape both charts read. */
|
||||
/** The series this node is bound to, resolved through whichever binding it has. */
|
||||
function useSeries(node) {
|
||||
const context = useSkillDataContext(null);
|
||||
const section = React.useMemo(() => ({
|
||||
const skillContext = useSkillDataContext(null);
|
||||
const pageContext = useUiContext();
|
||||
|
||||
const resolveSource = React.useCallback((binding) => resolveSkillData({
|
||||
id: node.id,
|
||||
/* `flow` because that is the shape whose payload these draw. Resolvers are
|
||||
keyed by source and never read this, but a section without a type is not
|
||||
a section, and naming the shape it consumes keeps that honest. */
|
||||
type: 'flow',
|
||||
source: node.data?.source || '',
|
||||
periods: node.data?.params?.periods || [],
|
||||
limit: node.data?.params?.limit || null,
|
||||
}), [node.id, node.data]);
|
||||
source: binding.source,
|
||||
periods: binding.params?.periods || [],
|
||||
limit: binding.params?.limit || null,
|
||||
}, skillContext), [node.id, skillContext]);
|
||||
|
||||
const data = React.useMemo(() => resolveSkillData(section, context), [section, context]);
|
||||
const rows = (data.steps || data.items || [])
|
||||
.map((step, i) => ({
|
||||
id: step.id || `${i}`,
|
||||
label: String(step.label || step.title || ''),
|
||||
value: Number(step.value) || 0,
|
||||
}))
|
||||
/* A slice of nothing is not a slice, and a bar of nothing is a gap. Both
|
||||
charts read real numbers or draw the empty note. */
|
||||
.filter((row) => row.label);
|
||||
|
||||
return { data, rows };
|
||||
return React.useMemo(
|
||||
() => readSeries(node.data, { context: pageContext, resolveSource }),
|
||||
[node.data, pageContext, resolveSource]
|
||||
);
|
||||
}
|
||||
|
||||
/** How tall the plot is, and therefore how big a pie fits in it. */
|
||||
const plotHeight = (presentation) => (presentation?.density === 'compact' ? 160 : 240);
|
||||
const plotHeight = (presentation) => (presentation?.density === 'compact' ? 180 : 260);
|
||||
|
||||
/** The panel both share — the same chrome every reading section draws. */
|
||||
function ChartPanel({ node, attrs, layout, presentation, title, children, empty }) {
|
||||
/**
|
||||
* The panel every one of them shares — the same chrome a reading section draws.
|
||||
*
|
||||
* `control` is whatever the *reading* publishes, drawn here so that replacing
|
||||
* one visualization with another does not quietly take a control away. Nothing
|
||||
* about it is known to this file beyond the shape declared in `series.js`: a
|
||||
* label, a closed set of options, the value the page is publishing, and the
|
||||
* page's own setter. A reading that publishes none renders none.
|
||||
*/
|
||||
function ChartPanel({ attrs, layout, presentation, title, children, empty, control = null }) {
|
||||
const look = presentationClasses(presentation);
|
||||
const height = plotHeight(presentation);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -76,95 +81,210 @@ function ChartPanel({ node, attrs, layout, presentation, title, children, empty
|
||||
aria-label={title || undefined}
|
||||
className={cn('rounded-2xl border', look.variant, look.padding, layoutClasses(layout))}
|
||||
>
|
||||
{title && (
|
||||
<div className={look.gap}>
|
||||
<h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>{title}</h3>
|
||||
{(title || control) && (
|
||||
<div className={cn('flex flex-wrap items-center justify-between gap-x-3 gap-y-2', look.gap)}>
|
||||
{title
|
||||
? <h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>{title}</h3>
|
||||
: <span />}
|
||||
{control && (
|
||||
<SegmentedToggle
|
||||
options={control.options}
|
||||
value={control.value}
|
||||
onChange={control.set}
|
||||
size="sm"
|
||||
ariaLabel={control.label}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{empty
|
||||
? <p className="text-caption text-ink-3">{empty}</p>
|
||||
: <div style={{ height }}>{children}</div>}
|
||||
: <div style={{ height: plotHeight(presentation) }}>{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bars, one per step, in the brand's own colour. */
|
||||
function ChartNode({ node, attrs = {}, layout = null, presentation = null, title = null }) {
|
||||
const { data, rows } = useSeries(node);
|
||||
const empty = !rows.length ? (data.emptyNote || 'Nothing to chart yet.') : null;
|
||||
/** The axes, grid, tooltip and legend every cartesian chart here draws alike. */
|
||||
function Cartesian({ measures }) {
|
||||
return (
|
||||
<>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={16} height={32} />
|
||||
<YAxis {...AXIS_PROPS} allowDecimals={false} width={32} />
|
||||
<Tooltip cursor={{ fill: CHART_TONES.mint, fillOpacity: 0.25 }} />
|
||||
{/* One series needs no key: the title already names it. */}
|
||||
{measures.length > 1 && (
|
||||
<Legend
|
||||
verticalAlign="top" align="right" height={24}
|
||||
iconType="circle" iconSize={8}
|
||||
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const colour = (i) => CHART_COLORS[i % CHART_COLORS.length];
|
||||
|
||||
/**
|
||||
* The five, as data.
|
||||
*
|
||||
* `draw` returns the recharts element for a resolved series; everything else —
|
||||
* the panel, the empty state, the identity, layout and presentation — is shared
|
||||
* above and below. Adding one is adding a row.
|
||||
*/
|
||||
const VISUALIZATIONS = [
|
||||
{
|
||||
type: 'bar-chart',
|
||||
label: 'Bar chart',
|
||||
summary: 'A bar for each point in a reading.',
|
||||
/* Bars compare magnitudes and claim nothing about order or wholeness, which
|
||||
is why this is the one drawing every meaning can take. */
|
||||
seriesKinds: ['periodic', 'cumulative', 'parts'],
|
||||
draw: ({ rows, measures }) => (
|
||||
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
|
||||
<Cartesian measures={measures} />
|
||||
{measures.map((measure, i) => (
|
||||
<Bar key={measure.key} dataKey={measure.key} name={measure.label}
|
||||
fill={colour(i)} radius={[4, 4, 0, 0]} />
|
||||
))}
|
||||
</BarChart>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'line-chart',
|
||||
label: 'Line chart',
|
||||
summary: 'The points joined in order.',
|
||||
/* A line asserts that the gap between two points means something, so it may
|
||||
only draw readings that are actually ordered. */
|
||||
seriesKinds: ['periodic', 'cumulative'],
|
||||
draw: ({ rows, measures }) => (
|
||||
<LineChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
|
||||
<Cartesian measures={measures} />
|
||||
{measures.map((measure, i) => (
|
||||
<Line key={measure.key} type="monotone" dataKey={measure.key} name={measure.label}
|
||||
stroke={colour(i)} strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
))}
|
||||
</LineChart>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'area-chart',
|
||||
label: 'Area chart',
|
||||
summary: 'The points joined in order, filled to the axis.',
|
||||
seriesKinds: ['periodic', 'cumulative'],
|
||||
draw: ({ rows, measures }) => (
|
||||
<AreaChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
|
||||
<Cartesian measures={measures} />
|
||||
{measures.map((measure, i) => (
|
||||
<Area key={measure.key} type="monotone" dataKey={measure.key} name={measure.label}
|
||||
stroke={colour(i)} strokeWidth={2} fill={colour(i)} fillOpacity={0.18}
|
||||
isAnimationActive={false} />
|
||||
))}
|
||||
</AreaChart>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'pie-chart',
|
||||
label: 'Pie chart',
|
||||
summary: 'The points as parts of one whole.',
|
||||
/* The narrow one, and deliberately. A slice is a share of a total, which is
|
||||
only true when the points are disjoint parts of that total. */
|
||||
seriesKinds: ['parts'],
|
||||
draw: ({ rows, measures }) => <Slices rows={rows} measure={measures[0]} inner={0} />,
|
||||
/* A disc of zeroes is a blank panel that every automated check calls a
|
||||
success because the elements are there. */
|
||||
guard: ({ rows, measures }) => (
|
||||
rows.reduce((sum, row) => sum + (row[measures[0]?.key] || 0), 0) === 0
|
||||
? 'Every value here is zero.' : null
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'donut-chart',
|
||||
label: 'Donut chart',
|
||||
summary: 'The same parts, drawn as a ring.',
|
||||
seriesKinds: ['parts'],
|
||||
draw: ({ rows, measures }) => <Slices rows={rows} measure={measures[0]} inner={0.55} />,
|
||||
guard: ({ rows, measures }) => (
|
||||
rows.reduce((sum, row) => sum + (row[measures[0]?.key] || 0), 0) === 0
|
||||
? 'Every value here is zero.' : null
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The wedges a pie and a donut share.
|
||||
*
|
||||
* Centre and radii given explicitly, in pixels. Percentage radii with no
|
||||
* `cx`/`cy` produced sectors that existed in the DOM, carried no fill and drew
|
||||
* nothing — the failure the guard above is about. The one pie already in this
|
||||
* repository does it this way, and it works.
|
||||
*/
|
||||
function Slices({ rows, measure, inner }) {
|
||||
const outer = 92;
|
||||
return (
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={rows}
|
||||
dataKey={measure?.key || 'value'}
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={Math.round(outer * inner)}
|
||||
outerRadius={outer}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{rows.map((row, i) => <Cell key={row.id} fill={colour(i)} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
);
|
||||
}
|
||||
|
||||
/** One visualization node — identical for all five but for the `draw` it was given. */
|
||||
function Visualization({ spec, node, attrs = {}, layout = null, presentation = null, title = null }) {
|
||||
const series = useSeries(node);
|
||||
const pageContext = useUiContext();
|
||||
const empty = !series.rows.length || !series.measures.length
|
||||
? (series.emptyNote || 'Nothing to chart yet.')
|
||||
: (spec.guard?.(series) || null);
|
||||
|
||||
/**
|
||||
* The control the reading publishes, and the reading's own name.
|
||||
*
|
||||
* Both exist so that a replacement is a change of renderer rather than a
|
||||
* quiet loss of what was on the panel. The title falls back to the name of
|
||||
* the thing being drawn — not to a made-up one — so a built-in section
|
||||
* replaced by a chart still says what it is showing.
|
||||
*/
|
||||
const control = controlOfBinding(node.data, pageContext);
|
||||
|
||||
return (
|
||||
<ChartPanel node={node} attrs={attrs} layout={layout} presentation={presentation} title={title} empty={empty}>
|
||||
<ChartPanel attrs={attrs} layout={layout} presentation={presentation}
|
||||
title={title || series.label || null} empty={empty} control={control}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
|
||||
<XAxis dataKey="label" {...AXIS_PROPS} interval={0} height={40} />
|
||||
<YAxis {...AXIS_PROPS} allowDecimals={false} />
|
||||
<Tooltip cursor={{ fill: CHART_TONES.mint, fillOpacity: 0.25 }} />
|
||||
<Bar dataKey="value" fill={CHART_TONES.brand} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
{spec.draw(series)}
|
||||
</ResponsiveContainer>
|
||||
</ChartPanel>
|
||||
);
|
||||
}
|
||||
|
||||
/** The same series as parts of a whole. */
|
||||
function PieNode({ node, attrs = {}, layout = null, presentation = null, title = null }) {
|
||||
const { data, rows } = useSeries(node);
|
||||
const total = rows.reduce((sum, row) => sum + row.value, 0);
|
||||
/* A pie of nothing is a blank disc, so an all-zero series is empty rather
|
||||
than drawn — the reading has values, they are simply all zero. */
|
||||
const empty = !rows.length
|
||||
? (data.emptyNote || 'Nothing to chart yet.')
|
||||
: (total === 0 ? 'Every value here is zero.' : null);
|
||||
|
||||
const height = plotHeight(presentation);
|
||||
const radius = { outer: Math.round(height * 0.38) };
|
||||
|
||||
return (
|
||||
<ChartPanel node={node} attrs={attrs} layout={layout} presentation={presentation} title={title} empty={empty}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
{/* Centre and radii given explicitly, in pixels.
|
||||
Percentage radii with no `cx`/`cy` produced sectors that existed in
|
||||
the DOM, carried no fill and drew nothing — a blank panel that
|
||||
every automated check called a success because the elements were
|
||||
there. The one pie already in this repository does it this way,
|
||||
and it works. */}
|
||||
<Pie
|
||||
data={rows}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={radius.outer}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{rows.map((row, i) => (
|
||||
<Cell key={row.id} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartPanel>
|
||||
);
|
||||
}
|
||||
|
||||
/* The shapes whose payload both of these draw, read from the vocabulary rather
|
||||
than written out, so a shape renamed there cannot leave these claiming one
|
||||
that no longer exists. */
|
||||
/* The shapes whose payload these draw, read from the vocabulary rather than
|
||||
written out, so a shape renamed there cannot leave these claiming one that no
|
||||
longer exists. */
|
||||
const SERIES_SHAPES = ['flow', 'stats'].filter((shape) => SECTION_TYPES.some((t) => t.id === shape));
|
||||
|
||||
for (const [type, label, summary, component] of [
|
||||
['chart', 'Chart', 'A bar for each step in a reading.', ChartNode],
|
||||
['pie', 'Pie', 'The same steps as parts of a whole.', PieNode],
|
||||
]) {
|
||||
for (const spec of VISUALIZATIONS) {
|
||||
const Component = (props) => <Visualization spec={spec} {...props} />;
|
||||
Component.displayName = spec.label;
|
||||
|
||||
registerNodeType({
|
||||
type,
|
||||
label,
|
||||
summary,
|
||||
component,
|
||||
type: spec.type,
|
||||
label: spec.label,
|
||||
summary: spec.summary,
|
||||
component: Component,
|
||||
dataShapes: SERIES_SHAPES,
|
||||
seriesKinds: spec.seriesKinds,
|
||||
dataRequired: true,
|
||||
propSchema: { title: { type: 'string', label: 'Title' } },
|
||||
variants: VARIANT_VALUES,
|
||||
@@ -174,3 +294,5 @@ for (const [type, label, summary, component] of [
|
||||
capabilities: ['update', 'remove', 'move', 'replace', 'hide'],
|
||||
});
|
||||
}
|
||||
|
||||
export { VISUALIZATIONS };
|
||||
|
||||
@@ -524,6 +524,20 @@ export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label ||
|
||||
* 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.
|
||||
*
|
||||
* **`series`** is the optional half that says what a reading's figures *mean*,
|
||||
* as one of the closed kinds in `SERIES_KINDS` — `periodic` (ordered in time),
|
||||
* `cumulative` (each step drawn from the one before) or `parts` (disjoint
|
||||
* shares of one whole). `shapes` answers whether a component *can* draw a
|
||||
* reading; this answers whether doing so would be true, and it is what refuses
|
||||
* a pie chart of a timeline.
|
||||
*
|
||||
* It is declared only where the reading's own definition already states the
|
||||
* meaning, and it is deliberately absent from most of them. An absent `series`
|
||||
* is "not established", and a component that has declared which meanings it
|
||||
* draws refuses rather than guesses — which is the safe direction. Adding one
|
||||
* is a statement about what the resolver actually returns, so it is added when
|
||||
* that is known and not before.
|
||||
*/
|
||||
export const DATA_SOURCES = [
|
||||
{
|
||||
@@ -532,6 +546,9 @@ export const DATA_SOURCES = [
|
||||
context: 'positionId',
|
||||
summary: 'Applications to this position, counted over time.',
|
||||
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
|
||||
/* "counted over time": the resolver buckets by period and returns
|
||||
the buckets in order, so the order is part of the reading. */
|
||||
series: 'periodic',
|
||||
options: ['periods'],
|
||||
},
|
||||
{
|
||||
@@ -540,6 +557,9 @@ export const DATA_SOURCES = [
|
||||
context: 'positionId',
|
||||
summary: 'Applied → screened → shortlisted → interviewed → hired.',
|
||||
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
|
||||
/* A funnel: everyone screened applied first, so the stages overlap
|
||||
and do not add up to a whole. */
|
||||
series: 'cumulative',
|
||||
},
|
||||
{
|
||||
id: 'position.candidates',
|
||||
@@ -594,6 +614,9 @@ export const DATA_SOURCES = [
|
||||
context: null,
|
||||
summary: 'Every candidate, counted by stage.',
|
||||
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
|
||||
/* The same funnel across the workspace, and cumulative for the same
|
||||
reason. */
|
||||
series: 'cumulative',
|
||||
},
|
||||
{
|
||||
id: 'candidates.activity',
|
||||
@@ -601,6 +624,8 @@ export const DATA_SOURCES = [
|
||||
context: null,
|
||||
summary: 'Applications across the workspace, counted over time.',
|
||||
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
|
||||
/* "counted over time", as above. */
|
||||
series: 'periodic',
|
||||
options: ['periods'],
|
||||
},
|
||||
{
|
||||
@@ -738,6 +763,9 @@ export const DATA_SOURCES = [
|
||||
context: null,
|
||||
summary: 'Events by type and by account.',
|
||||
shapes: ['stats', 'table', 'list', 'progress', 'flow', 'card'],
|
||||
/* Every event has exactly one type and one account, so counting by
|
||||
either partitions the same total: these are shares of one whole. */
|
||||
series: 'parts',
|
||||
options: ['periods', 'limit'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
* name, page name or component name appears below.
|
||||
*/
|
||||
|
||||
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
import { dataSourceFor } from '@/lib/skills/surfaces';
|
||||
import { kindOfBinding, labelOfBinding, seriesFor } from './series';
|
||||
import { locate, walk } from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
@@ -44,8 +45,23 @@ export function describeNode(node, { registry = nodeRegistry, parent = null, ind
|
||||
* supplies a `describe` and says where this one is; nothing here knows what
|
||||
* a placement is.
|
||||
*/
|
||||
/**
|
||||
* Last, the name of what it is reading.
|
||||
*
|
||||
* Identity has to survive a replacement. A built-in section carries its
|
||||
* name in its type label — "Hiring activity" — and replacing it with a
|
||||
* chart left a node whose only name was "Bar chart", so the very phrase
|
||||
* that had just worked stopped resolving and "change it back" answered
|
||||
* that there was no such thing on the page.
|
||||
*
|
||||
* The binding is the continuous fact across a replacement: the node is
|
||||
* still reading the same series, and the series has a name. Using it is
|
||||
* not a fallback invented for charts — a node is named by what it shows,
|
||||
* which is how a person refers to it either way.
|
||||
*/
|
||||
title: String(node.props?.title || '').trim()
|
||||
|| (typeof entry?.describe === 'function' ? String(entry.describe(node) || '').trim() || null : null),
|
||||
|| (typeof entry?.describe === 'function' ? String(entry.describe(node) || '').trim() || null : null)
|
||||
|| (node.data ? String(labelOfBinding(node.data) || '').trim() || null : null),
|
||||
known: Boolean(entry),
|
||||
container: Boolean(entry?.container),
|
||||
origin: node.origin,
|
||||
@@ -53,12 +69,24 @@ export function describeNode(node, { registry = nodeRegistry, parent = null, ind
|
||||
locked: node.locked === true,
|
||||
parent: parent?.id ?? null,
|
||||
index,
|
||||
/**
|
||||
* The binding, in whichever of the two kinds it is.
|
||||
*
|
||||
* A described node is what the conversation and the editor both reason
|
||||
* about, so it has to carry enough to answer "what can this become" — and
|
||||
* that now includes a page's own series. Projected rather than passed
|
||||
* through so a consumer still cannot reach a resolver from here.
|
||||
*/
|
||||
data: node.data
|
||||
? {
|
||||
source: node.data.source,
|
||||
label: dataSourceLabel(node.data.source),
|
||||
source: node.data.source || null,
|
||||
series: node.data.series || null,
|
||||
label: labelOfBinding(node.data),
|
||||
kind: kindOfBinding(node.data),
|
||||
params: node.data.params || {},
|
||||
known: Boolean(dataSourceFor(node.data.source)),
|
||||
known: node.data.series
|
||||
? Boolean(seriesFor(node.data.series))
|
||||
: Boolean(dataSourceFor(node.data.source)),
|
||||
}
|
||||
: null,
|
||||
layout: { ...(node.layout || {}) },
|
||||
@@ -120,11 +148,15 @@ export function outlineTree(nodes, { registry = nodeRegistry } = {}) {
|
||||
for (const node of list || []) {
|
||||
const entry = registry.get(node.type);
|
||||
const title = String(node.props?.title || '').trim();
|
||||
/* Named by the reading when it has one, for the same reason `describeNode`
|
||||
is: it is what a person calls the thing, and it survives a change of
|
||||
component. */
|
||||
const reading = node.data ? String(labelOfBinding(node.data) || '').trim() : '';
|
||||
const bits = [
|
||||
`${' '.repeat(depth)}${title || entry?.label || node.type}`,
|
||||
`${' '.repeat(depth)}${title || reading || entry?.label || node.type}`,
|
||||
`(${node.id})`,
|
||||
node.hidden ? '· hidden' : '',
|
||||
node.data?.source ? `· ${dataSourceLabel(node.data.source)}` : '',
|
||||
reading && reading !== (title || reading) ? `· ${reading}` : '',
|
||||
].filter(Boolean);
|
||||
lines.push(bits.join(' '));
|
||||
if (node.children?.length) visit(node.children, depth + 1);
|
||||
@@ -173,7 +205,10 @@ export function resolveTarget(nodes, phrase, {
|
||||
const entry = registry.get(node.type);
|
||||
const title = canon(node.props?.title);
|
||||
const label = canon(entry?.label || node.type);
|
||||
const source = canon(node.data ? dataSourceLabel(node.data.source) : '');
|
||||
/* `labelOfBinding`, not `dataSourceLabel`: a node bound to a page's own
|
||||
series has no source id, and scoring only the source made every such
|
||||
node unfindable by the name of the thing it draws. */
|
||||
const source = canon(node.data ? labelOfBinding(node.data) : '');
|
||||
const id = canon(node.id);
|
||||
|
||||
let score = 0;
|
||||
|
||||
@@ -29,6 +29,7 @@ import { SUPPORTED_DATA_SOURCES, dataSourceFor, dataSourceLabel } from '@/lib/sk
|
||||
import { addableTypes, describeNode, resolveTarget } from './inspect';
|
||||
import { freeNodeId, walk } from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
import { kindOfBinding, labelOfBinding, refuseSeries } from './series';
|
||||
|
||||
/** Lower-case, punctuation-free. The one normaliser, shared with `inspect`. */
|
||||
const canon = (value) => String(value ?? '')
|
||||
@@ -62,6 +63,28 @@ const VERBS = [
|
||||
*/
|
||||
{ op: 'present', words: ['compact', 'comfortable', 'spacious', 'emphasis', 'emphasise', 'emphasize', 'subtle', 'denser', 'tighter', 'roomier'] },
|
||||
|
||||
/**
|
||||
* "I don't like this. What else could it be?"
|
||||
*
|
||||
* Before `replace`, because the sentences overlap — "show me other options"
|
||||
* and "show me some other designs" both contain words `replace` and `show`
|
||||
* would claim — and only this one is a question rather than an instruction.
|
||||
*
|
||||
* Every phrase is two words or more on purpose. A bare "options" is what a
|
||||
* person says about a position or a shift, and stealing it would turn an
|
||||
* ordinary question into a layout answer.
|
||||
*/
|
||||
{ op: 'options', words: [
|
||||
'other options', 'other option', 'more options', 'some other options',
|
||||
'other designs', 'another design', 'different design', 'other design',
|
||||
'alternatives', 'alternative', 'other ways', 'another way', 'different way',
|
||||
'other visualisations', 'other visualizations', 'other visualisation',
|
||||
'other visualization', 'another visualisation', 'another visualization',
|
||||
'different visualisation', 'different visualization',
|
||||
'other look', 'another look', 'different look',
|
||||
'what else can', 'what else could', 'what else is', 'something else',
|
||||
] },
|
||||
|
||||
{ op: 'replace', words: ['change', 'turn', 'switch', 'convert', 'make it a', 'show as', 'show it as'] },
|
||||
{ op: 'add', words: ['add', 'insert', 'create a', 'put a new'] },
|
||||
{ op: 'layout', words: ['columns', 'column', 'side by side', 'two up', 'wider', 'narrower'] },
|
||||
@@ -94,6 +117,27 @@ const UI_WORDS = [
|
||||
|
||||
const SHOW_CONFIDENCE = 18;
|
||||
|
||||
/**
|
||||
* The words that mean "the thing we were just talking about".
|
||||
*
|
||||
* Deliberately a closed list of pronouns and demonstratives, and deliberately
|
||||
* not a general reference resolver. A phrase carrying one of these, that names
|
||||
* nothing else on the page, is asking about whatever was last acted on — and
|
||||
* saying so out loud is what makes the fallback safe: a request that *does*
|
||||
* name something is never redirected, and a request that names nothing and
|
||||
* says nothing pronominal is still unknown.
|
||||
*
|
||||
* "back" and "again" are here because "change it back" and "do that again" are
|
||||
* how the follow-up is actually said.
|
||||
*/
|
||||
const ANAPHORA = new Set([
|
||||
'it', 'this', 'that', 'these', 'those', 'them', 'same',
|
||||
'back', 'again', 'instead',
|
||||
]);
|
||||
|
||||
/** Does this phrase refer to something already under discussion? */
|
||||
const refersBack = (text) => text.split(' ').some((word) => ANAPHORA.has(word));
|
||||
|
||||
const NUMBER_WORDS = { one: 1, two: 2, three: 3, four: 4, six: 6, twelve: 12 };
|
||||
|
||||
/**
|
||||
@@ -109,11 +153,36 @@ export function matchUiEdit(question, {
|
||||
keeps "add a recent hiring timeline" from being answerable on a page that
|
||||
publishes none of the records such a section reads. */
|
||||
page = null,
|
||||
/**
|
||||
* The node this conversation last acted on, if any.
|
||||
*
|
||||
* The whole of "it". A conversation about a page has a subject, and asking a
|
||||
* person to re-name it in every sentence is not how anyone speaks — "change
|
||||
* Hiring activity to a bar chart" followed by "change it back to a line
|
||||
* chart" is one thought in two sentences, and the second one used to be
|
||||
* answered with "I could not find that on this page."
|
||||
*
|
||||
* An id, and only an id: the caller records which node an operation named
|
||||
* and hands it back next turn. Nothing is remembered here, nothing is
|
||||
* inferred from the model, and a focus that is no longer in the tree simply
|
||||
* does not resolve.
|
||||
*/
|
||||
focus = null,
|
||||
} = {}) {
|
||||
const text = canon(question);
|
||||
if (!text) return null;
|
||||
|
||||
const verb = VERBS.find((v) => has(text, ...v.words));
|
||||
/**
|
||||
* The verb, from the table — or from the registry's own nouns.
|
||||
*
|
||||
* "I want a different chart" asks the same question as "show me other
|
||||
* options" and shares not one word with it. The phrase table cannot grow to
|
||||
* cover it without writing component names into the language layer, so the
|
||||
* second reading derives the noun from the registry instead. See
|
||||
* `asksToRedraw`.
|
||||
*/
|
||||
const verb = VERBS.find((v) => has(text, ...v.words))
|
||||
|| (asksToRedraw(text, registry) ? VERBS.find((v) => v.op === 'options') : null);
|
||||
if (!verb) return null;
|
||||
|
||||
/**
|
||||
@@ -160,18 +229,36 @@ export function matchUiEdit(question, {
|
||||
if (verb.op === 'show') return planShow(text, tree, registry);
|
||||
if (verb.op === 'unhide') return planUnhide(text, tree, registry);
|
||||
|
||||
/**
|
||||
* "Show me something else" is asked before the interface-words gate.
|
||||
*
|
||||
* The gate wants a request to name a node, a registered type, or a word like
|
||||
* "section" — and the sentence people actually type names none of the three.
|
||||
* "I don't like this design. Show me other options." was refused by the gate
|
||||
* and fell through to the model, which is exactly the wrong place for it: the
|
||||
* answer is a list of registered components, and a model does not have one.
|
||||
*
|
||||
* Safe to run early because this verb decides for itself whether it has a
|
||||
* subject, and returns null when it does not — so "what are my options for
|
||||
* this position?" is still nobody's layout request.
|
||||
*/
|
||||
if (verb.op === 'options') return planOptions(text, tree, registry, page, focus);
|
||||
|
||||
const named = namedType(text, registry, page);
|
||||
const mentionsUi = has(text, ...UI_WORDS) || Boolean(named);
|
||||
const anyTarget = walk(tree).some((node) => resolveTarget(tree, text, { registry }).length > 0);
|
||||
if (!mentionsUi && !anyTarget) return null;
|
||||
/* A phrase that refers back to what was just changed names its subject
|
||||
without naming it, so the focus is evidence in its own right. */
|
||||
const carriesOn = Boolean(focus) && refersBack(text);
|
||||
if (!mentionsUi && !anyTarget && !carriesOn) return null;
|
||||
|
||||
switch (verb.op) {
|
||||
case 'hide': return planVisibility(text, tree, registry, true);
|
||||
case 'hide': return planVisibility(text, tree, registry, true, focus);
|
||||
case 'move': return planMove(text, tree, registry);
|
||||
case 'replace': return planReplace(text, tree, registry, page);
|
||||
case 'replace': return planReplace(text, tree, registry, page, focus);
|
||||
case 'add': return planAdd(text, tree, registry, named, role, page);
|
||||
case 'layout': return planLayout(text, tree, registry);
|
||||
case 'present': return planPresent(text, tree, registry);
|
||||
case 'layout': return planLayout(text, tree, registry, focus);
|
||||
case 'present': return planPresent(text, tree, registry, focus);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -183,7 +270,7 @@ export function matchUiEdit(question, {
|
||||
* while somebody is looking at another one is the failure this exists to
|
||||
* prevent. Two candidates come back as a question with both named.
|
||||
*/
|
||||
function target(text, tree, registry, { exclude = [], preferHidden = false } = {}) {
|
||||
function target(text, tree, registry, { exclude = [], preferHidden = false, focus = null } = {}) {
|
||||
const all = resolveTarget(tree, text, { registry })
|
||||
.filter((node) => !exclude.includes(node.id));
|
||||
/* "Bring back the timeline" means the hidden one, when a hidden one fits.
|
||||
@@ -191,6 +278,24 @@ function target(text, tree, registry, { exclude = [], preferHidden = false } = {
|
||||
const hiddenOnly = all.filter((node) => node.hidden);
|
||||
const hits = preferHidden && hiddenOnly.length ? hiddenOnly : all;
|
||||
|
||||
/**
|
||||
* Nothing named, but something referred to.
|
||||
*
|
||||
* The last resort, and it is fenced on three sides: the phrase has to carry
|
||||
* a pronoun or a demonstrative, the caller has to have recorded a subject,
|
||||
* and that subject has to still be on the page. Any of the three missing and
|
||||
* this is an unknown target exactly as before.
|
||||
*
|
||||
* It runs only when the words resolved to nothing, so a request that names a
|
||||
* section is never quietly redirected to a different one — which would be the
|
||||
* failure this whole resolver exists to prevent, reintroduced by the back
|
||||
* door.
|
||||
*/
|
||||
if (!hits.length && focus && refersBack(text) && !exclude.includes(focus)) {
|
||||
const carried = resolveTarget(tree, focus, { registry }).find((node) => node.id === focus);
|
||||
if (carried) return { node: carried };
|
||||
}
|
||||
|
||||
if (!hits.length) return { kind: 'unknown', phrase: text };
|
||||
|
||||
/**
|
||||
@@ -209,8 +314,8 @@ function target(text, tree, registry, { exclude = [], preferHidden = false } = {
|
||||
|
||||
|
||||
/** Hide or show a node the phrase named. */
|
||||
function planVisibility(text, tree, registry, hidden) {
|
||||
const found = target(text, tree, registry);
|
||||
function planVisibility(text, tree, registry, hidden, focus = null) {
|
||||
const found = target(text, tree, registry, { focus });
|
||||
if (found.kind) return found;
|
||||
return visibilityPlan(found.node, hidden);
|
||||
}
|
||||
@@ -356,8 +461,188 @@ function namedType(text, registry, page = null) {
|
||||
return hit?.entry || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this node could be shown as, and what to call it.
|
||||
*
|
||||
* The single place either half of the product asks the question. `planReplace`
|
||||
* uses it to check one answer and `planOptions` to list them all, so a type
|
||||
* offered in a list can never be one the same request would refuse — which is
|
||||
* the bug that two copies of this rule would eventually produce.
|
||||
*/
|
||||
function alternatives(node, registry, page) {
|
||||
const binding = node.data || null;
|
||||
const shapes = binding?.source ? dataSourceFor(binding.source)?.shapes || [] : null;
|
||||
const types = registry.replacements(node.type, {
|
||||
shapes,
|
||||
page,
|
||||
seriesKind: kindOfBinding(binding),
|
||||
seriesBound: Boolean(binding?.series),
|
||||
/* Whether there is a reading at all — which is what tells the registry
|
||||
apart "nothing to be wrong about" from "a reading that never said what
|
||||
it means". */
|
||||
bound: Boolean(binding),
|
||||
});
|
||||
return { binding, types, entries: types.map((type) => registry.get(type)).filter(Boolean) };
|
||||
}
|
||||
|
||||
/**
|
||||
* "Show me something else" — answered from the registry, never invented.
|
||||
*
|
||||
* This is the request that most invites a model to make something up, so it is
|
||||
* the one most firmly deterministic: the answer is the same list `planReplace`
|
||||
* would check a single name against, in the same order, produced by the same
|
||||
* function. Nothing here proposes a design; it reports which registered
|
||||
* components can honestly draw what this node is already reading.
|
||||
*
|
||||
* The target is the harder half, because the sentence rarely names one — "I
|
||||
* don't like this, show me other options" names nothing at all. So: resolve it
|
||||
* from the words if the words say; otherwise let the *tree* answer, and only
|
||||
* when the tree's answer is unambiguous. One node with alternatives is the
|
||||
* subject. Several is a question back, never a guess.
|
||||
*/
|
||||
function planOptions(text, tree, registry, page = null, focus = null) {
|
||||
/* "An alternative candidate" is not a layout request, whatever else the
|
||||
sentence contains. Checked first, so no amount of page state can turn it
|
||||
into one. */
|
||||
if (qualifiedElsewhere(text, registry)) return null;
|
||||
|
||||
const named = target(text, tree, registry, { focus });
|
||||
const subject = named.kind ? null : named.node;
|
||||
|
||||
if (!subject) {
|
||||
const changeable = walk(tree)
|
||||
.filter((node) => !node.hidden)
|
||||
.map((node) => describeNode(node, { registry }))
|
||||
.filter((node) => node.capabilities.includes('replace'))
|
||||
.filter((node) => alternatives(node, registry, page).entries.length > 0);
|
||||
|
||||
/**
|
||||
* Nothing on this page could be drawn another way.
|
||||
*
|
||||
* Answered by *not* answering. This verb now runs before the
|
||||
* interface-words gate, so it sees sentences that were never about the
|
||||
* layout, and the only honest thing to do with one of those, on a page with
|
||||
* nothing to offer, is leave it to whoever else can answer it. It used to
|
||||
* say "Nothing on this page can be drawn another way yet." — a true
|
||||
* sentence, and the wrong reply to a question about candidates.
|
||||
*/
|
||||
if (!changeable.length) return null;
|
||||
/* One thing it could be about is not ambiguity; several is a question. */
|
||||
if (changeable.length > 1) {
|
||||
return { kind: 'ambiguous', candidates: changeable.slice(0, 4) };
|
||||
}
|
||||
return offer(changeable[0], registry, page);
|
||||
}
|
||||
|
||||
if (!subject.capabilities.includes('replace')) {
|
||||
return { kind: 'refused', message: `\`${subject.title || subject.label}\` cannot be drawn another way.` };
|
||||
}
|
||||
return offer(subject, registry, page);
|
||||
}
|
||||
|
||||
/**
|
||||
* "A different chart", "another table" — with the nouns read from the registry.
|
||||
*
|
||||
* The phrase list above cannot cover this, because the noun a person reaches
|
||||
* for is the name of a kind of component, and writing those out here would put
|
||||
* component names back into the language layer — the one thing this file is
|
||||
* checked for. So the nouns are derived: the last word of every registered
|
||||
* type's own label, which is "chart" for five of them and "table", "card" or
|
||||
* "timeline" for the others. A type registered tomorrow is askable for by its
|
||||
* own noun, tomorrow, with this unchanged.
|
||||
*/
|
||||
function asksToRedraw(text, registry) {
|
||||
return [...redrawNouns(registry)].some((noun) => (
|
||||
new RegExp(`\\b(another|different|other)\\s+(?:\\w+\\s+)?${noun}\\b`).test(text)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* The nouns a person can ask for another *of*.
|
||||
*
|
||||
* The last word of every registered type's own label — "chart", "table",
|
||||
* "card", "timeline" — so the vocabulary is the registry's rather than a list
|
||||
* kept here. Page-bound types are left out on purpose: their labels are the
|
||||
* names of readings ("Hiring activity", "Position candidates"), and "another
|
||||
* candidate" is a question about people, not about panels.
|
||||
*/
|
||||
function redrawNouns(registry) {
|
||||
return new Set(
|
||||
registry.all()
|
||||
.filter((entry) => !entry.page)
|
||||
.map((entry) => canon(entry.label).split(' ').pop())
|
||||
.filter((noun) => noun && noun.length > 2)
|
||||
);
|
||||
}
|
||||
|
||||
/** The words a bare "another" is about when it is about the interface. */
|
||||
const REDRAW_WORDS = [
|
||||
'design', 'designs', 'look', 'looks', 'option', 'options', 'way', 'ways',
|
||||
'alternative', 'alternatives', 'version', 'versions', 'one', 'ones',
|
||||
'visualisation', 'visualisations', 'visualization', 'visualizations',
|
||||
];
|
||||
|
||||
/** The words that carry no evidence about what is being asked for. */
|
||||
const FILLER = new Set([
|
||||
'the', 'this', 'that', 'for', 'and', 'with', 'from', 'some', 'any', 'you',
|
||||
'can', 'could', 'are', 'was', 'all', 'out', 'into', 'please', 'give', 'show',
|
||||
'want', 'like', 'have', 'use', 'about', 'here',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Is the "other" in this sentence about something that is not the interface?
|
||||
*
|
||||
* The one test that keeps "give me an alternative candidate" a question about
|
||||
* people. Both sentences say "alternative"; the difference is the noun that
|
||||
* follows it, and it is decided by looking rather than by guessing:
|
||||
*
|
||||
* - nothing follows → about the interface ("give me alternatives")
|
||||
* - one of the interface words → about the interface ("other options")
|
||||
* - a registered type's noun → about the interface ("a different chart")
|
||||
* - anything else → not ours, and left entirely alone
|
||||
*
|
||||
* A false positive here costs a person a wrong answer about their candidates,
|
||||
* which is worse than a layout question going unanswered — so the doubtful case
|
||||
* falls through rather than being claimed.
|
||||
*/
|
||||
function qualifiedElsewhere(text, registry) {
|
||||
const ours = new Set([...REDRAW_WORDS, ...UI_WORDS, ...redrawNouns(registry)]);
|
||||
const words = text.split(' ');
|
||||
|
||||
return words.some((word, i) => {
|
||||
if (!['other', 'another', 'different', 'alternative', 'alternatives'].includes(word)) {
|
||||
return false;
|
||||
}
|
||||
const following = words.slice(i + 1, i + 3)
|
||||
.filter((next) => next.length > 2 && !FILLER.has(next));
|
||||
return following.length > 0 && !ours.has(following[0]);
|
||||
});
|
||||
}
|
||||
|
||||
/** The list, or the reason there is not one. */
|
||||
function offer(node, registry, page) {
|
||||
const { binding, entries } = alternatives(node, registry, page);
|
||||
if (!entries.length) {
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: `There is no other way to draw ${binding ? labelOfBinding(binding) : node.title || node.label} yet.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'options',
|
||||
node,
|
||||
subject: binding ? labelOfBinding(binding) : node.title || node.label,
|
||||
/* Four, because a choice a person reads at a glance is a choice they make.
|
||||
The cap is on what is offered, never on what is possible — the operation
|
||||
path accepts any registered type the same rule allows. */
|
||||
options: entries.slice(0, 4).map((entry) => ({
|
||||
type: entry.type, label: entry.label, summary: entry.summary,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Turn one node into another type. */
|
||||
function planReplace(text, tree, registry, page = null) {
|
||||
function planReplace(text, tree, registry, page = null, focus = null) {
|
||||
/**
|
||||
* The type asked for is the one after the connector.
|
||||
*
|
||||
@@ -385,7 +670,15 @@ function planReplace(text, tree, registry, page = null) {
|
||||
/* The subject is whatever came before the connector; with no connector, the
|
||||
sentence minus the type name. */
|
||||
const subject = split ? text.slice(0, split.index) : text.replace(canon(named.label), ' ');
|
||||
const found = target(subject, tree, registry);
|
||||
/**
|
||||
* "Change **it** back to a line chart."
|
||||
*
|
||||
* The half of the sentence before the connector is a pronoun, which names
|
||||
* nothing and resolves to nothing — so the subject is whatever this
|
||||
* conversation last changed. That is the ordinary reading of the sentence,
|
||||
* and producing "I could not find that on this page" instead was the gap.
|
||||
*/
|
||||
const found = target(subject, tree, registry, { focus });
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
@@ -398,14 +691,26 @@ function planReplace(text, tree, registry, page = null) {
|
||||
|
||||
/* The binding decides what it can become. Asking the registry rather than
|
||||
deciding here is what keeps this free of type knowledge. */
|
||||
const shapes = node.data ? dataSourceFor(node.data.source)?.shapes || [] : null;
|
||||
const allowed = registry.replacements(node.type, { shapes, page });
|
||||
if (!allowed.includes(named.type)) {
|
||||
const { binding, entries } = alternatives(node, registry, page);
|
||||
if (!entries.some((entry) => entry.type === named.type)) {
|
||||
/**
|
||||
* Refused, with the reason and the way forward.
|
||||
*
|
||||
* A bare no is the worst of the three things this could say. The reason
|
||||
* comes from what the reading *means* — computed once, in `series.js`, so
|
||||
* the editor's refusal and this one are the same sentence — and the
|
||||
* alternatives are the very list the next question would produce.
|
||||
*/
|
||||
const why = binding
|
||||
? refuseSeries(named, binding)
|
||||
: `\`${node.label}\` cannot become a ${named.label}.`;
|
||||
return {
|
||||
kind: 'refused',
|
||||
message: node.data
|
||||
? `\`${dataSourceLabel(node.data.source)}\` cannot be shown as a ${named.label}.`
|
||||
: `\`${node.label}\` cannot become a ${named.label}.`,
|
||||
message: why || (binding?.source
|
||||
? `\`${dataSourceLabel(binding.source)}\` cannot be shown as a ${named.label}.`
|
||||
: `\`${node.label}\` cannot become a ${named.label}.`),
|
||||
node,
|
||||
alternatives: entries.map((entry) => ({ type: entry.type, label: entry.label })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -514,7 +819,7 @@ function namedSource(text, named, answerable = null) {
|
||||
* putting something back is the request people actually make after trying
|
||||
* something, and it has to be sayable.
|
||||
*/
|
||||
function planPresent(text, tree, registry) {
|
||||
function planPresent(text, tree, registry, focus = null) {
|
||||
const wants = {};
|
||||
if (has(text, 'compact', 'denser', 'tighter')) wants.density = 'compact';
|
||||
if (has(text, 'comfortable', 'spacious', 'roomier')) wants.density = 'comfortable';
|
||||
@@ -523,7 +828,7 @@ function planPresent(text, tree, registry) {
|
||||
if (has(text, 'default', 'reset', 'normal')) wants.variant = 'default';
|
||||
if (!Object.keys(wants).length) return { kind: 'unknown', phrase: text };
|
||||
|
||||
const found = target(text, tree, registry);
|
||||
const found = target(text, tree, registry, { focus });
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
@@ -556,13 +861,13 @@ function planPresent(text, tree, registry) {
|
||||
}
|
||||
|
||||
/** Column counts. */
|
||||
function planLayout(text, tree, registry) {
|
||||
function planLayout(text, tree, registry, focus = null) {
|
||||
const digit = /(\d+)\s*(?:column|col)/.exec(text)?.[1];
|
||||
const word = Object.keys(NUMBER_WORDS).find((w) => has(text, `${w} column`));
|
||||
const columns = Number(digit) || NUMBER_WORDS[word] || (has(text, 'side by side', 'two up') ? 2 : null);
|
||||
if (!columns) return { kind: 'unknown', phrase: text };
|
||||
|
||||
const found = target(text, tree, registry);
|
||||
const found = target(text, tree, registry, { focus });
|
||||
if (found.kind) return found;
|
||||
|
||||
const node = found.node;
|
||||
|
||||
@@ -102,6 +102,30 @@ export const VARIANT_VALUES = ['default', 'subtle', 'emphasis'];
|
||||
/** How tightly a node is packed. `comfortable` is today's spacing, unchanged. */
|
||||
export const DENSITY_VALUES = ['comfortable', 'compact'];
|
||||
|
||||
/**
|
||||
* What a numeric series *means*, as a closed vocabulary.
|
||||
*
|
||||
* Shape and meaning are different questions and the engine had only the first.
|
||||
* `flow` says a reading is a labelled numeric series; it does not say whether
|
||||
* the labels are days, funnel stages or disjoint categories — and that is
|
||||
* exactly the difference between a pie chart that is true and one that is a
|
||||
* lie. `SECTION_TYPES` even admits it: a flow is "a sequence of stages **or**
|
||||
* periods".
|
||||
*
|
||||
* - `periodic` points ordered in time. The order carries the meaning and
|
||||
* the values do not add up to a whole.
|
||||
* - `cumulative` a funnel: each step is a subset of the one before it, so
|
||||
* the steps overlap and summing them counts people twice.
|
||||
* - `parts` disjoint categories that together make up one total. The
|
||||
* only meaning for which "share of the whole" is true.
|
||||
*
|
||||
* A type declares which of these it can honestly draw (`seriesKinds`); a
|
||||
* binding is asked what it is. Neither is inferred from a component name, and a
|
||||
* binding that cannot say stays unknown — which leaves it exactly as permissive
|
||||
* as it was before this vocabulary existed, rather than guessing.
|
||||
*/
|
||||
export const SERIES_KINDS = ['periodic', 'cumulative', 'parts'];
|
||||
|
||||
/**
|
||||
* A node, with every field settled.
|
||||
*
|
||||
@@ -158,6 +182,18 @@ function normalizeBinding(value) {
|
||||
return source ? { source, params: {} } : null;
|
||||
}
|
||||
if (typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
/**
|
||||
* A page's own series, rather than a skill's reading.
|
||||
*
|
||||
* The second kind of binding, and the reason built-in charts can be changed
|
||||
* at all. A page section's figures are not in the skill data vocabulary —
|
||||
* they are derived by the page from what it already loaded — so a node that
|
||||
* draws them can only name them, the same way a skill node names a source.
|
||||
* Which one a binding is, is decided by which key it carries; both are
|
||||
* resolved through a registry and neither can carry a value.
|
||||
*/
|
||||
const series = String(value.series ?? '').trim();
|
||||
if (series) return { series, params: plainObject(value.params) };
|
||||
const source = String(value.source ?? '').trim();
|
||||
if (!source) return null;
|
||||
return { source, params: plainObject(value.params) };
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
cloneNode, findNode, locate, makeNode, mapChildren, mapNode,
|
||||
} from './node';
|
||||
import { nodeRegistry } from './registry';
|
||||
import { refuseSeries } from './series';
|
||||
import { takenIds, validateTree } from './validate';
|
||||
|
||||
/** The operation names the engine understands. Anything else is refused by name. */
|
||||
@@ -355,7 +356,28 @@ const BUILDERS = {
|
||||
return fail(nodes, `\`${entry.label}\` cannot show ${label}.`);
|
||||
}
|
||||
}
|
||||
if (!binding?.source && entry.dataRequired) {
|
||||
/**
|
||||
* A page's own series is compatible with the types that draw one.
|
||||
*
|
||||
* The structural question for this kind of binding is not "which shapes"
|
||||
* — a page series is not in the shape vocabulary and should not be — it is
|
||||
* whether the candidate draws a series at all, which is what a non-empty
|
||||
* `seriesKinds` says.
|
||||
*/
|
||||
if (binding?.series && !entry.seriesKinds.length) {
|
||||
return fail(nodes, `\`${entry.label}\` cannot draw a series.`);
|
||||
}
|
||||
/**
|
||||
* And the semantic question, for either kind of binding.
|
||||
*
|
||||
* Refused by what the reading *is*, not by what the component is called, so
|
||||
* the sentence a person gets back tells them why rather than merely that.
|
||||
* Silence — an unknown kind — is not a refusal.
|
||||
*/
|
||||
const untrue = binding ? refuseSeries(entry, binding) : null;
|
||||
if (untrue && entry.seriesKinds.length) return fail(nodes, untrue);
|
||||
|
||||
if (!binding?.source && !binding?.series && entry.dataRequired) {
|
||||
return fail(nodes, `\`${entry.label}\` needs a reading, and \`${before.id}\` has none.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
DENSITY_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_CAPABILITIES, NODE_ID_PATTERN, VARIANT_VALUES,
|
||||
DENSITY_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_CAPABILITIES, NODE_ID_PATTERN, SERIES_KINDS,
|
||||
VARIANT_VALUES,
|
||||
} from './node';
|
||||
|
||||
/**
|
||||
@@ -87,6 +88,7 @@ export class NodeTypeRegistry {
|
||||
};
|
||||
refuseUnknown('variant', definition.variants, VARIANT_VALUES);
|
||||
refuseUnknown('density', definition.densities, DENSITY_VALUES);
|
||||
refuseUnknown('series kind', definition.seriesKinds, SERIES_KINDS);
|
||||
|
||||
/* A type offered by a picker and unable to be removed is a dead end: a
|
||||
person creates a node and has no way to take it back. Said here rather
|
||||
@@ -167,6 +169,22 @@ export class NodeTypeRegistry {
|
||||
variants: Object.freeze([...(definition.variants || [])]),
|
||||
densities: Object.freeze([...(definition.densities || [])]),
|
||||
|
||||
/**
|
||||
* The series *meanings* this type can honestly draw.
|
||||
*
|
||||
* Empty means two things at once, and both are the conservative reading:
|
||||
* this type is not a visualization of a series, and it makes no claim
|
||||
* about meaning. So a section registered before this field existed is
|
||||
* unaffected — it is neither offered as a chart nor vetoed as one.
|
||||
*
|
||||
* A non-empty list is a promise. `pie-chart` says `parts` and nothing
|
||||
* else, and that single word is what refuses to draw twelve days of
|
||||
* hiring as twelve slices of a whole. The alternative — letting the
|
||||
* shape vocabulary decide — cannot express it: `flow` is, in its own
|
||||
* words, "a sequence of stages *or* periods".
|
||||
*/
|
||||
seriesKinds: Object.freeze([...(definition.seriesKinds || [])]),
|
||||
|
||||
/**
|
||||
* The page this type belongs to, or `null` for one that belongs anywhere.
|
||||
*
|
||||
@@ -303,7 +321,19 @@ export class NodeTypeRegistry {
|
||||
* coupling it to data sources would make a UI type impossible to register
|
||||
* without one.
|
||||
*/
|
||||
replacements(type, { shapes = null, page = null } = {}) {
|
||||
replacements(type, {
|
||||
shapes = null, page = null, seriesKind = null, seriesBound = false,
|
||||
/**
|
||||
* Whether this node reads anything at all.
|
||||
*
|
||||
* Separate from `seriesKind`, because "no binding" and "a binding that
|
||||
* never said what it means" are different situations with different right
|
||||
* answers. An unbound node has nothing to be untrue about; a bound one
|
||||
* whose meaning was never established is exactly the case the semantic
|
||||
* veto exists for.
|
||||
*/
|
||||
bound = false,
|
||||
} = {}) {
|
||||
const current = this.get(type);
|
||||
if (!current) return [];
|
||||
|
||||
@@ -325,12 +355,47 @@ export class NodeTypeRegistry {
|
||||
* in every page's "Show as" list.
|
||||
*/
|
||||
.filter((entry) => this.offersChild(null, entry.type, { page }))
|
||||
/**
|
||||
* The semantic veto, applied to every binding.
|
||||
*
|
||||
* A type that has said which meanings it can draw may only draw those.
|
||||
* This is the one filter that is about truth rather than about structure,
|
||||
* and it is why "show hiring activity as a pie chart" is refused while
|
||||
* "as a bar chart" is not — both are structurally possible and only one
|
||||
* of them is honest. A kind of `null` is "not known", and vetoes nothing.
|
||||
*/
|
||||
.filter((entry) => {
|
||||
/* A type that claims no meanings makes no claim to be wrong about. */
|
||||
if (!entry.seriesKinds.length) return true;
|
||||
/* Nothing bound yet: the binding decides this, and there is not one. */
|
||||
if (!bound) return true;
|
||||
/**
|
||||
* Bound, and only offerable if the meaning is known and drawable.
|
||||
*
|
||||
* The `!seriesKind` case is the one that changed: an undeclared
|
||||
* meaning used to fall through as "no objection", which is how a card
|
||||
* reading `Recent hires` came to be offered a pie chart. Not knowing
|
||||
* is not permission — see `refuseSeries`, which produces the sentence
|
||||
* a person is given when they ask for one of these by name.
|
||||
*/
|
||||
return Boolean(seriesKind) && entry.seriesKinds.includes(seriesKind);
|
||||
})
|
||||
.filter((entry) => (
|
||||
/* A type that reads no data can only be swapped for another that reads
|
||||
none — a divider is not an alternative rendering of a chart. */
|
||||
wanted.length
|
||||
? entry.dataShapes.some((shape) => wanted.includes(shape))
|
||||
: entry.dataShapes.length === 0
|
||||
/**
|
||||
* The structural half, in the currency of whichever binding this is.
|
||||
*
|
||||
* A node bound to a page's own series is compatible with the types that
|
||||
* draw a series — which is what `seriesKinds` being non-empty means.
|
||||
* A node bound to a skill reading is compared shape to shape, as it has
|
||||
* always been. A type that reads no data can only be swapped for
|
||||
* another that reads none: a divider is not an alternative rendering of
|
||||
* a chart.
|
||||
*/
|
||||
seriesBound
|
||||
? entry.seriesKinds.length > 0
|
||||
: (wanted.length
|
||||
? entry.dataShapes.some((shape) => wanted.includes(shape))
|
||||
: entry.dataShapes.length === 0)
|
||||
))
|
||||
.map((entry) => entry.type);
|
||||
}
|
||||
|
||||
300
src/lib/ui/series.js
Normal file
300
src/lib/ui/series.js
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* What a series is, what it means, and where a page publishes one.
|
||||
*
|
||||
* Two things live here, and they are the two halves of "can this reading be
|
||||
* drawn that way".
|
||||
*
|
||||
* **The series registry.** A page derives figures from the records it has
|
||||
* already loaded — Control Center buckets applications, screenings, interviews
|
||||
* and hires by day — and those figures are not in the skill data vocabulary and
|
||||
* should not be: nothing outside that page can compute them. So a page declares
|
||||
* them here, by name, with a pure `read` that takes the bag the page publishes
|
||||
* and returns rows. A node then *names* the series, exactly as a skill node
|
||||
* names a source. **A binding never carries values**, so there is still nowhere
|
||||
* for an invented figure to live.
|
||||
*
|
||||
* **The meaning.** `dataShapes` answers whether a component can draw a shape;
|
||||
* it cannot answer whether doing so would be true. A pie of a time series is
|
||||
* structurally fine and semantically a lie — it throws the timeline away and
|
||||
* presents days as slices of a whole. `SERIES_KINDS` is the missing half, and
|
||||
* `kindOfBinding` is where a binding is asked which one it is.
|
||||
*
|
||||
* The rule for not knowing is deliberate, and it is strict: **an unknown kind
|
||||
* is a refusal.** A component that has declared which meanings it can draw may
|
||||
* only draw a meaning that was actually declared — by a page's series, by a
|
||||
* reading in the data-source vocabulary, or by the binding's own periods. This
|
||||
* is the one place the system could otherwise produce something structurally
|
||||
* valid and factually untrue, and silence is not evidence that it would be
|
||||
* true. Declaring a meaning is one field; guessing one is not offered.
|
||||
*
|
||||
* Components that declare no meanings at all — a table, a list, a card — are
|
||||
* untouched by any of this. They make no claim about what their rows mean, so
|
||||
* there is nothing for them to be wrong about.
|
||||
*/
|
||||
|
||||
import { SERIES_KINDS } from './node';
|
||||
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
|
||||
|
||||
export { SERIES_KINDS };
|
||||
|
||||
/** Why a kind is what it is, in words a refusal can use. */
|
||||
const KIND_REASON = {
|
||||
periodic: 'it is ordered in time, and that order is the reading',
|
||||
cumulative: 'each step is part of the one before it, so the steps overlap',
|
||||
parts: 'it is a set of parts that make up one whole',
|
||||
};
|
||||
|
||||
/** @type {Map<string, any>} */
|
||||
const providers = new Map();
|
||||
|
||||
/** A series id looks like a node id: lower-case, dashes, and a dotted namespace. */
|
||||
const SERIES_ID_PATTERN = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/;
|
||||
|
||||
/**
|
||||
* Declare a series a page publishes.
|
||||
*
|
||||
* Called at module scope beside the page that computes it, the same way a page
|
||||
* registers its composition — so a series and the page that can answer for it
|
||||
* are deployed together or not at all.
|
||||
*
|
||||
* Re-registering replaces, because the dev server re-runs the module on every
|
||||
* save and throwing there would make the file uneditable.
|
||||
*/
|
||||
/**
|
||||
* A control a series publishes, checked at registration.
|
||||
*
|
||||
* The missing half of "replacing a visualization must not remove what was
|
||||
* there". A page's built-in chart often ships with a control that governs the
|
||||
* reading itself — Control Center's 7D/30D/90D toggle changes *which rows the
|
||||
* series returns*, not how they are drawn — and replacing the component threw
|
||||
* it away, because the control lived inside the component.
|
||||
*
|
||||
* Declaring it on the **series** is what fixes that generically: the control
|
||||
* belongs to the reading, so every component that can draw the reading draws
|
||||
* the control too, and nothing anywhere names a range, a page or a chart. A
|
||||
* series that publishes none is unaffected, and no control is ever invented —
|
||||
* `read` and `write` are the page's own, so a page that stops publishing the
|
||||
* state stops publishing the control with it.
|
||||
*/
|
||||
function normalizeControl(id, control) {
|
||||
const controlId = String(control?.id ?? '').trim();
|
||||
if (!controlId) throw new Error(`registerSeries: \`${id}\` declares a control with no \`id\`.`);
|
||||
|
||||
const options = (control.options || []).map((option) => ({
|
||||
value: String(option?.value ?? '').trim(),
|
||||
label: String(option?.label ?? '').trim(),
|
||||
}));
|
||||
if (!options.length) {
|
||||
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` needs at least one option.`);
|
||||
}
|
||||
const blank = options.find((option) => !option.value || !option.label);
|
||||
if (blank) {
|
||||
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` has an option with no value or label.`);
|
||||
}
|
||||
if (typeof control.read !== 'function' || typeof control.write !== 'function') {
|
||||
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` needs a \`read\` and a \`write\`.`);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
id: controlId,
|
||||
label: String(control.label ?? controlId).trim(),
|
||||
options: Object.freeze(options.map(Object.freeze)),
|
||||
read: control.read,
|
||||
write: control.write,
|
||||
});
|
||||
}
|
||||
|
||||
export function registerSeries(definition) {
|
||||
const id = String(definition?.id ?? '').trim();
|
||||
if (!id) throw new Error('registerSeries: a series needs an `id`.');
|
||||
if (!SERIES_ID_PATTERN.test(id)) {
|
||||
throw new Error(`registerSeries: \`${id}\` must be lower-case letters, numbers, dashes and dots.`);
|
||||
}
|
||||
const kind = String(definition.kind ?? '').trim();
|
||||
if (!SERIES_KINDS.includes(kind)) {
|
||||
throw new Error(
|
||||
`registerSeries: \`${id}\` declares unknown kind \`${kind || '(none)'}\`. `
|
||||
+ `Known: ${SERIES_KINDS.join(', ')}.`
|
||||
);
|
||||
}
|
||||
if (typeof definition.read !== 'function') {
|
||||
throw new Error(`registerSeries: \`${id}\` needs a \`read(context)\`.`);
|
||||
}
|
||||
|
||||
const measures = (definition.measures || []).map((measure) => ({
|
||||
key: String(measure?.key ?? '').trim(),
|
||||
label: String(measure?.label ?? '').trim(),
|
||||
}));
|
||||
if (!measures.length) {
|
||||
throw new Error(`registerSeries: \`${id}\` needs at least one measure.`);
|
||||
}
|
||||
const blank = measures.find((measure) => !measure.key || !measure.label);
|
||||
if (blank) throw new Error(`registerSeries: \`${id}\` has a measure with no key or label.`);
|
||||
|
||||
const entry = Object.freeze({
|
||||
id,
|
||||
label: String(definition.label ?? id).trim(),
|
||||
kind,
|
||||
measures: Object.freeze(measures.map(Object.freeze)),
|
||||
/* The key on each row that names the point — the x axis, or the slice. */
|
||||
labelKey: String(definition.labelKey ?? 'label').trim() || 'label',
|
||||
read: definition.read,
|
||||
emptyNote: String(definition.emptyNote ?? 'Nothing to chart yet.').trim(),
|
||||
/* Optional, and absent for every series that does not publish one. */
|
||||
control: definition.control ? normalizeControl(id, definition.control) : null,
|
||||
});
|
||||
|
||||
providers.set(id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The registration, or null. */
|
||||
export const seriesFor = (id) => providers.get(String(id ?? '').trim()) || null;
|
||||
|
||||
/** Every registered series. Used by tests and by anything that has to list them. */
|
||||
export const listSeries = () => [...providers.values()];
|
||||
|
||||
/** Forget everything. Tests only. */
|
||||
export const resetSeries = () => providers.clear();
|
||||
|
||||
/**
|
||||
* What this binding means, or null for a binding that cannot say.
|
||||
*
|
||||
* Three answers, in order of how much they actually know:
|
||||
*
|
||||
* 1. A registered series declared its kind. That is a fact its author wrote.
|
||||
* 2. A reading bound to time periods is periodic. Also a fact — `periods` is
|
||||
* in the closed option vocabulary and the resolver buckets by it.
|
||||
* 3. Otherwise unknown, and unknown vetoes nothing.
|
||||
*/
|
||||
export function kindOfBinding(binding) {
|
||||
if (!binding) return null;
|
||||
if (binding.series) return seriesFor(binding.series)?.kind || null;
|
||||
if (binding.source) {
|
||||
if (binding.params?.periods?.length) return 'periodic';
|
||||
return dataSourceFor(binding.source)?.series || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** What to call this binding in a sentence. */
|
||||
export function labelOfBinding(binding) {
|
||||
if (!binding) return 'this';
|
||||
if (binding.series) return seriesFor(binding.series)?.label || binding.series;
|
||||
if (binding.source) return dataSourceLabel(binding.source) || binding.source;
|
||||
return 'this';
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a type may not draw this binding — one sentence, or null if it may.
|
||||
*
|
||||
* Written here rather than at the call sites so the editor and the conversation
|
||||
* refuse in the same words, and so the reason is about the *data* rather than
|
||||
* about the component. "A pie chart cannot show Hiring activity" tells nobody
|
||||
* anything; saying that the reading is ordered in time does.
|
||||
*/
|
||||
export function refuseSeries(entry, binding) {
|
||||
if (!entry.seriesKinds.length) {
|
||||
return `\`${entry.label}\` cannot draw ${labelOfBinding(binding)}.`;
|
||||
}
|
||||
const kind = kindOfBinding(binding);
|
||||
/**
|
||||
* Not knowing is a refusal, not a permission.
|
||||
*
|
||||
* This used to be the other way round — an unknown kind vetoed nothing — and
|
||||
* the consequence was visible in the product: a card reading `Recent hires`
|
||||
* was offered a pie chart, because nothing had ever said what those figures
|
||||
* mean and silence was read as consent. A component that has declared which
|
||||
* meanings it can honestly draw cannot draw one that was never declared, so
|
||||
* the honest answer is no, with the reason.
|
||||
*
|
||||
* Saying yes here is the only way this system can produce a chart that is
|
||||
* structurally valid and factually a lie, which is why the default is the
|
||||
* strict one. Establishing compatibility is one field on the reading — see
|
||||
* `series` in the data-source vocabulary — and is deliberately the author's
|
||||
* statement rather than a guess made here from a label or a shape.
|
||||
*/
|
||||
if (!kind) {
|
||||
return (
|
||||
`\`${entry.label}\` cannot show ${labelOfBinding(binding)}: `
|
||||
+ 'it does not say what its figures mean, so there is no way to know '
|
||||
+ 'that drawing them this way would be true.'
|
||||
);
|
||||
}
|
||||
if (entry.seriesKinds.includes(kind)) return null;
|
||||
return (
|
||||
`\`${entry.label}\` cannot show ${labelOfBinding(binding)}: `
|
||||
+ `${KIND_REASON[kind]}.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The control this binding publishes, resolved against the page, or null.
|
||||
*
|
||||
* Two facts have to line up before a control exists: the series declared one,
|
||||
* and the page is actually publishing the state it names. Either missing means
|
||||
* no control — nothing is drawn from a default, and nothing is fabricated to
|
||||
* fill the gap a replaced component left behind.
|
||||
*/
|
||||
export function controlOfBinding(binding, context = {}) {
|
||||
const entry = binding?.series ? seriesFor(binding.series) : null;
|
||||
const control = entry?.control;
|
||||
if (!control) return null;
|
||||
|
||||
const value = control.read(context || {});
|
||||
if (value == null) return null;
|
||||
|
||||
return {
|
||||
id: control.id,
|
||||
label: control.label,
|
||||
options: control.options,
|
||||
value,
|
||||
set: (next) => control.write(context || {}, next),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows a binding resolves to, in the one shape every visualization reads.
|
||||
*
|
||||
* `{ rows, measures, emptyNote }` — rows carry a label and one number per
|
||||
* measure, so a single-measure skill reading and a four-measure page series are
|
||||
* the same object by the time a chart sees them. Nothing is aggregated,
|
||||
* re-bucketed or re-ordered on the way: a chart draws what the page or the
|
||||
* resolver already computed, and a transformation that changed the meaning
|
||||
* would have to be written somewhere, and there is nowhere.
|
||||
*
|
||||
* `resolve` is passed in rather than imported so this stays free of React and
|
||||
* of the skill resolver — the component knows how to get a skill reading, and
|
||||
* this knows what to do with either kind.
|
||||
*/
|
||||
export function readSeries(binding, { context = {}, resolveSource = null } = {}) {
|
||||
const empty = { rows: [], measures: [], emptyNote: 'Nothing to chart yet.', label: '' };
|
||||
if (!binding) return empty;
|
||||
|
||||
if (binding.series) {
|
||||
const entry = seriesFor(binding.series);
|
||||
if (!entry) return empty;
|
||||
const raw = entry.read(context || {}) || [];
|
||||
const rows = (Array.isArray(raw) ? raw : []).map((row, i) => {
|
||||
const point = { id: String(row?.id ?? i), label: String(row?.[entry.labelKey] ?? '') };
|
||||
for (const measure of entry.measures) point[measure.key] = Number(row?.[measure.key]) || 0;
|
||||
return point;
|
||||
}).filter((row) => row.label);
|
||||
return { rows, measures: entry.measures, emptyNote: entry.emptyNote, label: entry.label };
|
||||
}
|
||||
|
||||
if (!binding.source || typeof resolveSource !== 'function') return empty;
|
||||
const data = resolveSource(binding) || {};
|
||||
const rows = (data.steps || data.items || []).map((step, i) => ({
|
||||
id: String(step?.id ?? i),
|
||||
label: String(step?.label || step?.title || ''),
|
||||
value: Number(step?.value) || 0,
|
||||
})).filter((row) => row.label);
|
||||
|
||||
return {
|
||||
rows,
|
||||
measures: [{ key: 'value', label: dataSourceLabel(binding.source) || 'Value' }],
|
||||
emptyNote: data.emptyNote || 'Nothing to chart yet.',
|
||||
label: dataSourceLabel(binding.source) || binding.source,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, dataSourceFor, sourceSupportsOption,
|
||||
sourceSupportsShape,
|
||||
} from '@/lib/skills/surfaces';
|
||||
import { refuseSeries, seriesFor } from './series';
|
||||
import {
|
||||
ALIGN_VALUES, DENSITY_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_ID_PATTERN,
|
||||
PRESENTATION_KEYS, SPACING_VALUES, VARIANT_VALUES, walk,
|
||||
@@ -303,6 +304,31 @@ function validateBinding(node, entry) {
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page's own series.
|
||||
*
|
||||
* Checked against the series registry rather than the data-source vocabulary,
|
||||
* because it is a different closed table — but the gate is the same one and
|
||||
* for the same reason: a binding may only *name* something already
|
||||
* registered, so there is still nowhere for a fabricated figure to live. The
|
||||
* meaning is checked too, so a stored patch that turned a time series into a
|
||||
* pie is refused on load rather than drawn.
|
||||
*/
|
||||
if (binding.series) {
|
||||
const series = seriesFor(binding.series);
|
||||
if (!series) {
|
||||
problems.push(problem(at, `Unsupported series: ${binding.series}.`));
|
||||
return problems;
|
||||
}
|
||||
if (!entry.seriesKinds.length) {
|
||||
problems.push(problem(at, `\`${entry.type}\` does not draw a series.`));
|
||||
return problems;
|
||||
}
|
||||
const untrue = refuseSeries(entry, binding);
|
||||
if (untrue) problems.push(problem(at, untrue));
|
||||
return problems;
|
||||
}
|
||||
|
||||
if (!entry.dataShapes.length) {
|
||||
problems.push(problem(at, `\`${entry.type}\` does not read data, so it cannot take a source.`));
|
||||
return problems;
|
||||
@@ -329,6 +355,19 @@ function validateBinding(node, entry) {
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* And the semantic half, for a skill reading as well as for a page series.
|
||||
*
|
||||
* The same call, the same sentence, one rule — so a stored patch that binds a
|
||||
* component to a reading it cannot honestly draw is refused on load exactly
|
||||
* as the operation that would have created it is refused, rather than being
|
||||
* replayed into the tree because nobody re-asked the question here.
|
||||
*/
|
||||
if (entry.seriesKinds.length) {
|
||||
const untrue = refuseSeries(entry, binding);
|
||||
if (untrue) problems.push(problem(at, untrue));
|
||||
}
|
||||
|
||||
const params = binding.params || {};
|
||||
|
||||
if (params.periods != null) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { recommendJobs, recommendNextCourse } from '@/lib/krowScore';
|
||||
import TalentHero from '@/components/krow/talent/TalentHero';
|
||||
import IncreaseMarketValue from '@/components/krow/talent/IncreaseMarketValue';
|
||||
import CareerGrowth from '@/components/krow/talent/CareerGrowth';
|
||||
import MarketOffers from '@/components/krow/talent/MarketOffers';
|
||||
import Opportunities from '@/components/krow/talent/Opportunities';
|
||||
import ExperienceNetwork from '@/components/krow/talent/ExperienceNetwork';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function EmployeeDashboard() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<TalentHero profile={profile} jobRecs={jobRecs} nextCourse={nextCourse} openOwliver={openOwliver} />
|
||||
<MarketOffers profile={profile} jobRecs={jobRecs} />
|
||||
<Opportunities jobRecs={jobRecs} />
|
||||
<IncreaseMarketValue courses={courses} />
|
||||
<CareerGrowth profile={profile} />
|
||||
<ExperienceNetwork profile={profile} />
|
||||
|
||||
208
src/pages/OpportunityDetail.jsx
Normal file
208
src/pages/OpportunityDetail.jsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, MapPin, Wallet, Sparkles, CheckCircle2, Award,
|
||||
Briefcase, Clock, Loader2, Compass,
|
||||
} from 'lucide-react';
|
||||
import { useWorkerProfile, useJobPostings } from '@/lib/krowHooks';
|
||||
import { computeJobMatch } from '@/lib/krowScore';
|
||||
|
||||
/**
|
||||
* OpportunityDetail — the full-page Employee job detail.
|
||||
*
|
||||
* A dedicated route (/opportunities/:id), NOT a drawer/sheet/modal. It continues
|
||||
* the Opportunities grid: the same title-first identity, the same match, and an
|
||||
* Apply action that hands off to the existing /apply?job=<id> flow (which runs
|
||||
* the AI interview). It reuses the existing matching — useWorkerProfile,
|
||||
* useJobPostings and computeJobMatch — and changes none of it.
|
||||
*
|
||||
* COMPANY PRIVACY: never renders job.company. Only title, role_category,
|
||||
* location, pay, status, description/responsibilities/qualifications and the
|
||||
* match signals are shown.
|
||||
*/
|
||||
|
||||
/* The skills the profile brings that this role asks for — the same
|
||||
case-insensitive overlap computeJobMatch scores, shown here for the reader
|
||||
rather than recomputed into a number. Read-only; the algorithm is untouched. */
|
||||
function matchedSkills(profile, job) {
|
||||
const have = (profile?.skills || []).map((s) => String(s).toLowerCase());
|
||||
const want = job?.qualifications || [];
|
||||
return want.filter((q) => {
|
||||
const ql = String(q).toLowerCase();
|
||||
return have.some((ps) => ql.includes(ps) || ps.includes(ql));
|
||||
});
|
||||
}
|
||||
|
||||
function matchedCerts(profile, job) {
|
||||
const have = (profile?.earned_badges || []).map((b) => String(b?.name || '').toLowerCase()).filter(Boolean);
|
||||
const want = job?.certifications_required || [];
|
||||
return want.filter((c) => {
|
||||
const cl = String(c).toLowerCase();
|
||||
return have.some((pc) => cl.includes(pc) || pc.includes(cl));
|
||||
});
|
||||
}
|
||||
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-bold text-[#0F172A] mb-3">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OpportunityDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data: profile, isLoading: loadingProfile } = useWorkerProfile();
|
||||
const { data: postings = [], isLoading: loadingJobs } = useJobPostings();
|
||||
|
||||
if (loadingProfile || loadingJobs) {
|
||||
return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0838E0]" /></div>;
|
||||
}
|
||||
|
||||
const job = postings.find((p) => p.id === id);
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto text-center py-20">
|
||||
<div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3">
|
||||
<Compass className="w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-[15px] font-semibold text-[#0F172A]">This opportunity is no longer available.</p>
|
||||
<button
|
||||
onClick={() => navigate('/employee')}
|
||||
className="mt-5 inline-flex items-center gap-1.5 text-[13px] font-semibold text-[#0838E0] hover:text-[#062BAF]"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Opportunities
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const match = computeJobMatch(profile, job);
|
||||
const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : null;
|
||||
const isHiring = job.status === 'active';
|
||||
const skills = matchedSkills(profile, job);
|
||||
const certs = matchedCerts(profile, job);
|
||||
const exp = profile?.experience_years || 0;
|
||||
const reqExp = job.min_experience_years || 0;
|
||||
const meetsExp = exp >= reqExp;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-5 pb-10">
|
||||
<button
|
||||
onClick={() => navigate('/employee')}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#6B7280] hover:text-[#111827]"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Opportunities
|
||||
</button>
|
||||
|
||||
{/* Header — title-first identity, no company name. */}
|
||||
<div className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center shrink-0">
|
||||
<Briefcase className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-[22px] font-bold text-[#0F172A] leading-tight">{job.title}</h1>
|
||||
{isHiring && (
|
||||
<span className="inline-flex items-center text-[11px] font-semibold text-white bg-[#0838E0] px-2.5 py-1 rounded-full">Hiring</span>
|
||||
)}
|
||||
</div>
|
||||
{job.role_category && <p className="mt-1 text-[14px] text-[#6B7280] capitalize">{job.role_category}</p>}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<span className="inline-flex items-center gap-1.5 text-[14px] font-bold text-[#0838E0]">
|
||||
<Sparkles className="w-4 h-4" /> {match}% match
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-[13px] text-[#6B7280]">
|
||||
<MapPin className="w-4 h-4 text-[#9CA3AF]" /> {job.location || 'Remote'}
|
||||
</span>
|
||||
{pay && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#111827]">
|
||||
<Wallet className="w-4 h-4 text-[#9CA3AF]" /> {pay}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* About this opportunity */}
|
||||
<Section title="About this opportunity">
|
||||
{job.description ? (
|
||||
<p className="text-[13px] text-[#6B7280] whitespace-pre-wrap leading-relaxed">{job.description}</p>
|
||||
) : (
|
||||
<p className="text-[13px] text-[#9CA3AF]">No description provided for this opportunity yet.</p>
|
||||
)}
|
||||
{job.responsibilities?.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-[13px] font-semibold text-[#0F172A] mb-1.5">What you'll do</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-[13px] text-[#6B7280]">
|
||||
{job.responsibilities.map((r, i) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{job.qualifications?.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-[13px] font-semibold text-[#0F172A] mb-1.5">What they're looking for</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-[13px] text-[#6B7280]">
|
||||
{job.qualifications.map((q, i) => <li key={i}>{q}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Why you're a match */}
|
||||
<Section title="Why you're a match">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex-1 h-2 rounded-full bg-[#F3F4F6] overflow-hidden">
|
||||
<div className="h-full rounded-full bg-[#0838E0]" style={{ width: `${match}%` }} />
|
||||
</div>
|
||||
<span className="text-[14px] font-bold text-[#0838E0]">{match}%</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2.5">
|
||||
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
|
||||
<Clock className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{exp} yr{exp === 1 ? '' : 's'} of experience
|
||||
{reqExp > 0 && <> · role asks for {reqExp}+ {meetsExp ? '— you qualify' : ''}</>}
|
||||
</span>
|
||||
</li>
|
||||
{skills.length > 0 && (
|
||||
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
|
||||
<CheckCircle2 className="w-4 h-4 text-[#16A34A] mt-0.5 shrink-0" />
|
||||
<span>Your skills match: {skills.join(', ')}</span>
|
||||
</li>
|
||||
)}
|
||||
{certs.length > 0 && (
|
||||
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
|
||||
<Award className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
|
||||
<span>Certifications you hold: {certs.join(', ')}</span>
|
||||
</li>
|
||||
)}
|
||||
{skills.length === 0 && certs.length === 0 && (
|
||||
<li className="flex items-start gap-2 text-[13px] text-[#6B7280]">
|
||||
<Sparkles className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
|
||||
<span>You showed up here because your profile aligns with this role. Add skills to strengthen the match.</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
{/* Apply */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => navigate(`/apply?job=${job.id}`)}
|
||||
className="inline-flex items-center justify-center gap-2 h-11 px-8 rounded-full bg-[#0838E0] text-white text-[14px] font-semibold hover:bg-[#062BAF] transition-colors
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
|
||||
>
|
||||
Apply <Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -126,7 +126,7 @@ function AgentCard({ agent, shipped, overridden, onOpen, onAction }) {
|
||||
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
|
||||
{shipped
|
||||
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
|
||||
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Delete</>}
|
||||
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Remove</>}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
@@ -310,7 +310,7 @@ function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
|
||||
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
|
||||
{shipped
|
||||
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
|
||||
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Delete</>}
|
||||
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Remove</>}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
@@ -330,7 +330,12 @@ export default function AdminWorkspaceAgents() {
|
||||
} = useAgents();
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
/* The default view is the agents in service. Removing an agent archives it,
|
||||
and an archived agent that stayed in the default list would make Remove
|
||||
look like it had not worked — while hiding it anywhere but a tab the
|
||||
reader can open would make it look destroyed. Archived is one click away
|
||||
and is where restoring happens. */
|
||||
const [status, setStatus] = useState('active');
|
||||
const [view, setView] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('krow_agents_view') || 'list';
|
||||
@@ -351,16 +356,35 @@ export default function AdminWorkspaceAgents() {
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const found = searchAgents(agents, query);
|
||||
return status === 'all' ? found : found.filter((a) => a.status === status);
|
||||
if (status === 'active') return found.filter((a) => a.status !== 'archived');
|
||||
return found.filter((a) => a.status === status);
|
||||
}, [agents, query, status]);
|
||||
|
||||
const activeCount = agents.filter((a) => a.status !== 'archived').length;
|
||||
const publishedCount = agents.filter((a) => a.status === 'published').length;
|
||||
const draftCount = agents.filter((a) => a.status === 'draft').length;
|
||||
const archivedCount = agents.filter((a) => a.status === 'archived').length;
|
||||
|
||||
/** Runs a lifecycle action and reports honestly when it is refused. */
|
||||
/**
|
||||
* Runs a lifecycle action and reports honestly when it is refused.
|
||||
*
|
||||
* `remove` resolves to two different operations, and the difference is the
|
||||
* whole of what makes removal safe:
|
||||
*
|
||||
* authored agent → archive. The definition is kept, under the same id,
|
||||
* with its instructions, tools and skills untouched. It
|
||||
* leaves the default list and can be restored.
|
||||
* shipped agent → remove, which deletes THIS ACCOUNT'S override row and
|
||||
* nothing else. The product's own definition takes over
|
||||
* again — that is the "Revert to shipped" wording the
|
||||
* menu already uses, and it destroys no shipped agent.
|
||||
*
|
||||
* Resolved here rather than in the menu so both the card and the table row
|
||||
* cannot drift apart on what Remove means.
|
||||
*/
|
||||
const run = async (action, agent) => {
|
||||
const fn = { publish, republish: publish, archive, restore, duplicate, remove }[action];
|
||||
const resolved = action === 'remove' && !isShipped(agent.id) ? 'archive' : action;
|
||||
const fn = { publish, republish: publish, archive, restore, duplicate, remove }[resolved];
|
||||
const result = await fn(agent.id);
|
||||
if (result?.conflict) toast.error(result.conflict.message);
|
||||
else if (result?.error) toast.error(result.error);
|
||||
@@ -368,7 +392,9 @@ export default function AdminWorkspaceAgents() {
|
||||
};
|
||||
|
||||
const onAction = (action, agent) => {
|
||||
/* Destructive and irreversible-looking actions confirm; the rest run. */
|
||||
/* Anything that takes an agent out of service confirms first, even though
|
||||
none of it is destructive — a reader should choose to stop an agent
|
||||
answering, not discover it. The rest run. */
|
||||
if (action === 'remove' || action === 'archive') {
|
||||
setConfirming({ action, agent });
|
||||
return;
|
||||
@@ -410,15 +436,15 @@ export default function AdminWorkspaceAgents() {
|
||||
<div className="inline-flex items-center rounded-xl border border-border/80 bg-surface-subtle/80 p-1 text-caption">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus('all')}
|
||||
onClick={() => setStatus('active')}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-1 font-medium transition-all',
|
||||
status === 'all'
|
||||
status === 'active'
|
||||
? 'bg-surface text-ink-1 shadow-2xs font-semibold'
|
||||
: 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
All <span className="text-ink-4 font-normal">({agents.length})</span>
|
||||
Active <span className="text-ink-4 font-normal">({activeCount})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -446,20 +472,26 @@ export default function AdminWorkspaceAgents() {
|
||||
Drafts <span className="text-ink-4 font-normal">({draftCount})</span>
|
||||
</button>
|
||||
)}
|
||||
{archivedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus('archived')}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-1 font-medium transition-all',
|
||||
status === 'archived'
|
||||
? 'bg-surface text-amber-700 shadow-2xs font-semibold'
|
||||
: 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
Archived <span className="text-ink-4 font-normal">({archivedCount})</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Always rendered, empty or not. Archived is where removing an
|
||||
agent puts it and where restoring it happens, and the remove
|
||||
confirmation says so in as many words — a tab that appears
|
||||
only once something is already in it tells a reader where
|
||||
their agent went strictly after they needed to know. The
|
||||
count answers "is anything in here?" without hiding the
|
||||
answer. Drafts stays conditional: an empty Drafts tab
|
||||
teaches nothing, because nothing is ever sent there. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus('archived')}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-1 font-medium transition-all',
|
||||
status === 'archived'
|
||||
? 'bg-surface text-amber-700 shadow-2xs font-semibold'
|
||||
: 'text-ink-3 hover:text-ink-1'
|
||||
)}
|
||||
>
|
||||
Archived <span className="text-ink-4 font-normal">({archivedCount})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -546,16 +578,23 @@ export default function AdminWorkspaceAgents() {
|
||||
title={
|
||||
confirming?.action === 'archive'
|
||||
? `Archive ${confirming?.agent.name}?`
|
||||
: isShipped(confirming?.agent?.id) ? 'Revert to the shipped definition?' : `Delete ${confirming?.agent.name}?`
|
||||
: isShipped(confirming?.agent?.id) ? 'Revert to the shipped definition?' : 'Remove agent?'
|
||||
}
|
||||
description={
|
||||
confirming?.action === 'archive'
|
||||
? 'It stops answering and disappears from the switcher. Its definition is kept, and it can be restored as a draft.'
|
||||
: isShipped(confirming?.agent?.id)
|
||||
? 'Your changes to this agent are discarded and the shipped definition takes over again.'
|
||||
: 'This definition is removed. It cannot be recovered.'
|
||||
: 'This agent will be removed from the active Agents list. Its configuration and '
|
||||
+ 'skills will be preserved and it can be restored later from Archived.'
|
||||
}
|
||||
confirmLabel={
|
||||
confirming?.action === 'archive'
|
||||
? 'Archive'
|
||||
/* Reverting a shipped agent is not a removal and must not read as
|
||||
one: the shipped definition takes over, nothing is destroyed. */
|
||||
: isShipped(confirming?.agent?.id) ? 'Confirm' : 'Remove Agent'
|
||||
}
|
||||
confirmLabel={confirming?.action === 'archive' ? 'Archive' : 'Confirm'}
|
||||
busy={saving}
|
||||
onConfirm={async () => {
|
||||
const pending = confirming;
|
||||
|
||||
@@ -366,6 +366,7 @@ function ActionQueue({ items }) {
|
||||
/* ── Page ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { registerNodeType } from '@/lib/ui/registry';
|
||||
import { registerSeries } from '@/lib/ui/series';
|
||||
import { registerPageComposition } from '@/lib/ui/composition';
|
||||
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
|
||||
|
||||
@@ -830,6 +831,57 @@ registerNodeType({
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* The figures behind this page's hiring chart, named so something else can draw
|
||||
* them.
|
||||
*
|
||||
* The page already computed this array — it is the same `activitySeries` the
|
||||
* composed chart above reads, taken from the same context, unaggregated and
|
||||
* unreordered. Declaring it here is what lets a person say "show this as a bar
|
||||
* chart" and get *these* numbers rather than a new empty panel, and it is the
|
||||
* only thing that had to be added to make a built-in chart changeable at all.
|
||||
*
|
||||
* `periodic` is the honest kind: these are days in order. It is also what
|
||||
* refuses the pie.
|
||||
*/
|
||||
registerSeries({
|
||||
id: 'control-center.hiring-activity',
|
||||
label: 'Hiring activity',
|
||||
kind: 'periodic',
|
||||
measures: [
|
||||
{ key: 'applications', label: 'Applications' },
|
||||
{ key: 'screened', label: 'AI Screened' },
|
||||
{ key: 'interviews', label: 'Interviews' },
|
||||
{ key: 'hires', label: 'Hires' },
|
||||
],
|
||||
read: (context) => context?.activitySeries || [],
|
||||
emptyNote: 'No applications yet.',
|
||||
/**
|
||||
* The range control, declared on the reading rather than on the component.
|
||||
*
|
||||
* It is the reason "show this as a bar chart" used to be a downgrade: the
|
||||
* 7D/30D/90D toggle lived inside this page's own chart section, so replacing
|
||||
* the section removed a control the person had been using — and the figures
|
||||
* it governs are the very figures the replacement draws.
|
||||
*
|
||||
* Declaring it here moves it to where it actually belongs. The control does
|
||||
* not change how the series is drawn; it changes which rows the series *is*,
|
||||
* which is a property of the reading. Every visualization draws it because
|
||||
* every visualization asks the binding for it, and none of them knows what a
|
||||
* range is. `options` is the same `RANGES` table the built-in section reads,
|
||||
* so the two cannot drift apart.
|
||||
*/
|
||||
control: {
|
||||
id: 'range',
|
||||
label: 'Hiring activity range',
|
||||
options: Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label })),
|
||||
/* The page's own state, both ways. Nothing is stored here, and a page that
|
||||
stops publishing `range` stops publishing the control with it. */
|
||||
read: (context) => context?.range ?? null,
|
||||
write: (context, value) => context?.setRange?.(value),
|
||||
},
|
||||
});
|
||||
|
||||
registerNodeType({
|
||||
type: 'cc-activity',
|
||||
/* This page's own section: it reads what this page publishes, so it belongs
|
||||
@@ -837,7 +889,17 @@ registerNodeType({
|
||||
page: 'control-center',
|
||||
label: 'Hiring activity',
|
||||
component: CCActivity,
|
||||
capabilities: SECTION,
|
||||
/**
|
||||
* The one section on this page that can become something else.
|
||||
*
|
||||
* It draws a series, it says which series, and it says what that series
|
||||
* means — which is the whole of what the engine needs to offer a bar, a line
|
||||
* or an area in its place and to refuse a pie. Nothing about charts is
|
||||
* written here: the candidates are computed by the registry from these three
|
||||
* facts.
|
||||
*/
|
||||
capabilities: [...SECTION, 'replace'],
|
||||
seriesKinds: ['periodic'],
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
@@ -899,7 +961,7 @@ registerNodeType({
|
||||
registerPageComposition('control-center', [
|
||||
{ id: 'cc-extensions-top', type: 'skill-surface', props: { page: 'control-center', placement: 'after-header' } },
|
||||
{ id: 'cc-snapshot', type: 'cc-snapshot' },
|
||||
{ id: 'cc-activity', type: 'cc-activity' },
|
||||
{ id: 'cc-activity', type: 'cc-activity', data: { series: 'control-center.hiring-activity' } },
|
||||
{ id: 'cc-pipeline', type: 'cc-pipeline' },
|
||||
{ id: 'cc-positions', type: 'cc-positions' },
|
||||
{ id: 'cc-actions', type: 'cc-actions' },
|
||||
|
||||
Reference in New Issue
Block a user