archive issue fix
Some checks failed
CI / check (push) Failing after 5m5s

This commit is contained in:
2026-09-10 19:29:39 +05:30
parent 6249e00a3a
commit f522b6508e
20 changed files with 2444 additions and 207 deletions

View File

@@ -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'],
},
{

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) };

View File

@@ -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.`);
}

View File

@@ -19,7 +19,8 @@
*/
import {
DENSITY_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_CAPABILITIES, NODE_ID_PATTERN, VARIANT_VALUES,
DENSITY_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_CAPABILITIES, NODE_ID_PATTERN, SERIES_KINDS,
VARIANT_VALUES,
} from './node';
/**
@@ -87,6 +88,7 @@ export class NodeTypeRegistry {
};
refuseUnknown('variant', definition.variants, VARIANT_VALUES);
refuseUnknown('density', definition.densities, DENSITY_VALUES);
refuseUnknown('series kind', definition.seriesKinds, SERIES_KINDS);
/* A type offered by a picker and unable to be removed is a dead end: a
person creates a node and has no way to take it back. Said here rather
@@ -167,6 +169,22 @@ export class NodeTypeRegistry {
variants: Object.freeze([...(definition.variants || [])]),
densities: Object.freeze([...(definition.densities || [])]),
/**
* The series *meanings* this type can honestly draw.
*
* Empty means two things at once, and both are the conservative reading:
* this type is not a visualization of a series, and it makes no claim
* about meaning. So a section registered before this field existed is
* unaffected — it is neither offered as a chart nor vetoed as one.
*
* A non-empty list is a promise. `pie-chart` says `parts` and nothing
* else, and that single word is what refuses to draw twelve days of
* hiring as twelve slices of a whole. The alternative — letting the
* shape vocabulary decide — cannot express it: `flow` is, in its own
* words, "a sequence of stages *or* periods".
*/
seriesKinds: Object.freeze([...(definition.seriesKinds || [])]),
/**
* The page this type belongs to, or `null` for one that belongs anywhere.
*
@@ -303,7 +321,19 @@ export class NodeTypeRegistry {
* coupling it to data sources would make a UI type impossible to register
* without one.
*/
replacements(type, { shapes = null, page = null } = {}) {
replacements(type, {
shapes = null, page = null, seriesKind = null, seriesBound = false,
/**
* Whether this node reads anything at all.
*
* Separate from `seriesKind`, because "no binding" and "a binding that
* never said what it means" are different situations with different right
* answers. An unbound node has nothing to be untrue about; a bound one
* whose meaning was never established is exactly the case the semantic
* veto exists for.
*/
bound = false,
} = {}) {
const current = this.get(type);
if (!current) return [];
@@ -325,12 +355,47 @@ export class NodeTypeRegistry {
* in every page's "Show as" list.
*/
.filter((entry) => this.offersChild(null, entry.type, { page }))
/**
* The semantic veto, applied to every binding.
*
* A type that has said which meanings it can draw may only draw those.
* This is the one filter that is about truth rather than about structure,
* and it is why "show hiring activity as a pie chart" is refused while
* "as a bar chart" is not — both are structurally possible and only one
* of them is honest. A kind of `null` is "not known", and vetoes nothing.
*/
.filter((entry) => {
/* A type that claims no meanings makes no claim to be wrong about. */
if (!entry.seriesKinds.length) return true;
/* Nothing bound yet: the binding decides this, and there is not one. */
if (!bound) return true;
/**
* Bound, and only offerable if the meaning is known and drawable.
*
* The `!seriesKind` case is the one that changed: an undeclared
* meaning used to fall through as "no objection", which is how a card
* reading `Recent hires` came to be offered a pie chart. Not knowing
* is not permission — see `refuseSeries`, which produces the sentence
* a person is given when they ask for one of these by name.
*/
return Boolean(seriesKind) && entry.seriesKinds.includes(seriesKind);
})
.filter((entry) => (
/* A type that reads no data can only be swapped for another that reads
none — a divider is not an alternative rendering of a chart. */
wanted.length
? entry.dataShapes.some((shape) => wanted.includes(shape))
: entry.dataShapes.length === 0
/**
* The structural half, in the currency of whichever binding this is.
*
* A node bound to a page's own series is compatible with the types that
* draw a series — which is what `seriesKinds` being non-empty means.
* A node bound to a skill reading is compared shape to shape, as it has
* always been. A type that reads no data can only be swapped for
* another that reads none: a divider is not an alternative rendering of
* a chart.
*/
seriesBound
? entry.seriesKinds.length > 0
: (wanted.length
? entry.dataShapes.some((shape) => wanted.includes(shape))
: entry.dataShapes.length === 0)
))
.map((entry) => entry.type);
}

300
src/lib/ui/series.js Normal file
View File

@@ -0,0 +1,300 @@
/**
* What a series is, what it means, and where a page publishes one.
*
* Two things live here, and they are the two halves of "can this reading be
* drawn that way".
*
* **The series registry.** A page derives figures from the records it has
* already loaded — Control Center buckets applications, screenings, interviews
* and hires by day — and those figures are not in the skill data vocabulary and
* should not be: nothing outside that page can compute them. So a page declares
* them here, by name, with a pure `read` that takes the bag the page publishes
* and returns rows. A node then *names* the series, exactly as a skill node
* names a source. **A binding never carries values**, so there is still nowhere
* for an invented figure to live.
*
* **The meaning.** `dataShapes` answers whether a component can draw a shape;
* it cannot answer whether doing so would be true. A pie of a time series is
* structurally fine and semantically a lie — it throws the timeline away and
* presents days as slices of a whole. `SERIES_KINDS` is the missing half, and
* `kindOfBinding` is where a binding is asked which one it is.
*
* The rule for not knowing is deliberate, and it is strict: **an unknown kind
* is a refusal.** A component that has declared which meanings it can draw may
* only draw a meaning that was actually declared — by a page's series, by a
* reading in the data-source vocabulary, or by the binding's own periods. This
* is the one place the system could otherwise produce something structurally
* valid and factually untrue, and silence is not evidence that it would be
* true. Declaring a meaning is one field; guessing one is not offered.
*
* Components that declare no meanings at all — a table, a list, a card — are
* untouched by any of this. They make no claim about what their rows mean, so
* there is nothing for them to be wrong about.
*/
import { SERIES_KINDS } from './node';
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
export { SERIES_KINDS };
/** Why a kind is what it is, in words a refusal can use. */
const KIND_REASON = {
periodic: 'it is ordered in time, and that order is the reading',
cumulative: 'each step is part of the one before it, so the steps overlap',
parts: 'it is a set of parts that make up one whole',
};
/** @type {Map<string, any>} */
const providers = new Map();
/** A series id looks like a node id: lower-case, dashes, and a dotted namespace. */
const SERIES_ID_PATTERN = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/;
/**
* Declare a series a page publishes.
*
* Called at module scope beside the page that computes it, the same way a page
* registers its composition — so a series and the page that can answer for it
* are deployed together or not at all.
*
* Re-registering replaces, because the dev server re-runs the module on every
* save and throwing there would make the file uneditable.
*/
/**
* A control a series publishes, checked at registration.
*
* The missing half of "replacing a visualization must not remove what was
* there". A page's built-in chart often ships with a control that governs the
* reading itself — Control Center's 7D/30D/90D toggle changes *which rows the
* series returns*, not how they are drawn — and replacing the component threw
* it away, because the control lived inside the component.
*
* Declaring it on the **series** is what fixes that generically: the control
* belongs to the reading, so every component that can draw the reading draws
* the control too, and nothing anywhere names a range, a page or a chart. A
* series that publishes none is unaffected, and no control is ever invented —
* `read` and `write` are the page's own, so a page that stops publishing the
* state stops publishing the control with it.
*/
function normalizeControl(id, control) {
const controlId = String(control?.id ?? '').trim();
if (!controlId) throw new Error(`registerSeries: \`${id}\` declares a control with no \`id\`.`);
const options = (control.options || []).map((option) => ({
value: String(option?.value ?? '').trim(),
label: String(option?.label ?? '').trim(),
}));
if (!options.length) {
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` needs at least one option.`);
}
const blank = options.find((option) => !option.value || !option.label);
if (blank) {
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` has an option with no value or label.`);
}
if (typeof control.read !== 'function' || typeof control.write !== 'function') {
throw new Error(`registerSeries: \`${id}\` control \`${controlId}\` needs a \`read\` and a \`write\`.`);
}
return Object.freeze({
id: controlId,
label: String(control.label ?? controlId).trim(),
options: Object.freeze(options.map(Object.freeze)),
read: control.read,
write: control.write,
});
}
export function registerSeries(definition) {
const id = String(definition?.id ?? '').trim();
if (!id) throw new Error('registerSeries: a series needs an `id`.');
if (!SERIES_ID_PATTERN.test(id)) {
throw new Error(`registerSeries: \`${id}\` must be lower-case letters, numbers, dashes and dots.`);
}
const kind = String(definition.kind ?? '').trim();
if (!SERIES_KINDS.includes(kind)) {
throw new Error(
`registerSeries: \`${id}\` declares unknown kind \`${kind || '(none)'}\`. `
+ `Known: ${SERIES_KINDS.join(', ')}.`
);
}
if (typeof definition.read !== 'function') {
throw new Error(`registerSeries: \`${id}\` needs a \`read(context)\`.`);
}
const measures = (definition.measures || []).map((measure) => ({
key: String(measure?.key ?? '').trim(),
label: String(measure?.label ?? '').trim(),
}));
if (!measures.length) {
throw new Error(`registerSeries: \`${id}\` needs at least one measure.`);
}
const blank = measures.find((measure) => !measure.key || !measure.label);
if (blank) throw new Error(`registerSeries: \`${id}\` has a measure with no key or label.`);
const entry = Object.freeze({
id,
label: String(definition.label ?? id).trim(),
kind,
measures: Object.freeze(measures.map(Object.freeze)),
/* The key on each row that names the point — the x axis, or the slice. */
labelKey: String(definition.labelKey ?? 'label').trim() || 'label',
read: definition.read,
emptyNote: String(definition.emptyNote ?? 'Nothing to chart yet.').trim(),
/* Optional, and absent for every series that does not publish one. */
control: definition.control ? normalizeControl(id, definition.control) : null,
});
providers.set(id, entry);
return entry;
}
/** The registration, or null. */
export const seriesFor = (id) => providers.get(String(id ?? '').trim()) || null;
/** Every registered series. Used by tests and by anything that has to list them. */
export const listSeries = () => [...providers.values()];
/** Forget everything. Tests only. */
export const resetSeries = () => providers.clear();
/**
* What this binding means, or null for a binding that cannot say.
*
* Three answers, in order of how much they actually know:
*
* 1. A registered series declared its kind. That is a fact its author wrote.
* 2. A reading bound to time periods is periodic. Also a fact — `periods` is
* in the closed option vocabulary and the resolver buckets by it.
* 3. Otherwise unknown, and unknown vetoes nothing.
*/
export function kindOfBinding(binding) {
if (!binding) return null;
if (binding.series) return seriesFor(binding.series)?.kind || null;
if (binding.source) {
if (binding.params?.periods?.length) return 'periodic';
return dataSourceFor(binding.source)?.series || null;
}
return null;
}
/** What to call this binding in a sentence. */
export function labelOfBinding(binding) {
if (!binding) return 'this';
if (binding.series) return seriesFor(binding.series)?.label || binding.series;
if (binding.source) return dataSourceLabel(binding.source) || binding.source;
return 'this';
}
/**
* Why a type may not draw this binding — one sentence, or null if it may.
*
* Written here rather than at the call sites so the editor and the conversation
* refuse in the same words, and so the reason is about the *data* rather than
* about the component. "A pie chart cannot show Hiring activity" tells nobody
* anything; saying that the reading is ordered in time does.
*/
export function refuseSeries(entry, binding) {
if (!entry.seriesKinds.length) {
return `\`${entry.label}\` cannot draw ${labelOfBinding(binding)}.`;
}
const kind = kindOfBinding(binding);
/**
* Not knowing is a refusal, not a permission.
*
* This used to be the other way round — an unknown kind vetoed nothing — and
* the consequence was visible in the product: a card reading `Recent hires`
* was offered a pie chart, because nothing had ever said what those figures
* mean and silence was read as consent. A component that has declared which
* meanings it can honestly draw cannot draw one that was never declared, so
* the honest answer is no, with the reason.
*
* Saying yes here is the only way this system can produce a chart that is
* structurally valid and factually a lie, which is why the default is the
* strict one. Establishing compatibility is one field on the reading — see
* `series` in the data-source vocabulary — and is deliberately the author's
* statement rather than a guess made here from a label or a shape.
*/
if (!kind) {
return (
`\`${entry.label}\` cannot show ${labelOfBinding(binding)}: `
+ 'it does not say what its figures mean, so there is no way to know '
+ 'that drawing them this way would be true.'
);
}
if (entry.seriesKinds.includes(kind)) return null;
return (
`\`${entry.label}\` cannot show ${labelOfBinding(binding)}: `
+ `${KIND_REASON[kind]}.`
);
}
/**
* The control this binding publishes, resolved against the page, or null.
*
* Two facts have to line up before a control exists: the series declared one,
* and the page is actually publishing the state it names. Either missing means
* no control — nothing is drawn from a default, and nothing is fabricated to
* fill the gap a replaced component left behind.
*/
export function controlOfBinding(binding, context = {}) {
const entry = binding?.series ? seriesFor(binding.series) : null;
const control = entry?.control;
if (!control) return null;
const value = control.read(context || {});
if (value == null) return null;
return {
id: control.id,
label: control.label,
options: control.options,
value,
set: (next) => control.write(context || {}, next),
};
}
/**
* The rows a binding resolves to, in the one shape every visualization reads.
*
* `{ rows, measures, emptyNote }` — rows carry a label and one number per
* measure, so a single-measure skill reading and a four-measure page series are
* the same object by the time a chart sees them. Nothing is aggregated,
* re-bucketed or re-ordered on the way: a chart draws what the page or the
* resolver already computed, and a transformation that changed the meaning
* would have to be written somewhere, and there is nowhere.
*
* `resolve` is passed in rather than imported so this stays free of React and
* of the skill resolver — the component knows how to get a skill reading, and
* this knows what to do with either kind.
*/
export function readSeries(binding, { context = {}, resolveSource = null } = {}) {
const empty = { rows: [], measures: [], emptyNote: 'Nothing to chart yet.', label: '' };
if (!binding) return empty;
if (binding.series) {
const entry = seriesFor(binding.series);
if (!entry) return empty;
const raw = entry.read(context || {}) || [];
const rows = (Array.isArray(raw) ? raw : []).map((row, i) => {
const point = { id: String(row?.id ?? i), label: String(row?.[entry.labelKey] ?? '') };
for (const measure of entry.measures) point[measure.key] = Number(row?.[measure.key]) || 0;
return point;
}).filter((row) => row.label);
return { rows, measures: entry.measures, emptyNote: entry.emptyNote, label: entry.label };
}
if (!binding.source || typeof resolveSource !== 'function') return empty;
const data = resolveSource(binding) || {};
const rows = (data.steps || data.items || []).map((step, i) => ({
id: String(step?.id ?? i),
label: String(step?.label || step?.title || ''),
value: Number(step?.value) || 0,
})).filter((row) => row.label);
return {
rows,
measures: [{ key: 'value', label: dataSourceLabel(binding.source) || 'Value' }],
emptyNote: data.emptyNote || 'Nothing to chart yet.',
label: dataSourceLabel(binding.source) || binding.source,
};
}

View File

@@ -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) {