3 Commits

Author SHA1 Message Date
f522b6508e archive issue fix
Some checks failed
CI / check (push) Failing after 5m5s
2026-09-10 19:29:39 +05:30
6249e00a3a candidates and board ui agent issue
Some checks failed
CI / check (push) Failing after 4m58s
2026-09-05 10:46:06 +05:30
e02a0c23d4 Make a conversation a registry, and add the second one
Some checks failed
CI / check (push) Failing after 4m57s
`positionFlow.js` was five per-domain concerns in one file — a field table, an
`@`-token resolver, a sentence extractor, a commit vocabulary and a set of
outcome renderers — and only the control flow between them was general.
Everything else knew it was creating a job posting. Adding a second
conversation meant a second copy of all of it.

So the control flow is now `conversationFlow.js` and each kind of record is a
REGISTRY. Which conversation a skill runs is the skill's own `flow:` line,
resolved through a map: `routing.js` used to say `if (skill.id ===
'create-position')`, which made a second conversational skill a change to the
router rather than a file on disk — the `if agent_key == ...` shape the
platform rules out one level up. The panel's write callback is likewise a map
keyed by flow id instead of an `onCreatePosition` prop, and the outcome wording
comes off the registry, so nothing in the panel names a kind of record any
more.

`create-employee-role` is the second registry. The worker is asked for and
never assumed: a conversation that names nobody re-asks rather than falling
back to the session, because an operator records this on somebody's behalf. Its
`extract` is deliberately narrower than the posting's — "bartender, weekends,
$30/hr" settles three fields and leaves the subject alone, since guessing WHO a
record is about from a fragment is how a role gets filed against the wrong
person.

THE CONFIRMATION STEP ACCEPTED "create position" AND SILENTLY REJECTED "create
positions" — the plural the Positions page itself uses. An anchored regex missed
it, and the reader got the summary back with no indication of what was wrong
with what they said, which is indistinguishable from the screen not having
updated. Matching is now exact membership against a normalized reply, so a
vocabulary is a list of phrases somebody can read rather than an expression
somebody has to parse.

Two bugs in `extractRole`, both of which fabricated a value nobody typed on the
one field a position cannot be created without:

  - The phrase pattern marks "new" as the role by the same grammar that marks
    "sous chef", so "create new position" opened the conversation titled "New".
    The scaffolding is a PHRASE at least as often as a single word, so a
    per-word test still produced "Brand New" and "One More". Scaffolding words
    are now stripped to DECIDE whether the phrase named anything, and the
    ORIGINAL phrase is returned when it did — strip to test, never to rewrite,
    or "second chef" becomes "Chef" and the cure is worse than the bug.

  - `(?:a|an)?\s*` has no word boundary, so it matched the leading "a" of
    "another" and the capture began mid-word. That mangled scaffolding into
    "Nother New" and, worse, corrupted every role introduced with "an":
    "create an open kitchen lead position" titled the position "N Open Kitchen
    Lead". A real role, typed correctly, silently wrong. Found by mutation
    testing the first fix.

`@companies` and `@workers` resolve from data the panel already holds — postings
and profiles the API has already scoped to the caller — so neither widens
anybody's view and neither costs a request. The company list is deliberately
unsorted: `useJobPostings` asks for `-created_date`, so the clients staffed for
most recently come first, and the panel does no ranking of its own. That last
part is a rule the suite enforces structurally, and it is the right rule — a
second opinion formed in the panel outranking the server's is exactly the kind
of thing that decays quietly.

`npm test` now refuses a conversation step whose field its registry does not
define. `stepsOf` drops unknown fields, so a typo means the flow asks fewer
questions than the file lists — and a skill whose steps are ALL unknown asks
none, jumps to the summary, and offers to write an empty record. Nothing errors
and the Markdown still reads correctly. 970 checks, up from 924; the new ones
walk both conversations end to end, because a wrong answer at the confirmation
step re-renders the same summary a right answer does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-09-02 15:29:48 +05:30
92 changed files with 18960 additions and 2475 deletions

393
docs/instanced-ui-nodes.md Normal file
View File

@@ -0,0 +1,393 @@
# Repeated and instanced UI nodes — design
> **Status: design only. Nothing here is implemented.**
> The audit below is traced from the code as it stands; the design that follows
> is a proposal to be reviewed and tested before any of it is built.
Every page migrated so far is *flat*: each node in the composition renders
exactly once, so a node id and a rendering are the same thing. Positions is the
first surface where that is not true. Six of its eight extension points render
**inside a record** — once per position — and the card those records are drawn
in is hand-written JSX that the node system cannot see at all.
This document says what is there now, proposes a model for it, and is honest
about what the model costs.
---
## 1. What is there now
### 1.1 The eight Positions placements, and which are instanced
`surfaces.js:47-76` declares eight placements under the `positions` surface and
`provides` already records the distinction that matters — which of them hand a
section a record:
| Placement | Rendered | Host | `provides` |
|---|---|---|---|
| `after-position-list-summary` | once per page | `Positions.jsx` (grid) | `[]` |
| `after-position-list` | once per page | `Positions.jsx` (grid) | `[]` |
| `after-position-card` | **once per record** | `PositionCard` | `positionId` |
| `after-header` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
| `after-position-summary` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
| `before-candidates` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
| `after-candidates` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
| `before-footer` | once per open record | `PositionDrawer` **and** `PositionDetail` | `positionId` |
Only the first two are in the node tree today (`pages/admin/positions/nodes.js`),
drawn through `UiNodeSlot`. The other six are still literal `<SkillSurface>`
elements.
### 1.2 The render loops
**The grid** — `Positions.jsx:1158`:
```jsx
{filtered.map((p) => (
<PositionCard key={p.id} position={p} onOpen={openPosition} isRecent={recentIds.has(p.id)} />
))}
```
`filtered` is page state: the search box, the role and location selects and the
sort, applied in `useMemo`. It is not a data source in the `surfaces.js` sense
and has no entry in `DATA_SOURCES` — it is the page's own working set.
**Inside each card** — `Positions.jsx:626-641`. The card's last element, wrapped
in a click-stopping `div` so a section inside a card does not open the drawer
behind it:
```jsx
<SkillSurface page="positions" placement="after-position-card"
context={{ position: p }} className="mt-3" />
```
**The drawer** — `PositionDrawer` (`Positions.jsx:719, 729, 793, 870, 897`) draws
five surfaces, each with `context={{ position: p }}`, for the one position
`openPosition` selected. `PositionDetail.jsx:288, 421, 472, 512, 513` draws the
same five placements for the position named in the route.
### 1.3 How a record reaches a section
Unchanged all the way down, and already correct for this design:
```
<SkillSurface context={{ position: p }} />
→ useSkillDataContext(context) SkillSurface.jsx:76 { ...published, ...context, ...collections }
→ resolveSkillData(section, ctx) dataResolver.js:1192
if (section.context === 'positionId' && !context.position) → unavailable
RESOLVERS['position.pipeline']({ position, applications }) → filters by position.id
```
An explicit `context` prop beats what the page published, which is exactly the
rule an instanced node needs: *a card knows which position it is.*
### 1.4 Section resolution
`useSkillSections(page, placement)` (`SkillSurface.jsx:46`) is independent of the
record. It reads the account's active skills and returns
`{ skill, section }[]` for the placement. **The same list is used for every
card** — repetition happens below it, in data resolution, not in which sections
exist. That is what makes one template node correct.
### 1.5 DOM identity today
None inside a card. `<SkillSurface>` renders `div.space-y-4 > section[aria-label]`
with no `data-` attributes, and `SkillSection`'s React key
(`` `${skill.id}:${section.id}` ``) is not emitted. `PositionCard` renders an
`<article>` with no id. There is nothing in the document that says *which*
position a rendering belongs to.
### 1.6 The card body
`PositionCard` (`Positions.jsx:519-643`) is roughly 120 lines of tuned JSX:
role glyph and client/title/category block, status pill, a terms `<dl>`, one
line of candidate criteria, a hairline rule, `Progression`, `WorkforceRow`, a
health chip with the insight line, and a footer pinned with `mt-auto` so a row
of cards keeps its footers aligned. Plus a two-minute "just saved" treatment.
**None of it is addressable.** "Make all position cards compact" has nothing to
act on today, in either the node tree or the component: no density prop exists.
---
## 2. The model
### 2.1 One node, many renderings
The rule the whole design rests on:
> A repeated surface is **one node in the tree**. The tree holds the template
> once; the DOM holds N renderings of it.
An operation targets the node, so one operation changes every card — which is
the "do not duplicate operations once per record" requirement met in the stored
bytes, not by a de-duplication pass.
### 2.2 Repetition is declared by the type, never by a patch
```js
registerNodeType({
type: 'position-card',
label: 'Position card',
component: PositionCardNode,
container: true,
accepts: ['skill-surface', ...],
repeats: {
from: 'positions', // a key in the bag the PAGE publishes to UiRenderContext
as: 'position', // the context key each rendering is given
key: 'id', // the record field that identifies a rendering
},
propSchema: { density: { enum: ['comfortable', 'compact'] } },
capabilities: ['update', 'move', 'hide', 'reorder'],
});
```
`repeats` lives on the **registration**, which is code, and never on the node —
so it is absent from `OP_FIELDS`, cannot be written by a stored patch, and gives
the validator nothing new to police on user data. A patch can change what a card
looks like; it can never change what a card iterates over.
`from` names a key in the page's own `UiRenderContext` bag
(`UiTreeRenderer.jsx:39`) rather than a `DATA_SOURCES` entry, because `filtered`
*is* page state — search, filters and sort applied. This adds no new data
pathway: the renderer reads `useUiContext()[entry.repeats.from]` and still never
looks inside the bag on its own account.
### 2.3 Node schema — unchanged
No new field on `UiNode`. A repeater is an ordinary container node whose *type*
happens to repeat:
```js
{
id: 'position-card',
type: 'position-card',
props: { density: 'comfortable' },
layout: { ... },
children: [ /* the template subtree */ ],
hidden: false, origin: 'builtin', locked: false,
}
```
The template's children are ordinary nodes with ordinary ids
(`position-card-terms`, `position-card-extensions`, …). Each is one node and N
renderings, by the same rule.
### 2.4 DOM identity
```html
<article data-ui-node="position-card" data-ui-instance="pos_123"> … </article>
<article data-ui-node="position-card" data-ui-instance="pos_456"> … </article>
```
`data-ui-node` keeps meaning **the node** — one value, N elements. `data-ui-instance`
carries the record key from `repeats.key`. Descendants of a rendering inherit the
instance from their ancestor rather than repeating it, so the pair
(`data-ui-node`, nearest ancestor `data-ui-instance`) addresses exactly one
rendering.
This **breaks the current invariant that `data-ui-node` is unique in a
document**, and everything that assumes it must be found and changed. See §5.1.
---
## 3. Targeting semantics
Three scopes. Only the first is proposed for implementation now.
### 3.1 Template scope — the default
```json
{ "op": "update", "target": "position-card", "props": { "density": "compact" } }
```
No new field. Applies to the node, therefore to every rendering. This is
"make all position cards compact", and it is one operation regardless of how
many positions exist.
Everything already true stays true: the op is validated against the type's
`propSchema` and `capabilities`, refused if `density` is not a declared enum
value, and stored in `uiLayouts` like any other.
### 3.2 Instance scope — designed, not built
An optional `scope` on the **operation**, not on the node:
```json
{ "op": "update", "target": "position-card",
"scope": { "key": "pos_123" },
"props": { "density": "comfortable" } }
```
The tree stays one template. `applyPatch` partitions:
- **unscoped ops** are applied to the tree as they are today;
- **scoped ops** are attached to their target node, grouped by key, and applied
at render time to that one rendering — by `applyOperations`, the same engine,
with the same validator.
There is no second mutation engine and no per-record tree in storage.
**Legality is declared per operation, not per page.** Each entry in `OPERATIONS`
gains `instanceable: true|false`:
| Operation | Instanceable | Why |
|---|---|---|
| `update` | yes | changes one rendering |
| `hide` | yes | changes one rendering |
| `move`, `reorder` | no (first cut) | one card structurally unlike its neighbours |
| `add`, `remove`, `replace` | no (first cut) | "remove this record's card" is a filter, not a layout change |
A `scope` on a node whose type does not declare `repeats` is refused — a registry
read, not a page branch.
### 3.3 Predicate scope — reserved, not designed
`scope: { where: [...] }` — "make all *draft* position cards compact". Named here
only so `scope` is an object from the first day rather than a bare key string
that would have to be widened later.
### 3.4 Natural language
Template scope needs nothing new: `resolveTarget` already scores "the position
card" against node titles, labels and ids, and there is exactly one such node.
Instance scope needs a record resolver — "this position's card" is only
answerable when a position is selected, which `PageContext` publishes for the
drawer but not for the grid. **Deferred.** Until then Owliver refuses instance
phrasing explicitly rather than silently widening it to every card, which is the
failure worth guarding hardest: a person who says "only this one" must never get
"all of them".
---
## 4. Persistence
### 4.1 Shape
No new store, no new key, no new tier. `preferences.uiLayouts[page].ops` gains an
optional `scope` per op:
```json
{
"schema": 2,
"page": "positions",
"updatedAt": "…",
"ops": [
{ "op": "update", "target": "position-card", "props": { "density": "compact" } },
{ "op": "hide", "target": "position-card-pay", "hidden": true },
{ "op": "update", "target": "position-card",
"scope": { "key": "pos_123" }, "props": { "density": "comfortable" } }
]
}
```
Size is **O(operations), not O(records)**. One op makes every card compact.
### 4.2 The schema bump is not optional
`OP_FIELDS` (`patch.js:51`) whitelists the fields each op may carry and
`normalizeOp` **silently drops anything else**. A build that predates `scope`
would therefore read the third op above as an unscoped one and make *every* card
comfortable — the exact "only this one became all of them" failure.
So:
- a patch containing **no** scoped op keeps `schema: 1` and every existing build
reads it exactly as it does today;
- the first scoped op written raises that page's patch to `schema: 2`;
- a reader that does not understand a schema **skips the whole patch** and
reports it through the existing `skipped` channel, rather than applying part
of it.
### 4.3 Stale keys
A scoped op naming a record that no longer exists is reported as `skipped`, like
any stale target, and **retained** rather than dropped. Retained deliberately:
the record may simply be filtered out of `filtered` by the page's own search or
role filter at that moment, and treating the page's filter state as deletion
would quietly destroy a person's saved overrides every time they typed in a
search box. Clearing them is an explicit action in the editor.
---
## 5. Risks
**5.1 `data-ui-node` stops being unique.** Any `querySelector` on it silently
takes the first rendering — in tests, in the editor, and in anything built later.
*Mitigation:* replace the uniqueness assertion with the real invariant — ids are
unique unless the node's type declares `repeats`, and (`data-ui-node`, ancestor
`data-ui-instance`) is unique always. This is a test to write **before** the
first repeater exists.
**5.2 Render cost.** Instance-scoped ops run the operation engine per rendering.
Negligible for a handful of overrides over tens of cards; not negligible for
hundreds of records. *Mitigation:* only records that actually carry a scoped op
do any work, memoized on (template, key, ops hash).
**5.3 Migrating `PositionCard` is a real rewrite, and the riskiest one so far.**
`mt-auto` footer alignment, truncation, the hover and focus rings, and the
"just saved" animation are all tuned. Phase 5A already produced one margin
regression on a far simpler surface. *Mitigation:* the same baseline capture
used for every other migration, run over the grid with a fixed record set, plus
the class-signature and word-signature checks.
**5.4 "Compact" does not exist yet.** No component honours a density prop. The
engine cannot invent one: the schema is what makes a change *expressible*, not
what makes it *possible*. Someone must implement two densities in `PositionCard`
before "make all position cards compact" can do anything, and until then the
honest answer to that request is that the card offers no such setting.
**5.5 The drawer and the detail page draw the same five placements.** They are
never on screen together — the drawer overlays `/admin/positions`, the detail
page is `/admin/positions/:id` — but they are different layouts around identical
extension points. If both render one composition, a change made in the drawer
also changes the detail page, which may surprise. *Proposal:* one composition
(`position-detail`) drawn by both hosts, editable from the detail route only in
the first cut, because an editing session currently holds a single composition
keyed to the route. Making a session span two compositions is the extension, and
`uiLayouts` already stores per page so it needs no storage change.
**5.6 Per-record overrides invite chaos.** Twenty individually tweaked cards is
unreviewable. *Mitigation:* keep instance scope to `update` and `hide`, show the
count of overrides on the node in the editor, and offer to clear them all.
**5.7 `repeats.from` is a page-state key, so a rename fails at run time.** The
page renames its context key, the repeater finds nothing, the grid renders empty
— with no build error. *Mitigation:* a check-script assertion that every
registered `repeats.from` is a key the owning page publishes, in the same shape
as the surface-route guard.
**5.8 Skill sections inside a repeater lose id uniqueness too.** A section under
`after-position-card` is one node and N renderings, each resolving against a
different `position`. Consistent with the model, and `SkillSurface`'s contract is
unchanged — `as: 'position'` feeds exactly the `context={{ position }}` prop it
already takes — but it is the same uniqueness caveat as §5.1 reaching the skill
layer.
**5.9 The MD schema is untouched.** No frontmatter key, no `normalizeSection`
change, therefore no Go parser change and no oracle regeneration. Confirmed by
construction: nothing in this design is authored in Markdown.
---
## 6. What must be tested before any of it is built
Against the *existing* Positions architecture, so the semantics are proven
before the card is touched:
1. One `update` on a repeater node changes every rendering — asserted on
rendering count, not on one element.
2. That change is **one** operation in `uiLayouts`, with a record count > 1.
3. A scoped `update` changes exactly one rendering and leaves the others.
4. A scoped op on a non-repeating node is refused.
5. A `move`/`remove`/`add`/`replace` carrying a `scope` is refused.
6. A schema-2 patch read by a schema-1 reader is skipped whole, never widened.
7. A scoped op naming an absent record is reported skipped and **retained**.
8. Filtering the grid does not drop overrides for the filtered-out records.
9. `data-ui-node` × ancestor `data-ui-instance` is unique; ids repeat only for
types declaring `repeats`.
10. Owliver refuses instance phrasing rather than widening it to the template.
11. A skill section under `after-position-card` resolves against its own card's
position, in every rendering.
12. The Positions grid renders identically to its captured baseline.

