candidates and board ui agent issue
Some checks failed
CI / check (push) Failing after 4m58s

This commit is contained in:
2026-09-05 10:46:06 +05:30
parent e02a0c23d4
commit 6249e00a3a
78 changed files with 15071 additions and 1998 deletions

View File

@@ -282,6 +282,40 @@ export function resolveAgentForTurn(agents = [], activeId, contextId) {
};
}
/* ── Skill ownership ────────────────────────────────────────────────────── */
/**
* Whether this agent context permits a skill's UI.
*
* Ownership is **opt-in**, and the asymmetry is the whole rule:
*
* - a skill claimed by at least one agent belongs to those agents, and its
* sections are drawn only where one of them is answering;
* - a skill claimed by nobody is unowned, and unowned means unchanged — the
* pages it declares and the account switch decide it, exactly as before.
*
* Enforcing ownership on every skill instead would have removed every skill
* section in the product on the day it shipped: the definitions that draw page
* UI are authored on an account and claimed by no agent, while every skill this
* build ships is conversation-only. Attaching a skill to an agent is therefore
* the act that brings it under that agent's control — a decision an author
* makes in Agent Configure, not one taken on their behalf.
*
* With no agents loaded — outside a provider, or before the registry has
* answered — nothing is owned and nothing is constrained, which is the safe
* reading rather than a permissive one: it can only ever show what the page and
* the account already allow.
*
* This answers one question only. What pages a skill declares and what the
* account has switched off are separate rules, checked separately.
*/
export function agentPermitsSkill(skillId, { agents = [], agent = null } = {}) {
if (!skillId) return false;
const owned = (agents || []).some((a) => (a?.skills || []).includes(skillId));
if (!owned) return true;
return (agent?.skills || []).includes(skillId);
}
/* ── Starters ───────────────────────────────────────────────────────────── */
/**

View File

@@ -43,6 +43,36 @@ export function desiredPayLabel(record = {}) {
return min ? `From $${min}/hr` : `Up to $${max}/hr`;
}
/**
* The body for creating a NEW employee together with their first declared role.
*
* Two nested records, because they are two rows and the endpoint writes them in
* one transaction: the worker on the outside, the role under `role`. Nesting
* rather than flattening is what stops a key meant for one landing on the
* other — `experience_years` means something different on a profile than on a
* declared role, and a flat body would have to guess.
*
* The email is passed through as the caller typed it. Nothing here derives one,
* defaults one, or falls back to another record's; an absent email reaches the
* server absent, and the server refuses it.
*/
export function toNewWorkerWithRolePayload(draft = {}) {
const role = toEmployeeRolePayload(draft);
/* The identity fields belong to the worker. The server copies them onto the
role from the row it just created, so sending them twice would let the two
disagree. */
const { worker_email: email, worker_name: name, worker_profile_id: _ignored, ...roleOnly } = role;
return {
full_name: name,
email,
availability: role.availability,
certifications: role.certifications,
experience_years: role.experience_years,
role: roleOnly,
};
}
/**
* The record the API is asked to create.
*

View File

@@ -1,5 +1,8 @@
import React from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { mergeLayouts, normalizeLayouts } from '@/lib/ui/patch';
import { API_BASE_URL, base44 } from '@/api/base44Client';
import { request } from '@/api/httpClient';
import { generateJobDescription, screenCandidate, matchTalentForJob } from './krowAi';
import { recalcProfilePatch } from './krowScore';
import { logActivity } from './userTracking';
@@ -62,6 +65,52 @@ export function useUpdatePreferences() {
});
}
/**
* A person's own UI layout changes, per page.
*
* Stored beside `customSkills` in the account's preferences, which is a store
* that already exists, is already per-user, and already reaches Postgres
* through `PATCH /api/v1/me/preferences`. No new endpoint, no new table, and
* nothing to deploy — which is the whole point: a layout change is a runtime
* change, and a runtime change that needed a release would not be one.
*
* What is written is the **operation list** the UI engine validated, never a
* copy of the rendered tree and never Markdown. A stored tree would be a
* photograph of the page on the day it was saved; an operation still means what
* it said after a release moves the built-ins around it.
*
* The whole `uiLayouts` object is sent on every write, because the endpoint
* shallow-merges its top-level keys: sending one page would replace the map and
* silently drop every other page's layout.
*/
export function useUiLayouts() {
const preferences = usePreferences();
const update = useUpdatePreferences();
const layouts = React.useMemo(
() => normalizeLayouts(preferences.uiLayouts).layouts,
[preferences.uiLayouts]
);
/**
* Store one page's patch.
*
* A patch with no operations is removed rather than stored empty — that is
* the same state as never having customised the page, and keeping the key
* would grow the blob with a record of every page somebody once opened.
*/
const save = React.useCallback(async (page, patch) => {
const key = String(page || '').trim();
if (!key) return null;
/* Only `uiLayouts` is sent. Every other preference — `customSkills` above
all — is left for the endpoint's shallow merge to preserve, so a layout
change can never disturb an authored skill. */
return update.mutateAsync({ uiLayouts: mergeLayouts(layouts, key, patch) });
}, [layouts, update]);
return { layouts, save, saving: update.isPending };
}
export function useJobPostings() {
return useQuery({
queryKey: ['jobPostings'],
@@ -326,6 +375,29 @@ export function useCreateJobPosting() {
* organization now has one more worker offering that role and what is worth
* asking has changed with it.
*/
/**
* Record a NEW employee and their first declared role, in one transaction.
*
* One request, not two. Creating the worker and then the role as separate calls
* leaves a worker nobody meant to create when the second fails — indistinguishable
* from a real one and with nothing to say why it is there. The endpoint writes
* both inside a transaction and rolls the worker back if the role cannot be
* written, so a refused create leaves the database exactly as it was.
*/
export function useCreateWorkerWithRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => request('POST', '/worker-profiles/with-role', { body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employeeRoles'] });
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
logActivity('create_employee_role');
},
});
}
/** Record another role for somebody already on file. A different request. */
export function useCreateEmployeeRole() {
const queryClient = useQueryClient();
return useMutation({

View File

@@ -11,7 +11,65 @@
* `useCreateJobPosting`, in both paths.
*/
/** Certifications a position can require, in the order they are offered. */
/**
* Which certifications matter for a role, from what this organization has
* actually asked for.
*
* THE SOURCE OF TRUTH, and it is worth being explicit about why it is this one.
* The schema has no role-to-certification relationship at all: `role_categories`
* and `certifications` are both bare `(id, org_id, name)` lists with nothing
* joining them. So relevance cannot be looked up — but it can be OBSERVED, and
* the observation is real organizational behaviour rather than a guess:
* `job_postings` carries `role_category` and `certifications_required` on the
* same row, so every posting this organization has written is a statement that
* these certifications matter for that role.
*
* That makes the answer tenant-specific for free. A staffing company that puts
* `Guard Card` on its Security postings gets Guard Card; one that does not,
* does not. Nothing here carries a list of roles or a list of certifications,
* and adding either to the product changes this function's output without
* changing this function.
*
* `postings` is the caller's own already-loaded set, so this widens nobody's
* view: the API scoped it before it reached the browser.
*
* Returns `null` when the postings have not loaded, and `[]` when they have and
* the role genuinely has none. Those are different answers — "we do not know
* yet" must not render as "there are none" — and the caller is expected to tell
* them apart rather than treating both as empty.
*/
export function certificationsForRole(role, postings) {
if (!Array.isArray(postings)) return null;
const want = String(role || '').trim().toLowerCase();
if (!want) return [];
const found = new Set();
for (const posting of postings) {
if (String(posting?.role_category || '').trim().toLowerCase() !== want) continue;
for (const cert of posting.certifications_required || []) {
const name = String(cert || '').trim();
if (name) found.add(name);
}
}
return [...found];
}
/**
* Certifications the CREATE POSITION FORM offers, in the order they are offered.
*
* A fixed list, and it should not be one — the organization keeps its own in
* the `certifications` table, which `useCertifications()` already reads and
* which nothing in this product currently consults. Against the live tenant
* this list is wrong twice over: it offers `ABC License`, which that
* organization does not use, and spells `CPR/First Aid` where the record says
* `CPR / First Aid`, so the two can never match.
*
* Left in place deliberately rather than quietly rewired: the form is a
* multi-select over a fixed vocabulary and changing its source is a change to
* how positions are authored, which is a product decision and not a bug fix.
* The conversational flows no longer read it — see `certificationsForRole`.
*/
export const CERT_OPTIONS = [
'Food Handler Card',
'ServSafe',

View File

@@ -1,4 +1,4 @@
import { toEmployeeRolePayload } from '@/lib/employeeRoleModel';
import { toNewWorkerWithRolePayload } from '@/lib/employeeRoleModel';
import { CERT_OPTIONS, ENGLISH_LEVELS, toPositionPayload } from '@/lib/positionModel';
import { routeForPageKey } from './registry';
@@ -488,10 +488,21 @@ const HANDLERS = {
* situation rather than how finished the record is, so the conversation's one
* verb commits it as `seeking` — there is no draft of a person's own role.
*/
create_employee_role: ({ draft, status }) => ({
type: 'create_employee_role',
data: { ...toEmployeeRolePayload(draft || {}), status: status || 'seeking' },
}),
/**
* Record a NEW employee and their first declared role.
*
* The payload is two nested records because the endpoint writes two rows in
* one transaction. Refused outright without a name and an email: those are
* the person, and neither is ever derived from the other or from the caller.
*/
create_employee_role: ({ draft, status }) => {
const record = { ...(draft || {}) };
if (!String(record.worker_name || '').trim() || !String(record.worker_email || '').trim()) return null;
return {
type: 'create_employee_role',
data: toNewWorkerWithRolePayload({ ...record, status: status || 'seeking' }),
};
},
/**
* Open the Add Skill Training flow, on the Forge page, with what the request

View File

@@ -129,10 +129,16 @@ export const missingRequired = (registry, flow, steps) => steps
* appearing as the literal "@workers", which is what it did before registries
* existed and each resolver knew every token.
*/
function suggestionsFor(registry, step, ctx) {
function suggestionsFor(registry, step, ctx, draft = {}) {
const resolved = step.options.flatMap((option) => {
if (!option.startsWith('@')) return [option];
return registry.resolve?.(option, ctx) || [];
/* The DRAFT is passed as well as the context, and it is what makes an
option list able to depend on an earlier answer. Certifications are the
case that forced it: which ones matter is a fact about the role chosen
two questions ago, and a resolver that only saw the page's data could
never know it. Recomputed on every ask, so changing the role from the
change menu recomputes rather than reusing what the last role produced. */
return registry.resolve?.(option, ctx, draft) || [];
});
const capped = [...new Set(resolved.filter(Boolean))].slice(0, 6);
@@ -158,7 +164,7 @@ function ask(registry, flow, steps, ctx, { preamble = null, retry = null } = {})
text(step.question),
retry ? note(retry) : null
),
followUp: suggestionsFor(registry, step, ctx),
followUp: suggestionsFor(registry, step, ctx, flow.draft),
};
}

View File

@@ -1,7 +1,7 @@
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
import { CERT_OPTIONS, ENGLISH_LEVELS } from '@/lib/positionModel';
import { ENGLISH_LEVELS, certificationsForRole } from '@/lib/positionModel';
import {
AVAILABILITY_OPTIONS, defaultEmployeeRole, desiredPayLabel,
AVAILABILITY_OPTIONS, desiredPayLabel,
} from '@/lib/employeeRoleModel';
import {
extractCertifications, extractEnglish, extractExperience, extractPay, extractRole,
@@ -31,37 +31,54 @@ const fields = {
* and what survives a worker profile being removed. A name with no email
* behind it is not an answer, so `parse` refuses one it cannot resolve.
*/
worker: {
label: 'Worker',
settled: (draft) => Boolean(String(draft.worker_email || '').trim()),
retry: 'Name the worker, or type their email address.',
parse: (answer, { workers = [] }) => {
const said = String(answer).trim();
if (!said) return null;
/* An email typed outright is the worker, whether or not a profile
exists — a role can be recorded before the profile is created. */
const email = /\b[^\s@]+@[^\s@]+\.[^\s@]+\b/.exec(said)?.[0];
const lower = said.toLowerCase();
const match = workers.find((w) => (email
? String(w.email || '').toLowerCase() === email.toLowerCase()
: String(w.name || '').toLowerCase() === lower))
/* A chip carries the full name; a typed answer may be part of one. */
|| (!email && workers.find((w) => String(w.name || '').toLowerCase().includes(lower)));
if (match) {
return {
worker_profile_id: match.id || null,
worker_email: match.email,
worker_name: match.name || '',
};
}
return email ? { worker_profile_id: null, worker_email: email, worker_name: '' } : null;
/**
* The new employee's name.
*
* A name and nothing more. It is NOT looked up, because a name cannot select
* anybody: an organization may employ any number of people who share one, and
* the previous version of this field searched the existing workers for a name
* match and attached the role to the first hit. With several people of the
* same name that silently filed the role against the wrong person; with a
* name nobody had, it understood nothing and asked the same question again,
* which is the loop this flow was stuck in.
*/
worker_name: {
label: 'Name',
settled: (draft) => Boolean(String(draft.worker_name || '').trim()),
retry: 'Type the new employee\u2019s full name.',
parse: (answer) => {
const name = String(answer).trim().replace(/\s+/g, ' ');
return name.length >= 2 ? { worker_name: name } : null;
},
summary: (draft) => (draft.worker_name
? `Worker: ${draft.worker_name} (${draft.worker_email})`
: `Worker: ${draft.worker_email}`),
summary: (draft) => `Name: ${draft.worker_name}`,
},
/**
* The new employee's email, which is their identity.
*
* Asked outright and never derived. There is no rule anywhere that turns a
* name into an address, no fallback to the operator's own account, and no
* reuse of anything an earlier conversation collected — an invented address
* is a real person's record filed under something they do not own.
*
* `worker_profiles` carries UNIQUE (org_id, email) over a `citext` column, so
* this value is what decides whether the person already exists. The check is
* the database's, not this field's: two operators recording the same person
* at the same moment cannot both win, whatever either browser believed.
*/
worker_email: {
label: 'Email',
settled: (draft) => Boolean(String(draft.worker_email || '').trim()),
retry: 'Type the employee\u2019s email address — it is how the record is identified.',
parse: (answer) => {
const said = String(answer).trim();
/* The whole answer must be the address. Pulling one out of a sentence
would accept "I don't know, maybe bob@x.com" as a considered answer. */
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(said)
? { worker_email: said }
: null;
},
summary: (draft) => `Email: ${draft.worker_email}`,
},
role_category: {
@@ -174,12 +191,25 @@ const fields = {
* `@workers` is the worker profiles the panel has already loaded for this
* caller — org-scoped by the API, and nothing here widens that view.
*/
const resolve = (token, { roles = [], workers = [] }) => {
const resolve = (token, { roles = [], postings = null }, draft = {}) => {
switch (token) {
case '@workers': return workers.map((w) => w.name).filter(Boolean);
case '@roles': return roles;
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
case '@certifications': return CERT_OPTIONS;
/**
* Only the certifications that matter for the role just chosen.
*
* Never the global list. A worker declaring themselves a Chef is asked
* about the certifications this organization puts on its Chef postings, and
* about nothing else — offering `Guard Card` there is the assistant
* inventing a requirement, and a chip is a suggestion the reader is
* entitled to read as informed.
*
* `null` back from the helper means the postings have not arrived; `[]`
* means this role genuinely has none. Both produce no chips here, and
* neither falls back to a global list — the step is optional, so `Skip` is
* offered by the engine either way and the reader can still type one.
*/
case '@certifications': return certificationsForRole(draft.role_category, postings) || [];
case '@availability': return AVAILABILITY_OPTIONS;
default: return [];
}
@@ -296,14 +326,28 @@ export const employeeRoleRegistry = {
extract,
/**
* No prefill from the opening request.
* No prefill from the opening request, and an EMPTY draft rather than a
* defaulted one.
*
* "Create an employee role" names nobody, and the posting flow's habit of
* reading a role out of the request would settle `role_category` from the
* word "role" in the phrase that started the conversation. The first question
* is who this is about, and it is asked.
*
* Returning `defaultEmployeeRole()` here — the record's write-time defaults —
* was worse than it looks. Every `settled` test asks whether a field HAS a
* value, and the defaults give all of them one: `certifications: []` is an
* array, `notes: ''` is a string, `experience_years: 0` is a number. So five
* of the eight questions were answered before they were asked, and the
* conversation went worker → role → pay and stopped. The certification step
* could not be reached at all.
*
* The two are different things wearing the same shape: write-time defaults
* are what a MISSING answer becomes, and a conversation must be able to tell
* missing from answered. `toEmployeeRolePayload` still applies them at the
* write, so nothing is lost by starting empty.
*/
prefill: () => defaultEmployeeRole(),
prefill: () => ({}),
/**
* One verb, because there is one outcome. A declared role has no draft state:

View File

@@ -1,5 +1,5 @@
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
import { CERT_OPTIONS, ENGLISH_LEVELS, payLabel } from '@/lib/positionModel';
import { ENGLISH_LEVELS, certificationsForRole, payLabel } from '@/lib/positionModel';
import {
buildPositionPrefill, extractCertifications, extractEnglish, extractExperience,
extractLocation, extractPay, extractRole,
@@ -143,12 +143,16 @@ const fields = {
* provision a TENANT the operator cannot then see, because every read is
* predicated on the session's own org_id.
*/
const resolve = (token, { roles = [], companies = [] }) => {
const resolve = (token, { roles = [], companies = [], postings = null }, draft = {}) => {
switch (token) {
case '@roles': return roles;
case '@companies': return companies;
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
case '@certifications': return CERT_OPTIONS;
/* The same rule as the role flow, from the same helper: what this
organization already asks for on postings of this category. A position
being written for a role it has never posted before offers none, which is
honest — there is nothing to go on yet. */
case '@certifications': return certificationsForRole(draft.role_category || draft.title, postings) || [];
default: return [];
}
};

View File

@@ -366,11 +366,36 @@ function blockEnd(lines, start, indent) {
return end;
}
/**
* How wide a line's indentation is.
*
* The same measurement the parser makes — `yaml.js` reads a leading run of
* whitespace and counts a tab as two — and it has to be, because a key the
* parser can see and the
* writer cannot is a key the writer will decide is missing and add a second
* copy of.
*
* That is not hypothetical: a definition stored on this account indents with
* U+00A0. JavaScript's `\s` matches it, so the parser read the file correctly
* and every screen showed the right values; the writer compared against literal
* spaces, found no `title:` inside `ui:`, and appended a whole second `ui:`
* block on the first edit. The Go port agrees with the parser here too — see
* `jsIsSpace` in `internal/definition/jsvalue.go`, which lists `0x00A0` — so the
* writer was the only thing in the chain using a narrower idea of a space.
*/
const indentWidth = (line) => (line.match(/^\s*/)?.[0] || '').replace(/\t/g, ' ').length;
/** The index of `key` at `indent` within `[from, to)`, or -1. */
function findKey(lines, key, indent, from, to) {
const pattern = new RegExp(`^${' '.repeat(indent)}${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
const pattern = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
for (let i = from; i < to; i += 1) {
if (pattern.test(lines[i])) return i;
const line = lines[i];
if (line === undefined || indentWidth(line) !== indent) continue;
/* Measured, then matched on what is left — so the comparison is about how
deep the key sits, never about which characters were used to put it
there. Identical for ASCII input, which is every definition this
repository ships. */
if (pattern.test(line.replace(/^\s*/, ''))) return i;
}
return -1;
}

View File

@@ -187,3 +187,70 @@ export function toolsForContext(contextId, disabled = [], customSkills = []) {
*/
export const toolAllowed = (name, allowed = []) =>
allowed.some((tool) => tool.name === name);
/* ── Typed action intent ────────────────────────────────────────────────── */
/**
* How much has to be typed before a partial word counts as an intent.
*
* Three, and the number is the whole point of the rule. One or two characters
* cannot say what somebody meant — "wh" is the start of four unrelated
* questions — and offering anything on them is how the composer ended up
* interrupting every reader who had already decided what to ask. Three is the
* shortest prefix of a real verb, and it still matches nothing unless it is
* genuinely the beginning of an action this page can perform.
*/
const ACTION_INTENT_MIN = 3;
/**
* A phrase reduced to what it means, so matching is about intent not typing.
*
* Case, surrounding space and an article are not differences: "Create",
* "create" and "create a" are all the beginning of the same request. Kept local
* and deliberately tiny — it exists to compare two short phrases, and anything
* cleverer would start deciding what a reader meant.
*/
const canon = (value) => String(value || '')
.toLowerCase()
.trim()
.split(/\s+/)
.filter((w) => w && !/^(?:a|an|the)$/.test(w))
.join(' ');
/**
* The actions this page can perform that the typed text is starting to name.
*
* Derived, never listed. The phrases come from the skills themselves — a
* definition's `prompt:` is the sentence its author wrote for exactly this
* purpose — and the candidate set is whatever `skillsForContext` already
* resolved, which has the page filter and the agent's scoping applied to it.
* So a skill added tomorrow is offered here without this file changing, a skill
* switched off in Settings is not offered at all, and there is no second list
* of action names to keep in step with the first.
*
* Only skills that DECLARE an action are eligible. A reading skill has nothing
* to autocomplete towards: "which positions are in draft" is a question, and
* offering it while somebody types is the generic-catalogue behaviour this
* replaced.
*
* Matched on `canon`, so "Create" reaches "Create a position" — the article and
* the case are not differences — and "create position" reaches it too. A prefix
* rather than a substring: typing the middle of a phrase is not evidence of
* intent, and substring matching is what made every keystroke produce chips.
*/
export function actionSuggestions(typed, skills = []) {
const query = canon(typed);
if (query.length < ACTION_INTENT_MIN) return [];
const seen = new Set();
const out = [];
for (const skill of skills) {
if (!skill?.prompt || !skill.actions?.length) continue;
const phrase = canon(skill.prompt);
if (!phrase.startsWith(query)) continue;
if (seen.has(phrase)) continue;
seen.add(phrase);
out.push({ label: skill.prompt, prompt: skill.prompt });
}
return out;
}

View File

@@ -32,13 +32,51 @@ const has = (q, ...terms) => terms.some((t) => q.includes(t));
* enquiry. "Assign them" after a preview must not be read as a fresh request
* for recommendations.
*/
/**
* The words that name a GROUP of people rather than one person.
*
* A detail request is about somebody: "show Maria Gonzalez" opens a record.
* A question naming a group is a search, and answering it with one person's
* record is the wrong answer to a different question.
*/
const GROUP_WORDS = [
'candidate', 'candidates', 'applicant', 'applicants',
'employee', 'employees', 'worker', 'workers', 'people', 'talent',
];
/**
* Words that place a question in the WORKFORCE rather than the hiring pipeline.
*
* These are two different sources and the product keeps them apart: a candidate
* is somebody in `job_applications`, and the talent pool is `worker_profiles`.
* Every intent below answers from the pipeline — `poolFor` is built from
* applications and a profile only ever enriches a candidate it already found —
* so a question about employees must not reach any of them.
*
* Returned as NO workforce intent, which hands the question to the Talent Pool
* responder to answer from the workforce. That is the correct source, and it is
* the direction this file used to get wrong: "show employees who match this
* role" was caught by a bare `show ` test and answered as a candidate lookup.
*/
const WORKFORCE_WORDS = ['talent pool', 'talent directory', 'employee', 'employees', 'worker', 'workers'];
const PIPELINE_WORDS = ['candidate', 'applicant', 'application'];
export function matchWorkforceIntent(question) {
const q = String(question).toLowerCase();
/* Source before intent. A question that names the workforce and not the
pipeline is not answered from the pipeline, whatever else it says. */
if (has(q, ...WORKFORCE_WORDS) && !has(q, ...PIPELINE_WORDS)) return null;
/* Inspection and record-opening are checked before assignment, so "show X"
and "open the full profile for X" never read as a request to assign. */
if (has(q, 'open the full profile', 'view full profile', 'full profile for')) return 'open_profile';
if (has(q, 'show ', "'s details", 'candidate details', 'tell me about ')) return 'candidate_detail';
if (has(q, "'s details", 'candidate details', 'tell me about ')) return 'candidate_detail';
/* `show X` is a detail request only when X is a person. Bare `show ` was
checked here unguarded and swallowed every collection query in the
product — "show candidates matching bartender" resolved to one candidate's
record rather than to a search. */
if (has(q, 'show ') && !has(q, ...GROUP_WORDS)) return 'candidate_detail';
if (has(q, 'can ', 'why not eligible', 'be assigned')) return 'eligibility';
if (has(q, 'confirm interview')) return 'confirm_interview';
@@ -53,11 +91,17 @@ export function matchWorkforceIntent(question) {
if (has(q, 'confirm assignment', 'yes, assign', 'confirm the assignment')) return 'confirm_assign';
if (has(q, 'assign ', 'fill this position', 'fill the position', 'assign the best', 'assign them')) return 'assign';
if (has(q, 'ready for interview', 'who should i interview', 'interview ready')) return 'interview_ready';
if (has(q, 'ready for interview', 'should i interview', 'interview ready')) return 'interview_ready';
if (has(q, 'applied today', "today's applicant", 'new applicant', 'new application')) return 'applied_today';
if (has(q, 'available', 'start earliest', 'can start', 'availability', 'free now')) return 'availability';
/* Candidate matching, in the words people actually use for it. The list was
narrow enough that "who is a match for this role", "find the best
candidate" and "find strong candidates" all fell through to no intent at
all. Every one of these is answered from the pipeline. */
if (has(q, 'who matches', 'who can fill', 'find candidates', 'best match', 'strongest candidate',
'recommend candidate', 'suitable candidate', 'who is suitable', 'candidates for')) return 'matches';
'recommend candidate', 'suitable candidate', 'who is suitable', 'candidates for',
'is a match', 'match for', 'best candidate', 'strong candidate', 'strongest match',
'which candidate', 'candidates matching', 'candidate matching', 'matching ')) return 'matches';
if (has(q, 'needs people first', 'biggest gap', 'largest gap', 'most understaffed',
'which position should i fill', 'priority')) return 'priority';
return null;

127
src/lib/ui/composition.js Normal file
View File

@@ -0,0 +1,127 @@
/**
* What a page is made of, and how a user's changes are folded into it.
*
* A page registers its own composition — the nodes it ships with, in order —
* and this module holds the table. The engine reads the table; it never learns
* a page name, and there is no switch here that would have to grow a case per
* surface. Registering is how a page opts in, exactly as `registerNodeType` is
* how a component does.
*
* The merge is the other half:
*
* built-in composition what the application ships
* ⊕ saved user patch the person's own changes, persisted
* ⊕ preview patch what they are trying, not yet saved
* = the tree that renders
*
* Both patches are **lists of operations**, replayed onto a freshly computed
* base. That is what makes a saved layout survive a release: a stored tree
* would be a photograph of the page on the day it was saved, and every
* improvement afterwards would be invisible to whoever had customised it.
*
* Skill sections are deliberately **not** merged here. A skill's `ui:` block is
* still rendered by `SkillSurface`, exactly as it is today, and a surface is
* simply one of the nodes a page composes. That keeps the existing Board-skill
* behaviour byte-for-byte unchanged while still putting it in the tree, where
* it can be hidden and reordered like anything else.
*/
import { makeNode } from './node';
import { applyPatch } from './patch';
import { nodeRegistry } from './registry';
/** @type {Map<string, any[]>} */
const compositions = new Map();
/**
* A container node's placement, as its own registration declared it.
*
* Read off `props` rather than from a field the engine knows about, so the
* composition layer needs no concept of what a placement is — only that a node
* may name one and that skill sections are grouped by the same name.
*/
const placementOf = (node) => String(node?.props?.placement || '');
/**
* The composition with each slot's skill sections hung underneath it.
*
* Done here, before any patch is replayed, so a person's saved operations act
* on the same tree they were made against — including the skill sections. A
* patch that hides a Board card keeps working; a patch naming a card whose
* skill has since been switched off is skipped, like any other stale operation.
*/
function attachSkillNodes(base, byPlacement) {
if (!byPlacement) return base;
return base.map((node) => {
const placement = placementOf(node);
const children = placement ? byPlacement[placement] : null;
if (!children?.length) return node;
return { ...node, children };
});
}
/**
* Declare the nodes a page ships with.
*
* Called once, at module scope, beside the page it describes — so a page and
* its composition move together and neither can be deployed without the other.
* Re-registering replaces, which is what a hot module reload needs; a duplicate
* is not an error the way a duplicate *type* is, because the second call is the
* same page saying the same thing again.
*/
export function registerPageComposition(page, nodes) {
const key = String(page ?? '').trim();
if (!key) throw new Error('registerPageComposition: a composition needs a page.');
compositions.set(key, (nodes || []).map((node) => makeNode({ origin: 'builtin', ...node })));
return compositions.get(key);
}
/** The nodes a page ships with, or an empty list for a page that has not opted in. */
export const compositionFor = (page) => compositions.get(String(page ?? '').trim()) || [];
/** Whether this page composes through the node system yet. */
export const hasComposition = (page) => compositions.has(String(page ?? '').trim());
/** Every page that has registered. Used by tests and, later, by the editor. */
export const composedPages = () => [...compositions.keys()];
/** Forget everything. Tests only. */
export const resetCompositions = () => compositions.clear();
/**
* The tree to render for a page.
*
* `skipped` carries the operations that no longer apply — a saved change naming
* a node a release has since removed. They are reported rather than thrown:
* that is not the user's mistake, and it must not cost them the rest of their
* layout.
*/
export function composePage(page, {
patch = null, preview = null, registry = nodeRegistry, role = null,
/**
* The sections this page's definitions contribute, grouped by placement.
*
* Passed in rather than read here, because resolving them needs the account's
* custom skills and disabled list — React state, which this module must stay
* free of to remain a pure function two callers can trust equally.
*/
skillNodes = null,
} = {}) {
const base = attachSkillNodes(compositionFor(page), skillNodes);
const context = { registry, role };
const skipped = [];
let tree = base;
/* Saved first, then preview. Order matters: a preview is composed against
what the person has already saved, so what they see while deciding is what
they will get if they keep it. */
for (const layer of [patch, preview]) {
if (!layer?.ops?.length) continue;
const result = applyPatch(tree, layer, context);
tree = result.tree;
skipped.push(...result.skipped);
}
return { tree, skipped };
}

259
src/lib/ui/inspect.js Normal file
View File

@@ -0,0 +1,259 @@
/**
* What the UI looks like, described rather than drawn.
*
* The agent must never guess what "this card" or "that section" refers to. This
* module turns a tree into a flat, addressable inventory — every node with its
* id, what it is, what it is showing, and what may be done to it — so a request
* is resolved against what is actually on the page rather than against what the
* model remembers about the product.
*
* It is a *read*. Nothing here mutates, and nothing here decides: resolving an
* ambiguous phrase to a single node is refused in favour of returning the
* candidates, because picking one and being wrong edits the thing the user was
* looking at while they were looking at something else.
*
* Everything is derived from the registry and the data vocabulary. No node
* name, page name or component name appears below.
*/
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
import { locate, walk } from './node';
import { nodeRegistry } from './registry';
/**
* One node, as the agent and the editor see it.
*
* `editable` is the answer to "what could I change here" — derived from the
* type's declared prop schema rather than from a list kept in parallel, so a
* property added to a registration becomes offerable without an edit here.
*/
export function describeNode(node, { registry = nodeRegistry, parent = null, index = 0 } = {}) {
const entry = registry.get(node.type);
return {
id: node.id,
type: node.type,
label: entry?.label || node.type,
/**
* The words on screen, which is how a person refers to a node out loud.
*
* Falls back to what the type can say about *this* node. Two skill slots on
* one page both rendered as "Skill sections" — indistinguishable in the
* outline, and unresolvable by name, because a slot carries no title of its
* own and its type label is the same for every one of them. The type
* supplies a `describe` and says where this one is; nothing here knows what
* a placement is.
*/
title: String(node.props?.title || '').trim()
|| (typeof entry?.describe === 'function' ? String(entry.describe(node) || '').trim() || null : null),
known: Boolean(entry),
container: Boolean(entry?.container),
origin: node.origin,
hidden: node.hidden === true,
locked: node.locked === true,
parent: parent?.id ?? null,
index,
data: node.data
? {
source: node.data.source,
label: dataSourceLabel(node.data.source),
params: node.data.params || {},
known: Boolean(dataSourceFor(node.data.source)),
}
: null,
layout: { ...(node.layout || {}) },
/* What this node looks like, and what it *could* look like. Both, because
every consumer needs the pair: the editor draws pickers from the second
and marks the first, and the conversation refuses a value that is not in
the second by name. One reading, so the two cannot disagree. */
presentation: { ...(node.presentation || {}) },
variants: entry ? [...entry.variants] : [],
densities: entry ? [...entry.densities] : [],
capabilities: entry ? [...entry.capabilities] : [],
editable: entry
? Object.entries(entry.propSchema).map(([key, rule]) => ({
key,
label: rule.label || key,
kind: Array.isArray(rule.enum) ? 'enum' : rule.type || 'string',
options: Array.isArray(rule.enum) ? [...rule.enum] : null,
required: rule.required === true,
value: node.props?.[key] ?? null,
}))
: [],
children: (node.children || []).map((child) => child.id),
};
}
/**
* The whole tree, flattened.
*
* Flat rather than nested because every question the agent asks is "which node
* is this" — and a nested shape makes that a traversal at every call site.
* Parentage survives as `parent` and `index`, so the structure is still
* recoverable.
*/
export function inspectTree(nodes, { registry = nodeRegistry } = {}) {
const out = [];
const visit = (list, parent) => {
(list || []).forEach((node, index) => {
out.push(describeNode(node, { registry, parent, index }));
if (node.children?.length) visit(node.children, node);
});
};
visit(nodes, null);
return out;
}
/**
* A short, readable rendering of the inventory.
*
* What a person is shown when they ask what is on the page, and what a
* conversation quotes back when a phrase matched more than one node. Indented
* by depth so the structure reads without drawing it.
*/
export function outlineTree(nodes, { registry = nodeRegistry } = {}) {
const lines = [];
const visit = (list, depth) => {
for (const node of list || []) {
const entry = registry.get(node.type);
const title = String(node.props?.title || '').trim();
const bits = [
`${' '.repeat(depth)}${title || entry?.label || node.type}`,
`(${node.id})`,
node.hidden ? '· hidden' : '',
node.data?.source ? `· ${dataSourceLabel(node.data.source)}` : '',
].filter(Boolean);
lines.push(bits.join(' '));
if (node.children?.length) visit(node.children, depth + 1);
}
};
visit(nodes, 0);
return lines;
}
/**
* The nodes a phrase could mean, best first.
*
* Deliberately a *list*. The caller decides what to do with two candidates, and
* the right answer in a conversation is to ask — so this never collapses a tie,
* and never returns a node on no evidence at all.
*
* Scoring is over what the node itself says: its id, its title, its type label
* and the label of the reading it shows. There is no dictionary of component
* names here, so a type registered tomorrow is matchable by its own label
* without this function changing.
*/
export function resolveTarget(nodes, phrase, {
registry = nodeRegistry, type = null,
/**
* Narrow to nodes that are, or are not, currently hidden.
*
* A hidden node stays in the tree and stays addressable — the renderer skips
* it, nothing else does. This filter exists so a caller can ask the question
* that actually disambiguates "show the timeline": is there something by that
* name which is currently not on screen? `null` means do not care.
*/
hidden = null,
} = {}) {
const want = canon(phrase);
if (!want) return [];
/* Words that carry no evidence about which node is meant. Matching on them
would make every phrase fit every node. */
const tokens = want.split(' ').filter((word) => word.length > 2 && !STOP.has(word));
const candidates = walk(nodes)
.filter((node) => (type ? node.type === type : true))
.filter((node) => (hidden === null ? true : Boolean(node.hidden) === hidden))
.map((node) => {
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) : '');
const id = canon(node.id);
let score = 0;
/* An id said verbatim is not a guess — it is the address, and it wins. */
if (id && id === want) score += 100;
if (title && title === want) score += 60;
if (title && want.includes(title)) score += 40;
if (title && title.includes(want)) score += 24;
if (label && want.includes(label)) score += 18;
if (source && want.includes(source)) score += 14;
if (id && want.includes(id)) score += 10;
/**
* Part of a name is still a name.
*
* "the notice" has to reach "Privileged actions notice", and nobody says
* a section's full label out loud. Scored per matching word and below
* every whole-name rule above, so a partial match never outranks somebody
* naming the thing properly — which is what keeps "the audit section"
* pointing at the audit log rather than tying with "Skill sections".
*/
for (const token of tokens) {
if (title && title.includes(token)) score += 8;
else if (label && label.includes(token)) score += 6;
else if (id && id.includes(token)) score += 4;
}
return { node, score };
})
.filter((row) => row.score > 0)
.sort((a, b) => b.score - a.score);
return candidates.map((row) => ({
...describeNode(row.node, {
registry,
parent: locate(nodes, row.node.id)?.parent || null,
index: locate(nodes, row.node.id)?.index || 0,
}),
/* Carried out so a caller can tell a clear winner from a tie. Deciding that
here would be deciding what to do about ambiguity, which belongs to
whoever has somebody to ask. */
score: row.score,
}));
}
/** Words too common to distinguish one node from another. */
const STOP = new Set([
'the', 'this', 'that', 'these', 'those', 'and', 'for', 'with', 'from', 'into',
'show', 'hide', 'move', 'make', 'add', 'put', 'change', 'turn', 'switch',
'above', 'below', 'under', 'over', 'before', 'after', 'top', 'bottom',
'please', 'section', 'sections', 'panel', 'panels', 'page', 'here',
]);
/** Lower-case, punctuation-free, single-spaced. The one normaliser for matching. */
const canon = (value) => String(value ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
/**
* What could be added inside a container, and what each could show.
*
* The picker's source of truth, for the agent and the visual editor alike.
* Reads the registry and the data vocabulary, so a newly registered type is
* offered without this being touched.
*/
export function addableTypes(parentType, { registry = nodeRegistry, role = null, page = null } = {}) {
return registry.all()
/* `offersChild`, not `acceptsChild`: what may structurally sit here is only
half the question. The other half — is this a type a person may add, and
does it belong to this page — is what kept every page's private sections
out of every other page's picker. */
.filter((entry) => registry.offersChild(parentType, entry.type, { page }))
.filter((entry) => !entry.roles || !role || entry.roles.includes(role))
.map((entry) => ({
type: entry.type,
label: entry.label,
summary: entry.summary,
container: entry.container,
dataShapes: [...entry.dataShapes],
dataRequired: entry.dataRequired,
}));
}

579
src/lib/ui/intent.js Normal file
View File

@@ -0,0 +1,579 @@
/**
* A request in words, turned into one validated operation.
*
* This is the whole of Owliver's UI-editing understanding, and it is
* deliberately small. It reads the verbs from a table, the type names from the
* node registry, the data sources from the closed vocabulary, and the targets
* from the tree that is actually on screen. There is no page in it, no
* component name, and no branch on what a node happens to be — a type
* registered tomorrow is addressable tomorrow, by its own label, with this file
* unchanged.
*
* What it can produce is an **operation**, never markup. The model — when there
* is one — is not in this path at all: matching is deterministic, which is what
* makes it impossible for a hallucinated component name or an invented data
* source to reach the engine. The worst a request can do is fail to match.
*
* Every outcome is one of a small set, and two of them are questions rather
* than actions:
*
* - `plan` an operation, ready to preview
* - `inspect` a description of what is on the page
* - `apply` / `discard` acting on a preview already shown
* - `ambiguous` more than one node fits, so the caller must ask
* - `unknown` a target that matches nothing on the page
* - `refused` understood, and not allowed — with the reason
*/
import { SUPPORTED_DATA_SOURCES, dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
import { addableTypes, describeNode, resolveTarget } from './inspect';
import { freeNodeId, walk } from './node';
import { nodeRegistry } from './registry';
/** Lower-case, punctuation-free. The one normaliser, shared with `inspect`. */
const canon = (value) => String(value ?? '')
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
const has = (text, ...words) => words.some((w) => text.includes(canon(w)));
/**
* The verbs, as data.
*
* Each names an operation and the words that ask for it. Ordered: the first
* match wins, so the more specific readings are declared before the general
* ones. Nothing here names a component or a page.
*/
const VERBS = [
{ op: 'inspect', words: ['what is on this page', 'whats on this page', 'what sections', 'list the sections', 'show me the layout', 'what can i change'] },
{ op: 'apply', words: ['apply', 'keep it', 'keep that', 'save it', 'save that', 'yes apply'] },
{ op: 'discard', words: ['discard', 'cancel that', 'undo that', 'never mind', 'nevermind', 'revert that'] },
/* Unambiguously about the interface: nobody says "unhide" about data. */
{ op: 'unhide', words: ['unhide', 'show again', 'bring back', 'bring it back', 'restore', 'put back', 'reveal'] },
{ op: 'hide', words: ['hide', 'remove', 'delete', 'get rid of', 'take off', 'take away'] },
{ op: 'move', words: ['move', 'put', 'place', 'reorder', 'bring'] },
/**
* How something looks, rather than what it is.
*
* Before `replace`, because "change this card to compact" names a
* presentation value and `replace` would read the same sentence as a request
* for a different type. The words themselves are the gate: none of them is
* something a person says about data.
*/
{ op: 'present', words: ['compact', 'comfortable', 'spacious', 'emphasis', 'emphasise', 'emphasize', 'subtle', 'denser', 'tighter', 'roomier'] },
{ 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'] },
/**
* Plain "show" is the hard one, and it is matched last.
*
* "Show me the candidates" is a reading; "show the timeline" — when the
* timeline is hidden — is a layout change. The word cannot tell them apart,
* so the *tree* does: this becomes a UI edit only when the phrase names
* something currently hidden, and names it convincingly. Last in the list
* because the word appears inside other requests — "add a card showing
* candidate activity" is an `add`, and would be stolen by an earlier `show`.
*/
{ op: 'show', words: ['show', 'display'] },
];
/**
* Words that say the request is about the interface rather than about the data.
*
* "chart" used to be here and is not any more: it names a registered type now,
* so `namedType` recognises it and the gate already opens on that. Keeping it
* would have been the language layer holding a component name of its own —
* which is the thing the check script greps for, and rightly.
*/
const UI_WORDS = [
'section', 'sections', 'panel', 'panels', 'block', 'blocks', 'widget', 'widgets',
'layout', 'page', 'column', 'columns',
'above', 'below', 'top', 'bottom', 'first', 'last', 'order',
];
const SHOW_CONFIDENCE = 18;
const NUMBER_WORDS = { one: 1, two: 2, three: 3, four: 4, six: 6, twelve: 12 };
/**
* Read a request.
*
* `tree` is what is on screen. Nothing is matched against a remembered page —
* a target is only resolvable if it is really there, which is what stops a
* confident answer about a section that does not exist.
*/
export function matchUiEdit(question, {
tree = [], registry = nodeRegistry, role = null, previewing = false,
/* The page being edited. Only ever compared, never interpreted — it is what
keeps "add a recent hiring timeline" from being answerable on a page that
publishes none of the records such a section reads. */
page = null,
} = {}) {
const text = canon(question);
if (!text) return null;
const verb = VERBS.find((v) => has(text, ...v.words));
if (!verb) return null;
/**
* Apply and discard.
*
* While something is being previewed these are unambiguous. With nothing
* previewed they are not: "apply" is an ordinary word — applying for a role,
* applying a filter — and this must not swallow it.
*
* But it must not hand back the panel's *own* words either. The chips this
* panel offers say "Apply the layout change" and "Discard the layout change",
* and a person who clicks one a second time, or after a re-render has dropped
* the preview, was previously answered by the model: an agent scoped to open
* roles explaining that layout changes are not in its scope. A request about
* the interface must never be answered by something that does not know the
* interface exists.
*
* So the phrase is claimed when it names the interface — the same word gate
* the other verbs use — and answered with the plain fact that there is
* nothing to act on. Everything else still falls through untouched.
*/
if (verb.op === 'apply' || verb.op === 'discard') {
if (previewing) return { kind: verb.op };
return has(text, ...UI_WORDS) ? { kind: 'nothing-previewed', op: verb.op } : null;
}
if (verb.op === 'inspect') return { kind: 'inspect' };
/**
* The gate that keeps ordinary questions ordinary.
*
* A verb alone is not enough — "show me the candidates" is a reading, not a
* layout change. The request has to also name something on this page, a type
* the registry knows, or a word about the interface itself.
*/
/**
* Bringing something back is decided by the tree, before the word gate.
*
* `show` deliberately does not consult the interface-words gate: the evidence
* that it means the interface is that a hidden node answers to the phrase,
* and requiring "section" or "panel" as well would make the only way to undo
* a hide harder to say than the hide was.
*/
if (verb.op === 'show') return planShow(text, tree, registry);
if (verb.op === 'unhide') return planUnhide(text, tree, registry);
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;
switch (verb.op) {
case 'hide': return planVisibility(text, tree, registry, true);
case 'move': return planMove(text, tree, registry);
case 'replace': return planReplace(text, tree, registry, page);
case 'add': return planAdd(text, tree, registry, named, role, page);
case 'layout': return planLayout(text, tree, registry);
case 'present': return planPresent(text, tree, registry);
default: return null;
}
}
/**
* The node a phrase means, or the question to ask instead.
*
* Ambiguity is never resolved by picking the first: editing the wrong section
* 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 } = {}) {
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.
Only a preference: with nothing hidden, the phrase still resolves. */
const hiddenOnly = all.filter((node) => node.hidden);
const hits = preferHidden && hiddenOnly.length ? hiddenOnly : all;
if (!hits.length) return { kind: 'unknown', phrase: text };
/**
* A tie is two nodes the phrase fits equally well.
*
* Judged on the score the resolver assigned rather than on a second reading
* of the words here: one place decides how well a phrase fits a node, and
* this only decides what to do when two fit the same. A clear winner is acted
* on; anything else is a question back.
*/
if (hits.length > 1 && hits[0].score === hits[1].score) {
return { kind: 'ambiguous', candidates: hits.filter((h) => h.score === hits[0].score).slice(0, 4) };
}
return { node: hits[0] };
}
/** Hide or show a node the phrase named. */
function planVisibility(text, tree, registry, hidden) {
const found = target(text, tree, registry);
if (found.kind) return found;
return visibilityPlan(found.node, hidden);
}
/**
* The plan, or the reason there is nothing to do.
*
* A node already in the state being asked for produces no operation. Saying so
* is better than storing a second `hide` on something hidden: the patch stays
* the record of decisions a person actually made, and undo steps back through
* changes rather than through no-ops.
*/
function visibilityPlan(node, hidden) {
if (!node.capabilities.includes('hide')) {
return { kind: 'refused', message: `\`${node.label}\` cannot be ${hidden ? 'hidden' : 'shown'}.` };
}
if (Boolean(node.hidden) === hidden) {
return {
kind: 'refused',
message: `\`${node.title || node.label}\` is already ${hidden ? 'hidden' : 'showing'}.`,
};
}
return {
kind: 'plan',
op: { op: 'hide', target: node.id, hidden },
summary: `${hidden ? 'Hide' : 'Show'} ${node.title || node.label}`,
node,
};
}
/**
* Plain "show", resolved only against what is hidden.
*
* Returning null is the important branch: it is what leaves "show me the
* candidates" to the rest of Owliver untouched. A hidden node answering to the
* phrase is the whole of the evidence that the interface was meant.
*/
function planShow(text, tree, registry) {
const hits = resolveTarget(tree, text, { registry, hidden: true })
/**
* Enough evidence to outweigh the ordinary meaning of the word.
*
* `SHOW_CONFIDENCE` is the score a phrase earns by naming a node properly —
* containing its whole label or title. A single shared word does not reach
* it, which is what keeps "show me the recent hires" a question about hires
* rather than a request to reveal the Recent hiring timeline.
*/
.filter((node) => node.score >= SHOW_CONFIDENCE);
if (!hits.length) return null;
if (hits.length > 1 && hits[0].score === hits[1].score) {
return { kind: 'ambiguous', candidates: hits.filter((h) => h.score === hits[0].score).slice(0, 4) };
}
return visibilityPlan(hits[0], false);
}
/**
* "Unhide", "bring it back", "restore".
*
* Always a layout request, so this resolves against the whole tree and reports
* when the thing named is already on screen — which is more useful than
* silently matching nothing.
*/
function planUnhide(text, tree, registry) {
const found = target(text, tree, registry, { preferHidden: true });
if (found.kind) return found;
return visibilityPlan(found.node, false);
}
/**
* Move one node relative to another, or to an end of the page.
*
* Expressed as a `reorder` of the root rather than a `move`, because that is
* what "above the notice" means on a flat page: the node stays where it lives
* and the order around it changes.
*/
function planMove(text, tree, registry) {
const roots = tree.map((node) => describeNode(node, { registry }));
const before = has(text, 'above', 'before', 'over', 'on top of', 'to the top', 'first');
const after = has(text, 'below', 'under', 'beneath', 'after', 'to the bottom', 'last', 'end');
/* Split on the positional word so the two halves name two different nodes:
"move timeline above the notice" is a subject and a reference. */
const pivot = ['above', 'before', 'below', 'under', 'beneath', 'after', 'over']
.map((w) => ({ w, at: text.indexOf(` ${w} `) }))
.filter((p) => p.at > 0)
.sort((a, b) => a.at - b.at)[0];
const subjectText = pivot ? text.slice(0, pivot.at) : text;
const subject = target(subjectText, tree, registry);
if (subject.kind) return subject;
const order = roots.map((n) => n.id);
const from = order.indexOf(subject.node.id);
if (from < 0) return { kind: 'unknown', phrase: subjectText };
if (!subject.node.capabilities.includes('move')) {
return { kind: 'refused', message: `\`${subject.node.label}\` cannot be moved.` };
}
let to;
if (pivot) {
const referenceText = text.slice(pivot.at + pivot.w.length + 2);
const reference = target(referenceText, tree, registry, { exclude: [subject.node.id] });
if (reference.kind) return reference;
const at = order.indexOf(reference.node.id);
if (at < 0) return { kind: 'unknown', phrase: referenceText };
to = ['above', 'before', 'over'].includes(pivot.w) ? at : at + 1;
} else if (before) {
to = 0;
} else if (after) {
to = order.length;
} else {
return { kind: 'unknown', phrase: text };
}
const next = [...order];
next.splice(from, 1);
next.splice(to > from ? to - 1 : to, 0, subject.node.id);
if (next.join() === order.join()) {
return { kind: 'refused', message: `\`${subject.node.title || subject.node.label}\` is already there.` };
}
return {
kind: 'plan',
op: { op: 'reorder', parent: null, order: next },
summary: `Move ${subject.node.title || subject.node.label}`,
node: subject.node,
};
}
/** The registered type a phrase names, by type id or by its label. */
function namedType(text, registry, page = null) {
const hit = registry.all()
/* Only what this page could actually hold. A type named in a sentence that
does not belong here is not a type this reader can mean. */
.filter((entry) => registry.offersChild(null, entry.type, { page }))
.map((entry) => ({ entry, word: canon(entry.label) }))
.filter(({ entry, word }) => has(text, entry.type) || (word && has(text, word)))
/* Longest label first, so "audit log" is not read as "log". */
.sort((a, b) => b.word.length - a.word.length)[0];
return hit?.entry || null;
}
/** Turn one node into another type. */
function planReplace(text, tree, registry, page = null) {
/**
* The type asked for is the one after the connector.
*
* "change the audit log to a table" names two things the registry knows — the
* section being changed and the shape it should become — and reading the whole
* sentence for a type finds whichever label happens to be longer. The
* connector is what tells them apart, and it is how people say it.
*/
const split = /\b(?:in)?to\s+(?:an?\s+)?|\bas\s+(?:an?\s+)?/.exec(text);
const wanted = split ? text.slice(split.index + split[0].length) : text;
/* Page-scoped, like every other reading of a type name. Without it a request
could name a section belonging to another page, and the refusal listed the
whole registry back — every page's private sections, to a person who can
use none of them. */
const named = namedType(wanted, registry, page);
if (!named) {
return {
kind: 'unknown-type',
phrase: text,
offered: addableTypes(null, { registry, page }).map((t) => t.type),
};
}
/* 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);
if (found.kind) return found;
const node = found.node;
if (!node.capabilities.includes('replace')) {
return { kind: 'refused', message: `\`${node.label}\` cannot be changed into something else.` };
}
if (node.type === named.type) {
return { kind: 'refused', message: `\`${node.title || node.label}\` is already a ${named.label}.` };
}
/* 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)) {
return {
kind: 'refused',
message: node.data
? `\`${dataSourceLabel(node.data.source)}\` cannot be shown as a ${named.label}.`
: `\`${node.label}\` cannot become a ${named.label}.`,
};
}
return {
kind: 'plan',
op: { op: 'replace', target: node.id, type: named.type },
summary: `Change ${node.title || node.label} to a ${named.label}`,
node,
};
}
/**
* Add a reading.
*
* A new node needs a type and a source, and both must be named — a source is
* never guessed. Asked for without one, this returns the choices rather than
* inventing a binding, which is the difference between a product that offers
* what it has and one that makes something up.
*/
function planAdd(text, tree, registry, named, role, page = null) {
if (!named) {
return {
kind: 'unknown-type',
phrase: text,
offered: addableTypes(null, { registry, role, page }).map((t) => t.type),
};
}
if (!registry.offersChild(null, named.type, { page })) {
return { kind: 'refused', message: `A ${named.label} cannot be added to this page.` };
}
if (named.roles && role && !named.roles.includes(role)) {
return { kind: 'refused', message: `You do not have access to ${named.label}.` };
}
/**
* Which readings could fill this shape *here*.
*
* Filtered by what the page can answer, not just by shape. A reading that
* needs a record — "Position activity" — placed on a page that is inside no
* record renders "This section needs a position to read." forever, and
* offering it is the same mistake the visual picker was making.
*/
const fits = (id) => {
const entry = dataSourceFor(id);
if (!entry || !(entry.shapes || []).includes(named.type)) return false;
return !entry.context;
};
const source = namedSource(text, named, fits);
if (!source) {
const options = SUPPORTED_DATA_SOURCES.filter(fits);
return { kind: 'needs-source', type: named, options: options.slice(0, 6) };
}
if (!fits(source)) {
return {
kind: 'refused',
message: `${dataSourceLabel(source)} needs a record this page is not showing.`,
};
}
const id = freeNodeId(tree, named.type);
return {
kind: 'plan',
op: {
op: 'add',
parent: null,
node: { id, type: named.type, data: { source }, props: { title: dataSourceLabel(source) } },
},
summary: `Add a ${named.label} showing ${dataSourceLabel(source)}`,
};
}
/** The data source a phrase names, restricted to what this type can draw. */
function namedSource(text, named, answerable = null) {
const hits = SUPPORTED_DATA_SOURCES
.filter((id) => (dataSourceFor(id)?.shapes || []).includes(named.type))
.map((id) => ({ id, words: canon(dataSourceLabel(id)) }))
.filter(({ id, words }) => has(text, words) || has(text, canon(id)))
.sort((a, b) => b.words.length - a.words.length);
if (!hits.length) return null;
/**
* Two readings can answer to the same words.
*
* `candidate.activity` and `candidates.activity` are both labelled "Candidate
* activity" — one is what happened on one candidate's record, the other is
* applications across the workspace. Nothing in the phrase separates them, so
* a sort decided, and on a page showing no candidate the sort could pick the
* one that can never resolve: a panel reading "This section needs a candidate
* to read." for as long as it is kept.
*
* So where the words do not decide, what the page can answer does. A reading
* that fits is preferred over one that cannot; with nothing to choose
* between, the longest match still wins and the caller refuses it by name.
*/
const fits = answerable || (() => true);
return (hits.find((hit) => fits(hit.id)) || hits[0]).id;
}
/**
* How a node presents itself.
*
* The words map to values from the closed vocabulary and nothing else — there
* is no path from a sentence to a class name. "Reset" is included because
* putting something back is the request people actually make after trying
* something, and it has to be sayable.
*/
function planPresent(text, tree, registry) {
const wants = {};
if (has(text, 'compact', 'denser', 'tighter')) wants.density = 'compact';
if (has(text, 'comfortable', 'spacious', 'roomier')) wants.density = 'comfortable';
if (has(text, 'emphasis', 'emphasise', 'emphasize')) wants.variant = 'emphasis';
if (has(text, 'subtle')) wants.variant = 'subtle';
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);
if (found.kind) return found;
const node = found.node;
if (!node.capabilities.includes('update')) {
return { kind: 'refused', message: `\`${node.label}\` cannot be changed.` };
}
/* Refused by name where the type cannot draw it, rather than stored as a
setting the component ignores — the same rule the validator applies, said
earlier so the person hears it instead of seeing nothing happen. */
for (const [key, value] of Object.entries(wants)) {
const supported = key === 'variant' ? node.variants : node.densities;
if (!supported.includes(value)) {
return {
kind: 'refused',
message: supported.length
? `\`${node.title || node.label}\` supports ${key}: ${supported.join(', ')}.`
: `\`${node.title || node.label}\` has no ${key} to set.`,
};
}
}
const said = Object.entries(wants).map(([k, v]) => `${k} ${v}`).join(' and ');
return {
kind: 'plan',
op: { op: 'update', target: node.id, presentation: wants },
summary: `Set ${node.title || node.label} to ${said}`,
node,
};
}
/** Column counts. */
function planLayout(text, tree, registry) {
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);
if (found.kind) return found;
const node = found.node;
if (!node.capabilities.includes('update')) {
return { kind: 'refused', message: `\`${node.label}\` cannot be changed.` };
}
return {
kind: 'plan',
op: { op: 'update', target: node.id, layout: { columns } },
summary: `Make ${node.title || node.label} ${columns} columns`,
node,
};
}

315
src/lib/ui/node.js Normal file
View File

@@ -0,0 +1,315 @@
/**
* A UI node — the unit the agent and the editor address.
*
* This is the whole shape of a piece of interface as far as the mutation engine
* is concerned. It is deliberately dumb: a node names a *type* and carries
* *configuration*, and it never carries a component, a class name, markup, or
* anything that could be evaluated. Turning a node into pixels is the
* renderer's job, and the renderer only ever looks the type up in a registry of
* components the application already ships.
*
* That split is the security boundary. `type` is a key, not an import; `props`
* are checked against a schema the type declares; `data` names a reading from
* the closed vocabulary in `lib/skills/surfaces.js`. There is no field here
* through which a definition can introduce code, and no field the engine passes
* through without checking. It is the same guarantee `SkillSections.jsx` and
* `uiConfig.js` already make for skill sections, widened from one slot to a
* whole tree.
*
* Nothing in this module knows a page name, a component name, or a product
* feature. Everything specific lives in the registry (`registry.js`) or in the
* composition a page publishes.
*/
/**
* Where a node came from, which is what keeps the three persistence tiers
* separate.
*
* `builtin` is the application's own composition; `skill` is adapted from a
* definition's `ui:` block; `user` is something a person added at runtime. The
* distinction matters at save time — a user patch is stored as operations
* against the other two, never as a copy of them — and at delete time, because
* removing a built-in is hiding it, while removing a node a user added is
* really removing it.
*/
export const NODE_ORIGINS = ['builtin', 'skill', 'user'];
/**
* What may be done to a node, declared per type rather than assumed.
*
* A type opts in. The engine never infers a capability from a type's name or
* shape, so a component that must not be moved says so once, in its
* registration, and every operation honours it without knowing what it is.
*
* `add` and `reorder` are about a node's *children* and only mean anything on a
* container. The rest are about the node itself.
*/
export const NODE_CAPABILITIES = [
'add', 'update', 'remove', 'move', 'replace', 'hide', 'reorder',
];
/** Ids are addresses. Same rule as a section id, so the two can never disagree. */
export const NODE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
/**
* How wide a node may be, as a count of grid columns.
*
* Bounded here rather than per type so that "make this two columns" has one
* answer everywhere; a type narrows it further through `constraints`.
* Twelve because that is the largest arrangement the grid utilities express,
* and an unbounded number is a layout that breaks below a phone width.
*/
export const MIN_COLUMNS = 1;
export const MAX_COLUMNS = 12;
/** The layout keys a node may carry. Anything else an author writes is dropped. */
export const LAYOUT_KEYS = ['columns', 'span', 'gap', 'align', 'spacingBefore', 'spacingAfter'];
export const GAP_VALUES = ['none', 'sm', 'md', 'lg'];
export const ALIGN_VALUES = ['start', 'center', 'end', 'stretch'];
/**
* Space above or below a node, as a step on a scale rather than a measurement.
*
* A closed set of four, each mapping to one class the design system already
* ships. This exists because a page sometimes needs a node to sit further from
* what precedes it than the page's own rhythm gives — and the alternative,
* letting configuration carry a class name, would put arbitrary CSS into a
* schema a person can edit. A step cannot say anything the product has not
* already decided it can say.
*/
export const SPACING_VALUES = ['none', 'sm', 'md', 'lg'];
/**
* How a node presents itself, as distinct from where it sits.
*
* `layout` answers *arrangement* — columns, span, the space around a node.
* This answers *treatment*: how tight the node is and how much weight it
* carries. They are kept apart because they are edited for different reasons
* and because calling this "layout" would make the word mean everything.
*
* Two closed scales, and closed is the point. A person can say "compact" and
* the product decides what compact means; there is no value here that carries a
* class name, a measurement or a colour, so nothing a person or an agent writes
* can reach the stylesheet. A type that has not declared it supports a value
* refuses it — see `variants` and `densities` on a registration.
*/
export const PRESENTATION_KEYS = ['variant', 'density'];
/** The weight a node carries. `default` is the panel every section already draws. */
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'];
/**
* A node, with every field settled.
*
* Callers hand in whatever they have; this decides what the rest of the system
* sees. It does **not** validate — `validate.js` does that, against the
* registry, and keeping the two apart is what lets an invalid node exist long
* enough to be reported with a message instead of vanishing.
*
* Unknown keys are dropped rather than carried. A node that survived with an
* extra field would eventually have that field read by something, and then the
* closed vocabulary would be closed only by convention.
*/
/** @param {any} raw @returns {any} */
export function makeNode(raw = {}) {
/** @type {any} */
const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
return {
id: String(source.id ?? '').trim(),
type: String(source.type ?? '').trim(),
props: plainObject(source.props),
data: normalizeBinding(source.data),
layout: normalizeLayout(source.layout),
presentation: normalizePresentation(source.presentation),
children: Array.isArray(source.children) ? source.children.map(makeNode) : [],
hidden: source.hidden === true,
origin: NODE_ORIGINS.includes(source.origin) ? source.origin : 'builtin',
/* Locked is the type's word, not the node's, but it is stored on the node so
a composition can pin one instance — a page may legitimately want its
header fixed while the same type is movable elsewhere. */
locked: source.locked === true,
};
}
/** A shallow copy of a plain object, or an empty one. Never an array, never null. */
function plainObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
}
/**
* The data binding, or null.
*
* `source` is an id in the closed data-source vocabulary and is checked there,
* not here. `params` is the small bag of options a source declares it reads —
* periods, limit — and is likewise checked at validation. What this does is
* make the shape predictable: a binding is either absent or an object with a
* string source, so nothing downstream has to test both `data.source` and a
* bare `source`.
*/
function normalizeBinding(value) {
if (value == null) return null;
if (typeof value === 'string') {
const source = value.trim();
return source ? { source, params: {} } : null;
}
if (typeof value !== 'object' || Array.isArray(value)) return null;
const source = String(value.source ?? '').trim();
if (!source) return null;
return { source, params: plainObject(value.params) };
}
/**
* Layout, reduced to the four keys that mean something.
*
* Coerced rather than refused: `columns: "2"` is what a form control produces
* and a conversation says, and treating that as an authoring error would make
* the format precious about typing. Out-of-range values are clamped at
* validation, where the type's own constraints are known — not here, where they
* are not.
*/
function normalizeLayout(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const layout = {};
for (const key of LAYOUT_KEYS) {
if (value[key] == null || value[key] === '') continue;
if (key === 'columns' || key === 'span') {
const n = Number(value[key]);
if (Number.isFinite(n)) layout[key] = Math.round(n);
continue;
}
layout[key] = String(value[key]).trim();
}
return layout;
}
/**
* The presentation keys a node may carry, and nothing else.
*
* Values are not checked here — `validate.js` does that against the type's own
* declared support, so an unsupported value survives long enough to be reported
* by name rather than disappearing silently.
*/
function normalizePresentation(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const presentation = {};
for (const key of PRESENTATION_KEYS) {
if (value[key] == null || value[key] === '') continue;
presentation[key] = String(value[key]).trim();
}
return presentation;
}
/* ── Reading a tree ─────────────────────────────────────────────────────────
A tree is an array of root nodes, not a single node with a synthetic root.
A page is a list of things, and inventing a root would give the engine one
node that every rule then has to except. */
/** Every node, parents before children. Order is the reading order of the page. */
export function walk(nodes) {
const out = [];
const visit = (list) => {
for (const node of list || []) {
out.push(node);
if (node.children?.length) visit(node.children);
}
};
visit(nodes);
return out;
}
/** The node with this id, or null. */
export function findNode(nodes, id) {
const want = String(id ?? '').trim();
if (!want) return null;
return walk(nodes).find((node) => node.id === want) || null;
}
/**
* Where a node sits: its parent (null at the root) and its index among siblings.
*
* Every structural operation needs this, and every one of them computing it
* separately is how `move` and `reorder` end up disagreeing about what an index
* means.
*/
export function locate(nodes, id) {
const want = String(id ?? '').trim();
if (!want) return null;
const search = (list, parent) => {
for (let index = 0; index < list.length; index += 1) {
if (list[index].id === want) return { parent, siblings: list, index, node: list[index] };
const deeper = list[index].children?.length ? search(list[index].children, list[index]) : null;
if (deeper) return deeper;
}
return null;
};
return search(nodes || [], null);
}
/** Every id in the tree, including duplicates — the caller decides what that means. */
export const idsOf = (nodes) => walk(nodes).map((node) => node.id);
/**
* A tree with one node replaced by the result of `fn`, and everything else
* shared.
*
* Structural sharing rather than a deep clone: React re-renders what changed,
* and a wholesale copy would repaint a page for a hidden flag. Returning `null`
* from `fn` removes the node, which is what makes `remove` a special case of
* this rather than a second traversal.
*/
export function mapNode(nodes, id, fn) {
const want = String(id ?? '').trim();
let touched = false;
const visit = (list) => list.reduce((acc, node) => {
if (node.id === want) {
touched = true;
const next = fn(node);
if (next) acc.push(next);
return acc;
}
if (node.children?.length) {
const children = visit(node.children);
acc.push(children === node.children ? node : { ...node, children });
return acc;
}
acc.push(node);
return acc;
}, []);
const next = visit(nodes || []);
return touched ? next : nodes;
}
/** A tree with `fn` applied to one node's children list. */
export function mapChildren(nodes, parentId, fn) {
const want = String(parentId ?? '').trim();
/* The root is addressed by a null parent, so a caller does not need a
different function to reorder top-level nodes than nested ones. */
if (!want) return fn(nodes || []);
return mapNode(nodes, want, (node) => ({ ...node, children: fn(node.children || []) }));
}
/**
* An id nothing in the tree is using, derived from the type.
*
* Shared, because Owliver and the editor both add nodes and two id schemes
* would mean the same action produced different addresses depending on which
* surface asked for it.
*/
export function freeNodeId(nodes, type) {
const taken = new Set(walk(nodes).map((node) => node.id));
let n = 1;
while (taken.has(`${type}-${n}`)) n += 1;
return `${type}-${n}`;
}
/** A node and everything under it, as a fresh tree. Used by move and by undo. */
export const cloneNode = (node) => makeNode(node);

475
src/lib/ui/operations.js Normal file
View File

@@ -0,0 +1,475 @@
/**
* The mutation engine.
*
* Eight operations over a UI tree, and **not one of them branches on a node
* type**. Every decision an operation makes — may this move, may that hold a
* child, is this column count legal, may this person do it at all — is read
* from the type's registration or from the closed data vocabulary. That is the
* property that makes a new UI type one `registerNodeType` call instead of an
* edit here, and it is worth checking on any change to this file: a `card`,
* `chart`, `table`, `positions` or `control-center` appearing below is a bug in
* the design, not a special case.
*
* Every operation is pure — `(tree, op) → { ok, tree, problems, diff }` — and
* returns a **new** tree, sharing everything it did not touch. Purity is what
* lets preview and apply run the identical code path: a preview is the result
* held in memory, an apply is the same result persisted. There is no second
* implementation for either, so they cannot disagree.
*
* An operation that fails returns the tree it was given, unchanged, with the
* problems that stopped it. Nothing is half-applied.
*/
import { dataSourceFor, sourceSupportsShape } from '@/lib/skills/surfaces';
import {
cloneNode, findNode, locate, makeNode, mapChildren, mapNode,
} from './node';
import { nodeRegistry } from './registry';
import { takenIds, validateTree } from './validate';
/** The operation names the engine understands. Anything else is refused by name. */
export const OPERATIONS = [
'add', 'update', 'remove', 'move', 'replace', 'hide', 'reorder',
];
const fail = (tree, ...problems) => ({
ok: false,
tree,
problems: problems.flat().map((p) => (typeof p === 'string' ? { at: null, message: p } : p)),
diff: null,
});
const done = (tree, diff) => ({ ok: true, tree, problems: [], diff });
/**
* Apply one operation.
*
* The candidate tree is built first and validated whole, rather than each
* operation checking its own effects. A structural change can invalidate a node
* it did not touch — moving a chart into a container that does not accept it,
* or leaving a container below its minimum — and only a whole-tree check sees
* that.
*/
export function applyOperation(tree, op, { registry = nodeRegistry, role = null } = {}) {
const nodes = Array.isArray(tree) ? tree : [];
const name = String(op?.op ?? '').trim();
if (!OPERATIONS.includes(name)) {
return fail(nodes, `Unknown operation: ${name || '(none)'}. Known: ${OPERATIONS.join(', ')}.`);
}
const context = { registry, role };
const built = BUILDERS[name](nodes, op, context);
if (!built.ok) return built;
/* One gate, after the change is composed and before it is anybody's. */
const { ok, problems } = validateTree(built.tree, context);
if (!ok) return fail(nodes, problems);
return done(built.tree, built.diff);
}
/**
* Apply a list, stopping at the first failure.
*
* All-or-nothing: a patch that applied its first three operations and refused
* the fourth would leave a page in a state the user never asked for and cannot
* name. The caller gets the original tree back and the problems from the one
* that stopped it.
*/
export function applyOperations(tree, ops, context = {}) {
const nodes = Array.isArray(tree) ? tree : [];
let current = nodes;
const diffs = [];
for (const [index, op] of (ops || []).entries()) {
const result = applyOperation(current, op, context);
if (!result.ok) {
return {
ok: false,
tree: nodes,
problems: result.problems.map((p) => ({ ...p, opIndex: index })),
diff: null,
};
}
current = result.tree;
diffs.push(result.diff);
}
return { ok: true, tree: current, problems: [], diff: diffs };
}
/* ── Shared checks ──────────────────────────────────────────────────────────
Written once because an operation that resolved its own target would be the
place a rule silently differs. */
/** The node an operation names, or a refusal that says which id was not found. */
function target(nodes, id) {
const found = findNode(nodes, id);
if (!found) return { problem: `No UI node with the id \`${id ?? '(none)'}\`.` };
return { node: found };
}
/**
* Whether the type behind this node permits this capability.
*
* Two refusals, deliberately different: a node the registry does not know is an
* unknown component, while a node whose type declines the capability is a
* component that exists and will not do this. Collapsing them would tell a user
* their chart does not exist.
*/
function permits(registry, node, capability) {
const entry = registry.get(node.type);
if (!entry) return `Unsupported UI type: ${node.type}.`;
if (node.locked) return `\`${entry.label}\` is fixed here and cannot be changed.`;
if (!entry.capabilities.includes(capability)) {
return `\`${entry.label}\` cannot be ${PARTICIPLE[capability]}.`;
}
return null;
}
const PARTICIPLE = {
add: 'added to', update: 'changed', remove: 'removed', move: 'moved',
replace: 'replaced', hide: 'hidden', reorder: 'reordered',
};
/** The container an operation places into: the root, or a node that accepts children. */
function container(nodes, parentId, registry) {
if (parentId == null || parentId === '') return { parent: null, children: nodes };
const found = findNode(nodes, parentId);
if (!found) return { problem: `No UI node with the id \`${parentId}\`.` };
const entry = registry.get(found.type);
if (!entry?.container) return { problem: `\`${entry?.label || found.type}\` cannot hold other nodes.` };
return { parent: found, children: found.children || [] };
}
/**
* Where a node's own origin says it may be deleted.
*
* Removing something the application ships is not removal, it is hiding: the
* built-in comes back with the next release, and a patch that claimed to have
* deleted it would silently stop matching. A node a person added is genuinely
* theirs to remove.
*
* Origin, not type — which is what keeps this rule generic.
*/
const isRemovable = (node) => node.origin === 'user';
/** A record reduced to the keys a type declared. Anything else is left behind. */
const keep = (source, allowed) => Object.fromEntries(
Object.entries(source || {}).filter(([key]) => allowed.includes(key))
);
/* ── The operations ─────────────────────────────────────────────────────── */
const BUILDERS = {
/** Put a new node into a container, at an index or at the end. */
add(nodes, op, { registry }) {
const spot = container(nodes, op.parent, registry);
if (spot.problem) return fail(nodes, spot.problem);
if (spot.parent) {
const refusal = permits(registry, spot.parent, 'add');
if (refusal) return fail(nodes, refusal);
}
const node = makeNode({ ...op.node, origin: op.node?.origin || 'user' });
if (!node.id) return fail(nodes, 'A new node needs an `id`.');
if (takenIds(nodes).has(node.id)) {
return fail(nodes, `A node with the id \`${node.id}\` already exists.`);
}
if (!registry.has(node.type)) {
return fail(
nodes,
`Unsupported UI type: ${node.type || '(none)'}. Supported types: ${registry.list().join(', ')}.`
);
}
const at = index(op.index, spot.children.length);
const next = mapChildren(nodes, spot.parent?.id ?? null, (list) => [
...list.slice(0, at), node, ...list.slice(at),
]);
return done(next, {
op: 'add', target: node.id, parent: spot.parent?.id ?? null, index: at,
summary: `Add ${registry.get(node.type).label}`,
});
},
/**
* Change a node's configuration in place.
*
* Props and layout merge rather than replace, so "make it two columns" does
* not clear a title nobody mentioned. A key set to `null` is removed, which is
* how a value gets *unset* — otherwise there would be no way to say "no
* limit" that was distinguishable from not mentioning it.
*
* `data` is different: it replaces, because a binding is one decision and a
* half-merged one would name a source with another source's options.
*/
update(nodes, op, { registry }) {
const found = target(nodes, op.target);
if (found.problem) return fail(nodes, found.problem);
const refusal = permits(registry, found.node, 'update');
if (refusal) return fail(nodes, refusal);
const before = found.node;
const next = mapNode(nodes, before.id, (node) => makeNode({
...node,
props: 'props' in op ? merge(node.props, op.props) : node.props,
layout: 'layout' in op ? merge(node.layout, op.layout) : node.layout,
/* Merged, not replaced, for the same reason layout is: setting a density
must not clear a variant nobody mentioned. `null` still unsets. */
presentation: 'presentation' in op ? merge(node.presentation, op.presentation) : node.presentation,
data: 'data' in op ? op.data : node.data,
}));
return done(next, {
op: 'update', target: before.id,
changed: [
...('props' in op ? Object.keys(op.props || {}) : []),
...('layout' in op ? Object.keys(op.layout || {}) : []),
...('presentation' in op ? Object.keys(op.presentation || {}) : []),
...('data' in op ? ['data'] : []),
],
summary: `Change ${registry.get(before.type)?.label || before.type}`,
});
},
/** Take a node out of the tree. Only where its origin allows it. */
remove(nodes, op, { registry }) {
const found = target(nodes, op.target);
if (found.problem) return fail(nodes, found.problem);
const refusal = permits(registry, found.node, 'remove');
if (refusal) return fail(nodes, refusal);
if (!isRemovable(found.node)) {
return fail(
nodes,
`\`${registry.get(found.node.type)?.label || found.node.type}\` is part of the page and `
+ 'cannot be deleted. Hide it instead.'
);
}
return done(mapNode(nodes, found.node.id, () => null), {
op: 'remove', target: found.node.id,
summary: `Remove ${registry.get(found.node.type)?.label || found.node.type}`,
});
},
/**
* Move a node to another container, or to another position in its own.
*
* Detach then insert, computing the index against the list *after* removal so
* that moving a node down within its own parent lands where a reader expects.
* Getting this wrong is the classic off-by-one that makes "move it to the
* end" stop one short.
*/
move(nodes, op, { registry }) {
const found = target(nodes, op.target);
if (found.problem) return fail(nodes, found.problem);
const refusal = permits(registry, found.node, 'move');
if (refusal) return fail(nodes, refusal);
const parentId = op.parent === undefined ? locate(nodes, found.node.id)?.parent?.id ?? null : op.parent;
/* A container cannot be moved inside itself; the tree would stop being one. */
if (parentId && findNode([found.node], parentId)) {
return fail(nodes, 'A node cannot be moved inside itself.');
}
const spot = container(nodes, parentId, registry);
if (spot.problem) return fail(nodes, spot.problem);
if (spot.parent) {
const parentRefusal = permits(registry, spot.parent, 'add');
if (parentRefusal) return fail(nodes, parentRefusal);
}
const moved = cloneNode(found.node);
const detached = mapNode(nodes, found.node.id, () => null);
const siblings = parentId == null
? detached
: findNode(detached, parentId)?.children || [];
const at = index(op.index, siblings.length);
const next = mapChildren(detached, parentId, (list) => [
...list.slice(0, at), moved, ...list.slice(at),
]);
return done(next, {
op: 'move', target: moved.id, parent: parentId, index: at,
summary: `Move ${registry.get(moved.type)?.label || moved.type}`,
});
},
/**
* Turn a node into another type, in place.
*
* The id, position and binding are kept — which is what makes "show this as a
* table" mean *this* reading as a table, rather than a new empty table where
* a chart used to be. Props are dropped unless the operation supplies new
* ones, because props belong to a type and carrying a chart's variant onto a
* table is how an invalid prop arrives without anyone writing one.
*/
replace(nodes, op, { registry }) {
const found = target(nodes, op.target);
if (found.problem) return fail(nodes, found.problem);
const refusal = permits(registry, found.node, 'replace');
if (refusal) return fail(nodes, refusal);
const type = String(op.type ?? '').trim();
const entry = registry.get(type);
if (!entry) {
return fail(
nodes,
`Unsupported UI type: ${type || '(none)'}. Supported types: ${registry.list().join(', ')}.`
);
}
const before = found.node;
if (before.children?.length && !entry.container) {
return fail(nodes, `\`${entry.label}\` cannot hold the nodes already inside \`${before.id}\`.`);
}
/**
* Can the new type draw what this node is already reading?
*
* Asked here, and refused by name, rather than left to the tree validator.
* Both would stop it, but only one can say *why*: "a Table cannot draw
* Candidate activity" is a sentence a person can act on, and "this node's
* binding is incoherent" is not. The binding itself is never touched — a
* replacement that cannot read what the node reads is refused, never
* repointed at a source that happens to fit.
*/
const binding = 'data' in op ? op.data : before.data;
if (binding?.source && entry.dataShapes.length) {
const drawable = entry.dataShapes.some((shape) => sourceSupportsShape(binding.source, shape));
if (!drawable) {
const label = dataSourceFor(binding.source)?.label || binding.source;
return fail(nodes, `\`${entry.label}\` cannot show ${label}.`);
}
}
if (!binding?.source && entry.dataRequired) {
return fail(nodes, `\`${entry.label}\` needs a reading, and \`${before.id}\` has none.`);
}
const next = mapNode(nodes, before.id, (node) => makeNode({
...node,
type,
/**
* What survives is what the new type has said it understands.
*
* Props were dropped wholesale, which is safe and also loses a title the
* person typed even when the new type has the very same field. Carried
* per key against the new type's own `propSchema` instead: a property both
* types declare is theirs to keep, one that belonged to the old type
* alone goes. Presentation is filtered the same way, so replacing into a
* type that cannot draw `emphasis` quietly drops it rather than failing
* the whole operation over a value nobody asked to keep.
*
* Id, layout, visibility, origin and children pass through untouched —
* the node is the same node, drawn differently.
*/
props: keep('props' in op ? merge({}, op.props) : node.props, Object.keys(entry.propSchema)),
presentation: {
...(entry.variants.includes(node.presentation?.variant) ? { variant: node.presentation.variant } : {}),
...(entry.densities.includes(node.presentation?.density) ? { density: node.presentation.density } : {}),
},
data: binding,
}));
return done(next, {
op: 'replace', target: before.id, from: before.type, to: type,
summary: `Change ${registry.get(before.type)?.label || before.type} to ${entry.label}`,
});
},
/**
* Hide or show a node.
*
* Its own operation rather than an `update` of a flag, because it is its own
* capability: a component may reasonably be hideable and not otherwise
* editable, and a page's header is the opposite. Idempotent — hiding what is
* already hidden is not an error, it is the state the user asked for.
*/
hide(nodes, op, { registry }) {
const found = target(nodes, op.target);
if (found.problem) return fail(nodes, found.problem);
const refusal = permits(registry, found.node, 'hide');
if (refusal) return fail(nodes, refusal);
const hidden = op.hidden !== false;
const next = mapNode(nodes, found.node.id, (node) => ({ ...node, hidden }));
return done(next, {
op: 'hide', target: found.node.id, hidden,
summary: `${hidden ? 'Hide' : 'Show'} ${registry.get(found.node.type)?.label || found.node.type}`,
});
},
/**
* Rearrange one container's children.
*
* Takes the ids in their new order. A partial list is honoured — the named
* nodes take the order given, and anything unnamed keeps its relative
* position after them — so "put the funnel first" does not require restating
* the whole page.
*/
reorder(nodes, op, { registry }) {
const spot = container(nodes, op.parent, registry);
if (spot.problem) return fail(nodes, spot.problem);
if (spot.parent) {
const refusal = permits(registry, spot.parent, 'reorder');
if (refusal) return fail(nodes, refusal);
}
const order = Array.isArray(op.order) ? op.order.map((id) => String(id).trim()) : null;
if (!order?.length) return fail(nodes, '`reorder` needs an `order` of node ids.');
const present = new Set(spot.children.map((node) => node.id));
const stranger = order.find((id) => !present.has(id));
if (stranger) {
return fail(nodes, `\`${stranger}\` is not inside \`${op.parent ?? 'the page'}\`.`);
}
if (new Set(order).size !== order.length) {
return fail(nodes, '`order` names the same node twice.');
}
const next = mapChildren(nodes, spot.parent?.id ?? null, (list) => {
const named = order.map((id) => list.find((node) => node.id === id));
const rest = list.filter((node) => !order.includes(node.id));
return [...named, ...rest];
});
return done(next, {
op: 'reorder', parent: spot.parent?.id ?? null, order,
summary: `Reorder ${spot.parent ? registry.get(spot.parent.type)?.label || spot.parent.type : 'the page'}`,
});
},
};
/** An insertion point, clamped. Absent means the end, which is what "add" means. */
function index(value, length) {
if (value == null) return length;
const n = Number(value);
if (!Number.isFinite(n)) return length;
return Math.max(0, Math.min(length, Math.round(n)));
}
/** Shallow merge where an explicit `null` deletes the key. */
function merge(base, patch) {
const next = { ...(base || {}) };
for (const [key, value] of Object.entries(patch || {})) {
if (value === null) delete next[key];
else next[key] = value;
}
return next;
}

203
src/lib/ui/patch.js Normal file
View File

@@ -0,0 +1,203 @@
/**
* A user's UI changes, as an ordered list of operations.
*
* **Operations are stored; trees are not.** That is the decision this module
* exists to hold, and it is what makes a saved layout survive the application
* changing underneath it. A stored tree is a photograph: the day a release adds
* a section to a page, every saved photograph is missing it, and the user's
* page silently stops receiving product improvements. A stored operation is an
* instruction — "hide `cc-activity`", "put `funnel` first" — which still means
* what it said after the built-ins around it move.
*
* It also gives undo and rollback for free. Applying is `push`, undoing is
* `pop`, and resetting is the empty list. There is no inverse-operation
* machinery to get wrong, because the base tree is recomputed rather than
* mutated.
*
* The stored shape is **normalized structured configuration** — never JSX,
* never a component, never Markdown. What is written here is what
* `operations.js` already validated.
*/
import { applyOperations, OPERATIONS } from './operations';
import { nodeRegistry } from './registry';
/**
* The stored format's version.
*
* Read on load and written on save, so a future change to the operation
* vocabulary can migrate rather than misread. A patch whose version this build
* does not know is dropped with a reason rather than half-applied — a partly
* understood layout is worse than the default one.
*/
export const PATCH_SCHEMA = 1;
/** An empty patch for a page. The shape a page gets when nobody has changed it. */
export const emptyPatch = (page) => ({
schema: PATCH_SCHEMA,
page: String(page ?? '').trim(),
ops: [],
updatedAt: null,
});
/**
* Only the fields an operation is allowed to carry, per operation.
*
* A whitelist rather than a pass-through, because this is the boundary where
* stored data becomes engine input. Anything else a client wrote — or anything
* that arrived in `user_preferences.extra` from an older build or another tab —
* is dropped before it reaches the engine.
*/
const OP_FIELDS = {
add: ['parent', 'index', 'node'],
update: ['target', 'props', 'layout', 'presentation', 'data'],
remove: ['target'],
move: ['target', 'parent', 'index'],
replace: ['target', 'type', 'props', 'data'],
hide: ['target', 'hidden'],
reorder: ['parent', 'order'],
};
/**
* One stored operation, reduced to what the engine reads.
*
* Returns null for anything unrecognised. The caller reports how many were
* dropped rather than failing the whole patch: one unreadable operation from a
* newer build should not cost a user the other nine they made.
*/
export function normalizeOp(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const op = String(raw.op ?? '').trim();
if (!OPERATIONS.includes(op)) return null;
const next = { op };
for (const field of OP_FIELDS[op]) {
if (raw[field] === undefined) continue;
next[field] = raw[field];
}
return next;
}
/** A stored patch, checked and reduced. `dropped` says what was not understood. */
export function normalizePatch(raw, page) {
const base = emptyPatch(page);
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { patch: base, dropped: 0 };
if (Number(raw.schema) !== PATCH_SCHEMA) {
/* Unknown version: keep nothing rather than guess. The page renders as the
application ships it, which is a defensible state; a half-read layout is
not. */
return { patch: base, dropped: Array.isArray(raw.ops) ? raw.ops.length : 0 };
}
const ops = [];
let dropped = 0;
for (const candidate of Array.isArray(raw.ops) ? raw.ops : []) {
const op = normalizeOp(candidate);
if (op) ops.push(op);
else dropped += 1;
}
return {
patch: {
schema: PATCH_SCHEMA,
page: base.page,
ops,
updatedAt: raw.updatedAt || null,
},
dropped,
};
}
/** The whole store: `{ [page]: patch }`, as it sits in preferences. */
export function normalizeLayouts(raw) {
const layouts = {};
let dropped = 0;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { layouts, dropped };
for (const [page, value] of Object.entries(raw)) {
const key = String(page ?? '').trim();
if (!key) continue;
const result = normalizePatch(value, key);
dropped += result.dropped;
/* An empty patch is not stored. A key mapping to no operations is the same
state as no key, and keeping it would grow the preferences blob with a
record of pages a user once looked at. */
if (result.patch.ops.length) layouts[key] = result.patch;
}
return { layouts, dropped };
}
/**
* Add an operation to a patch.
*
* Appends rather than folding into what is there. Two updates to the same node
* stay two operations: replaying them costs nothing, and collapsing them would
* mean undo skipped a step the user remembers making.
*/
export const pushOp = (patch, op) => ({
...patch,
ops: [...patch.ops, op],
updatedAt: new Date().toISOString(),
});
/** Undo: drop the last operation. The tree is recomputed, never reversed. */
export const popOp = (patch) => ({
...patch,
ops: patch.ops.slice(0, -1),
updatedAt: new Date().toISOString(),
});
/** Reset: back to what the application ships. */
export const clearOps = (patch) => ({ ...patch, ops: [], updatedAt: new Date().toISOString() });
/**
* The whole layout store with one page's patch set, cleared, or replaced.
*
* Pure, and separate from the hook that calls it, because this is where a
* mistake would be expensive and invisible: the preferences endpoint
* shallow-merges its top-level keys, so writing `uiLayouts` replaces the entire
* map. Sending one page's entry would silently delete every other page the
* person had customised, and they would only find out by visiting one.
*
* A patch with no operations removes its key rather than storing an empty one:
* that is the same state as never having customised the page.
*/
export function mergeLayouts(layouts, page, patch) {
const key = String(page ?? '').trim();
const next = { ...(layouts || {}) };
if (!key) return next;
if (patch?.ops?.length) next[key] = { ...patch, schema: PATCH_SCHEMA, page: key };
else delete next[key];
return next;
}
/**
* The tree a patch produces from a base.
*
* **Operations that no longer apply are skipped, not fatal.** A release that
* removes a section leaves any patch that mentioned it naming a node that is
* not there — which is not the user's mistake and must not cost them the rest
* of their layout. `skipped` reports them so a surface can say so quietly.
*
* This is the one place tolerance is right. Everywhere else — composing a new
* change, saving one — a refusal is correct, because there is a person present
* to be told.
*/
export function applyPatch(base, patch, { registry = nodeRegistry, role = null } = {}) {
const ops = patch?.ops || [];
let tree = base;
const skipped = [];
for (const [index, op] of ops.entries()) {
const result = applyOperations(tree, [op], { registry, role });
if (result.ok) {
tree = result.tree;
continue;
}
skipped.push({ index, op, problems: result.problems });
}
return { tree, skipped };
}

377
src/lib/ui/registry.js Normal file
View File

@@ -0,0 +1,377 @@
/**
* The node type registry — the only extension point in the UI system.
*
* A type is a name, a component the application already ships, and the metadata
* that says what may be done to it. Adding a UI type is one `register` call:
* the mutation engine, the validator, the renderer and the agent's conversation
* are untouched, because none of them contains a branch on a type. They ask
* this table instead.
*
* That is the whole reason this file exists. The alternative — an engine that
* knows `card` from `chart` — puts every future component into the engine, and
* the engine then has to be edited to add a UI type, which is the thing the
* design is meant to prevent.
*
* **The registry never receives a component name as a string.** A registration
* hands over a component *reference*, resolved by the module graph at build
* time. There is no dynamic import here and no lookup from configuration to
* code — configuration only ever names a key that is already in this table.
*/
import {
DENSITY_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_CAPABILITIES, NODE_ID_PATTERN, VARIANT_VALUES,
} from './node';
/**
* A registration, with every field settled.
*
* Defaults are deliberately conservative: a type that says nothing about its
* capabilities gets the read-only set, because a component whose author has not
* thought about being moved is one that should not be moved yet. Opting in is
* one word; opting out after a bug is a migration.
*/
const DEFAULT_CAPABILITIES = ['update', 'remove', 'move', 'replace', 'hide'];
/** Containers can also be added to and reordered — that is what makes them containers. */
const CONTAINER_CAPABILITIES = [...DEFAULT_CAPABILITIES, 'add', 'reorder'];
export class NodeTypeRegistry {
constructor() {
/** @type {Map<string, any>} */
this.types = new Map();
}
/**
* Declare a node type.
*
* Refuses at registration rather than at render. A registry that accepted a
* malformed type would fail later, inside a component, with a stack that
* names React rather than the registration that caused it — and by then the
* validator has already told a user their change was fine.
*/
register(definition) {
const type = String(definition?.type ?? '').trim();
if (!type) throw new Error('registerNodeType: a type needs a `type`.');
if (!NODE_ID_PATTERN.test(type)) {
throw new Error(`registerNodeType: \`${type}\` must be lower-case letters, numbers and dashes.`);
}
if (this.types.has(type)) {
throw new Error(`registerNodeType: \`${type}\` is already registered.`);
}
if (typeof definition.component !== 'function' && typeof definition.component !== 'object') {
throw new Error(`registerNodeType: \`${type}\` needs a \`component\`.`);
}
const container = definition.container === true;
const declared = Array.isArray(definition.capabilities) ? definition.capabilities : null;
const capabilities = declared || (container ? CONTAINER_CAPABILITIES : DEFAULT_CAPABILITIES);
const unknown = capabilities.find((c) => !NODE_CAPABILITIES.includes(c));
if (unknown) {
throw new Error(
`registerNodeType: \`${type}\` declares unknown capability \`${unknown}\`. `
+ `Known: ${NODE_CAPABILITIES.join(', ')}.`
);
}
/* A registration may choose from the vocabulary; it may not invent one.
Caught at boot, where the author can see it, rather than at validation
where it would look like the *user's* value was wrong. */
const refuseUnknown = (word, declared, allowed) => {
const bad = (declared || []).find((value) => !allowed.includes(value));
if (bad) {
throw new Error(
`registerNodeType: \`${type}\` declares unknown ${word} \`${bad}\`. `
+ `Known: ${allowed.join(', ')}.`
);
}
};
refuseUnknown('variant', definition.variants, VARIANT_VALUES);
refuseUnknown('density', definition.densities, DENSITY_VALUES);
/* 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
than discovered in the editor. */
if (definition.addable === true && !capabilities.includes('remove')) {
throw new Error(
`registerNodeType: \`${type}\` is declared addable but cannot be removed. `
+ 'Anything a person can add, they must be able to remove.'
);
}
/* A non-container claiming `add` or `reorder` is a registration that will
never do what its author expects: there is nowhere to put a child. */
if (!container) {
const childOnly = capabilities.find((c) => c === 'add' || c === 'reorder');
if (childOnly) {
throw new Error(
`registerNodeType: \`${type}\` declares \`${childOnly}\` but is not a container.`
);
}
}
const entry = Object.freeze({
type,
label: String(definition.label ?? type).trim(),
summary: String(definition.summary ?? '').trim(),
component: definition.component,
container,
capabilities: Object.freeze([...capabilities]),
/* `['*']` accepts anything, which is what a page-level container wants.
An empty list on a container accepts nothing, which is a leaf that
merely renders its own children — legitimate, and worth being able to
say. */
accepts: Object.freeze([...(definition.accepts || (container ? ['*'] : []))]),
propSchema: Object.freeze({ ...(definition.propSchema || {}) }),
/* Which readings this type can draw. Empty means the type takes no data
at all — a layout container, a divider — and validation then refuses a
binding rather than resolving one nothing will read. */
dataShapes: Object.freeze([...(definition.dataShapes || [])]),
dataRequired: definition.dataRequired === true,
constraints: Object.freeze({
minColumns: MIN_COLUMNS,
maxColumns: MAX_COLUMNS,
...(definition.constraints || {}),
}),
/**
* Whether the renderer supplies identity through a wrapper element.
*
* Most components render a root they control and can spread the node's
* attributes onto it. Some — the shared design-system primitives, which
* destructure their props explicitly — cannot, and for those the renderer
* puts a bare `div` around the component so the node is still addressable
* in the DOM.
*
* Declared per type rather than detected, because there is no way to ask
* a React component whether it forwards unknown props, and guessing wrong
* either loses the identity or adds an element nobody asked for.
*/
wrap: definition.wrap === true,
/* `null` means every role. A list narrows it, and is checked against the
caller's role at validation — the same three roles the API enforces. */
roles: definition.roles ? Object.freeze([...definition.roles]) : null,
/**
* The presentation values this type can actually draw.
*
* Empty means the type supports none, and that is the default on purpose:
* a presentation value is a promise that the component renders something
* different, and a type that has not made that promise must refuse it
* rather than store a setting nobody honours. Opting in is one list; the
* cost of the other default would be a person setting "compact" on a
* section that stays exactly as it was.
*
* Each value is also checked against the closed vocabulary in `node.js`,
* so a registration cannot widen what the product can say — only choose
* from it.
*/
variants: Object.freeze([...(definition.variants || [])]),
densities: Object.freeze([...(definition.densities || [])]),
/**
* The page this type belongs to, or `null` for one that belongs anywhere.
*
* The registry is global — one table, so a page can be composed before
* anything about it is known here — but most types are not. A page's own
* sections read that page's published render context: Hired History's
* chronology destructures `hires` and `filtered`, and on any other page
* those are simply absent. Offered there and added, it threw on
* `undefined.length` and took the whole application down with it.
*
* So a registration says where it belongs, and the engine only ever
* compares this string to the page being composed. It still knows no page
* names — `null` here is "anywhere", which is what the nine reading types
* and any future generic component declare by saying nothing.
*/
page: definition.page ? String(definition.page).trim() : null,
/**
* What one *instance* of this type should be called.
*
* Optional. Most types are named well enough by their label — there is
* one Hiring funnel on Analytics. Types a page mounts more than once are
* not: every skill slot is a "Skill sections", and two of them in an
* outline are two identical rows nobody can tell apart or name out loud.
* The type answers for its own instances; the engine only calls it.
*/
describe: typeof definition.describe === 'function' ? definition.describe : null,
/**
* Whether a person may add one of these.
*
* Two things have to be true, and the second is derived rather than
* declared: **anything a person can add, they must be able to remove.**
*
* The editor offered a page's own sections, which declare `move` and
* `hide` and not `remove` — so one could be added and then never deleted.
* The placeholder for a failed node said it "can be hidden or removed"
* and the Remove button was not there, because the inspector asks the
* type while the engine asks the node's origin. Deriving it here settles
* the disagreement in the one place both of them read.
*
* What is left addable is what a person actually adds: the readings. A
* page's own sections are composed by the page and a skill surface is an
* anchor the page owns — both are moved, hidden and reordered, never
* conjured up by a picker.
*/
addable: definition.addable !== false && capabilities.includes('remove'),
});
this.types.set(type, entry);
return entry;
}
/** The registration, or null. Every consumer goes through this. */
get(type) {
return this.types.get(String(type ?? '').trim()) || null;
}
has(type) {
return this.types.has(String(type ?? '').trim());
}
/** Every registered type name, in registration order. */
list() {
return [...this.types.keys()];
}
/** Every registration, for a picker that has to describe what it is offering. */
all() {
return [...this.types.values()];
}
/**
* Can this node type do this?
*
* The single question every operation asks. A type that is absent can do
* nothing — an unregistered type is not a permissive default, it is an
* unknown component, and the engine refuses it.
*/
allows(type, capability) {
const entry = this.get(type);
return Boolean(entry && entry.capabilities.includes(capability));
}
/**
* May a node of `childType` sit inside `parentType`?
*
* A null parent is the page root, which accepts anything registered — the
* page's own composition decides what is actually there, and refusing at the
* root would mean the root needed a registration of its own.
*/
acceptsChild(parentType, childType) {
if (!this.has(childType)) return false;
if (parentType == null) return true;
const parent = this.get(parentType);
if (!parent || !parent.container) return false;
return parent.accepts.includes('*') || parent.accepts.includes(childType);
}
/**
* May a person put one of these on this page, here?
*
* `acceptsChild` answers the structural half — does this container hold that
* kind of thing. This answers the rest, and the rest is what was missing:
* whether the type is one a person may add at all, and whether it belongs to
* the page they are standing on.
*
* Both halves have to hold. Without the first, the picker offered the slot it
* renders into; without the second, Candidates Analysis offered every private
* section of every other page in the application — forty-three types on a
* page that has seven — and adding one crashed the app.
*/
offersChild(parentType, childType, { page = null } = {}) {
if (!this.acceptsChild(parentType, childType)) return false;
const child = this.get(childType);
if (!child.addable) return false;
/* A type that names no page belongs anywhere. A page that is not named
cannot vouch for anything page-bound, so it is offered only the generic
types — which is the safe reading, not a permissive one. */
return child.page === null || child.page === page;
}
/**
* The types a node could be turned into, given what it is bound to.
*
* Derived rather than declared, so "change this chart to a table" is answered
* by the same compatibility rule that refuses an impossible section — a type
* is a candidate when it can draw at least one shape the current binding
* supports. A node with no binding may become any type that needs no data.
*
* `shapes` is passed in rather than looked up because the shape vocabulary
* belongs to `lib/skills/surfaces.js`, and this module deliberately does not
* import the product's data vocabulary: the registry is about components, and
* coupling it to data sources would make a UI type impossible to register
* without one.
*/
replacements(type, { shapes = null, page = null } = {}) {
const current = this.get(type);
if (!current) return [];
/* What the candidate has to be able to draw: the binding's shapes when a
binding was named, otherwise whatever this type itself draws. */
const wanted = shapes || current.dataShapes;
return this.all()
.filter((entry) => entry.type !== type)
/**
* The same scope that governs adding.
*
* Offering a replacement is offering to put that type on this page, so it
* answers to the same two rules: a type bound to another page belongs to
* that page, and a type nobody may add is not a thing to turn something
* into. This was safe only by accident — every page-bound section happens
* to declare no shapes, so the shape filter below excluded them — and an
* accident is not a rule. A section that declared one would have appeared
* in every page's "Show as" list.
*/
.filter((entry) => this.offersChild(null, entry.type, { page }))
.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
))
.map((entry) => entry.type);
}
/** Forget everything. Tests only — a fresh registry per case beats shared state. */
reset() {
this.types.clear();
}
}
/**
* The registry the application uses.
*
* A module singleton because the type table is the application's, not a
* component's, and threading it through every call site would be ceremony. Every
* function that reads it takes an override, so a test can register a throwaway
* type without touching this one.
*/
export const nodeRegistry = new NodeTypeRegistry();
/**
* Declare a node type on the application registry.
*
* Re-declaring **replaces**, which `register` itself refuses to do. The
* difference is deliberate and the two are for different callers:
*
* - `nodeRegistry.register` is strict, because a registry that quietly
* accepted two definitions of one type would render whichever module
* happened to load second.
* - This is what a module-scope registration wants. Types are declared as a
* side effect of importing the module that owns them, and the dev server
* re-runs that module every time the file is saved. Strictness there would
* throw on every edit, and keeping the first registration instead would
* leave the page drawing the component as it was before the edit — which is
* worse, because it looks like the change did not work.
*
* The cost is that two modules claiming one type name silently agree on the
* last one loaded. That is a real risk and the reason type names are prefixed
* with the surface that owns them.
*/
export const registerNodeType = (definition) => {
nodeRegistry.types.delete(String(definition?.type ?? '').trim());
return nodeRegistry.register(definition);
};

83
src/lib/ui/skillNodes.js Normal file
View File

@@ -0,0 +1,83 @@
/**
* A skill's declared section, as a node in the page tree.
*
* This is the join between the two halves of the UI system. A Board skill's
* `ui:` block is still parsed by `uiConfig.normalizeSection`, still validated
* against the closed vocabulary, and still drawn by the same nine components —
* nothing about definitions changes. What this adds is *identity*: the section
* becomes an addressable node, so it can be hidden, moved and reordered by the
* same operations that move a built-in.
*
* **Nothing here writes back to Markdown.** A node carries `origin: 'skill'`,
* and a change to it is stored as an operation in the person's own layout
* patch. The definition on disk, and the definition in the account's custom
* skills, are read-only from here — which is what keeps a layout preference
* from silently editing something another user also sees.
*/
import { dataSourceFor, sourceSupportsOption } from '@/lib/skills/surfaces';
import { makeNode } from './node';
/** Ids are `skill-<skill>-<section>`, so provenance is legible in the DOM. */
export const skillNodeId = (skillId, sectionId) => [
'skill', slug(skillId), slug(sectionId),
].filter(Boolean).join('-');
const slug = (value) => String(value || '')
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
/**
* One normalized section, adapted.
*
* Only options the source actually declares are carried across. The skill
* format checks that a period is a real period; this checks that the reading
* takes periods at all — a stricter rule, and one a definition written against
* the looser one could fail. Dropping the option is right where refusing the
* node would not be: a Board card must never disappear because of a parameter
* that was doing nothing anyway.
*/
export function skillSectionNode(skill, section) {
const params = {};
if (section.periods?.length && sourceSupportsOption(section.source, 'periods')) {
params.periods = [...section.periods];
}
if (section.limit && sourceSupportsOption(section.source, 'limit')) {
params.limit = section.limit;
}
return makeNode({
id: skillNodeId(skill.id, section.id),
type: section.type,
origin: 'skill',
data: { source: section.source, params },
props: {
/* The same fallback the surface uses, so a section with no title of its
own is still named by the skill that contributed it. */
title: section.title || skill.name,
...(section.description ? { description: section.description } : {}),
/* Attribution is what lets a reader tell an extension from a built-in
panel, and which skill to switch off. */
attribution: skill.name,
...(section.editable ? { editable: true } : {}),
},
});
}
/**
* Every section a page's definitions contribute, grouped by placement.
*
* Grouped because that is how the composition consumes them: each
* `skill-surface` node names one placement and takes the sections that declared
* it. A placement no slot offers simply has nowhere to render, which is the
* same outcome as today.
*/
export function skillNodesByPlacement(sections = []) {
const out = {};
for (const { skill, section } of sections) {
if (!dataSourceFor(section.source)) continue;
const key = section.placement || '';
if (!out[key]) out[key] = [];
out[key].push(skillSectionNode(skill, section));
}
return out;
}

393
src/lib/ui/validate.js Normal file
View File

@@ -0,0 +1,393 @@
/**
* What a UI tree has to satisfy before anything is applied or saved.
*
* This is the gate the plan runs through, and it is deliberately the *only*
* place that decides whether a change is allowed. Operations build a candidate
* tree; this says whether the product can honour it. Keeping the two apart is
* what makes a preview trustworthy: the tree a user is shown is the tree that
* passed, not a tree that will be checked again differently on the way to
* storage.
*
* Every refusal names what it refused and why. A validator that returns a
* boolean pushes the explaining into whichever caller happens to be nearest,
* and the caller does not know which rule fired.
*
* The rules split in two:
*
* - **Structure**, which this module owns: ids, types, props, layout,
* containment, capability, role.
* - **Data**, which it borrows from `lib/skills/surfaces.js` — the same
* closed source vocabulary and the same source/shape compatibility rule
* that `uiConfig.normalizeSection` already refuses on. Borrowed rather than
* restated: two readings of what a source can draw is how a form composes
* what the normalizer rejects.
*/
import {
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, dataSourceFor, sourceSupportsOption,
sourceSupportsShape,
} from '@/lib/skills/surfaces';
import {
ALIGN_VALUES, DENSITY_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_ID_PATTERN,
PRESENTATION_KEYS, SPACING_VALUES, VARIANT_VALUES, walk,
} from './node';
import { nodeRegistry } from './registry';
/** One refusal. `at` is the node it concerns, so an editor can point at it. */
const problem = (at, message) => ({ at: at || null, message });
/**
* Check one node in isolation, given where it sits.
*
* `parentType` is null at the root. Returns a list of problems, empty when the
* node is fine — never throws, because a tree with three bad nodes should
* report three, not the first.
*/
export function validateNode(node, {
parentType = null, registry = nodeRegistry, role = null,
} = {}) {
const problems = [];
const at = node?.id || null;
if (!node || typeof node !== 'object') {
return [problem(null, 'A node must be a mapping of options.')];
}
/* ── Identity ─────────────────────────────────────────────────────────── */
if (!node.id) {
problems.push(problem(null, 'A node needs an `id`.'));
} else if (!NODE_ID_PATTERN.test(node.id)) {
problems.push(problem(at, `\`${node.id}\`: an id must be lower-case letters, numbers and dashes.`));
}
/* ── Type ─────────────────────────────────────────────────────────────── */
if (!node.type) {
problems.push(problem(at, 'A node needs a `type`.'));
return problems;
}
const entry = registry.get(node.type);
if (!entry) {
/* The hallucinated-component gate. A type the application does not ship
cannot be rendered, and naming the supported set is what turns a refusal
into something the asker can act on. */
problems.push(problem(
at,
`Unsupported UI type: ${node.type}. Supported types: ${registry.list().join(', ') || 'none registered'}.`
));
return problems;
}
/* ── Containment ──────────────────────────────────────────────────────── */
if (!registry.acceptsChild(parentType, node.type)) {
problems.push(problem(
at,
`\`${node.type}\` cannot sit inside \`${parentType}\`.`
));
}
if (node.children?.length && !entry.container) {
problems.push(problem(at, `\`${node.type}\` cannot hold other nodes.`));
}
const { minChildren, maxChildren } = entry.constraints;
const count = node.children?.length || 0;
if (Number.isFinite(minChildren) && count < minChildren) {
problems.push(problem(at, `\`${node.type}\` needs at least ${minChildren} node(s); it has ${count}.`));
}
if (Number.isFinite(maxChildren) && count > maxChildren) {
problems.push(problem(at, `\`${node.type}\` holds at most ${maxChildren} node(s); it has ${count}.`));
}
/* ── Permission ───────────────────────────────────────────────────────── */
if (entry.roles && role && !entry.roles.includes(role)) {
/* Named without describing the component, because the refusal is about the
caller and not about what they are missing. */
problems.push(problem(at, `You do not have access to \`${entry.label}\`.`));
}
problems.push(...validateProps(node, entry));
problems.push(...validateLayout(node, entry));
problems.push(...validatePresentation(node, entry));
problems.push(...validateBinding(node, entry));
return problems;
}
/**
* Props, against the schema the type declared.
*
* Unknown keys are refused rather than dropped. Dropping is right when reading a
* definition an author wrote by hand — `uiConfig` does exactly that — but this
* path is a *mutation*, and a silently discarded prop is a change the user
* asked for, was told had been applied, and then did not happen.
*/
function validateProps(node, entry) {
const problems = [];
const schema = entry.propSchema || {};
const at = node.id;
for (const [key, value] of Object.entries(node.props || {})) {
const rule = schema[key];
if (!rule) {
const known = Object.keys(schema);
problems.push(problem(
at,
`\`${entry.type}\` has no property \`${key}\`.`
+ (known.length ? ` It accepts: ${known.join(', ')}.` : '')
));
continue;
}
problems.push(...checkValue(at, entry.type, key, value, rule));
}
for (const [key, rule] of Object.entries(schema)) {
if (rule?.required && node.props?.[key] == null) {
problems.push(problem(at, `\`${entry.type}\` needs \`${key}\`.`));
}
}
return problems;
}
/** One prop against one rule. Kept separate so the rule vocabulary has one reader. */
function checkValue(at, type, key, value, rule) {
const problems = [];
if (Array.isArray(rule.enum)) {
if (!rule.enum.includes(value)) {
problems.push(problem(
at,
`\`${type}.${key}\` must be one of: ${rule.enum.join(', ')}. Got \`${value}\`.`
));
}
return problems;
}
const kind = rule.type || 'string';
const actual = Array.isArray(value) ? 'array' : typeof value;
if (kind === 'number') {
if (!Number.isFinite(Number(value))) {
problems.push(problem(at, `\`${type}.${key}\` must be a number. Got \`${value}\`.`));
return problems;
}
const n = Number(value);
if (Number.isFinite(rule.min) && n < rule.min) {
problems.push(problem(at, `\`${type}.${key}\` must be at least ${rule.min}.`));
}
if (Number.isFinite(rule.max) && n > rule.max) {
problems.push(problem(at, `\`${type}.${key}\` must be at most ${rule.max}.`));
}
return problems;
}
if (kind !== actual) {
problems.push(problem(at, `\`${type}.${key}\` must be a ${kind}. Got ${actual}.`));
}
return problems;
}
/**
* Layout, bounded globally and then by the type.
*
* The responsive gate. A column count is the one layout value that can make a
* page unusable on a phone rather than merely ugly, so it is bounded twice —
* once by the grid the application can express at all, and once by what this
* particular component stays readable in.
*/
function validateLayout(node, entry) {
const problems = [];
const at = node.id;
const layout = node.layout || {};
const { minColumns, maxColumns } = entry.constraints;
for (const key of ['columns', 'span']) {
if (layout[key] == null) continue;
const n = Number(layout[key]);
if (!Number.isInteger(n)) {
problems.push(problem(at, `\`${key}\` must be a whole number. Got \`${layout[key]}\`.`));
continue;
}
if (n < MIN_COLUMNS || n > MAX_COLUMNS) {
problems.push(problem(at, `\`${key}\` must be between ${MIN_COLUMNS} and ${MAX_COLUMNS}.`));
continue;
}
if (key === 'columns' && (n < minColumns || n > maxColumns)) {
problems.push(problem(
at,
`\`${entry.type}\` supports ${minColumns}–${maxColumns} columns. Got ${n}.`
));
}
}
if (layout.gap != null && !GAP_VALUES.includes(layout.gap)) {
problems.push(problem(at, `\`gap\` must be one of: ${GAP_VALUES.join(', ')}.`));
}
if (layout.align != null && !ALIGN_VALUES.includes(layout.align)) {
problems.push(problem(at, `\`align\` must be one of: ${ALIGN_VALUES.join(', ')}.`));
}
for (const key of ['spacingBefore', 'spacingAfter']) {
if (layout[key] != null && !SPACING_VALUES.includes(layout[key])) {
problems.push(problem(at, `\`${key}\` must be one of: ${SPACING_VALUES.join(', ')}.`));
}
}
return problems;
}
/**
* Presentation, checked twice.
*
* Once against the product's vocabulary — a value that is not a value cannot be
* stored — and once against what *this type* declared it can draw. The second
* check is the one that matters: a setting a component ignores is a change a
* person made and cannot see, which is worse than being told no.
*
* The refusal names what is available, because the person is choosing from a
* closed set and the set is short enough to say out loud.
*/
function validatePresentation(node, entry) {
const problems = [];
const at = node.id;
const presentation = node.presentation || {};
for (const key of Object.keys(presentation)) {
if (!PRESENTATION_KEYS.includes(key)) {
problems.push(problem(at, `\`${key}\` is not a presentation setting.`));
}
}
for (const [key, allowed, supported] of [
['variant', VARIANT_VALUES, entry.variants],
['density', DENSITY_VALUES, entry.densities],
]) {
const value = presentation[key];
if (value == null) continue;
if (!allowed.includes(value)) {
problems.push(problem(at, `\`${key}\` must be one of: ${allowed.join(', ')}.`));
continue;
}
if (!supported.includes(value)) {
problems.push(problem(
at,
supported.length
? `\`${entry.type}\` supports ${key}: ${supported.join(', ')}. Got \`${value}\`.`
: `\`${entry.type}\` has no ${key} to set.`
));
}
}
return problems;
}
/**
* The data binding — the gate that stops invented figures.
*
* A node cannot carry data; it can only name a reading the application already
* offers. So a fabricated metric has nowhere to live: the resolver either
* returns real rows for a real source or the section draws its empty note. The
* three checks are that the source exists, that this component can draw it, and
* that any options named are ones the source actually reads.
*/
function validateBinding(node, entry) {
const problems = [];
const at = node.id;
const binding = node.data;
if (!binding) {
if (entry.dataRequired) {
problems.push(problem(at, `\`${entry.type}\` needs a data source.`));
}
return problems;
}
if (!entry.dataShapes.length) {
problems.push(problem(at, `\`${entry.type}\` does not read data, so it cannot take a source.`));
return problems;
}
if (!SUPPORTED_DATA_SOURCES.includes(binding.source)) {
problems.push(problem(
at,
`Unsupported data source: ${binding.source}. `
+ `Supported sources: ${SUPPORTED_DATA_SOURCES.join(', ')}.`
));
return problems;
}
/* A component draws one or more shapes; a source can fill some of them. The
node is only coherent where the two overlap. */
const drawable = entry.dataShapes.filter((shape) => sourceSupportsShape(binding.source, shape));
if (!drawable.length) {
const source = dataSourceFor(binding.source);
problems.push(problem(
at,
`\`${binding.source}\` cannot be shown as \`${entry.type}\`. `
+ `It supports: ${(source?.shapes || []).join(', ')}.`
));
}
const params = binding.params || {};
if (params.periods != null) {
if (!Array.isArray(params.periods)) {
problems.push(problem(at, '`periods` must be a list.'));
} else {
const unknown = params.periods.find((p) => !SUPPORTED_PERIODS.includes(p));
if (unknown) {
problems.push(problem(
at,
`Unsupported period: ${unknown}. Supported periods: ${SUPPORTED_PERIODS.join(', ')}.`
));
} else if (params.periods.length && !sourceSupportsOption(binding.source, 'periods')) {
problems.push(problem(at, `\`${binding.source}\` does not read periods.`));
}
}
}
if (params.limit != null) {
const limit = Number(params.limit);
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
problems.push(problem(at, '`limit` must be a whole number between 1 and 50.'));
} else if (!sourceSupportsOption(binding.source, 'limit')) {
problems.push(problem(at, `\`${binding.source}\` does not read a limit.`));
}
}
return problems;
}
/**
* A whole tree.
*
* Two things can only be seen from up here: that no id is used twice, and that
* containment holds all the way down. Both are checked once, over one walk, so
* a large page is not traversed per rule.
*/
export function validateTree(nodes, { registry = nodeRegistry, role = null } = {}) {
const problems = [];
const seen = new Set();
const visit = (list, parentType) => {
for (const node of list || []) {
if (node?.id) {
/* The duplicate-node gate. Two nodes with one id means every operation
after this point addresses whichever the traversal reached first. */
if (seen.has(node.id)) {
problems.push(problem(node.id, `Two nodes share the id \`${node.id}\`.`));
}
seen.add(node.id);
}
problems.push(...validateNode(node, { parentType, registry, role }));
if (node?.children?.length) visit(node.children, node.type);
}
};
visit(nodes, null);
return { ok: problems.length === 0, problems };
}
/** Every id already in use, so a new node can be given one that is not. */
export const takenIds = (nodes) => new Set(walk(nodes).map((node) => node.id));