diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index 88fb0da..b22c70e 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -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 !/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 ]*>\(\{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: '); +/* `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 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; } } diff --git a/src/App.jsx b/src/App.jsx index cb2002f..98cba13 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 = () => { {/* 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. */} +
} /> +
} /> +
} /> } /> } /> {/* `/positions` only matches the exact path, so the sub-routes need diff --git a/src/components/ai-assistant/uiEdit.js b/src/components/ai-assistant/uiEdit.js index 465ac81..c172c62 100644 --- a/src/components/ai-assistant/uiEdit.js +++ b/src/components/ai-assistant/uiEdit.js @@ -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; diff --git a/src/components/ai-assistant/useAssistant.js b/src/components/ai-assistant/useAssistant.js index dc894d8..3c80c59 100644 --- a/src/components/ai-assistant/useAssistant.js +++ b/src/components/ai-assistant/useAssistant.js @@ -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, }), diff --git a/src/components/krow/talent/Opportunities.jsx b/src/components/krow/talent/Opportunities.jsx new file mode 100644 index 0000000..4a41a23 --- /dev/null +++ b/src/components/krow/talent/Opportunities.jsx @@ -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 ( + + ); +} + +export default function Opportunities({ jobRecs = [] }) { + return ( +
+
+
+

+ Opportunities for you +

+

Matched to your profile. Open one to see why you fit.

+
+ {jobRecs.length > 0 && ( + + {jobRecs.length} match{jobRecs.length === 1 ? '' : 'es'} + + )} +
+ + {jobRecs.length === 0 ? ( +
+
+ +
+

No matched opportunities yet — keep leveling up.

+
+ ) : ( +
+ {jobRecs.map((rec) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/ui-editor/NodeInspector.jsx b/src/components/ui-editor/NodeInspector.jsx index e837385..700e42e 100644 --- a/src/components/ui-editor/NodeInspector.jsx +++ b/src/components/ui-editor/NodeInspector.jsx @@ -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 (
diff --git a/src/components/ui-tree/UiEditingProvider.jsx b/src/components/ui-tree/UiEditingProvider.jsx index 385d19d..e8cd293 100644 --- a/src/components/ui-tree/UiEditingProvider.jsx +++ b/src/components/ui-tree/UiEditingProvider.jsx @@ -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 {children}; diff --git a/src/components/ui-tree/chartNodeTypes.jsx b/src/components/ui-tree/chartNodeTypes.jsx index 34b50a1..5991099 100644 --- a/src/components/ui-tree/chartNodeTypes.jsx +++ b/src/components/ui-tree/chartNodeTypes.jsx @@ -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 (
- {title && ( -
-

{title}

+ {(title || control) && ( +
+ {title + ?

{title}

+ : } + {control && ( + + )}
)} {empty ?

{empty}

- :
{children}
} + :
{children}
}
); } -/** 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 ( + <> + + + + + {/* One series needs no key: the title already names it. */} + {measures.length > 1 && ( + + )} + + ); +} + +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 }) => ( + + + {measures.map((measure, i) => ( + + ))} + + ), + }, + { + 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 }) => ( + + + {measures.map((measure, i) => ( + + ))} + + ), + }, + { + type: 'area-chart', + label: 'Area chart', + summary: 'The points joined in order, filled to the axis.', + seriesKinds: ['periodic', 'cumulative'], + draw: ({ rows, measures }) => ( + + + {measures.map((measure, i) => ( + + ))} + + ), + }, + { + 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 }) => , + /* 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 }) => , + 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 ( + + + {rows.map((row, i) => )} + + + + ); +} + +/** 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 ( - + - - - - - - + {spec.draw(series)} ); } -/** 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 ( - - - - {/* 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. */} - - {rows.map((row, i) => ( - - ))} - - - - - - ); -} - -/* 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) => ; + 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 }; diff --git a/src/lib/skills/surfaces.js b/src/lib/skills/surfaces.js index 340d48a..e1d86fa 100644 --- a/src/lib/skills/surfaces.js +++ b/src/lib/skills/surfaces.js @@ -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'], }, { diff --git a/src/lib/ui/inspect.js b/src/lib/ui/inspect.js index 7a07c6b..34d8e65 100644 --- a/src/lib/ui/inspect.js +++ b/src/lib/ui/inspect.js @@ -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; diff --git a/src/lib/ui/intent.js b/src/lib/ui/intent.js index 21cfe1b..956c423 100644 --- a/src/lib/ui/intent.js +++ b/src/lib/ui/intent.js @@ -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; diff --git a/src/lib/ui/node.js b/src/lib/ui/node.js index 90068fc..69313fa 100644 --- a/src/lib/ui/node.js +++ b/src/lib/ui/node.js @@ -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) }; diff --git a/src/lib/ui/operations.js b/src/lib/ui/operations.js index 48e2613..805c0e3 100644 --- a/src/lib/ui/operations.js +++ b/src/lib/ui/operations.js @@ -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.`); } diff --git a/src/lib/ui/registry.js b/src/lib/ui/registry.js index f493e4f..71b5022 100644 --- a/src/lib/ui/registry.js +++ b/src/lib/ui/registry.js @@ -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); } diff --git a/src/lib/ui/series.js b/src/lib/ui/series.js new file mode 100644 index 0000000..2305209 --- /dev/null +++ b/src/lib/ui/series.js @@ -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} */ +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, + }; +} diff --git a/src/lib/ui/validate.js b/src/lib/ui/validate.js index 01fe6c8..16d5f99 100644 --- a/src/lib/ui/validate.js +++ b/src/lib/ui/validate.js @@ -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) { diff --git a/src/pages/EmployeeDashboard.jsx b/src/pages/EmployeeDashboard.jsx index c300582..471b540 100644 --- a/src/pages/EmployeeDashboard.jsx +++ b/src/pages/EmployeeDashboard.jsx @@ -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 (
- + diff --git a/src/pages/OpportunityDetail.jsx b/src/pages/OpportunityDetail.jsx new file mode 100644 index 0000000..c6f911f --- /dev/null +++ b/src/pages/OpportunityDetail.jsx @@ -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= 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 ( +
+

{title}

+ {children} +
+ ); +} + +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
; + } + + const job = postings.find((p) => p.id === id); + + if (!job) { + return ( +
+
+ +
+

This opportunity is no longer available.

+ +
+ ); + } + + 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 ( +
+ + + {/* Header — title-first identity, no company name. */} +
+
+
+ +
+
+
+

{job.title}

+ {isHiring && ( + Hiring + )} +
+ {job.role_category &&

{job.role_category}

} + +
+ + {match}% match + + + {job.location || 'Remote'} + + {pay && ( + + {pay} + + )} +
+
+
+
+ + {/* About this opportunity */} +
+ {job.description ? ( +

{job.description}

+ ) : ( +

No description provided for this opportunity yet.

+ )} + {job.responsibilities?.length > 0 && ( +
+

What you'll do

+
    + {job.responsibilities.map((r, i) =>
  • {r}
  • )} +
+
+ )} + {job.qualifications?.length > 0 && ( +
+

What they're looking for

+
    + {job.qualifications.map((q, i) =>
  • {q}
  • )} +
+
+ )} +
+ + {/* Why you're a match */} +
+
+
+
+
+ {match}% +
+ +
    +
  • + + + {exp} yr{exp === 1 ? '' : 's'} of experience + {reqExp > 0 && <> · role asks for {reqExp}+ {meetsExp ? '— you qualify' : ''}} + +
  • + {skills.length > 0 && ( +
  • + + Your skills match: {skills.join(', ')} +
  • + )} + {certs.length > 0 && ( +
  • + + Certifications you hold: {certs.join(', ')} +
  • + )} + {skills.length === 0 && certs.length === 0 && ( +
  • + + You showed up here because your profile aligns with this role. Add skills to strengthen the match. +
  • + )} +
+
+ + {/* Apply */} +
+ +
+
+ ); +} diff --git a/src/pages/admin/WorkspaceAgents.jsx b/src/pages/admin/WorkspaceAgents.jsx index b1a574d..d92e28e 100644 --- a/src/pages/admin/WorkspaceAgents.jsx +++ b/src/pages/admin/WorkspaceAgents.jsx @@ -126,7 +126,7 @@ function AgentCard({ agent, shipped, overridden, onOpen, onAction }) { onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive"> {shipped ? <> Revert to shipped - : <> Delete} + : <> Remove} )} @@ -310,7 +310,7 @@ function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) { onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive"> {shipped ? <> Revert to shipped - : <> Delete} + : <> Remove} )} @@ -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() {
)} - {archivedCount > 0 && ( - - )} + {/* 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. */} +
@@ -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; diff --git a/src/pages/admin/control-center/nodes.jsx b/src/pages/admin/control-center/nodes.jsx index cfe5e55..62047b1 100644 --- a/src/pages/admin/control-center/nodes.jsx +++ b/src/pages/admin/control-center/nodes.jsx @@ -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' },