952
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,11 @@
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"test": "node scripts/skill-check.mjs",
"seed:fixture": "node scripts/seed-fixture.mjs --write",
"seed:check": "node scripts/seed-fixture.mjs",
"seed:fixture": "node scripts/seed-fixture.mjs --write",
"seed:check": "node scripts/seed-fixture.mjs",
"typecheck": "tsc -p ./jsconfig.json",
"preview": "vite preview"
"preview": "vite preview",
"test:browser": "node scripts/browser-check.mjs"
},
"dependencies": {
"@astryxdesign/core": "^0.3.0",
@@ -61,6 +62,7 @@
"eslint-plugin-unused-imports": "^4.3.0",
"globals": "^15.14.0",
"postcss": "^8.5.3",
"puppeteer-core": "^23.11.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.2",
"vite": "^6.1.0"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

166
scripts/browser-check.mjs Normal file
View File

@@ -0,0 +1,166 @@
/**
* The live acceptance suite: the Owliver UI-editing journey, in a real browser.
*
* npm run test:browser
*
* Unit and SSR tests render these same modules and were not enough — every
* defect this guards was found in a browser and missed by a green test run. So
* this drives the real application: real routing, real React, real network,
* real `preferences.uiLayouts`.
*
* **It never logs in.** The session is an HttpOnly cookie and this script has
* no business handling anybody's credentials, so it attaches to a browser that
* is *already* signed in and says so plainly when it cannot:
*
* KROW_E2E_BROWSER_URL=http://127.0.0.1:9222 attach to a running Chrome
* (start one with: --remote-debugging-port=9222)
*
* KROW_E2E_USER_DATA_DIR=/path/to/profile launch Chrome on a profile
* that has been signed in once by hand
*
* With neither set, or with the browser not signed in, it exits **BLOCKED** —
* never a silent pass. An acceptance suite that reports green because it never
* ran is worse than no suite.
*
* The journey spans reloads, so the phases live in `browser-flows.js` and the
* reloads live here.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import puppeteer from 'puppeteer-core';
const ROOT = process.cwd();
const BASE = process.env.KROW_E2E_BASE_URL || 'http://localhost:5173';
const FLOWS = readFileSync(join(ROOT, 'scripts/browser-flows.js'), 'utf8');
const CHROME = process.env.KROW_E2E_CHROME
|| '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
/**
* The pages under test, as data.
*
* `target` is a node the page really composes and `phrase` is how a person
* names it — the pair this suite drives every page with. Adding a page is a row.
*/
const PAGES = [
{ page: 'activity', route: '/admin/activity', target: 'audit', phrase: 'the audit log' },
{ page: 'hired-history', route: '/admin/hired', target: 'chronology', phrase: 'the recent hiring timeline' },
{ page: 'analytics', route: '/admin/analytics', target: 'funnel', phrase: 'the hiring funnel' },
{
page: 'positions', route: '/admin/positions', target: 'skill-board-board', phrase: 'the Board card',
/* The Board skill's own card: a *skill* node, so the same journey proves
built-in and definition-contributed UI move through one engine. Needs the
Board definition switched on, which `prepare` below does not do — the
page is skipped rather than failed when its node is not there. */
optional: true,
data: ['What is on the board?', 'Show me the board activity'],
ordinaryQuestion: 'How do I apply for this position?',
},
];
const results = [];
const record = (name, pass, detail) => results.push({ name, pass, detail: detail || '' });
async function connect() {
if (process.env.KROW_E2E_BROWSER_URL) {
return puppeteer.connect({ browserURL: process.env.KROW_E2E_BROWSER_URL, defaultViewport: null });
}
if (process.env.KROW_E2E_USER_DATA_DIR) {
return puppeteer.launch({
executablePath: CHROME,
userDataDir: process.env.KROW_E2E_USER_DATA_DIR,
headless: false,
defaultViewport: null,
});
}
return null;
}
/** Load a route, install the flows, and wait for the page to have composed. */
async function open(page, route) {
await page.goto(`${BASE}${route}`, { waitUntil: 'networkidle2', timeout: 45_000 });
await page.evaluate(FLOWS);
await page.waitForFunction(
() => document.querySelector('textarea') && document.querySelectorAll('[data-ui-node]').length >= 0,
{ timeout: 30_000 }
);
await new Promise((r) => setTimeout(r, 2500));
}
const run = (page, fn, args) => page.evaluate(
async (name, a) => window.__uiFlows[name](a), fn, args
);
async function main() {
const browser = await connect();
if (!browser) {
console.error(
'BLOCKED — no browser to attach to.\n'
+ ' This suite does not log in: the session is an HttpOnly cookie and this\n'
+ ' script does not handle credentials. Point it at a signed-in browser:\n\n'
+ ' KROW_E2E_BROWSER_URL=http://127.0.0.1:9222 npm run test:browser\n'
+ ' (start Chrome with --remote-debugging-port=9222)\n\n'
+ ' KROW_E2E_USER_DATA_DIR=/path/to/profile npm run test:browser\n'
);
process.exit(2);
}
const page = await browser.newPage();
try {
await open(page, PAGES[0].route);
/* Signed in, or nothing below means anything. */
const authed = await page.evaluate(async () => {
const r = await fetch('/api/v1/me/preferences', { credentials: 'include' });
return { status: r.status, composer: Boolean(document.querySelector('textarea')) };
});
if (authed.status !== 200 || !authed.composer) {
console.error(
`BLOCKED — that browser is not signed in (GET /me/preferences → ${authed.status}).\n`
+ ' Sign in once in that profile, then run this again.'
);
process.exit(2);
}
for (const spec of PAGES) {
await open(page, spec.route);
const present = await page.evaluate((t) => window.__uiFlows.nodes().includes(t), spec.target);
if (!present) {
if (spec.optional) {
record(`${spec.page}: SKIPPED — ${spec.target} is not on the page`, true,
'the definition contributing it is switched off');
continue;
}
record(`${spec.page}: ${spec.target} is on the page`, false, 'not composed');
continue;
}
results.push(...await run(page, 'nothingPreviewed', {
page: spec.page, ordinaryQuestion: spec.ordinaryQuestion || null,
}));
if (spec.data) results.push(...await run(page, 'dataStaysData', { page: spec.page, questions: spec.data }));
results.push(...await run(page, 'phase1', spec));
await open(page, spec.route);
results.push(...await run(page, 'phase2', spec));
await open(page, spec.route);
results.push(...await run(page, 'phase3', spec));
}
} finally {
if (process.env.KROW_E2E_BROWSER_URL) browser.disconnect();
else await browser.close();
}
const failed = results.filter((r) => !r.pass);
for (const r of results) {
console.log(`[ ${r.pass ? ' ok ' : 'FAIL'} ] ${r.name}${r.detail ? ` — ${r.detail}` : ''}`);
}
console.log(`\n${results.length - failed.length}/${results.length} browser checks passed`);
if (failed.length) {
console.log('\nFailed:');
for (const r of failed) console.log(` - ${r.name} (${r.detail})`);
process.exit(1);
}
}
main().catch((error) => { console.error('BROWSER SUITE ERROR:', error); process.exit(1); });

256
scripts/browser-flows.js Normal file
View File

@@ -0,0 +1,256 @@
/**
* The Owliver UI-editing journey, as a script that runs *inside a real page*.
*
* Unit and SSR tests render the same modules this file drives, and they were
* not enough: every defect this suite now guards was found in a browser, not in
* a test run. So the acceptance test is the browser, and this is what it runs.
*
* It is deliberately dependency-free and framework-free — one function, no
* imports, no build step — because it has to be evaluatable in any authenticated
* session: through the puppeteer driver beside it, or pasted into a console.
*
* A full journey spans reloads, and nothing in a page survives one. So the
* journey is split into **phases** and the *driver* owns the reloads:
*
* phase 1 inspect → hide → preview → discard → hide → Apply
* ── reload ──
* phase 2 verify hidden → hidden node still listed → unhide → Apply
* ── reload ──
* phase 3 verify restored
*
* Every assertion is about what the browser actually shows — the composed
* nodes in the document and the bytes in `preferences.uiLayouts` — never about
* a module's return value.
*/
/* global window, document, fetch, HTMLTextAreaElement, Event */
(function attach() {
if (typeof window === 'undefined') return;
/** The nodes the page is actually drawing, in document order. */
const nodes = () => [...document.querySelectorAll('[data-ui-node]')]
.map((el) => el.getAttribute('data-ui-node'));
/** What the panel is showing. The transcript, not a component's state. */
const panel = () => document.querySelector('[class*="assistant"], aside')?.innerText || '';
/** The saved layouts, read back over the wire like any other client would. */
const saved = async () => {
const response = await fetch('/api/v1/me/preferences', { credentials: 'include' });
const body = await response.json();
const prefs = (body.data || body).preferences || (body.data || body);
return { uiLayouts: prefs.uiLayouts || {}, disabledSkills: prefs.disabledSkills || [] };
};
const sleep = (ms) => new Promise((resolve) => { window.setTimeout(resolve, ms); });
/**
* Wait until the panel is genuinely idle.
*
* Not cosmetic: a message submitted while the panel is streaming is dropped,
* and a suite that does not wait reports a phantom failure for a request that
* was never sent. That is exactly what happened during the manual audit.
*/
const idle = async () => {
for (let attempt = 0; attempt < 60; attempt += 1) {
const box = document.querySelector('textarea');
if (box && !box.disabled && !/Thinking/.test(document.body.innerText)) return true;
await sleep(500);
}
return false;
};
/**
* Ask Owliver, the way a person does.
*
* Through the composer and the form's own submit — not by calling a handler —
* so the routing, the gate and the panel are all really exercised.
*/
const ask = async (question) => {
await idle();
const box = document.querySelector('textarea');
const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
setValue.call(box, question);
box.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(150);
box.closest('form').requestSubmit();
await sleep(1500);
await idle();
await sleep(600);
return panel();
};
/**
* Every request that left the page, so "did this reach the model?" is
* answered by the network rather than by reading the reply and guessing.
*/
const spy = () => {
if (window.__uiFlowSpy) { window.__uiFlowCalls = []; return; }
window.__uiFlowCalls = [];
const original = window.fetch;
window.fetch = function spied(...args) {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
const method = (args[1]?.method || 'GET').toUpperCase();
if (/\/api\/v1\/(agents|runs)/.test(url) || method !== 'GET') {
window.__uiFlowCalls.push(`${method} ${url}`);
}
return original.apply(this, args);
};
window.__uiFlowSpy = true;
};
const modelCalls = () => (window.__uiFlowCalls || []).filter((c) => /\/agents\//.test(c));
const writeCalls = () => (window.__uiFlowCalls || []).filter((c) => /^PATCH/.test(c));
/** One assertion. `detail` is what a reader needs to debug a failure. */
const check = (results, name, pass, detail) => {
results.push({ name, pass: Boolean(pass), detail: String(detail ?? '') });
return Boolean(pass);
};
/**
* Phase 1 — the whole preview contract, before anything is kept.
*
* `target` is a node id; `phrase` is how a person would name it. Both are
* given by the caller so this file names no page and no section.
*/
async function phase1({ page, target, phrase }) {
const results = [];
spy();
const inventory = await ask('What is on this page?');
check(results, `${page}: inspect answers locally`, modelCalls().length === 0,
modelCalls().join(', ') || 'no agent run');
check(results, `${page}: inspect lists ${target}`, inventory.includes(`(${target})`),
inventory.slice(-300));
const before = nodes();
check(results, `${page}: ${target} is on the page`, before.includes(target), before.join(', '));
/* What was stored before anything was previewed. Compared against rather
than assumed empty: a page that has been customised before still has a
patch, and the claim under test is that a *preview* does not change it. */
const storedBefore = JSON.stringify((await saved()).uiLayouts[page] ?? null);
window.__uiFlowCalls = [];
await ask(`Hide ${phrase}`);
const hidden = nodes();
check(results, `${page}: hide removes it from the page`, !hidden.includes(target), hidden.join(', '));
check(results, `${page}: hide does not reach the model`, modelCalls().length === 0,
modelCalls().join(', ') || 'no agent run');
const storedDuring = JSON.stringify((await saved()).uiLayouts[page] ?? null);
check(results, `${page}: preview writes nothing`,
storedDuring === storedBefore && writeCalls().length === 0,
`before ${storedBefore} · during ${storedDuring}`);
await ask('Discard the layout change');
check(results, `${page}: discard restores it`, nodes().includes(target), nodes().join(', '));
await ask(`Hide ${phrase}`);
check(results, `${page}: hide again`, !nodes().includes(target), nodes().join(', '));
window.__uiFlowCalls = [];
await ask('Apply the layout change');
await sleep(1200);
const persisted = await saved();
const patch = persisted.uiLayouts[page];
check(results, `${page}: apply writes preferences`, writeCalls().length > 0, writeCalls().join(', '));
check(results, `${page}: apply stores an operation, not a tree`,
Boolean(patch) && patch.ops.some((op) => op.op === 'hide' && op.target === target && op.hidden === true),
JSON.stringify(patch ?? null));
check(results, `${page}: apply does not reach the model`, modelCalls().length === 0,
modelCalls().join(', ') || 'no agent run');
return results;
}
/** Phase 2 — after a cold reload: still hidden, still addressable, put back. */
async function phase2({ page, target, phrase }) {
const results = [];
spy();
check(results, `${page}: reload reconstructs the hidden state`, !nodes().includes(target), nodes().join(', '));
const inventory = await ask('What is on this page?');
check(results, `${page}: a hidden node stays in the inventory`, inventory.includes(`(${target})`),
inventory.slice(-300));
window.__uiFlowCalls = [];
await ask(`Show ${phrase}`);
check(results, `${page}: a hidden node is addressable after a reload`, nodes().includes(target),
nodes().join(', '));
check(results, `${page}: unhide does not reach the model`, modelCalls().length === 0,
modelCalls().join(', ') || 'no agent run');
await ask('Apply the layout change');
await sleep(1200);
const patch = (await saved()).uiLayouts[page];
check(results, `${page}: the unhide is persisted`,
Boolean(patch) && patch.ops.some((op) => op.op === 'hide' && op.target === target && op.hidden === false),
JSON.stringify(patch ?? null));
return results;
}
/** Phase 3 — after a second cold reload, the page is itself again. */
async function phase3({ page, target }) {
const results = [];
check(results, `${page}: reload reconstructs the restored state`, nodes().includes(target), nodes().join(', '));
return results;
}
/**
* The regression this suite exists for.
*
* Applying or discarding with nothing previewed used to fall through to the
* model, which answered — reasonably, for an agent scoped to open roles —
* that layout changes were not in its scope. A request about the interface
* must never be answered by something that does not know the interface
* exists. The second half is as important: ordinary "apply" must still be an
* ordinary word.
*/
async function nothingPreviewed({ page, ordinaryQuestion }) {
const results = [];
spy();
for (const verb of ['Discard', 'Apply']) {
window.__uiFlowCalls = [];
const reply = await ask(`${verb} the layout change`);
check(results, `${page}: "${verb} the layout change" with nothing previewed stays local`,
modelCalls().length === 0, modelCalls().join(', ') || 'no agent run');
check(results, `${page}: it says there is nothing to ${verb.toLowerCase()}`,
new RegExp(`nothing to ${verb.toLowerCase()}`, 'i').test(reply), reply.slice(-240));
}
if (ordinaryQuestion) {
window.__uiFlowCalls = [];
const reply = await ask(ordinaryQuestion);
check(results, `${page}: an ordinary "apply" is not swallowed`,
modelCalls().length > 0 && !/nothing to apply/i.test(reply.slice(-260)),
modelCalls().join(', ') || 'no agent run');
}
return results;
}
/** A data question must stay a data question, however it is worded. */
async function dataStaysData({ page, questions }) {
const results = [];
spy();
for (const question of questions) {
window.__uiFlowCalls = [];
const before = nodes();
await ask(question);
check(results, `${page}: "${question}" is answered as data`,
modelCalls().length > 0
&& !/Previewing/.test(document.body.innerText)
&& JSON.stringify(before) === JSON.stringify(nodes()),
`${modelCalls().join(', ') || 'no agent run'} · nodes ${nodes().join(', ')}`);
}
return results;
}
window.__uiFlows = { phase1, phase2, phase3, nothingPreviewed, dataStaysData, nodes, saved, ask, panel };
}());

115
scripts/render-page.mjs Normal file
View File

@@ -0,0 +1,115 @@
/**
* Render an Admin page to static markup, outside a browser.
*
* The migration to the UI node tree has to be provable rather than asserted:
* a page is rendered before it is touched, rendered again afterwards, and the
* two are compared. This is the thing that renders it, used both to capture a
* baseline and, from the check script, to compare against one.
*
* node scripts/render-page.mjs src/pages/admin/HiredHistory.jsx out.html
*
* The page is loaded through a real Vite server, so `@/` aliases, Markdown
* imports and `import.meta.glob` behave exactly as they do in the app. Queries
* are disabled rather than mocked: every page then renders its empty state,
* deterministically, which is all a structural comparison needs.
*/
import { createServer } from 'vite';
import { join } from 'node:path';
import { writeFileSync } from 'node:fs';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
/**
* The design system reads `window` when its modules evaluate, which is a
* pre-existing SSR limitation and not what any of this is testing. The shim is
* the same one the citation tests use.
*/
export function shimWindow() {
const had = 'window' in globalThis;
const hadSvg = 'SVGElement' in globalThis;
if (!had) {
globalThis.window = {
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
addEventListener() {}, removeEventListener() {},
};
}
/* Recharts tests `instanceof SVGElement` while measuring, which is a browser
global with no Node equivalent. A bare class is enough: nothing is ever an
instance of it, which is the correct answer outside a browser. */
if (!hadSvg) globalThis.SVGElement = class SVGElement {};
return () => {
if (!had) delete globalThis.window;
if (!hadSvg) delete globalThis.SVGElement;
};
}
/**
* Render one page.
*
* `providers` are supplied by the caller rather than assumed here, because the
* editing session belongs to the layout and a baseline captured before a
* migration must be rendered without it.
*/
export async function renderPage(server, modulePath, { route = '/', wrap = null } = {}) {
/* Imported through Node rather than the Vite graph so the instance matches
the one the page itself resolves — loading them as SSR modules creates a
second copy, and a second QueryClientProvider provides nothing. */
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
const { MemoryRouter } = await import('react-router-dom');
const Page = (await server.ssrLoadModule(modulePath)).default;
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
const inner = wrap ? wrap(React.createElement(Page)) : React.createElement(Page);
return renderToStaticMarkup(
React.createElement(MemoryRouter, { initialEntries: [route] },
React.createElement(QueryClientProvider, { client }, inner))
);
}
/**
* Two renderings are the same page when they paint the same styled boxes, in
* the same order, around the same words.
*
* Compared this way rather than byte-for-byte because a migration legitimately
* adds `data-ui-*` identity attributes, and for components that cannot forward
* unknown props a wrapper element carrying nothing else. Both are invisible.
* A changed utility class, a reordered section or altered text moves one of
* these and fails.
*/
export const classSignature = (html) => (html.match(/class="[^"]*"/g) || []).join('\n');
export const wordSignature = (html) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
/** Tag counts, so an added element is visible and can be characterised. */
export const tagCounts = (html) => (html.match(/<\/?[a-z][a-z0-9-]*/gi) || [])
.map((t) => t.toLowerCase())
.reduce((acc, t) => ({ ...acc, [t]: (acc[t] || 0) + 1 }), {});
/* Run directly: capture a baseline. */
if (process.argv[1] && process.argv[1].endsWith('render-page.mjs')) {
const [modulePath, out, route] = process.argv.slice(2);
const restore = shimWindow();
const server = await createServer({
root: process.cwd(),
server: { middlewareMode: true },
appType: 'custom',
logLevel: 'error',
resolve: { alias: { 'react-hot-toast': join(process.cwd(), 'scripts/stubs/react-hot-toast.js') } },
});
try {
const html = await renderPage(server, `/${modulePath.replace(/^\//, '')}`, { route: route || '/' });
writeFileSync(out, html);
console.log(`rendered ${html.length} chars -> ${out}`);
} catch (error) {
console.error('RENDER FAILED:', error.message);
process.exitCode = 1;
} finally {
await server.close();
restore();
/* `server.close()` leaves a handle open often enough that the process hangs
without this, and a capture script that never exits is a capture script
nobody runs. */
process.exit(process.exitCode || 0);
}
}

File diff suppressed because it is too large Load Diff

19
scripts/stubs/react-hot-toast.js vendored Normal file
View File

@@ -0,0 +1,19 @@
/**
* A no-op stand-in for `react-hot-toast`, used only by scripts/skill-check.mjs.
*
* The real package evaluates `goober`, which calls `document.createElement` the
* moment it is imported. That makes the whole design-system barrel — and so the
* Owliver block renderers — impossible to load in Node, which would leave the
* citation tests asserting on block objects rather than on rendered markup.
*
* Nothing under test ever raises a toast, so a stub costs nothing and buys the
* stronger assertion: the real renderers, producing real HTML.
*/
const noop = () => '';
export const toast = Object.assign(noop, {
success: noop, error: noop, loading: noop, custom: noop, dismiss: noop, remove: noop, promise: noop,
});
export const Toaster = () => null;
export const useToaster = () => ({ toasts: [], handlers: {} });
export const useToasterStore = () => ({ toasts: [] });
export default toast;

View File

@@ -25,6 +25,7 @@ import WorkerProfile from '@/pages/WorkerProfile';
import KrowIdentity from '@/pages/KrowIdentity';
import Owliver from '@/pages/Owliver';
import EmployeeDashboard from '@/pages/EmployeeDashboard';
import OpportunityDetail from '@/pages/OpportunityDetail';
import DesignSystem from '@/pages/DesignSystem';
/* Admin product — its own layout and route tree, so the Admin redesign can
@@ -98,6 +99,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 />} />
@@ -131,6 +138,12 @@ const AuthenticatedApp = () => {
</Route>
{/* Redirect top-level legacy aliases to the unified global shell */}
{/* Employee (talent) opportunity flow — full-page routes, no drawer.
Standalone (outside AdminLayout/AdminRoute, which are the employer
shell and gate); authenticated via ProtectedRoute. */}
<Route path="/employee" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-5xl mx-auto px-4 py-8"><EmployeeDashboard /></div></div>} />
<Route path="/opportunities/:id" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-3xl mx-auto px-4 py-8"><OpportunityDetail /></div></div>} />
<Route path="/apply" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-3xl mx-auto px-4 py-8"><Apply /></div></div>} />
<Route path="/overview" element={<Navigate to="/admin" replace />} />
<Route path="/positions" element={<Navigate to="/admin/positions" replace />} />
{/* `/positions` only matches the exact path, so the sub-routes need

View File

@@ -4,7 +4,7 @@ name: Positions Agent
description: Open roles — what they need, who has applied, and which are at risk of going unfilled.
icon: briefcase
status: published
version: 1
version: 2
reasoning: balanced
trigger: Use on Positions, for open roles, applicant flow, and specifying a new role.
pages:
@@ -12,6 +12,7 @@ pages:
- create-position
skills:
- create-position
- create-employee-role
- hiring-activity-assistant
- staffing-risk
starters:

View File

@@ -4,13 +4,14 @@ name: Talent Pool Agent
description: Available talent — who is in the pool, who is verified, and who is ready to place.
icon: layers
status: published
version: 1
version: 2
reasoning: balanced
trigger: Use on Talent Pool, for supply, availability and readiness of known workers.
pages:
- talent-pool
skills:
- talent-pool-analysis
- create-employee-role
starters:
- label: Who is available?
prompt: Who is available in the talent pool?

View File

@@ -29,6 +29,11 @@ const ENTITY_NAMES = [
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
'Course', 'Badge', 'LearningPath', 'Certification', 'RoleCategory',
'UserActivity', 'Evidence', 'User',
/* What a worker declares they DO — role, experience, desired pay,
availability. The supply side of JobPosting, which is what the organization
needs filled. The two meet through JobApplication, not through a reference
between them. */
'EmployeeRole',
/* Who is on which position, and for how long. The record that turns "hired"
into workforce allocation: without it a position knows its demand and its
applicants but not who is actually covering it. */

View File

@@ -99,6 +99,7 @@ const RESOURCE_PATHS = {
AIInterview: 'ai-interviews',
Staff: 'staff',
WorkerProfile: 'worker-profiles',
EmployeeRole: 'employee-roles',
Course: 'courses',
Badge: 'badges',
LearningPath: 'learning-paths',

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,21 +2,24 @@ 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, useCreateJobPosting, useGenerateJobDescription,
useAssignments, useAssignWorkers, useCreateWorkerWithRole, useCreateJobPosting,
useGenerateJobDescription,
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
useUpdateJobPosting,
usePreferences, useRoleCategories,
} 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';
@@ -38,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 = [];
@@ -52,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.
*
@@ -168,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 };
}
/**
@@ -441,6 +430,66 @@ export default function KrowAssistant({
return createJob.mutateAsync(result.data);
}, [createJob]);
/**
* Every conversation's write, keyed by the flow's id.
*
* `useAssistant` looks the writer up by the flow the answering skill declares,
* so adding a conversation is adding an entry here rather than another prop
* threaded through the panel.
*/
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,
}), [createPosition, createEmployeeRole]);
/**
* The clients this organization already staffs for.
*
* Distinct company names off the postings the panel has already loaded for
* this caller — org-scoped by the API, and nothing here widens that. They are
* offered as chips on the conversation's company question so an existing
* client is a tap, while typing a name that is not on the list is still how a
* new one is named. There is no company record to create: see the `@companies`
* note in `lib/skills/flows/position.js`.
*
* Deliberately NOT sorted here. The order is the one the postings arrived in
* — the API's `-created_date` — so the clients staffed for most recently are
* the ones offered first, and the panel does no ranking of its own. That last
* part is a rule `npm test` enforces structurally, and it is the right rule:
* a second opinion formed in the panel outranking the server's is exactly the
* failure that decays quietly.
*/
/**
* The workers a role can be recorded against.
*
* The profiles the panel already holds for this caller — org-scoped by the
* API. The conversation offers the names as chips and resolves a pick back to
* the profile id and email, so the row names a real person rather than
* whatever was typed. The operator is never the subject: the question is
* required and there is no fallback to the session.
*/
const workers = React.useMemo(() => (facts.profiles || []).map((w) => ({
id: w.id,
name: w.full_name || w.name || '',
email: w.email || '',
})).filter((w) => w.email), [facts.profiles]);
const companies = React.useMemo(() => [...new Set(
(facts.postings || []).map((p) => String(p.company || '').trim()).filter(Boolean)
)], [facts.postings]);
/**
* What to ask next, from the server, after something has been written.
*
@@ -460,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);
@@ -562,7 +614,13 @@ export default function KrowAssistant({
pageLabel: context.page,
onNavigate: goToPage,
onAction: performAction,
onCreatePosition: createPosition,
flowWriters,
uiEditing,
companies,
/* The postings this caller can already see — the evidence behind
role-aware certification suggestions. */
postings: facts.postings || [],
workers,
onRefreshSuggestions: refreshSuggestions,
onUpdatePosition,
onGenerateDescription,
@@ -612,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. */
@@ -627,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(
@@ -833,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
@@ -845,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} />
@@ -861,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.
*
@@ -1001,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';
@@ -23,7 +24,8 @@ import {
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
extractSkillName, findCourseByName,
} from '@/lib/skills/actions';
import { beginPositionFlow } from '@/lib/skills/positionFlow';
import { beginFlow } from '@/lib/skills/conversationFlow';
import { flowFor } from '@/lib/skills/flows';
/**
* Intent routing — deciding whether a question belongs to the page you are on.
@@ -633,7 +635,7 @@ function declaredAnswer({ skill, capability, question, skillContext }) {
*/
function resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
skillContext = null,
companies = [], postings = null, skillContext = null,
}) {
/**
* One question, one skill, then one way of answering it.
@@ -728,15 +730,21 @@ function resolveSkill({
}
/**
* Create Position is collected in the conversation, not in a form.
* A conversational skill is collected in the chat, not in a form.
*
* Nothing opens and nothing is navigated to: the skill's questions come back
* as a reply and its answers as chips, and the position is written at the end
* as a reply and its answers as chips, and the record is written at the end
* from what the conversation gathered. `flow` is the state that turn carries
* forward — the panel keeps it and feeds the next answer back in.
*
* Which conversation is the SKILL'S OWN `flow:` declaration, resolved through
* `FLOWS`. This used to be `if (skill.id === 'create-position')`, which made a
* second conversational skill a change to the router rather than a file on
* disk — precisely the `if agent_key == ...` shape §2's I6 rules out.
*/
if (skill.id === 'create-position') {
return { kind: 'skill', skill, ...beginPositionFlow({ question, skill, roles }) };
const registry = flowFor(skill);
if (registry) {
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies, postings } }) };
}
/**
@@ -855,6 +863,11 @@ function resolveDraftAction(question, workforce, positionId = null) {
export function resolveIntent({
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
courses = [], workforce = null, skillContext = null,
/* The clients this organization already staffs for, offered as chips on the
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.
*
@@ -869,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;
@@ -900,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,
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,224 @@
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,
/* The node this conversation last changed. Nothing is remembered inside the
matcher: continuity is a fact the caller holds and passes in. */
focus: ui.focus || null,
});
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?' }],
};
/**
* Understood, and not possible — with the reason and the way forward.
*
* A refusal that only says no leaves a person guessing at a vocabulary they
* cannot see. When the engine knows what this reading *could* be drawn as,
* it says so and offers the choices as chips, so "no, but here" costs one
* click rather than another round of guessing.
*/
case 'refused': {
const offered = match.alternatives || [];
return {
kind: 'ui-answer',
doc: offered.length
? doc(
text(match.message),
text(`It can be shown as: ${offered.map((o) => o.label).join(', ')}.`)
)
: doc(text(match.message)),
followUp: offered.slice(0, 3).map((option) => ({
label: option.label,
prompt: `Show ${match.node?.title || match.node?.label || 'it'} as a ${option.label}`,
})),
};
}
/**
* Every way this reading could honestly be drawn.
*
* The options are the registry's answer, not a suggestion: each is a
* component the application ships, each will draw this node's own figures,
* and choosing one produces exactly the operation `planReplace` would have
* produced from the same words. Nothing here is generated.
*/
case 'options':
return {
kind: 'ui-answer',
doc: doc(
text(`${match.subject} can be drawn these ways. Each one uses the same figures.`),
list(match.options.map((option) => `${option.label} — ${option.summary}`)),
note('Pick one to preview it. Nothing is saved until you apply.')
),
followUp: match.options.map((option) => ({
label: option.label,
prompt: `Show ${match.node.title || match.node.label} as a ${option.label}`,
})),
};
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

