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

@@ -98,6 +98,12 @@ const AuthenticatedApp = () => {
screening dimensions, which a 480px column cannot show without
hiding most of it. */}
<Route path="candidates/:id" element={<AdminCandidateProfile />} />
{/* Candidate Analysis. A sibling segment, not `candidates/:id`:
the analysis reads the whole pool, so it is not addressed by a
candidate id and must not be matched as one. The route is the
one the `candidates-analysis` surface declares, so the skill
table, the assistant placement table and this agree. */}
<Route path="candidates-analysis" element={<AdminCandidatesAnalysis />} />
<Route path="hired" element={<AdminHiredHistory />} />
<Route path="talent-pool" element={<AdminTalentPool />} />
<Route path="university" element={<University />} />

View File

@@ -394,6 +394,10 @@ export function AgentSkillWorkspace({
busy={busy}
query={boardQuery}
onQueryChange={setBoardQuery}
/* Ownership, through the one handler that writes `agent.skills` —
the same one the Owliver catalog attaches with. */
attachedIds={attachedIds}
onAttach={onToggleSkill}
/>
)}
</div>

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { LayoutTemplate, Pencil, Plus, SearchX } from 'lucide-react';
import { LayoutTemplate, Minus, Pencil, Plus, SearchX } from 'lucide-react';
import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { Alert, Button, SearchInput, Switch } from '@/components/ds';
@@ -9,24 +9,30 @@ import { Alert, Button, SearchInput, Switch } from '@/components/ds';
*
* The second half of one Skills section — the same heading, a tab away from the
* Owliver catalog — so nobody has to decide between two top-level destinations
* ever again. What it is *not* is a second copy of that catalog with the word
* Board on it, and the reason is worth stating because it is the one thing here
* that could quietly become a lie:
* ever again.
*
* **A Board skill is not carried by an agent.** `SkillSurface` renders a
* section from the page it names and the account's `disabledSkills`; it does not
* read `agent.skills` and has no agent in scope. Writing a Board skill id into
* `agent.skills` would therefore record an assignment nothing honours — a card
* that says "Added" and a product that behaves identically either way.
* **Two controls, because there are two different questions.** This card used to
* offer only the workspace switch, and said so at length: a Board skill was not
* carried by an agent, because `SkillSurface` read the page and the account's
* `disabledSkills` and never looked at `agent.skills`. That was true, and it is
* not any more — `useSkillSections` now asks `agentPermitsSkill`, so an id
* written into `agent.skills` is honoured on the page.
*
* So the relationship shown is the real one: these are the sections the pages
* *this agent answers on* will draw, and the control offered is the one that
* actually governs them — the workspace switch every surface already reads. It
* is labelled as workspace-wide, because it is.
* So both questions are asked here, and neither is dressed up as the other:
*
* - **Active** is the workspace switch. Off means off for everyone, on every
* page, for every agent. It is the account's `disabledSkills`.
* - **Add to agent** is ownership. It writes this skill's id into
* `agent.skills`, through the same handler the Owliver catalog uses.
*
* Ownership is opt-in and that is what makes the two safe together: a skill no
* agent claims still draws wherever its `pages:` say, exactly as before. The
* first agent to claim it is what narrows it — which is why attaching is worth
* saying out loud on the card rather than leaving as a silent side effect.
*/
/** One Board skill: what it draws, where, and whether it is switched on. */
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = false, onAttach = null }) {
const switchId = `board-${entry.id}`;
return (
@@ -110,6 +116,31 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
)}
</div>
{/* Ownership, separate from the workspace switch above it. The button
writes `agent.skills` through the same handler the Owliver catalog
uses — there is no second assignment path and nothing new stored. */}
{onAttach && (
<div className="relative z-10 mt-3 flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle px-2.5 py-2">
<span className="min-w-0 text-caption text-ink-3">
{attached
? 'Owned by this agent'
: 'Not owned by any agent on this page'}
</span>
<Button
size="xs"
variant={attached ? 'outline' : 'default'}
shape="rounded"
disabled={busy}
onClick={() => onAttach(entry.id)}
aria-label={`${attached ? 'Remove' : 'Add'} ${entry.name} ${attached ? 'from' : 'to'} this agent`}
>
{attached
? <><Minus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Remove</>
: <><Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Add to agent</>}
</Button>
</div>
)}
<div className="relative z-10 mt-4 flex items-center justify-between gap-2 border-t border-border/60 pt-3 font-medium">
<span className="text-caption text-ink-4">
{entry.surfaces.some((s) => onSurfaces.has(s.id))
@@ -131,6 +162,7 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy }) {
/** @param {any} props */
export function BoardSkillList({
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
attachedIds = null, onAttach = null,
}) {
const searchId = React.useId();
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
@@ -147,9 +179,11 @@ export function BoardSkillList({
return (
<section aria-label="Board skills" className="flex min-w-0 flex-col gap-4">
<Alert tone="info" title="Board skills belong to a page, not to an agent">
They draw sections on KROW pages and are switched on for the whole workspace —
so a change here affects every agent answering on that page, not just this one.
<Alert tone="info" title="Two switches, two different questions">
<strong>Active</strong> is workspace-wide: off means off on every page, for every
agent. <strong>Add to agent</strong> is ownership — once any agent owns a board
skill, it draws only where an owning agent is answering. A skill no agent owns
keeps drawing wherever its pages say, as before.
</Alert>
{total > 0 && (
@@ -192,6 +226,8 @@ export function BoardSkillList({
onToggle={onToggle}
onSurfaces={onSurfaces}
busy={busy}
attached={Boolean(attachedIds?.has(entry.id))}
onAttach={onAttach}
/>
))}
</ul>

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { usePreferences } from '@/lib/krowHooks';
import { allAgents } from '@/lib/agents/registry';
import { AGENTS, readAgentRegistry } from '@/lib/agents/registry';
import { sourcesFrom, useAgentDefinitions } from '@/lib/agents/agentStore';
import {
agentCovers, nativeAgentForContext, resolveAgentForTurn, resolveDefaultAgent, resolveSelection,
} from '@/lib/agents/runtime';
@@ -73,12 +74,30 @@ function writeSelection(selection) {
export function AgentProvider({ contextId = null, children }) {
const preferences = usePreferences();
/* Shipped definitions plus anything this account has authored, read through
the one registry so the switcher and the management page cannot disagree
about what exists. */
/**
* Shipped definitions plus anything this account has authored.
*
* Read from `agent-definitions` — the store the Agent Registry and Agent
* Configure write to — rather than from `preferences.customAgents`, which is
* where authored agents used to live. That move happened for the management
* screens and this was left behind, so the panel's idea of an agent was the
* shipped file and nothing else: an agent edited in Configure looked saved,
* and the agent answering beside it was still the version off disk.
*
* It is only visible once something actually depends on an authored field.
* Attaching a skill is that: ownership is read off `agent.skills`, and an
* attachment made in Configure has to be the one the page sees, or the two
* halves of the product disagree about what this agent owns.
*/
const definitions = useAgentDefinitions();
const shippedIds = React.useMemo(() => new Set(AGENTS.map((a) => a.id)), []);
const stored = React.useMemo(
() => sourcesFrom(definitions.data || [], shippedIds),
[definitions.data, shippedIds]
);
const agents = React.useMemo(
() => allAgents(preferences.customAgents || [], { customSkills: preferences.customSkills || [] }),
[preferences.customAgents, preferences.customSkills]
() => readAgentRegistry(stored, { customSkills: preferences.customSkills || [] }).agents,
[stored, preferences.customSkills]
);
const [selection, setSelection] = React.useState(() => readSelection());

View File

@@ -2,14 +2,14 @@ import * as React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
History, Home, Maximize2, Minimize2, PanelRightClose, Trash2, X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import {
useAssignments, useAssignWorkers, useCreateEmployeeRole, useCreateJobPosting,
useAssignments, useAssignWorkers, useCreateWorkerWithRole, useCreateJobPosting,
useGenerateJobDescription,
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
useUpdateJobPosting,
@@ -17,7 +17,9 @@ import {
} from '@/lib/krowHooks';
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
import { runAction } from '@/lib/skills/actions';
import { allSkills, pageKeyForContext } from '@/lib/skills/registry';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { allSkills, pageKeyForContext, skillsForContext } from '@/lib/skills/registry';
import { actionSuggestions } from '@/lib/skills/tools';
import { suggestionChips } from '@/lib/skills/serverSuggestions';
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
import { profileForEmail } from '@/lib/skillGraph';
@@ -39,7 +41,7 @@ import { PromptChips } from './PromptChips';
*
* Identity, not emptiness: `prompts` is recomputed on every keystroke, and a
* fresh `[]` each time would rerender the row — and anything memoized against
* it — throughout the whole of a query that is still too short to match.
* it — on every turn that offers nothing.
*/
const EMPTY_PROMPTS = [];
@@ -53,41 +55,6 @@ const EMPTY_PROMPTS = [];
*/
const EMPTY_SUGGESTIONS = [];
/**
* How long a pause counts as having finished typing.
*
* Short enough that the chips feel like they are keeping up, long enough that a
* word typed at speed is one request rather than eight. The endpoint is cached
* per query, so a reader deleting back to something already asked pays nothing
* either way.
*/
const SUGGEST_DEBOUNCE_MS = 180;
/**
* A value, held still until it stops changing.
*
* Deliberately generic and local: it debounces the composer's contents and
* nothing else, and the alternative — debouncing inside the query hook — would
* make every other caller of that hook pay for a delay it did not ask for.
*/
function useDebounced(value, delay) {
const [settled, setSettled] = React.useState(value);
React.useEffect(() => {
/* An emptied composer settles immediately. Waiting would leave the previous
query's chips under a blank input for a fifth of a second, which reads as
the panel not having noticed. */
if (!value) {
setSettled(value);
return undefined;
}
const timer = setTimeout(() => setSettled(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return settled;
}
/**
* Owliver History — the conversations that came before.
*
@@ -169,77 +136,98 @@ function HistoryView({ groups, currentId, onOpen, onForget }) {
}
/**
* Show the Back to Home row on scroll direction, not scroll position.
* Whether the composer is offering anything, as one rule in one place.
*
* Hidden while reading downwards, back the instant the user scrolls up — the
* way out of a long answer should not be something you have to scroll all the
* way to the top to reach. Direction is read off the panel's own scrolling
* element (the body region below), never the window: the page behind the panel
* does not move when the thread does.
* Extracted because it is the whole of the interaction and every clause is a
* decision somebody could reasonably make differently:
*
* The listener is passive and rAF-throttled, and `setVisible` is only ever
* called with a value that can change — React bails out on an identical one, so
* a fast scroll costs at most one render per direction change.
*
* `pinToBottom` is the same follow-the-stream scroll the panel already did,
* routed through here so a programmatic jump is not mistaken for the user
* scrolling down and does not hide the control under them.
* focused — an offer belongs to the thing you are about to type into. An
* unfocused composer showing suggestions is the panel talking
* first.
* !busy — nothing is offered while an answer is still arriving; the next
* question is not knowable until this one lands.
* chat — History is a different view with a different body.
* count — nothing to say, nothing shown, and this clause now carries what
* a `!typed` test used to. Typing does not hide the panel by
* rule; it changes what the panel HAS. An empty composer offers
* follow-ups, a typed action intent offers the matching actions,
* and arbitrary partial text matches no action and so offers
* nothing — which is the same outcome by a more honest route,
* and the reason "Create" can be answered while "he" cannot.
*/
function useDirectionalNav(scrollRef, { active, resetKey }) {
const [visible, setVisible] = React.useState(true);
const lastY = React.useRef(0);
export const shouldShowSuggestions = ({ focused, busy, view, count }) => Boolean(
focused && !busy && view === 'chat' && count > 0
);
/* Ignore sub-pixel and trackpad jitter, but nothing a deliberate scroll would
produce: a real direction change clears this within one frame. */
const NOISE = 4;
/**
* The questions on offer, above the composer they belong to.
*
* A labelled panel rather than a bare row of chips: the label is what makes
* three sentences read as an offer rather than as something the assistant just
* said. It sits inside the composer's own region, above the input and below the
* conversation, so it reads as part of the thing you are about to type into.
*
* Always mounted, height animated. Mounting on open would move the input the
* instant the panel appeared and again when it left, so the caret would jump
* under the reader's hands every time they focused the box. Animating a
* collapsed height keeps the geometry continuous, and `pointer-events-none`
* plus `inert`-style tab removal means the closed panel cannot be clicked or
* tabbed into.
*
* `max-h` is generous enough for four wrapped questions and scrolls past that,
* so a narrow panel on a small screen cannot push the composer off the bottom.
*/
function SuggestedQuestions({ prompts, open, onSelect }) {
return (
<div
className={cn(
'overflow-hidden transition-all duration-200 ease-out motion-reduce:transition-none',
open
? 'max-h-56 translate-y-0 opacity-100'
: 'pointer-events-none max-h-0 translate-y-1 opacity-0'
)}
aria-hidden={open ? undefined : 'true'}
>
<p className="px-1 pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
Suggested questions
</p>
{/* The existing chip component, and the existing submit path behind it —
`runPrompt` is the same handler a typed question goes through. */}
<PromptChips
prompts={prompts}
onSelect={onSelect}
align="start"
focusable={open}
className="pb-1"
/>
</div>
);
}
const pinToBottom = React.useCallback(() => {
/**
* Keep the thread pinned to its newest content.
*
* All that survives of a floating "Back to Home" row that used to sit between
* the header and the conversation. That row was `absolute`, so it did not take
* part in the layout — it OVERLAID the top of the scrolling region, and the
* first line or two of a long answer arrived underneath it. It hid on
* down-scroll to compensate, which meant the fix for a control covering the
* answer was to make the control disappear while you read.
*
* The navigation it carried now lives in the header, where the panel's other
* controls already are and where nothing can cover the response. What is left
* here is the scroll behaviour, which was always a separate concern that had
* been folded in because the two happened to share a listener.
*
* Direct `scrollTop` rather than smooth scrolling: at streaming frequency a
* smooth scroll never catches up and the thread visibly lags the text.
*/
function usePinToBottom(scrollRef) {
return React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
/* Adopt the new position before the scroll event lands, so the next read
sees no delta and the row keeps whatever state the user left it in. */
lastY.current = el.scrollTop;
}, [scrollRef]);
React.useEffect(() => {
const el = scrollRef.current;
/* Nothing to hide when the row is not rendered — and a fresh thread or a
newly opened panel always starts with it showing. */
setVisible(true);
if (!el || !active) return undefined;
lastY.current = el.scrollTop;
let frame = 0;
const read = () => {
frame = 0;
const y = el.scrollTop;
if (y <= 0) {
lastY.current = y;
setVisible(true);
return;
}
const delta = y - lastY.current;
/* Leave `lastY` alone below the threshold so slow scrolls accumulate
rather than being swallowed frame by frame. */
if (Math.abs(delta) < NOISE) return;
lastY.current = y;
setVisible(delta < 0);
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(read);
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
if (frame) cancelAnimationFrame(frame);
};
}, [scrollRef, active, resetKey]);
return { visible, pinToBottom };
}
/**
@@ -449,13 +437,18 @@ export default function KrowAssistant({
* so adding a conversation is adding an entry here rather than another prop
* threaded through the panel.
*/
const createRole = useCreateEmployeeRole();
const createRole = useCreateWorkerWithRole();
const createEmployeeRole = React.useCallback(async (draft, skill, status) => {
const result = runAction('create_employee_role', { draft, skill, status });
if (result?.type !== 'create_employee_role') return null;
return createRole.mutateAsync(result.data);
}, [createRole]);
/* The page's layout session, mounted by the layout above both this panel and
the page. Null on a surface that composes no tree, which is every page that
has not migrated — and every branch that reads it checks first. */
const uiEditing = useUiEditing();
const flowWriters = React.useMemo(() => ({
position: createPosition,
'employee-role': createEmployeeRole,
@@ -516,13 +509,16 @@ export default function KrowAssistant({
* used here — it was computed before the row existed.
*/
const queryClient = useQueryClient();
const refreshSuggestions = React.useCallback(async () => {
const refreshSuggestions = React.useCallback(async ({ query = '' } = {}) => {
if (!owliverPage) return [];
const fresh = await queryClient.fetchQuery({
/* The empty query string is the untyped request — the same key
`useOwliverSuggestions` uses when the composer is empty. */
queryKey: ['owliverSuggestions', owliverPage, ''],
queryFn: () => fetchOwliverSuggestions({ page: owliverPage }),
/* An empty query is the untyped request — the same key
`useOwliverSuggestions` uses when the composer is empty, and what a
write wants: the page changed, so ask what matters on it now.
A question passed in ranks the same catalogue AGAINST that question,
which is what makes a follow-up follow from something. */
queryKey: ['owliverSuggestions', owliverPage, query],
queryFn: () => fetchOwliverSuggestions({ page: owliverPage, query }),
staleTime: 0,
});
return suggestionChips(fresh || [], context.id);
@@ -619,7 +615,11 @@ export default function KrowAssistant({
onNavigate: goToPage,
onAction: performAction,
flowWriters,
uiEditing,
companies,
/* The postings this caller can already see — the evidence behind
role-aware certification suggestions. */
postings: facts.postings || [],
workers,
onRefreshSuggestions: refreshSuggestions,
onUpdatePosition,
@@ -670,12 +670,12 @@ export default function KrowAssistant({
const scrollRef = React.useRef(null);
const isEmpty = messages.length === 0 && !pending;
/* The Back to Home row only exists in the states that are not already home. */
const showBackRow = view === 'history' || messages.length > 0 || Boolean(pending);
const { visible: backVisible, pinToBottom } = useDirectionalNav(scrollRef, {
active: showBackRow,
resetKey: `${view}:${conversationId || ''}`,
});
const pinToBottom = usePinToBottom(scrollRef);
/* Whether there is anything to leave. The header's own control is shown on
exactly the states that are not already home — the same test the removed
row used, now deciding a button rather than an overlay. */
const canGoHome = view === 'history' || messages.length > 0 || Boolean(pending);
/* Greeting and suggestions come from live data, so they recompute only when
the data or the page actually changes. */
@@ -685,60 +685,98 @@ export default function KrowAssistant({
);
/**
* What the chip row actually shows, which is one of three separate things.
* What the composer offers, and when.
*
* They are separate states, not one merged list, because they answer to
* different owners. Follow-ups belong to the answer that raised them; the
* suggestions belong to the server. Only one of them can be true at a time,
* and the order below is that precedence.
* Three rules, and they are about DIFFERENT questions — what to show, and
* whether to show anything at all.
*
* 1. Follow-ups. When an answer ends by asking something, its chips *are*
* the answers to it — the role list after "create a position". They are
* never capped and never filtered, and they stand until the next turn or
* until the reader starts typing something else.
* WHAT. Before the first question, the page's own suggested questions: the
* reader has asked nothing, so there is nothing to follow up and the useful
* offer is the range of what this page can answer. After an answer, the
* follow-ups that answer carried — questions this conversation has not
* already covered, worked out in `nextSteps`. Never both: a thread that has
* run out of new ground shows nothing rather than falling back to the
* catalogue it has already been through.
*
* 2. The server's suggestions. From the moment there is something in the
* composer, `GET /api/v1/owliver/suggestions` is asked what this page
* can usefully answer for this query, and its reply is rendered in the
* order it arrived. The panel does not rank, score, filter or reorder
* it: which readings exist depends on the caller's role and on what is
* actually in the database, and neither of those is knowable here.
* WHEN. Only while the composer has focus and is empty. Suggestions used to
* appear from the second character typed, which is the wrong moment twice
* over: a reader who is typing has already decided what to ask, and two
* characters is not enough to know what they mean. So typing hides them and
* the reader's own text is never touched.
*
* 3. Nothing. An empty composer offers no chips at all. The panel used to
* open on a dozen of them, which taught the range of what could be asked
* by saying all of it at once and pushed the composer — the thing the
* reader came for — under a wall of suggestions. The greeting still
* carries the page's context; `buildIntro` reads the same fact sheet it
* always did.
* The typed-query branch is gone with it. The endpoint still takes a query
* and `nextSteps` still uses it — that is what makes a follow-up follow from
* something — but nothing asks it on a keystroke any more.
*/
const followUp = messages[messages.length - 1]?.followUp;
const typed = input.trim();
const [composerFocused, setComposerFocused] = React.useState(false);
/**
* The request behind (2), debounced.
*
* The endpoint is cheap and cached per query, but a keystroke is not a
* decision — a reader typing "positions" would otherwise fire nine requests
* to see the answer to the ninth. A short delay means one request per pause,
* and `placeholderData` in the hook keeps the previous answer on screen
* meanwhile so the row does not empty and refill.
*
* Only asked while there is something in the composer. An empty one offers no
* chips, so there would be nothing to render the answer into — and a reader
* who starts typing has left the follow-up behind, which is why typing
* supersedes it rather than being ranked against it.
*/
const debouncedQuery = useDebounced(typed, SUGGEST_DEBOUNCE_MS);
const { data: suggested = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
/* The page's own questions, for a thread that has not started. One untyped
request, cached by the hook, asked only while it could be shown. */
const { data: pageSuggestions = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
page: owliverPage,
query: debouncedQuery,
enabled: Boolean(debouncedQuery),
enabled: Boolean(owliverPage) && messages.length === 0,
});
/**
* What is on offer, which now depends on whether anything has been typed.
*
* TYPED — the actions this page can perform that the text is starting to
* name, and nothing else. "Create" reaches "Create a position" here because
* that is a skill on this page that declares an action; "he" reaches nothing,
* and neither does "abc". This is the narrow case the composer was missing:
* a reader typing an action intent had to finish the sentence unaided, while
* a reader typing anything at all used to get the whole page catalogue.
*
* EMPTY — the follow-ups the last answer left, or, before a thread starts,
* what this page can be asked. Unchanged.
*/
const reachableSkills = React.useMemo(
() => skillsForContext(context.id, disabledSkills, preferences.customSkills || []),
[context.id, disabledSkills, preferences.customSkills]
);
const prompts = React.useMemo(() => {
if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS;
return suggestionChips(suggested, context.id);
}, [typed, followUp, suggested, context.id]);
if (typed) return actionSuggestions(typed, reachableSkills);
if (messages.length) return followUp?.length ? followUp : EMPTY_PROMPTS;
return suggestionChips(pageSuggestions, context.id);
}, [typed, reachableSkills, messages.length, followUp, pageSuggestions, context.id]);
const showSuggestions = shouldShowSuggestions({
focused: composerFocused, busy, view, count: prompts.length,
});
/**
* Focus, read at the composer rather than at the input.
*
* A chip lives inside the same region, so moving to one keeps the region
* focused and the panel open long enough for the click to land — which a
* `blur` handler on the textarea alone would not do. `relatedTarget` is what
* makes "clicked outside" mean it: focus leaving for anywhere else in the
* document closes the panel.
*/
const onComposerBlur = React.useCallback((event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setComposerFocused(false);
}, []);
/**
* Asking closes the panel, and only focus reopens it.
*
* A blur handler alone was not enough, which a live run showed: after a
* question was sent, focus ended up on `document.body` while `composerFocused`
* was still true, so the panel came back on its own under the finished answer
* with nobody's cursor in the box. The subtree re-renders while the answer
* streams, and a focus lost that way does not always arrive as a blur this
* handler sees.
*
* So submitting is treated as what it is — the reader has finished with the
* composer for now — rather than relying on a blur that may never come. The
* state table is unchanged: focus opens it, everything else leaves it shut.
*/
React.useEffect(() => {
if (busy) setComposerFocused(false);
}, [busy]);
/* The newest assistant turn, which is the one that carries the rating. */
const lastAnswerIndex = React.useMemo(
@@ -891,6 +929,10 @@ export default function KrowAssistant({
different places with different affordances. The panel does the
first job only; the registry behind it is unchanged. */}
<div className="flex items-center gap-0.5">
{/* The way back to a clean panel, positioned in front of History */}
{canGoHome && (
<IconButton icon={Home} label="Back to home" variant="ghost" size="sm" onClick={goHome} />
)}
{/* History lives with the other window controls rather than in the
body, so the layout of the panel is unchanged whether or not
there is anything to show. It toggles: pressing it again returns
@@ -903,9 +945,6 @@ export default function KrowAssistant({
aria-pressed={view === 'history'}
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
/>
{messages.length > 0 && view === 'chat' && (
<IconButton icon={RotateCcw} label="New conversation" variant="ghost" size="sm" onClick={reset} />
)}
{expanded
? onRestore && (
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
@@ -919,37 +958,6 @@ export default function KrowAssistant({
</div>
</div>
{/* Floating directional Back to Home row — reveals on UP-scroll, hides on DOWN-scroll */}
{showBackRow && (
<div
className={cn(
`absolute top-[3.25rem] left-0 right-0 z-20 flex items-center justify-between gap-2
border-b border-border bg-white/95 dark:bg-slate-900/95 px-4 py-2 shadow-sm backdrop-blur-md
transition-all duration-200 ease-out motion-reduce:transition-none`,
backVisible
? 'translate-y-0 opacity-100 pointer-events-auto'
: '-translate-y-full opacity-0 pointer-events-none'
)}
aria-hidden={backVisible ? undefined : 'true'}
>
<button
type="button"
onClick={goHome}
tabIndex={backVisible ? undefined : -1}
className="inline-flex items-center gap-1.5 rounded text-caption font-semibold text-ink-1 dark:text-white transition-colors
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 cursor-pointer"
>
<ArrowLeft className="h-3.5 w-3.5 text-krow-blue" aria-hidden="true" />
<span>Back to Home</span>
</button>
<span className="truncate text-[11px] font-medium text-ink-3">
{view === 'history'
? `${history.length} conversation${history.length === 1 ? '' : 's'}`
: context.page}
</span>
</div>
)}
{/**
* A capability test running through this panel.
*
@@ -1059,23 +1067,18 @@ export default function KrowAssistant({
</div>
{/* ── Composer: fixed to the bottom in both states ───────────────── */}
<div className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5">
{/* All suggestions on the landing screen, where they teach what can be
asked. Capped once a thread exists, because from then on the vertical
space belongs to the conversation. Expanded fits more per line, so it
can afford one more.
Follow-ups are never capped: when Owliver has asked a question, its
chips *are* the answers, and hiding three of the six roles would make
the flow look broken. */}
{!busy && view === 'chat' && (
<PromptChips
prompts={prompts}
onSelect={runPrompt}
max={isEmpty || followUp?.length ? undefined : expanded ? 4 : 3}
align="start"
/>
)}
{/* Focus is tracked on the whole region rather than on the textarea, so
reaching for a suggestion does not close the panel out from under the
click. In normal flow, never floating: an overlay here would sit on
top of the answer, which is the mistake the removed Back to Home row
made. The body above is `flex-1`, so it yields the height and the
response stays whole and scrollable. */}
<div
className="shrink-0 space-y-2 border-t border-white/60 px-3 pb-2.5 pt-2.5"
onFocusCapture={() => setComposerFocused(true)}
onBlurCapture={onComposerBlur}
>
<SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} />
{composer}
<p className="px-1 text-[10px] leading-tight text-ink-4">
Owliver reads this page&apos;s data. Check anything you act on.

View File

@@ -24,7 +24,13 @@ import { cn } from '@/lib/utils';
* Arrow keys move between chips, so the whole set is one tab stop.
*/
/** @param {any} props */
export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center', className = '' }) {
export function PromptChips({
prompts = [], onSelect, max = 0, align = 'center', className = '',
/* Taken out of the tab order while the row is collapsed but still mounted:
a chip inside a zero-height container is invisible, and a Tab that lands on
something invisible is a keyboard user losing their place. */
focusable = true,
}) {
const chipRefs = React.useRef([]);
const visible = max ? prompts.slice(0, max) : prompts;
@@ -59,6 +65,7 @@ export function PromptChips({ prompts = [], onSelect, max = 0, align = 'center',
type="button"
onClick={() => onSelect(prompt)}
onKeyDown={(e) => onKeyDown(e, i)}
tabIndex={focusable ? undefined : -1}
title={prompt.prompt}
className={cn(
/* 14px is the radius `rounded-full` already produces on a one-line

View File

@@ -22,10 +22,42 @@ import { usePageAction } from './PageContext';
* snapshot, and settled blocks must not re-render with it.
*/
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_)/g;
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_|\[[^\]\n]+\]\([^)\s]*\))/g;
const LINK = /^\[([^\]\n]+)\]\(([^)\s]*)\)$/;
/** Inline `**bold**` and `_italic_`. Kept deliberately small — structure is
* carried by blocks, not by markup inside a paragraph. */
/**
* Where a link in an answer is allowed to point.
*
* An allow-list, and it is a security boundary rather than a tidiness rule: the
* text being parsed here was written by a model, and a model reading a document
* that says "link to javascript:…" is exactly the injection §I7 of CLAUDE.md
* calls untrusted input. Anything not on this list renders as the plain text it
* came from — visible, inert, and obvious.
*
* `#` alone is deliberately absent. A bare empty anchor is the shape the
* citation sanitiser removes; one that reaches here is a link to nowhere, and
* showing its label as text is better than an anchor that does nothing.
*/
const isSafeHref = (href) => (
/^\/(?!\/)/.test(href) // in-app route
|| /^#[^\s]+$/.test(href) // an anchor on this page, but not a bare '#'
|| /^https?:\/\//i.test(href) // the open web
|| /^mailto:[^\s]+$/i.test(href)
);
/**
* Inline `**bold**`, `_italic_` and `[label](href)`.
*
* Links were the gap: `markdownToBlocks` never touched them, and this renderer
* had no case for them, so `[staffing policy](#staffing)` reached the reader as
* its own source. Structure is still carried by blocks rather than by markup —
* this stays three constructs, not a markdown library.
*
* A citation-shaped link never arrives here at all. `[337042b3](#)` is removed
* upstream in `provider.js`, by the wrapper's shape, before a block is built —
* so the two concerns stay apart: the sanitiser decides what is an internal
* reference, and this decides how a real link looks.
*/
function Inline({ value }) {
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
@@ -36,6 +68,33 @@ function Inline({ value }) {
if (part.startsWith('_') && part.endsWith('_')) {
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
}
const link = LINK.exec(part);
if (link) {
const [, label, href] = link;
if (!isSafeHref(href)) return part;
/* An in-app route goes through the router, like every other internal link
in this file — a full page load would throw away the conversation the
reader is being pointed away from. Everything else is an anchor, and
anything leaving the app opens away from it. */
const external = /^https?:\/\//i.test(href);
const className = 'font-medium text-krow-blue underline decoration-krow-blue/30 underline-offset-2'
+ ' transition-colors hover:decoration-krow-blue focus-visible:outline-none'
+ ' focus-visible:ring-2 focus-visible:ring-krow-blue/50 rounded-sm';
if (href.startsWith('/')) return <Link key={i} to={href} className={className}>{label}</Link>;
return (
<a
key={i}
href={href}
className={className}
{...(external ? { target: '_blank', rel: 'noreferrer noopener' } : null)}
>
{label}
</a>
);
}
return part;
});
}

View File

@@ -53,7 +53,22 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
return {
id: 'agent',
async *stream({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
/**
* Every snapshot leaves through here, and every snapshot is sanitised.
*
* The wrapper is the point. Below it there are four ways a response gets
* built — streamed deltas, a completed run, a bounded run's trailing
* message, and the two failure notes — and only one of them passes through
* the markdown parser that removes citation ids. Sanitising at the yield
* rather than at each construction means a fifth way, added later, cannot
* reintroduce the leak by forgetting a call.
*/
async *stream(request) {
for await (const snapshot of this.run(request)) yield sanitizeBlocks(snapshot);
},
/** The run itself. Public only so `stream` can wrap it; call `stream`. */
async *run({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
/* No agent, no run. The panel resolves which agent covers the page before
calling; reaching here without one means the routing layer changed and
this should say so rather than guess at an agent id. */
@@ -169,7 +184,9 @@ async function* readRunStream(response, signal) {
if (typeof event.delta === 'string') {
text += event.delta;
yield markdownToBlocks(text);
/* Still arriving: the frontier rules apply, so a citation split
across two frames is never rendered half-written. */
yield markdownToBlocks(text, { partial: true });
continue;
}
if (event.run) final = event.run;
@@ -226,6 +243,290 @@ function toBlocks(run) {
return blocks;
}
/* ── Citations ──────────────────────────────────────────────────────────── */
/**
* Why any of this exists.
*
* The backend asks the model to cite: `knowledge/context.go` tells it "Each
* <source> carries an id: cite it when you use what it says", and the
* `knowledge_search` tool repeats it. What neither does is say HOW — so the
* model picks a format, and picks a different one on a different day. The ids
* themselves are `knowledge_chunks.id`, which are UUIDs, and the model quotes
* them whole or truncated to their first block.
*
* The panel has no citation surface to render any of that into, so whatever
* shape the model chose arrives on screen as raw markup. The formats seen so
* far are a `<cite>` tag and a markdown link to an empty anchor; the rules
* below are written against the SHAPE of an identifier rather than against
* either format's syntax, so a third spelling of the same idea is far more
* likely to be caught than to be a new bug.
*
* A citation id is hex and dashes — a UUID or a leading run of one. That is
* what makes `10 applications`, `97% coverage` and `113 shifts` safe: decimal
* counts in prose are not addresses, are never inside citation syntax, and no
* rule here looks at a bare number.
*/
const CITATION_ID = String.raw`[0-9a-f]{4,}(?:-[0-9a-f]{4,})*`;
/**
* The tag spelling — `<cite id="…">…</cite>`, whatever attributes it carries.
*
* Matched by TAG NAME, never by id: the ids are minted per run, so a rule
* written against the ones in today's output would let tomorrow's through.
*/
const CITATION_TAG = /<\/?cit(?:e|ation)\b[^>]*>/gi;
/**
* The link spelling, and the brackets the model wraps a run of them in —
* `([337042b3](#), [2b94bc43](#))`.
*
* Identified by two conditions TOGETHER, never either alone: the target must be
* a bare `#` anchor, AND the label must look like an identifier rather than
* words. A real link has a real href, a real anchor link has a destination
* after the `#`, and a link a person would click has a label they could read.
* Requiring both is what keeps `[staffing policy](#staffing)`, `[Read more](/docs)`
* and even `[Read more](#)` on screen.
*
* The group is removed whole rather than link by link, because removing them
* one at a time leaves `(, )` behind — which reads worse than the ids did.
*/
const CITATION_GROUP = new RegExp(
String.raw`\s*\(\s*\[${CITATION_ID}\]\(#\)(?:\s*,\s*\[${CITATION_ID}\]\(#\))*\s*\)`,
'gi'
);
const CITATION_LINK = new RegExp(String.raw`\s*\[${CITATION_ID}\]\(#\)`, 'gi');
/**
* The prose spelling: the model narrating the attribute rather than marking it
* up — `(id `f34e8ef0-…`)`, `(reference `…`)`, `(source: `…`)`.
*
* Why this exists is the same reason the other two do. `context.go` hands the
* model `<source id="…">` and tells it to cite the id without saying how, so
* the model reaches for whatever syntax feels natural that day. This one is not
* markup at all — it is the id written out in a parenthesis, which is why no
* tag rule and no link rule saw it.
*
* THE WRAPPER IS WHAT IDENTIFIES IT, NEVER THE ID. That distinction is the
* whole rule and it has to survive future edits: a worker's record id is the
* same shape as a chunk id, so a rule that recognised ids rather than wrappers
* would delete real data off a profile. `Worker ID: f34e8ef0-…` has no
* parenthesis, so nothing here reaches it; `(id `f34e8ef0-…`)` is a
* parenthesis containing a reference word and nothing but ids, which is not a
* shape prose takes for any other reason.
*
* Backticks are optional on each side independently, because a model that opens
* a code span and forgets to close it before the bracket must not defeat this.
*/
const REF_WORD = String.raw`(?:ids?|refs?|references?|sources?|citations?)`;
const TICKED_ID = String.raw`\x60?${CITATION_ID}\x60?`;
const CITATION_LABELLED = new RegExp(
String.raw`\s*\(\s*${REF_WORD}\s*:?\s*${TICKED_ID}(?:\s*,\s*${TICKED_ID})*\s*\)`,
'gi'
);
/**
* Ids in square brackets with nothing else in them — `[uuid]`, `[uuid, uuid]`.
*
* A markdown link is `[label](target)`; a bracket holding only identifiers is
* not a link and is not something prose does. The lookahead leaves anything
* followed by `(` to the link rules, so a genuine link whose label happens to
* be a reference number keeps its destination and stays on screen.
*/
/* Stricter than CITATION_ID, and only here. A bare bracket has no reference
word to disambiguate it, so the id itself has to carry the evidence: at least
eight hex characters, or a dashed group. Without that `[2026]` is four hex
digits and a year in brackets would disappear from an answer. */
const BRACKETABLE_ID = String.raw`(?:[0-9a-f]{8,}|[0-9a-f]{4,}(?:-[0-9a-f]{4,})+)`;
const CITATION_BRACKETED = new RegExp(
String.raw`\s*\[\s*\x60?${BRACKETABLE_ID}\x60?(?:\s*,\s*\x60?${BRACKETABLE_ID}\x60?)*\s*\](?!\()`,
'gi'
);
/**
* Whatever is still arriving at the end of the text.
*
* The streaming half of the problem, and it is a real one rather than a
* theoretical one: `readRunStream` re-parses the WHOLE accumulated answer on
* every delta, so a citation split across two frames is a state the reader can
* see. `…before a first shift ([3370` renders for as long as the next delta
* takes to arrive.
*
* Both rules are anchored to the end of the text, so they can only ever
* describe the frontier of the stream and never something the answer has
* already moved past. The fragment is held back until it completes, at which
* point the rules above remove it properly — which is buffering, expressed as
* a parse rather than as a second copy of the text.
*/
const CITATION_TAG_PARTIAL = /<\/?[a-z]*$|<\/?cit(?:e|ation)\b[^>]*$/i;
const CITATION_LINK_PARTIAL = new RegExp([
/* An open bracket holding at least one COMPLETE citation and not yet closed:
`…shift ([337042b3](#)` and `…shift ([337042b3](#), `. Without this the
complete link is removed by the rule above and the `(` is stranded. */
String.raw`\s*\((?:\s*\[${CITATION_ID}\]\(#\)\s*,?)+\s*(?:\[[0-9a-f-]*(?:\](?:\(#?)?)?)?$`,
/* One part-written, with or without its bracket: `([3370`, `[337042b3`,
`[337042b3](`, `[337042b3](#`. */
String.raw`\s*\(?\s*\[[0-9a-f-]*(?:\](?:\(#?)?)?$`,
].join('|'), 'i');
/**
* The same two, part-written.
*
* `Policy (id `f34e8ef0-9a01-5921` is a state the reader can see, because the
* stream re-parses everything on every delta. Both are anchored to the end, so
* they describe only the frontier.
*
* The labelled rule accepts any short leading word rather than only a reference
* word, because `(i` and `(id` are prefixes of one and `(` is a prefix of
* everything. The cost is that an ordinary parenthetical is held back for the
* frames between its bracket and its first non-hex character —
* `Omar Haddad (hired 2026-01-0` waits, `…(hired 2026-01-04) starts` does not.
* A parenthesis arriving a frame late is not something a reader can notice; a
* half-written reference id is exactly what they reported.
*/
const CITATION_LABELLED_PARTIAL = new RegExp(
String.raw`\s*\((?:[a-z]{0,12}[\s:]*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?)?$`,
'i'
);
const CITATION_BRACKETED_PARTIAL = new RegExp(
String.raw`\s*\[\s*\x60?[0-9a-f-]*(?:\s*,\s*\x60?[0-9a-f-]*)*\x60?$`,
'i'
);
/**
* What a lifted citation leaves behind.
*
* `check ()`, `check (, )`, `check .` — the wrapper is gone and its punctuation
* is not, and a sentence ending in an empty bracket reads as broken markup
* rather than as a clean sentence. Applied after the removals, never before.
*/
const EMPTY_PARENS = /\s*\(\s*[,;]*\s*\)/g;
const SPACE_BEFORE_PUNCTUATION = /\s+([.,;:!?])/g;
const DOUBLED_SPACES = / {2,}/g;
/**
* Removes citation markup, keeping the sentence inside it.
*
* The wrapper is addressing, not content — it tells a client which retrieved
* chunk a claim came from — and with nowhere to render it the honest move is to
* show the claim and drop the envelope. The backend's citation metadata is
* untouched: it is still on the run, still in the trajectory, and this only
* decides what reaches a reader.
*
* Content is never altered, only the wrapper around it, so markdown inside a
* citation — bold, a bullet, a table row — parses exactly as it would have
* unwrapped.
*
* If the panel ever grows a real citation affordance, this is the seam: parse
* the ids out here into a block the renderer can draw, rather than discarding
* them. Nothing else has to move.
*/
export function stripCitations(markdown, { partial = false } = {}) {
let out = String(markdown ?? '')
.replace(CITATION_TAG, '')
.replace(CITATION_GROUP, '')
.replace(CITATION_LABELLED, '')
.replace(CITATION_BRACKETED, '');
/**
* The frontier rules, and ONLY while there is a frontier.
*
* They describe something that is still being written, so they are wrong to
* apply to a finished answer — and quietly so. `…and note [12]` ends in a
* bracket holding two hex characters, which is indistinguishable from the
* first two characters of an id still arriving. Mid-stream, holding it back
* for a frame is right. At the end of a completed answer there is nothing
* more coming, the bracket is all there will ever be, and removing it deletes
* a footnote marker from the reader's answer.
*
* The caller knows which it is: `readRunStream` passes `partial` on a delta
* and not on the final snapshot. That is the only place the distinction
* exists, so it is the only place it can be made.
*
* Order is load-bearing. `…shift ([337042b3](#),` is a COMPLETE link inside
* an unclosed bracket — strip the link first and `(,` is left on screen,
* which is the broken bracket this exists to prevent. Matching the
* unterminated group first takes the whole fragment.
*/
if (partial) {
out = out
.replace(CITATION_TAG_PARTIAL, '')
.replace(CITATION_LINK_PARTIAL, '')
.replace(CITATION_LABELLED_PARTIAL, '')
.replace(CITATION_BRACKETED_PARTIAL, '');
}
return out
.replace(CITATION_LINK, '')
.replace(EMPTY_PARENS, '')
.replace(SPACE_BEFORE_PUNCTUATION, '$1')
.replace(DOUBLED_SPACES, ' ');
}
/**
* Keys that carry text a person reads, on any block.
*
* An allow-list rather than a deny-list, because the two mistakes do not cost
* the same: missing a display field leaks an id, while sanitising an address
* field would corrupt a confirmation token, a route or a record id and break
* what it points at. A new block type gets its display keys covered for free; a
* new addressing key is safe by default.
*/
const DISPLAY_KEYS = new Set([
'text', 'sub', 'label', 'title', 'summary', 'caption',
'description', 'detail', 'note', 'heading', 'hint',
]);
/** Keys whose value is a list of sentences rather than one. */
const DISPLAY_LISTS = new Set(['items', 'warnings', 'details', 'lines', 'rows']);
/**
* Citation-proofs a whole response, whatever shape it arrived in.
*
* `markdownToBlocks` strips the model's markdown, and for a completed answer
* that is the whole story. It is NOT the whole story for the response: the same
* provider also emits `note(run.message)` when a run did not complete,
* `note(event.error.message)` when the stream fails, and `confirmation(payload)`
* carrying server wording composed around model-supplied arguments. None of
* those go through the markdown parser, so each was a way for an id to reach
* the DOM without passing the one place that removes them.
*
* Rather than a `stripCitations` call at each — three sites today, and a fourth
* the next time the provider learns to say something — every block the agent
* provider yields goes through here.
*
* Walks recursively so nested shapes are reached (a table's rows, a
* confirmation's warnings, an insight's items) and touches only the keys above:
* `token`, `id`, `route`, `to` and everything else addressing-like is left
* exactly as the server sent it.
*/
export function sanitizeBlocks(blocks) {
return (blocks || []).map((block) => sanitizeValue(block, null));
}
function sanitizeValue(value, key) {
if (typeof value === 'string') {
return DISPLAY_KEYS.has(key) || DISPLAY_LISTS.has(key) ? stripCitations(value) : value;
}
if (Array.isArray(value)) {
/* The key travels into the elements, strings and objects alike. A string in
`items` is display text; an object in `rows` is a row, and only the key
says so — its own cell keys are positional (`c0`, `c1`) and carry no
meaning at all. An object in `columns` still defers to its own keys. */
return value.map((entry) => sanitizeValue(entry, key));
}
if (value && typeof value === 'object') {
const out = {};
for (const [k, v] of Object.entries(value)) {
/* A table row is `{ c0: 'Applied', c1: '4' }` — the cell keys are
positional and carry no meaning, so the row itself marks them. */
out[k] = key === 'rows' && typeof v === 'string' ? stripCitations(v) : sanitizeValue(v, k);
}
return out;
}
return value;
}
/**
* Turns a model's markdown into the block vocabulary the panel already renders.
*
@@ -247,8 +548,8 @@ function toBlocks(run) {
* parser would be a large dependency in exchange for handling footnotes nobody
* writes.
*/
export function markdownToBlocks(markdown) {
const lines = String(markdown).replace(/\r\n/g, '\n').split('\n');
export function markdownToBlocks(markdown, { partial = false } = {}) {
const lines = stripCitations(markdown, { partial }).replace(/\r\n/g, '\n').split('\n');
const blocks = [];
let paragraph = [];
let listItems = null;
@@ -353,7 +654,15 @@ function parseTable(lines, start) {
* left alone — those go through `Inline`, which renders bold properly.
*/
function stripInline(value) {
return String(value).replace(/\*\*(.+?)\*\*/g, '$1').replace(/`(.+?)`/g, '$1').trim();
return String(value)
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/`(.+?)`/g, '$1')
/* A link keeps its label and loses its target. Headings and cells are drawn
as plain strings by their components, so an anchor cannot survive here —
and the label alone reads correctly, where the raw `[label](href)` does
not. `Inline` renders the real thing everywhere a link CAN be one. */
.replace(/\[([^\]\n]+)\]\([^)\s]*\)/g, '$1')
.trim();
}
/**
@@ -438,7 +747,7 @@ export function createAssistantProvider() {
export function createUnconfiguredProvider() {
return {
id: 'unconfigured',
// eslint-disable-next-line require-yield
async *stream() {
yield [note(
'Owliver is not configured on this deployment. Set VITE_AGENT_API and give the '

View File

@@ -1,4 +1,5 @@
import { doc, heading, insights, list, note, skillSection, text } from './blocks';
import { resolveUiEdit } from './uiEdit';
import { ASSISTANT_CONTEXTS } from './contexts';
import { poolFor } from '@/lib/workforce';
import { matchSkill } from '@/lib/skills/registry';
@@ -634,7 +635,7 @@ function declaredAnswer({ skill, capability, question, skillContext }) {
*/
function resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
companies = [], skillContext = null,
companies = [], postings = null, skillContext = null,
}) {
/**
* One question, one skill, then one way of answering it.
@@ -743,7 +744,7 @@ function resolveSkill({
*/
const registry = flowFor(skill);
if (registry) {
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies } }) };
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies, postings } }) };
}
/**
@@ -866,6 +867,7 @@ export function resolveIntent({
company question. Read off the postings the caller can already see, so it
expands nobody's view — see the `@companies` note in flows/position.js. */
companies = [],
postings = null,
/**
* The active agent, and where the reader is.
*
@@ -880,6 +882,14 @@ export function resolveIntent({
was one. Only the draft flow reads it; a typed question carries none and
resolves exactly as it always did. */
positionId = null,
/**
* The layout session for this page, when there is one.
*
* Carries the tree on screen and whether something is already being
* previewed. Absent — or on a page that composes no tree — every branch below
* resolves exactly as it did before this existed.
*/
ui = null,
}) {
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
@@ -911,9 +921,21 @@ export function resolveIntent({
const draftIntent = resolveDraftAction(question, workforce || { positions: [] }, positionId);
if (draftIntent) return draftIntent;
/**
* 1b. Changing the page itself.
*
* Ahead of the skills because a request to hide a section is about the
* interface, and a skill trigger reading the same words would answer about
* the data behind it. Tightly gated: `resolveUiEdit` returns null unless the
* page composes a tree AND the words name something on it, a registered
* panel type, or the layout — so an ordinary question is never taken.
*/
const uiIntent = resolveUiEdit({ question, ui });
if (uiIntent) return uiIntent;
/* 2. Current page skills — specific triggers, ahead of the general reader. */
const skill = resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses, companies,
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses, companies, postings,
/* The envelope travels beside the collections rather than replacing them:
a resolver reads records, and the envelope says where the reader is. A
source that needs a position still finds it exactly where it always was. */

View File

@@ -0,0 +1,177 @@
import { doc, list, note, text } from './blocks';
import { matchUiEdit } from '@/lib/ui/intent';
import { outlineTree } from '@/lib/ui/inspect';
import { dataSourceLabel } from '@/lib/skills/surfaces';
/**
* Owliver's half of a layout change.
*
* Turns a request into an intent the panel can act on, and into the words that
* go back. The understanding itself is in `lib/ui/intent.js`; this decides what
* to say about it.
*
* Every outcome is one of four kinds, and the split matters:
*
* - `ui-preview` an operation to show, not to keep
* - `ui-apply` / `ui-discard` acting on what is already shown
* - `ui-answer` a question back, or a refusal — nothing changes
*
* A preview is never applied in the same turn. The person asked for a change;
* they have not yet seen it, and agreeing to something unseen is not agreement.
*/
/** The chips offered while something is being previewed. */
const PREVIEW_CHIPS = [
{ label: 'Apply', prompt: 'Apply the layout change' },
{ label: 'Discard', prompt: 'Discard the layout change' },
];
/**
* Read a layout request.
*
* Returns null for anything that is not one, which is most of what is typed —
* and returning null is what leaves every existing Owliver answer exactly as it
* was. The gate is in `matchUiEdit`: a verb alone is never enough.
*/
export function resolveUiEdit({ question, ui }) {
if (!ui?.available) return null;
const match = matchUiEdit(question, {
tree: ui.tree,
registry: ui.registry,
role: ui.role,
previewing: ui.previewing,
/* Which page this is. Owliver may only offer, and only accept, what this
page can actually hold — the same scope the visual editor's picker uses,
so the two can never disagree about what is addable here. */
page: ui.page,
});
if (!match) return null;
switch (match.kind) {
case 'inspect':
return { kind: 'ui-answer', doc: describe(ui.tree, ui.registry) };
case 'apply':
return {
kind: 'ui-apply',
doc: doc(text('Saved. This page will look like this the next time you open it.')),
};
case 'discard':
return {
kind: 'ui-discard',
doc: doc(text('Put back the way it was. Nothing was saved.')),
};
case 'plan':
return {
kind: 'ui-preview',
op: match.op,
doc: doc(
text(`${match.summary}. This is a preview — nothing is saved yet.`),
note('Choose Apply to keep it, or Discard to put it back.')
),
followUp: PREVIEW_CHIPS,
};
/**
* More than one thing fits.
*
* Named back rather than guessed at. Editing the wrong section while
* somebody is looking at another one is the failure the whole target
* resolver exists to avoid, and a coin toss here would reintroduce it.
*/
case 'ambiguous':
return {
kind: 'ui-answer',
doc: doc(
text('More than one part of this page fits that. Which did you mean?'),
list(match.candidates.map((node) => `${node.title || node.label} (${node.id})`))
),
followUp: match.candidates.slice(0, 3).map((node) => ({
label: node.title || node.label,
prompt: `${node.id}`,
})),
};
case 'unknown':
return {
kind: 'ui-answer',
doc: doc(
text('I could not find that on this page.'),
note('Ask what is on this page to see what can be changed.')
),
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
};
/** A type nobody has registered. Offered the real ones rather than invented. */
case 'unknown-type':
return {
kind: 'ui-answer',
doc: doc(
text('I do not have that kind of panel.'),
text(`I can use: ${match.offered.join(', ')}.`)
),
};
/**
* A shape with no reading named.
*
* The one place a data source could be invented, and the place it is most
* firmly refused: the choices come from the closed vocabulary, and the
* person picks.
*/
case 'needs-source':
return {
kind: 'ui-answer',
doc: doc(
text(`What should the ${match.type.label} show?`),
list(match.options.map(dataSourceLabel))
),
followUp: match.options.slice(0, 3).map((id) => ({
label: dataSourceLabel(id),
prompt: `Add a ${match.type.label} showing ${dataSourceLabel(id)}`,
})),
};
/**
* Asked to apply or discard with nothing being previewed.
*
* Answered here rather than left to fall through, because falling through
* sent the panel's own chip text to the model, which replied — correctly
* for what it is — that layout changes are not in its scope. The honest
* answer is that there is nothing to act on.
*/
case 'nothing-previewed':
return {
kind: 'ui-answer',
doc: doc(
text(match.op === 'apply'
? 'There is nothing to apply — no layout change is being previewed.'
: 'There is nothing to discard — no layout change is being previewed.'),
note('Ask what is on this page to see what can be changed.')
),
followUp: [{ label: 'What is on this page?', prompt: 'What is on this page?' }],
};
case 'refused':
return { kind: 'ui-answer', doc: doc(text(match.message)) };
default:
return null;
}
}
/** What is on the page, as a reading rather than a change. */
function describe(tree, registry) {
const lines = outlineTree(tree, { registry });
if (!lines.length) {
return doc(text('This page is not one I can rearrange yet.'));
}
return doc(
text('This page is made of these parts. You can hide, show or reorder any of them.'),
list(lines),
note('Say for example "hide the audit log" or "move the timeline to the top".')
);
}

View File

@@ -185,6 +185,60 @@ function normalizeDraftChip(chip) {
return chip;
}
/**
* One question, reduced to what it asks.
*
* Case, surrounding space and a trailing question mark are not differences, so
* "What should I do next?" and "what should i do next" are one question and are
* not offered twice.
*/
const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/[?.!]+$/, '');
/**
* The chips to offer after an answer: what this conversation has not covered.
*
* Two rules, and the second is the one that matters. The suggestions are ranked
* by the SERVER against the question just asked — the panel does not decide what
* is worth asking, it only decides what has already been said — and then
* anything this thread has asked or already offered is removed.
*
* Without that second rule the row repeats. A page carries a handful of intents
* and the top of that list barely moves between turns, so the same three chips
* come back after every answer, including the one the reader has just pressed.
* Removing what has been used leaves genuinely new ground each time and runs out
* honestly rather than looping.
*
* There is NO fallback to the page's own ranking, and that is the correction a
* live run forced. Asking "Summarize hiring activity" matches nothing in the
* catalogue, so nothing was excluded, so the fallback returned the page's top
* three — and the reader got "How healthy is the platform right now?" under an
* answer about hiring activity, which is the generic-catalogue behaviour this
* function exists to end. A page ranking is what to ask on a PAGE; it is not a
* follow-up to anything. When the conversation has no next question, the honest
* answer is none.
*/
export async function nextSteps({ question, history, refresh }) {
if (!refresh) return undefined;
const used = new Set([asQuestion(question)]);
for (const message of history) {
if (message.role === 'user') used.add(asQuestion(message.text));
for (const chip of message.followUp || []) used.add(asQuestion(chip.prompt || chip.label));
}
const unused = (chips) => (chips || []).filter((chip) => {
const key = asQuestion(chip.prompt || chip.label);
if (!key || used.has(key)) return false;
/* A list that repeats itself within one turn is the same defect at a
smaller scale. */
used.add(key);
return true;
});
const onTopic = unused(await refresh({ query: question }));
return onTopic.length ? onTopic : undefined;
}
/**
* A stored thread, with any completed-then-reopen action stripped out.
*
@@ -246,6 +300,14 @@ export function useConversation({
* "and now employee roles too". This is one entry in a map.
*/
flowWriters = {},
/**
* The page's layout session, when the surface has one.
*
* Read for the tree Owliver inspects and called to preview or keep a change.
* Absent on every page that composes no tree, and every branch that touches
* it checks first — so the panel behaves exactly as it did before on those.
*/
uiEditing = null,
onAssignWorkers, onScheduleInterview,
/* Finishing a draft: the same two mutations the Create Position form calls.
Passed in rather than reached for, so this layer still writes nothing
@@ -256,6 +318,9 @@ export function useConversation({
/* The clients this organization already staffs for, offered as chips on the
company question. Derived from postings the caller can already read. */
companies = [],
/* The caller's own postings, which is where role-to-certification relevance
is observed from. See `certificationsForRole`. */
postings = null,
/* The worker profiles a declared role can be recorded against, as
`{ id, name, email }`. Same rule: already-loaded, already-permitted rows. */
workers = [],
@@ -491,7 +556,7 @@ export function useConversation({
skill,
...advanceFlow({
registry: flowFor(skill), flow: flowRef.current, answer: text, skill,
ctx: { roles, companies, workers },
ctx: { roles, companies, workers, postings },
}),
};
}
@@ -507,11 +572,24 @@ export function useConversation({
question: text,
contextId: turnContext,
disabledSkills: turnDisabled,
customSkills, roles, skillCategories, companies,
customSkills, roles, skillCategories, companies, postings,
courses, workforce, skillContext, positionId,
agent: turnAgent,
agentCoversPage: turnCovers,
agentSuggestion, owliverContext,
ui: uiEditing
? {
available: Boolean(uiEditing.tree?.length),
tree: uiEditing.tree,
previewing: uiEditing.previewing,
role: uiEditing.role || null,
registry: uiEditing.registry || undefined,
/* Where the reader is standing. What can be added here is a
property of the page, not of the registry, and this is how
the conversation learns it. */
page: uiEditing.page || null,
}
: null,
}),
/* Only while a real agent is behind the panel. With the local
simulator there is nothing better to defer TO, and deferring
@@ -520,6 +598,37 @@ export function useConversation({
);
}
/**
* A layout change, acted on before the reply says what happened.
*
* Three kinds, and the split is the safety property: a preview is shown and
* nothing is stored; an apply keeps what was already shown; an answer — a
* question back, or a refusal — changes nothing at all. Owliver never
* applies in the same turn it proposes.
*
* `propose` validates against the tree on screen and refuses rather than
* previewing something that could not be kept, so a refusal here is
* reported in the words the engine gave rather than a generic apology.
*/
if (intent.kind === 'ui-preview' && uiEditing) {
const result = uiEditing.propose(intent.op);
if (!result.ok) {
intent = {
...intent,
kind: 'ui-answer',
doc: doc(textBlock(result.problems[0]?.message || 'That change is not possible here.')),
followUp: undefined,
};
}
} else if (intent.kind === 'ui-apply' && uiEditing) {
const result = await uiEditing.apply();
if (!result.ok) {
intent = { ...intent, doc: doc(textBlock('That change could not be saved.')) };
}
} else if (intent.kind === 'ui-discard' && uiEditing) {
uiEditing.discard();
}
/**
* The one step that writes. It happens before the reply rather than after,
* because the reply is the outcome — "Position created successfully" has to
@@ -785,9 +894,53 @@ export function useConversation({
// half-written answer loses what they were already reading. Stopping
// before the first block, though, should leave no empty turn behind.
if (latest.length) {
/**
* What to ask NEXT — which is not the same as what is worth asking.
*
* Every other path in this file ends its turn with `followUp`; the
* agent path was the one that did not, so an agent answer was the only
* kind that left the chip row empty.
*
* The first attempt at fixing that asked for the page's untyped
* suggestions, and they are ranked by signal rather than by the
* conversation — so a page with six intents offered its top three, and
* offered the same three after every answer, including the one that had
* just been asked. Three standing highlights repeated verbatim are not
* follow-ups; they are the landing screen redrawn under a reply.
*
* So the question is passed to the server as the query, which ranks the
* same catalogue against what was actually asked, and anything this
* thread has already asked or already offered is removed. What is left
* is what this conversation has not covered yet — which is what a
* follow-up is. When nothing is left, nothing is shown: a panel with
* nothing new to suggest should say so by being quiet, not by repeating
* itself.
*
* A stopped run is offered nothing. The reader interrupted the answer,
* so the next step it implies has not been established.
*/
let followUp;
if (!controller.signal.aborted) {
try {
followUp = await nextSteps({
question: text,
history: messagesRef.current,
refresh: onRefreshSuggestions,
});
} catch {
/* The answer arrived; failing to fetch what to ask next is not a
reason to withhold it. */
}
}
const next = [
...messagesRef.current,
{ role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined },
{
role: 'assistant',
blocks: latest,
stopped: controller.signal.aborted || undefined,
...(followUp ? { followUp } : null),
},
];
messagesRef.current = next;
persist(next);
@@ -810,8 +963,13 @@ export function useConversation({
onGenerateDescription, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses, skillContext, companies, workers,
agent, agentCoversPage, agentSuggestion, owliverContext]);
customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings,
agent, agentCoversPage, agentSuggestion, owliverContext,
/* The layout session changes as a page's composition and the account's
skills resolve, and a stale one means the tree Owliver inspects is the
empty one from the first render — so a layout request falls through to
the model and comes back as "I don't cover that". */
uiEditing]);
const stop = React.useCallback(() => abortRef.current?.abort(), []);

View File

@@ -15,6 +15,11 @@ import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
from the module rather than the package index so a page section never pulls
the assistant panel in behind it. */
import { usePageAction, usePageContext } from '@/components/ai-assistant/PageContext';
/* Which agent is answering on this page. Imported from the module rather than
the package index for the same reason `PageContext` is: a page section must
not pull the assistant panel in behind it. */
import { useActiveAgent } from '@/components/ai-assistant/AgentContext';
import { agentPermitsSkill } from '@/lib/agents/runtime';
/**
* The extension point: one controlled slot a page offers to skills.
@@ -35,23 +40,71 @@ import { usePageAction, usePageContext } from '@/components/ai-assistant/PageCon
*/
/** The skills contributing sections to this page right now. */
function useSkillSections(page, placement) {
/**
* The sections definitions contribute to a page, optionally narrowed to one
* placement.
*
* Exported because the node tree needs the identical reading: a skill's section
* drawn as a child node and the same section drawn by this surface must come
* from one resolution, or the two would disagree about what a page carries.
* Called with no placement it returns every section on the page.
*/
export function useSkillSections(page, placement) {
const preferences = usePreferences();
/**
* Who is answering here.
*
* Read through the same context the panel reads, which is mounted around the
* page as well as around the panel — see `AssistantPanelContext`. Outside a
* provider this returns an inert value, so a surface drawn anywhere else sees
* no agents and no ownership, which is exactly today's behaviour.
*/
const { agent, agents } = useActiveAgent();
const customKey = JSON.stringify(preferences.customSkills || []);
const disabledKey = JSON.stringify(preferences.disabledSkills || []);
/* Stable keys, because these are fresh arrays on every render and a memo
keyed on their identity would recompute forever. */
const ownedKey = JSON.stringify((agents || []).map((a) => a.skills || []));
const mineKey = JSON.stringify(agent?.skills || []);
return useMemo(() => {
const custom = JSON.parse(customKey);
const disabled = JSON.parse(disabledKey);
/**
* Agent ownership, and why it is opt-in.
*
* A skill that an agent claims belongs to that agent: its UI is drawn where
* that agent is answering and nowhere else. A skill that **no** agent claims
* is unowned, and unowned means unchanged — page scope and the account
* switch decide it, exactly as they did before this existed.
*
* That asymmetry is the whole design. Every skill this product ships is
* conversation-only and claimed for conversation; the definitions that draw
* page UI are authored on the account and claimed by nobody. Enforcing
* ownership on all of them would have removed every skill section in the
* product on the day it shipped. Attaching a skill to an agent is therefore
* the act that brings it under an agent's control — a decision an author
* makes in Agent Configure, not one taken on their behalf here.
*
* Three separate ideas meet here and none of them is the others: what the
* *agent* owns, what pages the *skill* declares, and what the *account* has
* switched off. All three must pass.
*/
/* The rule itself lives with the other agent rules, so it can be reasoned
about and tested without rendering a page. */
const scope = { agents: JSON.parse(ownedKey).map((skills) => ({ skills })), agent: { skills: JSON.parse(mineKey) } };
return allSkills(custom)
/* Inactive means registered but not offered — the same rule the assistant
follows, so switching a skill off removes its UI too. */
.filter((skill) => skill.status === 'active' && !disabled.includes(skill.id))
.filter((skill) => agentPermitsSkill(skill.id, scope))
.flatMap((skill) => sectionsForPage(skill, page)
.filter((section) => !placement || section.placement === placement)
.map((section) => ({ skill, section })));
}, [page, placement, customKey, disabledKey]);
}, [page, placement, customKey, disabledKey, ownedKey, mineKey]);
}
/**

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) } } : {}),
},
};
}

View File

@@ -0,0 +1,171 @@
import React from 'react';
import { useUiLayouts } from '@/lib/krowHooks';
import { useSkillSections } from '@/components/skills/SkillSurface';
import { skillNodesByPlacement } from '@/lib/ui/skillNodes';
import { composePage } from '@/lib/ui/composition';
import { applyOperation } from '@/lib/ui/operations';
import { clearOps, emptyPatch, popOp, pushOp } from '@/lib/ui/patch';
import { nodeRegistry } from '@/lib/ui/registry';
/**
* Preview, Apply, and the line between them.
*
* Three states of a UI change live here, and keeping them apart is the whole
* job of this module:
*
* - **Preview** is in React state. It is what the person is trying. It never
* reaches the network, and a reload discards it.
* - **Saved** is the person's own stored operation list, read from and
* written to their account preferences. It survives a reload and a new
* session.
* - **Source** — the page's registered composition and the Markdown skill
* files — is never written by anything here. Not on preview, not on apply.
*
* A proposed operation is validated *before* it becomes a preview, so a change
* that could not be saved is never shown as though it could. Preview and apply
* then run the identical merge through `composePage` and the identical
* renderer, which is what makes a preview honest: there is no second code path
* for the applied state that could disagree with it.
*/
const UiEditingContext = React.createContext(null);
/** The editing session for the page around it. Null outside a provider. */
export const useUiEditing = () => React.useContext(UiEditingContext);
export function UiEditingProvider({ page, role = null, registry = nodeRegistry, children }) {
const { layouts, save, saving } = useUiLayouts();
/**
* What the person is trying, not yet theirs.
*
* Held per page so that navigating away and back does not carry an
* unfinished experiment onto a different surface.
*/
const [preview, setPreview] = React.useState(() => emptyPatch(page));
const [problems, setProblems] = React.useState([]);
/* A page change is a new editing session. Anything unsaved was about the page
that is no longer on screen. */
React.useEffect(() => {
setPreview(emptyPatch(page));
setProblems([]);
}, [page]);
const saved = layouts[page] || emptyPatch(page);
/**
* What this page's definitions contribute, as nodes.
*
* Resolved here because it needs the account's custom skills and disabled
* list, and handed to `composePage` so that module stays a pure function.
* The result is that a Board skill's card is a node in the same tree as the
* page's own sections — addressable by the same operations, and hidden or
* moved by a patch rather than by editing the definition.
*/
const sections = useSkillSections(page);
const skillNodes = React.useMemo(() => skillNodesByPlacement(sections), [sections]);
/**
* The tree on screen: what the application ships, with what the person saved,
* with what they are trying, in that order.
*/
const composed = React.useMemo(
() => composePage(page, { patch: saved, preview, registry, role, skillNodes }),
[page, saved, preview, registry, role, skillNodes]
);
/**
* Try an operation.
*
* Validated against the tree as it currently stands — saved changes included
* — so an operation is judged against what the person is actually looking at.
* A refusal returns the reasons and changes nothing; there is no partially
* applied preview.
*/
const propose = React.useCallback((op) => {
const result = applyOperation(composed.tree, op, { registry, role });
if (!result.ok) {
setProblems(result.problems);
return result;
}
setProblems([]);
setPreview((current) => pushOp(current, op));
return result;
}, [composed.tree, registry, role]);
/** Throw the experiment away. Nothing was stored, so nothing is undone. */
const discard = React.useCallback(() => {
setPreview(emptyPatch(page));
setProblems([]);
}, [page]);
/**
* Keep it.
*
* The preview's operations are appended to what was already saved and written
* as one list. The preview is only cleared once the write resolves, so a
* failed save leaves the person looking at the change they asked for rather
* than watching it disappear with an error beside it.
*/
const apply = React.useCallback(async () => {
if (!preview.ops.length) return { ok: true, saved };
const next = { ...saved, page, ops: [...saved.ops, ...preview.ops], updatedAt: new Date().toISOString() };
const result = await save(page, next);
if (result?.persisted === false) {
setProblems([{ at: null, message: result.error || 'That change could not be saved.' }]);
return { ok: false, saved };
}
setPreview(emptyPatch(page));
return { ok: true, saved: next };
}, [preview, saved, page, save]);
/**
* Undo one step.
*
* The most recent thing first: an unsaved operation if there is one, and only
* then a saved one. Undoing a saved change is a write, because the saved list
* is the record of what the person chose.
*/
const undo = React.useCallback(async () => {
if (preview.ops.length) {
setPreview((current) => popOp(current));
return { ok: true };
}
if (!saved.ops.length) return { ok: true };
await save(page, popOp(saved));
return { ok: true };
}, [preview, saved, page, save]);
/** Back to the page as the application ships it. Clears both tiers. */
const reset = React.useCallback(async () => {
setPreview(emptyPatch(page));
setProblems([]);
if (saved.ops.length) await save(page, clearOps(saved));
return { ok: true };
}, [page, saved, save]);
const value = React.useMemo(() => ({
page,
tree: composed.tree,
/* Operations that no longer apply — a saved change naming a section a
release has since removed. Surfaced so a page can say so quietly rather
than leaving the person wondering why nothing happened. */
skipped: composed.skipped,
saved,
preview,
problems,
previewing: preview.ops.length > 0,
customised: saved.ops.length > 0,
saving,
propose,
discard,
apply,
undo,
reset,
}), [
page, composed, saved, preview, problems, saving, propose, discard, apply, undo, reset,
]);
return <UiEditingContext.Provider value={value}>{children}</UiEditingContext.Provider>;
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
/**
* One node's blast radius.
*
* A section is a component like any other, and a component can throw. Without
* this, one that did took the entire application down: adding Hired History's
* chronology to Candidates Analysis — which the picker offered, and which the
* registry now refuses — left the section destructuring `hires` and `filtered`
* from a render context that publishes neither, and `undefined.length` unmounted
* the whole tree to a white screen with no way back but a reload.
*
* The scope fix means that particular node can no longer be placed there. This
* exists because that was never the only way to get here: a page can rename
* what it publishes, a release can change a section's data shape, and a saved
* layout is replayed months after it was made. A layout a person saved must
* never be able to cost them the page.
*
* So a node that throws renders as a node that could not be drawn — in place,
* named, and still in the tree, so the editor can still select it and the
* conversation can still hide or remove it. Everything around it keeps working.
*
* Deliberately not a retry: the same props render the same failure, and a
* boundary that re-throws in a loop is worse than one that stops. It resets when
* the node it is holding changes, which is what makes removing the broken node
* put the page right without a reload.
*/
export class UiNodeBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { failed: false };
}
static getDerivedStateFromError() {
return { failed: true };
}
/**
* Reset when the node changes.
*
* Without this, hiding or removing a failed node would leave the boundary
* latched and the placeholder on screen — the fix applied, and invisible.
*/
static getDerivedStateFromProps(props, state) {
if (state.failed && state.forNode !== props.node?.id) return { failed: false, forNode: props.node?.id };
return state.forNode === props.node?.id ? null : { ...state, forNode: props.node?.id };
}
componentDidCatch(error) {
/* The node, not just the stack: the stack names React, and what a person
debugging this needs is which section and which page. */
// eslint-disable-next-line no-console
console.error(`[ui] node "${this.props.node?.id}" (${this.props.node?.type}) failed to render`, error);
}
render() {
if (!this.state.failed) return this.props.children;
const { node } = this.props;
return (
<section
data-ui-node={node?.id}
data-ui-type={node?.type}
data-ui-failed="true"
className="rounded-2xl border border-dashed border-border bg-surface-subtle p-4"
>
<p className="text-body-sm font-semibold text-ink-2">
{node?.props?.title || 'This section could not be shown'}
</p>
<p className="mt-0.5 text-caption text-ink-4">
It is still on the page and can be hidden or removed from Customise layout.
</p>
</section>
);
}
}

View File

@@ -0,0 +1,308 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { nodeRegistry } from '@/lib/ui/registry';
import { UiNodeBoundary } from './UiNodeBoundary';
import { composePage } from '@/lib/ui/composition';
import { useUiEditing } from './UiEditingProvider';
/**
* Draws a UI tree.
*
* One recursive renderer for every node type there will ever be. It looks a
* type up in the registry and renders the component that registration named —
* and that is the whole of its knowledge. There is no branch on a type here, no
* component name held as a string, no dynamic import, and no path by which
* configuration becomes code. A node names a key; the key was registered by a
* module the bundler resolved at build time; anything else renders nothing.
*
* That is what makes agent-authored UI safe. The worst a malformed or hostile
* configuration can do is name a type that does not exist, and the answer to
* that is an empty space — never an evaluated string, never injected markup.
*
* **It adds no markup of its own.** A node's component renders its own root and
* receives `attrs` to spread onto it, so a migrated page emits the elements it
* always did. The one exception is a type that declares `wrap`, for components
* that cannot forward unknown props; those get a bare `div` whose only purpose
* is to carry the node's identity.
*/
/**
* What a page hands its own sections.
*
* A page's built-in nodes need the page's own state — its filtered rows, its
* loading flag, its handlers — and threading that through the tree as props
* would make the renderer know what a page contains. So the page publishes one
* opaque bag and its sections read what they need out of it. The renderer never
* looks inside.
*
* Deliberately separate from `PageContext`, which is the panel's read-only view
* of a page's *records*. This is a page talking to its own parts.
*/
const UiRenderContext = React.createContext(null);
/** The bag the page published. Empty when a component is rendered outside a tree. */
export const useUiContext = () => React.useContext(UiRenderContext) || {};
/**
* The node currently being drawn.
*
* Lets a section know its own id without being passed it — which is what a
* future selection affordance needs, and what keeps the identity in one place
* rather than repeated in every registration.
*/
const UiNodeContext = React.createContext(null);
export const useUiNode = () => React.useContext(UiNodeContext);
/**
* The DOM attributes that make a node addressable.
*
* Two, not one: the id answers "which node is this", and the type answers "what
* is it" without a lookup. Both are `data-` attributes, so they carry no
* styling and cannot collide with anything the design system uses.
*/
export const nodeAttrs = (node) => ({
'data-ui-node': node.id,
'data-ui-type': node.type,
});
/**
* The grid a container arranges its children in.
*
* Only emitted when a node actually asks for columns. A node with no layout
* renders its children exactly as a page would have written them, which is what
* lets an existing page migrate without its markup changing.
*/
function layoutClass(layout) {
if (!layout?.columns) return null;
/* Written out rather than interpolated: Tailwind scans source text for class
names, and `grid-cols-${n}` is invisible to that scan and absent from the
build. One column is not a grid at all. */
const columns = {
1: null,
2: 'grid grid-cols-1 sm:grid-cols-2',
3: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid grid-cols-2 lg:grid-cols-4',
6: 'grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-6',
12: 'grid grid-cols-2 sm:grid-cols-4 xl:grid-cols-6',
}[layout.columns] || 'grid grid-cols-1 sm:grid-cols-2';
const gap = { none: 'gap-0', sm: 'gap-2', md: 'gap-4', lg: 'gap-6' }[layout.gap] || 'gap-4';
return cn(columns, columns && gap);
}
/**
* The margin a node asks for above or below itself.
*
* Written out rather than interpolated, for the same reason the column classes
* are: Tailwind scans source text, and a class assembled at run time is absent
* from the build. Four steps, each a class the app already uses.
*/
export function spacingClasses(layout) {
/**
* Marked important, and that is not a shortcut.
*
* Pages stack their sections with `space-y-*`, which Tailwind implements as
* `.space-y-6 > :not([hidden]) ~ :not([hidden]) { margin-top: … }` — two
* classes and a pseudo-class, so it outranks a plain `mt-6` on the child no
* matter which is written last. The class landed on the element, the computed
* margin never moved, and the page looked identical: exactly the failure this
* whole change exists to end, one level further down.
*
* `none` is therefore a real value rather than the absence of one. Asking for
* no space above has to be able to say so, or the container's default is
* simply unopposable.
*
* Still a closed set: four steps, each written out, each a class Tailwind can
* see in this file. There is no path here from a stored value to arbitrary
* CSS — an unknown value maps to nothing.
*/
const before = {
none: '!mt-0', sm: '!mt-2', md: '!mt-4', lg: '!mt-6',
}[layout?.spacingBefore];
const after = {
none: '!mb-0', sm: '!mb-2', md: '!mb-4', lg: '!mb-6',
}[layout?.spacingAfter];
return cn(before, after) || undefined;
}
/**
* Everything a node's own layout asks of the element it is drawn as.
*
* `layoutClass` above is the other half and answers a different question: it is
* the grid a *container* arranges its children in. This is what a node asks for
* *itself* — the margin above and below it, how many columns of its parent's
* grid it occupies, and how it sits in the row.
*
* It existed only as spacing, and only one component ever called it. Everything
* else — the nine readings a person can add, and every section a definition
* draws — took `layout` as a prop and dropped it, so an operation validated,
* changed the tree, persisted, and produced no visible change at all. A layout
* value that cannot be seen is worse than one that is refused.
*
* **Every class is written out.** Tailwind scans source text, so a class
* assembled at run time is absent from the build and would silently do nothing —
* the same failure in a new place. That is also what keeps this closed: these
* are the only classes a layout value can ever produce, there is no path from
* configuration to arbitrary CSS, and a value outside the vocabulary maps to
* nothing rather than to itself.
*/
export function layoutClasses(layout) {
const span = {
1: 'col-span-1', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4',
5: 'col-span-5', 6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8',
9: 'col-span-9', 10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12',
}[layout?.span];
const align = {
start: 'self-start', center: 'self-center', end: 'self-end', stretch: 'self-stretch',
}[layout?.align];
return cn(spacingClasses(layout), span, align) || undefined;
}
/**
* The panel a node draws itself as.
*
* The closed half of variant and density: two short maps, every class written
* out so Tailwind can see it, and nothing that reads a value from configuration
* into a class name. A value outside the vocabulary yields the default, so the
* worst a malformed patch can do is look ordinary.
*
* `default` and `comfortable` reproduce **exactly** the panel every reading
* section has always drawn — that is what lets this ship without changing a
* single existing page, and what the migration baselines check.
*
* Returned as parts rather than one string because a component composes them
* with its own classes and needs to control the order.
*/
export function presentationClasses(presentation) {
const variant = {
default: 'border-border bg-surface shadow-xs',
subtle: 'border-border bg-surface-subtle shadow-none',
emphasis: 'border-krow-blue/40 bg-krow-blue-tint/30 shadow-md ring-1 ring-krow-blue/20',
}[presentation?.variant] || 'border-border bg-surface shadow-xs';
const density = {
comfortable: { padding: 'p-5', gap: 'mb-3', title: 'text-body' },
compact: { padding: 'p-3', gap: 'mb-1.5', title: 'text-body-sm' },
}[presentation?.density] || { padding: 'p-5', gap: 'mb-3', title: 'text-body' };
return { variant, ...density };
}
/** One node: its component, its identity, and its children if it holds any. */
function UiNode({ node, registry }) {
const entry = registry.get(node.type);
/* Hidden is a user's decision and is honoured before anything else, so a
hidden node costs nothing to render. */
if (node.hidden) return null;
/* Validation refuses an unknown type long before a tree is rendered, so
reaching here means the registry and a stored patch have drifted — a type
removed by a release, most likely. Render nothing rather than throw: one
stale node must not take the page down. */
if (!entry) return null;
const Component = entry.component;
const attrs = nodeAttrs(node);
const children = entry.container
? <UiTree nodes={node.children} registry={registry} layout={node.layout} />
: null;
const drawn = (
<Component
node={node}
attrs={entry.wrap ? {} : attrs}
layout={node.layout}
presentation={node.presentation}
{...node.props}
>
{children}
</Component>
);
/* Wrapped so a section that throws costs the page that section, not the
application. See `UiNodeBoundary`. */
return (
<UiNodeContext.Provider value={node}>
<UiNodeBoundary node={node}>
{entry.wrap ? <div {...attrs}>{drawn}</div> : drawn}
</UiNodeBoundary>
</UiNodeContext.Provider>
);
}
/**
* A list of nodes.
*
* Renders a bare fragment unless a layout was asked for, so the nodes sit
* directly inside whatever container the page already had — and a page that
* migrates its sections into the tree keeps the spacing it always had.
*/
function UiTree({ nodes, registry, layout = null }) {
const grid = layoutClass(layout);
const drawn = (nodes || []).map((node) => (
<UiNode key={node.id} node={node} registry={registry} />
));
return grid ? <div className={grid}>{drawn}</div> : <>{drawn}</>;
}
/**
* Render a composed page.
*
* `context` is the bag the page publishes for its own sections. `nodes` is the
* merged tree — built-ins with the user's saved changes and any preview already
* folded in by `composePage`, so this component neither reads storage nor knows
* that a patch exists.
*/
export function UiTreeRenderer({ nodes = [], context = null, registry = nodeRegistry }) {
/* Identity-stable across renders so a page's sections do not remount every
time the page re-renders for an unrelated reason. */
const value = React.useMemo(() => context || {}, [context]);
return (
<UiRenderContext.Provider value={value}>
<UiTree nodes={nodes} registry={registry} />
</UiRenderContext.Provider>
);
}
/**
* One node of a page's tree, drawn where the page puts it.
*
* For a page that is only **partly** composed. Positions is the case this
* exists for: its two page-level extension slots sit at fixed points in a large
* hand-written layout, and migrating that whole layout is a separate piece of
* work. Until then the page keeps its own JSX and renders the composed nodes it
* does have, each in the place it has always been.
*
* The consequence is worth being clear about: nodes anchored this way can be
* hidden, and their children moved and reordered, but reordering the page's
* *roots* has nowhere to happen — there is no single container drawing them in
* sequence. A fully composed page has no such limit.
*
* `page` is named rather than inferred so the component works during a baseline
* capture, where no editing session is mounted.
*/
export function UiNodeSlot({ page, id, context = null, registry = nodeRegistry }) {
const editing = useUiEditing();
const node = React.useMemo(() => {
const tree = editing?.page === page && editing.tree
? editing.tree
: composePage(page, { registry }).tree;
return (tree || []).find((candidate) => candidate.id === id) || null;
}, [editing, page, id, registry]);
if (!node || node.hidden) return null;
/* A container with nothing in it draws nothing — the rule `SkillSurface` has
always followed, and the reason an empty extension point costs no space on
the pages that have no definitions for it. */
const entry = registry.get(node.type);
if (entry?.container && !node.children?.length) return null;
return <UiTreeRenderer nodes={[node]} context={context} registry={registry} />;
}

View File

@@ -0,0 +1,176 @@
import React from 'react';
import {
Bar, BarChart, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { cn } from '@/lib/utils';
import { AXIS_PROPS, CHART_COLORS, CHART_TONES } from '@/components/ds/ChartContainer';
import { SECTION_TYPES } from '@/lib/skills/surfaces';
import { useSkillDataContext } from '@/components/skills/SkillSurface';
import { resolveSkillData } from '@/lib/skills/dataResolver';
import { DENSITY_VALUES, VARIANT_VALUES } from '@/lib/ui/node';
import { registerNodeType } from '@/lib/ui/registry';
import { layoutClasses, presentationClasses } from './UiTreeRenderer';
/**
* Two ways of drawing a series: as bars, and as parts of a whole.
*
* These are the first node types that are **not** skill section types. The nine
* readings exist in `surfaces.js` because a Markdown definition may name them;
* chart and pie are named by nobody but this registry, so adding them here adds
* nothing to the MD vocabulary, obliges no change in the Go parser, and cannot
* drift from its conformance oracle. A person reaches them by turning an
* existing node into one.
*
* **Nothing is transformed and nothing is invented.** A `flow` or a `stats`
* reading already resolves to `{ steps: [{ id, label, value }] }` — a labelled
* numeric series, which is precisely what a bar chart and a pie chart each
* need. So these draw the same payload the Flow section draws, from the same
* resolver, keyed by the same source. There is no adapter layer here because
* there is nothing to adapt.
*
* The consequence is the compatibility rule, and it is the existing one: a
* source can become a chart exactly when it declares `flow` or `stats`, decided
* by `sourceSupportsShape` like every other replacement. No new shape, no new
* vocabulary, no per-source table.
*/
/** The series a reading resolved to, in the one shape both charts read. */
function useSeries(node) {
const context = useSkillDataContext(null);
const section = React.useMemo(() => ({
id: node.id,
/* `flow` because that is the shape whose payload these draw. Resolvers are
keyed by source and never read this, but a section without a type is not
a section, and naming the shape it consumes keeps that honest. */
type: 'flow',
source: node.data?.source || '',
periods: node.data?.params?.periods || [],
limit: node.data?.params?.limit || null,
}), [node.id, node.data]);
const data = React.useMemo(() => resolveSkillData(section, context), [section, context]);
const rows = (data.steps || data.items || [])
.map((step, i) => ({
id: step.id || `${i}`,
label: String(step.label || step.title || ''),
value: Number(step.value) || 0,
}))
/* A slice of nothing is not a slice, and a bar of nothing is a gap. Both
charts read real numbers or draw the empty note. */
.filter((row) => row.label);
return { data, rows };
}
/** How tall the plot is, and therefore how big a pie fits in it. */
const plotHeight = (presentation) => (presentation?.density === 'compact' ? 160 : 240);
/** The panel both share — the same chrome every reading section draws. */
function ChartPanel({ node, attrs, layout, presentation, title, children, empty }) {
const look = presentationClasses(presentation);
const height = plotHeight(presentation);
return (
<section
{...attrs}
aria-label={title || undefined}
className={cn('rounded-2xl border', look.variant, look.padding, layoutClasses(layout))}
>
{title && (
<div className={look.gap}>
<h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>{title}</h3>
</div>
)}
{empty
? <p className="text-caption text-ink-3">{empty}</p>
: <div style={{ height }}>{children}</div>}
</section>
);
}
/** Bars, one per step, in the brand's own colour. */
function ChartNode({ node, attrs = {}, layout = null, presentation = null, title = null }) {
const { data, rows } = useSeries(node);
const empty = !rows.length ? (data.emptyNote || 'Nothing to chart yet.') : null;
return (
<ChartPanel node={node} attrs={attrs} layout={layout} presentation={presentation} title={title} empty={empty}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<XAxis dataKey="label" {...AXIS_PROPS} interval={0} height={40} />
<YAxis {...AXIS_PROPS} allowDecimals={false} />
<Tooltip cursor={{ fill: CHART_TONES.mint, fillOpacity: 0.25 }} />
<Bar dataKey="value" fill={CHART_TONES.brand} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartPanel>
);
}
/** The same series as parts of a whole. */
function PieNode({ node, attrs = {}, layout = null, presentation = null, title = null }) {
const { data, rows } = useSeries(node);
const total = rows.reduce((sum, row) => sum + row.value, 0);
/* A pie of nothing is a blank disc, so an all-zero series is empty rather
than drawn — the reading has values, they are simply all zero. */
const empty = !rows.length
? (data.emptyNote || 'Nothing to chart yet.')
: (total === 0 ? 'Every value here is zero.' : null);
const height = plotHeight(presentation);
const radius = { outer: Math.round(height * 0.38) };
return (
<ChartPanel node={node} attrs={attrs} layout={layout} presentation={presentation} title={title} empty={empty}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
{/* Centre and radii given explicitly, in pixels.
Percentage radii with no `cx`/`cy` produced sectors that existed in
the DOM, carried no fill and drew nothing — a blank panel that
every automated check called a success because the elements were
there. The one pie already in this repository does it this way,
and it works. */}
<Pie
data={rows}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
outerRadius={radius.outer}
isAnimationActive={false}
>
{rows.map((row, i) => (
<Cell key={row.id} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</ChartPanel>
);
}
/* The shapes whose payload both of these draw, read from the vocabulary rather
than written out, so a shape renamed there cannot leave these claiming one
that no longer exists. */
const SERIES_SHAPES = ['flow', 'stats'].filter((shape) => SECTION_TYPES.some((t) => t.id === shape));
for (const [type, label, summary, component] of [
['chart', 'Chart', 'A bar for each step in a reading.', ChartNode],
['pie', 'Pie', 'The same steps as parts of a whole.', PieNode],
]) {
registerNodeType({
type,
label,
summary,
component,
dataShapes: SERIES_SHAPES,
dataRequired: true,
propSchema: { title: { type: 'string', label: 'Title' } },
variants: VARIANT_VALUES,
/* Both densities change the plot height, which is the one thing a chart has
to give. Declared because they are honoured, not because they exist. */
densities: DENSITY_VALUES,
capabilities: ['update', 'remove', 'move', 'replace', 'hide'],
});
}

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { SUPPORTED_SECTION_TYPES } from '@/lib/skills/surfaces';
import { layoutClasses } from './UiTreeRenderer';
import { registerNodeType } from '@/lib/ui/registry';
/* The nine reading components, registered from the skill vocabulary. Imported
here so one import gives a page every type it can offer. */
import './sectionNodeTypes';
/* The two visualisation types. Not skill sections — see the note in the file. */
import './chartNodeTypes';
/**
* Node types every page can use.
*
* Page-specific sections register beside their own page; this file is for the
* types that belong to no page in particular. Today that is one: the slot a
* skill definition renders into.
*/
/**
* A skill surface, as a node.
*
* This is the join between the two systems, and it is deliberately thin. A
* `ui:` block in a skill definition is still normalized by `uiConfig.js`,
* resolved by `dataResolver.js` and drawn by `SkillSurface` exactly as it is
* today — nothing about Board skills changes. What changes is that the *slot*
* is now a node, so a person can move the extension point up the page or hide
* it, using the same operations that move a built-in section.
*
* It renders **nothing of its own**. `SkillSurface` already returns `null` when
* no definition claims the placement, and that must stay true: a slot that
* rendered an empty wrapper would add a gap to every page it sits on, on every
* account that has authored no skills — which is nearly all of them. So this
* type does not declare `wrap`, and accepts having no DOM identity while it is
* empty over changing what an empty page looks like.
*/
/**
* The slot, as a container.
*
* It draws nothing of its own and holds no logic: the sections that belong to
* it are children in the tree, put there by `composePage`, and the renderer
* walks them like any other children. That is what makes a Board skill's card
* addressable — it is a node, not something a component conjured up while
* rendering.
*
* Empty means **nothing**, not an empty box. `SkillSurface` has always returned
* `null` where no definition claims a placement, and every page that has not
* migrated still calls it directly. A slot that rendered a wrapper regardless
* would put a gap on every page of every account that has authored no skills,
* which is nearly all of them.
*/
function SkillSurfaceNode({ node, children, layout }) {
if (!node.children?.length) return null;
/* The same spacing `SkillSurface` puts between sections, plus whatever margin
the page's composition asked for — which is how a slot keeps the exact
separation it had before it became a node. */
return <div className={cn('space-y-4', layoutClasses(layout))}>{children}</div>;
}
registerNodeType({
type: 'skill-surface',
label: 'Skill sections',
summary: 'Where definitions authored for this page render.',
component: SkillSurfaceNode,
container: true,
/* Only readings may sit in a slot — the nine the skill format already allows.
Derived from the vocabulary rather than listed, so the two cannot drift. */
accepts: SUPPORTED_SECTION_TYPES,
/* Moving and hiding the slot is meaningful; replacing it with a chart is not,
and neither is deleting the only way a page can be extended. Its children
are separately addressable and carry their own capabilities. */
capabilities: ['move', 'hide', 'reorder'],
/**
* Never something a person adds.
*
* A slot exists because a page offered an extension point at a particular
* spot, and a second one conjured up by a picker would anchor nothing — no
* definition names it, so it would render empty forever. It was offered and
* then refused with "'Skill sections' cannot be added to", which is the
* product asking a question it already knew the answer to. It is a container
* and an anchor; the two are not the same claim.
*/
addable: false,
/**
* Which slot this is.
*
* A page mounts several, and they all share one label — so an outline listed
* "Skill sections" twice with nothing to choose between them, and "hide the
* skill sections" had no answer. The placement is the only thing that
* distinguishes them and it is already on the node, so it is what they are
* called: "Skill sections (after header)".
*/
describe: (node) => {
const placement = String(node?.props?.placement || '').trim();
if (!placement) return 'Skill sections';
return `Skill sections (${placement.replace(/-/g, ' ')})`;
},
propSchema: {
page: { type: 'string', required: true, label: 'Page' },
placement: { type: 'string', required: true, label: 'Placement' },
},
});

View File

@@ -0,0 +1,155 @@
import React from 'react';
import { Sparkles } from 'lucide-react';
import { SECTION_COMPONENTS } from '@/components/skills/SkillSections';
import { useSkillDataContext } from '@/components/skills/SkillSurface';
import { usePageAction } from '@/components/ai-assistant/PageContext';
import { resolveSkillData } from '@/lib/skills/dataResolver';
import { SECTION_TYPES, dataSourceFor } from '@/lib/skills/surfaces';
import { DENSITY_VALUES, VARIANT_VALUES } from '@/lib/ui/node';
import { registerNodeType } from '@/lib/ui/registry';
import { layoutClasses, presentationClasses } from './UiTreeRenderer';
import { cn } from '@/lib/utils';
/**
* The nine reading components, registered as node types.
*
* These are the same components a Board skill draws with — `SECTION_COMPONENTS`
* from `SkillSections.jsx`, resolved through the same `resolveSkillData` and the
* same closed data vocabulary. Registering them here is what makes "add a card"
* and "change this to a table" mean something: a person can put one of the
* product's own readings on a page without authoring a skill for it.
*
* Nothing about skills changes. A `ui:` block still renders through
* `SkillSurface` exactly as before; this is a second consumer of the same
* renderer, which is the arrangement `SkillSections.jsx` was already built for —
* the page and the chat panel were the first two.
*
* **A node of these types cannot invent data.** It carries a data *binding*, not
* data: a source id from the closed vocabulary, resolved at render time against
* records the caller already has. A binding naming something that is not a
* source is refused by validation before it can be previewed, let alone saved.
*/
/**
* One reading, drawn.
*
* The `section` shape is assembled from the node rather than from a Markdown
* definition, but it is the same shape `normalizeSection` produces — which is
* why the resolver and the component take it without knowing which of the two
* built it.
*/
function ReadingNode({
node, attrs = {}, layout = null, presentation = null, title = null, description = null,
attribution = null, editable = false, type,
}) {
const Component = SECTION_COMPONENTS[type];
const context = useSkillDataContext(null);
const section = React.useMemo(() => ({
id: node.id,
type,
title: title || null,
description: description || null,
source: node.data?.source || '',
periods: node.data?.params?.periods || [],
limit: node.data?.params?.limit || null,
/* What the reading needs the page to have open. Read off the source, so a
node cannot claim a context its source never declared. */
context: dataSourceFor(node.data?.source)?.context,
editable: Boolean(editable),
}), [node.id, node.data, title, description, editable, type]);
const data = React.useMemo(() => resolveSkillData(section, context), [section, context]);
/* How this section writes back, if the page is offering that write at all —
the same rule `SkillSurface` applies, so a section declared editable on a
page that does not own the data stays an honest read-out. */
const apply = usePageAction(section.editable ? section.source : null);
/* Validation refuses a binding-less reading long before this, so reaching here
without one means a stored patch outlived a vocabulary change. Draw nothing
rather than an empty panel with a title. */
/* The closed map, read once. Not a hook, so it sits with the other derived
values and changes nothing about when this component re-renders. */
const look = presentationClasses(presentation);
if (!Component || !node.data?.source) return null;
/**
* Two chromes, one component.
*
* A section contributed by a skill is drawn exactly as `SkillSurface` has
* always drawn it — same panel, same heading, same attribution pill — because
* moving a Board card into the node tree must not change how it looks. A node
* a person added has no skill to attribute, so it gets the plain panel. The
* difference is a property, not a branch on where the node came from.
*/
return (
<section
{...attrs}
aria-label={section.title || attribution || undefined}
/* The panel this draws, as its own presentation describes it, plus
whatever its layout asks for. Both helpers return the existing values
when a node asks for nothing, so a section nobody has customised
renders exactly the markup it did before — which is what the migration
baselines check. */
className={cn('rounded-2xl border', look.variant, look.padding, layoutClasses(layout))}
>
{(section.title || attribution) && (
<div className={cn(look.gap, 'flex flex-wrap items-start justify-between gap-2')}>
<div className="min-w-0">
<h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>
{section.title}
</h3>
{section.description && (
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{section.description}</p>
)}
</div>
{attribution && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-krow-blue-tint px-2 py-0.5 text-[10px] font-semibold text-krow-blue">
<Sparkles className="h-3 w-3" aria-hidden="true" />
{attribution}
</span>
)}
</div>
)}
<Component data={data} section={section} onApply={apply} />
</section>
);
}
for (const { id, label, summary } of SECTION_TYPES) {
registerNodeType({
type: id,
label,
summary,
/* Bound per type so the component does not have to read its own name back
out of the node it was handed. */
component: (props) => <ReadingNode {...props} type={id} />,
/* All nine draw the same panel through `ReadingNode`, so all nine can
honour the whole vocabulary. Declared from it rather than listed, so a
value added to the product reaches every reading without an edit here —
and a value removed cannot be left behind claiming support. */
variants: VARIANT_VALUES,
densities: DENSITY_VALUES,
/* The one shape this component draws. Validation pairs it against what a
source can fill, so `table` accepts only sources that have a table in
them — the same rule `normalizeSection` already applies to a skill. */
dataShapes: [id],
dataRequired: true,
propSchema: {
title: { type: 'string', label: 'Title' },
description: { type: 'string', label: 'Description' },
/* The skill that contributed this section, when one did. Carried as a
property rather than inferred from `origin`, so the renderer stays
ignorant of provenance. */
attribution: { type: 'string', label: 'Contributed by' },
editable: { type: 'boolean', label: 'Editable' },
},
/* Everything, because unlike a built-in page section these are nodes a
person put there: they can be removed as well as hidden. */
capabilities: ['update', 'remove', 'move', 'replace', 'hide'],
});
}

View File

@@ -15,6 +15,8 @@ import {
} from '@/components/ui/dropdown-menu';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { AssistantPanel, AssistantPanelProvider } from '@/components/ai-assistant';
import { UiEditingProvider } from '@/components/ui-tree/UiEditingProvider';
import { pageKeyForRoute } from '@/lib/skills/registry';
import { endAdminSession } from '@/lib/admin/session';
import { useCurrentUser } from '@/lib/krowHooks';
@@ -177,8 +179,22 @@ export default function AdminLayout() {
);
};
/**
* The layout session for whichever page is open.
*
* Mounted here rather than inside a page because both the page and the panel
* beside it need the same one: the page renders the tree, and Owliver
* proposes changes to it. Two providers would be two trees, and the preview
* shown in chat would be of a page nobody was looking at.
*
* A page that has registered no composition simply has an empty tree, so this
* costs nothing on the surfaces that have not migrated yet.
*/
const uiPage = pageKeyForRoute(location.pathname) || '';
return (
<AssistantPanelProvider role="admin" pathname={location.pathname}>
<UiEditingProvider page={uiPage}>
{/* Transparent so the ambient canvas painted behind the app reads through.
An opaque shell here would cover it and every Admin page would lose the
tint at once — which is exactly why the canvas is one layer and not a
@@ -354,6 +370,7 @@ export default function AdminLayout() {
/>
</div>
</div>
</UiEditingProvider>
</AssistantPanelProvider>
);
}

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

View File

@@ -1,15 +1,12 @@
import React, { useMemo, useState } from 'react';
import {
Activity, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert, UserCheck, UserPlus,
Users,
} from 'lucide-react';
import {
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
} from '@/components/ds';
import { useUserActivity } from '@/lib/krowHooks';
import { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { AdminPage } from '@/components/admin/PageShell';
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { composePage } from '@/lib/ui/composition';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/activity/nodes';
/**
* Admin Activity — an audit centre.
@@ -33,35 +30,9 @@ const SEVERITY = {
apply_job: 'low',
};
const SEVERITY_META = {
high: { label: 'High', variant: 'destructive' },
medium: { label: 'Medium', variant: 'warning' },
low: { label: 'Low', variant: 'neutral' },
};
/** Severity carried through to the timeline node, so the two views agree. */
const TIMELINE_TONE = { high: 'destructive', medium: 'warning', low: 'neutral' };
/** How many events the timeline shows before the table takes over. */
const TIMELINE_LIMIT = 12;
/**
* An icon per event type. Worth the table: on a chronology the glyph is what
* makes a run of hires distinguishable from a run of logins at a glance, which is
* the whole reason to show a timeline rather than another list of rows.
*/
const EVENT_ICON = {
hire_candidate: UserCheck,
assign_employee: Users,
create_position: FilePlus2,
screen_candidate: ScanSearch,
start_interview: Mic,
apply_job: Send,
signup: UserPlus,
login: LogIn,
logout: LogOut,
};
/** Stable pseudo-IP from the email, so the demo shows metadata without inventing
* a value that changes on every render. */
const ipFor = (email) => {
@@ -143,162 +114,42 @@ export default function AdminActivity() {
return [...groups.entries()];
}, [recent]);
/**
* What this page's sections read.
*
* Published once, as one bag. The renderer passes it down untouched and never
* looks inside, which is what keeps it ignorant of what an Activity page
* contains — and what lets the next page publish something entirely
* different without the renderer changing.
*/
const context = useMemo(() => ({
isLoading, events, users, actions, filtered, isFiltered, clearFilters,
search, setSearch, user, setUser, action, setAction, severity, setSeverity, range, setRange,
highRisk, last24h, recent, byDay,
}), [
isLoading, events, users, actions, filtered, isFiltered,
search, user, action, severity, range, highRisk, last24h, recent, byDay,
]);
return (
<AdminPage
title="Activity"
subtitle="Monitor workforce operations, user actions and system events."
>
<SkillSurface page="activity" placement="after-header" />
<MetricStrip
columns={4}
loading={isLoading}
items={[
{ label: 'Total events', value: events.length },
{ label: 'Last 24 hours', value: last24h.length, tone: last24h.length ? 'default' : 'warning' },
{ label: 'Privileged actions', value: highRisk.length, tone: 'warning', sub: 'hires and position changes' },
{ label: 'Distinct users', value: users.length },
]}
/>
{highRisk.length > 0 && (
<Surface variant="solid" radius="lg" padding="sm" elevation="xs" className="border-warning/30 bg-warning-muted">
<div className="flex items-start gap-2.5">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" aria-hidden="true" />
<p className="text-body-sm text-ink-2">
<span className="font-semibold text-ink-1">{highRisk.length} privileged actions</span> in this log —
hires and position changes. These should always trace to a named person.
</p>
</div>
</Surface>
)}
{/* The recent timeline. Deliberately above the log and deliberately not
filtered: this answers "what just happened", which is a different
question from the one the toolbar below exists to ask. Reading a
chronology is also how an auditor starts — sequence first, then
interrogate the specifics. */}
<section aria-labelledby="timeline" className="space-y-3">
<SectionTitle
id="timeline"
title="Operational timeline"
meta={`Most recent ${recent.length} events`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{recent.length ? (
<div className="divide-y divide-border">
{byDay.map(([day, dayEvents]) => (
<div key={day} className="px-4 py-3.5">
<p className="mb-3 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
{day}
</p>
<Timeline
compact
items={dayEvents.map((e) => ({
id: e.id,
icon: EVENT_ICON[e.event_type] || Activity,
tone: TIMELINE_TONE[e.severity],
title: e.event_type.replace(/_/g, ' '),
description: e.details || undefined,
meta: `${e.user_name || e.user_email}${e.account_type ? ` · ${e.account_type}` : ''}`,
timestamp: new Date(e.created_date).toLocaleTimeString(undefined, {
hour: 'numeric', minute: '2-digit',
}),
}))}
/>
</div>
))}
</div>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
No activity has been logged yet.
</p>
)}
</Surface>
</section>
<section aria-labelledby="audit" className="space-y-3">
<SectionTitle id="audit" title="Audit log" meta={`${filtered.length} of ${events.length} events`} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search events" size="sm" />}
filters={
<>
<FilterSelect value={user} onChange={setUser} label="User"
options={[{ value: 'all', label: 'All users' }, ...users.map((u) => ({ value: u, label: u }))]} />
<FilterSelect value={action} onChange={setAction} label="Action"
options={[{ value: 'all', label: 'All actions' }, ...actions.map((a) => ({ value: a, label: a.replace(/_/g, ' ') }))]} />
<FilterSelect value={severity} onChange={setSeverity} label="Severity" options={[
{ value: 'all', label: 'All severities' }, { value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' },
]} />
<FilterSelect value={range} onChange={setRange} label="Date" options={[
{ value: 'all', label: 'All time' }, { value: '24h', label: 'Last 24 hours' },
{ value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' },
]} />
</>
}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={20}
isFiltered={isFiltered}
onClearFilters={clearFilters}
caption="Platform audit log"
rowClassName={(e) => (e.severity === 'high' ? 'bg-warning-muted/40' : undefined)}
columns={[
{
key: 'user_name', header: 'User', sortable: true,
cell: (e) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={e.user_name || e.user_email} size="xs" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{e.user_name || '—'}</p>
<p className="truncate text-[10px] text-ink-4">{e.user_email}</p>
</div>
</div>
),
},
{
key: 'event_type', header: 'Action', sortable: true,
cell: (e) => <span className="whitespace-nowrap">{e.event_type.replace(/_/g, ' ')}</span>,
},
{
key: 'details', header: 'Entity', hideBelow: 'md',
cell: (e) => <span className="line-clamp-1 text-ink-3">{e.details || '—'}</span>,
},
{
key: 'account_type', header: 'Role', hideBelow: 'lg',
cell: (e) => <Badge variant={e.account_type === 'employer' ? 'info' : 'neutral'} size="sm">{e.account_type || 'unknown'}</Badge>,
},
{
key: 'ip', header: 'Source IP', hideBelow: 'lg',
cell: (e) => <span className="font-mono text-[11px] text-ink-4">{e.ip}</span>,
},
{
key: 'created_date', header: 'Timestamp', align: 'right', sortable: true,
sortValue: (e) => new Date(e.created_date).getTime(),
cell: (e) => (
<span className="whitespace-nowrap text-[11px] text-ink-4">
{new Date(e.created_date).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
})}
</span>
),
},
{
key: 'severity', header: 'Severity', align: 'right',
cell: (e) => (
<Badge variant={SEVERITY_META[e.severity].variant} size="sm">
{SEVERITY_META[e.severity].label}
</Badge>
),
},
]}
/>
</section>
<SkillSurface page="activity" placement="before-footer" />
{/* The editing session is mounted by the layout, so the panel beside this
page shares it. The page only renders what it is handed, which is why
a preview in chat and the applied state cannot diverge. */}
<UiEditor />
<ActivityComposition context={context} />
</AdminPage>
);
}
/** The composed page. Split out so it can read the tree the provider computed. */
function ActivityComposition({ context }) {
const editing = useUiEditing();
/* Outside a layout — a test, a storybook — there is no session, and the page
still has to draw. It falls back to the composition as shipped. */
const tree = editing?.tree || composePage('activity').tree;
return <UiTreeRenderer nodes={tree} context={context} />;
}

View File

@@ -165,6 +165,7 @@ export default function AdminAgentDetail() {
/* A new agent needs an id before it has an address. Derived from the name
so the author never has to invent one. */
const composed = applyAgentFields(baseSource, fields);
const problem = validateAgentSource(composed);
if (problem) { toast.error(problem); return null; }

View File

@@ -1,16 +1,13 @@
import React, { useMemo } from 'react';
import {
Activity, Award, Clock, Gauge, Lightbulb, Target, TrendingUp, TriangleAlert,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge, MetricStrip, Surface } from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
import { HiringFlow } from '@/components/charts/HiringFlow';
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
import { useSize } from '@/hooks/use-size';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { AdminPage } from '@/components/admin/PageShell';
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import { composePage } from '@/lib/ui/composition';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/analytics/nodes';
import {
buildEfficiency, buildFunnel, buildInsights, buildTrend, byDepartment, byPosition,
hiresWithFill, summarise,
@@ -38,101 +35,6 @@ import {
* cannot disagree.
*/
const INSIGHT_TONE = {
success: { ring: 'border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20', dot: 'text-emerald-600 dark:text-emerald-400', Icon: TrendingUp },
warning: { ring: 'border-amber-500/30 bg-amber-50/40 dark:bg-amber-950/20', dot: 'text-amber-600 dark:text-amber-400', Icon: TriangleAlert },
risk: { ring: 'border-red-500/30 bg-red-50/40 dark:bg-red-950/20', dot: 'text-red-600 dark:text-red-400', Icon: TriangleAlert },
info: { ring: 'border-border bg-surface', dot: 'text-krow-blue', Icon: Lightbulb },
};
/** One finding, with the evidence underneath it. */
function Insight({ item }) {
const tone = INSIGHT_TONE[item.tone] || INSIGHT_TONE.info;
const { Icon } = tone;
return (
<li className={cn('flex items-start gap-2.5 rounded-xl border p-3.5', tone.ring)}>
<Icon className={cn('mt-0.5 h-4 w-4 shrink-0', tone.dot)} aria-hidden="true" />
<div className="min-w-0">
<p className="font-heading text-body-sm font-semibold text-ink-1">{item.title}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.body}</p>
</div>
</li>
);
}
/**
* The funnel, drawn once its container can be measured.
*
* `HiringFlow` renders an MUI bar chart with no explicit width, which means the
* chart measures its parent on mount. On this page that first measure lands
* before the layout has resolved a width — the shell's `main` is a `flex-1`
* column beside the Owliver panel — and the chart warns that it has nothing to
* size itself against. Gating on the measured width means the chart mounts once,
* already knowing how wide it is, instead of mounting into nothing and
* recovering.
*
* The reserved height keeps the section from collapsing and reflowing the page
* on the frame between measure and draw.
*/
function MeasuredFunnel({ funnel }) {
const ref = React.useRef(null);
const size = useSize(ref);
return (
<div ref={ref} className="w-full">
{size?.width ? (
<HiringFlow
stages={funnel.stages}
transitions={funnel.transitions}
weakestKey={funnel.weakestKey}
/>
) : (
<div className="h-[280px] rounded-xl border border-border bg-surface" aria-hidden="true" />
)}
</div>
);
}
/** A ranked comparison row — used for both fastest and slowest to fill. */
function VelocityList({ items, median, tone }) {
if (!items.length) {
return (
<p className="rounded-lg border border-dashed border-border px-3 py-3 text-body-sm text-ink-3">
No role has enough dated hires to measure velocity yet.
</p>
);
}
return (
<ul className="space-y-2">
{items.map((role) => {
const delta = median ? role.avgDays - median : 0;
return (
<li key={role.role} className="flex items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-body-sm font-medium text-ink-1">{role.role}</p>
<p className="text-[10px] text-ink-4">
{role.count} hire{role.count === 1 ? '' : 's'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="font-heading text-body-sm font-bold tabular-nums text-ink-1">
{role.avgDays}d
</span>
{median > 0 && delta !== 0 && (
<Badge variant={tone === 'fast' ? 'success' : 'warning'} size="sm" className="tabular-nums">
{delta > 0 ? '+' : ''}{delta}d
</Badge>
)}
</div>
</li>
);
})}
</ul>
);
}
export default function AdminAnalytics() {
const { data: staff = [], isLoading } = useStaff();
const { data: applications = [] } = useApplications();
@@ -154,211 +56,24 @@ export default function AdminAnalytics() {
[hires, departments, positions, funnel, efficiency]
);
const context = useMemo(() => ({
summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications,
}), [summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications]);
return (
<AdminPage
title="Analytics"
subtitle="How hiring is performing — rates, trends, comparison and what to act on."
>
<SkillSurface page="analytics" placement="after-header" />
{/* 1. Hiring performance — the four figures the rest of the page explains. */}
<section aria-labelledby="performance" className="space-y-3">
<SectionTitle id="performance" title="Hiring performance" meta="Across the workspace" />
<MetricStrip
columns={4}
loading={isLoading}
items={[
{ label: 'Total hires', value: summary.total, icon: Award, tone: 'brand' },
{
label: 'Avg time-to-hire',
value: summary.speed ? `${summary.speed}d` : '—',
icon: Clock,
sub: efficiency.median ? `${efficiency.median}d median` : undefined,
},
{
label: 'Quality of hire',
value: summary.quality || '—',
icon: TrendingUp,
tone: summary.quality >= 80 ? 'success' : 'default',
sub: 'avg AI score',
},
{
label: 'Conversion rate',
value: `${funnel.conversion}%`,
icon: Target,
sub: `${funnel.stages[4].count} of ${funnel.stages[0].count} applicants`,
},
]}
/>
</section>
{/* 2. The funnel — where candidates are lost, which is the finding this
page exists to surface. */}
<section aria-labelledby="funnel" className="space-y-3">
<SectionTitle
id="funnel"
title="Hiring funnel"
meta="Applied → Screened → Shortlisted → Interview → Hired"
/>
{funnel.stages[0].count ? (
<MeasuredFunnel funnel={funnel} />
) : (
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<p className="text-body-sm leading-relaxed text-ink-3">
No applications on file yet. The funnel appears once the first candidate applies.
</p>
</Surface>
)}
</section>
{/* 3. Trend over time. */}
<section aria-labelledby="trend" className="space-y-3">
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
<HiringTrendChart
points={trend}
emptyState={(
<div className="px-4 py-8 text-center">
<p className="font-heading text-body font-semibold text-ink-1">
Not enough history for a trend
</p>
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
{hires.length
? `All ${hires.length} hire${hires.length === 1 ? '' : 's'} closed in ${trend[0]?.label || 'a single month'}. A month-on-month line appears once hiring spans a second month.`
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
</p>
</div>
)}
/>
</section>
{/* 4. Department comparison. */}
<section aria-labelledby="departments" className="space-y-3">
<SectionTitle
id="departments"
title="Department performance"
meta={`${departments.length} department${departments.length === 1 ? '' : 's'} compared`}
/>
<DepartmentPerformance items={departments} />
</section>
{/* 5. Position comparison — the same question one level down. */}
<section aria-labelledby="positions" className="space-y-3">
<SectionTitle
id="positions"
title="Position performance"
meta={`${positions.length} role${positions.length === 1 ? '' : 's'} filled`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] border-collapse text-body-sm">
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
<th
key={h}
scope="col"
className={cn(
'whitespace-nowrap px-3.5 py-2.5 text-[10px] font-bold uppercase tracking-wider text-ink-4',
i ? 'text-right' : 'text-left'
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{positions.map((r) => (
<tr key={r.role} className="border-b border-border/60 last:border-0 transition-colors hover:bg-surface-subtle/80">
<td className="px-3.5 py-2.5 font-medium text-ink-1">{r.role}</td>
<td className="px-3.5 py-2.5 text-right font-semibold tabular-nums text-ink-2">{r.count}</td>
<td className="px-3.5 py-2.5 text-right font-bold tabular-nums text-blue-600 dark:text-blue-400">{r.avgScore || '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
<td className="px-3.5 py-2.5 text-right">
{r.avgRating != null
? <Badge variant="success" size="sm" className="font-bold">{r.avgRating}/5</Badge>
: <Badge variant="warning" size="sm" className="font-bold">Pending</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
{/* 6. Efficiency — velocity against this workspace's own median, so the
comparison is one an operator can check. */}
<section aria-labelledby="efficiency" className="space-y-3">
<SectionTitle
id="efficiency"
title="Hiring efficiency"
meta={efficiency.median ? `${efficiency.median}d median time-to-hire` : 'Not enough dated hires'}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-krow-blue">
<Gauge className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Velocity</h3>
</div>
<p className="mt-2 font-heading text-title font-bold tabular-nums text-ink-1">
{efficiency.median ? `${efficiency.median}d` : '—'}
<span className="ml-1.5 text-caption font-normal text-ink-3">median</span>
</p>
<p className="mt-1 text-caption leading-relaxed text-ink-3">
{efficiency.within48h
? `${efficiency.within48h}% of roles close within 48 hours.`
: 'Velocity appears once hires carry an application date.'}
</p>
</Surface>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
<Activity className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Fastest to fill</h3>
</div>
<div className="mt-3">
<VelocityList items={efficiency.fastest} median={efficiency.median} tone="fast" />
</div>
</Surface>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<Clock className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Slowest to fill</h3>
</div>
<div className="mt-3">
<VelocityList items={efficiency.slowest} median={efficiency.median} tone="slow" />
</div>
</Surface>
</div>
</section>
{/* 7. What the numbers mean. Only findings the data actually supports —
an insight list that is always the same length is decoration. */}
<section aria-labelledby="insights" className="space-y-3">
<SectionTitle
id="insights"
title="AI hiring insights"
meta={insights.length ? `${insights.length} finding${insights.length === 1 ? '' : 's'}` : undefined}
/>
{insights.length ? (
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{insights.map((item) => <Insight key={item.title} item={item} />)}
</ul>
) : (
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<p className="text-body-sm leading-relaxed text-ink-3">
There is not enough hiring on file to draw a finding from yet. Insights appear as
applications, hires and reviews accumulate.
</p>
</Surface>
)}
</section>
<SkillSurface page="analytics" placement="before-footer" />
<UiEditor />
<AnalyticsComposition context={context} />
</AdminPage>
);
}
/** The composed page. */
function AnalyticsComposition({ context }) {
const editing = useUiEditing();
const tree = editing?.tree || composePage('analytics').tree;
return <UiTreeRenderer nodes={tree} context={context} />;
}

View File

@@ -1,12 +1,16 @@
import React, { useMemo, useState } from 'react';
import { Button, SearchInput } from '@/components/ds';
import { useApplications, useInterviews, useJobPostings, useUpdateApplication, useHireCandidate } from '@/lib/krowHooks';
import { base44 } from '@/api/base44Client';
import { toast } from 'react-hot-toast';
import { AdminPage, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import CandidateCard from '@/components/krow/CandidateCard';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { AdminPage } from '@/components/admin/PageShell';
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import { composePage } from '@/lib/ui/composition';
import { SORTS } from '@/pages/admin/candidates/nodes';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/candidates/nodes';
import AIInterviewModal from '@/components/krow/AIInterviewModal';
import ScheduleInterviewModal from '@/components/krow/ScheduleInterviewModal';
import MessageCandidateModal from '@/components/krow/MessageCandidateModal';
@@ -19,12 +23,6 @@ const SCORE_BANDS = {
unscored: (s) => !s,
};
const SORTS = {
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) },
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime() },
name: { label: 'Name A–Z', compare: (a, b) => a.applicant_name.localeCompare(b.applicant_name) },
experience: { label: 'Most experience', compare: (a, b) => (b.years_experience || 0) - (a.years_experience || 0) },
};
export default function AdminCandidates() {
const { data: applications = [], isLoading } = useApplications();
@@ -103,74 +101,21 @@ export default function AdminCandidates() {
const postingById = useMemo(() => Object.fromEntries(postings.map(p => [p.id, p])), [postings]);
const resolveTitle = (a) => a.job_title || postingById[a.job_posting_id]?.title || '';
const context = useMemo(() => ({
applications, filtered, isLoading, isFiltered, clearFilters, positions, resolveTitle,
search, setSearch, position, setPosition, stage, setStage, band, setBand, sort, setSort,
handleAction, setMessageApp, setScheduleApp, handleDecline, handleDelete, handleHire,
}), [applications, filtered, isLoading, isFiltered, positions, search, position, stage, band, sort]);
return (
<AdminPage
title="Candidates"
subtitle="Review, compare and manage the candidate pipeline."
>
<SkillSurface page="candidates" placement="after-header" />
<UiEditor />
<CandidatesComposition context={context} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
filters={
<>
<FilterSelect value={position} onChange={setPosition} label="Position"
options={[{ value: 'all', label: 'All positions' }, ...positions.map((p) => ({ value: p, label: p }))]} />
<FilterSelect value={stage} onChange={setStage} label="Stage" options={[
{ value: 'all', label: 'All stages' }, { value: 'applied', label: 'Applied' },
{ value: 'ai_screened', label: 'AI Screened' }, { value: 'interview', label: 'Interviewing' },
{ value: 'hired', label: 'Hired' }, { value: 'rejected', label: 'Declined' },
]} />
<FilterSelect value={band} onChange={setBand} label="Score" options={[
{ value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' },
{ value: 'strong', label: '60–79 Strong' }, { value: 'weak', label: 'Under 60' },
{ value: 'unscored', label: 'Unscored' },
]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</>
}
meta={`${filtered.length} of ${applications.length} candidates${isFiltered ? ' · filtered' : ''}`}
/>
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-24 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16">
<p className="text-body-sm text-ink-3">No candidates match your filters</p>
{isFiltered && (
<Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}>
Clear filters
</Button>
)}
</div>
) : (
<div className="space-y-4">
{filtered.map((app, idx) => (
<CandidateCard
key={app.id}
application={app}
jobTitle={resolveTitle(app)}
rank={idx + 1}
onAction={handleAction}
onMessage={setMessageApp}
onCall={setMessageApp}
onSchedule={setScheduleApp}
onDecline={handleDecline}
onDelete={handleDelete}
onHire={handleHire}
/>
))}
</div>
)}
<SkillSurface page="candidates" placement="before-footer" />
{/* Modals */}
{/* Modals — overlays rather than sections, so they stay outside the tree. */}
{interviewApp && (
<AIInterviewModal open={true} onClose={() => setInterviewApp(null)} application={interviewApp} job={postings.find(p => p.id === interviewApp.job_posting_id)} />
)}
@@ -183,3 +128,10 @@ export default function AdminCandidates() {
</AdminPage>
);
}
/** The composed page. */
function CandidatesComposition({ context }) {
const editing = useUiEditing();
const tree = editing?.tree || composePage('candidates').tree;
return <UiTreeRenderer nodes={tree} context={context} />;
}

View File

@@ -1,17 +1,17 @@
import React, { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { ArrowRight } from 'lucide-react';
import {
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, InsightList, InsightRow,
MetricStrip, ProgressBar, ProgressRing, Surface,
import { CHART_TONES,
} from '@/components/ds';
import { useApplications, useInterviews, useJobPostings, useStaff, useWorkerProfiles } from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { AdminPage } from '@/components/admin/PageShell';
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import { composePage } from '@/lib/ui/composition';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/candidates-analysis/nodes';
/**
* Admin Candidates Analysis — the analytical counterpart to Candidates.
@@ -161,262 +161,24 @@ export default function AdminCandidatesAnalysis() {
},
].filter(Boolean), [f, skillGaps]);
const context = useMemo(() => ({ navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations }), [
navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations,
]);
return (
<AdminPage
title="Candidates Analysis"
subtitle="Talent supply, quality distribution and pipeline risk."
>
<SkillSurface page="candidates-analysis" placement="after-header" />
<MetricStrip
columns={5}
items={[
{ label: 'In pipeline', value: f.total },
{ label: 'Scored', value: f.scored.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
{ label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'brand' },
{ label: 'Avg score', value: f.avgScore || '—' },
{ label: 'Risk flags', value: risks.length, tone: risks.length ? 'warning' : 'success' },
]}
/>
<div className="grid gap-6 lg:grid-cols-5">
<section aria-labelledby="dist" className="space-y-3 lg:col-span-2">
<SectionTitle id="dist" title="Quality distribution" meta={`${f.total} candidates`} />
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={bands} dataKey="value" nameKey="name" innerRadius={48} outerRadius={78} paddingAngle={2} strokeWidth={0}>
{bands.map((b) => <Cell key={b.name} fill={b.color} />)}
</Pie>
<Tooltip content={<ChartTooltip />} />
</PieChart>
</ResponsiveContainer>
<div className="space-y-1.5">
{bands.map((b) => (
<div key={b.name} className="flex items-center gap-2 text-caption">
<span className="h-2 w-2 shrink-0 rounded-sm" style={{ background: b.color }} aria-hidden="true" />
<span className="flex-1 text-ink-3">{b.name}</span>
<span className="font-semibold tabular-nums text-ink-1">{b.value}</span>
</div>
))}
</div>
</section>
<section aria-labelledby="strongest" className="space-y-3 lg:col-span-3">
<SectionTitle
id="strongest"
title="Strongest candidates"
meta="By AI score"
action={{ label: 'All candidates', onClick: () => navigate('/admin/candidates') }}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
{top.length ? top.map((c, i) => (
<div key={c.id} className="flex items-center gap-3 px-4 py-2.5">
<span className="w-4 shrink-0 text-caption font-bold text-ink-4">{i + 1}</span>
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-ink-1">{c.applicant_name}</p>
<p className="truncate text-[10px] text-ink-4">{c.job_title}</p>
</div>
{c.ai_match_label && (
<Badge variant="soft" size="sm" className="hidden sm:inline-flex">{c.ai_match_label}</Badge>
)}
<div className="w-20 shrink-0">
<ProgressBar value={c.ai_score} tone="score" size="xs" />
</div>
<ProgressRing value={c.ai_score} size={30} strokeWidth={3} tone="score" />
</div>
)) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">No scored candidates yet.</p>
)}
</Surface>
</section>
</div>
<section aria-labelledby="risk" className="space-y-3">
<SectionTitle id="risk" title="Candidate risk" meta={risks.length ? `${risks.length} flags` : 'No flags'} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{risks.length ? (
<InsightList>{risks.map((r, i) => <InsightRow key={i} {...r} />)}</InsightList>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
No material risk flags. Credentials, availability and interview integrity all check out.
</p>
)}
</Surface>
<p className="text-caption text-ink-4">
Flags are questions for a human, not rejections.
</p>
</section>
{/* Skill supply and the gaps in it, side by side: what the pool has, and
what the open roles ask for and nobody offers. */}
<div className="grid gap-6 lg:grid-cols-5">
<section aria-labelledby="skills" className="space-y-3 lg:col-span-3">
<SectionTitle
id="skills"
title="Skill supply"
meta={`Top ${skillSupply.length} across the scored pool`}
/>
{skillSupply.length ? (
<ResponsiveContainer width="100%" height={Math.max(180, skillSupply.length * 30)}>
<BarChart data={skillSupply} layout="vertical" margin={{ top: 0, right: 28, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} horizontal={false} />
<XAxis type="number" {...AXIS_PROPS} allowDecimals={false} />
<YAxis
type="category"
dataKey="skill"
{...AXIS_PROPS}
width={128}
tick={{ fontSize: 11, fill: CHART_TONES.axis }}
/>
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="count" name="Candidates" radius={[0, 4, 4, 0]} maxBarSize={16}>
{/* Tinted by the average score of the people holding the skill,
so breadth and quality read together. */}
{skillSupply.map((s) => (
<Cell
key={s.skill}
fill={s.avgScore >= 75 ? CHART_TONES.brand : s.avgScore >= 60 ? CHART_TONES.accentPale : CHART_TONES.mint}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-body-sm text-ink-3">
Nothing is scored yet, so there is no skill supply to read.
</p>
)}
<p className="text-caption text-ink-4">
Bar length is how many candidates claim the skill. Darker blue means the
people holding it average 75 or above.
</p>
</section>
<section aria-labelledby="gaps" className="space-y-3 lg:col-span-2">
<SectionTitle
id="gaps"
title="Skill gaps"
meta={skillGaps.length ? `${skillGaps.length} unmet` : 'None'}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{skillGaps.length ? (
<ul className="divide-y divide-border">
{skillGaps.map((g) => (
<li key={g.name} className="px-4 py-2.5">
<p className="text-body-sm font-medium text-ink-1">{g.name}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
Required by {g.roles.join(', ')} — held by nobody in the scored pool.
</p>
</li>
))}
</ul>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
Every credential an open role requires is held by someone in the scored pool.
</p>
)}
</Surface>
</section>
</div>
{/* Position fit — which roles can actually be filled from current supply. */}
<section aria-labelledby="fit" className="space-y-3">
<SectionTitle id="fit" title="Position fit" meta={`${positionFit.length} open roles`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[40rem] border-collapse text-body-sm">
<caption className="sr-only">
Applicant supply against candidates clearing the bar, per open role
</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Applied', 'Qualified', 'Avg score', 'Fit'].map((h, i) => (
<th
key={h}
scope="col"
className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{positionFit.map((r) => (
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>
{r.applied}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
<td className="px-4 py-2.5">
<div className="ml-auto w-24">
<ProgressBar
value={r.fit}
tone={r.fit >= 50 ? 'success' : r.fit > 0 ? 'warning' : 'destructive'}
size="xs"
/>
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{r.fit}% qualified</p>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
{/* Recommendations — the actions the analysis above implies. */}
{recommendations.length > 0 && (
<section aria-labelledby="recommend" className="space-y-3">
<SectionTitle
id="recommend"
title="Recommendations"
meta={`${recommendations.length} ordered by impact`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<ol className="divide-y divide-border">
{recommendations.map((r, i) => (
<li key={r.title} className="flex gap-3 px-4 py-3">
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
{i + 1}
</span>
<div className="min-w-0">
<p className="text-body-sm font-medium text-ink-1">{r.title}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{r.body}</p>
</div>
</li>
))}
</ol>
</Surface>
</section>
)}
<section aria-labelledby="screening" className="space-y-3">
<SectionTitle id="screening" title="Screening efficiency" />
<div className="grid gap-4 sm:grid-cols-3">
{[
{ label: 'Coverage', value: `${f.standardizedPct}%`, detail: `${f.scored.length} of ${f.total} scored`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
{ label: 'Interview completion', value: `${f.interviewCompletion}%`, detail: `${f.completedInterviews.length} of ${f.interviews.length} scored` },
{ label: 'Quality lift', value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—', detail: 'hires vs pool average', tone: 'success' },
].map((s) => (
<Surface key={s.label} variant="solid" radius="lg" padding="default" elevation="xs">
<p className="text-[11px] font-medium uppercase tracking-wide text-ink-4">{s.label}</p>
<p className={`mt-1 font-heading text-title-lg font-bold tabular-nums ${s.tone === 'success' ? 'text-success' : s.tone === 'warning' ? 'text-warning' : 'text-ink-1'}`}>
{s.value}
</p>
<p className="mt-0.5 text-caption text-ink-3">{s.detail}</p>
</Surface>
))}
</div>
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/analytics')} className="w-fit">
Full analytics <ArrowRight aria-hidden="true" />
</Button>
</section>
<SkillSurface page="candidates-analysis" placement="before-footer" />
<UiEditor />
<CandidatesAnalysisComposition context={context} />
</AdminPage>
);
}
/** The composed page. */
function CandidatesAnalysisComposition({ context }) {
const editing = useUiEditing();
const tree = editing?.tree || composePage('candidates-analysis').tree;
return <UiTreeRenderer nodes={tree} context={context} />;
}

View File

@@ -1,23 +1,23 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Area, Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, ResponsiveContainer,
Tooltip, XAxis, YAxis,
} from 'recharts';
import {
AlertTriangle, ArrowRight, Building2, CalendarCheck, CheckCircle2, ChevronRight, Clock, Download, Filter, Sparkles, Users, Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, EmptyState, SegmentedToggle,
Skeleton, StatusBadge, toast,
import { Button, EmptyState, toast,
} from '@/components/ds';
import {
useApplications, useInterviews, useJobPostings, useStaff, useUserActivity, useWorkerProfiles,
} from '@/lib/krowHooks';
import { buildFacts } from '@/components/ai-assistant/insights';
import { AdminPage } from '@/components/admin/PageShell';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import { composePage } from '@/lib/ui/composition';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/control-center/nodes';
/**
* Control Center — the KROW Admin command centre.
@@ -577,6 +577,10 @@ export default function ControlCenter() {
toast.success('Operations snapshot exported');
};
const context = useMemo(() => ({ navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot }), [
navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot,
]);
return (
<AdminPage
title="Control Center"
@@ -587,397 +591,15 @@ export default function ControlCenter() {
</Button>
)}
>
<SkillSurface page="control-center" placement="after-header" />
{/* 1 ── Operations snapshot */}
<Snapshot metrics={metrics} loading={isLoading} />
{/* 2 ── Hiring activity */}
<Band
id="cc-activity"
title="Hiring activity"
meta={
RANGES[range].step === 1
? `Applications, screening, interviews and hires · last ${RANGES[range].days} days, daily`
: `Applications, screening, interviews and hires · last ${RANGES[range].days} days, ${RANGES[range].step}-day totals`
}
action={
<SegmentedToggle
options={Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label }))}
value={range}
onChange={setRange}
size="sm"
ariaLabel="Hiring activity range"
/>
}
>
<div className="rounded-xl border border-border bg-surface px-2 py-4">
{isLoading ? (
<Skeleton className="mx-2 h-[272px] rounded-lg" />
) : f.total ? (
<ResponsiveContainer width="100%" height={272}>
<ComposedChart data={activitySeries} margin={{ top: 4, right: 12, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="ccApplications" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={20} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
<Tooltip content={<ChartTooltip />} />
<Legend
verticalAlign="top"
align="right"
height={28}
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
{/* Applications are the volume everything else is drawn from, so
they take the filled area and the downstream stages are lines
over it. */}
<Area
type="monotone"
dataKey="applications"
name="Applications"
stroke={CHART_TONES.brand}
strokeWidth={2}
fill="url(#ccApplications)"
activeDot={{ r: 4, strokeWidth: 0 }}
/>
<Line
type="monotone"
dataKey="screened"
name="AI Screened"
stroke={CHART_TONES.navy}
strokeWidth={2}
strokeDasharray="4 3"
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
<Line
type="monotone"
dataKey="interviews"
name="Interviews"
stroke={CHART_TONES.navy}
strokeWidth={1.5}
strokeOpacity={0.55}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
{/* Hires are sparse and are the outcome, so they take the accent
and a bar — a line at this volume reads as flat. */}
<Bar
dataKey="hires"
name="Hires"
fill={CHART_TONES.accent}
radius={[3, 3, 0, 0]}
maxBarSize={14}
/>
</ComposedChart>
</ResponsiveContainer>
) : (
<div className="px-2 py-8">
<EmptyState
title="No applications yet"
description="Once candidates start applying, daily hiring activity appears here."
/>
</div>
)}
</div>
</Band>
{/* 3 ── Pipeline intelligence */}
<Band
id="cc-pipeline"
title="Pipeline intelligence"
meta={`${f.total} entered · conversion and drop-off by stage`}
>
<PipelineFunnel
funnel={f.funnel}
transitions={f.transitions}
weakest={f.bottleneck && f.bottleneck.rate < WEAK_TRANSITION ? f.bottleneck : null}
total={f.total}
/>
</Band>
{/* 4 ── Position performance */}
<Band
id="cc-positions"
title="Position performance"
meta={strongest ? `Strongest conversion: ${strongest}` : undefined}
action={<ViewAll label="View all positions" onClick={() => navigate('/admin/positions')} />}
>
<div className="rounded-xl border border-border bg-surface px-2 py-4">
{positionPerformance.length ? (
<>
<ResponsiveContainer width="100%" height={236}>
<ComposedChart
data={positionPerformance}
barGap={3}
margin={{ top: 4, right: 12, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis
dataKey="name"
{...AXIS_PROPS}
interval={0}
tick={{ fontSize: 10, fill: CHART_TONES.axis }}
/>
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
<Tooltip content={<ChartTooltip />} />
<Legend
verticalAlign="top"
align="right"
height={28}
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
<Bar
dataKey="applicants"
name="Applicants"
fill={CHART_TONES.brand}
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
<Bar dataKey="qualified" name="Qualified (70+)" radius={[3, 3, 0, 0]} maxBarSize={24}>
{/* The best converter takes the accent at full strength and the
worst is left pale, so both ends of the comparison are
visible without a callout. */}
{positionPerformance.map((r) => (
<Cell
key={r.name}
fill={r.name === strongest ? CHART_TONES.accent
: r.name === weakestRole ? CHART_TONES.mint : CHART_TONES.accentPale}
/>
))}
</Bar>
<Bar
dataKey="hired"
name="Hired"
fill={CHART_TONES.navy}
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
</ComposedChart>
</ResponsiveContainer>
{weakestRole && (
<p className="px-2 pt-2 text-caption leading-relaxed text-ink-3">
<span className="font-semibold text-ink-1">{strongest}</span> converts applicants
into qualified candidates best;{' '}
<span className="font-semibold text-ink-1">{weakestRole}</span> converts worst and
is where sourcing — or the bar itself — is worth a look.
</p>
)}
</>
) : (
<div className="px-2 py-8">
<EmptyState
title="No open positions"
description="Publish a role and its hiring performance appears here."
/>
</div>
)}
</div>
</Band>
{/* 5 ── Action center */}
<Band
id="cc-actions"
title="Action center"
meta={actions.length ? `${actions.length} items, most consequential first` : 'All clear'}
>
<ActionQueue items={actions} />
</Band>
{/* 6 / 7 ── Active positions and top talent. Two lists of comparable weight,
so they share a row rather than each taking one. */}
<div className="grid gap-6 xl:grid-cols-2">
<Band
id="cc-active"
title="Active positions"
meta={`${f.openPositions.length} hiring`}
action={<ViewAll label="View all" onClick={() => navigate('/admin/positions')} />}
>
{activePositions.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{activePositions.map((r) => {
const coverage = r.applied ? Math.round((r.screened / r.applied) * 100) : 0;
return (
<li key={r.title}>
<button
type="button"
onClick={() => navigate('/admin/positions')}
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">
{r.title}
</span>
<span className="block truncate text-caption text-ink-3">
{r.applied} applicant{r.applied === 1 ? '' : 's'} · {r.screened} screened
{' · '}{r.hired} hired
</span>
</span>
{/* Screening coverage as a small bar — the one number that
says whether this role is actually being worked. */}
<span className="hidden w-16 shrink-0 sm:block">
<span className="block h-1.5 overflow-hidden rounded-full bg-surface-sunken">
<span
className={cn(
'block h-full rounded-full',
coverage === 100 ? 'bg-success'
: coverage >= 50 ? 'bg-krow-blue' : 'bg-warning'
)}
style={{ width: `${Math.max(coverage, r.applied ? 4 : 0)}%` }}
/>
</span>
<span className="mt-1 block text-right text-[10px] tabular-nums text-ink-4">
{coverage}%
</span>
</span>
<StatusBadge status={r.posting.status} size="sm" />
<ChevronRight
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
);
})}
</ul>
) : (
<EmptyState title="No open positions" description="Publish a role to start hiring." />
)}
</Band>
<Band
id="cc-talent"
title="Top talent"
meta={topTalent.length ? 'By KROW Score' : undefined}
action={<ViewAll label="View all" onClick={() => navigate('/admin/candidates')} />}
>
{topTalent.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{topTalent.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => navigate('/admin/candidates')}
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
{/* The seeded portrait where there is one, otherwise the design
system's initials avatar. Never an invented face. */}
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">
{c.applicant_name}
</span>
<span className="block truncate text-caption text-ink-3">{c.job_title}</span>
</span>
<span className="shrink-0 text-right">
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
{c.ai_score}
</span>
<span className="block text-[10px] text-ink-4">KROW</span>
</span>
<StatusBadge status={c.status} size="sm" />
<ChevronRight
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
))}
</ul>
) : (
<EmptyState
title="Nobody scored yet"
description="Screen the pipeline and the strongest candidates appear here."
/>
)}
</Band>
</div>
{/* 8 ── Recent activity */}
<Band
id="cc-recent"
title="Recent activity"
meta={`${activity.length} events logged`}
action={<ViewAll label="View all" onClick={() => navigate('/admin/activity')} />}
>
<div className="overflow-hidden rounded-xl border border-border bg-surface">
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] border-collapse">
<caption className="sr-only">Most recent platform activity</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['User', 'Action', 'Entity', 'Time', 'Role'].map((h) => (
<th
key={h}
scope="col"
className={cn(
'whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
h === 'Role' ? 'text-right' : 'text-left'
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{recent.map((event) => (
<tr key={event.id} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2.5">
<Avatar name={event.user_name || event.user_email} size="xs" />
<div className="min-w-0">
<p className="truncate text-body-sm font-medium text-ink-1">
{event.user_name || '—'}
</p>
<p className="truncate text-[10px] text-ink-4">{event.user_email}</p>
</div>
</div>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-body-sm text-ink-2">
{String(event.event_type).replace(/_/g, ' ')}
</td>
<td className="max-w-[24rem] px-4 py-2.5 text-body-sm text-ink-3">
<span className="line-clamp-1">{event.details || '—'}</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-caption text-ink-4">
{new Date(event.created_date).toLocaleDateString(undefined, {
month: 'short', day: 'numeric',
})}
</td>
<td className="px-4 py-2.5 text-right">
<Badge variant={event.account_type === 'employer' ? 'info' : 'neutral'} size="sm">
{event.account_type || 'unknown'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Band>
<SkillSurface page="control-center" placement="before-footer" />
<UiEditor />
<ControlCenterComposition context={context} />
</AdminPage>
);
}
/** The composed page. */
function ControlCenterComposition({ context }) {
const editing = useUiEditing();
const tree = editing?.tree || composePage('control-center').tree;
return <UiTreeRenderer nodes={tree} context={context} />;
}

View File

@@ -1,14 +1,17 @@
import React, { useMemo, useState } from 'react';
import { Award, Building2, CalendarDays, Clock, Mail, Phone, UserCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, Button, DataTable, Drawer, EmptyState, FilterBar, MetricStrip, StatusBadge,
Surface, Timeline,
} from '@/components/ds';
import { Mail, Phone, UserCheck } from 'lucide-react';
import { Avatar, Badge, Drawer, StatusBadge, Surface } from '@/components/ds';
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { hiresWithFill, summarise } from '@/lib/hiringRecords';
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import { composePage } from '@/lib/ui/composition';
import { DAY, RANGES, formatDate } from '@/pages/admin/hired-history/nodes';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/hired-history/nodes';
/**
* Admin Hired History — the record of who was hired.
@@ -29,20 +32,6 @@ import { hiresWithFill, summarise } from '@/lib/hiringRecords';
* Both pages read `lib/hiringRecords.js`, so the totals cannot disagree.
*/
/** Date-range windows, expressed as days back from today. */
const RANGES = [
{ value: 'all', label: 'Any time', days: null },
{ value: '7', label: 'Last 7 days', days: 7 },
{ value: '30', label: 'Last 30 days', days: 30 },
{ value: '90', label: 'Last 90 days', days: 90 },
];
const DAY = 24 * 60 * 60 * 1000;
const formatDate = (value) => (value
? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: '—');
/** One label/value row in the detail panel. */
function Fact({ label, children }) {
return (
@@ -181,165 +170,39 @@ export default function AdminHiredHistory() {
setFilters({ position: 'all', department: 'all', range: 'all' });
};
/* What this page's sections read. Published once; the renderer passes it
down untouched and never looks inside. */
const context = useMemo(() => ({
isLoading, hires, filtered, recent, summary, isFiltered,
search, setSearch, filters, setFilters,
positionOptions, departmentOptions, clearFilters, setSelected,
}), [
isLoading, hires, filtered, recent, summary, isFiltered,
search, filters, positionOptions, departmentOptions,
]);
return (
<AdminPage
title="Hired History"
subtitle="Who was hired, for which position, and what has happened since."
meta={`${hires.length} on record`}
>
<SkillSurface page="hired-history" placement="after-header" />
<UiEditor />
<HiredComposition context={context} />
{/* 1. Search and filters — the page is a record, so finding one is the
first thing it has to do well. */}
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search by name, position, client or department…"
filters={[
{ key: 'position', label: 'Position', type: 'select', options: [{ value: 'all', label: 'All positions' }, ...positionOptions.map((p) => ({ value: p, label: p }))] },
{ key: 'department', label: 'Department', type: 'select', options: [{ value: 'all', label: 'All departments' }, ...departmentOptions.map((d) => ({ value: d, label: d }))] },
{ key: 'range', label: 'Hired', type: 'select', options: RANGES.map((r) => ({ value: r.value, label: r.label })) },
]}
values={filters}
onChange={setFilters}
/>
{/* 2. What the current selection contains. A count of the record, not a
performance verdict — that reading is Analytics'. */}
<MetricStrip
columns={3}
loading={isLoading}
items={[
{
label: isFiltered ? 'Hires matching' : 'Total hires',
value: summary.total,
icon: Award,
tone: 'brand',
sub: isFiltered ? `of ${hires.length} on record` : undefined,
},
{
label: 'Most recent hire',
value: recent[0] ? formatDate(recent[0].hire_date) : '—',
icon: CalendarDays,
sub: recent[0]?.name,
},
{
label: 'Avg time-to-hire',
value: summary.speed ? `${summary.speed}d` : '—',
icon: Clock,
sub: `${summary.active} still active`,
},
]}
/>
{/* 3. The chronology. */}
{recent.length > 0 && (
<section aria-labelledby="chronology" className="space-y-3">
<SectionTitle
id="chronology"
title="Recent hiring timeline"
meta={`Last ${recent.length} of ${filtered.length}`}
/>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<Timeline
items={recent.map((h, i) => ({
id: h.id,
title: h.name,
description: [h.role, h.company !== '—' ? h.company : null]
.filter(Boolean).join(' · '),
timestamp: formatDate(h.hire_date),
tone: i === 0 ? 'brand' : 'neutral',
current: i === 0,
icon: UserCheck,
}))}
/>
</Surface>
</section>
)}
{/* 4. The records themselves — every field a hire carries, one row each. */}
<section aria-labelledby="records" className="space-y-3">
<SectionTitle
id="records"
title="Hired candidate records"
meta={isFiltered ? `${filtered.length} of ${hires.length}` : `${hires.length} people`}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={12}
caption="Everyone hired, with position, client, score and time-to-hire"
onRowClick={setSelected}
isFiltered={isFiltered}
onClearFilters={clearFilters}
emptyState={(
<EmptyState
title={isFiltered ? 'No hires match these filters' : 'No hires recorded yet'}
description={
isFiltered
? 'Try a broader search, or clear the filters to see the whole record.'
: 'Hires appear here as positions close.'
}
action={isFiltered
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
: undefined}
/>
)}
columns={[
{
key: 'name', header: 'Candidate', sortable: true,
cell: (h) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={h.name} size="sm" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{h.name}</p>
<p className="truncate text-[10px] text-ink-4">{h.email}</p>
</div>
</div>
),
},
{ key: 'role', header: 'Position', sortable: true, hideBelow: 'md' },
{
key: 'company', header: 'Company', sortable: true, hideBelow: 'lg',
cell: (h) => (h.company && h.company !== '—'
? (
<span className="inline-flex items-center gap-1.5 text-ink-2">
<Building2 className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
<span className="truncate">{h.company}</span>
</span>
)
: '—'),
},
{ key: 'department', header: 'Department', sortable: true, hideBelow: 'lg' },
{
key: 'hire_date', header: 'Hired', align: 'right', sortable: true,
cell: (h) => (
<span className="whitespace-nowrap text-[11px] text-ink-4">{formatDate(h.hire_date)}</span>
),
},
{
key: 'score', header: 'AI score', align: 'right', sortable: true,
cell: (h) => (h.score
? <span className="font-semibold tabular-nums text-ink-1">{h.score}</span>
: '—'),
},
{
key: 'timeToHire', header: 'Time to hire', align: 'right', sortable: true, hideBelow: 'lg',
cell: (h) => (h.timeToHire ? `${h.timeToHire}d` : '—'),
},
{
key: 'status', header: 'Status', align: 'right',
cell: (h) => <StatusBadge status={h.status || 'hired'} size="sm" />,
},
]}
/>
<p className={cn('text-caption text-ink-4')}>
Select a row to open the full record.
</p>
</section>
{/* 5. One person, in full. */}
{/* One person, in full. An overlay rather than a section of the page, so
it stays outside the tree — there is nothing to reorder about a
drawer. */}
<HireDetail hire={selected} onClose={() => setSelected(null)} />
</AdminPage>
);
}
/** The composed page. Split out so it can read the tree the provider computed. */
function HiredComposition({ context }) {
const editing = useUiEditing();
/* Outside a layout — a test, a baseline capture — there is no session, and
the page still has to draw. It falls back to the composition as shipped. */
const tree = editing?.tree || composePage('hired-history').tree;
return <UiTreeRenderer nodes={tree} context={context} />;
}

View File

@@ -19,6 +19,10 @@ import {
PositionCustomRequirements, PositionOverview, PositionRequirements,
} from '@/components/krow/PositionDetails';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { UiNodeSlot } from '@/components/ui-tree/UiTreeRenderer';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/positions/nodes';
import { useAssistantPanel, usePublishPageContext } from '@/components/ai-assistant';
import { continueDraftRequest } from '@/lib/skills/draftFlow';
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
@@ -1128,11 +1132,17 @@ export default function AdminPositions() {
{isFiltered ? `${filtered.length} of ${rows.length} positions · filtered` : summary}
</p>
{/* The editor for what this page composes. Positions is migrated at page
level only, so the tree it offers is the two extension slots and
whatever definitions have claimed them — which is exactly what a
person needs in order to move or hide a Board card. */}
<UiEditor />
{/* The list's own extension points. These render once for the page — no
position in context — which is what a definition reporting across
every role needs. The per-card and per-drawer placements are separate
slots, so one skill can address the board and another one role. */}
<SkillSurface page="positions" placement="after-position-list-summary" className="mb-4" />
<UiNodeSlot page="positions" id="positions-extensions-summary" />
{/* Three columns on desktop, two on tablet, one on mobile. */}
{isLoading ? (
@@ -1173,7 +1183,7 @@ export default function AdminPositions() {
/>
)}
<SkillSurface page="positions" placement="after-position-list" className="mt-6" />
<UiNodeSlot page="positions" id="positions-extensions-list" />
<PositionDrawer
position={selected}

View File

@@ -1,25 +1,19 @@
import React, { useMemo, useState } from 'react';
import { Bookmark, Star, UserRound, Trophy, CheckCircle2, TrendingUp, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
} from '@/components/ds';
import { toast } from '@/components/ds';
import { useWorkerProfiles } from '@/lib/krowHooks';
import { getScoreBand, toFICO } from '@/lib/talentHome';
import { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import { SkillSurface } from '@/components/skills/SkillSurface';
import { AdminPage } from '@/components/admin/PageShell';
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
import { UiEditor } from '@/components/ui-editor/UiEditor';
import { composePage } from '@/lib/ui/composition';
import { SORTS } from '@/pages/admin/talent-pool/nodes';
import '@/components/ui-tree/nodeTypes';
import '@/pages/admin/talent-pool/nodes';
/**
* Admin Talent Pool — talent intelligence, not a card gallery.
*/
const SORTS = {
score: { label: 'Highest score', compare: (a, b) => (b.krow_score || 0) - (a.krow_score || 0) },
experience: { label: 'Most experience', compare: (a, b) => (b.experience_years || 0) - (a.experience_years || 0) },
name: { label: 'Name A–Z', compare: (a, b) => a.full_name.localeCompare(b.full_name) },
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime() },
};
const BANDS = {
all: () => true,
@@ -30,53 +24,6 @@ const BANDS = {
unscored: (s) => !s,
};
const SEGMENT_CONFIG = {
Elite: {
key: 'elite',
icon: Trophy,
bg: 'bg-blue-50/80 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
activeBorder: 'border-blue-600 ring-2 ring-blue-500/20',
text: 'text-blue-600 dark:text-blue-400',
accent: 'from-blue-500 to-indigo-500',
},
Excellent: {
key: 'excellent',
icon: Star,
bg: 'bg-emerald-50/80 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
activeBorder: 'border-emerald-600 ring-2 ring-emerald-500/20',
text: 'text-emerald-600 dark:text-emerald-400',
accent: 'from-emerald-500 to-teal-500',
},
Solid: {
key: 'solid',
icon: CheckCircle2,
bg: 'bg-indigo-50/80 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
activeBorder: 'border-indigo-600 ring-2 ring-indigo-500/20',
text: 'text-indigo-600 dark:text-indigo-400',
accent: 'from-indigo-500 to-purple-500',
},
Building: {
key: 'building',
icon: TrendingUp,
bg: 'bg-amber-50/80 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
activeBorder: 'border-amber-600 ring-2 ring-amber-500/20',
text: 'text-amber-600 dark:text-amber-400',
accent: 'from-amber-500 to-orange-500',
},
Unscored: {
key: 'unscored',
icon: Sparkles,
bg: 'bg-orange-50/80 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400',
border: 'border-orange-200/50 dark:border-orange-800/40 hover:border-orange-400',
activeBorder: 'border-orange-600 ring-2 ring-orange-500/20',
text: 'text-orange-600 dark:text-orange-400',
accent: 'from-orange-500 to-red-500',
},
};
export default function AdminTalentPool() {
const { data: profiles = [], isLoading } = useWorkerProfiles();
@@ -159,214 +106,30 @@ export default function AdminTalentPool() {
});
};
const context = useMemo(() => ({
profiles, segments, filtered, isLoading, isFiltered, clearFilters, saved, toggleSave,
search, setSearch, skill, setSkill, experience, setExperience, location, setLocation,
band, setBand, availability, setAvailability, sort, setSort,
skills, locations, availabilities,
}), [
profiles, segments, filtered, isLoading, isFiltered, saved,
search, skill, experience, location, band, availability, sort, skills, locations, availabilities,
]);
return (
<AdminPage
title="Talent Pool"
subtitle="Discover and manage high-potential talent for future hiring."
>
<SkillSurface page="talent-pool" placement="after-header" />
{/* Segments — executive metric cards */}
<section aria-labelledby="segments" className="space-y-3">
<SectionTitle
id="segments"
title="Talent segments"
meta={`${profiles.length} in the pool`}
/>
<div>
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
{segments.map((s) => {
const cfg = SEGMENT_CONFIG[s.label] || SEGMENT_CONFIG.Elite;
const Icon = cfg.icon;
const isSelected = band === cfg.key;
const empty = s.count === 0;
return (
<div
key={s.label}
onClick={() => setBand(isSelected ? 'all' : cfg.key)}
className={cn(
'group relative flex flex-col justify-between cursor-pointer rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isSelected ? cfg.activeBorder : cfg.border
)}
>
<div>
{/* Header Row: Label, Hint & Icon */}
<div className="flex items-center justify-between gap-1.5">
<div className="flex items-baseline gap-1.5 min-w-0">
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-1">
{s.label}
</span>
<span className="truncate text-[10px] font-medium text-ink-4">
{s.hint}
</span>
</div>
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg)}>
<Icon className="h-3.5 w-3.5" />
</div>
</div>
{/* Main count */}
<div className="mt-2.5 flex items-baseline gap-1.5">
<span
className={cn(
'font-heading text-title-xl font-bold leading-none tabular-nums',
empty ? 'text-ink-4' : cfg.text
)}
>
{s.count}
</span>
</div>
</div>
{/* Sub pill & Accent bar */}
<div className="mt-3.5 space-y-2">
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
{empty
? 'No profiles'
: [
`${s.available} available`,
s.label === 'Unscored'
? 'needs verification'
: s.avgExperience > 0
? `${s.avgExperience}y avg`
: null,
]
.filter(Boolean)
.join(' · ')}
</span>
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
</div>
</div>
);
})}
</div>
<p className="mt-3 border-t border-border/60 pt-3 text-caption leading-relaxed text-ink-3">
{segments[4].count > segments[0].count + segments[1].count
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}
</p>
</div>
</section>
<SectionTitle title="Talent directory" meta={`${profiles.length} profiles`} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search name, role or skill" size="sm" />}
filters={
<>
<FilterSelect value={skill} onChange={setSkill} label="Skills"
options={[{ value: 'all', label: 'All skills' }, ...skills.map((s) => ({ value: s, label: s }))]} />
<FilterSelect value={experience} onChange={setExperience} label="Experience" options={[
{ value: 'all', label: 'Any experience' }, { value: '5plus', label: '5+ years' },
{ value: '2to5', label: '2–5 years' }, { value: 'under2', label: 'Under 2 years' },
]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={band} onChange={setBand} label="Score" options={[
{ value: 'all', label: 'All scores' }, { value: 'elite', label: 'Elite 90+' },
{ value: 'excellent', label: 'Excellent 75+' }, { value: 'solid', label: 'Solid 60+' },
{ value: 'building', label: 'Building' }, { value: 'unscored', label: 'Unscored' },
]} />
<FilterSelect value={availability} onChange={setAvailability} label="Availability"
options={[{ value: 'all', label: 'Any availability' }, ...availabilities.map((a) => ({ value: a, label: a }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</>
}
meta={`${filtered.length} of ${profiles.length} talents${isFiltered ? ' · filtered' : ''}${saved.length ? ` · ${saved.length} saved` : ''}`}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={15}
isFiltered={isFiltered}
onClearFilters={clearFilters}
caption="Talent pool ranked by career score"
columns={[
{
key: 'full_name', header: 'Candidate', sortable: true,
cell: (p) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={p.full_name} src={p.selfie_url} size="sm" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
<p className="truncate text-[10px] text-ink-4">{p.address || p.email}</p>
</div>
</div>
),
},
{
key: 'role', header: 'Role', hideBelow: 'md',
cell: (p) => (
<div className="min-w-0">
<p className="truncate text-ink-2">{p.current_position || p.desired_position || '—'}</p>
{p.desired_position && p.current_position && (
<p className="truncate text-[10px] text-ink-4">wants {p.desired_position}</p>
)}
</div>
),
},
{
key: 'skills', header: 'Skills', hideBelow: 'lg',
cell: (p) => (p.skills?.length ? (
<div className="flex items-center gap-1">
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
{p.skills.length > 1 && <span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>}
</div>
) : <span className="text-[11px] text-ink-4">—</span>),
},
{
key: 'experience_years', header: 'Exp', align: 'right', sortable: true, hideBelow: 'md',
cell: (p) => (p.experience_years ? `${p.experience_years}y` : '—'),
},
{
key: 'availability', header: 'Availability', hideBelow: 'lg',
cell: (p) => (p.availability?.length
? <span className="text-[11px] text-ink-3">{p.availability.join(', ')}</span>
: <Badge variant="warning" size="sm">Not set</Badge>),
},
{
key: 'krow_score', header: 'Career score', align: 'right', sortable: true,
cell: (p) => {
const score = p.krow_score || 0;
const bandInfo = getScoreBand(score);
return (
<div className="flex items-center justify-end gap-2">
<div className="text-right">
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{toFICO(score)}</p>
<p className="text-[10px] text-ink-4">{bandInfo.label}</p>
</div>
<ProgressRing value={score} size={26} strokeWidth={3} tone="score" label="" />
</div>
);
},
},
{
key: 'actions', header: '', align: 'right', width: 92,
cell: (p) => (
<div className="flex items-center justify-end gap-0.5" onClick={(e) => e.stopPropagation()} role="presentation">
<IconButton
icon={saved.includes(p.id) ? Star : Bookmark}
label={saved.includes(p.id) ? `Remove ${p.full_name} from saved` : `Save ${p.full_name}`}
variant="ghost"
size="sm"
onClick={() => toggleSave(p.id, p.full_name)}
/>
<IconButton
icon={UserRound}
label={`View ${p.full_name}'s profile`}
variant="ghost"
size="sm"
onClick={() => toast.info(`Opening ${p.full_name}'s KROW Identity`)}
/>
</div>
),
},
]}
/>
<SkillSurface page="talent-pool" placement="before-footer" />
<UiEditor />
<TalentPoolComposition context={context} />
</AdminPage>
);
}
/** The composed page. */
function TalentPoolComposition({ context }) {
const editing = useUiEditing();
const tree = editing?.tree || composePage('talent-pool').tree;
return <UiTreeRenderer nodes={tree} context={context} />;
}

View File

@@ -0,0 +1,313 @@
import React from 'react';
import {
Activity as ActivityIcon, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert,
UserCheck, UserPlus, Users,
} from 'lucide-react';
import {
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
} from '@/components/ds';
import { SectionTitle, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
/**
* The Activity page, as addressable nodes.
*
* This is the first page composed through the UI node system, and it is the
* pattern every other page follows. Nothing about the sections changed: the
* markup below was moved here verbatim from `Activity.jsx`, which is what lets
* the page render exactly as it did while becoming something a person can
* reorder and hide.
*
* Two things are worth understanding before copying the pattern.
*
* **The page still owns its state.** Filters, the loading flag, the derived
* rows — all of it stays in the page component and is published once through
* `useUiContext`. A section reads what it needs. The renderer never looks
* inside the bag, so it stays ignorant of what any page contains.
*
* **Each section is its own node type.** They are singletons — there is no
* sense in which a page could have two audit logs — so they declare only the
* capabilities that mean something for a built-in: `move` and `hide`. A page
* section is not addable, removable or replaceable, and saying so in the
* registration is what stops the engine from ever offering it.
*/
/** Severity carried through to the timeline node, so the two views agree. */
const TIMELINE_TONE = { high: 'destructive', medium: 'warning', low: 'neutral' };
const SEVERITY_META = {
high: { label: 'High', variant: 'destructive' },
medium: { label: 'Medium', variant: 'warning' },
low: { label: 'Low', variant: 'neutral' },
};
/**
* An icon per event type. Worth the table: on a chronology the glyph is what
* makes a run of hires distinguishable from a run of logins at a glance, which is
* the whole reason to show a timeline rather than another list of rows.
*/
const EVENT_ICON = {
hire_candidate: UserCheck,
assign_employee: Users,
create_position: FilePlus2,
screen_candidate: ScanSearch,
start_interview: Mic,
apply_job: Send,
signup: UserPlus,
login: LogIn,
logout: LogOut,
};
/** The counted figures across the top. */
function ActivitySummary() {
const { isLoading, events, last24h, highRisk, users } = useUiContext();
return (
<MetricStrip
columns={4}
loading={isLoading}
items={[
{ label: 'Total events', value: events.length },
{ label: 'Last 24 hours', value: last24h.length, tone: last24h.length ? 'default' : 'warning' },
{ label: 'Privileged actions', value: highRisk.length, tone: 'warning', sub: 'hires and position changes' },
{ label: 'Distinct users', value: users.length },
]}
/>
);
}
/** The privileged-action notice. Absent, rather than empty, when there are none. */
function ActivityPrivilegedNotice({ attrs = {} }) {
const { highRisk } = useUiContext();
if (!highRisk.length) return null;
return (
<Surface variant="solid" radius="lg" padding="sm" elevation="xs" className="border-warning/30 bg-warning-muted" {...attrs}>
<div className="flex items-start gap-2.5">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" aria-hidden="true" />
<p className="text-body-sm text-ink-2">
<span className="font-semibold text-ink-1">{highRisk.length} privileged actions</span> in this log —
hires and position changes. These should always trace to a named person.
</p>
</div>
</Surface>
);
}
/* The recent timeline. Deliberately above the log and deliberately not
filtered: this answers "what just happened", which is a different
question from the one the toolbar below exists to ask. Reading a
chronology is also how an auditor starts — sequence first, then
interrogate the specifics. */
function ActivityTimeline({ attrs = {} }) {
const { recent, byDay } = useUiContext();
return (
<section aria-labelledby="timeline" className="space-y-3" {...attrs}>
<SectionTitle
id="timeline"
title="Operational timeline"
meta={`Most recent ${recent.length} events`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{recent.length ? (
<div className="divide-y divide-border">
{byDay.map(([day, dayEvents]) => (
<div key={day} className="px-4 py-3.5">
<p className="mb-3 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
{day}
</p>
<Timeline
compact
items={dayEvents.map((e) => ({
id: e.id,
icon: EVENT_ICON[e.event_type] || ActivityIcon,
tone: TIMELINE_TONE[e.severity],
title: e.event_type.replace(/_/g, ' '),
description: e.details || undefined,
meta: `${e.user_name || e.user_email}${e.account_type ? ` · ${e.account_type}` : ''}`,
timestamp: new Date(e.created_date).toLocaleTimeString(undefined, {
hour: 'numeric', minute: '2-digit',
}),
}))}
/>
</div>
))}
</div>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
No activity has been logged yet.
</p>
)}
</Surface>
</section>
);
}
/** The filterable audit log. */
function ActivityAuditLog({ attrs = {} }) {
const {
isLoading, events, filtered, users, actions, isFiltered, clearFilters,
search, setSearch, user, setUser, action, setAction, severity, setSeverity, range, setRange,
} = useUiContext();
return (
<section aria-labelledby="audit" className="space-y-3" {...attrs}>
<SectionTitle id="audit" title="Audit log" meta={`${filtered.length} of ${events.length} events`} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search events" size="sm" />}
filters={
<>
<FilterSelect value={user} onChange={setUser} label="User"
options={[{ value: 'all', label: 'All users' }, ...users.map((u) => ({ value: u, label: u }))]} />
<FilterSelect value={action} onChange={setAction} label="Action"
options={[{ value: 'all', label: 'All actions' }, ...actions.map((a) => ({ value: a, label: a.replace(/_/g, ' ') }))]} />
<FilterSelect value={severity} onChange={setSeverity} label="Severity" options={[
{ value: 'all', label: 'All severities' }, { value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' },
]} />
<FilterSelect value={range} onChange={setRange} label="Date" options={[
{ value: 'all', label: 'All time' }, { value: '24h', label: 'Last 24 hours' },
{ value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' },
]} />
</>
}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={20}
isFiltered={isFiltered}
onClearFilters={clearFilters}
caption="Platform audit log"
rowClassName={(e) => (e.severity === 'high' ? 'bg-warning-muted/40' : undefined)}
columns={[
{
key: 'user_name', header: 'User', sortable: true,
cell: (e) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={e.user_name || e.user_email} size="xs" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{e.user_name || '—'}</p>
<p className="truncate text-[10px] text-ink-4">{e.user_email}</p>
</div>
</div>
),
},
{
key: 'event_type', header: 'Action', sortable: true,
cell: (e) => <span className="whitespace-nowrap">{e.event_type.replace(/_/g, ' ')}</span>,
},
{
key: 'details', header: 'Entity', hideBelow: 'md',
cell: (e) => <span className="line-clamp-1 text-ink-3">{e.details || '—'}</span>,
},
{
key: 'account_type', header: 'Role', hideBelow: 'lg',
cell: (e) => <Badge variant={e.account_type === 'employer' ? 'info' : 'neutral'} size="sm">{e.account_type || 'unknown'}</Badge>,
},
{
key: 'ip', header: 'Source IP', hideBelow: 'lg',
cell: (e) => <span className="font-mono text-[11px] text-ink-4">{e.ip}</span>,
},
{
key: 'created_date', header: 'Timestamp', align: 'right', sortable: true,
sortValue: (e) => new Date(e.created_date).getTime(),
cell: (e) => (
<span className="whitespace-nowrap text-[11px] text-ink-4">
{new Date(e.created_date).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
})}
</span>
),
},
{
key: 'severity', header: 'Severity', align: 'right',
cell: (e) => (
<Badge variant={SEVERITY_META[e.severity].variant} size="sm">
{SEVERITY_META[e.severity].label}
</Badge>
),
},
]}
/>
</section>
);
}
/* ── Registration ───────────────────────────────────────────────────────────
A page section is a singleton: `move` and `hide` are the only operations that
mean anything for one, and declaring exactly those is what makes the engine
refuse the rest without knowing what an audit log is. */
const SECTION = ['move', 'hide'];
registerNodeType({
type: 'activity-summary',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'activity',
label: 'Activity summary',
summary: 'Total events, recent volume, privileged actions and distinct users.',
component: ActivitySummary,
capabilities: SECTION,
/* `MetricStrip` destructures its props, so it cannot carry the node's
identity itself. The renderer supplies a bare wrapper instead — no styling,
no layout of its own, and the page's own vertical rhythm applies to it
exactly as it applied to the grid before. */
wrap: true,
});
registerNodeType({
type: 'activity-privileged-notice',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'activity',
label: 'Privileged actions notice',
summary: 'A warning banner when the log contains hires or position changes.',
component: ActivityPrivilegedNotice,
capabilities: SECTION,
});
registerNodeType({
type: 'activity-timeline',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'activity',
label: 'Operational timeline',
summary: 'The most recent events, grouped by day.',
component: ActivityTimeline,
capabilities: SECTION,
});
registerNodeType({
type: 'activity-audit-log',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'activity',
label: 'Audit log',
summary: 'The filterable table of every recorded event.',
component: ActivityAuditLog,
capabilities: SECTION,
});
/**
* The page as it ships.
*
* The order here is the order on screen, and the ids are the page's stable
* addresses. `timeline` and `audit` are the ids the headings already carried in
* the DOM, so a node's identity is continuous with what the page has always
* said about itself rather than a second naming invented alongside it.
*/
registerPageComposition('activity', [
{ id: 'activity-extensions-top', type: 'skill-surface', props: { page: 'activity', placement: 'after-header' } },
{ id: 'activity-summary', type: 'activity-summary' },
{ id: 'activity-privileged-notice', type: 'activity-privileged-notice' },
{ id: 'timeline', type: 'activity-timeline' },
{ id: 'audit', type: 'activity-audit-log' },
{ id: 'activity-extensions-bottom', type: 'skill-surface', props: { page: 'activity', placement: 'before-footer' } },
]);

View File

@@ -0,0 +1,424 @@
import React from 'react';
import {
Activity, Award, Clock, Gauge, Lightbulb, Target, TrendingUp, TriangleAlert,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge, MetricStrip, Surface } from '@/components/ds';
import { DepartmentPerformance } from '@/components/charts/DepartmentPerformance';
import { HiringFlow } from '@/components/charts/HiringFlow';
import { HiringTrendChart } from '@/components/charts/HiringTrendChart';
import { useSize } from '@/hooks/use-size';
import { SectionTitle } from '@/components/admin/PageShell';
/**
* Analytics, as addressable nodes.
*
* Seven readings, moved verbatim. Each keeps the id its heading already carried
* in the DOM, so what a person names in conversation and what the markup says
* are the same string.
*/
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
const INSIGHT_TONE = {
success: { ring: 'border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20', dot: 'text-emerald-600 dark:text-emerald-400', Icon: TrendingUp },
warning: { ring: 'border-amber-500/30 bg-amber-50/40 dark:bg-amber-950/20', dot: 'text-amber-600 dark:text-amber-400', Icon: TriangleAlert },
risk: { ring: 'border-red-500/30 bg-red-50/40 dark:bg-red-950/20', dot: 'text-red-600 dark:text-red-400', Icon: TriangleAlert },
info: { ring: 'border-border bg-surface', dot: 'text-krow-blue', Icon: Lightbulb },
};
/** One finding, with the evidence underneath it. */
function Insight({ item }) {
const tone = INSIGHT_TONE[item.tone] || INSIGHT_TONE.info;
const { Icon } = tone;
return (
<li className={cn('flex items-start gap-2.5 rounded-xl border p-3.5', tone.ring)}>
<Icon className={cn('mt-0.5 h-4 w-4 shrink-0', tone.dot)} aria-hidden="true" />
<div className="min-w-0">
<p className="font-heading text-body-sm font-semibold text-ink-1">{item.title}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.body}</p>
</div>
</li>
);
}
/**
* The funnel, drawn once its container can be measured.
*
* `HiringFlow` renders an MUI bar chart with no explicit width, which means the
* chart measures its parent on mount. On this page that first measure lands
* before the layout has resolved a width — the shell's `main` is a `flex-1`
* column beside the Owliver panel — and the chart warns that it has nothing to
* size itself against. Gating on the measured width means the chart mounts once,
* already knowing how wide it is, instead of mounting into nothing and
* recovering.
*
* The reserved height keeps the section from collapsing and reflowing the page
* on the frame between measure and draw.
*/
function MeasuredFunnel({ funnel }) {
const ref = React.useRef(null);
const size = useSize(ref);
return (
<div ref={ref} className="w-full">
{size?.width ? (
<HiringFlow
stages={funnel.stages}
transitions={funnel.transitions}
weakestKey={funnel.weakestKey}
/>
) : (
<div className="h-[280px] rounded-xl border border-border bg-surface" aria-hidden="true" />
)}
</div>
);
}
/** A ranked comparison row — used for both fastest and slowest to fill. */
function VelocityList({ items, median, tone }) {
if (!items.length) {
return (
<p className="rounded-lg border border-dashed border-border px-3 py-3 text-body-sm text-ink-3">
No role has enough dated hires to measure velocity yet.
</p>
);
}
return (
<ul className="space-y-2">
{items.map((role) => {
const delta = median ? role.avgDays - median : 0;
return (
<li key={role.role} className="flex items-center justify-between gap-3 rounded-xl bg-surface-subtle px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-body-sm font-medium text-ink-1">{role.role}</p>
<p className="text-[10px] text-ink-4">
{role.count} hire{role.count === 1 ? '' : 's'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="font-heading text-body-sm font-bold tabular-nums text-ink-1">
{role.avgDays}d
</span>
{median > 0 && delta !== 0 && (
<Badge variant={tone === 'fast' ? 'success' : 'warning'} size="sm" className="tabular-nums">
{delta > 0 ? '+' : ''}{delta}d
</Badge>
)}
</div>
</li>
);
})}
</ul>
);
}
function AnalyticsPerformance({ attrs = {} }) {
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="performance" className="space-y-3" {...attrs}>
<SectionTitle id="performance" title="Hiring performance" meta="Across the workspace" />
<MetricStrip
columns={4}
loading={isLoading}
items={[
{ label: 'Total hires', value: summary.total, icon: Award, tone: 'brand' },
{
label: 'Avg time-to-hire',
value: summary.speed ? `${summary.speed}d` : '—',
icon: Clock,
sub: efficiency.median ? `${efficiency.median}d median` : undefined,
},
{
label: 'Quality of hire',
value: summary.quality || '—',
icon: TrendingUp,
tone: summary.quality >= 80 ? 'success' : 'default',
sub: 'avg AI score',
},
{
label: 'Conversion rate',
value: `${funnel.conversion}%`,
icon: Target,
sub: `${funnel.stages[4].count} of ${funnel.stages[0].count} applicants`,
},
]}
/>
</section>
);
}
function AnalyticsFunnel({ attrs = {} }) {
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="funnel" className="space-y-3" {...attrs}>
<SectionTitle
id="funnel"
title="Hiring funnel"
meta="Applied → Screened → Shortlisted → Interview → Hired"
/>
{funnel.stages[0].count ? (
<MeasuredFunnel funnel={funnel} />
) : (
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<p className="text-body-sm leading-relaxed text-ink-3">
No applications on file yet. The funnel appears once the first candidate applies.
</p>
</Surface>
)}
</section>
);
}
function AnalyticsTrend({ attrs = {} }) {
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="trend" className="space-y-3" {...attrs}>
<SectionTitle id="trend" title="Hiring trend" meta="Cumulative hires by month" />
<HiringTrendChart
points={trend}
emptyState={(
<div className="px-4 py-8 text-center">
<p className="font-heading text-body font-semibold text-ink-1">
Not enough history for a trend
</p>
<p className="mx-auto mt-1 max-w-md text-body-sm leading-relaxed text-ink-3">
{hires.length
? `All ${hires.length} hire${hires.length === 1 ? '' : 's'} closed in ${trend[0]?.label || 'a single month'}. A month-on-month line appears once hiring spans a second month.`
: 'No hires recorded yet. Hiring volume over time appears here once the first role closes.'}
</p>
</div>
)}
/>
</section>
);
}
function AnalyticsDepartments({ attrs = {} }) {
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="departments" className="space-y-3" {...attrs}>
<SectionTitle
id="departments"
title="Department performance"
meta={`${departments.length} department${departments.length === 1 ? '' : 's'} compared`}
/>
<DepartmentPerformance items={departments} />
</section>
);
}
function AnalyticsPositions({ attrs = {} }) {
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="positions" className="space-y-3" {...attrs}>
<SectionTitle
id="positions"
title="Position performance"
meta={`${positions.length} role${positions.length === 1 ? '' : 's'} filled`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden border border-border">
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] border-collapse text-body-sm">
<caption className="sr-only">Hires, quality, speed and review outcome by role</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Hires', 'Avg score', 'Avg days', 'Reviewed', 'Rating'].map((h, i) => (
<th
key={h}
scope="col"
className={cn(
'whitespace-nowrap px-3.5 py-2.5 text-[10px] font-bold uppercase tracking-wider text-ink-4',
i ? 'text-right' : 'text-left'
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{positions.map((r) => (
<tr key={r.role} className="border-b border-border/60 last:border-0 transition-colors hover:bg-surface-subtle/80">
<td className="px-3.5 py-2.5 font-medium text-ink-1">{r.role}</td>
<td className="px-3.5 py-2.5 text-right font-semibold tabular-nums text-ink-2">{r.count}</td>
<td className="px-3.5 py-2.5 text-right font-bold tabular-nums text-blue-600 dark:text-blue-400">{r.avgScore || '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-2">{r.avgDays ? `${r.avgDays}d` : '—'}</td>
<td className="px-3.5 py-2.5 text-right tabular-nums text-ink-3">{r.rated}/{r.count}</td>
<td className="px-3.5 py-2.5 text-right">
{r.avgRating != null
? <Badge variant="success" size="sm" className="font-bold">{r.avgRating}/5</Badge>
: <Badge variant="warning" size="sm" className="font-bold">Pending</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
);
}
function AnalyticsEfficiency({ attrs = {} }) {
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="efficiency" className="space-y-3" {...attrs}>
<SectionTitle
id="efficiency"
title="Hiring efficiency"
meta={efficiency.median ? `${efficiency.median}d median time-to-hire` : 'Not enough dated hires'}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-krow-blue">
<Gauge className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Velocity</h3>
</div>
<p className="mt-2 font-heading text-title font-bold tabular-nums text-ink-1">
{efficiency.median ? `${efficiency.median}d` : '—'}
<span className="ml-1.5 text-caption font-normal text-ink-3">median</span>
</p>
<p className="mt-1 text-caption leading-relaxed text-ink-3">
{efficiency.within48h
? `${efficiency.within48h}% of roles close within 48 hours.`
: 'Velocity appears once hires carry an application date.'}
</p>
</Surface>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
<Activity className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Fastest to fill</h3>
</div>
<div className="mt-3">
<VelocityList items={efficiency.fastest} median={efficiency.median} tone="fast" />
</div>
</Surface>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<Clock className="h-4 w-4" aria-hidden="true" />
<h3 className="font-heading text-body-sm font-bold text-ink-1">Slowest to fill</h3>
</div>
<div className="mt-3">
<VelocityList items={efficiency.slowest} median={efficiency.median} tone="slow" />
</div>
</Surface>
</div>
</section>
);
}
function AnalyticsInsights({ attrs = {} }) {
const { summary, funnel, trend, departments, positions, efficiency, insights, hires, isLoading, applications } = useUiContext();
return (
<section aria-labelledby="insights" className="space-y-3" {...attrs}>
<SectionTitle
id="insights"
title="AI hiring insights"
meta={insights.length ? `${insights.length} finding${insights.length === 1 ? '' : 's'}` : undefined}
/>
{insights.length ? (
<ul className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{insights.map((item) => <Insight key={item.title} item={item} />)}
</ul>
) : (
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<p className="text-body-sm leading-relaxed text-ink-3">
There is not enough hiring on file to draw a finding from yet. Insights appear as
applications, hires and reviews accumulate.
</p>
</Surface>
)}
</section>
);
}
const SECTION = ['move', 'hide'];
registerNodeType({
type: 'analytics-performance',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'analytics',
label: 'Hiring performance',
component: AnalyticsPerformance,
capabilities: SECTION,
});
registerNodeType({
type: 'analytics-funnel',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'analytics',
label: 'Hiring funnel',
component: AnalyticsFunnel,
capabilities: SECTION,
});
registerNodeType({
type: 'analytics-trend',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'analytics',
label: 'Hiring trend',
component: AnalyticsTrend,
capabilities: SECTION,
});
registerNodeType({
type: 'analytics-departments',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'analytics',
label: 'Departments',
component: AnalyticsDepartments,
capabilities: SECTION,
});
registerNodeType({
type: 'analytics-positions',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'analytics',
label: 'Position comparison',
component: AnalyticsPositions,
capabilities: SECTION,
});
registerNodeType({
type: 'analytics-efficiency',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'analytics',
label: 'Hiring efficiency',
component: AnalyticsEfficiency,
capabilities: SECTION,
});
registerNodeType({
type: 'analytics-insights',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'analytics',
label: 'Insights',
component: AnalyticsInsights,
capabilities: SECTION,
});
registerPageComposition('analytics', [
{ id: 'analytics-extensions-top', type: 'skill-surface', props: { page: 'analytics', placement: 'after-header' } },
{ id: 'performance', type: 'analytics-performance' },
{ id: 'funnel', type: 'analytics-funnel' },
{ id: 'trend', type: 'analytics-trend' },
{ id: 'departments', type: 'analytics-departments' },
{ id: 'positions', type: 'analytics-positions' },
{ id: 'efficiency', type: 'analytics-efficiency' },
{ id: 'insights', type: 'analytics-insights' },
{ id: 'analytics-extensions-bottom', type: 'skill-surface', props: { page: 'analytics', placement: 'before-footer' } },
]);

View File

@@ -0,0 +1,415 @@
import React from 'react';
import {
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { ArrowRight } from 'lucide-react';
import {
AXIS_PROPS, Avatar, Badge, Button, CHART_TONES, ChartTooltip, InsightList, InsightRow,
MetricStrip, ProgressBar, ProgressRing, Surface,
} from '@/components/ds';
import { SectionTitle } from '@/components/admin/PageShell';
/**
* Admin Candidates Analysis — the analytical counterpart to Candidates.
*
* Candidates is for working through people one by one; this page is for reading
* the pool as a whole: supply, quality distribution, and where the risk sits.
* It is one of the three pages with the contextual Owliver panel, because these
* are the questions worth asking in language rather than filters.
*/
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
/**
* Candidates Analysis, as addressable nodes.
*
* Two of its readings are laid out side by side inside a grid, so each grid is
* one node holding both — splitting them would let somebody move half a pair
* out of its own layout.
*/
function CASupply({ attrs = {} }) {
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<>
<MetricStrip
columns={5}
items={[
{ label: 'In pipeline', value: f.total },
{ label: 'Scored', value: f.scored.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
{ label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'brand' },
{ label: 'Avg score', value: f.avgScore || '—' },
{ label: 'Risk flags', value: risks.length, tone: risks.length ? 'warning' : 'success' },
]}
/>
</>
);
}
function CADistribution({ attrs = {} }) {
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<>
<div className="grid gap-6 lg:grid-cols-5">
<section aria-labelledby="dist" className="space-y-3 lg:col-span-2">
<SectionTitle id="dist" title="Quality distribution" meta={`${f.total} candidates`} />
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={bands} dataKey="value" nameKey="name" innerRadius={48} outerRadius={78} paddingAngle={2} strokeWidth={0}>
{bands.map((b) => <Cell key={b.name} fill={b.color} />)}
</Pie>
<Tooltip content={<ChartTooltip />} />
</PieChart>
</ResponsiveContainer>
<div className="space-y-1.5">
{bands.map((b) => (
<div key={b.name} className="flex items-center gap-2 text-caption">
<span className="h-2 w-2 shrink-0 rounded-sm" style={{ background: b.color }} aria-hidden="true" />
<span className="flex-1 text-ink-3">{b.name}</span>
<span className="font-semibold tabular-nums text-ink-1">{b.value}</span>
</div>
))}
</div>
</section>
<section aria-labelledby="strongest" className="space-y-3 lg:col-span-3">
<SectionTitle
id="strongest"
title="Strongest candidates"
meta="By AI score"
action={{ label: 'All candidates', onClick: () => navigate('/admin/candidates') }}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="divide-y divide-border overflow-hidden">
{top.length ? top.map((c, i) => (
<div key={c.id} className="flex items-center gap-3 px-4 py-2.5">
<span className="w-4 shrink-0 text-caption font-bold text-ink-4">{i + 1}</span>
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-ink-1">{c.applicant_name}</p>
<p className="truncate text-[10px] text-ink-4">{c.job_title}</p>
</div>
{c.ai_match_label && (
<Badge variant="soft" size="sm" className="hidden sm:inline-flex">{c.ai_match_label}</Badge>
)}
<div className="w-20 shrink-0">
<ProgressBar value={c.ai_score} tone="score" size="xs" />
</div>
<ProgressRing value={c.ai_score} size={30} strokeWidth={3} tone="score" />
</div>
)) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">No scored candidates yet.</p>
)}
</Surface>
</section>
</div>
</>
);
}
function CARisk({ attrs = {} }) {
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<section aria-labelledby="risk" className="space-y-3" {...attrs}>
<SectionTitle id="risk" title="Candidate risk" meta={risks.length ? `${risks.length} flags` : 'No flags'} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{risks.length ? (
<InsightList>{risks.map((r, i) => <InsightRow key={i} {...r} />)}</InsightList>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
No material risk flags. Credentials, availability and interview integrity all check out.
</p>
)}
</Surface>
<p className="text-caption text-ink-4">
Flags are questions for a human, not rejections.
</p>
</section>
);
}
function CASkills({ attrs = {} }) {
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<>
{/* Skill supply and the gaps in it, side by side: what the pool has, and
what the open roles ask for and nobody offers. */}
<div className="grid gap-6 lg:grid-cols-5">
<section aria-labelledby="skills" className="space-y-3 lg:col-span-3">
<SectionTitle
id="skills"
title="Skill supply"
meta={`Top ${skillSupply.length} across the scored pool`}
/>
{skillSupply.length ? (
<ResponsiveContainer width="100%" height={Math.max(180, skillSupply.length * 30)}>
<BarChart data={skillSupply} layout="vertical" margin={{ top: 0, right: 28, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} horizontal={false} />
<XAxis type="number" {...AXIS_PROPS} allowDecimals={false} />
<YAxis
type="category"
dataKey="skill"
{...AXIS_PROPS}
width={128}
tick={{ fontSize: 11, fill: CHART_TONES.axis }}
/>
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="count" name="Candidates" radius={[0, 4, 4, 0]} maxBarSize={16}>
{/* Tinted by the average score of the people holding the skill,
so breadth and quality read together. */}
{skillSupply.map((s) => (
<Cell
key={s.skill}
fill={s.avgScore >= 75 ? CHART_TONES.brand : s.avgScore >= 60 ? CHART_TONES.accentPale : CHART_TONES.mint}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-body-sm text-ink-3">
Nothing is scored yet, so there is no skill supply to read.
</p>
)}
<p className="text-caption text-ink-4">
Bar length is how many candidates claim the skill. Darker blue means the
people holding it average 75 or above.
</p>
</section>
<section aria-labelledby="gaps" className="space-y-3 lg:col-span-2">
<SectionTitle
id="gaps"
title="Skill gaps"
meta={skillGaps.length ? `${skillGaps.length} unmet` : 'None'}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
{skillGaps.length ? (
<ul className="divide-y divide-border">
{skillGaps.map((g) => (
<li key={g.name} className="px-4 py-2.5">
<p className="text-body-sm font-medium text-ink-1">{g.name}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">
Required by {g.roles.join(', ')} — held by nobody in the scored pool.
</p>
</li>
))}
</ul>
) : (
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
Every credential an open role requires is held by someone in the scored pool.
</p>
)}
</Surface>
</section>
</div>
</>
);
}
function CAFit({ attrs = {} }) {
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<>
{/* Position fit — which roles can actually be filled from current supply. */}
<section aria-labelledby="fit" className="space-y-3" {...attrs}>
<SectionTitle id="fit" title="Position fit" meta={`${positionFit.length} open roles`} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[40rem] border-collapse text-body-sm">
<caption className="sr-only">
Applicant supply against candidates clearing the bar, per open role
</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['Role', 'Applied', 'Qualified', 'Avg score', 'Fit'].map((h, i) => (
<th
key={h}
scope="col"
className={`whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4 ${i ? 'text-right' : 'text-left'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{positionFit.map((r) => (
<tr key={r.title} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5 font-medium text-ink-1">{r.title}</td>
<td className={`px-4 py-2.5 text-right tabular-nums ${r.applied ? 'text-ink-2' : 'font-semibold text-destructive'}`}>
{r.applied}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.qualified}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-2">{r.avgScore || '—'}</td>
<td className="px-4 py-2.5">
<div className="ml-auto w-24">
<ProgressBar
value={r.fit}
tone={r.fit >= 50 ? 'success' : r.fit > 0 ? 'warning' : 'destructive'}
size="xs"
/>
<p className="mt-1 text-right text-[10px] tabular-nums text-ink-4">{r.fit}% qualified</p>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Surface>
</section>
</>
);
}
function CARecommend({ attrs = {} }) {
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<>
{/* Recommendations — the actions the analysis above implies. */}
{recommendations.length > 0 && (
<section aria-labelledby="recommend" className="space-y-3">
<SectionTitle
id="recommend"
title="Recommendations"
meta={`${recommendations.length} ordered by impact`}
/>
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
<ol className="divide-y divide-border">
{recommendations.map((r, i) => (
<li key={r.title} className="flex gap-3 px-4 py-3">
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
{i + 1}
</span>
<div className="min-w-0">
<p className="text-body-sm font-medium text-ink-1">{r.title}</p>
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{r.body}</p>
</div>
</li>
))}
</ol>
</Surface>
</section>
)}
</>
);
}
function CAScreening({ attrs = {} }) {
const { navigate, applications, postings, interviews, staff, profiles, f, bands, risks, top, skillSupply, skillGaps, positionFit, recommendations } = useUiContext();
return (
<section aria-labelledby="screening" className="space-y-3" {...attrs}>
<SectionTitle id="screening" title="Screening efficiency" />
<div className="grid gap-4 sm:grid-cols-3">
{[
{ label: 'Coverage', value: `${f.standardizedPct}%`, detail: `${f.scored.length} of ${f.total} scored`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
{ label: 'Interview completion', value: `${f.interviewCompletion}%`, detail: `${f.completedInterviews.length} of ${f.interviews.length} scored` },
{ label: 'Quality lift', value: f.hiredAvgScore && f.avgScore ? `+${f.hiredAvgScore - f.avgScore}` : '—', detail: 'hires vs pool average', tone: 'success' },
].map((s) => (
<Surface key={s.label} variant="solid" radius="lg" padding="default" elevation="xs">
<p className="text-[11px] font-medium uppercase tracking-wide text-ink-4">{s.label}</p>
<p className={`mt-1 font-heading text-title-lg font-bold tabular-nums ${s.tone === 'success' ? 'text-success' : s.tone === 'warning' ? 'text-warning' : 'text-ink-1'}`}>
{s.value}
</p>
<p className="mt-0.5 text-caption text-ink-3">{s.detail}</p>
</Surface>
))}
</div>
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/analytics')} className="w-fit">
Full analytics <ArrowRight aria-hidden="true" />
</Button>
</section>
);
}
const SECTION = ['move', 'hide'];
registerNodeType({
type: 'ca-supply',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates-analysis',
label: 'Supply summary',
component: CASupply,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'ca-distribution',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates-analysis',
label: 'Score distribution',
component: CADistribution,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'ca-risk',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates-analysis',
label: 'Pipeline risk',
component: CARisk,
capabilities: SECTION,
});
registerNodeType({
type: 'ca-skills',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates-analysis',
label: 'Skill supply and gaps',
component: CASkills,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'ca-fit',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates-analysis',
label: 'Position fit',
component: CAFit,
capabilities: SECTION,
});
registerNodeType({
type: 'ca-recommend',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates-analysis',
label: 'Recommendations',
component: CARecommend,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'ca-screening',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates-analysis',
label: 'Screening',
component: CAScreening,
capabilities: SECTION,
});
registerPageComposition('candidates-analysis', [
{ id: 'ca-extensions-top', type: 'skill-surface', props: { page: 'candidates-analysis', placement: 'after-header' } },
{ id: 'supply', type: 'ca-supply' },
{ id: 'distribution', type: 'ca-distribution' },
{ id: 'risk', type: 'ca-risk' },
{ id: 'skills', type: 'ca-skills' },
{ id: 'fit', type: 'ca-fit' },
{ id: 'recommend', type: 'ca-recommend' },
{ id: 'screening', type: 'ca-screening' },
{ id: 'ca-extensions-bottom', type: 'skill-surface', props: { page: 'candidates-analysis', placement: 'before-footer' } },
]);

View File

@@ -0,0 +1,137 @@
import React from 'react';
import { Button, SearchInput } from '@/components/ds';
import { Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import CandidateCard from '@/components/krow/CandidateCard';
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
/**
* Candidates, as addressable nodes.
*
* The markup was moved here verbatim. The page keeps its state, its mutations
* and its modals; what moved is only the part of it that is *layout*.
*/
export const SORTS = {
score: { label: 'Highest score', compare: (a, b) => (b.ai_score || 0) - (a.ai_score || 0) },
recent: { label: 'Recent activity', compare: (a, b) => new Date(b.updated_date).getTime() - new Date(a.updated_date).getTime() },
name: { label: 'Name A–Z', compare: (a, b) => a.applicant_name.localeCompare(b.applicant_name) },
experience: { label: 'Most experience', compare: (a, b) => (b.years_experience || 0) - (a.years_experience || 0) },
};
function CandidatesToolbar() {
const {
search, setSearch, position, setPosition, stage, setStage, band, setBand, sort, setSort,
positions, filtered, applications, isFiltered,
} = useUiContext();
return (
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search candidates..." size="sm" />}
filters={
<>
<FilterSelect value={position} onChange={setPosition} label="Position"
options={[{ value: 'all', label: 'All positions' }, ...positions.map((p) => ({ value: p, label: p }))]} />
<FilterSelect value={stage} onChange={setStage} label="Stage" options={[
{ value: 'all', label: 'All stages' }, { value: 'applied', label: 'Applied' },
{ value: 'ai_screened', label: 'AI Screened' }, { value: 'interview', label: 'Interviewing' },
{ value: 'hired', label: 'Hired' }, { value: 'rejected', label: 'Declined' },
]} />
<FilterSelect value={band} onChange={setBand} label="Score" options={[
{ value: 'all', label: 'All scores' }, { value: 'top', label: '80+ Top' },
{ value: 'strong', label: '60–79 Strong' }, { value: 'weak', label: 'Under 60' },
{ value: 'unscored', label: 'Unscored' },
]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</>
}
meta={`${filtered.length} of ${applications.length} candidates${isFiltered ? ' · filtered' : ''}`}
/>
);
}
function CandidatesList() {
const {
isLoading, filtered, isFiltered, clearFilters, resolveTitle,
handleAction, setMessageApp, setScheduleApp, handleDecline, handleDelete, handleHire,
} = useUiContext();
if (isLoading) {
return (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-24 bg-white border border-[#E5E7EB] rounded-xl animate-pulse" />
))}
</div>
);
}
if (filtered.length === 0) {
return (
<div className="text-center py-16">
<p className="text-body-sm text-ink-3">No candidates match your filters</p>
{isFiltered && (
<Button size="xs" variant="outline" className="mt-3" onClick={clearFilters}>
Clear filters
</Button>
)}
</div>
);
}
return (
<div className="space-y-4">
{filtered.map((app, idx) => (
<CandidateCard
key={app.id}
application={app}
jobTitle={resolveTitle(app)}
rank={idx + 1}
onAction={handleAction}
onMessage={setMessageApp}
onCall={setMessageApp}
onSchedule={setScheduleApp}
onDecline={handleDecline}
onDelete={handleDelete}
onHire={handleHire}
/>
))}
</div>
);
}
const SECTION = ['move', 'hide'];
registerNodeType({
type: 'candidates-toolbar',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates',
label: 'Search and filters',
summary: 'Search, filter and sort the candidate pipeline.',
component: CandidatesToolbar,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'candidates-list',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'candidates',
label: 'Candidate list',
summary: 'Every candidate matching the current filters, ranked.',
component: CandidatesList,
capabilities: SECTION,
wrap: true,
});
registerPageComposition('candidates', [
{ id: 'candidates-extensions-top', type: 'skill-surface', props: { page: 'candidates', placement: 'after-header' } },
{ id: 'candidates-toolbar', type: 'candidates-toolbar' },
{ id: 'candidates-list', type: 'candidates-list' },
{ id: 'candidates-extensions-bottom', type: 'skill-surface', props: { page: 'candidates', placement: 'before-footer' } },
]);

View File

@@ -0,0 +1,909 @@
import React from 'react';
import {
Area, Bar, CartesianGrid, Cell, ComposedChart, Legend, Line, ResponsiveContainer,
Tooltip, XAxis, YAxis,
} from 'recharts';
import {
AlertTriangle, ArrowRight, Building2, CalendarCheck, CheckCircle2, ChevronRight, Clock, Filter, Sparkles, Users, Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
AXIS_PROPS, Avatar, Badge, CHART_TONES, ChartTooltip, EmptyState, SegmentedToggle,
Skeleton, StatusBadge,
} from '@/components/ds';
/**
* Control Center — the KROW Admin command centre.
*/
const RANGES = {
'7d': { label: '7D', days: 7, step: 1 },
'30d': { label: '30D', days: 30, step: 3 },
'90d': { label: '90D', days: 90, step: 9 },
};
const WEAK_TRANSITION = 60;
/** @param {any} props */
function Band({ id, title, meta = '', children, action = null }) {
return (
<section aria-labelledby={id} className="space-y-3">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2">
<div className="flex items-baseline gap-2">
<h2 id={id} className="font-heading text-body font-semibold text-ink-1">{title}</h2>
{meta && <span className="text-caption text-ink-4">{meta}</span>}
</div>
{action}
</div>
{children}
</section>
);
}
function ViewAll({ label, onClick }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-0.5 rounded text-caption font-semibold text-krow-blue transition-colors
hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{label}
<ArrowRight className="h-3 w-3" aria-hidden="true" />
</button>
);
}
/* ── 1. Operations snapshot ─────────────────────────────────────────────── */
const snapshotConfig = {
'Open positions': {
icon: Building2,
bg: 'bg-indigo-50/80 dark:bg-indigo-950/50',
iconColor: 'text-indigo-600 dark:text-indigo-400',
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
text: 'text-indigo-600 dark:text-indigo-400',
accent: 'from-indigo-500 to-blue-500',
},
Candidates: {
icon: Users,
bg: 'bg-blue-50/80 dark:bg-blue-950/50',
iconColor: 'text-blue-600 dark:text-blue-400',
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
text: 'text-blue-600 dark:text-blue-400',
accent: 'from-blue-500 to-cyan-500',
},
'In review': {
icon: Clock,
bg: 'bg-amber-50/80 dark:bg-amber-950/50',
iconColor: 'text-amber-600 dark:text-amber-400',
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
text: 'text-amber-600 dark:text-amber-400',
accent: 'from-amber-500 to-orange-500',
},
'AI screened': {
icon: Sparkles,
bg: 'bg-purple-50/80 dark:bg-purple-950/50',
iconColor: 'text-purple-600 dark:text-purple-400',
border: 'border-purple-200/50 dark:border-purple-800/40 hover:border-purple-400',
text: 'text-purple-600 dark:text-purple-400',
accent: 'from-purple-500 to-indigo-500',
},
Hired: {
icon: CheckCircle2,
bg: 'bg-emerald-50/80 dark:bg-emerald-950/50',
iconColor: 'text-emerald-600 dark:text-emerald-400',
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
text: 'text-emerald-600 dark:text-emerald-400',
accent: 'from-emerald-500 to-teal-500',
},
'Avg KROW score': {
icon: Zap,
bg: 'bg-blue-50/80 dark:bg-blue-950/50',
iconColor: 'text-blue-600 dark:text-blue-400',
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
text: 'text-blue-600 dark:text-blue-400',
accent: 'from-blue-600 to-indigo-600',
},
};
function Snapshot({ metrics, loading }) {
return (
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 xl:grid-cols-6">
{metrics.map((m) => {
const cfg = snapshotConfig[m.label] || snapshotConfig['Candidates'];
const Icon = cfg.icon;
return (
<div
key={m.label}
className={cn(
'group relative flex flex-col justify-between rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
cfg.border
)}
>
<div>
<div className="flex items-center justify-between gap-1.5">
<span className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-4">
{m.label}
</span>
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg, cfg.iconColor)}>
<Icon className="h-3.5 w-3.5" />
</div>
</div>
{loading ? (
<div className="mt-2.5 h-8 w-16 animate-pulse rounded-lg bg-surface-sunken" />
) : (
<div className="mt-2 flex items-baseline gap-1.5">
<span className={cn('font-heading text-title-xl font-bold leading-none tabular-nums', cfg.text)}>
{m.value}
</span>
</div>
)}
</div>
<div className="mt-3 space-y-2">
{m.sub && (
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
{m.sub}
</span>
)}
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
</div>
</div>
);
})}
</div>
);
}
/* ── 3. Pipeline intelligence ───────────────────────────────────────────── */
function PipelineFunnel({ funnel, transitions, weakest, total }) {
if (!total) {
return (
<EmptyState
title="Nothing in the pipeline"
description="Once candidates apply, stage conversion and drop-off appear here."
/>
);
}
const stageIcons = [Users, Sparkles, Filter, CalendarCheck, CheckCircle2];
return (
<div className="space-y-4">
{/* 5 Stage Funnel Cards */}
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-5">
{funnel.map((stage, i) => {
const transition = i === 0 ? null : transitions[i - 1];
const isWeak = transition && weakest
&& transition.from === weakest.from && transition.to === weakest.to;
const share = Math.round((stage.count / total) * 100);
const StageIcon = stageIcons[i] || Users;
return (
<div
key={stage.key}
className={cn(
'group relative flex flex-col justify-between rounded-xl border p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isWeak
? 'border-amber-500/50 bg-gradient-to-br from-amber-50/70 via-surface to-amber-50/20 dark:from-amber-950/40 dark:to-surface'
: 'border-border/80 bg-surface hover:border-blue-500/40'
)}
>
<div>
{/* Header: Stage Name & Icon */}
<div className="flex items-center justify-between gap-1.5">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[10px] font-extrabold text-ink-4 tabular-nums">0{i + 1}</span>
<h4 className="truncate font-heading text-body-sm font-bold text-ink-1">
{stage.label}
</h4>
</div>
<div
className={cn(
'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs',
isWeak
? 'bg-amber-500 text-white shadow-amber-500/25'
: i === funnel.length - 1
? 'bg-emerald-600 text-white shadow-emerald-500/25'
: 'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400'
)}
>
<StageIcon className="h-3.5 w-3.5" />
</div>
</div>
{/* Main Candidate Count & Pool Share */}
<div className="mt-3 flex items-baseline justify-between">
<span className="font-heading text-title-xl font-bold leading-none tabular-nums text-ink-1">
{stage.count}
</span>
<span
className={cn(
'rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums border',
isWeak
? 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300'
: 'bg-surface-subtle text-ink-3 border-border/60'
)}
>
{share}% pool
</span>
</div>
</div>
{/* Stage Fill Bar & Transition Metrics */}
<div className="mt-4 space-y-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-sunken">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
isWeak
? 'bg-gradient-to-r from-amber-500 to-orange-500'
: i === funnel.length - 1
? 'bg-gradient-to-r from-emerald-500 to-teal-500'
: 'bg-gradient-to-r from-blue-600 to-indigo-600'
)}
style={{ width: `${Math.max(share, stage.count ? 5 : 0)}%` }}
/>
</div>
<div className="flex items-center justify-between text-[10px] text-ink-4 pt-0.5">
{transition ? (
<>
<span className={cn(isWeak ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-ink-3')}>
{transition.rate}% pass
</span>
<span>{transition.lost ? `−${transition.lost} lost` : '0 lost'}</span>
</>
) : (
<span className="text-ink-4">Entry Stage</span>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Primary Pipeline Bottleneck Alert Banner */}
{weakest && weakest.lost > 0 && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl border border-amber-500/40 bg-gradient-to-r from-amber-50/90 via-surface to-orange-50/40 dark:from-amber-950/40 dark:via-surface dark:to-orange-950/30 p-4 shadow-xs">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-amber-500 text-white shadow-md shadow-amber-500/20">
<AlertTriangle className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400">
Primary Pipeline Bottleneck
</span>
<span className="rounded-full bg-amber-200/60 dark:bg-amber-900/60 px-2 py-0.5 text-[10px] font-bold text-amber-800 dark:text-amber-300">
{weakest.from} → {weakest.to}
</span>
</div>
<p className="mt-0.5 text-body-sm text-ink-2">
Only <span className="font-bold text-amber-700 dark:text-amber-400">{weakest.rate}%</span> pass through and{' '}
<span className="font-bold text-ink-1">{weakest.lost} candidates</span> are lost at this transition. Recovering this conversion yields the highest ROI.
</p>
</div>
</div>
</div>
)}
</div>
);
}
/* ── 5. Action center ───────────────────────────────────────────────────── */
const SEVERITY = {
critical: { dot: 'bg-destructive', label: 'Critical' },
warning: { dot: 'bg-warning', label: 'Warning' },
info: { dot: 'bg-krow-blue', label: 'For review' },
};
/** One operational queue, divided rows. Not five cards in a grid. */
function ActionQueue({ items }) {
if (!items.length) {
return (
<div className="rounded-xl border border-border bg-surface px-4 py-8 text-center">
<p className="text-body-sm text-ink-3">
Nothing needs intervention. Every role has candidates, every applicant is scored,
and no hire is awaiting review.
</p>
</div>
);
}
return (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{items.map((item) => (
<li key={item.title}>
<button
type="button"
onClick={item.onClick}
className="group flex w-full items-center gap-3.5 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
<span
className={cn('h-2 w-2 shrink-0 rounded-full', SEVERITY[item.severity].dot)}
aria-hidden="true"
/>
<span className="sr-only">{SEVERITY[item.severity].label}.</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">{item.title}</span>
<span className="block truncate text-caption text-ink-3">{item.detail}</span>
</span>
<span className="shrink-0 text-right">
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
{item.metric}
</span>
<span className="block text-[10px] text-ink-4">{item.metricLabel}</span>
</span>
<span className="hidden shrink-0 items-center gap-0.5 text-caption font-semibold text-krow-blue sm:inline-flex">
Review
<ChevronRight
className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</span>
</button>
</li>
))}
</ul>
);
}
/* ── Page ───────────────────────────────────────────────────────────────── */
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
/**
* Control Center, as addressable nodes.
*
* The largest composition in the product, and migrated last for that reason.
* Every band was moved verbatim. Each is wrapped so it carries its node
* identity: `Band` and `Snapshot` render their own roots and do not forward
* unknown props, and the wrapper is a bare div that paints nothing.
*/
function CCSnapshot({ attrs = {} }) {
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 1 ── Operations snapshot */}
<Snapshot metrics={metrics} loading={isLoading} />
</>
);
}
function CCActivity({ attrs = {} }) {
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 2 ── Hiring activity */}
<Band
id="cc-activity"
title="Hiring activity"
meta={
RANGES[range].step === 1
? `Applications, screening, interviews and hires · last ${RANGES[range].days} days, daily`
: `Applications, screening, interviews and hires · last ${RANGES[range].days} days, ${RANGES[range].step}-day totals`
}
action={
<SegmentedToggle
options={Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label }))}
value={range}
onChange={setRange}
size="sm"
ariaLabel="Hiring activity range"
/>
}
>
<div className="rounded-xl border border-border bg-surface px-2 py-4">
{isLoading ? (
<Skeleton className="mx-2 h-[272px] rounded-lg" />
) : f.total ? (
<ResponsiveContainer width="100%" height={272}>
<ComposedChart data={activitySeries} margin={{ top: 4, right: 12, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="ccApplications" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART_TONES.brand} stopOpacity={0.22} />
<stop offset="100%" stopColor={CHART_TONES.brand} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={20} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
<Tooltip content={<ChartTooltip />} />
<Legend
verticalAlign="top"
align="right"
height={28}
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
{/* Applications are the volume everything else is drawn from, so
they take the filled area and the downstream stages are lines
over it. */}
<Area
type="monotone"
dataKey="applications"
name="Applications"
stroke={CHART_TONES.brand}
strokeWidth={2}
fill="url(#ccApplications)"
activeDot={{ r: 4, strokeWidth: 0 }}
/>
<Line
type="monotone"
dataKey="screened"
name="AI Screened"
stroke={CHART_TONES.navy}
strokeWidth={2}
strokeDasharray="4 3"
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
<Line
type="monotone"
dataKey="interviews"
name="Interviews"
stroke={CHART_TONES.navy}
strokeWidth={1.5}
strokeOpacity={0.55}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
{/* Hires are sparse and are the outcome, so they take the accent
and a bar — a line at this volume reads as flat. */}
<Bar
dataKey="hires"
name="Hires"
fill={CHART_TONES.accent}
radius={[3, 3, 0, 0]}
maxBarSize={14}
/>
</ComposedChart>
</ResponsiveContainer>
) : (
<div className="px-2 py-8">
<EmptyState
title="No applications yet"
description="Once candidates start applying, daily hiring activity appears here."
/>
</div>
)}
</div>
</Band>
</>
);
}
function CCPipeline({ attrs = {} }) {
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 3 ── Pipeline intelligence */}
<Band
id="cc-pipeline"
title="Pipeline intelligence"
meta={`${f.total} entered · conversion and drop-off by stage`}
>
<PipelineFunnel
funnel={f.funnel}
transitions={f.transitions}
weakest={f.bottleneck && f.bottleneck.rate < WEAK_TRANSITION ? f.bottleneck : null}
total={f.total}
/>
</Band>
</>
);
}
function CCPositions({ attrs = {} }) {
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 4 ── Position performance */}
<Band
id="cc-positions"
title="Position performance"
meta={strongest ? `Strongest conversion: ${strongest}` : undefined}
action={<ViewAll label="View all positions" onClick={() => navigate('/admin/positions')} />}
>
<div className="rounded-xl border border-border bg-surface px-2 py-4">
{positionPerformance.length ? (
<>
<ResponsiveContainer width="100%" height={236}>
<ComposedChart
data={positionPerformance}
barGap={3}
margin={{ top: 4, right: 12, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis
dataKey="name"
{...AXIS_PROPS}
interval={0}
tick={{ fontSize: 10, fill: CHART_TONES.axis }}
/>
<YAxis {...AXIS_PROPS} allowDecimals={false} width={30} />
<Tooltip content={<ChartTooltip />} />
<Legend
verticalAlign="top"
align="right"
height={28}
iconType="circle"
iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
<Bar
dataKey="applicants"
name="Applicants"
fill={CHART_TONES.brand}
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
<Bar dataKey="qualified" name="Qualified (70+)" radius={[3, 3, 0, 0]} maxBarSize={24}>
{/* The best converter takes the accent at full strength and the
worst is left pale, so both ends of the comparison are
visible without a callout. */}
{positionPerformance.map((r) => (
<Cell
key={r.name}
fill={r.name === strongest ? CHART_TONES.accent
: r.name === weakestRole ? CHART_TONES.mint : CHART_TONES.accentPale}
/>
))}
</Bar>
<Bar
dataKey="hired"
name="Hired"
fill={CHART_TONES.navy}
radius={[3, 3, 0, 0]}
maxBarSize={24}
/>
</ComposedChart>
</ResponsiveContainer>
{weakestRole && (
<p className="px-2 pt-2 text-caption leading-relaxed text-ink-3">
<span className="font-semibold text-ink-1">{strongest}</span> converts applicants
into qualified candidates best;{' '}
<span className="font-semibold text-ink-1">{weakestRole}</span> converts worst and
is where sourcing — or the bar itself — is worth a look.
</p>
)}
</>
) : (
<div className="px-2 py-8">
<EmptyState
title="No open positions"
description="Publish a role and its hiring performance appears here."
/>
</div>
)}
</div>
</Band>
</>
);
}
function CCActions({ attrs = {} }) {
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 5 ── Action center */}
<Band
id="cc-actions"
title="Action center"
meta={actions.length ? `${actions.length} items, most consequential first` : 'All clear'}
>
<ActionQueue items={actions} />
</Band>
</>
);
}
function CCLists({ attrs = {} }) {
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 6 / 7 ── Active positions and top talent. Two lists of comparable weight,
so they share a row rather than each taking one. */}
<div className="grid gap-6 xl:grid-cols-2">
<Band
id="cc-active"
title="Active positions"
meta={`${f.openPositions.length} hiring`}
action={<ViewAll label="View all" onClick={() => navigate('/admin/positions')} />}
>
{activePositions.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{activePositions.map((r) => {
const coverage = r.applied ? Math.round((r.screened / r.applied) * 100) : 0;
return (
<li key={r.title}>
<button
type="button"
onClick={() => navigate('/admin/positions')}
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">
{r.title}
</span>
<span className="block truncate text-caption text-ink-3">
{r.applied} applicant{r.applied === 1 ? '' : 's'} · {r.screened} screened
{' · '}{r.hired} hired
</span>
</span>
{/* Screening coverage as a small bar — the one number that
says whether this role is actually being worked. */}
<span className="hidden w-16 shrink-0 sm:block">
<span className="block h-1.5 overflow-hidden rounded-full bg-surface-sunken">
<span
className={cn(
'block h-full rounded-full',
coverage === 100 ? 'bg-success'
: coverage >= 50 ? 'bg-krow-blue' : 'bg-warning'
)}
style={{ width: `${Math.max(coverage, r.applied ? 4 : 0)}%` }}
/>
</span>
<span className="mt-1 block text-right text-[10px] tabular-nums text-ink-4">
{coverage}%
</span>
</span>
<StatusBadge status={r.posting.status} size="sm" />
<ChevronRight
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
);
})}
</ul>
) : (
<EmptyState title="No open positions" description="Publish a role to start hiring." />
)}
</Band>
<Band
id="cc-talent"
title="Top talent"
meta={topTalent.length ? 'By KROW Score' : undefined}
action={<ViewAll label="View all" onClick={() => navigate('/admin/candidates')} />}
>
{topTalent.length ? (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{topTalent.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => navigate('/admin/candidates')}
className="group flex w-full items-center gap-3 px-4 py-3 text-left transition-colors
hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-inset focus-visible:ring-krow-blue/50"
>
{/* The seeded portrait where there is one, otherwise the design
system's initials avatar. Never an invented face. */}
<Avatar name={c.applicant_name} src={c.selfie_url} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">
{c.applicant_name}
</span>
<span className="block truncate text-caption text-ink-3">{c.job_title}</span>
</span>
<span className="shrink-0 text-right">
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">
{c.ai_score}
</span>
<span className="block text-[10px] text-ink-4">KROW</span>
</span>
<StatusBadge status={c.status} size="sm" />
<ChevronRight
className="h-3.5 w-3.5 shrink-0 text-ink-4 transition-transform duration-base group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
))}
</ul>
) : (
<EmptyState
title="Nobody scored yet"
description="Screen the pipeline and the strongest candidates appear here."
/>
)}
</Band>
</div>
</>
);
}
function CCRecent({ attrs = {} }) {
const { navigate, range, setRange, applications, isLoading, postings, interviews, staff, profiles, activity, f, metrics, activitySeries, positionPerformance, strongest, weakestRole, actions, activePositions, topTalent, recent, exportSnapshot } = useUiContext();
return (
<>
{/* 8 ── Recent activity */}
<Band
id="cc-recent"
title="Recent activity"
meta={`${activity.length} events logged`}
action={<ViewAll label="View all" onClick={() => navigate('/admin/activity')} />}
>
<div className="overflow-hidden rounded-xl border border-border bg-surface">
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] border-collapse">
<caption className="sr-only">Most recent platform activity</caption>
<thead>
<tr className="border-b border-border bg-surface-subtle">
{['User', 'Action', 'Entity', 'Time', 'Role'].map((h) => (
<th
key={h}
scope="col"
className={cn(
'whitespace-nowrap px-4 py-2.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
h === 'Role' ? 'text-right' : 'text-left'
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{recent.map((event) => (
<tr key={event.id} className="border-b border-border last:border-0 hover:bg-surface-subtle">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2.5">
<Avatar name={event.user_name || event.user_email} size="xs" />
<div className="min-w-0">
<p className="truncate text-body-sm font-medium text-ink-1">
{event.user_name || '—'}
</p>
<p className="truncate text-[10px] text-ink-4">{event.user_email}</p>
</div>
</div>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-body-sm text-ink-2">
{String(event.event_type).replace(/_/g, ' ')}
</td>
<td className="max-w-[24rem] px-4 py-2.5 text-body-sm text-ink-3">
<span className="line-clamp-1">{event.details || '—'}</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-caption text-ink-4">
{new Date(event.created_date).toLocaleDateString(undefined, {
month: 'short', day: 'numeric',
})}
</td>
<td className="px-4 py-2.5 text-right">
<Badge variant={event.account_type === 'employer' ? 'info' : 'neutral'} size="sm">
{event.account_type || 'unknown'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Band>
</>
);
}
const SECTION = ['move', 'hide'];
registerNodeType({
type: 'cc-snapshot',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'control-center',
label: 'Operations snapshot',
component: CCSnapshot,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'cc-activity',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'control-center',
label: 'Hiring activity',
component: CCActivity,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'cc-pipeline',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'control-center',
label: 'Pipeline intelligence',
component: CCPipeline,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'cc-positions',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'control-center',
label: 'Position performance',
component: CCPositions,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'cc-actions',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'control-center',
label: 'Action center',
component: CCActions,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'cc-lists',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'control-center',
label: 'Active positions and top talent',
component: CCLists,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'cc-recent',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'control-center',
label: 'Recent activity',
component: CCRecent,
capabilities: SECTION,
wrap: true,
});
registerPageComposition('control-center', [
{ id: 'cc-extensions-top', type: 'skill-surface', props: { page: 'control-center', placement: 'after-header' } },
{ id: 'cc-snapshot', type: 'cc-snapshot' },
{ id: 'cc-activity', type: 'cc-activity' },
{ id: 'cc-pipeline', type: 'cc-pipeline' },
{ id: 'cc-positions', type: 'cc-positions' },
{ id: 'cc-actions', type: 'cc-actions' },
{ id: 'cc-lists', type: 'cc-lists' },
{ id: 'cc-recent', type: 'cc-recent' },
{ id: 'cc-extensions-bottom', type: 'skill-surface', props: { page: 'control-center', placement: 'before-footer' } },
]);

View File

@@ -0,0 +1,275 @@
import React from 'react';
import { Award, Building2, CalendarDays, Clock, UserCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Button, DataTable, EmptyState, FilterBar, MetricStrip, StatusBadge, Surface, Timeline,
} from '@/components/ds';
import { SectionTitle } from '@/components/admin/PageShell';
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
/**
* Hired History, as addressable nodes.
*
* The second page composed through the UI node tree, and it follows Activity's
* pattern exactly: the markup below was moved here verbatim, the page keeps its
* own state and publishes it once, and each section is a singleton type that
* declares only the operations that mean something for a built-in.
*
* `chronology` is the Recent Hiring Timeline. It keeps the id its heading has
* always carried in the DOM, so a person naming it in conversation and the
* markup on screen refer to the same thing.
*/
/** Date-range windows, expressed as days back from today. */
export const RANGES = [
{ value: 'all', label: 'Any time', days: null },
{ value: '7', label: 'Last 7 days', days: 7 },
{ value: '30', label: 'Last 30 days', days: 30 },
{ value: '90', label: 'Last 90 days', days: 90 },
];
export const DAY = 24 * 60 * 60 * 1000;
export const formatDate = (value) => (value
? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: '—');
/* 1. Search and filters — the page is a record, so finding one is the
first thing it has to do well. */
function HiredFilters() {
const { search, setSearch, filters, setFilters, positionOptions, departmentOptions } = useUiContext();
return (
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search by name, position, client or department…"
filters={[
{ key: 'position', label: 'Position', type: 'select', options: [{ value: 'all', label: 'All positions' }, ...positionOptions.map((p) => ({ value: p, label: p }))] },
{ key: 'department', label: 'Department', type: 'select', options: [{ value: 'all', label: 'All departments' }, ...departmentOptions.map((d) => ({ value: d, label: d }))] },
{ key: 'range', label: 'Hired', type: 'select', options: RANGES.map((r) => ({ value: r.value, label: r.label })) },
]}
values={filters}
onChange={setFilters}
/>
);
}
/* 2. What the current selection contains. A count of the record, not a
performance verdict — that reading is Analytics'. */
function HiredSummary() {
const { isLoading, hires, summary, recent, isFiltered } = useUiContext();
return (
<MetricStrip
columns={3}
loading={isLoading}
items={[
{
label: isFiltered ? 'Hires matching' : 'Total hires',
value: summary.total,
icon: Award,
tone: 'brand',
sub: isFiltered ? `of ${hires.length} on record` : undefined,
},
{
label: 'Most recent hire',
value: recent[0] ? formatDate(recent[0].hire_date) : '—',
icon: CalendarDays,
sub: recent[0]?.name,
},
{
label: 'Avg time-to-hire',
value: summary.speed ? `${summary.speed}d` : '—',
icon: Clock,
sub: `${summary.active} still active`,
},
]}
/>
);
}
/* 3. The chronology. */
function HiredChronology({ attrs = {} }) {
const { recent, filtered } = useUiContext();
if (!recent.length) return null;
return (
<section aria-labelledby="chronology" className="space-y-3" {...attrs}>
<SectionTitle
id="chronology"
title="Recent hiring timeline"
meta={`Last ${recent.length} of ${filtered.length}`}
/>
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="border border-border">
<Timeline
items={recent.map((h, i) => ({
id: h.id,
title: h.name,
description: [h.role, h.company !== '—' ? h.company : null]
.filter(Boolean).join(' · '),
timestamp: formatDate(h.hire_date),
tone: i === 0 ? 'brand' : 'neutral',
current: i === 0,
icon: UserCheck,
}))}
/>
</Surface>
</section>
);
}
/* 4. The records themselves — every field a hire carries, one row each. */
function HiredRecords({ attrs = {} }) {
const { isLoading, hires, filtered, isFiltered, clearFilters, setSelected } = useUiContext();
return (
<section aria-labelledby="records" className="space-y-3" {...attrs}>
<SectionTitle
id="records"
title="Hired candidate records"
meta={isFiltered ? `${filtered.length} of ${hires.length}` : `${hires.length} people`}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={12}
caption="Everyone hired, with position, client, score and time-to-hire"
onRowClick={setSelected}
isFiltered={isFiltered}
onClearFilters={clearFilters}
emptyState={(
<EmptyState
title={isFiltered ? 'No hires match these filters' : 'No hires recorded yet'}
description={
isFiltered
? 'Try a broader search, or clear the filters to see the whole record.'
: 'Hires appear here as positions close.'
}
action={isFiltered
? <Button variant="outline" size="sm" onClick={clearFilters}>Clear filters</Button>
: undefined}
/>
)}
columns={[
{
key: 'name', header: 'Candidate', sortable: true,
cell: (h) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={h.name} size="sm" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{h.name}</p>
<p className="truncate text-[10px] text-ink-4">{h.email}</p>
</div>
</div>
),
},
{ key: 'role', header: 'Position', sortable: true, hideBelow: 'md' },
{
key: 'company', header: 'Company', sortable: true, hideBelow: 'lg',
cell: (h) => (h.company && h.company !== '—'
? (
<span className="inline-flex items-center gap-1.5 text-ink-2">
<Building2 className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
<span className="truncate">{h.company}</span>
</span>
)
: '—'),
},
{ key: 'department', header: 'Department', sortable: true, hideBelow: 'lg' },
{
key: 'hire_date', header: 'Hired', align: 'right', sortable: true,
cell: (h) => (
<span className="whitespace-nowrap text-[11px] text-ink-4">{formatDate(h.hire_date)}</span>
),
},
{
key: 'score', header: 'AI score', align: 'right', sortable: true,
cell: (h) => (h.score
? <span className="font-semibold tabular-nums text-ink-1">{h.score}</span>
: '—'),
},
{
key: 'timeToHire', header: 'Time to hire', align: 'right', sortable: true, hideBelow: 'lg',
cell: (h) => (h.timeToHire ? `${h.timeToHire}d` : '—'),
},
{
key: 'status', header: 'Status', align: 'right',
cell: (h) => <StatusBadge status={h.status || 'hired'} size="sm" />,
},
]}
/>
<p className={cn('text-caption text-ink-4')}>
Select a row to open the full record.
</p>
</section>
);
}
/* A page section is a singleton: `move` and `hide` are the only operations that
mean anything for one. */
const SECTION = ['move', 'hide'];
registerNodeType({
type: 'hired-filters',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'hired-history',
label: 'Search and filters',
summary: 'Search across every hire, and the fields worth filtering by.',
component: HiredFilters,
capabilities: SECTION,
/* `FilterBar` destructures its props, so the renderer supplies a bare wrapper
to carry the node's identity. No styling, no layout of its own. */
wrap: true,
});
registerNodeType({
type: 'hired-summary',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'hired-history',
label: 'Hire summary',
summary: 'How much of the record the current filters select.',
component: HiredSummary,
capabilities: SECTION,
wrap: true,
});
registerNodeType({
type: 'hired-chronology',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'hired-history',
label: 'Recent hiring timeline',
summary: 'The most recent hires, newest first.',
component: HiredChronology,
capabilities: SECTION,
});
registerNodeType({
type: 'hired-records',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'hired-history',
label: 'Hired candidate records',
summary: 'Every hire, one row each, with position, client and score.',
component: HiredRecords,
capabilities: SECTION,
});
/**
* The page as it ships.
*
* `chronology` and `records` keep the ids their headings already carried, so a
* node's identity is continuous with what the page has always said about
* itself. The detail drawer is deliberately absent: it is an overlay, not a
* section of the page, and nothing sensible would come of reordering it.
*/
registerPageComposition('hired-history', [
{ id: 'hired-extensions-top', type: 'skill-surface', props: { page: 'hired-history', placement: 'after-header' } },
{ id: 'hired-filters', type: 'hired-filters' },
{ id: 'hired-summary', type: 'hired-summary' },
{ id: 'chronology', type: 'hired-chronology' },
{ id: 'records', type: 'hired-records' },
]);

View File

@@ -0,0 +1,36 @@
import { registerPageComposition } from '@/lib/ui/composition';
/**
* Positions, page level only.
*
* Positions has three scopes and only one of them is flat. These two slots
* render **once for the page**, with no position in context, which is what a
* definition reporting across every role needs — and it is where the Board
* skill's section lives.
*
* The other seven slots are *instanced*: `after-position-card` renders inside
* each card, and five more render inside the drawer for one open position, each
* with `context={{ position }}`. A single tree cannot address those, because
* there is no one node for them — there are as many renderings as there are
* records. Giving them identity needs a repeating-subtree model and a decision
* about whether a change to one card means every card; that is deliberately not
* attempted here, and the page keeps rendering them exactly as it always has.
*
* So this composition is honest about being partial: two nodes, drawn where
* they have always been drawn, addressable like anything else.
*/
registerPageComposition('positions', [
{
id: 'positions-extensions-summary',
type: 'skill-surface',
props: { page: 'positions', placement: 'after-position-list-summary' },
/* The separation this slot has always had from the grid below it. */
layout: { spacingAfter: 'md' },
},
{
id: 'positions-extensions-list',
type: 'skill-surface',
props: { page: 'positions', placement: 'after-position-list' },
layout: { spacingBefore: 'lg' },
},
]);

View File

@@ -0,0 +1,332 @@
import React from 'react';
import { Bookmark, Star, UserRound, Trophy, CheckCircle2, TrendingUp, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Avatar, Badge, DataTable, IconButton, ProgressRing, SearchInput, toast,
} from '@/components/ds';
import { getScoreBand, toFICO } from '@/lib/talentHome';
import { SectionTitle, Toolbar } from '@/components/admin/PageShell';
import { FilterSelect } from '@/pages/admin/Positions';
import { registerNodeType } from '@/lib/ui/registry';
import { registerPageComposition } from '@/lib/ui/composition';
import { useUiContext } from '@/components/ui-tree/UiTreeRenderer';
/**
* Talent Pool, as addressable nodes.
*
* Moved verbatim from the page. The directory — its heading, its toolbar and
* its table — is one node rather than three: hiding a table and leaving its
* title behind is not a state anybody wants, and the three move together.
*/
export const SORTS = {
score: { label: 'Highest score', compare: (a, b) => (b.krow_score || 0) - (a.krow_score || 0) },
experience: { label: 'Most experience', compare: (a, b) => (b.experience_years || 0) - (a.experience_years || 0) },
name: { label: 'Name A\u2013Z', compare: (a, b) => a.full_name.localeCompare(b.full_name) },
recent: { label: 'Recently added', compare: (a, b) => new Date(b.created_date).getTime() - new Date(a.created_date).getTime() },
};
export const SEGMENT_CONFIG = {
Elite: {
key: 'elite',
icon: Trophy,
bg: 'bg-blue-50/80 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
activeBorder: 'border-blue-600 ring-2 ring-blue-500/20',
text: 'text-blue-600 dark:text-blue-400',
accent: 'from-blue-500 to-indigo-500',
},
Excellent: {
key: 'excellent',
icon: Star,
bg: 'bg-emerald-50/80 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
activeBorder: 'border-emerald-600 ring-2 ring-emerald-500/20',
text: 'text-emerald-600 dark:text-emerald-400',
accent: 'from-emerald-500 to-teal-500',
},
Solid: {
key: 'solid',
icon: CheckCircle2,
bg: 'bg-indigo-50/80 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
activeBorder: 'border-indigo-600 ring-2 ring-indigo-500/20',
text: 'text-indigo-600 dark:text-indigo-400',
accent: 'from-indigo-500 to-purple-500',
},
Building: {
key: 'building',
icon: TrendingUp,
bg: 'bg-amber-50/80 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
activeBorder: 'border-amber-600 ring-2 ring-amber-500/20',
text: 'text-amber-600 dark:text-amber-400',
accent: 'from-amber-500 to-orange-500',
},
Unscored: {
key: 'unscored',
icon: Sparkles,
bg: 'bg-orange-50/80 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400',
border: 'border-orange-200/50 dark:border-orange-800/40 hover:border-orange-400',
activeBorder: 'border-orange-600 ring-2 ring-orange-500/20',
text: 'text-orange-600 dark:text-orange-400',
accent: 'from-orange-500 to-red-500',
},
};
/* Segments — executive metric cards */
function TalentSegments({ attrs = {} }) {
const { profiles, segments, band, setBand } = useUiContext();
return (
<section aria-labelledby="segments" className="space-y-3" {...attrs}>
<SectionTitle
id="segments"
title="Talent segments"
meta={`${profiles.length} in the pool`}
/>
<div>
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
{segments.map((s) => {
const cfg = SEGMENT_CONFIG[s.label] || SEGMENT_CONFIG.Elite;
const Icon = cfg.icon;
const isSelected = band === cfg.key;
const empty = s.count === 0;
return (
<div
key={s.label}
onClick={() => setBand(isSelected ? 'all' : cfg.key)}
className={cn(
'group relative flex flex-col justify-between cursor-pointer rounded-xl border bg-surface p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isSelected ? cfg.activeBorder : cfg.border
)}
>
<div>
{/* Header Row: Label, Hint & Icon */}
<div className="flex items-center justify-between gap-1.5">
<div className="flex items-baseline gap-1.5 min-w-0">
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-1">
{s.label}
</span>
<span className="truncate text-[10px] font-medium text-ink-4">
{s.hint}
</span>
</div>
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg)}>
<Icon className="h-3.5 w-3.5" />
</div>
</div>
{/* Main count */}
<div className="mt-2.5 flex items-baseline gap-1.5">
<span
className={cn(
'font-heading text-title-xl font-bold leading-none tabular-nums',
empty ? 'text-ink-4' : cfg.text
)}
>
{s.count}
</span>
</div>
</div>
{/* Sub pill & Accent bar */}
<div className="mt-3.5 space-y-2">
<span className="inline-flex items-center rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-semibold text-ink-3 border border-border/60">
{empty
? 'No profiles'
: [
`${s.available} available`,
s.label === 'Unscored'
? 'needs verification'
: s.avgExperience > 0
? `${s.avgExperience}y avg`
: null,
]
.filter(Boolean)
.join(' · ')}
</span>
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
</div>
</div>
);
})}
</div>
<p className="mt-3 border-t border-border/60 pt-3 text-caption leading-relaxed text-ink-3">
{segments[4].count > segments[0].count + segments[1].count
? `${segments[4].count} workers carry no career score against ${segments[0].count + segments[1].count} at Excellent or above. Supply is not the constraint here — verification is.`
: 'The scored part of the pool outweighs the unscored, so matching has enough to work with.'}
</p>
</div>
</section>
);
}
/** The directory: its heading, its filters and the ranked table. */
function TalentDirectory({ attrs = {} }) {
const {
profiles, filtered, isLoading, isFiltered, clearFilters, saved, toggleSave,
search, setSearch, skill, setSkill, experience, setExperience, location, setLocation,
band, setBand, availability, setAvailability, sort, setSort,
skills, locations, availabilities,
} = useUiContext();
/* `space-y-6` because these three were direct children of the page's own
stack and drew their separation from it. Grouping them into one node has to
carry that rhythm inward, or the heading, the filters and the table close
up against each other. */
return (
<div className="space-y-6" {...attrs}>
<SectionTitle title="Talent directory" meta={`${profiles.length} profiles`} />
<Toolbar
search={<SearchInput value={search} onChange={setSearch} placeholder="Search name, role or skill" size="sm" />}
filters={
<>
<FilterSelect value={skill} onChange={setSkill} label="Skills"
options={[{ value: 'all', label: 'All skills' }, ...skills.map((s) => ({ value: s, label: s }))]} />
<FilterSelect value={experience} onChange={setExperience} label="Experience" options={[
{ value: 'all', label: 'Any experience' }, { value: '5plus', label: '5+ years' },
{ value: '2to5', label: '2–5 years' }, { value: 'under2', label: 'Under 2 years' },
]} />
<FilterSelect value={location} onChange={setLocation} label="Location"
options={[{ value: 'all', label: 'All locations' }, ...locations.map((l) => ({ value: l, label: l }))]} />
<FilterSelect value={band} onChange={setBand} label="Score" options={[
{ value: 'all', label: 'All scores' }, { value: 'elite', label: 'Elite 90+' },
{ value: 'excellent', label: 'Excellent 75+' }, { value: 'solid', label: 'Solid 60+' },
{ value: 'building', label: 'Building' }, { value: 'unscored', label: 'Unscored' },
]} />
<FilterSelect value={availability} onChange={setAvailability} label="Availability"
options={[{ value: 'all', label: 'Any availability' }, ...availabilities.map((a) => ({ value: a, label: a }))]} />
<FilterSelect value={sort} onChange={setSort} label="Sort"
options={Object.entries(SORTS).map(([value, s]) => ({ value, label: s.label }))} />
</>
}
meta={`${filtered.length} of ${profiles.length} talents${isFiltered ? ' · filtered' : ''}${saved.length ? ` · ${saved.length} saved` : ''}`}
/>
<DataTable
loading={isLoading}
rows={filtered}
pageSize={15}
isFiltered={isFiltered}
onClearFilters={clearFilters}
caption="Talent pool ranked by career score"
columns={[
{
key: 'full_name', header: 'Candidate', sortable: true,
cell: (p) => (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={p.full_name} src={p.selfie_url} size="sm" />
<div className="min-w-0">
<p className="truncate font-medium text-ink-1">{p.full_name}</p>
<p className="truncate text-[10px] text-ink-4">{p.address || p.email}</p>
</div>
</div>
),
},
{
key: 'role', header: 'Role', hideBelow: 'md',
cell: (p) => (
<div className="min-w-0">
<p className="truncate text-ink-2">{p.current_position || p.desired_position || '—'}</p>
{p.desired_position && p.current_position && (
<p className="truncate text-[10px] text-ink-4">wants {p.desired_position}</p>
)}
</div>
),
},
{
key: 'skills', header: 'Skills', hideBelow: 'lg',
cell: (p) => (p.skills?.length ? (
<div className="flex items-center gap-1">
<Badge variant="neutral" size="sm">{p.skills[0]}</Badge>
{p.skills.length > 1 && <span className="text-[10px] text-ink-4">+{p.skills.length - 1}</span>}
</div>
) : <span className="text-[11px] text-ink-4">—</span>),
},
{
key: 'experience_years', header: 'Exp', align: 'right', sortable: true, hideBelow: 'md',
cell: (p) => (p.experience_years ? `${p.experience_years}y` : '—'),
},
{
key: 'availability', header: 'Availability', hideBelow: 'lg',
cell: (p) => (p.availability?.length
? <span className="text-[11px] text-ink-3">{p.availability.join(', ')}</span>
: <Badge variant="warning" size="sm">Not set</Badge>),
},
{
key: 'krow_score', header: 'Career score', align: 'right', sortable: true,
cell: (p) => {
const score = p.krow_score || 0;
const bandInfo = getScoreBand(score);
return (
<div className="flex items-center justify-end gap-2">
<div className="text-right">
<p className="font-heading text-body font-bold tabular-nums text-ink-1">{toFICO(score)}</p>
<p className="text-[10px] text-ink-4">{bandInfo.label}</p>
</div>
<ProgressRing value={score} size={26} strokeWidth={3} tone="score" label="" />
</div>
);
},
},
{
key: 'actions', header: '', align: 'right', width: 92,
cell: (p) => (
<div className="flex items-center justify-end gap-0.5" onClick={(e) => e.stopPropagation()} role="presentation">
<IconButton
icon={saved.includes(p.id) ? Star : Bookmark}
label={saved.includes(p.id) ? `Remove ${p.full_name} from saved` : `Save ${p.full_name}`}
variant="ghost"
size="sm"
onClick={() => toggleSave(p.id, p.full_name)}
/>
<IconButton
icon={UserRound}
label={`View ${p.full_name}'s profile`}
variant="ghost"
size="sm"
onClick={() => toast.info(`Opening ${p.full_name}'s KROW Identity`)}
/>
</div>
),
},
]}
/>
</div>
);
}
const SECTION = ['move', 'hide'];
registerNodeType({
type: 'talent-segments',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'talent-pool',
label: 'Talent segments',
summary: 'The pool split into score bands, each a filter.',
component: TalentSegments,
capabilities: SECTION,
});
registerNodeType({
type: 'talent-directory',
/* This page's own section: it reads what this page publishes, so it belongs
nowhere else. See `page` in the registry. */
page: 'talent-pool',
label: 'Talent directory',
summary: 'Every profile, filtered and ranked by career score.',
component: TalentDirectory,
capabilities: SECTION,
});
registerPageComposition('talent-pool', [
{ id: 'talent-extensions-top', type: 'skill-surface', props: { page: 'talent-pool', placement: 'after-header' } },
{ id: 'segments', type: 'talent-segments' },
{ id: 'directory', type: 'talent-directory' },
{ id: 'talent-extensions-bottom', type: 'skill-surface', props: { page: 'talent-pool', placement: 'before-footer' } },
]);

View File

@@ -57,13 +57,18 @@ which already carry the funnel, the interview and the outcome.
Each line is `field | question | suggestions | required?`. Suggestions beginning
with `@` come from the application's own data.
`@workers` is the worker profiles already on screen for this organization.
Picking one records the role against that person's profile and email; typing an
email address that has no profile yet also works, because a role can be declared
before a profile exists. The worker is always asked for and is never assumed to
be whoever is typing — an operator records this on somebody's behalf.
This creates a NEW employee. The name is not looked up — an organization may
employ several people who share one, so a name selects nobody — and the email is
asked for outright and never derived from the name, from the operator's account
or from an earlier conversation. The email is the identity: `worker_profiles`
carries UNIQUE (org_id, email), so the database decides whether this person
already exists.
- worker | Which worker is this role for? Type their name or email. | @workers | required
Recording another role for somebody who is already on file is a different
request and is not this flow.
- worker_name | What is the new employee’s full name? | | required
- worker_email | What is their email address? | | required
- role_category | What role do they work as? | @roles | required
- experience_years | How much experience do they have? | No experience; 1 year; 2 years; 3+ years | optional
- english_level | What is their English level? | @english | optional