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

@@ -0,0 +1,233 @@
import React from 'react';
import { ArrowDown, ArrowUp, Eye, EyeOff, Replace, Trash2 } from 'lucide-react';
import { Button } from '@/components/ds';
import { cn } from '@/lib/utils';
import {
ALIGN_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, SPACING_VALUES,
} from '@/lib/ui/node';
import { dataSourceFor, dataSourceLabel } from '@/lib/skills/surfaces';
import { nodeRegistry } from '@/lib/ui/registry';
import { hideOp, layoutOp, nudgeOp, presentationOp, propOp, removeOp, replaceOp } from './ops';
/**
* Everything a person may change about the selected node.
*
* Every control below is *derived*: the properties come from the type's own
* `propSchema`, the buttons from its declared `capabilities`, the replacement
* options from `registry.replacements`, and the layout values from the closed
* vocabulary in `lib/ui/node.js`. Nothing is listed by hand, which is what makes
* a type registered tomorrow editable tomorrow — and what makes it impossible
* for this panel to offer something the validator would refuse.
*
* There is no free-text style field anywhere, by construction. A person can set
* a title, pick from an enum, choose a column count — and there is no control
* that accepts a class name, a style, or markup, because no `propSchema`
* declares one and the layout vocabulary is four fixed steps.
*/
const field = 'w-full rounded-lg border border-border bg-surface px-2 py-1.5 text-body-sm text-ink-1 outline-none focus:border-krow-blue';
const labelled = 'block text-[10px] font-semibold uppercase tracking-wide text-ink-4';
export function NodeInspector({ node, siblings, page = null, onOperate, registry = nodeRegistry }) {
const entry = registry.get(node.type);
if (!entry) {
return <p className="text-caption text-ink-3">This node’s type is no longer available.</p>;
}
const can = (capability) => entry.capabilities.includes(capability) && !node.locked;
const index = siblings.findIndex((s) => s.id === node.id);
/** Reorder this node's own container by moving it one step. */
const nudge = (delta) => {
const op = nudgeOp(node, siblings, delta);
if (op) onOperate(op);
};
const shapes = node.data ? dataSourceFor(node.data.source)?.shapes || [] : null;
const replacements = can('replace') ? registry.replacements(node.type, { shapes, page }) : [];
return (
<div className="space-y-4">
<header className="space-y-1">
<p className="font-heading text-body font-semibold text-ink-1">{node.title || node.label}</p>
<p className="font-mono text-[10px] text-ink-4">{node.id} · {node.type} · {node.origin}</p>
</header>
{/* ── What may be done at all ─────────────────────────────────────── */}
<div className="flex flex-wrap gap-1.5">
{can('hide') && (
<Button
size="xs" variant="outline" shape="rounded"
onClick={() => onOperate(hideOp(node))}
>
{node.hidden
? <><Eye className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Show</>
: <><EyeOff className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Hide</>}
</Button>
)}
{can('move') && (
<>
<Button size="xs" variant="outline" shape="rounded" disabled={index <= 0}
onClick={() => nudge(-1)} aria-label="Move up">
<ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
<Button size="xs" variant="outline" shape="rounded" disabled={index < 0 || index >= siblings.length - 1}
onClick={() => nudge(1)} aria-label="Move down">
<ArrowDown className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</>
)}
{can('remove') && (
<Button size="xs" variant="outline" shape="rounded"
onClick={() => onOperate(removeOp(node))}>
<Trash2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Remove
</Button>
)}
</div>
{/* ── Turn it into something else ─────────────────────────────────── */}
{replacements.length > 0 && (
<label className="block space-y-1">
<span className={labelled}>
<Replace className="mr-1 inline h-3 w-3" aria-hidden="true" />Show as
</span>
<select
className={field}
value=""
onChange={(e) => e.target.value && onOperate(replaceOp(node, e.target.value))}
>
<option value="">Keep {entry.label}</option>
{replacements.map((type) => (
<option key={type} value={type}>{registry.get(type)?.label || type}</option>
))}
</select>
</label>
)}
{/* ── Properties, exactly as the type declared them ───────────────── */}
{can('update') && node.editable.length > 0 && (
<div className="space-y-2.5">
{node.editable.map((prop) => (
<label key={prop.key} className="block space-y-1">
<span className={labelled}>{prop.label}{prop.required ? ' *' : ''}</span>
{prop.kind === 'enum' && (
<select
className={field}
value={prop.value ?? ''}
onChange={(e) => onOperate(propOp(node, prop.key, e.target.value))}
>
<option value="">—</option>
{prop.options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
)}
{prop.kind === 'boolean' && (
<input
type="checkbox"
checked={Boolean(prop.value)}
onChange={(e) => onOperate(propOp(node, prop.key, e.target.checked || null))}
/>
)}
{prop.kind === 'number' && (
<input
type="number" className={field} defaultValue={prop.value ?? ''}
onBlur={(e) => onOperate(propOp(node, prop.key, e.target.value === '' ? null : Number(e.target.value)))}
/>
)}
{prop.kind === 'string' && (
<input
type="text" className={field} defaultValue={prop.value ?? ''}
onBlur={(e) => onOperate(propOp(node, prop.key, e.target.value))}
/>
)}
</label>
))}
</div>
)}
{/* ── What it is reading ──────────────────────────────────────────── */}
{node.data && (
<div className="space-y-1">
<span className={labelled}>Reading</span>
<p className="rounded-lg border border-border bg-surface-subtle px-2 py-1.5 text-body-sm text-ink-2">
{dataSourceLabel(node.data.source)}
</p>
</div>
)}
{/* ── How it looks ────────────────────────────────────────────────
Offered only where the type says it can draw them, so a picker never
shows a setting the component would ignore. */}
{can('update') && (node.variants.length > 0 || node.densities.length > 0) && (
<div className="grid grid-cols-2 gap-2">
{node.variants.length > 0 && (
<Choice
label="Style" value={node.presentation.variant} options={node.variants}
onPick={(v) => onOperate(presentationOp(node, 'variant', v))}
/>
)}
{node.densities.length > 0 && (
<Choice
label="Density" value={node.presentation.density} options={node.densities}
onPick={(v) => onOperate(presentationOp(node, 'density', v))}
/>
)}
</div>
)}
{/* ── Layout: four closed vocabularies, nothing typed ─────────────── */}
{can('update') && (
<div className="grid grid-cols-2 gap-2">
<Choice
label="Columns" value={node.layout.columns}
options={range(entry.constraints.minColumns ?? MIN_COLUMNS, entry.constraints.maxColumns ?? MAX_COLUMNS)}
onPick={(v) => onOperate(layoutOp(node, 'columns', v))}
/>
<Choice
label="Span" value={node.layout.span} options={range(MIN_COLUMNS, MAX_COLUMNS)}
onPick={(v) => onOperate(layoutOp(node, 'span', v))}
/>
<Choice
label="Gap" value={node.layout.gap} options={GAP_VALUES}
onPick={(v) => onOperate(layoutOp(node, 'gap', v))}
/>
<Choice
label="Align" value={node.layout.align} options={ALIGN_VALUES}
onPick={(v) => onOperate(layoutOp(node, 'align', v))}
/>
<Choice
label="Space above" value={node.layout.spacingBefore} options={SPACING_VALUES}
onPick={(v) => onOperate(layoutOp(node, 'spacingBefore', v))}
/>
<Choice
label="Space below" value={node.layout.spacingAfter} options={SPACING_VALUES}
onPick={(v) => onOperate(layoutOp(node, 'spacingAfter', v))}
/>
</div>
)}
</div>
);
}
/** One closed-vocabulary picker. Empty means "leave it to the page". */
function Choice({ label, value, options, onPick }) {
return (
<label className="block space-y-1">
<span className={labelled}>{label}</span>
<select
className={cn(field, 'text-caption')}
value={value ?? ''}
onChange={(e) => onPick(e.target.value === '' ? null : coerce(e.target.value))}
>
<option value="">Default</option>
{options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</label>
);
}
const range = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => from + i);
const coerce = (value) => (/^\d+$/.test(value) ? Number(value) : value);

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ds';
import { addableTypes } from '@/lib/ui/inspect';
import { nodeRegistry } from '@/lib/ui/registry';
import { dataSourceLabel, placementProvides, sourcesForShape } from '@/lib/skills/surfaces';
import { addOp } from './ops';
/**
* What can be added here, and what it would show.
*
* Both lists are answers to questions the product already knows how to answer:
* `addableTypes` asks the registry what this container accepts and what this
* role may use, and `sourcesForShape` asks the closed data vocabulary which
* readings can fill that shape. Nothing is offered that validation would then
* refuse, and nothing can be typed — a source is chosen from what exists, which
* is what makes an invented metric impossible to compose here.
*/
/** A property's value on a described node, read the way the inspector reads it. */
const propOf = (node, key) => node?.editable?.find((prop) => prop.key === key)?.value || null;
export function NodePicker({ tree, parent, page = null, role = null, onAdd, registry = nodeRegistry }) {
const [type, setType] = React.useState('');
const [source, setSource] = React.useState('');
const options = addableTypes(parent?.type ?? null, { registry, role, page });
const chosen = options.find((option) => option.type === type) || null;
/**
* What this location can actually answer.
*
* Every reading in the vocabulary was offered here, including the ones that
* need a record — so a Card bound to "Position activity" could be added to a
* page that has no position, and rendered "This section needs a position to
* read." forever. Offered, accepted, saved, and dead.
*
* `provides` on the surface already records which context each placement
* supplies, and `sourcesForShape` already filters on it. Only the question
* was missing. A node at the page root is inside no record, so it is offered
* the readings that need none.
*/
const slot = propOf(parent, 'placement');
const provided = slot ? placementProvides(propOf(parent, 'page') || page, slot) : [];
const sources = chosen?.dataShapes.length
? sourcesForShape(chosen.dataShapes[0], { context: provided }).map((s) => s.id)
: [];
/* A type that reads data cannot be added until a reading is picked. The
button says so by staying disabled rather than by failing on submit. */
const ready = Boolean(chosen) && (!chosen.dataRequired || Boolean(source));
const add = () => {
if (!ready) return;
onAdd(addOp(tree, parent, chosen.type, source || null));
setType('');
setSource('');
};
if (!options.length) {
return <p className="text-caption text-ink-4">Nothing can be added here.</p>;
}
const field = 'w-full rounded-lg border border-border bg-surface px-2 py-1.5 text-body-sm text-ink-1 outline-none focus:border-krow-blue';
return (
<div className="space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">
Add {parent ? `inside ${parent.title || parent.label}` : 'to the page'}
</p>
<select className={field} value={type} onChange={(e) => { setType(e.target.value); setSource(''); }}>
<option value="">Choose a panel…</option>
{options.map((option) => (
<option key={option.type} value={option.type}>{option.label}</option>
))}
</select>
{chosen?.dataShapes.length > 0 && (
<select className={field} value={source} onChange={(e) => setSource(e.target.value)}>
<option value="">What should it show?</option>
{sources.map((id) => <option key={id} value={id}>{dataSourceLabel(id)}</option>)}
</select>
)}
<Button size="xs" shape="rounded" disabled={!ready} onClick={add}>
<Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Add
</Button>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { Eye, EyeOff, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { inspectTree } from '@/lib/ui/inspect';
import { nodeRegistry } from '@/lib/ui/registry';
/**
* The page, as an outline.
*
* Everything shown here comes from `inspectTree` — the same inventory Owliver
* reads before it resolves "the recent hiring timeline". There is no second
* description of a page anywhere: if the editor can see a node, so can the
* conversation, and vice versa.
*
* **Hidden nodes are listed.** A hidden node is still in the tree and still
* addressable; the renderer skips it and nothing else does. Leaving it out here
* would make the editor the one place a person could not undo a hide.
*/
export function TreePanel({ tree, selectedId, onSelect, registry = nodeRegistry }) {
const nodes = inspectTree(tree, { registry });
if (!nodes.length) {
return (
<p className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-caption text-ink-3">
This page has no addressable sections yet.
</p>
);
}
/* Depth from parentage rather than from a nested walk, so the flat inventory
stays the one source and this only decides indentation. */
const depthOf = (node) => {
let depth = 0;
let cursor = node;
while (cursor?.parent) {
cursor = nodes.find((n) => n.id === cursor.parent);
depth += 1;
}
return depth;
};
return (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{nodes.map((node) => {
const selected = node.id === selectedId;
return (
<li key={node.id}>
<button
type="button"
onClick={() => onSelect(node.id)}
aria-current={selected ? 'true' : undefined}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-left transition-colors',
selected ? 'bg-krow-blue-tint' : 'hover:bg-surface-subtle'
)}
>
<span style={{ paddingLeft: `${depthOf(node) * 14}px` }} className="flex min-w-0 flex-1 items-center gap-2">
<span className={cn('truncate text-body-sm', node.hidden ? 'text-ink-4' : 'text-ink-1')}>
{node.title || node.label}
</span>
{/* Provenance. A person needs to know which panels came from a
definition they could switch off, and which are the page. */}
{node.origin === 'skill' && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-krow-blue-tint px-1.5 py-0.5 text-[10px] font-semibold text-krow-blue">
<Sparkles className="h-2.5 w-2.5" aria-hidden="true" />
Skill
</span>
)}
{node.origin === 'user' && (
<span className="shrink-0 rounded-full bg-surface-sunken px-1.5 py-0.5 text-[10px] font-semibold text-ink-3">
Added
</span>
)}
</span>
<span className="shrink-0 font-mono text-[10px] text-ink-4">{node.type}</span>
{node.hidden
? <EyeOff className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-label="Hidden" />
: <Eye className="h-3.5 w-3.5 shrink-0 text-ink-4/40" aria-hidden="true" />}
</button>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { RotateCcw, SlidersHorizontal, Undo2 } from 'lucide-react';
import { Button } from '@/components/ds';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { inspectTree } from '@/lib/ui/inspect';
import { nodeRegistry } from '@/lib/ui/registry';
import { TreePanel } from './TreePanel';
import { NodeInspector } from './NodeInspector';
import { NodePicker } from './NodePicker';
/**
* The visual editor.
*
* It is a *client* of the UI system, not a second implementation of it. Every
* control it draws ends in one call — `propose(op)` on the editing session —
* with an operation object of exactly the shape Owliver produces for the same
* change. From there the two are indistinguishable: same validation, same
* preview merge, same Apply, same `preferences.uiLayouts`.
*
* That is the whole architecture:
*
* editor / Owliver → operation → propose → validate → preview
* ↓ Apply
* preferences.uiLayouts
*
* There is no page in this file, no component name, and no branch on what a
* node is. What can be done to the selected node comes from its registration;
* what can be added comes from the registry and the closed data vocabulary;
* what it is showing comes from the tree. A page that migrates tomorrow is
* editable tomorrow with nothing here changed.
*/
export function UiEditor({ registry = nodeRegistry }) {
const editing = useUiEditing();
const [open, setOpen] = React.useState(false);
const [selectedId, setSelectedId] = React.useState(null);
/* Rendered only where a page has opted into composition. A page that has not
is not broken; it simply has nothing to arrange. */
if (!editing) return null;
const {
tree, propose, discard, apply, undo, reset,
previewing, customised, saving, problems, skipped,
/* The page this session belongs to. Handed to the picker so what can be
added here is decided by the registry rather than by the picker being
shown everything that exists. */
page,
} = editing;
const nodes = inspectTree(tree, { registry });
const selected = nodes.find((node) => node.id === selectedId) || null;
/* A node's own container, for the reorder buttons and for the picker. */
const siblings = selected ? nodes.filter((node) => node.parent === selected.parent) : [];
const parent = selected?.container ? selected : nodes.find((n) => n.id === selected?.parent) || null;
/**
* The one door out of this component.
*
* Everything the panels do arrives here as an operation and goes straight to
* the session. Nothing is applied, nothing is stored, and nothing is
* validated locally — `propose` refuses what cannot be kept and the refusal
* is shown below.
*/
const operate = (op) => {
const result = propose(op);
/* A removed node cannot stay selected; a replaced one keeps its id. */
if (result.ok && op.op === 'remove') setSelectedId(null);
};
return (
<div data-ui-controls="editor" className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
size="xs"
variant={open ? 'default' : 'outline'}
shape="rounded"
onClick={() => setOpen((v) => !v)}
>
<SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
Customise layout
</Button>
{/* The preview bar. Unsaved and saved have to be told apart at a
glance, because the whole promise is that nothing is kept until
somebody says so. */}
{(previewing || customised) && (
<div className="flex flex-wrap items-center gap-2">
{previewing && (
<span className="rounded-full bg-krow-blue-tint px-2 py-0.5 text-caption font-semibold text-krow-blue">
Previewing — not saved yet
</span>
)}
{!previewing && customised && (
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold text-ink-3">
Saved layout
</span>
)}
{previewing && (
<>
<Button size="xs" shape="rounded" loading={saving} onClick={apply}>Apply</Button>
<Button size="xs" variant="outline" shape="rounded" onClick={discard}>Discard</Button>
</>
)}
<Button size="xs" variant="ghost" shape="rounded" onClick={undo}>
<Undo2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Undo
</Button>
{customised && (
<Button size="xs" variant="ghost" shape="rounded" onClick={reset}>
<RotateCcw className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Reset
</Button>
)}
</div>
)}
</div>
{problems.length > 0 && (
<p className="text-caption text-destructive">{problems[0].message}</p>
)}
{skipped.length > 0 && (
<p className="text-caption text-ink-4">
{skipped.length} saved change{skipped.length === 1 ? '' : 's'} no longer apply to this page.
</p>
)}
{open && (
<div className="grid gap-3 rounded-xl border border-border bg-surface-subtle p-3 lg:grid-cols-2">
<div className="space-y-3">
<TreePanel tree={tree} selectedId={selectedId} onSelect={setSelectedId} registry={registry} />
<NodePicker tree={tree} parent={parent} page={page} onAdd={operate} registry={registry} />
</div>
<div className="rounded-xl border border-border bg-surface p-3">
{selected
? <NodeInspector node={selected} siblings={siblings} page={page} onOperate={operate} registry={registry} />
: (
<p className="text-caption text-ink-3">
Choose a section on the left to see what can be changed about it.
</p>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { freeNodeId } from '@/lib/ui/node';
import { dataSourceLabel } from '@/lib/skills/surfaces';
/**
* The operations the editor's controls stand for.
*
* Pure, and separate from the components, for one reason: it is the claim that
* the editor and Owliver do the same thing, and a claim buried inside a click
* handler cannot be checked. Here it can — `hideOp(node)` and the operation
* `matchUiEdit('hide the recent hiring timeline')` produces are the same object,
* and a test says so.
*
* Nothing here validates or applies. Every one of these is handed to
* `propose()` on the editing session, which is the single gate both surfaces
* pass through.
*/
/** Hide a visible node, or show a hidden one. */
export const hideOp = (node) => ({ op: 'hide', target: node.id, hidden: !node.hidden });
/** Set a node's hidden state explicitly, which is what a conversation says. */
export const visibilityOp = (node, hidden) => ({ op: 'hide', target: node.id, hidden });
/**
* Move a node one place within its own container.
*
* A reorder of the whole sibling list rather than a `move`, because that is
* what the button means: nothing changes parent, the order around it changes.
* The same shape Owliver produces for "move the timeline above the notice".
*/
export function nudgeOp(node, siblings, delta) {
const order = siblings.map((s) => s.id);
const from = order.indexOf(node.id);
const to = from + delta;
if (from < 0 || to < 0 || to >= order.length) return null;
order.splice(to, 0, ...order.splice(from, 1));
return { op: 'reorder', parent: node.parent ?? null, order };
}
/** Put a node at an explicit index among its siblings. */
export function reorderOp(node, siblings, index) {
const order = siblings.map((s) => s.id);
const from = order.indexOf(node.id);
if (from < 0 || index < 0 || index >= order.length) return null;
order.splice(index, 0, ...order.splice(from, 1));
return { op: 'reorder', parent: node.parent ?? null, order };
}
/** Turn a node into another registered type. */
export const replaceOp = (node, type) => ({ op: 'replace', target: node.id, type });
/** Remove a node the person added. */
export const removeOp = (node) => ({ op: 'remove', target: node.id });
/**
* Change one declared property.
*
* An empty value becomes `null`, which the engine reads as "unset" — the only
* way to clear a property, and the reason a blank field is not the same as a
* field nobody touched.
*/
export const propOp = (node, key, value) => ({
op: 'update', target: node.id, props: { [key]: value === '' || value === undefined ? null : value },
});
/** Change one layout value, from the closed vocabulary. */
export const layoutOp = (node, key, value) => ({
op: 'update', target: node.id, layout: { [key]: value === '' || value === undefined ? null : value },
});
/**
* Change how a node presents itself, from the closed vocabulary.
*
* The same `update` operation everything else here produces — the editor has no
* mutation of its own, and this is the shape Owliver emits for the same words.
*/
export const presentationOp = (node, key, value) => ({
op: 'update',
target: node.id,
presentation: { [key]: value === '' || value === undefined ? null : value },
});
/**
* Add a node of a registered type, optionally bound to a reading.
*
* The id is generated by the shared helper, so a node added here and a node
* added by Owliver get their addresses from one rule.
*/
export function addOp(tree, parent, type, source = null) {
return {
op: 'add',
parent: parent?.id ?? null,
node: {
id: freeNodeId(tree, type),
type,
...(source ? { data: { source }, props: { title: dataSourceLabel(source) } } : {}),
},
};
}