@@ -6,9 +6,8 @@ import {
useUserActivity, useWorkerProfile, useWorkerProfiles,
} from '@/lib/krowHooks';
import { skillsForContext } from '@/lib/skills/registry';
import {
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
} from '@/lib/skills/positionFlow';
import { advanceFlow } from '@/lib/skills/conversationFlow';
import { flowFor } from '@/lib/skills/flows';
import {
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
publishedReply, weightsSetReply, weightsUnchangedReply,
@@ -186,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.
*
@@ -237,7 +290,24 @@ function withoutAuthoringActions(messages = []) {
* routing applies to both without either knowing it exists.
*/
export function useConversation({
contextId, facts, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
contextId, facts, onNavigate, onAction, onRefreshSuggestions,
/**
* How each conversation's record gets written, keyed by the flow's id.
*
* A single `onCreatePosition` prop was the last place the panel named one
* kind of record. A second conversation needed a second prop, a second branch
* at the write, and a second set of outcome renderers — three edits to answer
* "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
@@ -245,6 +315,15 @@ export function useConversation({
onUpdatePosition, onGenerateDescription,
workforce = null, disabledSkills = [], customSkills = [],
roles = [], skillCategories = [], courses = [], skillContext = null,
/* 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 = [],
/**
* The active agent and where the reader is.
*
@@ -475,7 +554,10 @@ export function useConversation({
intent = {
kind: 'flow',
skill,
...advancePositionFlow({ flow: flowRef.current, answer: text, skill, roles }),
...advanceFlow({
registry: flowFor(skill), flow: flowRef.current, answer: text, skill,
ctx: { roles, companies, workers, postings },
}),
};
}
}
@@ -490,11 +572,28 @@ export function useConversation({
question: text,
contextId: turnContext,
disabledSkills: turnDisabled,
customSkills, roles, skillCategories,
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,
/* What this session last changed, so "change it back" has an
"it". A node id and nothing else — see `focus` on the
editing provider. */
focus: uiEditing.focus || null,
}
: null,
}),
/* Only while a real agent is behind the panel. With the local
simulator there is nothing better to defer TO, and deferring
@@ -503,19 +602,56 @@ 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
* be true when it is said.
*/
if (intent.kind === 'flow' && intent.create) {
/* The skill says which conversation this is, so the write and the wording
of its outcome both come from that registry rather than from a name
hardcoded here. */
const registry = flowFor(intent.skill);
let created = null;
/* Kept, not swallowed. The reply states the outcome, and "it did not
work" is a worse outcome to state than the reason it did not: a
required field, a refused role, or an API that is not running. */
let failure = null;
try {
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
created = await flowWriters[registry.id]?.(
intent.create.draft, intent.skill, intent.create.status
);
} catch (error) {
created = null;
failure = error;
@@ -551,19 +687,16 @@ export function useConversation({
intent = {
...intent,
flow: null,
doc: positionCreatedReply(created),
followUp: [...createdFollowUp(created), ...refreshed],
doc: registry.outcome.created(created),
followUp: [...registry.outcome.followUp(created), ...refreshed],
};
} else {
/* Keep the answers: the summary is still there to try again from. */
intent = {
...intent,
flow: { ...intent.flow, stage: 'review' },
doc: positionFailedReply(failure?.message),
followUp: [
{ label: 'Create position', prompt: 'Create position' },
{ label: 'Change details', prompt: 'Change details' },
],
doc: registry.outcome.failed(failure?.message),
followUp: registry.outcome.retryChips,
};
}
}
@@ -765,9 +898,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);
@@ -785,13 +962,18 @@ export function useConversation({
setPending(null);
abortRef.current = null;
}
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
}, [contextId, facts, persist, onNavigate, onAction, flowWriters, onRefreshSuggestions,
onUpdatePosition,
onGenerateDescription, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses, skillContext,
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

@@ -0,0 +1,107 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Briefcase, MapPin, Wallet, Sparkles, Compass, ArrowRight } from 'lucide-react';
/**
* Opportunities — the Employee opportunity-discovery grid.
*
* Replaces the old demand-feed cards on the Employee jobs surface. Each card is
* an opportunity, identified by its JOB TITLE — never a company/client name.
* "View Position" navigates (route-based, no drawer) to the full-page detail at
* /opportunities/:id.
*
* It takes the same `{ job, match }` records the dashboard already computes with
* recommendJobs(profile, postings, 6); nothing about the matching changes here.
*
* COMPANY PRIVACY: this component reads only the fields the Employee experience
* needs — id, title, role_category, location, pay_range_min/max, status, match —
* and never `job.company`.
*/
function OpportunityCard({ rec }) {
const navigate = useNavigate();
const { job, match } = rec;
const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : null;
const isHiring = job.status === 'active';
return (
<button
type="button"
onClick={() => navigate(`/opportunities/${job.id}`)}
className="group text-left w-full rounded-2xl bg-white border border-[#E5E7EB] p-5 transition-all
hover:border-[#0838E0]/40 hover:shadow-lg hover:shadow-[#0838E0]/5 hover:-translate-y-0.5
focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
>
<div className="flex items-start justify-between gap-3">
<div className="w-11 h-11 rounded-xl bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center shrink-0">
<Briefcase className="w-5 h-5" />
</div>
{isHiring && (
<span className="inline-flex items-center text-[11px] font-semibold text-white bg-[#0838E0] px-2.5 py-1 rounded-full">
Hiring
</span>
)}
</div>
{/* The job title is the opportunity's identity — no company name. */}
<h3 className="mt-3 text-[16px] font-bold text-[#0F172A] leading-snug line-clamp-2">{job.title}</h3>
{job.role_category && (
<p className="mt-0.5 text-[13px] text-[#6B7280] capitalize">{job.role_category}</p>
)}
<div className="mt-3 space-y-1.5">
<p className="text-[13px] text-[#6B7280] flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-[#9CA3AF]" /> {job.location || 'Remote'}
</p>
{pay && (
<p className="text-[13px] font-medium text-[#111827] flex items-center gap-1.5">
<Wallet className="w-3.5 h-3.5 text-[#9CA3AF]" /> {pay}
</p>
)}
</div>
<div className="mt-4 flex items-center justify-between pt-4 border-t border-[#F3F4F6]">
<span className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0838E0] bg-[#EEF3FE] px-2.5 py-1 rounded-full">
<Sparkles className="w-3 h-3" /> {match}% match
</span>
<span className="inline-flex items-center gap-1 text-[13px] font-semibold text-[#0838E0] group-hover:gap-1.5 transition-all">
View Position <ArrowRight className="w-4 h-4" />
</span>
</div>
</button>
);
}
export default function Opportunities({ jobRecs = [] }) {
return (
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
<div className="flex items-center justify-between mb-5">
<div>
<h2 className="text-[18px] font-bold text-[#0F172A] flex items-center gap-2">
<Compass className="w-4 h-4 text-[#0838E0]" /> Opportunities for you
</h2>
<p className="text-[13px] text-[#6B7280] mt-0.5">Matched to your profile. Open one to see why you fit.</p>
</div>
{jobRecs.length > 0 && (
<span className="inline-flex items-center text-[12px] font-semibold text-[#0838E0] bg-[#EEF3FE] px-3 py-1.5 rounded-full">
{jobRecs.length} match{jobRecs.length === 1 ? '' : 'es'}
</span>
)}
</div>
{jobRecs.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3">
<Compass className="w-6 h-6" />
</div>
<p className="text-[14px] font-medium text-[#0F172A]">No matched opportunities yet — keep leveling up.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{jobRecs.map((rec) => (
<OpportunityCard key={rec.job.id} rec={rec} />
))}
</div>
)}
</section>
);
}

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,251 @@
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 { kindOfBinding } from '@/lib/ui/series';
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);
};
/**
* What this node could be shown as.
*
* The same five facts the conversation passes, in the same order, to the same
* registry call — the editor holds no compatibility rule of its own, so a
* type in this list is exactly a type "show this as a …" would accept. Two
* readings of the rule is how a picker comes to offer something the operation
* then refuses.
*/
const shapes = node.data?.source ? dataSourceFor(node.data.source)?.shapes || [] : null;
const replacements = can('replace')
? registry.replacements(node.type, {
shapes,
page,
seriesKind: kindOfBinding(node.data),
seriesBound: Boolean(node.data?.series),
bound: Boolean(node.data),
})
: [];
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,208 @@
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);
/**
* The node an operation is about, or null.
*
* Read from the operation's own shape rather than from a table of operation
* names with special cases: every operation that concerns one node names it in
* `target`, and `add` names the node it is creating. `reorder` concerns a whole
* list and has no single subject, so it leaves the previous one standing —
* which is right: reordering the page does not change what "it" means.
*/
function subjectOf(op) {
const named = String(op?.target ?? op?.node?.id ?? '').trim();
return named || null;
}
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([]);
/**
* The subject of the conversation, as a node id.
*
* What makes "change it back to a line chart" answerable. Recorded from the
* operation that stood, so it is always a node that really exists and really
* changed — never a guess, and never something the model supplied.
*/
const [focus, setFocus] = React.useState(null);
/* 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));
/* Only once the operation stood. A refused change never becomes the thing
"it" refers to. */
const named = subjectOf(op);
if (named) setFocus(named);
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([]);
setFocus(null);
}, [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([]);
setFocus(null);
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,
/**
* The node this session last changed, and therefore what "it" means.
*
* Session state, not stored state: it is a fact about the conversation
* rather than about the layout, so it is deliberately not persisted and is
* gone on reload. Kept across an Apply — applying does not end the subject —
* and cleared by Discard and Reset, which do.
*/
focus,
customised: saved.ops.length > 0,
saving,
propose,
discard,
apply,
undo,
reset,
}), [
page, composed, saved, preview, problems, saving, focus, 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,298 @@
import React from 'react';
import {
Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, 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 { SegmentedToggle } from '@/components/ds/Toggle';
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 { controlOfBinding, readSeries } from '@/lib/ui/series';
import { layoutClasses, presentationClasses, useUiContext } from './UiTreeRenderer';
/**
* Five ways of drawing one series, and one table that says which is which.
*
* These are the node types a person reaches by asking for them — "show this as
* a bar chart" — and everything that decides whether they may is declared here
* as data. There is no branch anywhere on which chart is being drawn: the
* registry compares `seriesKinds` to what the binding means, and a sixth chart
* added to `VISUALIZATIONS` below is addressable by name, in conversation and
* in the editor, with no other file touched.
*
* **Nothing is transformed and nothing is invented.** Every one of these reads
* `readSeries`, which either resolves a skill reading through the ordinary
* resolver or calls a page's own registered `read` — and then relabels neither.
* A bar chart of hiring activity draws the same four numbers per day that the
* page's own composed chart draws, because it is literally the same array.
*
* The interesting column is `seriesKinds`, and it is the whole reason a person
* can be told *why* rather than merely *no*. `flow` cannot express it — it is,
* in `SECTION_TYPES`' own words, "a sequence of stages **or** periods" — so a
* pie was structurally free to slice twelve days into twelve wedges. Saying
* `parts` here is what makes that impossible, and the refusal explains itself
* in terms of the reading rather than the component.
*/
/** The series this node is bound to, resolved through whichever binding it has. */
function useSeries(node) {
const skillContext = useSkillDataContext(null);
const pageContext = useUiContext();
const resolveSource = React.useCallback((binding) => resolveSkillData({
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: binding.source,
periods: binding.params?.periods || [],
limit: binding.params?.limit || null,
}, skillContext), [node.id, skillContext]);
return React.useMemo(
() => readSeries(node.data, { context: pageContext, resolveSource }),
[node.data, pageContext, resolveSource]
);
}
/** How tall the plot is, and therefore how big a pie fits in it. */
const plotHeight = (presentation) => (presentation?.density === 'compact' ? 180 : 260);
/**
* The panel every one of them shares — the same chrome a reading section draws.
*
* `control` is whatever the *reading* publishes, drawn here so that replacing
* one visualization with another does not quietly take a control away. Nothing
* about it is known to this file beyond the shape declared in `series.js`: a
* label, a closed set of options, the value the page is publishing, and the
* page's own setter. A reading that publishes none renders none.
*/
function ChartPanel({ attrs, layout, presentation, title, children, empty, control = null }) {
const look = presentationClasses(presentation);
return (
<section
{...attrs}
aria-label={title || undefined}
className={cn('rounded-2xl border', look.variant, look.padding, layoutClasses(layout))}
>
{(title || control) && (
<div className={cn('flex flex-wrap items-center justify-between gap-x-3 gap-y-2', look.gap)}>
{title
? <h3 className={cn('font-heading font-semibold text-ink-1', look.title)}>{title}</h3>
: <span />}
{control && (
<SegmentedToggle
options={control.options}
value={control.value}
onChange={control.set}
size="sm"
ariaLabel={control.label}
/>
)}
</div>
)}
{empty
? <p className="text-caption text-ink-3">{empty}</p>
: <div style={{ height: plotHeight(presentation) }}>{children}</div>}
</section>
);
}
/** The axes, grid, tooltip and legend every cartesian chart here draws alike. */
function Cartesian({ measures }) {
return (
<>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_TONES.grid} vertical={false} />
<XAxis dataKey="label" {...AXIS_PROPS} interval="preserveStartEnd" minTickGap={16} height={32} />
<YAxis {...AXIS_PROPS} allowDecimals={false} width={32} />
<Tooltip cursor={{ fill: CHART_TONES.mint, fillOpacity: 0.25 }} />
{/* One series needs no key: the title already names it. */}
{measures.length > 1 && (
<Legend
verticalAlign="top" align="right" height={24}
iconType="circle" iconSize={8}
wrapperStyle={{ fontSize: 12, color: CHART_TONES.axis }}
/>
)}
</>
);
}
const colour = (i) => CHART_COLORS[i % CHART_COLORS.length];
/**
* The five, as data.
*
* `draw` returns the recharts element for a resolved series; everything else —
* the panel, the empty state, the identity, layout and presentation — is shared
* above and below. Adding one is adding a row.
*/
const VISUALIZATIONS = [
{
type: 'bar-chart',
label: 'Bar chart',
summary: 'A bar for each point in a reading.',
/* Bars compare magnitudes and claim nothing about order or wholeness, which
is why this is the one drawing every meaning can take. */
seriesKinds: ['periodic', 'cumulative', 'parts'],
draw: ({ rows, measures }) => (
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<Cartesian measures={measures} />
{measures.map((measure, i) => (
<Bar key={measure.key} dataKey={measure.key} name={measure.label}
fill={colour(i)} radius={[4, 4, 0, 0]} />
))}
</BarChart>
),
},
{
type: 'line-chart',
label: 'Line chart',
summary: 'The points joined in order.',
/* A line asserts that the gap between two points means something, so it may
only draw readings that are actually ordered. */
seriesKinds: ['periodic', 'cumulative'],
draw: ({ rows, measures }) => (
<LineChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<Cartesian measures={measures} />
{measures.map((measure, i) => (
<Line key={measure.key} type="monotone" dataKey={measure.key} name={measure.label}
stroke={colour(i)} strokeWidth={2} dot={false} isAnimationActive={false} />
))}
</LineChart>
),
},
{
type: 'area-chart',
label: 'Area chart',
summary: 'The points joined in order, filled to the axis.',
seriesKinds: ['periodic', 'cumulative'],
draw: ({ rows, measures }) => (
<AreaChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<Cartesian measures={measures} />
{measures.map((measure, i) => (
<Area key={measure.key} type="monotone" dataKey={measure.key} name={measure.label}
stroke={colour(i)} strokeWidth={2} fill={colour(i)} fillOpacity={0.18}
isAnimationActive={false} />
))}
</AreaChart>
),
},
{
type: 'pie-chart',
label: 'Pie chart',
summary: 'The points as parts of one whole.',
/* The narrow one, and deliberately. A slice is a share of a total, which is
only true when the points are disjoint parts of that total. */
seriesKinds: ['parts'],
draw: ({ rows, measures }) => <Slices rows={rows} measure={measures[0]} inner={0} />,
/* A disc of zeroes is a blank panel that every automated check calls a
success because the elements are there. */
guard: ({ rows, measures }) => (
rows.reduce((sum, row) => sum + (row[measures[0]?.key] || 0), 0) === 0
? 'Every value here is zero.' : null
),
},
{
type: 'donut-chart',
label: 'Donut chart',
summary: 'The same parts, drawn as a ring.',
seriesKinds: ['parts'],
draw: ({ rows, measures }) => <Slices rows={rows} measure={measures[0]} inner={0.55} />,
guard: ({ rows, measures }) => (
rows.reduce((sum, row) => sum + (row[measures[0]?.key] || 0), 0) === 0
? 'Every value here is zero.' : null
),
},
];
/**
* The wedges a pie and a donut share.
*
* 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 — the failure the guard above is about. The one pie already in this
* repository does it this way, and it works.
*/
function Slices({ rows, measure, inner }) {
const outer = 92;
return (
<PieChart>
<Pie
data={rows}
dataKey={measure?.key || 'value'}
nameKey="label"
cx="50%"
cy="50%"
innerRadius={Math.round(outer * inner)}
outerRadius={outer}
isAnimationActive={false}
>
{rows.map((row, i) => <Cell key={row.id} fill={colour(i)} />)}
</Pie>
<Tooltip />
</PieChart>
);
}
/** One visualization node — identical for all five but for the `draw` it was given. */
function Visualization({ spec, node, attrs = {}, layout = null, presentation = null, title = null }) {
const series = useSeries(node);
const pageContext = useUiContext();
const empty = !series.rows.length || !series.measures.length
? (series.emptyNote || 'Nothing to chart yet.')
: (spec.guard?.(series) || null);
/**
* The control the reading publishes, and the reading's own name.
*
* Both exist so that a replacement is a change of renderer rather than a
* quiet loss of what was on the panel. The title falls back to the name of
* the thing being drawn — not to a made-up one — so a built-in section
* replaced by a chart still says what it is showing.
*/
const control = controlOfBinding(node.data, pageContext);
return (
<ChartPanel attrs={attrs} layout={layout} presentation={presentation}
title={title || series.label || null} empty={empty} control={control}>
<ResponsiveContainer width="100%" height="100%">
{spec.draw(series)}
</ResponsiveContainer>
</ChartPanel>
);
}
/* The shapes whose payload 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 spec of VISUALIZATIONS) {
const Component = (props) => <Visualization spec={spec} {...props} />;
Component.displayName = spec.label;
registerNodeType({
type: spec.type,
label: spec.label,
summary: spec.summary,
component: Component,
dataShapes: SERIES_SHAPES,
seriesKinds: spec.seriesKinds,
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'],
});
}
export { VISUALIZATIONS };

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

@@ -0,0 +1,99 @@
/**
* A worker's declared professional role.
*
* The supply side of `positionModel.js`: that one describes what an
* organization needs filled, this one describes what a person says they do.
* They share a vocabulary — a role category, an English level, certifications —
* and almost nothing else, which is why the pay fields are named for what the
* worker WANTS rather than what a posting OFFERS.
*/
/** English levels, in the order the schema declares them. */
export const EMPLOYEE_ROLE_STATUSES = ['seeking', 'placed', 'inactive'];
/** When somebody can work. Free text in the column; these are the common ones. */
export const AVAILABILITY_OPTIONS = ['Weekdays', 'Weekends', 'Evenings', 'Full time'];
/** Everything the record needs, before the conversation has said anything. */
export const defaultEmployeeRole = () => ({
worker_profile_id: null,
worker_email: '',
worker_name: '',
role_category: '',
experience_years: 0,
english_level: 'basic',
certifications: [],
desired_pay_min: 0,
desired_pay_max: 0,
availability: [],
notes: '',
});
/**
* "$25–$35/hr", or "from $25/hr" when only a floor was given.
*
* A maximum of zero means "no ceiling stated" rather than "free", which is why
* it is not rendered as a range ending at nothing.
*/
export function desiredPayLabel(record = {}) {
const min = Number(record.desired_pay_min) || 0;
const max = Number(record.desired_pay_max) || 0;
if (!min && !max) return null;
if (min && max) return `$${min}–$${max}/hr`;
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.
*
* Numbers are coerced here rather than at the field, because the conversation
* collects strings and the column is an int — and a string in an int column is
* a 400 the reader cannot act on. `org_id` and `created_by` are absent
* deliberately: both are the server's, derived from the session, and a value
* sent for either is dropped before the insert.
*/
export function toEmployeeRolePayload(draft = {}) {
const record = { ...defaultEmployeeRole(), ...draft };
return {
...record,
worker_email: String(record.worker_email || '').trim(),
worker_name: String(record.worker_name || '').trim(),
role_category: String(record.role_category || '').trim(),
experience_years: Number(record.experience_years) || 0,
desired_pay_min: Number(record.desired_pay_min) || 0,
desired_pay_max: Number(record.desired_pay_max) || 0,
certifications: Array.isArray(record.certifications) ? record.certifications : [],
availability: Array.isArray(record.availability) ? record.availability : [],
notes: String(record.notes || '').trim(),
};
}

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'],
@@ -317,6 +366,51 @@ export function useCreateJobPosting() {
});
}
/**
* Record what a worker declares they do.
*
* The supply-side twin of `useCreateJobPosting`. It invalidates the worker
* queries as well as its own, because a declared role changes what the Talent
* Pool shows about that person — and the Owliver context, because the
* 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({
mutationFn: /** @param {any} data */ (data) => base44.entities.EmployeeRole.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employeeRoles'] });
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
logActivity('create_employee_role');
},
});
}
export function useUpdateJobPosting() {
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,3 +1,4 @@
import { toNewWorkerWithRolePayload } from '@/lib/employeeRoleModel';
import { CERT_OPTIONS, ENGLISH_LEVELS, toPositionPayload } from '@/lib/positionModel';
import { routeForPageKey } from './registry';
@@ -24,6 +25,34 @@ import { routeForPageKey } from './registry';
* that category — and a category added in Create Position is understood without
* a change to this file.
*/
/**
* Words that introduce a role rather than name one.
*
* "Create new position" marks "new" as the role by the same grammar that marks
* "sous chef" in "create sous chef position", and the phrase pattern below
* cannot tell them apart. Without this, that request opened the conversation
* with the title already set to "New" — a value nobody typed, on the one field
* a position cannot be created without, which the reader then had to notice and
* correct. An empty title is the honest answer to a request that named no role.
*
* These are stripped to decide whether a phrase named anything, and the ORIGINAL
* phrase is what is returned when it did. That distinction is the whole design:
* a rule that returned the stripped words instead would turn "second chef" into
* "Chef" and "open kitchen lead" into "Kitchen Lead", quietly renaming real
* roles to fix a problem those roles do not have. Strip to TEST, never to
* rewrite.
*
* Only determiners, quantifiers and intensifiers belong here. Nothing that
* could be part of a job title, which is what makes the strip-and-test safe:
* a phrase is rejected only when EVERY word in it is one of these.
*/
const GENERIC_ROLE_WORDS = new Set([
'a', 'an', 'the', 'this', 'that', 'these', 'those', 'it',
'new', 'brand', 'fresh', 'another', 'other', 'more', 'extra', 'additional',
'further', 'second', 'third', 'next', 'one', 'couple', 'few', 'several',
'some', 'any', 'open', 'spare', 'whole', 'just', 'quick',
]);
export function extractRole(question, categories = []) {
const q = String(question).toLowerCase();
@@ -37,12 +66,24 @@ export function extractRole(question, categories = []) {
/* "…for a Sous Chef", "…a Line Cook position" — take the phrase the sentence
itself marks as the role when it matches no known category. */
const phrase = /(?:create|open|post|add|new|hiring|hire)\s+(?:a|an)?\s*([a-z][a-z\s/-]{2,40}?)\s*(?:position|role|job|opening)\b/i.exec(question)
|| /\b(?:position|role|job)\s+for\s+(?:a|an)?\s*([a-z][a-z\s/-]{2,40})/i.exec(question);
/* The article is optional but must be a WHOLE word when present. Written as
`(?:a|an)?\s*` it matched the "a" of "another", so "create another new
position" captured "nother new" and offered the position a title that is
not even a word. */
const phrase = /(?:create|open|post|add|new|hiring|hire)\s+(?:(?:an?)\s+)?([a-z][a-z\s/-]{2,40}?)\s*(?:position|role|job|opening)\b/i.exec(question)
|| /\b(?:position|role|job)\s+for\s+(?:(?:an?)\s+)?([a-z][a-z\s/-]{2,40})/i.exec(question);
if (!phrase) return null;
const cleaned = phrase[1].trim().replace(/\s+/g, ' ');
return cleaned ? cleaned.replace(/\b\w/g, (c) => c.toUpperCase()) : null;
if (!cleaned) return null;
/* Named nothing if every word was scaffolding — "a brand new", "one more".
A single-word check missed all of those, because the scaffolding is a
PHRASE at least as often as it is one word. */
const named = cleaned.split(' ').some((word) => !GENERIC_ROLE_WORDS.has(word.toLowerCase()));
if (!named) return null;
return cleaned.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** A location, when the sentence names one with "in" or "at". */
@@ -440,6 +481,29 @@ const HANDLERS = {
data: toPositionPayload(draft || {}, status ? { status } : undefined),
}),
/**
* Write the employee role the conversation collected.
*
* The supply-side twin of `create_position`. `status` is the WORKER's
* 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.
*/
/**
* 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
* already answered. Routing to the page carries the intent in navigation

View File

@@ -0,0 +1,360 @@
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
/**
* A skill's questions, asked one at a time, as a conversation.
*
* This is the engine `positionFlow.js` used to be. It was five things in one
* file — a field table, an `@`-token resolver, a sentence extractor, a commit
* vocabulary and a set of outcome renderers — and only the control flow between
* them was general. Everything else knew it was creating a job posting.
*
* So the control flow lives here and the five domain concerns live in a
* REGISTRY, one per kind of record. Adding a second conversation is writing a
* second registry; it is not editing this file. That is the same rule §3 states
* for agents, applied one level down: the skill file declares the questions, the
* registry says what each field means, and this decides what to say next.
*
* WHAT A REGISTRY PROVIDES
*
* fields { [field]: { label, settled, retry, parse, summary } }
* resolve an `@token` from the skill file → the chips it stands for
* prefill a first draft read out of the opening request
* extract one sentence read against every field at once (see `restate`)
* verbs what the reader can say at the summary, and what each commits
* copy the wording of the summary, the cancel and the failure
*
* A flow is a plain serializable object: it survives sessionStorage between
* turns and holds no component state, so a conversation resumes where it
* stopped. Nothing here writes. The last step returns a draft and the panel
* performs the mutation.
*/
/* ── Reading answers ────────────────────────────────────────────────────── */
/** "Skip" is an answer to an optional question — it settles it, unanswered. */
export const SKIP = /^(?:skip|skip this|skip it|no preference|not sure|does ?n(?:'|o)t matter|any|none of these)$/i;
/** A chip that means "let me type it" rather than an answer in itself. */
export const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere else)$/i;
/** Title Case, for the free-text answers that name a proper noun. */
export const titleCase = (value) => value.replace(/\b\w/g, (c) => c.toUpperCase());
/** A short free-text answer, stripped of the words around it. */
export function cleanPhrase(answer, max = 60) {
const value = String(answer)
.trim()
.replace(/^(?:in|at|around|near|it is|its|it's)\s+/i, '')
.replace(/[.!?,;]+$/, '')
.replace(/\s+/g, ' ');
return value && value.length <= max ? value : null;
}
/**
* One spoken reply, reduced to the form the vocabularies below are matched on.
*
* This exists because the confirmation step used to test the raw answer against
* an ANCHORED regex, which meant "create position" committed and "create
* positions" — the plural, and the wording the Positions page itself uses —
* fell through to the not-understood branch. The reader saw the summary again
* with no indication of what was wrong with what they said.
*
* Lower case, single-spaced, trailing punctuation removed. Matching is then
* exact set membership rather than a pattern, so a vocabulary is a list of
* phrases somebody can read rather than an expression somebody has to parse.
*/
export const normalizeReply = (said) => String(said)
.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/[.!?,;]+$/, '')
.trim();
/** Ending the conversation without finishing it. */
const CANCEL = new Set([
'cancel', 'stop', 'never mind', 'nevermind', 'forget it', 'quit', 'exit',
]);
/** Going back to a detail already given. */
const CHANGE = new Set([
'change', 'change details', 'edit', 'change something', 'change a detail', 'no',
]);
/* ── Flow state ─────────────────────────────────────────────────────────── */
/**
* The steps a skill declares, keeping only the fields this registry understands.
*
* A field the registry does not know is dropped, which is deliberate — but it
* used to be dropped SILENTLY, and a skill whose steps were all unknown asked
* nothing and went straight to a summary of an empty record. `npm test` now
* refuses a conversation step whose field no registry defines, so the drop here
* only ever removes a field a shipped skill does not have.
*/
export const stepsOf = (registry, skill) => (skill?.conversation || [])
.filter((s) => registry.fields[s.field]);
/** Is this field answered, or deliberately passed over? */
export const isSettled = (registry, flow, field) => registry.fields[field].settled(flow.draft)
|| flow.skipped.includes(field);
/** The next question, or `null` when there is nothing left to ask. */
export function nextStep(registry, flow, steps) {
if (flow.editing) return steps.find((s) => s.field === flow.editing) || null;
return steps.find((s) => !isSettled(registry, flow, s.field)) || null;
}
/** Everything decided so far, one line each, in the order it is asked. */
export function summaryLines(registry, flow, steps) {
return steps
.filter((s) => isSettled(registry, flow, s.field))
.map((s) => registry.fields[s.field].summary(flow.draft))
.filter(Boolean);
}
/** The required fields the record cannot be created without. */
export const missingRequired = (registry, flow, steps) => steps
.filter((s) => s.required && !registry.fields[s.field].settled(flow.draft));
/* ── Suggestions ────────────────────────────────────────────────────────── */
/**
* A step's chips.
*
* `@`-prefixed entries in the skill file resolve through the registry to the
* lists the application already owns, so the roles offered here are the roles
* the form offers and a certification added to the record's options appears in
* the conversation without this file or the skill file changing.
*
* An `@token` the registry cannot resolve contributes nothing rather than
* appearing as the literal "@workers", which is what it did before registries
* existed and each resolver knew every token.
*/
function suggestionsFor(registry, step, ctx, draft = {}) {
const resolved = step.options.flatMap((option) => {
if (!option.startsWith('@')) return [option];
/* 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);
/* An optional question needs a way past it that is not a typed sentence. */
if (!step.required) capped.push('Skip');
return capped.map((label) => ({ label, prompt: label }));
}
/* ── Replies ────────────────────────────────────────────────────────────── */
/** Ask the next question, or read the record back when there is none left. */
function ask(registry, flow, steps, ctx, { preamble = null, retry = null } = {}) {
const step = nextStep(registry, flow, steps);
if (!step) return review(registry, flow, steps);
const known = summaryLines(registry, flow, steps);
return {
flow: { ...flow, stage: 'collect', step: step.field },
doc: doc(
preamble ? text(preamble) : null,
preamble && known.length ? list(known) : null,
text(step.question),
retry ? note(retry) : null
),
followUp: suggestionsFor(registry, step, ctx, flow.draft),
};
}
/** The whole record, before anything is written. */
function review(registry, flow, steps) {
const missing = missingRequired(registry, flow, steps);
/* Only reachable if a required answer was cleared — ask for it rather than
offering to create something incomplete. */
if (missing.length) {
return {
flow: { ...flow, stage: 'collect', step: missing[0].field, editing: null },
doc: doc(text(missing[0].question)),
followUp: [],
};
}
return {
flow: { ...flow, stage: 'review', step: null, editing: null },
doc: doc(
text(registry.copy.reviewQuestion),
list(summaryLines(registry, flow, steps)),
note(registry.copy.reviewNote)
),
followUp: registry.verbs
.filter((v) => v.chip)
.map((v) => ({ label: v.chip, prompt: v.chip }))
.concat([{ label: 'Change details', prompt: 'Change details' }]),
};
}
/** Which detail to change — the answers already given, as chips. */
function changeMenu(registry, flow, steps) {
const settled = steps.filter((s) => isSettled(registry, flow, s.field));
return {
flow: { ...flow, stage: 'change', step: null },
doc: doc(text('What should I change?')),
followUp: settled.map((s) => ({
label: registry.fields[s.field].label,
prompt: registry.fields[s.field].label,
})),
};
}
/**
* A sentence read against every field at once.
*
* This is what makes the conversation forgiving: an answer that arrives out of
* order, or a correction stated rather than chosen from a menu, lands in the
* right field instead of being rejected for not answering the question asked.
* Returns an updated flow, or `null` when the sentence settles nothing.
*
* The reading itself is the registry's — a sentence about a job posting and a
* sentence about a worker's role name different things — and only the bookkeeping
* around it is general.
*/
function restate(registry, flow, steps, said, ctx) {
const fields = new Set(steps.map((s) => s.field));
const result = registry.extract?.(said, ctx, fields);
if (!result || !result.touched?.size) return null;
return {
...flow,
draft: { ...flow.draft, ...result.patch },
/* Step names, not record fields — a step the sentence settled must come off
the skipped list so the summary shows it. */
skipped: flow.skipped.filter((f) => !result.touched.has(f)),
editing: null,
};
}
/* ── Entry points ───────────────────────────────────────────────────────── */
/**
* Start collecting, from whatever the request already said.
*
* "Create a bartender position in Chennai paying $30–$40/hr" answers three
* questions before the first one is asked, and those are not asked again.
*/
export function beginFlow({ registry, question, skill, ctx = {} }) {
const steps = stepsOf(registry, skill);
const flow = {
flowId: registry.id,
skillId: skill.id,
draft: registry.prefill(question, ctx),
skipped: [],
editing: null,
stage: 'collect',
step: null,
};
const known = summaryLines(registry, flow, steps);
return ask(registry, flow, steps, ctx, {
preamble: known.length ? 'Got it — here is what I have so far:' : null,
});
}
/**
* One answer, and whatever it makes the next thing to say.
*
* Returns `{ flow, doc, followUp }`, and on the confirmation step additionally
* `create: { draft, status }` — the panel writes it, this module does not.
*/
export function advanceFlow({ registry, flow, answer, skill, ctx = {} }) {
const steps = stepsOf(registry, skill);
const said = String(answer).trim();
const reply = normalizeReply(said);
/* A way out that does not require finishing. `flow: null` ends it, and the
next question is answered by the page as usual. */
if (CANCEL.has(reply)) {
return {
flow: null,
doc: doc(text(registry.copy.cancelled), note(registry.copy.cancelledNote)),
followUp: [],
};
}
/* The confirmation step. A verb is the only path to a record.
Change is tested before the verbs so that a bare "no" reaches the change
menu rather than being read as a refusal to commit. */
if (flow.stage === 'review') {
if (CHANGE.has(reply)) return changeMenu(registry, flow, steps);
const verb = registry.verbs.find((v) => v.says.has(reply));
if (verb) {
if (missingRequired(registry, flow, steps).length) return review(registry, flow, steps);
return {
flow: { ...flow, stage: 'creating' },
create: { draft: flow.draft, status: verb.status },
};
}
/* Anything else at the confirmation step is a correction stated outright —
"make it Bengaluru", "$32–$40". Read it against every field and apply
what it settles, rather than making the user find the menu. */
const revised = restate(registry, flow, steps, said, ctx);
if (revised) return review(registry, revised, steps);
return {
...review(registry, flow, steps),
doc: doc(
text(`I did not catch that. ${registry.copy.reviewQuestion}`),
list(summaryLines(registry, flow, steps)),
note(registry.copy.reviewRetryNote)
),
};
}
/* Choosing which detail to revisit. */
if (flow.stage === 'change') {
const target = steps.find((s) => registry.fields[s.field].label.toLowerCase() === reply);
if (!target) {
const revised = restate(registry, flow, steps, said, ctx);
if (revised) return review(registry, revised, steps);
return changeMenu(registry, flow, steps);
}
return ask(registry, { ...flow, editing: target.field }, steps, ctx);
}
/* Answering the question that was asked. */
const step = steps.find((s) => s.field === flow.step) || nextStep(registry, flow, steps);
if (!step) return review(registry, flow, steps);
const field = registry.fields[step.field];
if (SKIP.test(said) && !step.required) {
const next = { ...flow, skipped: [...flow.skipped, step.field], editing: null };
return ask(registry, next, steps, ctx);
}
const patch = field.parse(said, ctx);
if (!patch) {
/* Not an answer to this question — but it may still be a fact about the
record ("in Chennai" while being asked for pay). Take it if so. */
const revised = restate(registry, flow, steps, said, ctx);
if (revised) return ask(registry, revised, steps, ctx);
return ask(registry, flow, steps, ctx, { retry: field.retry });
}
const next = {
...flow,
draft: { ...flow.draft, ...patch },
skipped: flow.skipped.filter((f) => f !== step.field),
editing: null,
};
/* A detail revisited from the change menu goes straight back to the summary
rather than walking the rest of the questions again. */
if (flow.editing) return review(registry, next, steps);
return ask(registry, next, steps, ctx);
}

View File

@@ -0,0 +1,388 @@
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
import { ENGLISH_LEVELS, certificationsForRole } from '@/lib/positionModel';
import {
AVAILABILITY_OPTIONS, desiredPayLabel,
} from '@/lib/employeeRoleModel';
import {
extractCertifications, extractEnglish, extractExperience, extractPay, extractRole,
} from '../actions';
import { cleanPhrase, titleCase } from '../conversationFlow';
/**
* What an employee role's questions mean.
*
* The supply side. Its sibling registry, `position.js`, is the demand side —
* what the organization needs filled. The distinction matters more than the
* shared vocabulary suggests: "3 years" on a posting is a MINIMUM the applicant
* must clear, and the same words here are what the person HAS. Collapsing them
* into one registry with a mode flag would put that difference in a branch
* rather than in a field.
*
* The worker is never derived from the session. An operator records a role on
* somebody's behalf, so the subject is answered explicitly — which is also why
* `employee-roles` grants Create to operators only. See the policy note in
* `internal/domain/policy.go`.
*/
const fields = {
/**
* Whose role this is.
*
* Settled by the email, not the name: the email is what the row is scoped on
* 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.
*/
/**
* 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) => `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: {
label: 'Role',
settled: (draft) => Boolean(String(draft.role_category || '').trim()),
retry: 'Name the role they work as — "bartender", "server", "line cook".',
parse: (answer, { roles = [] }) => {
const role = extractRole(answer, roles) || cleanPhrase(answer, 40);
if (!role || role.length < 2) return null;
const known = roles.find((r) => String(r).toLowerCase() === role.toLowerCase());
return { role_category: known || titleCase(role) };
},
summary: (draft) => `Role: ${draft.role_category}`,
},
experience_years: {
label: 'Experience',
settled: (draft) => draft.experience_years !== undefined && draft.experience_years !== null,
retry: 'How many years — or "no experience".',
parse: (answer) => {
const years = extractExperience(answer);
if (years !== null) return { experience_years: years };
const bare = /^(\d{1,2})\s*\+?$/.exec(String(answer).trim());
if (bare) return { experience_years: Number(bare[1]) };
return /^(?:none|no experience|new|fresher)$/i.test(String(answer).trim())
? { experience_years: 0 }
: null;
},
summary: (draft) => (draft.experience_years
? `${draft.experience_years} years experience`
: 'No experience yet'),
},
english_level: {
label: 'English',
settled: (draft) => Boolean(draft.english_level),
retry: `One of ${ENGLISH_LEVELS.map((l) => l.label).join(', ')}.`,
parse: (answer) => {
const level = extractEnglish(answer)
|| ENGLISH_LEVELS.find((l) => l.label.toLowerCase() === String(answer).trim().toLowerCase())?.value;
return level ? { english_level: level } : null;
},
summary: (draft) => {
const level = ENGLISH_LEVELS.find((l) => l.value === draft.english_level);
return level ? `English: ${level.label}` : null;
},
},
certifications: {
label: 'Certifications',
settled: (draft) => Array.isArray(draft.certifications),
retry: 'Name a certification they hold, or "none".',
parse: (answer) => {
const found = extractCertifications(answer);
if (found) return { certifications: found };
return /^(?:none|no|no certifications?)$/i.test(String(answer).trim())
? { certifications: [] }
: null;
},
summary: (draft) => (draft.certifications?.length
? `Certifications: ${draft.certifications.join(', ')}`
: null),
},
desired_pay: {
label: 'Desired pay',
settled: (draft) => Number(draft.desired_pay_min) > 0 || Number(draft.desired_pay_max) > 0,
retry: 'Give a range like "$25–$35/hr", or a single rate.',
parse: (answer) => {
const pay = extractPay(answer);
return pay ? { desired_pay_min: pay.min, desired_pay_max: pay.max } : null;
},
summary: (draft) => {
const label = desiredPayLabel(draft);
return label ? `Looking for ${label}` : null;
},
},
availability: {
label: 'Availability',
settled: (draft) => Array.isArray(draft.availability),
retry: 'When can they work — weekdays, weekends, evenings, full time?',
parse: (answer) => {
const said = String(answer).toLowerCase();
const found = AVAILABILITY_OPTIONS.filter((o) => said.includes(o.toLowerCase()));
if (found.length) return { availability: found };
const value = cleanPhrase(answer, 40);
return value ? { availability: [titleCase(value)] } : null;
},
summary: (draft) => (draft.availability?.length
? `Available: ${draft.availability.join(', ')}`
: null),
},
notes: {
label: 'Notes',
settled: (draft) => typeof draft.notes === 'string' && draft.notes !== '',
retry: 'Anything worth recording, or "skip".',
parse: (answer) => {
const value = cleanPhrase(answer, 280);
return value ? { notes: value } : null;
},
summary: (draft) => (draft.notes ? `Notes: ${draft.notes}` : null),
},
};
/**
* The `@` tokens this conversation's skill file may use.
*
* `@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 = [], postings = null }, draft = {}) => {
switch (token) {
case '@roles': return roles;
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
/**
* 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 [];
}
};
/**
* One sentence read against every field at once.
*
* Deliberately narrower than the posting's. A worker is never inferred from a
* loose sentence: "bartender, weekends, $30/hr" settles three fields and leaves
* the subject alone, because guessing WHO a record is about from a fragment is
* how a role gets filed against the wrong person.
*/
function extract(said, ctx, fieldNames) {
const patch = {};
const touched = new Set();
if (fieldNames.has('role_category')) {
const role = extractRole(said, ctx.roles || []);
if (role) {
patch.role_category = role;
touched.add('role_category');
}
}
if (fieldNames.has('desired_pay')) {
const pay = extractPay(said);
if (pay) {
patch.desired_pay_min = pay.min;
patch.desired_pay_max = pay.max;
touched.add('desired_pay');
}
}
if (fieldNames.has('experience_years')) {
const years = extractExperience(said);
if (years !== null) {
patch.experience_years = years;
touched.add('experience_years');
}
}
if (fieldNames.has('english_level')) {
const level = extractEnglish(said);
if (level) {
patch.english_level = level;
touched.add('english_level');
}
}
if (fieldNames.has('certifications')) {
const certs = extractCertifications(said);
if (certs) {
patch.certifications = certs;
touched.add('certifications');
}
}
if (fieldNames.has('availability')) {
const lower = String(said).toLowerCase();
const found = AVAILABILITY_OPTIONS.filter((o) => lower.includes(o.toLowerCase()));
if (found.length) {
patch.availability = found;
touched.add('availability');
}
}
return { patch, touched };
}
/* ── Outcomes ───────────────────────────────────────────────────────────── */
/** The role exists. Said plainly, with what was recorded. */
const createdReply = (record) => doc(
text('Employee role recorded.'),
list([
record.worker_name || record.worker_email,
record.role_category,
record.experience_years ? `${record.experience_years} years experience` : null,
desiredPayLabel(record),
].filter(Boolean)),
note('It is on the Talent Pool now — this worker can be matched against open positions by this role.')
);
/**
* The write failed. The answers are kept, so nothing has to be retyped.
*
* The server's own wording is said out loud when there is one, for the same
* reason it is on the posting side: a validation error naming a field, a
* refused role and an API that is not running are three different problems and
* one sentence cannot tell them apart.
*/
const failedReply = (reason = null) => {
const said = String(reason || '').trim();
return doc(
text('I could not record that employee role.'),
said ? note(said) : null,
note('Nothing was saved. Choose Create employee role to try again.')
);
};
/**
* What is worth asking once the role exists.
*
* The obvious next question is which open positions this person now matches,
* and that is an ordinary question the workforce engine already answers — so it
* is offered as words rather than as a handler of its own.
*/
const createdFollowUp = (record) => [
{ label: 'Match positions', prompt: `Which positions suit ${record.worker_name || record.worker_email}?` },
];
/* ── The registry ───────────────────────────────────────────────────────── */
export const employeeRoleRegistry = {
id: 'employee-role',
fields,
resolve,
extract,
/**
* 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: () => ({}),
/**
* One verb, because there is one outcome. A declared role has no draft state:
* `status` is about the WORKER's situation — seeking, placed, inactive — not
* about how finished the record is, which is why 'draft' has no meaning here
* and is not offered.
*/
verbs: [
{
chip: 'Create employee role',
status: 'seeking',
says: new Set([
'create employee role', 'create an employee role', 'create the employee role',
'create employee roles', 'add employee role', 'add an employee role',
'create role', 'create it', 'create', 'save', 'save it',
'yes', 'confirm', 'looks good', 'go ahead',
]),
},
],
outcome: {
created: createdReply,
failed: failedReply,
followUp: createdFollowUp,
retryChips: [
{ label: 'Create employee role', prompt: 'Create employee role' },
{ label: 'Change details', prompt: 'Change details' },
],
},
copy: {
reviewQuestion: 'Ready to record this employee role?',
reviewNote: 'Nothing is saved until you choose. The worker can hold more than one role — recording this does not replace an existing one.',
reviewRetryNote: 'Choose Create employee role, or tell me what to change.',
cancelled: 'Stopped — nothing was recorded.',
cancelledNote: 'Ask me to create an employee role whenever you are ready.',
},
};

View File

@@ -0,0 +1,18 @@
import { employeeRoleRegistry } from './employeeRole';
import { positionRegistry } from './position';
/**
* Every conversation a skill can be bound to, by the id its `flow:` names.
*
* A skill declares `flow: position`; this is what that word resolves to. Adding
* a conversation is adding a registry here and a `flow:` line in the skill file
* — there is no branch in the router, and nothing in the engine learns a new
* name. That is §3's "specs are data" applied to conversations.
*/
export const FLOWS = {
[positionRegistry.id]: positionRegistry,
[employeeRoleRegistry.id]: employeeRoleRegistry,
};
/** The registry a parsed skill is bound to, or `null` if it declares none. */
export const flowFor = (skill) => (skill?.flow ? FLOWS[skill.flow] || null : null);

View File

@@ -0,0 +1,326 @@
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
import { ENGLISH_LEVELS, certificationsForRole, payLabel } from '@/lib/positionModel';
import {
buildPositionPrefill, extractCertifications, extractEnglish, extractExperience,
extractLocation, extractPay, extractRole,
} from '../actions';
import { FREE_TEXT, cleanPhrase, titleCase } from '../conversationFlow';
/**
* What a job posting's questions mean.
*
* The demand side: what the organization needs filled. Its sibling registry,
* `employeeRole.js`, is the supply side — what a worker says they do. They share
* a vocabulary and almost nothing else, which is why they are two registries
* over one engine rather than one registry with a mode flag.
*
* `parse` returns a patch for the draft, or `null` when the answer was not
* understood — which re-asks with `retry` rather than storing a guess.
*/
const fields = {
/**
* The client this role is being staffed for.
*
* A "client" is not a record of its own in this product — it is the `company`
* on the position, which is the field the Create Position form writes, the
* Positions card leads with, and Hired History reports against. So the
* conversation collects it into the same field rather than into a store of
* its own, and asking Owliver to create a client starts here.
*
* Blueprint decision D2 (a `clients` table) is still open and this does not
* pre-empt it: promoting company to a record later adds a nullable reference
* beside this column and changes no endpoint.
*/
company: {
label: 'Company',
settled: (draft) => Boolean(String(draft.company || '').trim()),
retry: 'Type the client or company name — "Fairmont San Jose".',
parse: (answer) => {
const value = cleanPhrase(answer, 60);
return value && value.length > 1 ? { company: titleCase(value) } : null;
},
summary: (draft) => (draft.company ? `Company: ${draft.company}` : null),
},
role_category: {
label: 'Role',
settled: (draft) => Boolean(draft.title),
retry: 'Name the role — "bartender", "line cook", "event staff".',
parse: (answer, { roles = [] }) => {
const role = extractRole(answer, roles) || cleanPhrase(answer, 40);
if (!role || role.length < 2) return null;
const known = roles.find((r) => String(r).toLowerCase() === role.toLowerCase());
/* A known category files the position; anything else becomes the title and
leaves the category at its default, exactly as the form behaves. */
return known
? { role_category: known, title: known }
: { title: titleCase(role) };
},
summary: (draft) => (draft.role_category && draft.role_category !== draft.title
? `${draft.title} · ${draft.role_category}`
: draft.title),
},
location: {
label: 'Location',
settled: (draft) => Boolean(draft.location),
retry: 'Type the city or area this role is based in.',
parse: (answer) => {
if (FREE_TEXT.test(String(answer).trim())) return null;
const value = extractLocation(answer) || cleanPhrase(answer);
return value ? { location: titleCase(value) } : null;
},
summary: (draft) => draft.location,
},
pay: {
label: 'Pay',
settled: (draft) => Number(draft.pay_range_min) > 0 || Number(draft.pay_range_max) > 0,
retry: 'Give a range like "$28–$36/hr", or a single rate.',
parse: (answer) => {
const pay = extractPay(answer);
return pay ? { pay_range_min: String(pay.min), pay_range_max: String(pay.max) } : null;
},
summary: (draft) => payLabel(draft),
},
min_experience_years: {
label: 'Experience',
settled: (draft) => draft.min_experience_years !== undefined && draft.min_experience_years !== null,
retry: 'How many years — or "no minimum".',
parse: (answer) => {
const years = extractExperience(answer);
if (years !== null) return { min_experience_years: years };
const bare = /^(\d{1,2})\s*\+?$/.exec(String(answer).trim());
return bare ? { min_experience_years: Number(bare[1]) } : null;
},
summary: (draft) => (draft.min_experience_years
? `${draft.min_experience_years}+ years experience`
: 'No minimum experience'),
},
english_required: {
label: 'English',
settled: (draft) => Boolean(draft.english_required),
retry: `One of ${ENGLISH_LEVELS.map((l) => l.label).join(', ')}.`,
parse: (answer) => {
const level = extractEnglish(answer)
|| ENGLISH_LEVELS.find((l) => l.label.toLowerCase() === String(answer).trim().toLowerCase())?.value;
return level ? { english_required: level } : null;
},
summary: (draft) => {
const level = ENGLISH_LEVELS.find((l) => l.value === draft.english_required);
return level ? `English: ${level.label}` : null;
},
},
certifications_required: {
label: 'Certifications',
settled: (draft) => Array.isArray(draft.certifications_required),
retry: 'Name a certification, or "none".',
parse: (answer) => {
const found = extractCertifications(answer);
if (found) return { certifications_required: found };
if (/^(?:none|no|no certifications?)$/i.test(String(answer).trim())) {
return { certifications_required: [] };
}
return null;
},
summary: (draft) => (draft.certifications_required?.length
? `Certifications: ${draft.certifications_required.join(', ')}`
: null),
},
};
/**
* The `@` tokens this conversation's skill file may use.
*
* `@companies` is the clients this organization already staffs for, read off the
* postings the caller can already see — so it offers an existing client without
* a new endpoint, and typing a name that is not on the list is still how a new
* client is named. Which is the whole of "create a company": there is no
* company record to create, and creating an `organizations` row instead would
* 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 = [], postings = null }, draft = {}) => {
switch (token) {
case '@roles': return roles;
case '@companies': return companies;
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
/* 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 [];
}
};
/** One sentence read against every field of a posting at once. */
function extract(said, { roles = [] }, fieldNames) {
const { prefill } = buildPositionPrefill(said, roles);
const patch = {};
const touched = new Set();
if (fieldNames.has('role_category') && prefill.title) {
if (prefill.role_category) patch.role_category = prefill.role_category;
patch.title = prefill.title;
touched.add('role_category');
}
if (fieldNames.has('location') && prefill.location) {
patch.location = prefill.location;
touched.add('location');
}
if (fieldNames.has('pay') && prefill.pay_range_min) {
patch.pay_range_min = prefill.pay_range_min;
patch.pay_range_max = prefill.pay_range_max;
touched.add('pay');
}
if (fieldNames.has('min_experience_years') && prefill.min_experience_years !== undefined) {
patch.min_experience_years = prefill.min_experience_years;
touched.add('min_experience_years');
}
if (fieldNames.has('english_required') && prefill.english_required) {
patch.english_required = prefill.english_required;
touched.add('english_required');
}
if (fieldNames.has('certifications_required') && prefill.certifications_required) {
patch.certifications_required = prefill.certifications_required;
touched.add('certifications_required');
}
return { patch, touched };
}
/* ── Outcomes ───────────────────────────────────────────────────────────── */
/** The position exists. Said plainly, with what was created. */
function createdReply(position) {
const isDraft = position.status === 'draft';
return doc(
/* Draft and published are different outcomes, so they are named
differently: one was saved, the other went live. */
text(isDraft ? 'Saved as a draft.' : 'Position published successfully.'),
list([
position.company,
position.title,
position.location,
payLabel(position),
].filter(Boolean)),
note(isDraft
? 'It is on the Positions list as a draft — nobody can apply until it is published, and it stays a draft until you publish it.'
: 'It is on the Positions list now — applications will start appearing against it.')
);
}
/**
* The write failed. The draft is kept, so the answers are not lost.
*
* The server's own message is said out loud when there is one. This used to be
* a fixed sentence, and a fixed sentence is the wrong answer to three different
* failures: a validation error naming a field, a permission refusal, and an API
* that is not running all read as "I could not create that position", leaving
* the reader to guess which of the three they are looking at and what to change.
*
* The message comes from `KrowApiError.message`, which `httpClient` sets to the
* server's wording verbatim — so the reason is the API's, not one invented here
* from a status code.
*/
function failedReply(reason = null) {
const said = String(reason || '').trim();
return doc(
text('I could not create that position.'),
said ? note(said) : null,
note('Nothing was saved. Choose Create position to try again.')
);
}
/**
* What the panel offers after a position is created.
*
* A published role has an obvious next question — who can fill it — so it is
* offered here rather than left to be typed. The chip carries the position's
* own title and the wording the workforce engine already answers, so it is an
* ordinary question resolved by the path that was already there: no handler,
* no navigation, and the same answer as asking it by hand.
*/
const createdFollowUp = (position) => (position.status === 'draft'
/**
* A saved draft offers no action, and that is the fix.
*
* This used to offer "Continue to save", routing back to the Create Position
* form. It made the completed state look unfinished: the reader had just been
* told the position was saved, and was immediately asked to save it again —
* by a button that put them back in the form they had just left. The write
* has happened, the record exists with `status: draft`, and the way to finish
* a draft later is its own card on the Positions list.
*/
? []
: [
{ label: 'View position', route: `/admin/positions/${position.id}` },
{ label: 'Match candidates', prompt: `Who matches ${position.title}?` },
]);
/* ── The registry ───────────────────────────────────────────────────────── */
export const positionRegistry = {
id: 'position',
fields,
resolve,
extract,
prefill: (question, { roles = [] }) => buildPositionPrefill(question, roles).prefill,
/**
* The two the form offers, in the same words and the same order, so the
* conversation and the page commit a position the same two ways.
*
* `says` is exact membership against a normalized reply, not a pattern. The
* anchored regex this replaced accepted "create position" and rejected
* "create positions" — the plural the Positions page itself uses — with no
* indication of what was wrong.
*/
verbs: [
{
chip: 'Save as Draft',
status: 'draft',
says: new Set(['save as draft', 'save draft', 'draft', 'save it as a draft']),
},
{
chip: 'Publish Job Posting',
status: 'active',
says: new Set([
'publish job posting', 'publish', 'publish it',
'create position', 'create positions', 'create a position',
'create the position', 'create this position', 'create it', 'create',
'yes', 'confirm', 'looks good', 'go ahead',
]),
},
],
/**
* How the outcome is said. The panel renders these without knowing what kind
* of record it just wrote — which is what lets a second conversation report
* its own result instead of borrowing a job posting's wording.
*/
outcome: {
created: createdReply,
failed: failedReply,
followUp: createdFollowUp,
/* Offered beside the failure, so a retry does not need retyping. */
retryChips: [
{ label: 'Create position', prompt: 'Create position' },
{ label: 'Change details', prompt: 'Change details' },
],
},
copy: {
reviewQuestion: 'Ready to create this position?',
reviewNote: 'Nothing is saved until you choose one. Save as Draft keeps it unpublished — the same as the button on the form.',
reviewRetryNote: 'Choose Save as Draft or Publish Job Posting, or tell me what to change.',
cancelled: 'Stopped — nothing was created.',
cancelledNote: 'Ask me to create a position whenever you are ready.',
},
};

View File

@@ -1,9 +1,5 @@
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
import { CERT_OPTIONS, ENGLISH_LEVELS, payLabel } from '@/lib/positionModel';
import {
buildPositionPrefill, extractCertifications, extractEnglish, extractExperience,
extractLocation, extractPay, extractRole,
} from './actions';
import { advanceFlow, beginFlow } from './conversationFlow';
import { positionRegistry } from './flows/position';
/**
* Creating a position, as a conversation.
@@ -13,515 +9,44 @@ import {
* described the job in one sentence to type the rest of it into a drawer. The
* form was doing the asking, and the assistant was doing the paperwork.
*
* This module turns that round. The skill file lists the questions; this decides
* what each field name means — how an answer is read, whether it is already
* settled, and how it reads back in the summary. Same split as `actions.js`:
* Markdown declares, code interprets, and a field the code does not know is
* simply not asked about rather than being handled arbitrarily.
* This module turns that round. The skill file lists the questions,
* `flows/position.js` says what each field name means, and `conversationFlow.js`
* decides what to say next. Same split as `actions.js`: Markdown declares, code
* interprets, and a field the code does not know is simply not asked about.
*
* The flow is a plain serializable object. It survives being written to
* sessionStorage between turns, and it holds no component state, so the
* conversation can be resumed exactly where it stopped.
* WHAT IS LEFT HERE. The two entry points the panel calls, bound to the posting
* registry. The outcome renderers moved into that registry with everything else
* that knows what a job posting is; they are re-exported below because the
* panel and its tests have always imported them from here.
*
* Nothing here writes. The last step returns a draft, and the panel creates the
* position with the same mutation the form uses.
*/
/* ── Reading answers ────────────────────────────────────────────────────── */
/** Title Case, for the free-text answers that name a proper noun. */
const titleCase = (value) => value.replace(/\b\w/g, (c) => c.toUpperCase());
/** A short free-text answer, stripped of the words around it. */
function cleanPhrase(answer, max = 60) {
const value = String(answer)
.trim()
.replace(/^(?:in|at|around|near|it is|its|it's)\s+/i, '')
.replace(/[.!?,;]+$/, '')
.replace(/\s+/g, ' ');
return value && value.length <= max ? value : null;
}
/** "Skip" is an answer to an optional question — it settles it, unanswered. */
const SKIP = /^(?:skip|skip this|skip it|no preference|not sure|does ?n(?:'|o)t matter|any|none of these)$/i;
/** A chip that means "let me type it" rather than an answer in itself. */
const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere else)$/i;
/**
* What each field means: when it is already settled, how an answer is read, and
* how it reads back.
*
* `parse` returns a patch for the draft, or `null` when the answer was not
* understood — which re-asks with `retry` rather than storing a guess.
*/
const FIELDS = {
/**
* The client this role is being staffed for.
*
* A "client" is not a record of its own in this product — it is the `company`
* on the position, which is the field the Create Position form writes, the
* Positions card leads with, and Hired History reports against. So the
* conversation collects it into the same field rather than into a store of
* its own, and asking Owliver to create a client starts here.
*/
company: {
label: 'Company',
settled: (draft) => Boolean(String(draft.company || '').trim()),
retry: 'Type the client or company name — "Fairmont San Jose".',
parse: (answer) => {
const value = cleanPhrase(answer, 60);
return value && value.length > 1 ? { company: titleCase(value) } : null;
},
summary: (draft) => (draft.company ? `Company: ${draft.company}` : null),
},
role_category: {
label: 'Role',
settled: (draft) => Boolean(draft.title),
retry: 'Name the role — "bartender", "line cook", "event staff".',
parse: (answer, { roles }) => {
const role = extractRole(answer, roles) || cleanPhrase(answer, 40);
if (!role || role.length < 2) return null;
const known = roles.find((r) => String(r).toLowerCase() === role.toLowerCase());
/* A known category files the position; anything else becomes the title and
leaves the category at its default, exactly as the form behaves. */
return known
? { role_category: known, title: known }
: { title: titleCase(role) };
},
summary: (draft) => (draft.role_category && draft.role_category !== draft.title
? `${draft.title} · ${draft.role_category}`
: draft.title),
},
location: {
label: 'Location',
settled: (draft) => Boolean(draft.location),
retry: 'Type the city or area this role is based in.',
parse: (answer) => {
if (FREE_TEXT.test(String(answer).trim())) return null;
const value = extractLocation(answer) || cleanPhrase(answer);
return value ? { location: titleCase(value) } : null;
},
summary: (draft) => draft.location,
},
pay: {
label: 'Pay',
settled: (draft) => Number(draft.pay_range_min) > 0 || Number(draft.pay_range_max) > 0,
retry: 'Give a range like "$28–$36/hr", or a single rate.',
parse: (answer) => {
const pay = extractPay(answer);
return pay ? { pay_range_min: String(pay.min), pay_range_max: String(pay.max) } : null;
},
summary: (draft) => payLabel(draft),
},
min_experience_years: {
label: 'Experience',
settled: (draft) => draft.min_experience_years !== undefined && draft.min_experience_years !== null,
retry: 'How many years — or "no minimum".',
parse: (answer) => {
const years = extractExperience(answer);
if (years !== null) return { min_experience_years: years };
const bare = /^(\d{1,2})\s*\+?$/.exec(String(answer).trim());
return bare ? { min_experience_years: Number(bare[1]) } : null;
},
summary: (draft) => (draft.min_experience_years
? `${draft.min_experience_years}+ years experience`
: 'No minimum experience'),
},
english_required: {
label: 'English',
settled: (draft) => Boolean(draft.english_required),
retry: `One of ${ENGLISH_LEVELS.map((l) => l.label).join(', ')}.`,
parse: (answer) => {
const level = extractEnglish(answer)
|| ENGLISH_LEVELS.find((l) => l.label.toLowerCase() === String(answer).trim().toLowerCase())?.value;
return level ? { english_required: level } : null;
},
summary: (draft) => {
const level = ENGLISH_LEVELS.find((l) => l.value === draft.english_required);
return level ? `English: ${level.label}` : null;
},
},
certifications_required: {
label: 'Certifications',
settled: (draft) => Array.isArray(draft.certifications_required),
retry: 'Name a certification, or "none".',
parse: (answer) => {
const found = extractCertifications(answer);
if (found) return { certifications_required: found };
if (/^(?:none|no|no certifications?)$/i.test(String(answer).trim())) {
return { certifications_required: [] };
}
return null;
},
summary: (draft) => (draft.certifications_required?.length
? `Certifications: ${draft.certifications_required.join(', ')}`
: null),
},
};
/* ── Suggestions ────────────────────────────────────────────────────────── */
/**
* A step's chips.
*
* `@`-prefixed entries in the skill file resolve to the lists the application
* already owns, so the roles offered here are the roles the form offers and a
* certification added to the record's options appears in the conversation
* without this file or the skill file changing.
*/
function suggestionsFor(step, { roles }) {
const resolved = step.options.flatMap((option) => {
if (option === '@roles') return roles;
if (option === '@english') return ENGLISH_LEVELS.map((l) => l.label);
if (option === '@certifications') return CERT_OPTIONS;
return [option];
});
const capped = [...new Set(resolved)].slice(0, 6);
/* An optional question needs a way past it that is not a typed sentence. */
if (!step.required) capped.push('Skip');
return capped.map((label) => ({ label, prompt: label }));
}
/* ── Flow state ─────────────────────────────────────────────────────────── */
/** The steps a skill declares, keeping only the fields this module understands. */
const stepsOf = (skill) => (skill?.conversation || []).filter((s) => FIELDS[s.field]);
/** Is this field answered, or deliberately passed over? */
const isSettled = (flow, field) => FIELDS[field].settled(flow.draft) || flow.skipped.includes(field);
/** The next question, or `null` when there is nothing left to ask. */
function nextStep(flow, steps) {
if (flow.editing) return steps.find((s) => s.field === flow.editing) || null;
return steps.find((s) => !isSettled(flow, s.field)) || null;
}
/** Everything decided so far, one line each, in the order it is asked. */
function summaryLines(flow, steps) {
return steps
.filter((s) => isSettled(flow, s.field))
.map((s) => FIELDS[s.field].summary(flow.draft))
.filter(Boolean);
}
/** The required fields a position cannot be created without. */
const missingRequired = (flow, steps) => steps.filter((s) => s.required && !FIELDS[s.field].settled(flow.draft));
/* ── Replies ────────────────────────────────────────────────────────────── */
/** Ask the next question, or read the position back when there is none left. */
function ask(flow, steps, { roles, preamble = null, retry = null }) {
const step = nextStep(flow, steps);
if (!step) return review(flow, steps);
return {
flow: { ...flow, stage: 'collect', step: step.field },
doc: doc(
preamble ? text(preamble) : null,
preamble && summaryLines(flow, steps).length ? list(summaryLines(flow, steps)) : null,
text(step.question),
retry ? note(retry) : null
),
followUp: suggestionsFor(step, { roles }),
};
}
/** The whole position, before anything is written. */
function review(flow, steps) {
const missing = missingRequired(flow, steps);
/* Only reachable if a required answer was cleared — ask for it rather than
offering to create something incomplete. */
if (missing.length) {
return {
flow: { ...flow, stage: 'collect', step: missing[0].field, editing: null },
doc: doc(text(missing[0].question)),
followUp: [],
};
}
return {
flow: { ...flow, stage: 'review', step: null, editing: null },
doc: doc(
text('Ready to create this position?'),
list(summaryLines(flow, steps)),
note('Nothing is saved until you choose one. Save as Draft keeps it unpublished — the same as the button on the form.')
),
/* The two the form offers, in the same words and the same order, so the
conversation and the page commit a position the same two ways. */
followUp: [
{ label: 'Save as Draft', prompt: 'Save as draft' },
{ label: 'Publish Job Posting', prompt: 'Publish job posting' },
{ label: 'Change details', prompt: 'Change details' },
],
};
}
/** Which detail to change — the answers already given, as chips. */
function changeMenu(flow, steps) {
const settled = steps.filter((s) => isSettled(flow, s.field));
return {
flow: { ...flow, stage: 'change', step: null },
doc: doc(text('What should I change?')),
followUp: settled.map((s) => ({ label: FIELDS[s.field].label, prompt: FIELDS[s.field].label })),
};
}
/* ── Entry points ───────────────────────────────────────────────────────── */
/**
* Start collecting, from whatever the request already said.
*
* "Create a bartender position in Chennai paying $30–$40/hr" answers three
* questions before the first one is asked, and those are not asked again.
*/
export function beginPositionFlow({ question, skill, roles = [] }) {
const { prefill } = buildPositionPrefill(question, roles);
const steps = stepsOf(skill);
/** Start collecting, from whatever the request already said. */
export const beginPositionFlow = ({ question, skill, roles = [], companies = [] }) => beginFlow({
registry: positionRegistry, question, skill, ctx: { roles, companies },
});
const flow = {
skillId: skill.id,
draft: prefill,
skipped: [],
editing: null,
stage: 'collect',
step: null,
};
/** One answer, and whatever it makes the next thing to say. */
export const advancePositionFlow = ({ flow, answer, skill, roles = [], companies = [] }) => advanceFlow({
registry: positionRegistry, flow, answer, skill, ctx: { roles, companies },
});
const known = summaryLines(flow, steps);
return ask(flow, steps, {
roles,
preamble: known.length ? `Got it — here is what I have so far:` : null,
});
}
/**
* One answer, and whatever it makes the next thing to say.
*
* Returns `{ flow, doc, followUp }`, and on the confirmation step additionally
* `create: { draft }` — the panel writes it, this module does not.
*/
export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
const steps = stepsOf(skill);
const said = String(answer).trim();
/* A way out that does not require finishing. `flow: null` ends it, and the
next question is answered by the page as usual. */
if (/^(?:cancel|stop|never ?mind|nevermind|forget it|quit|exit)$/i.test(said)) {
return {
flow: null,
doc: doc(
text('Stopped — nothing was created.'),
note('Ask me to create a position whenever you are ready.')
),
followUp: [],
};
}
/* The confirmation step. "Create position" is the only path to a record. */
if (flow.stage === 'review') {
/* Saving unpublished. The same write, with the status the form's own
"Save as Draft" button sets — one create path, two statuses. */
if (/^(?:save as draft|save draft|draft|save it as a draft)$/i.test(said)) {
if (missingRequired(flow, steps).length) return review(flow, steps);
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'draft' } };
}
if (/^(?:create position|publish job posting|publish|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) {
if (missingRequired(flow, steps).length) return review(flow, steps);
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'active' } };
}
if (/^(?:change|change details|edit|change something|no)$/i.test(said)) return changeMenu(flow, steps);
/* Anything else at the confirmation step is a correction stated outright —
"make it Bengaluru", "$32–$40". Read it against every field and apply
what it settles, rather than making the user find the menu. */
const revised = applyStatement(flow, steps, said, roles);
if (revised) return review(revised, steps);
return {
...review(flow, steps),
doc: doc(
text('I did not catch that. Ready to create this position?'),
list(summaryLines(flow, steps)),
note('Choose Save as Draft or Publish Job Posting, or tell me what to change.')
),
};
}
/* Choosing which detail to revisit. */
if (flow.stage === 'change') {
const target = steps.find((s) => FIELDS[s.field].label.toLowerCase() === said.toLowerCase());
if (!target) {
const revised = applyStatement(flow, steps, said, roles);
if (revised) return review(revised, steps);
return changeMenu(flow, steps);
}
return ask({ ...flow, editing: target.field }, steps, { roles });
}
/* Answering the question that was asked. */
const step = steps.find((s) => s.field === flow.step) || nextStep(flow, steps);
if (!step) return review(flow, steps);
const field = FIELDS[step.field];
if (SKIP.test(said) && !step.required) {
const next = { ...flow, skipped: [...flow.skipped, step.field], editing: null };
return ask(next, steps, { roles });
}
const patch = field.parse(said, { roles });
if (!patch) {
/* Not an answer to this question — but it may still be a fact about the
position ("in Chennai" while being asked for pay). Take it if so. */
const revised = applyStatement(flow, steps, said, roles);
if (revised) return ask(revised, steps, { roles });
return ask(flow, steps, { roles, retry: field.retry });
}
const next = {
...flow,
draft: { ...flow.draft, ...patch },
skipped: flow.skipped.filter((f) => f !== step.field),
editing: null,
};
/* A detail revisited from the change menu goes straight back to the summary
rather than walking the rest of the questions again. */
if (flow.editing) return review(next, steps);
return ask(next, steps, { roles });
}
/**
* A sentence read against every field at once.
*
* This is what makes the conversation forgiving: an answer that arrives out of
* order, or a correction stated rather than chosen from a menu, lands in the
* right field instead of being rejected for not answering the question asked.
* Returns an updated flow, or `null` when the sentence settles nothing.
*/
function applyStatement(flow, steps, said, roles) {
const { prefill } = buildPositionPrefill(said, roles);
const fields = new Set(steps.map((s) => s.field));
const patch = {};
/* Step names, not record fields — a step the sentence settled must come off
the skipped list so the summary shows it. */
const touched = new Set();
if (fields.has('role_category') && prefill.title) {
if (prefill.role_category) patch.role_category = prefill.role_category;
patch.title = prefill.title;
touched.add('role_category');
}
if (fields.has('location') && prefill.location) {
patch.location = prefill.location;
touched.add('location');
}
if (fields.has('pay') && prefill.pay_range_min) {
patch.pay_range_min = prefill.pay_range_min;
patch.pay_range_max = prefill.pay_range_max;
touched.add('pay');
}
if (fields.has('min_experience_years') && prefill.min_experience_years !== undefined) {
patch.min_experience_years = prefill.min_experience_years;
touched.add('min_experience_years');
}
if (fields.has('english_required') && prefill.english_required) {
patch.english_required = prefill.english_required;
touched.add('english_required');
}
if (fields.has('certifications_required') && prefill.certifications_required) {
patch.certifications_required = prefill.certifications_required;
touched.add('certifications_required');
}
if (!touched.size) return null;
return {
...flow,
draft: { ...flow.draft, ...patch },
skipped: flow.skipped.filter((f) => !touched.has(f)),
editing: null,
};
}
/* ── Outcomes ───────────────────────────────────────────────────────────── */
/** The position exists. Said plainly, with what was created. */
export function positionCreatedReply(position) {
const isDraft = position.status === 'draft';
return doc(
/* Draft and published are different outcomes, so they are named
differently: one was saved, the other went live. */
text(isDraft ? 'Saved as a draft.' : 'Position published successfully.'),
list([
position.company,
position.title,
position.location,
payLabel(position),
].filter(Boolean)),
note(isDraft
? 'It is on the Positions list as a draft — nobody can apply until it is published, and it stays a draft until you publish it.'
: 'It is on the Positions list now — applications will start appearing against it.')
);
}
/**
* The write failed. The draft is kept, so the answers are not lost.
* Re-exported from the registry, where they now live beside the field table.
*
* The server's own message is said out loud when there is one. This used to be
* a fixed sentence, and a fixed sentence is the wrong answer to three different
* failures: a validation error naming a field, a permission refusal, and an API
* that is not running all read as "I could not create that position", leaving
* the reader to guess which of the three they are looking at and what to change.
*
* The message comes from `KrowApiError.message`, which `httpClient` sets to the
* server's wording verbatim — so the reason is the API's, not one invented here
* from a status code.
* The panel reads these off `registry.outcome` and no longer names a position
* to render one. These names stay so that nothing importing them has to change
* in the same commit as the split.
*/
export function positionFailedReply(reason = null) {
const said = String(reason || '').trim();
return doc(
text('I could not create that position.'),
said ? note(said) : null,
note('Nothing was saved. Choose Create position to try again.')
);
}
/**
* What the panel offers after a position is created.
*
* A published role has an obvious next question — who can fill it — so it is
* offered here rather than left to be typed. The chip carries the position's
* own title and the wording the workforce engine already answers, so it is an
* ordinary question resolved by the path that was already there: no handler,
* no navigation, and the same answer as asking it by hand.
*/
export const createdFollowUp = (position) => (position.status === 'draft'
/**
* A saved draft offers no action, and that is the fix.
*
* This used to offer "Continue to save", routing back to the Create Position
* form. It made the completed state look unfinished: the reader had just been
* told the position was saved, and was immediately asked to save it again —
* by a button that put them back in the form they had just left. The write
* has happened, the record exists with `status: draft`, and the way to finish
* a draft later is its own card on the Positions list.
*/
? []
: [
{ label: 'View position', route: `/admin/positions/${position.id}` },
{ label: 'Match candidates', prompt: `Who matches ${position.title}?` },
]);
export const {
created: positionCreatedReply,
failed: positionFailedReply,
followUp: createdFollowUp,
} = positionRegistry.outcome;

View File

@@ -427,6 +427,16 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
capabilities: sectionBullets(body, 'Capabilities'),
purpose: sectionBullets(body, 'Purpose'),
conversation,
/**
* Which conversation registry reads this skill's questions.
*
* Data, not a branch. `routing.js` used to name `create-position` in an
* `if`, which meant a second conversational skill was a code change in
* the router rather than a file on disk — the same shape §3 rules out for
* agents, one level down. A skill with a `## Conversation` block and no
* `flow:` defaults to `position`, so nothing that shipped changes.
*/
flow: data.flow ? String(data.flow) : (conversation.length ? 'position' : null),
path,
body,
custom,

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

@@ -524,6 +524,20 @@ export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label ||
* position id, a candidate id, or nothing. A source is resolved by
* `dataResolver.js`; a definition cannot reach a store directly, cannot write,
* and cannot name a field that is not offered here.
*
* **`series`** is the optional half that says what a reading's figures *mean*,
* as one of the closed kinds in `SERIES_KINDS` — `periodic` (ordered in time),
* `cumulative` (each step drawn from the one before) or `parts` (disjoint
* shares of one whole). `shapes` answers whether a component *can* draw a
* reading; this answers whether doing so would be true, and it is what refuses
* a pie chart of a timeline.
*
* It is declared only where the reading's own definition already states the
* meaning, and it is deliberately absent from most of them. An absent `series`
* is "not established", and a component that has declared which meanings it
* draws refuses rather than guesses — which is the safe direction. Adding one
* is a statement about what the resolver actually returns, so it is added when
* that is known and not before.
*/
export const DATA_SOURCES = [
{
@@ -532,6 +546,9 @@ export const DATA_SOURCES = [
context: 'positionId',
summary: 'Applications to this position, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
/* "counted over time": the resolver buckets by period and returns
the buckets in order, so the order is part of the reading. */
series: 'periodic',
options: ['periods'],
},
{
@@ -540,6 +557,9 @@ export const DATA_SOURCES = [
context: 'positionId',
summary: 'Applied → screened → shortlisted → interviewed → hired.',
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
/* A funnel: everyone screened applied first, so the stages overlap
and do not add up to a whole. */
series: 'cumulative',
},
{
id: 'position.candidates',
@@ -594,6 +614,9 @@ export const DATA_SOURCES = [
context: null,
summary: 'Every candidate, counted by stage.',
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
/* The same funnel across the workspace, and cumulative for the same
reason. */
series: 'cumulative',
},
{
id: 'candidates.activity',
@@ -601,6 +624,8 @@ export const DATA_SOURCES = [
context: null,
summary: 'Applications across the workspace, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
/* "counted over time", as above. */
series: 'periodic',
options: ['periods'],
},
{
@@ -738,6 +763,9 @@ export const DATA_SOURCES = [
context: null,
summary: 'Events by type and by account.',
shapes: ['stats', 'table', 'list', 'progress', 'flow', 'card'],
/* Every event has exactly one type and one account, so counting by
either partitions the same total: these are shares of one whole. */
series: 'parts',
options: ['periods', 'limit'],
},
{

View File

@@ -42,6 +42,18 @@ export const TOOLS = [
/* Writes a record other people will act on. Always confirmed. */
requiresApproval: true,
},
{
name: 'create_employee_role',
label: 'Create employee role',
summary: "Records what a worker declares they do — role, experience, desired pay and availability — from a conversation.",
params: ['draft', 'status'],
readOnly: false,
mutates: 'EmployeeRole',
/* Writes a record ABOUT SOMEBODY ELSE, which is the stronger case for a
confirmation rather than the weaker one: the person it names is not the
person approving it. */
requiresApproval: true,
},
{
name: 'open_create_skill_training',
label: 'Open Add Skill Training',
@@ -175,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 };
}

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

@@ -0,0 +1,294 @@
/**
* 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 } from '@/lib/skills/surfaces';
import { kindOfBinding, labelOfBinding, seriesFor } from './series';
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.
*/
/**
* Last, the name of what it is reading.
*
* Identity has to survive a replacement. A built-in section carries its
* name in its type label — "Hiring activity" — and replacing it with a
* chart left a node whose only name was "Bar chart", so the very phrase
* that had just worked stopped resolving and "change it back" answered
* that there was no such thing on the page.
*
* The binding is the continuous fact across a replacement: the node is
* still reading the same series, and the series has a name. Using it is
* not a fallback invented for charts — a node is named by what it shows,
* which is how a person refers to it either way.
*/
title: String(node.props?.title || '').trim()
|| (typeof entry?.describe === 'function' ? String(entry.describe(node) || '').trim() || null : null)
|| (node.data ? String(labelOfBinding(node.data) || '').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,
/**
* The binding, in whichever of the two kinds it is.
*
* A described node is what the conversation and the editor both reason
* about, so it has to carry enough to answer "what can this become" — and
* that now includes a page's own series. Projected rather than passed
* through so a consumer still cannot reach a resolver from here.
*/
data: node.data
? {
source: node.data.source || null,
series: node.data.series || null,
label: labelOfBinding(node.data),
kind: kindOfBinding(node.data),
params: node.data.params || {},
known: node.data.series
? Boolean(seriesFor(node.data.series))
: 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();
/* Named by the reading when it has one, for the same reason `describeNode`
is: it is what a person calls the thing, and it survives a change of
component. */
const reading = node.data ? String(labelOfBinding(node.data) || '').trim() : '';
const bits = [
`${' '.repeat(depth)}${title || reading || entry?.label || node.type}`,
`(${node.id})`,
node.hidden ? '· hidden' : '',
reading && reading !== (title || reading) ? `· ${reading}` : '',
].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);
/* `labelOfBinding`, not `dataSourceLabel`: a node bound to a page's own
series has no source id, and scoring only the source made every such
node unfindable by the name of the thing it draws. */
const source = canon(node.data ? labelOfBinding(node.data) : '');
const id = canon(node.id);
let score = 0;
/* 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,
}));
}

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

@@ -0,0 +1,884 @@
/**
* 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';
import { kindOfBinding, labelOfBinding, refuseSeries } from './series';
/** 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'] },
/**
* "I don't like this. What else could it be?"
*
* Before `replace`, because the sentences overlap — "show me other options"
* and "show me some other designs" both contain words `replace` and `show`
* would claim — and only this one is a question rather than an instruction.
*
* Every phrase is two words or more on purpose. A bare "options" is what a
* person says about a position or a shift, and stealing it would turn an
* ordinary question into a layout answer.
*/
{ op: 'options', words: [
'other options', 'other option', 'more options', 'some other options',
'other designs', 'another design', 'different design', 'other design',
'alternatives', 'alternative', 'other ways', 'another way', 'different way',
'other visualisations', 'other visualizations', 'other visualisation',
'other visualization', 'another visualisation', 'another visualization',
'different visualisation', 'different visualization',
'other look', 'another look', 'different look',
'what else can', 'what else could', 'what else is', 'something else',
] },
{ op: 'replace', words: ['change', 'turn', 'switch', 'convert', 'make it a', 'show as', 'show it as'] },
{ op: 'add', words: ['add', 'insert', 'create a', 'put a new'] },
{ op: 'layout', words: ['columns', 'column', 'side by side', 'two up', 'wider', 'narrower'] },
/**
* 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;
/**
* The words that mean "the thing we were just talking about".
*
* Deliberately a closed list of pronouns and demonstratives, and deliberately
* not a general reference resolver. A phrase carrying one of these, that names
* nothing else on the page, is asking about whatever was last acted on — and
* saying so out loud is what makes the fallback safe: a request that *does*
* name something is never redirected, and a request that names nothing and
* says nothing pronominal is still unknown.
*
* "back" and "again" are here because "change it back" and "do that again" are
* how the follow-up is actually said.
*/
const ANAPHORA = new Set([
'it', 'this', 'that', 'these', 'those', 'them', 'same',
'back', 'again', 'instead',
]);
/** Does this phrase refer to something already under discussion? */
const refersBack = (text) => text.split(' ').some((word) => ANAPHORA.has(word));
const NUMBER_WORDS = { one: 1, two: 2, three: 3, four: 4, six: 6, twelve: 12 };
/**
* 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,
/**
* The node this conversation last acted on, if any.
*
* The whole of "it". A conversation about a page has a subject, and asking a
* person to re-name it in every sentence is not how anyone speaks — "change
* Hiring activity to a bar chart" followed by "change it back to a line
* chart" is one thought in two sentences, and the second one used to be
* answered with "I could not find that on this page."
*
* An id, and only an id: the caller records which node an operation named
* and hands it back next turn. Nothing is remembered here, nothing is
* inferred from the model, and a focus that is no longer in the tree simply
* does not resolve.
*/
focus = null,
} = {}) {
const text = canon(question);
if (!text) return null;
/**
* The verb, from the table — or from the registry's own nouns.
*
* "I want a different chart" asks the same question as "show me other
* options" and shares not one word with it. The phrase table cannot grow to
* cover it without writing component names into the language layer, so the
* second reading derives the noun from the registry instead. See
* `asksToRedraw`.
*/
const verb = VERBS.find((v) => has(text, ...v.words))
|| (asksToRedraw(text, registry) ? VERBS.find((v) => v.op === 'options') : null);
if (!verb) return null;
/**
* 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);
/**
* "Show me something else" is asked before the interface-words gate.
*
* The gate wants a request to name a node, a registered type, or a word like
* "section" — and the sentence people actually type names none of the three.
* "I don't like this design. Show me other options." was refused by the gate
* and fell through to the model, which is exactly the wrong place for it: the
* answer is a list of registered components, and a model does not have one.
*
* Safe to run early because this verb decides for itself whether it has a
* subject, and returns null when it does not — so "what are my options for
* this position?" is still nobody's layout request.
*/
if (verb.op === 'options') return planOptions(text, tree, registry, page, focus);
const named = namedType(text, registry, page);
const mentionsUi = has(text, ...UI_WORDS) || Boolean(named);
const anyTarget = walk(tree).some((node) => resolveTarget(tree, text, { registry }).length > 0);
/* A phrase that refers back to what was just changed names its subject
without naming it, so the focus is evidence in its own right. */
const carriesOn = Boolean(focus) && refersBack(text);
if (!mentionsUi && !anyTarget && !carriesOn) return null;
switch (verb.op) {
case 'hide': return planVisibility(text, tree, registry, true, focus);
case 'move': return planMove(text, tree, registry);
case 'replace': return planReplace(text, tree, registry, page, focus);
case 'add': return planAdd(text, tree, registry, named, role, page);
case 'layout': return planLayout(text, tree, registry, focus);
case 'present': return planPresent(text, tree, registry, focus);
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, focus = null } = {}) {
const all = resolveTarget(tree, text, { registry })
.filter((node) => !exclude.includes(node.id));
/* "Bring back the timeline" means the hidden one, when a hidden one fits.
Only a preference: with nothing hidden, the phrase still resolves. */
const hiddenOnly = all.filter((node) => node.hidden);
const hits = preferHidden && hiddenOnly.length ? hiddenOnly : all;
/**
* Nothing named, but something referred to.
*
* The last resort, and it is fenced on three sides: the phrase has to carry
* a pronoun or a demonstrative, the caller has to have recorded a subject,
* and that subject has to still be on the page. Any of the three missing and
* this is an unknown target exactly as before.
*
* It runs only when the words resolved to nothing, so a request that names a
* section is never quietly redirected to a different one — which would be the
* failure this whole resolver exists to prevent, reintroduced by the back
* door.
*/
if (!hits.length && focus && refersBack(text) && !exclude.includes(focus)) {
const carried = resolveTarget(tree, focus, { registry }).find((node) => node.id === focus);
if (carried) return { node: carried };
}
if (!hits.length) return { kind: 'unknown', phrase: text };
/**
* 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, focus = null) {
const found = target(text, tree, registry, { focus });
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;
}
/**
* What this node could be shown as, and what to call it.
*
* The single place either half of the product asks the question. `planReplace`
* uses it to check one answer and `planOptions` to list them all, so a type
* offered in a list can never be one the same request would refuse — which is
* the bug that two copies of this rule would eventually produce.
*/
function alternatives(node, registry, page) {
const binding = node.data || null;
const shapes = binding?.source ? dataSourceFor(binding.source)?.shapes || [] : null;
const types = registry.replacements(node.type, {
shapes,
page,
seriesKind: kindOfBinding(binding),
seriesBound: Boolean(binding?.series),
/* Whether there is a reading at all — which is what tells the registry
apart "nothing to be wrong about" from "a reading that never said what
it means". */
bound: Boolean(binding),
});
return { binding, types, entries: types.map((type) => registry.get(type)).filter(Boolean) };
}
/**
* "Show me something else" — answered from the registry, never invented.
*
* This is the request that most invites a model to make something up, so it is
* the one most firmly deterministic: the answer is the same list `planReplace`
* would check a single name against, in the same order, produced by the same
* function. Nothing here proposes a design; it reports which registered
* components can honestly draw what this node is already reading.
*
* The target is the harder half, because the sentence rarely names one — "I
* don't like this, show me other options" names nothing at all. So: resolve it
* from the words if the words say; otherwise let the *tree* answer, and only
* when the tree's answer is unambiguous. One node with alternatives is the
* subject. Several is a question back, never a guess.
*/
function planOptions(text, tree, registry, page = null, focus = null) {
/* "An alternative candidate" is not a layout request, whatever else the
sentence contains. Checked first, so no amount of page state can turn it
into one. */
if (qualifiedElsewhere(text, registry)) return null;
const named = target(text, tree, registry, { focus });
const subject = named.kind ? null : named.node;
if (!subject) {
const changeable = walk(tree)
.filter((node) => !node.hidden)
.map((node) => describeNode(node, { registry }))
.filter((node) => node.capabilities.includes('replace'))
.filter((node) => alternatives(node, registry, page).entries.length > 0);
/**
* Nothing on this page could be drawn another way.
*
* Answered by *not* answering. This verb now runs before the
* interface-words gate, so it sees sentences that were never about the
* layout, and the only honest thing to do with one of those, on a page with
* nothing to offer, is leave it to whoever else can answer it. It used to
* say "Nothing on this page can be drawn another way yet." — a true
* sentence, and the wrong reply to a question about candidates.
*/
if (!changeable.length) return null;
/* One thing it could be about is not ambiguity; several is a question. */
if (changeable.length > 1) {
return { kind: 'ambiguous', candidates: changeable.slice(0, 4) };
}
return offer(changeable[0], registry, page);
}
if (!subject.capabilities.includes('replace')) {
return { kind: 'refused', message: `\`${subject.title || subject.label}\` cannot be drawn another way.` };
}
return offer(subject, registry, page);
}
/**
* "A different chart", "another table" — with the nouns read from the registry.
*
* The phrase list above cannot cover this, because the noun a person reaches
* for is the name of a kind of component, and writing those out here would put
* component names back into the language layer — the one thing this file is
* checked for. So the nouns are derived: the last word of every registered
* type's own label, which is "chart" for five of them and "table", "card" or
* "timeline" for the others. A type registered tomorrow is askable for by its
* own noun, tomorrow, with this unchanged.
*/
function asksToRedraw(text, registry) {
return [...redrawNouns(registry)].some((noun) => (
new RegExp(`\\b(another|different|other)\\s+(?:\\w+\\s+)?${noun}\\b`).test(text)
));
}
/**
* The nouns a person can ask for another *of*.
*
* The last word of every registered type's own label — "chart", "table",
* "card", "timeline" — so the vocabulary is the registry's rather than a list
* kept here. Page-bound types are left out on purpose: their labels are the
* names of readings ("Hiring activity", "Position candidates"), and "another
* candidate" is a question about people, not about panels.
*/
function redrawNouns(registry) {
return new Set(
registry.all()
.filter((entry) => !entry.page)
.map((entry) => canon(entry.label).split(' ').pop())
.filter((noun) => noun && noun.length > 2)
);
}
/** The words a bare "another" is about when it is about the interface. */
const REDRAW_WORDS = [
'design', 'designs', 'look', 'looks', 'option', 'options', 'way', 'ways',
'alternative', 'alternatives', 'version', 'versions', 'one', 'ones',
'visualisation', 'visualisations', 'visualization', 'visualizations',
];
/** The words that carry no evidence about what is being asked for. */
const FILLER = new Set([
'the', 'this', 'that', 'for', 'and', 'with', 'from', 'some', 'any', 'you',
'can', 'could', 'are', 'was', 'all', 'out', 'into', 'please', 'give', 'show',
'want', 'like', 'have', 'use', 'about', 'here',
]);
/**
* Is the "other" in this sentence about something that is not the interface?
*
* The one test that keeps "give me an alternative candidate" a question about
* people. Both sentences say "alternative"; the difference is the noun that
* follows it, and it is decided by looking rather than by guessing:
*
* - nothing follows → about the interface ("give me alternatives")
* - one of the interface words → about the interface ("other options")
* - a registered type's noun → about the interface ("a different chart")
* - anything else → not ours, and left entirely alone
*
* A false positive here costs a person a wrong answer about their candidates,
* which is worse than a layout question going unanswered — so the doubtful case
* falls through rather than being claimed.
*/
function qualifiedElsewhere(text, registry) {
const ours = new Set([...REDRAW_WORDS, ...UI_WORDS, ...redrawNouns(registry)]);
const words = text.split(' ');
return words.some((word, i) => {
if (!['other', 'another', 'different', 'alternative', 'alternatives'].includes(word)) {
return false;
}
const following = words.slice(i + 1, i + 3)
.filter((next) => next.length > 2 && !FILLER.has(next));
return following.length > 0 && !ours.has(following[0]);
});
}
/** The list, or the reason there is not one. */
function offer(node, registry, page) {
const { binding, entries } = alternatives(node, registry, page);
if (!entries.length) {
return {
kind: 'refused',
message: `There is no other way to draw ${binding ? labelOfBinding(binding) : node.title || node.label} yet.`,
};
}
return {
kind: 'options',
node,
subject: binding ? labelOfBinding(binding) : node.title || node.label,
/* Four, because a choice a person reads at a glance is a choice they make.
The cap is on what is offered, never on what is possible — the operation
path accepts any registered type the same rule allows. */
options: entries.slice(0, 4).map((entry) => ({
type: entry.type, label: entry.label, summary: entry.summary,
})),
};
}
/** Turn one node into another type. */
function planReplace(text, tree, registry, page = null, focus = 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), ' ');
/**
* "Change **it** back to a line chart."
*
* The half of the sentence before the connector is a pronoun, which names
* nothing and resolves to nothing — so the subject is whatever this
* conversation last changed. That is the ordinary reading of the sentence,
* and producing "I could not find that on this page" instead was the gap.
*/
const found = target(subject, tree, registry, { focus });
if (found.kind) return found;
const node = found.node;
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 { binding, entries } = alternatives(node, registry, page);
if (!entries.some((entry) => entry.type === named.type)) {
/**
* Refused, with the reason and the way forward.
*
* A bare no is the worst of the three things this could say. The reason
* comes from what the reading *means* — computed once, in `series.js`, so
* the editor's refusal and this one are the same sentence — and the
* alternatives are the very list the next question would produce.
*/
const why = binding
? refuseSeries(named, binding)
: `\`${node.label}\` cannot become a ${named.label}.`;
return {
kind: 'refused',
message: why || (binding?.source
? `\`${dataSourceLabel(binding.source)}\` cannot be shown as a ${named.label}.`
: `\`${node.label}\` cannot become a ${named.label}.`),
node,
alternatives: entries.map((entry) => ({ type: entry.type, label: entry.label })),
};
}
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, focus = null) {
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, { focus });
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, focus = null) {
const digit = /(\d+)\s*(?:column|col)/.exec(text)?.[1];
const word = Object.keys(NUMBER_WORDS).find((w) => has(text, `${w} column`));
const columns = Number(digit) || NUMBER_WORDS[word] || (has(text, 'side by side', 'two up') ? 2 : null);
if (!columns) return { kind: 'unknown', phrase: text };
const found = target(text, tree, registry, { focus });
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,
};
}

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

@@ -0,0 +1,351 @@
/**
* 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'];
/**
* What a numeric series *means*, as a closed vocabulary.
*
* Shape and meaning are different questions and the engine had only the first.
* `flow` says a reading is a labelled numeric series; it does not say whether
* the labels are days, funnel stages or disjoint categories — and that is
* exactly the difference between a pie chart that is true and one that is a
* lie. `SECTION_TYPES` even admits it: a flow is "a sequence of stages **or**
* periods".
*
* - `periodic` points ordered in time. The order carries the meaning and
* the values do not add up to a whole.
* - `cumulative` a funnel: each step is a subset of the one before it, so
* the steps overlap and summing them counts people twice.
* - `parts` disjoint categories that together make up one total. The
* only meaning for which "share of the whole" is true.
*
* A type declares which of these it can honestly draw (`seriesKinds`); a
* binding is asked what it is. Neither is inferred from a component name, and a
* binding that cannot say stays unknown — which leaves it exactly as permissive
* as it was before this vocabulary existed, rather than guessing.
*/
export const SERIES_KINDS = ['periodic', 'cumulative', 'parts'];
/**
* A node, with every field settled.
*
* 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;
/**
* A page's own series, rather than a skill's reading.
*
* The second kind of binding, and the reason built-in charts can be changed
* at all. A page section's figures are not in the skill data vocabulary —
* they are derived by the page from what it already loaded — so a node that
* draws them can only name them, the same way a skill node names a source.
* Which one a binding is, is decided by which key it carries; both are
* resolved through a registry and neither can carry a value.
*/
const series = String(value.series ?? '').trim();
if (series) return { series, params: plainObject(value.params) };
const source = String(value.source ?? '').trim();
if (!source) return null;
return { source, params: plainObject(value.params) };
}
/**
* 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);

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

@@ -0,0 +1,497 @@
/**
* 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 { refuseSeries } from './series';
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}.`);
}
}
/**
* A page's own series is compatible with the types that draw one.
*
* The structural question for this kind of binding is not "which shapes"
* — a page series is not in the shape vocabulary and should not be — it is
* whether the candidate draws a series at all, which is what a non-empty
* `seriesKinds` says.
*/
if (binding?.series && !entry.seriesKinds.length) {
return fail(nodes, `\`${entry.label}\` cannot draw a series.`);
}
/**
* And the semantic question, for either kind of binding.
*
* Refused by what the reading *is*, not by what the component is called, so
* the sentence a person gets back tells them why rather than merely that.
* Silence — an unknown kind — is not a refusal.
*/
const untrue = binding ? refuseSeries(entry, binding) : null;
if (untrue && entry.seriesKinds.length) return fail(nodes, untrue);
if (!binding?.source && !binding?.series && entry.dataRequired) {
return fail(nodes, `\`${entry.label}\` needs a reading, and \`${before.id}\` has none.`);
}
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 };
}

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

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

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

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

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

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

@@ -0,0 +1,432 @@
/**
* 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 { refuseSeries, seriesFor } from './series';
import {
ALIGN_VALUES, DENSITY_VALUES, GAP_VALUES, MAX_COLUMNS, MIN_COLUMNS, NODE_ID_PATTERN,
PRESENTATION_KEYS, SPACING_VALUES, VARIANT_VALUES, walk,
} 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;
}
/**
* A page's own series.
*
* Checked against the series registry rather than the data-source vocabulary,
* because it is a different closed table — but the gate is the same one and
* for the same reason: a binding may only *name* something already
* registered, so there is still nowhere for a fabricated figure to live. The
* meaning is checked too, so a stored patch that turned a time series into a
* pie is refused on load rather than drawn.
*/
if (binding.series) {
const series = seriesFor(binding.series);
if (!series) {
problems.push(problem(at, `Unsupported series: ${binding.series}.`));
return problems;
}
if (!entry.seriesKinds.length) {
problems.push(problem(at, `\`${entry.type}\` does not draw a series.`));
return problems;
}
const untrue = refuseSeries(entry, binding);
if (untrue) problems.push(problem(at, untrue));
return problems;
}
if (!entry.dataShapes.length) {
problems.push(problem(at, `\`${entry.type}\` does not read data, so it cannot take a source.`));
return problems;
}
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(', ')}.`
));
}
/**
* And the semantic half, for a skill reading as well as for a page series.
*
* The same call, the same sentence, one rule — so a stored patch that binds a
* component to a reading it cannot honestly draw is refused on load exactly
* as the operation that would have created it is refused, rather than being
* replayed into the tree because nobody re-asked the question here.
*/
if (entry.seriesKinds.length) {
const untrue = refuseSeries(entry, binding);
if (untrue) problems.push(problem(at, untrue));
}
const params = binding.params || {};
if (params.periods != null) {
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

@@ -4,7 +4,7 @@ import { recommendJobs, recommendNextCourse } from '@/lib/krowScore';
import TalentHero from '@/components/krow/talent/TalentHero';
import IncreaseMarketValue from '@/components/krow/talent/IncreaseMarketValue';
import CareerGrowth from '@/components/krow/talent/CareerGrowth';
import MarketOffers from '@/components/krow/talent/MarketOffers';
import Opportunities from '@/components/krow/talent/Opportunities';
import ExperienceNetwork from '@/components/krow/talent/ExperienceNetwork';
import { Loader2 } from 'lucide-react';
@@ -25,7 +25,7 @@ export default function EmployeeDashboard() {
return (
<div className="space-y-6">
<TalentHero profile={profile} jobRecs={jobRecs} nextCourse={nextCourse} openOwliver={openOwliver} />
<MarketOffers profile={profile} jobRecs={jobRecs} />
<Opportunities jobRecs={jobRecs} />
<IncreaseMarketValue courses={courses} />
<CareerGrowth profile={profile} />
<ExperienceNetwork profile={profile} />

View File

@@ -0,0 +1,208 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft, MapPin, Wallet, Sparkles, CheckCircle2, Award,
Briefcase, Clock, Loader2, Compass,
} from 'lucide-react';
import { useWorkerProfile, useJobPostings } from '@/lib/krowHooks';
import { computeJobMatch } from '@/lib/krowScore';
/**
* OpportunityDetail — the full-page Employee job detail.
*
* A dedicated route (/opportunities/:id), NOT a drawer/sheet/modal. It continues
* the Opportunities grid: the same title-first identity, the same match, and an
* Apply action that hands off to the existing /apply?job=<id> flow (which runs
* the AI interview). It reuses the existing matching — useWorkerProfile,
* useJobPostings and computeJobMatch — and changes none of it.
*
* COMPANY PRIVACY: never renders job.company. Only title, role_category,
* location, pay, status, description/responsibilities/qualifications and the
* match signals are shown.
*/
/* The skills the profile brings that this role asks for — the same
case-insensitive overlap computeJobMatch scores, shown here for the reader
rather than recomputed into a number. Read-only; the algorithm is untouched. */
function matchedSkills(profile, job) {
const have = (profile?.skills || []).map((s) => String(s).toLowerCase());
const want = job?.qualifications || [];
return want.filter((q) => {
const ql = String(q).toLowerCase();
return have.some((ps) => ql.includes(ps) || ps.includes(ql));
});
}
function matchedCerts(profile, job) {
const have = (profile?.earned_badges || []).map((b) => String(b?.name || '').toLowerCase()).filter(Boolean);
const want = job?.certifications_required || [];
return want.filter((c) => {
const cl = String(c).toLowerCase();
return have.some((pc) => cl.includes(pc) || pc.includes(cl));
});
}
function Section({ title, children }) {
return (
<div className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
<h2 className="text-[15px] font-bold text-[#0F172A] mb-3">{title}</h2>
{children}
</div>
);
}
export default function OpportunityDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { data: profile, isLoading: loadingProfile } = useWorkerProfile();
const { data: postings = [], isLoading: loadingJobs } = useJobPostings();
if (loadingProfile || loadingJobs) {
return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0838E0]" /></div>;
}
const job = postings.find((p) => p.id === id);
if (!job) {
return (
<div className="max-w-2xl mx-auto text-center py-20">
<div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3">
<Compass className="w-6 h-6" />
</div>
<p className="text-[15px] font-semibold text-[#0F172A]">This opportunity is no longer available.</p>
<button
onClick={() => navigate('/employee')}
className="mt-5 inline-flex items-center gap-1.5 text-[13px] font-semibold text-[#0838E0] hover:text-[#062BAF]"
>
<ArrowLeft className="w-4 h-4" /> Back to Opportunities
</button>
</div>
);
}
const match = computeJobMatch(profile, job);
const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : null;
const isHiring = job.status === 'active';
const skills = matchedSkills(profile, job);
const certs = matchedCerts(profile, job);
const exp = profile?.experience_years || 0;
const reqExp = job.min_experience_years || 0;
const meetsExp = exp >= reqExp;
return (
<div className="max-w-3xl mx-auto space-y-5 pb-10">
<button
onClick={() => navigate('/employee')}
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#6B7280] hover:text-[#111827]"
>
<ArrowLeft className="w-4 h-4" /> Back to Opportunities
</button>
{/* Header — title-first identity, no company name. */}
<div className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-[#EEF3FE] text-[#0838E0] flex items-center justify-center shrink-0">
<Briefcase className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-[22px] font-bold text-[#0F172A] leading-tight">{job.title}</h1>
{isHiring && (
<span className="inline-flex items-center text-[11px] font-semibold text-white bg-[#0838E0] px-2.5 py-1 rounded-full">Hiring</span>
)}
</div>
{job.role_category && <p className="mt-1 text-[14px] text-[#6B7280] capitalize">{job.role_category}</p>}
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
<span className="inline-flex items-center gap-1.5 text-[14px] font-bold text-[#0838E0]">
<Sparkles className="w-4 h-4" /> {match}% match
</span>
<span className="inline-flex items-center gap-1.5 text-[13px] text-[#6B7280]">
<MapPin className="w-4 h-4 text-[#9CA3AF]" /> {job.location || 'Remote'}
</span>
{pay && (
<span className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#111827]">
<Wallet className="w-4 h-4 text-[#9CA3AF]" /> {pay}
</span>
)}
</div>
</div>
</div>
</div>
{/* About this opportunity */}
<Section title="About this opportunity">
{job.description ? (
<p className="text-[13px] text-[#6B7280] whitespace-pre-wrap leading-relaxed">{job.description}</p>
) : (
<p className="text-[13px] text-[#9CA3AF]">No description provided for this opportunity yet.</p>
)}
{job.responsibilities?.length > 0 && (
<div className="mt-4">
<p className="text-[13px] font-semibold text-[#0F172A] mb-1.5">What you'll do</p>
<ul className="list-disc pl-5 space-y-1 text-[13px] text-[#6B7280]">
{job.responsibilities.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
{job.qualifications?.length > 0 && (
<div className="mt-4">
<p className="text-[13px] font-semibold text-[#0F172A] mb-1.5">What they're looking for</p>
<ul className="list-disc pl-5 space-y-1 text-[13px] text-[#6B7280]">
{job.qualifications.map((q, i) => <li key={i}>{q}</li>)}
</ul>
</div>
)}
</Section>
{/* Why you're a match */}
<Section title="Why you're a match">
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-2 rounded-full bg-[#F3F4F6] overflow-hidden">
<div className="h-full rounded-full bg-[#0838E0]" style={{ width: `${match}%` }} />
</div>
<span className="text-[14px] font-bold text-[#0838E0]">{match}%</span>
</div>
<ul className="space-y-2.5">
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
<Clock className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<span>
{exp} yr{exp === 1 ? '' : 's'} of experience
{reqExp > 0 && <> · role asks for {reqExp}+ {meetsExp ? '— you qualify' : ''}</>}
</span>
</li>
{skills.length > 0 && (
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
<CheckCircle2 className="w-4 h-4 text-[#16A34A] mt-0.5 shrink-0" />
<span>Your skills match: {skills.join(', ')}</span>
</li>
)}
{certs.length > 0 && (
<li className="flex items-start gap-2 text-[13px] text-[#374151]">
<Award className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<span>Certifications you hold: {certs.join(', ')}</span>
</li>
)}
{skills.length === 0 && certs.length === 0 && (
<li className="flex items-start gap-2 text-[13px] text-[#6B7280]">
<Sparkles className="w-4 h-4 text-[#0838E0] mt-0.5 shrink-0" />
<span>You showed up here because your profile aligns with this role. Add skills to strengthen the match.</span>
</li>
)}
</ul>
</Section>
{/* Apply */}
<div className="flex justify-end">
<button
onClick={() => navigate(`/apply?job=${job.id}`)}
className="inline-flex items-center justify-center gap-2 h-11 px-8 rounded-full bg-[#0838E0] text-white text-[14px] font-semibold hover:bg-[#062BAF] transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0838E0]/40"
>
Apply <Sparkles className="w-4 h-4" />
</button>
</div>
</div>
);
}

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

@@ -126,7 +126,7 @@ function AgentCard({ agent, shipped, overridden, onOpen, onAction }) {
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
{shipped
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Delete</>}
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Remove</>}
</DropdownMenuItem>
</>
)}
@@ -310,7 +310,7 @@ function AgentTableRow({ agent, shipped, overridden, onOpen, onAction }) {
<DropdownMenuItem onClick={() => onAction('remove', agent)} className="cursor-pointer text-destructive focus:text-destructive">
{shipped
? <><RotateCcw className="mr-2 h-3.5 w-3.5" /> Revert to shipped</>
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Delete</>}
: <><Trash2 className="mr-2 h-3.5 w-3.5" /> Remove</>}
</DropdownMenuItem>
</>
)}
@@ -330,7 +330,12 @@ export default function AdminWorkspaceAgents() {
} = useAgents();
const [query, setQuery] = useState('');
const [status, setStatus] = useState('all');
/* The default view is the agents in service. Removing an agent archives it,
and an archived agent that stayed in the default list would make Remove
look like it had not worked — while hiding it anywhere but a tab the
reader can open would make it look destroyed. Archived is one click away
and is where restoring happens. */
const [status, setStatus] = useState('active');
const [view, setView] = useState(() => {
try {
return localStorage.getItem('krow_agents_view') || 'list';
@@ -351,16 +356,35 @@ export default function AdminWorkspaceAgents() {
const visible = useMemo(() => {
const found = searchAgents(agents, query);
return status === 'all' ? found : found.filter((a) => a.status === status);
if (status === 'active') return found.filter((a) => a.status !== 'archived');
return found.filter((a) => a.status === status);
}, [agents, query, status]);
const activeCount = agents.filter((a) => a.status !== 'archived').length;
const publishedCount = agents.filter((a) => a.status === 'published').length;
const draftCount = agents.filter((a) => a.status === 'draft').length;
const archivedCount = agents.filter((a) => a.status === 'archived').length;
/** Runs a lifecycle action and reports honestly when it is refused. */
/**
* Runs a lifecycle action and reports honestly when it is refused.
*
* `remove` resolves to two different operations, and the difference is the
* whole of what makes removal safe:
*
* authored agent → archive. The definition is kept, under the same id,
* with its instructions, tools and skills untouched. It
* leaves the default list and can be restored.
* shipped agent → remove, which deletes THIS ACCOUNT'S override row and
* nothing else. The product's own definition takes over
* again — that is the "Revert to shipped" wording the
* menu already uses, and it destroys no shipped agent.
*
* Resolved here rather than in the menu so both the card and the table row
* cannot drift apart on what Remove means.
*/
const run = async (action, agent) => {
const fn = { publish, republish: publish, archive, restore, duplicate, remove }[action];
const resolved = action === 'remove' && !isShipped(agent.id) ? 'archive' : action;
const fn = { publish, republish: publish, archive, restore, duplicate, remove }[resolved];
const result = await fn(agent.id);
if (result?.conflict) toast.error(result.conflict.message);
else if (result?.error) toast.error(result.error);
@@ -368,7 +392,9 @@ export default function AdminWorkspaceAgents() {
};
const onAction = (action, agent) => {
/* Destructive and irreversible-looking actions confirm; the rest run. */
/* Anything that takes an agent out of service confirms first, even though
none of it is destructive — a reader should choose to stop an agent
answering, not discover it. The rest run. */
if (action === 'remove' || action === 'archive') {
setConfirming({ action, agent });
return;
@@ -410,15 +436,15 @@ export default function AdminWorkspaceAgents() {
<div className="inline-flex items-center rounded-xl border border-border/80 bg-surface-subtle/80 p-1 text-caption">
<button
type="button"
onClick={() => setStatus('all')}
onClick={() => setStatus('active')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'all'
status === 'active'
? 'bg-surface text-ink-1 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
All <span className="text-ink-4 font-normal">({agents.length})</span>
Active <span className="text-ink-4 font-normal">({activeCount})</span>
</button>
<button
type="button"
@@ -446,20 +472,26 @@ export default function AdminWorkspaceAgents() {
Drafts <span className="text-ink-4 font-normal">({draftCount})</span>
</button>
)}
{archivedCount > 0 && (
<button
type="button"
onClick={() => setStatus('archived')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'archived'
? 'bg-surface text-amber-700 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
Archived <span className="text-ink-4 font-normal">({archivedCount})</span>
</button>
)}
{/* Always rendered, empty or not. Archived is where removing an
agent puts it and where restoring it happens, and the remove
confirmation says so in as many words — a tab that appears
only once something is already in it tells a reader where
their agent went strictly after they needed to know. The
count answers "is anything in here?" without hiding the
answer. Drafts stays conditional: an empty Drafts tab
teaches nothing, because nothing is ever sent there. */}
<button
type="button"
onClick={() => setStatus('archived')}
className={cn(
'rounded-lg px-3 py-1 font-medium transition-all',
status === 'archived'
? 'bg-surface text-amber-700 shadow-2xs font-semibold'
: 'text-ink-3 hover:text-ink-1'
)}
>
Archived <span className="text-ink-4 font-normal">({archivedCount})</span>
</button>
</div>
</div>
@@ -546,16 +578,23 @@ export default function AdminWorkspaceAgents() {
title={
confirming?.action === 'archive'
? `Archive ${confirming?.agent.name}?`
: isShipped(confirming?.agent?.id) ? 'Revert to the shipped definition?' : `Delete ${confirming?.agent.name}?`
: isShipped(confirming?.agent?.id) ? 'Revert to the shipped definition?' : 'Remove agent?'
}
description={
confirming?.action === 'archive'
? 'It stops answering and disappears from the switcher. Its definition is kept, and it can be restored as a draft.'
: isShipped(confirming?.agent?.id)
? 'Your changes to this agent are discarded and the shipped definition takes over again.'
: 'This definition is removed. It cannot be recovered.'
: 'This agent will be removed from the active Agents list. Its configuration and '
+ 'skills will be preserved and it can be restored later from Archived.'
}
confirmLabel={
confirming?.action === 'archive'
? 'Archive'
/* Reverting a shipped agent is not a removal and must not read as
one: the shipped definition takes over, nothing is destroyed. */
: isShipped(confirming?.agent?.id) ? 'Confirm' : 'Remove Agent'
}
confirmLabel={confirming?.action === 'archive' ? 'Archive' : 'Confirm'}
busy={saving}
onConfirm={async () => {
const pending = confirming;

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,971 @@
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 { registerSeries } from '@/lib/ui/series';
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,
});
/**
* The figures behind this page's hiring chart, named so something else can draw
* them.
*
* The page already computed this array — it is the same `activitySeries` the
* composed chart above reads, taken from the same context, unaggregated and
* unreordered. Declaring it here is what lets a person say "show this as a bar
* chart" and get *these* numbers rather than a new empty panel, and it is the
* only thing that had to be added to make a built-in chart changeable at all.
*
* `periodic` is the honest kind: these are days in order. It is also what
* refuses the pie.
*/
registerSeries({
id: 'control-center.hiring-activity',
label: 'Hiring activity',
kind: 'periodic',
measures: [
{ key: 'applications', label: 'Applications' },
{ key: 'screened', label: 'AI Screened' },
{ key: 'interviews', label: 'Interviews' },
{ key: 'hires', label: 'Hires' },
],
read: (context) => context?.activitySeries || [],
emptyNote: 'No applications yet.',
/**
* The range control, declared on the reading rather than on the component.
*
* It is the reason "show this as a bar chart" used to be a downgrade: the
* 7D/30D/90D toggle lived inside this page's own chart section, so replacing
* the section removed a control the person had been using — and the figures
* it governs are the very figures the replacement draws.
*
* Declaring it here moves it to where it actually belongs. The control does
* not change how the series is drawn; it changes which rows the series *is*,
* which is a property of the reading. Every visualization draws it because
* every visualization asks the binding for it, and none of them knows what a
* range is. `options` is the same `RANGES` table the built-in section reads,
* so the two cannot drift apart.
*/
control: {
id: 'range',
label: 'Hiring activity range',
options: Object.entries(RANGES).map(([value, r]) => ({ value, label: r.label })),
/* The page's own state, both ways. Nothing is stored here, and a page that
stops publishing `range` stops publishing the control with it. */
read: (context) => context?.range ?? null,
write: (context, value) => context?.setRange?.(value),
},
});
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,
/**
* The one section on this page that can become something else.
*
* It draws a series, it says which series, and it says what that series
* means — which is the whole of what the engine needs to offer a bar, a line
* or an area in its place and to refuse a pie. Nothing about charts is
* written here: the candidates are computed by the registry from these three
* facts.
*/
capabilities: [...SECTION, 'replace'],
seriesKinds: ['periodic'],
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', data: { series: 'control-center.hiring-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

@@ -0,0 +1,82 @@
---
id: create-employee-role
name: Create Employee Role
description: Record what a worker does — their role, experience, pay and availability — by answering a few questions in the chat.
pages:
- talent-pool
- positions
status: active
version: 1
prompt: Create an employee role
flow: employee-role
triggers:
- create an employee role
- create employee role
- create employee roles
- add an employee role
- add employee role
- new employee role
- create a worker role
- create worker role
- record a role for
- add a worker role
actions:
- create_employee_role
---
# Create Employee Role
## Purpose
Record a worker's declared professional role without leaving the page. Owliver
asks one question at a time, offers the answers as chips, and reads the whole
thing back before anything is written.
**This is not Create Position, and the difference is the point.** A position is
what the ORGANIZATION needs filled — a company, a title, a pay range it will
pay. An employee role is what a WORKER says they do — the role they present
themselves as, the experience they have, and the pay they are looking for. The
two share a vocabulary and nothing else: "3 years" on a position is a minimum an
applicant must clear, and the same words here are what this person has.
They are never joined by a column. Supply and demand meet through applications,
which already carry the funnel, the interview and the outcome.
## Capabilities
- Understand requests to record what a worker does.
- Ask who the role is for, and resolve the answer to a real worker profile.
- Read the role, experience, English level, certifications, desired pay and
availability out of a single sentence.
- Ask only for what the request did not already answer.
- Offer each answer as a suggestion, so the whole flow can be clicked.
- Read the role back for confirmation before recording it.
## Conversation
Each line is `field | question | suggestions | required?`. Suggestions beginning
with `@` come from the application's own data.
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.
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
- certifications | Any certifications they hold? | @certifications; None | optional
- desired_pay | What pay are they looking for? | $18–$28/hr; $25–$35/hr; $30–$40/hr; Custom | optional
- availability | When are they available? | @availability | optional
- notes | Anything else worth recording? | | optional
## Actions
- create_employee_role

View File

@@ -56,7 +56,12 @@ Each line is `field | question | suggestions | required?`. Suggestions beginning
with `@` come from the application's own data, so a role category added in the
form is offered here without this file changing.
- company | Which client is this role for? Type the company name. | | required
`@companies` is the clients this organization already staffs for, read off the
postings already on screen. Picking one is a tap; typing a name that is not on
the list is how a new client is named, which is all "create a client" has ever
meant here — the company is a field on the position, not a record of its own.
- company | Which client is this role for? | @companies | required
- role_category | What role are you hiring for? | @roles | required
- location | Where will this role be based? | Chennai; Bengaluru; Coimbatore; Bay Area; Other | required
- pay | What is the pay range? | $18–$28/hr; $25–$35/hr; $30–$40/hr; Custom | required