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

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