Compare commits
39 Commits
f522b6508e
...
owliver-la
| Author | SHA1 | Date | |
|---|---|---|---|
| 81e5979f8f | |||
| 8ac4e1ee88 | |||
| 1ef0c6fc8a | |||
| e676d259b2 | |||
| 34505d7cf6 | |||
| 878a23532a | |||
| 3ddacf269a | |||
| 2f9b3baa56 | |||
| e8207038dd | |||
| 21133a6064 | |||
| 78f1b44c2e | |||
| 34acd63a80 | |||
| 284a7e7671 | |||
| a0f3f77900 | |||
| b99dc7c576 | |||
| 7a95d95151 | |||
| 3f835eee93 | |||
| dde4ba62c6 | |||
| 446df7b37b | |||
| 55ddaa134a | |||
| fc8d7eec52 | |||
| e73929f47e | |||
| eaa677f815 | |||
| ecf5e75d76 | |||
| fcdaa32f4d | |||
| be49184006 | |||
| 3d5f54bd5b | |||
| e7e1e9873f | |||
| 543da9d4e7 | |||
| d440036211 | |||
| 2b8f5746bd | |||
| 3e654c2bf7 | |||
| 1133eca16a | |||
| 02a2ab05ef | |||
| dca184289e | |||
| 64140c7add | |||
| f96f128839 | |||
| d1425f974c | |||
| 1775395256 |
209
MIGRATION_BASELINE.md
Normal file
209
MIGRATION_BASELINE.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# TypeScript migration — baseline
|
||||||
|
|
||||||
|
The state of this repository **immediately before** the first source file was
|
||||||
|
renamed, measured rather than assumed. Everything the migration does is checked
|
||||||
|
against these numbers; a figure that moves without a phase claiming it is a
|
||||||
|
regression.
|
||||||
|
|
||||||
|
Measured on 2026-09-11, on `main` with a clean tree, before any change in
|
||||||
|
Milestone 1.
|
||||||
|
|
||||||
|
## The numbers
|
||||||
|
|
||||||
|
| Check | Command | Baseline |
|
||||||
|
|---|---|---|
|
||||||
|
| Types | `npm run typecheck` | **FAILS — 71 errors across 27 files** |
|
||||||
|
| Lint | `npm run lint` | **PASSES**, exit 0 |
|
||||||
|
| Lint (with warnings) | `npx eslint .` | 289 problems — 0 errors, 289 warnings |
|
||||||
|
| Files linted | `npx eslint . --format json` | 302 |
|
||||||
|
| Behaviour | `npm test` | **1641 / 1642 checks passed** |
|
||||||
|
| Build | `npm run build` | succeeds |
|
||||||
|
| Owliver, via the suite | `npm test` section 21 | **all green** — schema, skills, routes, and per-context skills, suggestions, prompts and intent routing |
|
||||||
|
| Owliver, standalone | `node scripts/owliver-baseline.mjs` | **FAILS — pre-existing, see below** |
|
||||||
|
|
||||||
|
### The 71 type errors, by code
|
||||||
|
|
||||||
|
| Code | Count | What it is |
|
||||||
|
|---|---|---|
|
||||||
|
| TS2339 | 38 | Property does not exist on an inferred type |
|
||||||
|
| TS2741 | 9 | Missing required prop in JSX |
|
||||||
|
| TS2353 | 9 | Unknown property in an object literal |
|
||||||
|
| TS2345 | 6 | Argument type mismatch |
|
||||||
|
| TS2554 | 2 | Wrong argument count |
|
||||||
|
| TS2362 / TS2363 | 4 | Arithmetic on a non-number |
|
||||||
|
| TS2739 / TS2559 / TS2349 | 3 | Missing props / no common props / not callable |
|
||||||
|
|
||||||
|
These are **pre-existing**. They were not introduced by the migration and the
|
||||||
|
migration is not obliged to fix them; they are the backlog it has to avoid
|
||||||
|
adding to. Six of them are `import.meta` errors that Phase 1 resolves as a side
|
||||||
|
effect of configuring `vite/client`.
|
||||||
|
|
||||||
|
Note that `npm run typecheck` **is not run by CI** (`.github/workflows/ci.yml`
|
||||||
|
runs lint, test, build and the seed check). That is how 71 errors accumulated
|
||||||
|
without anyone seeing them. Wiring it in is the last phase of the migration, not
|
||||||
|
the first — it cannot be a gate until it is green.
|
||||||
|
|
||||||
|
## The standalone Owliver script is already broken
|
||||||
|
|
||||||
|
`node scripts/owliver-baseline.mjs` fails before any of this began — verified by
|
||||||
|
stashing every change and running it on a clean `main`, where it fails
|
||||||
|
identically. It is **not** caused by the migration.
|
||||||
|
|
||||||
|
```
|
||||||
|
Failed to scan for dependencies from entries:
|
||||||
|
index.html
|
||||||
|
scripts/__baseline__/activity-page.pre-migration.html
|
||||||
|
… the other seven snapshots …
|
||||||
|
✘ [ERROR] The server is being restarted or closed. Request is outdated [plugin vite:dep-scan]
|
||||||
|
Owliver behaviour has DRIFTED from the baseline.
|
||||||
|
```
|
||||||
|
|
||||||
|
Two things are going on, and neither is a real drift. Vite's dependency scanner
|
||||||
|
treats every `*.html` under the project root as an entry point, so the eight
|
||||||
|
captured SSR snapshots in `scripts/__baseline__/` are scanned as if they were
|
||||||
|
pages of the app. The script then calls `server.close()` as soon as
|
||||||
|
`captureBaseline` returns, while that scan is still running, and the scan
|
||||||
|
reports that it was cancelled. The capture comes back incomplete and the
|
||||||
|
comparison calls it drift.
|
||||||
|
|
||||||
|
**The coverage itself is not lost.** `skill-check.mjs` imports the same
|
||||||
|
`captureBaseline` and the same `BASELINE_PATH` and asserts against them in
|
||||||
|
section 21, and it does enough other work afterwards that the scan finishes. Run
|
||||||
|
`npm test` and the whole section is green:
|
||||||
|
|
||||||
|
```
|
||||||
|
── Owliver behaviour baseline ──
|
||||||
|
[ ok ] baseline schema matches — expected 1, got 1
|
||||||
|
[ ok ] every skill that existed before the agent layer still registers — 23 kept, 1 added since
|
||||||
|
[ ok ] every route that resolved before resolves the same way — 18 unchanged
|
||||||
|
[ ok ] controlCenter: keeps every skill it had — 7 skill(s), unchanged
|
||||||
|
…per context: page key, skills, suggestions, prompts, intent routing
|
||||||
|
```
|
||||||
|
|
||||||
|
So **`npm test` is the Owliver drift check for the duration of the migration**,
|
||||||
|
and the standalone script is a convenience wrapper that needs repairing on its
|
||||||
|
own terms — excluding `scripts/__baseline__` from the scan, or awaiting the
|
||||||
|
optimizer before closing. That repair is not part of the migration: it changes
|
||||||
|
a script's behaviour, and this milestone's whole claim is that it changed none.
|
||||||
|
|
||||||
|
## The one failing check
|
||||||
|
|
||||||
|
```
|
||||||
|
1641/1642 checks passed
|
||||||
|
|
||||||
|
Failed:
|
||||||
|
- the backend fixture is in step with this seed (stale — run `npm run seed:fixture`)
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/__baseline__` is intact and `owliver-baseline.mjs` matches; this is not
|
||||||
|
an Owliver failure. `seed/fixtures/seed.json` in the sibling `krow-backend`
|
||||||
|
checkout has drifted from `src/api/seed.js`.
|
||||||
|
|
||||||
|
**This failure predates the migration and is unrelated to it. It is recorded
|
||||||
|
here so that it is never mistaken for something the migration caused, and it is
|
||||||
|
deliberately left alone** — regenerating the fixture would write into another
|
||||||
|
repository and change what the backend seeds, which is a product change wearing
|
||||||
|
a migration's clothes. Fix it separately, on its own terms.
|
||||||
|
|
||||||
|
## What "unchanged" means for the Owliver / agent flow
|
||||||
|
|
||||||
|
The agent flow is ~112 files and 38% of the codebase, and it is converted last,
|
||||||
|
by rename and type annotation only. The evidence that it still behaves
|
||||||
|
identically is already in the repository and needs nothing new:
|
||||||
|
|
||||||
|
- `npm test` — 1642 checks covering skill discovery, routing, context
|
||||||
|
resolution and SSR-rendered markup.
|
||||||
|
- `scripts/__baseline__/owliver-baseline.json` — contexts, skill ids and routes.
|
||||||
|
- `scripts/__baseline__/*.pre-migration.html` — eight pages of byte-level SSR
|
||||||
|
output.
|
||||||
|
- `node scripts/owliver-baseline.mjs` — the drift check.
|
||||||
|
- CI's `FLOOR = 900` — fails if the suite silently shrinks.
|
||||||
|
|
||||||
|
**These artifacts are read-only for the duration of the migration.** If the
|
||||||
|
baseline drifts, revert the change. Regenerating it to clear a red check records
|
||||||
|
the regression instead of catching it, which is worse than having no baseline —
|
||||||
|
`owliver-baseline.mjs` says as much in its own header.
|
||||||
|
|
||||||
|
`src/agents/**/*.md` and `src/skills/**/*.md` are likewise untouched throughout:
|
||||||
|
they are the definitions the flow is built from, not code being migrated.
|
||||||
|
|
||||||
|
## How to re-measure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck; echo "exit=$?"
|
||||||
|
npm run typecheck 2>&1 | grep -c "error TS"
|
||||||
|
npm run lint; echo "exit=$?"
|
||||||
|
npx eslint . --format json | python3 -c "import json,sys;print(len(json.load(sys.stdin)))"
|
||||||
|
npm test
|
||||||
|
node scripts/owliver-baseline.mjs
|
||||||
|
npm run build
|
||||||
|
git diff --stat -M
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-20 — closing addendum: the migration is complete
|
||||||
|
|
||||||
|
Everything above this line is the record as measured on 2026-09-11 and is left
|
||||||
|
exactly as it was written. The numbers in it describe `main` before the first
|
||||||
|
rename; they are the thing the migration was checked against, so correcting them
|
||||||
|
to today's figures would destroy the comparison rather than update it. What
|
||||||
|
follows is the other end of that comparison.
|
||||||
|
|
||||||
|
### Final state, measured on the `ts-migration` branch
|
||||||
|
|
||||||
|
| Check | Command | 2026-09-11 baseline | Now |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Types | `npm run typecheck` | FAILS — 71 errors / 27 files | **0 errors**, 321 files in the program |
|
||||||
|
| Lint | `npm run lint` | PASSES, exit 0 | PASSES, exit 0 |
|
||||||
|
| Lint (with warnings) | `npx eslint .` | 0 errors, 289 warnings | 0 errors, 289 warnings |
|
||||||
|
| Behaviour | `npm test` | 1641 / 1642 | **1693 / 1693** |
|
||||||
|
| Build | `npm run build` | succeeds | succeeds |
|
||||||
|
| Bundle | `dist/assets/index-*.js` | — | `74d17e2d5cafdd6f88eaf6d89ffdab11` |
|
||||||
|
| Owliver, standalone | `node scripts/owliver-baseline.mjs` | FAILS — pre-existing | **matches the baseline** |
|
||||||
|
| Backend fixture | `npm run seed:check` | FAILS — stale | **in step** |
|
||||||
|
|
||||||
|
No `.js` or `.jsx` remains under `src/`: 103 `.ts`, 216 `.tsx` and 2 `.d.ts`.
|
||||||
|
`src/agents/**/*.md` and `src/skills/**/*.md` are untouched, as promised.
|
||||||
|
|
||||||
|
### The three items this document left open, and where they were closed
|
||||||
|
|
||||||
|
**The standalone Owliver script.** Recorded above as failing before any of this
|
||||||
|
began, verified by running it on a clean `main`. The byte-exact comparison was
|
||||||
|
reporting one real difference: the baseline held 23 skills and the runtime
|
||||||
|
resolved 24, because `create-employee-role` was added after the baseline was
|
||||||
|
last written and nobody recaptured it. `npm test` tolerated it — its check
|
||||||
|
asserts only that nothing was *removed*, and read "23 kept, 1 added since" — so
|
||||||
|
the drift was visible only to the stricter script. Refreshed in `34505d7`:
|
||||||
|
three inserted lines, no deletions, routes byte-identical at 18 and all eleven
|
||||||
|
contexts unchanged. The check now reads "24 kept, 0 added since".
|
||||||
|
|
||||||
|
**The backend seed fixture.** Recorded above as predating the migration and
|
||||||
|
deliberately left alone. It stayed that way for good reason: the generator was a
|
||||||
|
version behind the seeder and emitted no `users` key, so regenerating would have
|
||||||
|
dropped `employer@krow.app` from every fresh seed and left nobody to sign in as
|
||||||
|
to reach the employer console. Fixed in `3ddacf2` by teaching the generator to
|
||||||
|
emit the key, not by overwriting the fixture — with that change the generator
|
||||||
|
reproduces the committed fixture byte for byte, so `seed.json` in the sibling
|
||||||
|
repository was never written to at all.
|
||||||
|
|
||||||
|
**The HTML render baselines.** Recorded above as read-only for the duration of
|
||||||
|
the migration, and that held: they were untouched through every phase. Three of
|
||||||
|
them — Hired History, Analytics and Candidates — were recaptured in `878a235`,
|
||||||
|
after the migration, because a parallel feature removed demo data those files had
|
||||||
|
captured while it was still in effect. They are renamed `*.render.html`, since a
|
||||||
|
file called `pre-migration` holding post-feature markup is a lie in the filename.
|
||||||
|
The other five still hold genuine pre-migration markup, still pass, and keep the
|
||||||
|
name. One check that could no longer be satisfied was replaced by three that read
|
||||||
|
the render directly; `scripts/__baseline__/README.md` has the detail.
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
|
||||||
|
3ddacf2 fix(seed): emit the users array the backend seeder reads
|
||||||
|
878a235 test(baseline): recapture the three HTML baselines that held invented people
|
||||||
|
34505d7 chore: refresh owliver baseline
|
||||||
|
|
||||||
|
Strictness remains as debt rather than a blocker: `strict: true` reports 4438
|
||||||
|
errors, `noImplicitAny` 3317 and `strictNullChecks` 1794, while the committed
|
||||||
|
configuration reports none. None of it affects emitted output — the production
|
||||||
|
bundle hash is unchanged — so it is hardening work, not migration work.
|
||||||
90
README.md
90
README.md
@@ -19,22 +19,22 @@ Recharts + MUI X Charts · react-hot-toast.
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
main.jsx entry — mounts <App/>, imports index.css
|
main.tsx entry — mounts <App/>, imports index.css
|
||||||
App.jsx providers + the entire route table
|
App.tsx providers + the entire route table
|
||||||
index.css design tokens (HSL CSS variables) + utility layers
|
index.css design tokens (HSL CSS variables) + utility layers
|
||||||
|
|
||||||
api/ the backend seam — see below
|
api/ the backend seam — see below
|
||||||
base44Client.js the contract the production app talks to
|
base44Client.ts the contract the production app talks to
|
||||||
store.js in-memory entity store, mirrored to localStorage
|
store.js in-memory entity store, mirrored to localStorage
|
||||||
aiEngine.js local, deterministic InvokeLLM
|
aiEngine.ts local, deterministic InvokeLLM
|
||||||
seed.js demo dataset
|
seed.ts demo dataset
|
||||||
attendanceSeed.js shift/attendance dataset
|
attendanceSeed.ts shift/attendance dataset
|
||||||
|
|
||||||
lib/ domain logic and data hooks
|
lib/ domain logic and data hooks
|
||||||
krowHooks.js React Query hooks over the entity store
|
krowHooks.ts React Query hooks over the entity store
|
||||||
krowAi.js AI workflows
|
krowAi.ts AI workflows
|
||||||
krowScore.js score engine
|
krowScore.ts score engine
|
||||||
AuthContext.jsx auth provider
|
AuthContext.tsx auth provider
|
||||||
admin/ admin session, permissions, position insights
|
admin/ admin session, permissions, position insights
|
||||||
skills/ the Owliver skill system (registry, resolver, flows)
|
skills/ the Owliver skill system (registry, resolver, flows)
|
||||||
agents/ the agent system (registry, runtime, lifecycle)
|
agents/ the agent system (registry, runtime, lifecycle)
|
||||||
@@ -79,37 +79,37 @@ The app opens on **`/admin/login`**. Everything else lives under `/admin/*` behi
|
|||||||
| `/admin/workspace/skills`, `skills/new`, `skills/:id` | UI skill list and editor |
|
| `/admin/workspace/skills`, `skills/new`, `skills/:id` | UI skill list and editor |
|
||||||
| `/admin/workspace/skills/owliver/new`, `owliver/:id` | Owliver skill editor |
|
| `/admin/workspace/skills/owliver/new`, `owliver/:id` | Owliver skill editor |
|
||||||
| `/admin/workspace/skill-development` | Skill Development |
|
| `/admin/workspace/skill-development` | Skill Development |
|
||||||
| `*` | `lib/PageNotFound.jsx` |
|
| `*` | `lib/PageNotFound.tsx` |
|
||||||
|
|
||||||
Legacy top-level paths (`/overview`, `/positions`, `/candidates`, `/hired`, `/talent-pool`,
|
Legacy top-level paths (`/overview`, `/positions`, `/candidates`, `/hired`, `/talent-pool`,
|
||||||
`/university`, `/analytics`, `/activity`, `/tracking`) all `Navigate` into their `/admin/*`
|
`/university`, `/analytics`, `/activity`, `/tracking`) all `Navigate` into their `/admin/*`
|
||||||
equivalent, id preserved. Static route segments are declared before dynamic ones so
|
equivalent, id preserved. Static route segments are declared before dynamic ones so
|
||||||
`workspace/agents/new` cannot be read as an agent whose id is `"new"`.
|
`workspace/agents/new` cannot be read as an agent whose id is `"new"`.
|
||||||
|
|
||||||
> **Note.** `App.jsx` still imports a set of pre-redesign Employer/Talent pages
|
> **Note.** `App.tsx` still imports a set of pre-redesign Employer/Talent pages
|
||||||
> (`Overview`, `Apply`, `WorkerProfile`, `KrowIdentity`, `EmployeeDashboard`, `DesignSystem`, …)
|
> (`Overview`, `Apply`, `WorkerProfile`, `KrowIdentity`, `EmployeeDashboard`, `DesignSystem`, …)
|
||||||
> and `layouts/Layout.jsx`. None of them are mounted on a route — they are unreachable at runtime
|
> and `layouts/Layout.tsx`. None of them are mounted on a route — they are unreachable at runtime
|
||||||
> and retained pending a decision on whether to re-route or remove them.
|
> and retained pending a decision on whether to re-route or remove them.
|
||||||
|
|
||||||
## The backend seam
|
## The backend seam
|
||||||
|
|
||||||
The production app talks to a Base44 backend through `src/api/base44Client.js`. This demo keeps that
|
The production app talks to a Base44 backend through `src/api/base44Client.ts`. This demo keeps that
|
||||||
module path, export name and full method contract, and swaps only the transport:
|
module path, export name and full method contract, and swaps only the transport:
|
||||||
|
|
||||||
| Contract | Demo implementation |
|
| Contract | Demo implementation |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `base44.entities.<Name>.list/filter/get/create/update/delete` | `api/store.js` — in-memory records, mirrored to `localStorage` |
|
| `base44.entities.<Name>.list/filter/get/create/update/delete` | `api/store.js` — in-memory records, mirrored to `localStorage` |
|
||||||
| `base44.integrations.Core.InvokeLLM` | `api/aiEngine.js` — local, deterministic |
|
| `base44.integrations.Core.InvokeLLM` | `api/aiEngine.ts` — local, deterministic |
|
||||||
| `base44.integrations.Core.UploadFile` | blob URLs, session-scoped |
|
| `base44.integrations.Core.UploadFile` | blob URLs, session-scoped |
|
||||||
| `base44.auth.*` | seeded employer/admin user, always signed in |
|
| `base44.auth.*` | seeded employer/admin user, always signed in |
|
||||||
|
|
||||||
Because the seam did not move, everything above it — `lib/krowHooks.js`, `lib/krowAi.js`,
|
Because the seam did not move, everything above it — `lib/krowHooks.ts`, `lib/krowAi.ts`,
|
||||||
`lib/krowScore.js`, every page and component — is unchanged from the reference implementation.
|
`lib/krowScore.ts`, every page and component — is unchanged from the reference implementation.
|
||||||
|
|
||||||
## The AI engine
|
## The AI engine
|
||||||
|
|
||||||
Every AI workflow funnels through one call, `InvokeLLM({ prompt, response_json_schema })`.
|
Every AI workflow funnels through one call, `InvokeLLM({ prompt, response_json_schema })`.
|
||||||
`api/aiEngine.js` reimplements it locally: it recognises each workflow by the phrase its prompt opens
|
`api/aiEngine.ts` reimplements it locally: it recognises each workflow by the phrase its prompt opens
|
||||||
with, reads the structured fields the prompt already carries (`Years Experience: 6`,
|
with, reads the structured fields the prompt already carries (`Years Experience: 6`,
|
||||||
`Required Certifications: …`, the talent-pool JSON block) and scores them deterministically.
|
`Required Certifications: …`, the talent-pool JSON block) and scores them deterministically.
|
||||||
|
|
||||||
@@ -124,8 +124,8 @@ Two registries discover their definitions from markdown at build time:
|
|||||||
|
|
||||||
| Registry | Glob | Files |
|
| Registry | Glob | Files |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `lib/skills/registry.js` | `import.meta.glob('/src/skills/**/*.md')` | 18 Owliver skills, 5 workforce training skills |
|
| `lib/skills/registry.ts` | `import.meta.glob('/src/skills/**/*.md')` | 19 Owliver skills, 5 workforce training skills |
|
||||||
| `lib/agents/registry.js` | `import.meta.glob('/src/agents/**/*.md')` | 9 agent definitions |
|
| `lib/agents/registry.ts` | `import.meta.glob('/src/agents/**/*.md')` | 9 agent definitions |
|
||||||
|
|
||||||
**These globs are absolute paths.** Moving or renaming `src/skills/` or `src/agents/` makes the
|
**These globs are absolute paths.** Moving or renaming `src/skills/` or `src/agents/` makes the
|
||||||
registry silently return nothing — no build error, no import failure, just an empty registry. Add
|
registry silently return nothing — no build error, no import failure, just an empty registry. Add
|
||||||
@@ -133,9 +133,9 @@ definitions by dropping a new `.md` file into the right folder; nothing else nee
|
|||||||
|
|
||||||
## Demo data
|
## Demo data
|
||||||
|
|
||||||
Seeded in `src/api/seed.js` — positions, applicants, AI-scored candidates, interviews, hires,
|
Seeded in `src/api/seed.ts` — positions, applicants, AI-scored candidates, interviews, hires,
|
||||||
talent-pool profiles, Proving Ground courses and an activity log; shift data in
|
talent-pool profiles, Proving Ground courses and an activity log; shift data in
|
||||||
`src/api/attendanceSeed.js`.
|
`src/api/attendanceSeed.ts`.
|
||||||
|
|
||||||
Edits persist to `localStorage`. To restore the shipped dataset:
|
Edits persist to `localStorage`. To restore the shipped dataset:
|
||||||
|
|
||||||
@@ -163,12 +163,12 @@ Tokens live in `src/index.css` as HSL CSS variables consumed by `tailwind.config
|
|||||||
| Body | Inter |
|
| Body | Inter |
|
||||||
|
|
||||||
`src/components/ds/` is the vocabulary the app actually writes in — `Surface`, `KpiCard`,
|
`src/components/ds/` is the vocabulary the app actually writes in — `Surface`, `KpiCard`,
|
||||||
`DataTable`, `PageHeader`, `Modal`, `toast` and the rest, exported through `ds/index.js`.
|
`DataTable`, `PageHeader`, `Modal`, `toast` and the rest, exported through `ds/index.ts`.
|
||||||
`src/components/ui/` holds the shadcn primitives those are built on. Surfaces use `glass` and
|
`src/components/ui/` holds the shadcn primitives those are built on. Surfaces use `glass` and
|
||||||
`glass-card`; the page background is the `gradient-mesh` utility.
|
`glass-card`; the page background is the `gradient-mesh` utility.
|
||||||
|
|
||||||
Toasts go through `ds/toast` (a wrapper over `react-hot-toast`), rendered by `<HotToaster>` in
|
Toasts go through `ds/toast` (a wrapper over `react-hot-toast`), rendered by `<HotToaster>` in
|
||||||
`App.jsx`.
|
`App.tsx`.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
@@ -179,8 +179,8 @@ Toasts go through `ds/toast` (a wrapper over `react-hot-toast`), rendered by `<H
|
|||||||
| `npm run preview` | Serve the build |
|
| `npm run preview` | Serve the build |
|
||||||
| `npm run lint` | ESLint — currently clean |
|
| `npm run lint` | ESLint — currently clean |
|
||||||
| `npm run lint:fix` | ESLint with `--fix` |
|
| `npm run lint:fix` | ESLint with `--fix` |
|
||||||
| `npm test` | `scripts/skill-check.mjs` — 835 assertions over the skill and agent systems |
|
| `npm test` | `scripts/skill-check.mjs` — 1693 assertions over the skill and agent systems |
|
||||||
| `npm run typecheck` | `tsc -p ./jsconfig.json` with `checkJs` |
|
| `npm run typecheck` | `tsc -p ./tsconfig.json` — 0 errors across 321 TypeScript files |
|
||||||
|
|
||||||
### The test harness
|
### The test harness
|
||||||
|
|
||||||
@@ -191,17 +191,41 @@ mocks. It also diffs Owliver's behaviour across eleven page contexts against a c
|
|||||||
`node scripts/owliver-baseline.mjs --write`, never to turn a red check green.
|
`node scripts/owliver-baseline.mjs --write`, never to turn a red check green.
|
||||||
|
|
||||||
Because it loads modules by absolute path, the harness pins the locations of `src/api/`, `src/lib/`,
|
Because it loads modules by absolute path, the harness pins the locations of `src/api/`, `src/lib/`,
|
||||||
`src/lib/skills/`, `src/lib/agents/`, `src/components/ai-assistant/`, `src/App.jsx`,
|
`src/lib/skills/`, `src/lib/agents/`, `src/components/ai-assistant/`, `src/App.tsx`,
|
||||||
`src/layouts/AdminLayout.jsx` and a handful of agent files. Move any of them and update
|
`src/layouts/AdminLayout.tsx` and a handful of agent files. Move any of them and update
|
||||||
`scripts/owliver-capture.mjs` and `scripts/skill-check.mjs` in the same change.
|
`scripts/owliver-capture.mjs` and `scripts/skill-check.mjs` in the same change.
|
||||||
|
|
||||||
### Known-failing checks
|
### Check status
|
||||||
|
|
||||||
Two checks fail on a clean checkout and are tracked as separate work, not regressions:
|
Everything above passes on a clean checkout: `npm test` is 1693/1693 and
|
||||||
|
`npm run typecheck` reports no errors.
|
||||||
|
|
||||||
- `npm test` — 834/835 pass; `the seeded overtime climb is found` fails.
|
This section used to list two known failures — 834/835 on the suite, and 59 type
|
||||||
- `npm run typecheck` — 59 errors, all pre-existing JSDoc/inference gaps in `lib/krowAi.js`,
|
errors across `lib/krowAi`, `lib/skills/*` and `lib/positionModel`. Both are
|
||||||
`lib/skills/*`, `lib/positionModel.js` and a few components.
|
gone, and they were unrelated to each other.
|
||||||
|
|
||||||
|
The suite failure was `the seeded overtime climb is found`. It asserted against
|
||||||
|
the live calendar: the shifts are generated by counting back from the day the
|
||||||
|
suite runs, so the oldest week in the window thinned as the week wore on and
|
||||||
|
inflated the baseline every later week was compared against. The climb was
|
||||||
|
reported Sunday through Thursday and vanished on Friday and Saturday. That was a
|
||||||
|
product defect rather than a flaky assertion, and it was fixed in
|
||||||
|
`src/lib/attendance.ts` (commit `88c412f`) by dropping a leading week rostered
|
||||||
|
well below the usual — only from the front, so a genuine collapse in the middle
|
||||||
|
is still a finding. `the seeded overtime climb is detectable on every day of the
|
||||||
|
week` re-runs the detector against 28 consecutive anchors and is what keeps it
|
||||||
|
fixed.
|
||||||
|
|
||||||
|
The type errors were resolved by the TypeScript migration.
|
||||||
|
|
||||||
|
A third check, `the backend fixture is in step with this seed`, failed for its
|
||||||
|
own reasons and is recorded here because it is easily confused with the above:
|
||||||
|
`seed.json` in the sibling backend had drifted from this seed module. Fixed in
|
||||||
|
commit `3ddacf2` by teaching the generator to emit the `users` array the Go
|
||||||
|
seeder reads, rather than by overwriting the fixture. All three pass.
|
||||||
|
|
||||||
|
The heading is kept rather than deleted so that the absence of failures is
|
||||||
|
stated rather than merely implied.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "new-york",
|
"style": "new-york",
|
||||||
"rsc": false,
|
"rsc": false,
|
||||||
"tsx": false,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.js",
|
"config": "tailwind.config.js",
|
||||||
"css": "src/index.css",
|
"css": "src/index.css",
|
||||||
|
|||||||
124
eslint.config.js
124
eslint.config.js
@@ -3,39 +3,17 @@ import pluginJs from "@eslint/js";
|
|||||||
import pluginReact from "eslint-plugin-react";
|
import pluginReact from "eslint-plugin-react";
|
||||||
import pluginReactHooks from "eslint-plugin-react-hooks";
|
import pluginReactHooks from "eslint-plugin-react-hooks";
|
||||||
import pluginUnusedImports from "eslint-plugin-unused-imports";
|
import pluginUnusedImports from "eslint-plugin-unused-imports";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
export default [
|
/**
|
||||||
{
|
* The rule set, written once and applied to JavaScript and TypeScript alike.
|
||||||
files: [
|
*
|
||||||
"src/components/**/*.{js,mjs,cjs,jsx}",
|
* Shared rather than duplicated because the two blocks below differ in exactly
|
||||||
"src/pages/**/*.{js,mjs,cjs,jsx}",
|
* one thing — which parser reads the file — and a rule that applied to `.jsx`
|
||||||
"src/layouts/**/*.{js,mjs,cjs,jsx}",
|
* but not to its `.tsx` successor would make the TypeScript migration look like
|
||||||
"src/hooks/**/*.{js,mjs,cjs,jsx}",
|
* it was tidying the code up. It is not; it renames files and adds types.
|
||||||
],
|
*/
|
||||||
ignores: ["src/lib/**/*", "src/components/ui/**/*"],
|
const rules = {
|
||||||
...pluginJs.configs.recommended,
|
|
||||||
...pluginReact.configs.flat.recommended,
|
|
||||||
languageOptions: {
|
|
||||||
globals: globals.browser,
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 2022,
|
|
||||||
sourceType: "module",
|
|
||||||
ecmaFeatures: {
|
|
||||||
jsx: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
react: {
|
|
||||||
version: "detect",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
react: pluginReact,
|
|
||||||
"react-hooks": pluginReactHooks,
|
|
||||||
"unused-imports": pluginUnusedImports,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"react/jsx-uses-vars": "error",
|
"react/jsx-uses-vars": "error",
|
||||||
"react/jsx-uses-react": "error",
|
"react/jsx-uses-react": "error",
|
||||||
@@ -56,6 +34,88 @@ export default [
|
|||||||
{ ignore: ["cmdk-input-wrapper", "toast-close"] },
|
{ ignore: ["cmdk-input-wrapper", "toast-close"] },
|
||||||
],
|
],
|
||||||
"react-hooks/rules-of-hooks": "error",
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
const plugins = {
|
||||||
|
react: pluginReact,
|
||||||
|
"react-hooks": pluginReactHooks,
|
||||||
|
"unused-imports": pluginUnusedImports,
|
||||||
|
};
|
||||||
|
|
||||||
|
const settings = { react: { version: "detect" } };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which files are linted.
|
||||||
|
*
|
||||||
|
* Unchanged from what this config has always covered — `src/lib` and
|
||||||
|
* `src/components/ui` stay out of it — with `ts` and `tsx` added to every
|
||||||
|
* pattern. That addition is the point: ESLint matches on extension, so the
|
||||||
|
* moment a `.jsx` file became `.tsx` it would have dropped out of the run
|
||||||
|
* silently, and `eslint .` would have gone on exiting 0 while linting less and
|
||||||
|
* less of the codebase. A lint that passes because it checked nothing is worse
|
||||||
|
* than one that fails.
|
||||||
|
*/
|
||||||
|
const directories = ["src/components", "src/pages", "src/layouts", "src/hooks"];
|
||||||
|
const ignores = ["src/lib/**/*", "src/components/ui/**/*"];
|
||||||
|
|
||||||
|
const filesWith = (extensions) =>
|
||||||
|
directories.map((directory) => `${directory}/**/*.{${extensions}}`);
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
files: filesWith("js,mjs,cjs,jsx"),
|
||||||
|
ignores,
|
||||||
|
...pluginJs.configs.recommended,
|
||||||
|
...pluginReact.configs.flat.recommended,
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: "module",
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
plugins,
|
||||||
|
rules,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same block for TypeScript, with the TypeScript parser.
|
||||||
|
*
|
||||||
|
* A separate block rather than one widened glob, so that nothing about how
|
||||||
|
* the existing JavaScript is parsed or reported changes on the day this
|
||||||
|
* lands. `typescript-eslint`'s parser accepts plain JavaScript too, and
|
||||||
|
* merging the two would have been shorter — but it would also have quietly
|
||||||
|
* re-parsed 200-odd existing files, and this phase is meant to be provably
|
||||||
|
* inert.
|
||||||
|
*
|
||||||
|
* Deliberately NOT type-aware (no `projectService`): type errors are
|
||||||
|
* `npm run typecheck`'s job, and asking ESLint to build a program as well
|
||||||
|
* would make every lint run pay for it twice.
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
files: filesWith("ts,tsx,mts,cts"),
|
||||||
|
ignores,
|
||||||
|
...pluginReact.configs.flat.recommended,
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
parser: tseslint.parser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: "module",
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
plugins: { ...plugins, "@typescript-eslint": tseslint.plugin },
|
||||||
|
rules: {
|
||||||
|
...rules,
|
||||||
|
/* TypeScript resolves identifiers itself and reports the ones it cannot,
|
||||||
|
with better messages and without ESLint's browser/node globals list
|
||||||
|
needing to be right. Leaving the core rule on would report every `type`
|
||||||
|
and `interface` name as undefined. */
|
||||||
|
"no-undef": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
},
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"lib": ["esnext", "dom"],
|
|
||||||
"target": "esnext",
|
|
||||||
"checkJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"types": []
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist", "src/components/ui", "src/api", "src/lib"]
|
|
||||||
}
|
|
||||||
401
package-lock.json
generated
401
package-lock.json
generated
@@ -31,10 +31,12 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^11.16.4",
|
"framer-motion": "^11.16.4",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
|
"i18next": "^26.4.2",
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-i18next": "^17.0.16",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
@@ -57,6 +59,7 @@
|
|||||||
"puppeteer-core": "^23.11.1",
|
"puppeteer-core": "^23.11.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
|
"typescript-eslint": "^8.70.0",
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3378,6 +3381,301 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
|
"version": "8.70.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
|
||||||
|
"integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
|
"@typescript-eslint/scope-manager": "8.70.0",
|
||||||
|
"@typescript-eslint/type-utils": "8.70.0",
|
||||||
|
"@typescript-eslint/utils": "8.70.0",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.70.0",
|
||||||
|
"ignore": "^7.0.5",
|
||||||
|
"natural-compare": "^1.4.0",
|
||||||
|
"ts-api-utils": "^2.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@typescript-eslint/parser": "^8.70.0",
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||||
|
"version": "7.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz",
|
||||||
|
"integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/parser": {
|
||||||
|
"version": "8.70.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz",
|
||||||
|
"integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/scope-manager": "8.70.0",
|
||||||
|
"@typescript-eslint/types": "8.70.0",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.70.0",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.70.0",
|
||||||
|
"debug": "^4.4.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
|
"version": "8.70.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz",
|
||||||
|
"integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/tsconfig-utils": "^8.70.0",
|
||||||
|
"@typescript-eslint/types": "^8.70.0",
|
||||||
|
"debug": "^4.4.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
|
"version": "8.70.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz",
|
||||||
|
"integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/types": "8.70.0",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.70.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
|
"version": "8.70.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz",
|
||||||
|
"integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
|
"version": "8.70.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz",
|
||||||
|
"integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/types": "8.70.0",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.70.0",
|
||||||
|
"@typescript-eslint/utils": "8.70.0",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"ts-api-utils": "^2.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/types": {
|
||||||
|
"version": "8.70.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz",
|
||||||
|
"integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
|
"version": "8.70.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz",
|
||||||
|
"integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/project-service": "8.70.0",
|
||||||
|
"@typescript-eslint/tsconfig-utils": "8.70.0",
|
||||||
|
"@typescript-eslint/types": "8.70.0",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.70.0",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"minimatch": "^10.2.2",
|
||||||
|
"semver": "^7.7.3",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"ts-api-utils": "^2.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
|
"version": "5.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
|
"version": "10.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
|
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^5.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/utils": {
|
||||||
|
"version": "8.70.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz",
|
||||||
|
"integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
|
"@typescript-eslint/scope-manager": "8.70.0",
|
||||||
|
"@typescript-eslint/types": "8.70.0",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.70.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
|
"version": "8.70.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz",
|
||||||
|
"integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/types": "8.70.0",
|
||||||
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ungap/structured-clone": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
|
||||||
@@ -6019,6 +6317,15 @@
|
|||||||
"react-is": "^16.7.0"
|
"react-is": "^16.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-parse-stringify": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://locize.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-url-attributes": {
|
"node_modules/html-url-attributes": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||||
@@ -6070,6 +6377,34 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "26.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||||
|
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com/i18next"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5 || ^6 || ^7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -8405,6 +8740,33 @@
|
|||||||
"react-dom": ">=16"
|
"react-dom": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-i18next": {
|
||||||
|
"version": "17.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz",
|
||||||
|
"integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.29.7",
|
||||||
|
"html-parse-stringify": "^4.0.1",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"i18next": ">= 26.2.0",
|
||||||
|
"react": ">= 16.8.0",
|
||||||
|
"typescript": "^5 || ^6 || ^7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -9650,6 +10012,19 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-api-utils": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-interface-checker": {
|
"node_modules/ts-interface-checker": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||||
@@ -9764,7 +10139,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -9774,6 +10149,30 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typescript-eslint": {
|
||||||
|
"version": "8.70.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz",
|
||||||
|
"integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "8.70.0",
|
||||||
|
"@typescript-eslint/parser": "8.70.0",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.70.0",
|
||||||
|
"@typescript-eslint/utils": "8.70.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"test": "node scripts/skill-check.mjs",
|
"test": "node scripts/skill-check.mjs",
|
||||||
"seed:fixture": "node scripts/seed-fixture.mjs --write",
|
"seed:fixture": "node scripts/seed-fixture.mjs --write",
|
||||||
"seed:check": "node scripts/seed-fixture.mjs",
|
"seed:check": "node scripts/seed-fixture.mjs",
|
||||||
"typecheck": "tsc -p ./jsconfig.json",
|
"typecheck": "tsc -p ./tsconfig.json",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test:browser": "node scripts/browser-check.mjs"
|
"test:browser": "node scripts/browser-check.mjs"
|
||||||
},
|
},
|
||||||
@@ -39,10 +39,12 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^11.16.4",
|
"framer-motion": "^11.16.4",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
|
"i18next": "^26.4.2",
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-i18next": "^17.0.16",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
@@ -65,6 +67,7 @@
|
|||||||
"puppeteer-core": "^23.11.1",
|
"puppeteer-core": "^23.11.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
|
"typescript-eslint": "^8.70.0",
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,79 @@ agent layer existed. `skill-check.mjs` asserts against it on every run.
|
|||||||
|
|
||||||
Regenerating it is a deliberate act, and the reason belongs here.
|
Regenerating it is a deliberate act, and the reason belongs here.
|
||||||
|
|
||||||
|
## 2026-09-20 — the three HTML baselines that held invented people were recaptured
|
||||||
|
|
||||||
|
Settled. The section that stood here said six checks failed on purpose and
|
||||||
|
listed the evidence; this is that debt being paid.
|
||||||
|
|
||||||
|
`hiringRecords.js` used to pad the hires list with five invented people
|
||||||
|
(`DEMO_FILL`) so Hired History read as a history rather than as three rows. The
|
||||||
|
padding reached Analytics too, where "total hires" counted eight against a
|
||||||
|
database holding three. It was removed, and because these baselines render with
|
||||||
|
queries disabled, the padding was *all* they contained. Candidates separately
|
||||||
|
became the final-selection queue, which changed what its empty state says.
|
||||||
|
|
||||||
|
Verified before regenerating, so the drift was known rather than assumed. Tag
|
||||||
|
counts for Hired History, baseline -> now:
|
||||||
|
|
||||||
|
<td 40 -> 0 five table rows of people who were never hired
|
||||||
|
<span 51 -> 13
|
||||||
|
<div 78 -> 39
|
||||||
|
<p 26 -> 7
|
||||||
|
<svg 20 -> 8
|
||||||
|
|
||||||
|
Every difference was content that had been fabricated. Analytics lost 269 class
|
||||||
|
attributes and 238 words to the same cause. Candidates gained exactly one class
|
||||||
|
— the description paragraph under "Nobody is awaiting a decision". No styling
|
||||||
|
rule, ordering rule or structural rule changed on any of the three.
|
||||||
|
|
||||||
|
**Recaptured and renamed**, because a file called `pre-migration` that holds
|
||||||
|
post-`dca1842` markup is a lie in the filename:
|
||||||
|
|
||||||
|
hired-history.pre-migration.html -> hired-history.render.html
|
||||||
|
analytics.pre-migration.html -> analytics.render.html
|
||||||
|
candidates.pre-migration.html -> candidates.render.html
|
||||||
|
|
||||||
|
`activity-page`, `candidates-analysis`, `control-center`, `positions` and
|
||||||
|
`talent-pool` still hold genuine pre-migration markup, still pass, and keep the
|
||||||
|
name that says so. The `PAGES` table in `skill-check.mjs` now carries each
|
||||||
|
baseline's FILENAME rather than deriving one suffix for all of them.
|
||||||
|
|
||||||
|
**The migration proof was not thrown away with the baseline.** One check —
|
||||||
|
`Hired History added only identity wrappers` — compared tag tallies to assert
|
||||||
|
that migrating the page added exactly two `<div>`s and changed nothing else.
|
||||||
|
That was true, and it was checkable only while the DATA was frozen as well: the
|
||||||
|
predicate subtracts one whole render from another, so removing the invented
|
||||||
|
hires moved every count and the arithmetic stopped describing wrappers at all.
|
||||||
|
Recapturing would not have rescued it — with the baseline equal to the render
|
||||||
|
the delta is zero, and a predicate demanding two can never hold again. Left in
|
||||||
|
place it would have stayed red for a new reason, which is worse than failing
|
||||||
|
for the old one.
|
||||||
|
|
||||||
|
It is replaced by three checks that read the render itself and need no frozen
|
||||||
|
file, so they keep holding as the page's content changes:
|
||||||
|
|
||||||
|
Hired History wraps exactly the node types registered to wrap
|
||||||
|
Hired History identities are unique and name composed nodes
|
||||||
|
Hired History identity wrappers carry no styling
|
||||||
|
|
||||||
|
Together these say what the tally said — `UiTreeRenderer` encloses a type
|
||||||
|
registered `wrap: true` in `<div {...attrs}>`, every other type takes the
|
||||||
|
attributes on its own root element, and a wrapper contributes identity and no
|
||||||
|
styling. Hired History composes five nodes; two are registered to wrap and
|
||||||
|
produce the two divs, two carry their identity on their own `<section>`, and
|
||||||
|
`hired-extensions-top` is an extension slot that renders nothing while no skill
|
||||||
|
is attached to it. The second check therefore asserts uniqueness and no strays
|
||||||
|
rather than one identity per composed node — a one-to-one rule would be
|
||||||
|
asserting that every extension slot is always filled.
|
||||||
|
|
||||||
|
`carries node identity in the DOM` was described here as needing pre-migration
|
||||||
|
markup. It does not: it reads only the live render, names `chronology` and
|
||||||
|
`records` directly, and is unaffected by any of this. It still passes.
|
||||||
|
|
||||||
|
`npm test` is 1693/1693.
|
||||||
|
|
||||||
|
|
||||||
## 2026-08-27 — the seed gained the three statuses nothing exercised
|
## 2026-08-27 — the seed gained the three statuses nothing exercised
|
||||||
|
|
||||||
`application_status` has seven values. The fixture produced four: `applied`,
|
`application_status` has seven values. The fixture produced four: `applied`,
|
||||||
@@ -63,3 +136,48 @@ with it. It now names the page's topics in a sentence.
|
|||||||
Every intent `kind` was unchanged. No routing moved, no skill matching changed.
|
Every intent `kind` was unchanged. No routing moved, no skill matching changed.
|
||||||
That is why this regeneration was safe: the diff was read first, and it was one
|
That is why this regeneration was safe: the diff was read first, and it was one
|
||||||
cosmetic change on a path that only runs when no agent is configured at all.
|
cosmetic change on a path that only runs when no agent is configured at all.
|
||||||
|
|
||||||
|
## 2026-09-11 — Candidates became the final-selection queue
|
||||||
|
|
||||||
|
`candidates: shows the same words` now fails, and it is the only check this
|
||||||
|
change breaks.
|
||||||
|
|
||||||
|
The page used to list every application the org had ever taken — all seven
|
||||||
|
statuses at once — which made it a second Talent Pool rather than the queue of
|
||||||
|
hiring decisions waiting on a human. It now opens on final selection: interview
|
||||||
|
completed, not hired, not rejected. That is a change to what the page *says*, so
|
||||||
|
a check asserting the page says exactly what it said before was always going to
|
||||||
|
fail. There is no version of this work that leaves those words alone.
|
||||||
|
|
||||||
|
**What drifted, verified before leaving it failing.** The rendered delta is the
|
||||||
|
empty state and nothing else:
|
||||||
|
|
||||||
|
before "…0 of 0 candidates No candidates match your filters"
|
||||||
|
now "…0 of 0 candidates Nobody is awaiting a decision
|
||||||
|
Candidates arrive here once their interview is completed,
|
||||||
|
and leave once they are hired or declined."
|
||||||
|
|
||||||
|
Title, subtitle and toolbar meta are byte-identical. The old copy was not merely
|
||||||
|
different, it was untrue: with no filters applied there is nothing to clear, and
|
||||||
|
"no candidates match your filters" describes a filter that was never set.
|
||||||
|
|
||||||
|
Tag and class counts, baseline → now:
|
||||||
|
|
||||||
|
class="…" 26 -> 27 one inserted: the description paragraph
|
||||||
|
|
||||||
|
One insertion, nothing changed and nothing dropped — which is why
|
||||||
|
`candidates: paints the same styled elements, in the same order` and
|
||||||
|
`candidates: carries node identity in the DOM` both still pass. Those two are
|
||||||
|
the migration proof; only the words moved.
|
||||||
|
|
||||||
|
The new stage filter options cost nothing here. `FilterSelect` is a Radix
|
||||||
|
`Select`, so its options live in a portal that is closed in static markup, and
|
||||||
|
`SelectValue` renders empty on the server — the baseline contains neither the
|
||||||
|
old option labels nor the new ones.
|
||||||
|
|
||||||
|
**Regenerated on 2026-09-20**, with the other two — see the entry at the top of
|
||||||
|
this file. It was held until then because `candidates.pre-migration.html` was
|
||||||
|
load-bearing for the UI node tree proof, and recapturing it earlier would have
|
||||||
|
written post-migration markup into a file named `pre-migration`. The file is now
|
||||||
|
`candidates.render.html` and the proof it was holding up has been replaced by
|
||||||
|
three checks that read the render directly.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/analytics.render.html
Normal file
1
scripts/__baseline__/analytics.render.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/candidates.render.html
Normal file
1
scripts/__baseline__/candidates.render.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
scripts/__baseline__/hired-history.render.html
Normal file
1
scripts/__baseline__/hired-history.render.html
Normal file
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@
|
|||||||
"bartending-training",
|
"bartending-training",
|
||||||
"candidate-analysis",
|
"candidate-analysis",
|
||||||
"candidate-search",
|
"candidate-search",
|
||||||
|
"create-employee-role",
|
||||||
"create-position",
|
"create-position",
|
||||||
"customer-service-training",
|
"customer-service-training",
|
||||||
"executive-summary",
|
"executive-summary",
|
||||||
@@ -161,6 +162,7 @@
|
|||||||
"pageKey": "positions",
|
"pageKey": "positions",
|
||||||
"route": "/admin/positions",
|
"route": "/admin/positions",
|
||||||
"skills": [
|
"skills": [
|
||||||
|
"create-employee-role",
|
||||||
"create-position",
|
"create-position",
|
||||||
"hiring-activity-assistant",
|
"hiring-activity-assistant",
|
||||||
"staffing-risk"
|
"staffing-risk"
|
||||||
@@ -707,6 +709,7 @@
|
|||||||
"pageKey": "talent-pool",
|
"pageKey": "talent-pool",
|
||||||
"route": "/admin/talent-pool",
|
"route": "/admin/talent-pool",
|
||||||
"skills": [
|
"skills": [
|
||||||
|
"create-employee-role",
|
||||||
"talent-pool-analysis"
|
"talent-pool-analysis"
|
||||||
],
|
],
|
||||||
"suggestions": [
|
"suggestions": [
|
||||||
|
|||||||
134
scripts/authreturnto-check.mjs
Normal file
134
scripts/authreturnto-check.mjs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* returnTo validation check.
|
||||||
|
*
|
||||||
|
* Runs the REAL module through Vite's SSR loader, the same way
|
||||||
|
* skill-check.mjs does, so the `@/` alias and the TypeScript compile are the
|
||||||
|
* app's own rather than a reimplementation of them. No test framework is added
|
||||||
|
* for a 130-line module; this follows the convention already in this directory.
|
||||||
|
*
|
||||||
|
* node scripts/authreturnto-check.mjs
|
||||||
|
*
|
||||||
|
* Exits non-zero on failure, so it can gate a build.
|
||||||
|
*
|
||||||
|
* WHAT THIS IS DEFENDING
|
||||||
|
*
|
||||||
|
* safeReturnTo decides whether a URL somebody else supplied may be navigated
|
||||||
|
* to. The cases below are therefore mostly hostile input, and each asserts the
|
||||||
|
* result is null rather than merely "not the attacker's value" — a wrong answer
|
||||||
|
* that is still a navigation is not a pass.
|
||||||
|
*/
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const record = (name, pass, detail = '') => {
|
||||||
|
results.push({ name, pass, detail });
|
||||||
|
console.log(`[${pass ? ' ok ' : ' FAIL '}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ORIGIN = 'https://platform.krowforce.com';
|
||||||
|
|
||||||
|
// A real authorization URL, with every parameter the flow depends on, built the
|
||||||
|
// way the Go server builds it: path + RawQuery, percent-escaped into ?returnTo=.
|
||||||
|
const AUTHORIZE =
|
||||||
|
'/oauth/authorize?client_id=989c3ec1-4afa-4d76-93fa-7f45f1d45e22' +
|
||||||
|
'&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback' +
|
||||||
|
'&response_type=code' +
|
||||||
|
'&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' +
|
||||||
|
'&code_challenge_method=S256' +
|
||||||
|
'&resource=https%3A%2F%2Fplatform.krowforce.com%2Fmcp' +
|
||||||
|
'&scope=krow.read' +
|
||||||
|
'&state=vT7nQ2xK_Lp9';
|
||||||
|
|
||||||
|
const q = (v) => '?returnTo=' + encodeURIComponent(v);
|
||||||
|
|
||||||
|
const server = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' });
|
||||||
|
try {
|
||||||
|
globalThis.window = { location: { origin: ORIGIN, search: '' } };
|
||||||
|
const { safeReturnTo } = await server.ssrLoadModule('/src/lib/authReturnTo.ts');
|
||||||
|
|
||||||
|
/* ── 1–2. The OAuth authorize URL, and its query byte for byte ─────────── */
|
||||||
|
|
||||||
|
const oauth = safeReturnTo(q(AUTHORIZE));
|
||||||
|
record('1. /oauth/authorize is accepted', oauth !== null && oauth.path === AUTHORIZE,
|
||||||
|
oauth ? `via=${oauth.via}` : 'returned null');
|
||||||
|
record('12. and is marked for full browser navigation', oauth?.via === 'browser',
|
||||||
|
`via=${oauth?.via} — React Router has no such route`);
|
||||||
|
|
||||||
|
for (const [name, literal] of [
|
||||||
|
['client_id', 'client_id=989c3ec1-4afa-4d76-93fa-7f45f1d45e22'],
|
||||||
|
['redirect_uri', 'redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback'],
|
||||||
|
['response_type', 'response_type=code'],
|
||||||
|
['code_challenge', 'code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'],
|
||||||
|
['code_challenge_method', 'code_challenge_method=S256'],
|
||||||
|
['resource', 'resource=https%3A%2F%2Fplatform.krowforce.com%2Fmcp'],
|
||||||
|
['scope', 'scope=krow.read'],
|
||||||
|
['state', 'state=vT7nQ2xK_Lp9'],
|
||||||
|
]) {
|
||||||
|
record(`2. ${name} preserved exactly`, Boolean(oauth?.path.includes(literal)));
|
||||||
|
}
|
||||||
|
record('2. percent-encoding is not rewritten', Boolean(oauth?.path.includes('%2F')),
|
||||||
|
'%2F must not become /');
|
||||||
|
record('2. an encoded space survives',
|
||||||
|
safeReturnTo(q('/oauth/authorize?scope=krow.read%20krow.write'))?.path.includes('%20') === true,
|
||||||
|
'%20 must not become +');
|
||||||
|
|
||||||
|
/* ── 3, 13. Internal admin routes keep router navigation ───────────────── */
|
||||||
|
|
||||||
|
const admin = safeReturnTo(q('/admin/candidates?stage=applied'));
|
||||||
|
record('3. /admin/... is accepted', admin?.path === '/admin/candidates?stage=applied');
|
||||||
|
record('13. and is marked for router navigation', admin?.via === 'router',
|
||||||
|
`via=${admin?.via} — must not reload the app`);
|
||||||
|
record('3. bare /admin is accepted', safeReturnTo(q('/admin'))?.via === 'router');
|
||||||
|
|
||||||
|
/* ── 4–10. Hostile and malformed values are refused ────────────────────── */
|
||||||
|
|
||||||
|
const refuse = [
|
||||||
|
['4. external URL', 'https://evil.example'],
|
||||||
|
['4. external URL with our path', 'https://evil.example/oauth/authorize'],
|
||||||
|
['4. userinfo trick', 'https://platform.krowforce.com@evil.example/'],
|
||||||
|
['4. another port on our host', 'https://platform.krowforce.com:8443/admin'],
|
||||||
|
['5. protocol-relative', '//evil.example'],
|
||||||
|
['5. protocol-relative with path', '//evil.example/steal'],
|
||||||
|
['5. triple slash', '///evil.example'],
|
||||||
|
['6. javascript:', 'javascript:alert(document.cookie)'],
|
||||||
|
['6. javascript: mixed case', 'JaVaScRiPt:alert(1)'],
|
||||||
|
['6. tab-obfuscated scheme', 'java\tscript:alert(1)'],
|
||||||
|
['7. data:', 'data:text/html,<script>alert(1)</script>'],
|
||||||
|
['8. backslash', '/\\evil.example'],
|
||||||
|
// These two reach the slash/backslash guard specifically: the path is on
|
||||||
|
// the allowlist, so only the guard can refuse them. Without them the guard
|
||||||
|
// is unfalsifiable — removing it leaves every other case still passing,
|
||||||
|
// which a mutation run showed.
|
||||||
|
['8. backslash in the query of an allowed path', '/admin/candidates?a=\\evil'],
|
||||||
|
['8. backslash escape smuggled past the allowlist', '/admin/x?next=/\\evil.example'],
|
||||||
|
['8. dot-slash-slash', '/.//evil.example'],
|
||||||
|
['9. /admin/login itself', '/admin/login'],
|
||||||
|
['9. /admin/login with a query', '/admin/login?returnTo=%2Fadmin'],
|
||||||
|
['9. bare /login', '/login'],
|
||||||
|
['10. malformed', 'http://[::1'],
|
||||||
|
['10. file scheme', 'file:///etc/passwd'],
|
||||||
|
['10. unrelated backend route', '/oauth/token'],
|
||||||
|
['10. unrelated app route', '/apply'],
|
||||||
|
['10. the MCP endpoint', '/mcp'],
|
||||||
|
];
|
||||||
|
for (const [name, value] of refuse) {
|
||||||
|
const got = safeReturnTo(q(value));
|
||||||
|
record(`${name} is refused`, got === null, got ? `returned ${JSON.stringify(got)}` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 11. Absent or empty falls back safely ─────────────────────────────── */
|
||||||
|
|
||||||
|
for (const [name, search] of [
|
||||||
|
['no query at all', ''],
|
||||||
|
['other parameters only', '?foo=bar'],
|
||||||
|
['empty returnTo', '?returnTo='],
|
||||||
|
]) {
|
||||||
|
record(`11. ${name} returns null`, safeReturnTo(search) === null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = results.filter((r) => !r.pass).length;
|
||||||
|
console.log(`\n${results.length - failed} passed, ${failed} failed\n`);
|
||||||
|
process.exit(failed === 0 ? 0 : 1);
|
||||||
209
scripts/gen-entity-types.mjs
Normal file
209
scripts/gen-entity-types.mjs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Generates `src/types/entities.generated.ts` from the backend's resource
|
||||||
|
* registry.
|
||||||
|
*
|
||||||
|
* node scripts/gen-entity-types.mjs # check: exits non-zero if stale
|
||||||
|
* node scripts/gen-entity-types.mjs --write # regenerate
|
||||||
|
*
|
||||||
|
* The same arrangement as `seed-fixture.mjs`, and for the same reason: two
|
||||||
|
* descriptions of one shape, maintained by hand, only ever fail quietly. Here
|
||||||
|
* the two are the columns Postgres actually has and the fields this app
|
||||||
|
* believes it will be sent.
|
||||||
|
*
|
||||||
|
* ## Why generate rather than transcribe
|
||||||
|
*
|
||||||
|
* `krow-backend/go-api/internal/domain/resources_gen.go` is itself generated,
|
||||||
|
* out of `information_schema`, and says so: "Column names, types, enum values
|
||||||
|
* and nullability are read out of information_schema so they cannot drift from
|
||||||
|
* the migrations." It is the closest thing to the database that can be read
|
||||||
|
* without connecting to one — 15 resources, 298 columns, every one carrying its
|
||||||
|
* kind, its nullability and its permitted values.
|
||||||
|
*
|
||||||
|
* Two hundred and ninety-eight fields retyped by hand would be two hundred and
|
||||||
|
* ninety-eight chances to be confidently wrong, and a type that is wrong is
|
||||||
|
* worse than no type: it is a claim the compiler will defend.
|
||||||
|
*
|
||||||
|
* ## Where the TypeScript types come from
|
||||||
|
*
|
||||||
|
* Not from the Postgres type — from `Column.SelectExpr()` in
|
||||||
|
* `domain/resource.go`, which is what the read projection actually emits. The
|
||||||
|
* two differ, and the differences are the whole point of reading the code
|
||||||
|
* rather than the schema:
|
||||||
|
*
|
||||||
|
* uuid ::text -> string (never a byte array)
|
||||||
|
* numeric ::float8 -> number (never pgtype.Numeric)
|
||||||
|
* date to_char(…,'YYYY-MM-DD') -> string (a date, not a timestamp)
|
||||||
|
* timestamptz to_char(…ISO with ms…) -> string
|
||||||
|
* citext ::text -> string
|
||||||
|
* bigint ::text -> string ← user_activity.id only
|
||||||
|
* int (uncast) -> number
|
||||||
|
*
|
||||||
|
* That last one is the case worth the whole exercise: `user_activity.id` is an
|
||||||
|
* identity bigint and arrives as a STRING, because every id the frontend
|
||||||
|
* handles is an opaque string. A hand-written interface would have called it a
|
||||||
|
* number, and nothing would have contradicted that until a comparison silently
|
||||||
|
* stopped matching.
|
||||||
|
*
|
||||||
|
* Nullability is the column's: `NotNull: true` becomes a required field, its
|
||||||
|
* absence becomes `| null`. Every column is emitted because the projection
|
||||||
|
* emits every column — `Repo.selectList()` maps over `res.Columns` with no
|
||||||
|
* filter, and §4.1 of the API contract states the record is complete.
|
||||||
|
*
|
||||||
|
* ## What this does NOT generate
|
||||||
|
*
|
||||||
|
* Three of the eighteen entities the client knows are absent from the registry
|
||||||
|
* because they are not served by the generic entity machinery: AgentDefinition
|
||||||
|
* and SkillDefinition have dedicated handlers in `httpserver/definitions.go`,
|
||||||
|
* and the user is `httpserver/me.go`. Their shapes are hand-written in
|
||||||
|
* `src/types/user.ts` and `src/types/entities.ts`. This file does not invent
|
||||||
|
* them.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
/** The registry, read from the sibling checkout. */
|
||||||
|
export const REGISTRY_PATH = join(
|
||||||
|
process.cwd(), '..', 'krow-backend', 'go-api', 'internal', 'domain', 'resources_gen.go');
|
||||||
|
|
||||||
|
export const OUTPUT_PATH = join(process.cwd(), 'src', 'types', 'entities.generated.ts');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind -> TypeScript, following `Column.SelectExpr()` rather than the column's
|
||||||
|
* Postgres type. `pgType` is needed because two kinds cast conditionally.
|
||||||
|
*/
|
||||||
|
function tsTypeFor(kind, pgType, enumValues) {
|
||||||
|
switch (kind) {
|
||||||
|
case 'KindUUID': return 'string'; // ::text
|
||||||
|
case 'KindString': return 'string'; // citext also ::text
|
||||||
|
case 'KindBool': return 'boolean';
|
||||||
|
case 'KindFloat': return 'number'; // ::float8
|
||||||
|
case 'KindInt': return pgType === 'bigint' ? 'string' : 'number';
|
||||||
|
case 'KindDate': return 'string'; // 'YYYY-MM-DD'
|
||||||
|
case 'KindTimestamp': return 'string'; // ISO-8601 with ms
|
||||||
|
case 'KindTextArray': return 'string[]';
|
||||||
|
case 'KindEnum': return enumValues.map((v) => `'${v}'`).join(' | ');
|
||||||
|
case 'KindJSON': return 'unknown'; // jsonb: shape is the column's own
|
||||||
|
default: throw new Error(`unmapped Kind: ${kind}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses the Go registry into `[{ name, path, table, columns }]`. */
|
||||||
|
export function parseRegistry(source) {
|
||||||
|
const resources = [];
|
||||||
|
/* Each resource opens with a lone `{` one tab in, then a `Name:` line two
|
||||||
|
tabs in. Splitting on that opener gives one block per resource; the header
|
||||||
|
match rejects anything that is not one. */
|
||||||
|
const blocks = source.split(/\n\t\{\n/).slice(1);
|
||||||
|
for (const block of blocks) {
|
||||||
|
const head = block.match(/^\t\tName: "(\w+)", Path: "([\w-]+)", Table: "(\w+)",/);
|
||||||
|
if (!head) continue;
|
||||||
|
const [, name, path, table] = head;
|
||||||
|
const columns = [];
|
||||||
|
const colRe = /\{Name: "(\w+)", Kind: (Kind\w+), PGType: "([^"]+)"([^}]*)\}/g;
|
||||||
|
let m;
|
||||||
|
while ((m = colRe.exec(block)) !== null) {
|
||||||
|
const [, colName, kind, pgType, rest] = m;
|
||||||
|
/* `rest` is already cut at the first `}`, which for an enum column is
|
||||||
|
the one closing `[]string{...}` — so the values are in it but the
|
||||||
|
brace is not. Requiring a closing brace here matched nothing and
|
||||||
|
silently produced empty unions. */
|
||||||
|
const enumMatch = rest.match(/Enum: \[\]string\{([^}]*)/);
|
||||||
|
const enumValues = enumMatch
|
||||||
|
? [...enumMatch[1].matchAll(/"([^"]*)"/g)].map((e) => e[1])
|
||||||
|
: [];
|
||||||
|
columns.push({
|
||||||
|
name: colName,
|
||||||
|
kind,
|
||||||
|
pgType,
|
||||||
|
notNull: /NotNull: true/.test(rest),
|
||||||
|
readOnly: /ReadOnly: true/.test(rest),
|
||||||
|
required: /Required: true/.test(rest),
|
||||||
|
enumValues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (columns.length) resources.push({ name, path, table, columns });
|
||||||
|
}
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLAGS = (c) => {
|
||||||
|
const notes = [];
|
||||||
|
if (c.readOnly) notes.push('server-owned');
|
||||||
|
if (c.required) notes.push('required on create');
|
||||||
|
return notes.length ? ` /** ${notes.join('; ')}. */\n` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One resource -> one exported interface. */
|
||||||
|
function renderInterface(res) {
|
||||||
|
const fields = res.columns.map((c) => {
|
||||||
|
const ts = tsTypeFor(c.kind, c.pgType, c.enumValues);
|
||||||
|
/* A nullable column returns JSON null, so the field is present and null
|
||||||
|
rather than absent. `?:` would describe a key that can be missing, which
|
||||||
|
is not what the projection does. */
|
||||||
|
const type = c.notNull ? ts : `${ts} | null`;
|
||||||
|
return `${FLAGS(c)} ${c.name}: ${type};`;
|
||||||
|
}).join('\n');
|
||||||
|
return `/** \`${res.path}\` — the \`${res.table}\` table, every column the projection returns. */\nexport interface ${res.name} {\n${fields}\n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render(resources) {
|
||||||
|
const banner = `/**
|
||||||
|
* GENERATED FILE — DO NOT EDIT BY HAND.
|
||||||
|
*
|
||||||
|
* Regenerate with: node scripts/gen-entity-types.mjs --write
|
||||||
|
* Source of truth: krow-backend/go-api/internal/domain/resources_gen.go
|
||||||
|
* (itself generated from information_schema)
|
||||||
|
*
|
||||||
|
* Field types follow \`Column.SelectExpr()\` in \`domain/resource.go\` — what the
|
||||||
|
* read projection emits — not the raw Postgres type. See the generator's header
|
||||||
|
* for the mapping and for why the two differ.
|
||||||
|
*
|
||||||
|
* ${resources.length} resources, ${resources.reduce((n, r) => n + r.columns.length, 0)} columns.
|
||||||
|
*/
|
||||||
|
`;
|
||||||
|
const interfaces = resources.map(renderInterface).join('\n\n');
|
||||||
|
const names = resources.map((r) => ` | '${r.name}'`).join('\n');
|
||||||
|
const mapEntries = resources.map((r) => ` ${r.name}: ${r.name};`).join('\n');
|
||||||
|
const tail = `/** The entity names the generic registry serves. Three more exist — see \`entities.ts\`. */
|
||||||
|
export type GeneratedEntityName =
|
||||||
|
${names};
|
||||||
|
|
||||||
|
/** Entity name -> its record type, for looking a record up by name. */
|
||||||
|
export interface GeneratedEntityRecords {
|
||||||
|
${mapEntries}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
return `${banner}\n${interfaces}\n\n${tail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CLI ──────────────────────────────────────────────────────────────────── */
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
if (!existsSync(REGISTRY_PATH)) {
|
||||||
|
console.log('krow-backend is not checked out beside this repo; entity types not checked here.');
|
||||||
|
console.log('The types in src/types/entities.generated.ts are committed, so this is not fatal.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = parseRegistry(readFileSync(REGISTRY_PATH, 'utf8'));
|
||||||
|
if (!resources.length) {
|
||||||
|
console.error('parsed no resources out of resources_gen.go — has its shape changed?');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const generated = render(resources);
|
||||||
|
|
||||||
|
if (process.argv.includes('--write')) {
|
||||||
|
writeFileSync(OUTPUT_PATH, generated);
|
||||||
|
const cols = resources.reduce((n, r) => n + r.columns.length, 0);
|
||||||
|
console.log(`entities.generated.ts written — ${resources.length} resources, ${cols} columns.`);
|
||||||
|
} else if (!existsSync(OUTPUT_PATH)) {
|
||||||
|
console.error('entities.generated.ts is missing. Run: node scripts/gen-entity-types.mjs --write');
|
||||||
|
process.exit(1);
|
||||||
|
} else if (readFileSync(OUTPUT_PATH, 'utf8') !== generated) {
|
||||||
|
console.error('entities.generated.ts is stale — it no longer matches the backend registry.');
|
||||||
|
console.error('Run: node scripts/gen-entity-types.mjs --write');
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log('entities.generated.ts is in step with the backend registry.');
|
||||||
|
}
|
||||||
|
}
|
||||||
58
scripts/i18n-audit.mjs
Normal file
58
scripts/i18n-audit.mjs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Every t('key') in the source, checked against the catalogues.
|
||||||
|
*
|
||||||
|
* A key the code calls and the catalogue lacks renders as humanised English at
|
||||||
|
* best, so this is the check that says the extraction is actually complete.
|
||||||
|
* The reverse — a catalogue entry nothing calls — is only waste, and reported
|
||||||
|
* separately.
|
||||||
|
*/
|
||||||
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const walk = (dir, acc = []) => {
|
||||||
|
for (const e of readdirSync(dir)) {
|
||||||
|
if (e === 'node_modules' || e.startsWith('.')) continue;
|
||||||
|
const p = join(dir, e);
|
||||||
|
if (statSync(p).isDirectory()) walk(p, acc);
|
||||||
|
else if (/\.(tsx|ts|jsx|js)$/.test(p)) acc.push(p);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flat = (obj, prefix = '', out = {}) => {
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
const key = prefix ? `${prefix}.${k}` : k;
|
||||||
|
if (v && typeof v === 'object') flat(v, key, out);
|
||||||
|
else out[key] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const en = flat(JSON.parse(readFileSync('src/lib/i18n/locales/en.json', 'utf8')));
|
||||||
|
const es = flat(JSON.parse(readFileSync('src/lib/i18n/locales/es.json', 'utf8')));
|
||||||
|
|
||||||
|
const used = new Set();
|
||||||
|
const dynamic = [];
|
||||||
|
for (const file of walk('src')) {
|
||||||
|
const src = readFileSync(file, 'utf8');
|
||||||
|
for (const m of src.matchAll(/\bt\(\s*'([^']+)'/g)) used.add(m[1]);
|
||||||
|
for (const m of src.matchAll(/\bt\(\s*`([^`]+)`/g)) dynamic.push(`${file}: ${m[1]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A plural key is stored as name_one / name_other, so the base name is never
|
||||||
|
itself in the catalogue and is not missing. */
|
||||||
|
const present = (k) => k in en || `${k}_other` in en || `${k}_one` in en;
|
||||||
|
|
||||||
|
const missingEn = [...used].filter((k) => !present(k)).sort();
|
||||||
|
const missingEs = [...used].filter((k) => present(k) && !(k in es || `${k}_other` in es)).sort();
|
||||||
|
const unused = Object.keys(en).filter((k) => !used.has(k) && !used.has(k.replace(/_(one|other)$/, ''))).sort();
|
||||||
|
|
||||||
|
console.log(`\n t() calls with a literal key : ${used.size}`);
|
||||||
|
console.log(` built from a template : ${dynamic.length} (checked by hand)`);
|
||||||
|
console.log(` MISSING from en.json : ${missingEn.length}`);
|
||||||
|
console.log(` MISSING from es.json : ${missingEs.length}`);
|
||||||
|
console.log(` in en.json, never called : ${unused.length}`);
|
||||||
|
|
||||||
|
for (const k of missingEn.slice(0, 15)) console.log(` missing en: ${k}`);
|
||||||
|
for (const k of missingEs.slice(0, 10)) console.log(` missing es: ${k}`);
|
||||||
|
if (missingEn.length) process.exitCode = 1;
|
||||||
126
scripts/i18n-coverage.mjs
Normal file
126
scripts/i18n-coverage.mjs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* How much of each page is actually translated.
|
||||||
|
*
|
||||||
|
* Renders every migrated page twice — once in English, once in Spanish — and
|
||||||
|
* reports the text that did NOT change. That residue is the untranslated part,
|
||||||
|
* and it is the only honest measure of coverage: a catalogue can be full of
|
||||||
|
* Spanish that no component ever calls, and counting keys would score that as
|
||||||
|
* finished.
|
||||||
|
*
|
||||||
|
* Reports rather than asserts. The point is a number per page while the work is
|
||||||
|
* half done; `skill-check.mjs` is where a finished page gets a check that keeps
|
||||||
|
* it finished.
|
||||||
|
*
|
||||||
|
* node scripts/i18n-coverage.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import React from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { withSourceResolution } from './ssr-resolve.mjs';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
/* The pages reach for `window` on the way up — a media query, a resize
|
||||||
|
listener. The same minimal stub the skill-check uses, installed before any
|
||||||
|
page module is loaded. */
|
||||||
|
if (!('window' in globalThis)) {
|
||||||
|
globalThis.window = {
|
||||||
|
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||||||
|
addEventListener() {}, removeEventListener() {},
|
||||||
|
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
root: ROOT,
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
appType: 'custom',
|
||||||
|
logLevel: 'error',
|
||||||
|
resolve: { alias: { 'react-hot-toast': join(ROOT, 'scripts/stubs/react-hot-toast.js') } },
|
||||||
|
});
|
||||||
|
withSourceResolution(server);
|
||||||
|
|
||||||
|
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
|
||||||
|
const { MemoryRouter } = await import('react-router-dom');
|
||||||
|
const { UiEditingProvider } = await server.ssrLoadModule('/src/components/ui-tree/UiEditingProvider.jsx');
|
||||||
|
const i18n = (await server.ssrLoadModule('/src/lib/i18n/index.ts')).default;
|
||||||
|
|
||||||
|
const PAGES = [
|
||||||
|
['Control Center', '/src/pages/admin/ControlCenter.jsx', '/admin', 'control-center'],
|
||||||
|
['Positions', '/src/pages/admin/Positions.jsx', '/admin/positions', 'positions'],
|
||||||
|
['Candidates', '/src/pages/admin/Candidates.jsx', '/admin/candidates', 'candidates'],
|
||||||
|
['Talent Pool', '/src/pages/admin/TalentPool.jsx', '/admin/talent-pool', 'talent-pool'],
|
||||||
|
['Analytics', '/src/pages/admin/Analytics.jsx', '/admin/analytics', 'analytics'],
|
||||||
|
['Hired History', '/src/pages/admin/HiredHistory.jsx', '/admin/hired', 'hired-history'],
|
||||||
|
['Activity', '/src/pages/admin/Activity.jsx', '/admin/activity', 'activity'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/* Text only, split into phrases on the punctuation the interface uses to
|
||||||
|
separate one label from the next, so "17 total" counts separately from the
|
||||||
|
heading above it. Numbers and symbols alone are not evidence either way —
|
||||||
|
"27" is the same in both languages — so anything without a letter is
|
||||||
|
dropped rather than scored as translated. */
|
||||||
|
const phrases = (html) =>
|
||||||
|
html
|
||||||
|
.replace(/<[^>]*>/g, '\n')
|
||||||
|
.split(/[\n·|•]+/)
|
||||||
|
.map((p) => p.replace(/\s+/g, ' ').trim())
|
||||||
|
.filter((p) => p.length > 2 && /[A-Za-z]{3}/.test(p));
|
||||||
|
|
||||||
|
async function render(path, page) {
|
||||||
|
const Page = (await server.ssrLoadModule(path)).default;
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
React.createElement(MemoryRouter, { initialEntries: [page.route] },
|
||||||
|
React.createElement(QueryClientProvider, { client },
|
||||||
|
React.createElement(UiEditingProvider, { page: page.key },
|
||||||
|
React.createElement(Page))))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n Translation coverage — text that changes when the language does\n');
|
||||||
|
|
||||||
|
let totalSame = 0;
|
||||||
|
let totalAll = 0;
|
||||||
|
|
||||||
|
for (const [name, path, route, key] of PAGES) {
|
||||||
|
let en;
|
||||||
|
let es;
|
||||||
|
try {
|
||||||
|
await i18n.changeLanguage('en');
|
||||||
|
en = phrases(await render(path, { route, key }));
|
||||||
|
await i18n.changeLanguage('es');
|
||||||
|
es = phrases(await render(path, { route, key }));
|
||||||
|
await i18n.changeLanguage('en');
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ${name.padEnd(16)} could not render — ${String(err.message).slice(0, 60)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A phrase present in BOTH renders is one the switch did not touch. Proper
|
||||||
|
nouns and figures land here too, which is why the sample below is printed:
|
||||||
|
a human reads it and sees at once whether the residue is "KROW Forge" or a
|
||||||
|
whole untranslated paragraph. */
|
||||||
|
const esSet = new Set(es);
|
||||||
|
const untouched = en.filter((p) => esSet.has(p));
|
||||||
|
|
||||||
|
totalSame += untouched.length;
|
||||||
|
totalAll += en.length;
|
||||||
|
|
||||||
|
const pct = en.length ? Math.round(((en.length - untouched.length) / en.length) * 100) : 0;
|
||||||
|
const bar = '█'.repeat(Math.round(pct / 5)).padEnd(20, '·');
|
||||||
|
console.log(` ${name.padEnd(16)} ${bar} ${String(pct).padStart(3)}% ${en.length - untouched.length}/${en.length} phrases`);
|
||||||
|
|
||||||
|
if (untouched.length) {
|
||||||
|
const sample = untouched.filter((p) => /[a-z]{4}/.test(p)).slice(0, 4);
|
||||||
|
if (sample.length) console.log(` ${''.padEnd(16)} still English: ${sample.join(' / ').slice(0, 96)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overall = totalAll ? Math.round(((totalAll - totalSame) / totalAll) * 100) : 0;
|
||||||
|
console.log(`\n Overall: ${overall}% of rendered phrases change with the language.\n`);
|
||||||
|
|
||||||
|
await server.close();
|
||||||
307
scripts/i18n-extract.mjs
Normal file
307
scripts/i18n-extract.mjs
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* Lift hard-coded English out of components and into the catalogue.
|
||||||
|
*
|
||||||
|
* node scripts/i18n-extract.mjs --dry src/components/krow
|
||||||
|
* node scripts/i18n-extract.mjs src/components/krow/SomeCard.tsx
|
||||||
|
*
|
||||||
|
* An AST codemod rather than a regex pass, because the hard part is not finding
|
||||||
|
* the string — it is knowing which component encloses it, so `const { t } =
|
||||||
|
* useTranslation()` lands in the right function and not in a module-level
|
||||||
|
* constant where hooks cannot run.
|
||||||
|
*
|
||||||
|
* WHAT IT WILL TOUCH
|
||||||
|
* - JSX text children that are entirely static words
|
||||||
|
* - string literals on a short list of text-bearing attributes
|
||||||
|
*
|
||||||
|
* WHAT IT LEAVES ALONE, and why each one would be a bug:
|
||||||
|
* - anything inside a module-level object or array; those are built once at
|
||||||
|
* import, so a t() there freezes the first language the app loaded
|
||||||
|
* - strings with no letters, single characters, and pure punctuation
|
||||||
|
* - `className`, `id`, `key`, `type`, `name`, routes, urls, test ids
|
||||||
|
* - text inside <code>, <pre>, <script> and <style>
|
||||||
|
* - a component it cannot find a function body for
|
||||||
|
*
|
||||||
|
* Keys are `<namespace>.<slug>`, the namespace from the file name, the slug
|
||||||
|
* from the words themselves — so a key reads as what it says, and moving a
|
||||||
|
* heading between files does not rename it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
|
import { join, basename, extname } from 'node:path';
|
||||||
|
import { parse } from '@babel/parser';
|
||||||
|
import _traverse from '@babel/traverse';
|
||||||
|
import _generate from '@babel/generator';
|
||||||
|
import * as t from '@babel/types';
|
||||||
|
|
||||||
|
const traverse = _traverse.default || _traverse;
|
||||||
|
const generate = _generate.default || _generate;
|
||||||
|
|
||||||
|
const DRY = process.argv.includes('--dry');
|
||||||
|
const TARGETS = process.argv.slice(2).filter((a) => !a.startsWith('--'));
|
||||||
|
|
||||||
|
const TEXT_ATTRS = new Set([
|
||||||
|
'title', 'label', 'description', 'placeholder', 'subtitle', 'meta',
|
||||||
|
'aria-label', 'alt', 'emptyLabel', 'heading', 'hint', 'caption', 'tooltip',
|
||||||
|
'confirmLabel', 'cancelLabel', 'actionLabel', 'emptyTitle', 'emptyDescription',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SKIP_ELEMENTS = new Set(['code', 'pre', 'script', 'style']);
|
||||||
|
|
||||||
|
/* A string worth translating has letters, more than one of them, and is not a
|
||||||
|
token that only looks like prose (a route, a url, a css class, an enum). */
|
||||||
|
const translatable = (s) => {
|
||||||
|
const v = s.trim();
|
||||||
|
if (v.length < 2) return false;
|
||||||
|
if (!/[A-Za-z]{2}/.test(v)) return false;
|
||||||
|
if (/^[a-z0-9_-]+$/.test(v)) return false; // enum / slug / class
|
||||||
|
if (/^(https?:|\/|#|data:|mailto:)/.test(v)) return false;
|
||||||
|
if (/^[A-Z0-9_]+$/.test(v)) return false; // CONSTANT_CASE
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const slug = (s) =>
|
||||||
|
s.trim().toLowerCase()
|
||||||
|
.replace(/[’']/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.split(' ')
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1)))
|
||||||
|
.join('') || 'text';
|
||||||
|
|
||||||
|
const namespaceFor = (file) => {
|
||||||
|
const base = basename(file, extname(file));
|
||||||
|
if (base === 'nodes' || base === 'index') {
|
||||||
|
const parent = basename(join(file, '..'));
|
||||||
|
return parent.replace(/[^A-Za-z0-9]+/g, '');
|
||||||
|
}
|
||||||
|
return base.charAt(0).toLowerCase() + base.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The nearest enclosing function that is a component (capitalised name). */
|
||||||
|
function enclosingComponent(path) {
|
||||||
|
let p = path;
|
||||||
|
while (p) {
|
||||||
|
if (p.isFunctionDeclaration() || p.isFunctionExpression() || p.isArrowFunctionExpression()) {
|
||||||
|
const id = p.node.id?.name
|
||||||
|
|| (p.parentPath?.isVariableDeclarator() && p.parentPath.node.id?.name);
|
||||||
|
/* Only a component with a BLOCK body can take the hook. An arrow whose
|
||||||
|
body is the JSX itself (`const X = () => <p>Text</p>`) would need
|
||||||
|
rewriting into a block first, and a codemod that reshapes a component
|
||||||
|
to translate one word is doing more than it was asked. Those are left
|
||||||
|
alone and reported, so they can be done by hand. */
|
||||||
|
if (id && /^[A-Z]/.test(id)) {
|
||||||
|
return t.isBlockStatement(p.node.body) ? p : null;
|
||||||
|
}
|
||||||
|
/* An inline render callback — keep walking out to the component that
|
||||||
|
owns it, so one hook serves both. */
|
||||||
|
}
|
||||||
|
p = p.parentPath;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the node sits in a module-level object/array literal. */
|
||||||
|
function atModuleScope(path) {
|
||||||
|
let p = path;
|
||||||
|
while (p) {
|
||||||
|
if (p.isFunction()) return false;
|
||||||
|
if (p.isProgram()) return true;
|
||||||
|
p = p.parentPath;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processFile(file, catalogue) {
|
||||||
|
const src = readFileSync(file, 'utf8');
|
||||||
|
if (!/<[A-Za-z]/.test(src)) return null;
|
||||||
|
|
||||||
|
let ast;
|
||||||
|
try {
|
||||||
|
ast = parse(src, {
|
||||||
|
sourceType: 'module',
|
||||||
|
plugins: ['jsx', 'typescript', 'classProperties', 'decorators-legacy'],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { file, skipped: 'could not parse' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A file that already binds `t` to something of its own — a theme token, a
|
||||||
|
translate helper, a loop variable — cannot also take the hook's `t`
|
||||||
|
without shadowing it. Skipped whole rather than half converted: the hook
|
||||||
|
would compile and then silently resolve to the wrong thing. */
|
||||||
|
if (/\b(?:const|let|var)\s+t\b\s*=/.test(src) || /\(\s*t\s*[,)]/.test(src)) {
|
||||||
|
return { file, skipped: 'already binds `t`' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ns = namespaceFor(file);
|
||||||
|
const needHook = new Set();
|
||||||
|
const found = [];
|
||||||
|
/* Character ranges to splice into the ORIGINAL text. Babel's generator would
|
||||||
|
reprint the whole file — restyling commas, quotes and JSX that nobody
|
||||||
|
asked it to touch — and a 52-file diff of incidental reformatting is one
|
||||||
|
nobody can review. */
|
||||||
|
const edits = [];
|
||||||
|
|
||||||
|
const keyFor = (text) => {
|
||||||
|
const key = `${ns}.${slug(text)}`;
|
||||||
|
const existing = catalogue.get(key);
|
||||||
|
if (existing && existing !== text) {
|
||||||
|
/* Same words, different file, different sentence: disambiguate rather
|
||||||
|
than let one overwrite the other. */
|
||||||
|
let i = 2;
|
||||||
|
while (catalogue.has(`${key}${i}`) && catalogue.get(`${key}${i}`) !== text) i += 1;
|
||||||
|
return `${key}${i}`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
traverse(ast, {
|
||||||
|
JSXText(path) {
|
||||||
|
const raw = path.node.value;
|
||||||
|
const text = raw.trim();
|
||||||
|
if (!translatable(text)) return;
|
||||||
|
const parent = path.parent;
|
||||||
|
if (t.isJSXElement(parent)) {
|
||||||
|
const name = parent.openingElement.name;
|
||||||
|
if (t.isJSXIdentifier(name) && SKIP_ELEMENTS.has(name.name)) return;
|
||||||
|
}
|
||||||
|
if (atModuleScope(path)) return;
|
||||||
|
const comp = enclosingComponent(path);
|
||||||
|
if (!comp) return;
|
||||||
|
|
||||||
|
const key = keyFor(text);
|
||||||
|
catalogue.set(key, text);
|
||||||
|
produced.add(key);
|
||||||
|
found.push({ key, text });
|
||||||
|
needHook.add(comp);
|
||||||
|
/* Only the trimmed span is replaced, so the surrounding newlines and
|
||||||
|
indentation survive exactly as written. */
|
||||||
|
const lead = raw.length - raw.trimStart().length;
|
||||||
|
edits.push({
|
||||||
|
start: path.node.start + lead,
|
||||||
|
end: path.node.start + lead + text.length,
|
||||||
|
text: `{t('${key}')}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
JSXAttribute(path) {
|
||||||
|
const name = path.node.name;
|
||||||
|
const attr = t.isJSXIdentifier(name) ? name.name
|
||||||
|
: t.isJSXNamespacedName(name) ? `${name.namespace.name}:${name.name.name}` : null;
|
||||||
|
if (!attr || !TEXT_ATTRS.has(attr)) return;
|
||||||
|
const value = path.node.value;
|
||||||
|
if (!t.isStringLiteral(value)) return;
|
||||||
|
const text = value.value.trim();
|
||||||
|
if (!translatable(text)) return;
|
||||||
|
if (atModuleScope(path)) return;
|
||||||
|
const comp = enclosingComponent(path);
|
||||||
|
if (!comp) return;
|
||||||
|
|
||||||
|
const key = keyFor(text);
|
||||||
|
catalogue.set(key, text);
|
||||||
|
produced.add(key);
|
||||||
|
found.push({ key, text });
|
||||||
|
needHook.add(comp);
|
||||||
|
edits.push({ start: value.start, end: value.end, text: `{t('${key}')}` });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!found.length) return null;
|
||||||
|
|
||||||
|
/* One hook per component that now calls t(), on its own line just inside the
|
||||||
|
body. A component that already destructures t is left alone. */
|
||||||
|
for (const comp of needHook) {
|
||||||
|
const body = comp.node.body;
|
||||||
|
if (!t.isBlockStatement(body)) continue;
|
||||||
|
const already = body.body.some(
|
||||||
|
(st) => t.isVariableDeclaration(st)
|
||||||
|
&& st.declarations.some((d) => t.isObjectPattern(d.id)
|
||||||
|
&& d.id.properties.some((pr) => t.isObjectProperty(pr) && pr.key?.name === 't'))
|
||||||
|
);
|
||||||
|
if (already) continue;
|
||||||
|
/* Indentation copied from the first statement, so the inserted line sits
|
||||||
|
where a person would have typed it. */
|
||||||
|
const first = body.body[0];
|
||||||
|
const lineStart = first ? src.lastIndexOf('\n', first.start) + 1 : -1;
|
||||||
|
const indent = first ? src.slice(lineStart, first.start).match(/^[ \t]*/)[0] : ' ';
|
||||||
|
edits.push({
|
||||||
|
start: body.start + 1,
|
||||||
|
end: body.start + 1,
|
||||||
|
text: `\n${indent}const { t } = useTranslation();`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Applied last-first so earlier offsets stay valid. */
|
||||||
|
let out = src;
|
||||||
|
edits.sort((a, b) => b.start - a.start);
|
||||||
|
for (const e of edits) out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
||||||
|
|
||||||
|
if (!/from ['"]react-i18next['"]/.test(out)) {
|
||||||
|
const lines = out.split('\n');
|
||||||
|
let last = -1;
|
||||||
|
for (let i = 0; i < Math.min(lines.length, 60); i += 1) {
|
||||||
|
if (/^import .*;\s*$/.test(lines[i])) last = i;
|
||||||
|
}
|
||||||
|
lines.splice(last + 1, 0, "import { useTranslation } from 'react-i18next';");
|
||||||
|
out = lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DRY) writeFileSync(file, out);
|
||||||
|
return { file, found };
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(target, acc = []) {
|
||||||
|
const st = statSync(target);
|
||||||
|
if (st.isFile()) {
|
||||||
|
if (/\.(tsx|jsx)$/.test(target)) acc.push(target);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
for (const entry of readdirSync(target)) {
|
||||||
|
if (entry === 'node_modules' || entry.startsWith('.')) continue;
|
||||||
|
walk(join(target, entry), acc);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seeded from the catalogue already on disk, so a key taken by a DIFFERENT
|
||||||
|
string in an earlier run is seen as taken. Without this each run starts
|
||||||
|
blank, two runs mint the same key for two different sentences, and the
|
||||||
|
merge silently keeps one of them — which is how "Search positions" became
|
||||||
|
"Search positions...". */
|
||||||
|
const produced = new Set();
|
||||||
|
const catalogue = new Map();
|
||||||
|
try {
|
||||||
|
const existing = JSON.parse(readFileSync('src/lib/i18n/locales/en.json', 'utf8'));
|
||||||
|
for (const [ns, items] of Object.entries(existing)) {
|
||||||
|
if (items && typeof items === 'object') {
|
||||||
|
for (const [leaf, val] of Object.entries(items)) catalogue.set(`${ns}.${leaf}`, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* first run, nothing to seed from */ }
|
||||||
|
const files = TARGETS.flatMap((tg) => walk(tg));
|
||||||
|
let changed = 0;
|
||||||
|
let strings = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const res = processFile(file, catalogue);
|
||||||
|
if (!res) continue;
|
||||||
|
if (res.skipped) { console.log(` skip ${file} — ${res.skipped}`); continue; }
|
||||||
|
changed += 1;
|
||||||
|
strings += res.found.length;
|
||||||
|
console.log(` ${DRY ? 'would do' : 'done'} ${file.replace('src/', '')} — ${res.found.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n ${changed} files, ${strings} strings${DRY ? ' (dry run, nothing written)' : ''}`);
|
||||||
|
|
||||||
|
/* ONLY the keys this run created. The catalogue is seeded from disk so
|
||||||
|
collisions are visible, and writing it back out whole would re-emit every
|
||||||
|
existing entry — including the nested ones this format does not model. */
|
||||||
|
const additions = {};
|
||||||
|
for (const [key, text] of catalogue) {
|
||||||
|
if (!produced.has(key)) continue;
|
||||||
|
const [ns, leaf] = [key.slice(0, key.indexOf('.')), key.slice(key.indexOf('.') + 1)];
|
||||||
|
(additions[ns] ||= {})[leaf] = text;
|
||||||
|
}
|
||||||
|
writeFileSync('scripts/__i18n-additions.json', `${JSON.stringify(additions, null, 2)}\n`);
|
||||||
|
console.log(' catalogue written to scripts/__i18n-additions.json');
|
||||||
@@ -16,6 +16,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { createServer } from 'vite';
|
import { createServer } from 'vite';
|
||||||
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
|
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
|
||||||
|
import { withSourceResolution } from './ssr-resolve.mjs';
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ const server = await createServer({
|
|||||||
logLevel: 'error',
|
logLevel: 'error',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Resolves the capture's module paths whatever extension they carry during the
|
||||||
|
TypeScript migration. The comparison this script performs is unaffected: it
|
||||||
|
loads the same modules and serialises the same values. */
|
||||||
|
withSourceResolution(server, ROOT);
|
||||||
|
|
||||||
const captured = await captureBaseline(server);
|
const captured = await captureBaseline(server);
|
||||||
await server.close();
|
await server.close();
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { join } from 'node:path';
|
|||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync } from 'node:fs';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { withSourceResolution } from './ssr-resolve.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The design system reads `window` when its modules evaluate, which is a
|
* The design system reads `window` when its modules evaluate, which is a
|
||||||
@@ -97,6 +98,9 @@ if (process.argv[1] && process.argv[1].endsWith('render-page.mjs')) {
|
|||||||
logLevel: 'error',
|
logLevel: 'error',
|
||||||
resolve: { alias: { 'react-hot-toast': join(process.cwd(), 'scripts/stubs/react-hot-toast.js') } },
|
resolve: { alias: { 'react-hot-toast': join(process.cwd(), 'scripts/stubs/react-hot-toast.js') } },
|
||||||
});
|
});
|
||||||
|
/* The page path arrives on the command line with whatever extension it has
|
||||||
|
today; during the TypeScript migration that may be .jsx or .tsx. */
|
||||||
|
withSourceResolution(server, process.cwd());
|
||||||
try {
|
try {
|
||||||
const html = await renderPage(server, `/${modulePath.replace(/^\//, '')}`, { route: route || '/' });
|
const html = await renderPage(server, `/${modulePath.replace(/^\//, '')}`, { route: route || '/' });
|
||||||
writeFileSync(out, html);
|
writeFileSync(out, html);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { createServer } from 'vite';
|
|||||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { withSourceResolution } from './ssr-resolve.mjs';
|
||||||
|
|
||||||
export const FIXTURE_PATH = join(process.cwd(), '..', 'krow-backend', 'seed', 'fixtures', 'seed.json');
|
export const FIXTURE_PATH = join(process.cwd(), '..', 'krow-backend', 'seed', 'fixtures', 'seed.json');
|
||||||
|
|
||||||
@@ -30,10 +31,31 @@ export const GENERATED_NOTE =
|
|||||||
|
|
||||||
export function fixtureFrom(seedModule) {
|
export function fixtureFrom(seedModule) {
|
||||||
const { ShiftRecord, ...entities } = seedModule.seedData;
|
const { ShiftRecord, ...entities } = seedModule.seedData;
|
||||||
/* First key, so it is the first thing anyone opening the file reads. The Go
|
/* `_generated` first, so it is the first thing anyone opening the file reads.
|
||||||
loader unmarshals into a struct of demoUser + entities and ignores the
|
|
||||||
rest, so this costs nothing on the reading side. */
|
`users` is the channel the seeder actually reads — `internal/seeder`
|
||||||
return { _generated: GENERATED_NOTE, demoUser: seedModule.DEMO_USER, entities };
|
unmarshals into `{demoUser, users, entities}` and writes every account in
|
||||||
|
`users`, falling back to `demoUser` alone when the key is absent. That
|
||||||
|
fallback is why `demoUser` stays beside it rather than being replaced: a
|
||||||
|
fixture written here still seeds correctly against a backend that predates
|
||||||
|
the list, it simply seeds one account instead of two.
|
||||||
|
|
||||||
|
Emitting only `demoUser` is what this generator used to do, and it was a
|
||||||
|
version behind: the fixture on disk carries the employer account, the
|
||||||
|
seeder reads it from `users`, and regenerating without this key would drop
|
||||||
|
`employer@krow.app` from every fresh seed — leaving nobody to sign in as
|
||||||
|
to reach the employer console, which is the exact gap the account was
|
||||||
|
added to close.
|
||||||
|
|
||||||
|
`entities.User` carries the same list. The seeder ignores it, and it is
|
||||||
|
emitted because `seedData` is copied wholesale; the two are the same array
|
||||||
|
rather than two places to keep in step. */
|
||||||
|
return {
|
||||||
|
_generated: GENERATED_NOTE,
|
||||||
|
demoUser: seedModule.DEMO_USER,
|
||||||
|
users: seedModule.seedData.User,
|
||||||
|
entities,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serialised exactly as the committed file is: 2-space indent, no trailing newline. */
|
/** Serialised exactly as the committed file is: 2-space indent, no trailing newline. */
|
||||||
@@ -51,6 +73,9 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|||||||
const server = await createServer({
|
const server = await createServer({
|
||||||
root: process.cwd(), server: { middlewareMode: true }, appType: 'custom', logLevel: 'error',
|
root: process.cwd(), server: { middlewareMode: true }, appType: 'custom', logLevel: 'error',
|
||||||
});
|
});
|
||||||
|
/* `buildFixture` loads `/src/api/seed.js`, which the TypeScript migration
|
||||||
|
will rename. The fixture it generates is unchanged either way. */
|
||||||
|
withSourceResolution(server, process.cwd());
|
||||||
const built = await buildFixture(server);
|
const built = await buildFixture(server);
|
||||||
await server.close();
|
await server.close();
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
108
scripts/ssr-resolve.mjs
Normal file
108
scripts/ssr-resolve.mjs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Extension-agnostic SSR module loading, for the duration of the TypeScript
|
||||||
|
* migration.
|
||||||
|
*
|
||||||
|
* Every script in here addresses modules by literal path — `ssrLoadModule(
|
||||||
|
* '/src/lib/skills/registry.js')` — about a hundred and seventy times across
|
||||||
|
* `skill-check.mjs`, `owliver-capture.mjs`, `render-page.mjs` and
|
||||||
|
* `seed-fixture.mjs`. That is fine while every source file is JavaScript and
|
||||||
|
* fatal the moment one is not: renaming `registry.js` to `registry.ts` turns
|
||||||
|
* the check suite's very first load into a failure, and the suite is the only
|
||||||
|
* evidence the Owliver flow still behaves the way it did.
|
||||||
|
*
|
||||||
|
* Rewriting all those call sites would be a large, noisy, error-prone diff
|
||||||
|
* against the file that guards the migration — exactly the wrong thing to
|
||||||
|
* disturb. So the loader is wrapped once instead and the call sites keep the
|
||||||
|
* paths they already have, which stay readable as the names of real files.
|
||||||
|
*
|
||||||
|
* Resolution is by existence on disk, not by catching a failed load. A load
|
||||||
|
* that fails for a real reason — a syntax error, a bad import inside the module
|
||||||
|
* — must surface as itself; retrying under another extension would bury it
|
||||||
|
* behind a second, more confusing error about a file that was never there.
|
||||||
|
*
|
||||||
|
* The path as written is always tried first, so while a module is still
|
||||||
|
* JavaScript this changes nothing at all.
|
||||||
|
*
|
||||||
|
* This file is temporary. When `src` holds no `.js` or `.jsx` any more, the
|
||||||
|
* call sites can be renamed in one pass and this wrapper deleted.
|
||||||
|
*/
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The candidate paths for one module specifier, in the order they are tried.
|
||||||
|
*
|
||||||
|
* Only `.js` and `.jsx` are rewritten. Anything else — a bare specifier, a
|
||||||
|
* `.mjs` file, something under `/node_modules` — is returned untouched, because
|
||||||
|
* nothing in this migration renames it.
|
||||||
|
*
|
||||||
|
* `.js` is allowed to become `.tsx` as well as `.ts`. Not because any `.js`
|
||||||
|
* file here contains JSX today — none of the 93 does, checked with a parser
|
||||||
|
* rather than a guess — but because one may be renamed that way: the only
|
||||||
|
* `.js` under `src/pages` is `admin/positions/nodes.js`, sitting among seven
|
||||||
|
* sibling `nodes.jsx` files, and whoever converts that directory will
|
||||||
|
* reasonably want all eight to end in `.tsx`. The extra candidate costs one
|
||||||
|
* `existsSync` that answers no.
|
||||||
|
*/
|
||||||
|
export function candidatesFor(path) {
|
||||||
|
const specifier = String(path);
|
||||||
|
if (!/\.jsx?$/.test(specifier)) return [specifier];
|
||||||
|
const stem = specifier.replace(/\.jsx?$/, '');
|
||||||
|
return [...new Set([specifier, `${stem}.ts`, `${stem}.tsx`])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps `server.ssrLoadModule` so it finds a module whichever of the four
|
||||||
|
* extensions it currently carries.
|
||||||
|
*
|
||||||
|
* Mutates and returns the server, so it reads as one line after `createServer`
|
||||||
|
* and every later call — including the dynamically-built paths, which is why
|
||||||
|
* this is done here rather than at the call sites — goes through it.
|
||||||
|
*
|
||||||
|
* `root` is where the leading-slash paths are rooted; it defaults to the
|
||||||
|
* process's working directory, which is what every caller here uses.
|
||||||
|
*/
|
||||||
|
export function withSourceResolution(server, root = process.cwd()) {
|
||||||
|
const load = server.ssrLoadModule.bind(server);
|
||||||
|
|
||||||
|
server.ssrLoadModule = (path, options) => {
|
||||||
|
for (const candidate of candidatesFor(path)) {
|
||||||
|
if (existsSync(join(root, candidate.replace(/^\//, '')))) {
|
||||||
|
return load(candidate, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Nothing on disk under any extension. Load the path as written so the
|
||||||
|
error names what the caller actually asked for. */
|
||||||
|
return load(path, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same resolution, for source read as TEXT rather than loaded as a module.
|
||||||
|
*
|
||||||
|
* `skill-check.mjs` asserts structural facts by reading source files and
|
||||||
|
* matching against their contents — "the panel imports no local suggestion
|
||||||
|
* ranker", "no runtime path writes a definition". Those reads go through
|
||||||
|
* `readFileSync`, not `ssrLoadModule`, so `withSourceResolution` above never
|
||||||
|
* sees them: it wraps the loader, and this is a second, entirely separate
|
||||||
|
* channel. Renaming `base44Client.js` to `.ts` is what surfaced the difference,
|
||||||
|
* as an ENOENT in the middle of a suite that had been passing.
|
||||||
|
*
|
||||||
|
* Takes and returns a path RELATIVE to the project root, so the call site keeps
|
||||||
|
* its `join(ROOT, …)` exactly as it was:
|
||||||
|
*
|
||||||
|
* readFileSync(join(ROOT, resolveSourcePath('src/api/base44Client.js')), 'utf8')
|
||||||
|
*
|
||||||
|
* Unresolvable paths come back unchanged, so the resulting error still names
|
||||||
|
* the file the caller asked for rather than a candidate it invented. That also
|
||||||
|
* keeps the deliberately absent ones honest: a check asserting a file is GONE
|
||||||
|
* gets the path it asked about, and `existsSync` still answers false.
|
||||||
|
*/
|
||||||
|
export function resolveSourcePath(path, root = process.cwd()) {
|
||||||
|
for (const candidate of candidatesFor(path)) {
|
||||||
|
if (existsSync(join(root, candidate))) return candidate;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/* Side-effect import: initialises i18next and subscribes it to the language
|
||||||
|
store, before any component calls t(). Imported here rather than in main so
|
||||||
|
that every entry point into the app gets it. */
|
||||||
|
import '@/lib/i18n';
|
||||||
|
|
||||||
import { Toaster } from "@/components/ui/toaster"
|
import { Toaster } from "@/components/ui/toaster"
|
||||||
import { Toaster as HotToaster } from 'react-hot-toast';
|
import { Toaster as HotToaster } from 'react-hot-toast';
|
||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
@@ -38,6 +43,7 @@ import AdminCandidateProfile from '@/pages/admin/CandidateProfile';
|
|||||||
import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis';
|
import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis';
|
||||||
import AdminHiredHistory from '@/pages/admin/HiredHistory';
|
import AdminHiredHistory from '@/pages/admin/HiredHistory';
|
||||||
import AdminTalentPool from '@/pages/admin/TalentPool';
|
import AdminTalentPool from '@/pages/admin/TalentPool';
|
||||||
|
import AdminTalentProfile from '@/pages/admin/TalentProfile';
|
||||||
import AdminAnalytics from '@/pages/admin/Analytics';
|
import AdminAnalytics from '@/pages/admin/Analytics';
|
||||||
import AdminActivity from '@/pages/admin/Activity';
|
import AdminActivity from '@/pages/admin/Activity';
|
||||||
import AdminProfile from '@/pages/admin/Profile';
|
import AdminProfile from '@/pages/admin/Profile';
|
||||||
@@ -107,6 +113,12 @@ const AuthenticatedApp = () => {
|
|||||||
<Route path="candidates-analysis" element={<AdminCandidatesAnalysis />} />
|
<Route path="candidates-analysis" element={<AdminCandidatesAnalysis />} />
|
||||||
<Route path="hired" element={<AdminHiredHistory />} />
|
<Route path="hired" element={<AdminHiredHistory />} />
|
||||||
<Route path="talent-pool" element={<AdminTalentPool />} />
|
<Route path="talent-pool" element={<AdminTalentPool />} />
|
||||||
|
{/* The person, keyed by worker profile — distinct from
|
||||||
|
`candidates/:id`, which is one person FOR ONE POSITION. Two
|
||||||
|
routes because they answer different questions; one screen
|
||||||
|
would push the product back towards a candidate record per
|
||||||
|
vacancy, which is the thing `worker_profiles` exists to stop. */}
|
||||||
|
<Route path="talent/:id" element={<AdminTalentProfile />} />
|
||||||
<Route path="university" element={<University />} />
|
<Route path="university" element={<University />} />
|
||||||
<Route path="university/:id" element={<CourseDetail />} />
|
<Route path="university/:id" element={<CourseDetail />} />
|
||||||
<Route path="analytics" element={<AdminAnalytics />} />
|
<Route path="analytics" element={<AdminAnalytics />} />
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
* and results that actually respond to the data instead of being canned.
|
* and results that actually respond to the data instead of being canned.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
const clamp = (n: unknown, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||||
|
|
||||||
/** Simulated model latency so streaming/pending UI behaves as designed. */
|
/** Simulated model latency so streaming/pending UI behaves as designed. */
|
||||||
const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
|
const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -21,39 +21,39 @@ const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
|
|||||||
/* ── Prompt field readers ──────────────────────────────────────────────── */
|
/* ── Prompt field readers ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
/** Reads a `Label: value` line out of a prompt. */
|
/** Reads a `Label: value` line out of a prompt. */
|
||||||
function field(prompt, label) {
|
function field(prompt: string, label: string): string {
|
||||||
const match = prompt.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'mi'));
|
const match = prompt.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'mi'));
|
||||||
return match ? match[1].trim() : '';
|
return match ? match[1].trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function numField(prompt, label) {
|
function numField(prompt: string, label: string): number {
|
||||||
const raw = field(prompt, label);
|
const raw = field(prompt, label);
|
||||||
const match = raw.match(/-?\d+(\.\d+)?/);
|
const match = raw.match(/-?\d+(\.\d+)?/);
|
||||||
return match ? Number(match[0]) : 0;
|
return match ? Number(match[0]) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads a comma-separated line, treating the app's "None" sentinel as empty. */
|
/** Reads a comma-separated line, treating the app's "None" sentinel as empty. */
|
||||||
function listField(prompt, label) {
|
function listField(prompt: string, label: string): string[] {
|
||||||
const raw = field(prompt, label);
|
const raw = field(prompt, label);
|
||||||
if (!raw || /^(none|not specified|not provided)$/i.test(raw)) return [];
|
if (!raw || /^(none|not specified|not provided)$/i.test(raw)) return [];
|
||||||
return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pulls the first triple-quoted block (transcripts, free-text resumes). */
|
/** Pulls the first triple-quoted block (transcripts, free-text resumes). */
|
||||||
function quotedBlock(prompt) {
|
function quotedBlock(prompt: string): string {
|
||||||
const match = prompt.match(/"""\s*([\s\S]*?)\s*"""/);
|
const match = prompt.match(/"""\s*([\s\S]*?)\s*"""/);
|
||||||
return match ? match[1].trim() : '';
|
return match ? match[1].trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENGLISH_RANK = { basic: 1, conversational: 2, fluent: 3, native: 4 };
|
const ENGLISH_RANK: Record<string, number> = { basic: 1, conversational: 2, fluent: 3, native: 4 };
|
||||||
|
|
||||||
const isSpanish = (prompt) => /Spanish \(español\)/.test(prompt) && !/default to English/.test(prompt);
|
const isSpanish = (prompt: string) => /Spanish \(español\)/.test(prompt) && !/default to English/.test(prompt);
|
||||||
|
|
||||||
/* ── 1. Candidate screening ────────────────────────────────────────────── */
|
/* ── 1. Candidate screening ────────────────────────────────────────────── */
|
||||||
|
|
||||||
function screenCandidate(prompt) {
|
function screenCandidate(prompt: string) {
|
||||||
// Weights are stated inline: "experience 25%, english 20%, …"
|
// Weights are stated inline: "experience 25%, english 20%, …"
|
||||||
const weightOf = (name) => {
|
const weightOf = (name: string) => {
|
||||||
const match = prompt.match(new RegExp(`${name} (\\d+)%`));
|
const match = prompt.match(new RegExp(`${name} (\\d+)%`));
|
||||||
return match ? Number(match[1]) : 20;
|
return match ? Number(match[1]) : 20;
|
||||||
};
|
};
|
||||||
@@ -184,7 +184,7 @@ function screenCandidate(prompt) {
|
|||||||
|
|
||||||
/* ── 2. Job description generator ──────────────────────────────────────── */
|
/* ── 2. Job description generator ──────────────────────────────────────── */
|
||||||
|
|
||||||
const RESPONSIBILITY_LIBRARY = {
|
const RESPONSIBILITY_LIBRARY: Record<string, string[]> = {
|
||||||
Bartender: [
|
Bartender: [
|
||||||
'Set up and break down a full service bar',
|
'Set up and break down a full service bar',
|
||||||
'Pour to spec at event pace without sacrificing presentation',
|
'Pour to spec at event pace without sacrificing presentation',
|
||||||
@@ -236,7 +236,7 @@ const DEFAULT_RESPONSIBILITIES = [
|
|||||||
'Complete close-out duties before leaving',
|
'Complete close-out duties before leaving',
|
||||||
];
|
];
|
||||||
|
|
||||||
function generateJobDescription(prompt) {
|
function generateJobDescription(prompt: string) {
|
||||||
const title = field(prompt, 'Role Title');
|
const title = field(prompt, 'Role Title');
|
||||||
const category = field(prompt, 'Role Category');
|
const category = field(prompt, 'Role Category');
|
||||||
const minYears = numField(prompt, 'Min Experience');
|
const minYears = numField(prompt, 'Min Experience');
|
||||||
@@ -301,7 +301,7 @@ const CERT_KEYWORDS = [
|
|||||||
['food handler', 'Food Handler Card'], ['forklift', 'Forklift Operator'], ['osha', 'OSHA 10'],
|
['food handler', 'Food Handler Card'], ['forklift', 'Forklift Operator'], ['osha', 'OSHA 10'],
|
||||||
];
|
];
|
||||||
|
|
||||||
function buildResume(prompt) {
|
function buildResume(prompt: string) {
|
||||||
const text = quotedBlock(prompt);
|
const text = quotedBlock(prompt);
|
||||||
const lower = text.toLowerCase();
|
const lower = text.toLowerCase();
|
||||||
|
|
||||||
@@ -343,14 +343,14 @@ function buildResume(prompt) {
|
|||||||
|
|
||||||
const INTERVIEW_QUESTIONS = {
|
const INTERVIEW_QUESTIONS = {
|
||||||
en: [
|
en: [
|
||||||
(role) => `Thanks for joining. To start — what drew you to ${role} work, and how long have you been doing it?`,
|
(role: string) => `Thanks for joining. To start — what drew you to ${role} work, and how long have you been doing it?`,
|
||||||
() => `Walk me through your busiest shift. What actually happened, and what did you do about it?`,
|
() => `Walk me through your busiest shift. What actually happened, and what did you do about it?`,
|
||||||
() => `Something goes wrong mid-service and your lead is unreachable. Talk me through your next three moves.`,
|
() => `Something goes wrong mid-service and your lead is unreachable. Talk me through your next three moves.`,
|
||||||
() => `Tell me about feedback that changed how you work. What did you do differently afterward?`,
|
() => `Tell me about feedback that changed how you work. What did you do differently afterward?`,
|
||||||
() => `Last one — what does your schedule realistically look like, and what do you want to be doing a year from now?`,
|
() => `Last one — what does your schedule realistically look like, and what do you want to be doing a year from now?`,
|
||||||
],
|
],
|
||||||
es: [
|
es: [
|
||||||
(role) => `Gracias por acompañarme. Para empezar — ¿qué te atrajo al trabajo de ${role}, y cuánto tiempo llevas haciéndolo?`,
|
(role: string) => `Gracias por acompañarme. Para empezar — ¿qué te atrajo al trabajo de ${role}, y cuánto tiempo llevas haciéndolo?`,
|
||||||
() => `Cuéntame de tu turno más ocupado. ¿Qué pasó realmente, y qué hiciste al respecto?`,
|
() => `Cuéntame de tu turno más ocupado. ¿Qué pasó realmente, y qué hiciste al respecto?`,
|
||||||
() => `Algo sale mal a mitad del servicio y no puedes contactar a tu supervisor. Explícame tus siguientes tres pasos.`,
|
() => `Algo sale mal a mitad del servicio y no puedes contactar a tu supervisor. Explícame tus siguientes tres pasos.`,
|
||||||
() => `Háblame de algún comentario que cambió tu manera de trabajar. ¿Qué hiciste diferente después?`,
|
() => `Háblame de algún comentario que cambió tu manera de trabajar. ¿Qué hiciste diferente después?`,
|
||||||
@@ -358,7 +358,7 @@ const INTERVIEW_QUESTIONS = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function interviewQuestion(prompt) {
|
function interviewQuestion(prompt: string) {
|
||||||
const lang = isSpanish(prompt) ? 'es' : 'en';
|
const lang = isSpanish(prompt) ? 'es' : 'en';
|
||||||
const numberMatch = prompt.match(/Ask question (\d+) of 5/);
|
const numberMatch = prompt.match(/Ask question (\d+) of 5/);
|
||||||
const index = Math.min(4, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
const index = Math.min(4, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
||||||
@@ -369,7 +369,7 @@ function interviewQuestion(prompt) {
|
|||||||
|
|
||||||
const OWLIVER_QUESTIONS = {
|
const OWLIVER_QUESTIONS = {
|
||||||
en: [
|
en: [
|
||||||
(name) => `Hey ${name} — good to meet you. Tell me about yourself, in your own words.`,
|
(name: string) => `Hey ${name} — good to meet you. Tell me about yourself, in your own words.`,
|
||||||
() => `I like that. Tell me about something you got through that you're genuinely proud of.`,
|
() => `I like that. Tell me about something you got through that you're genuinely proud of.`,
|
||||||
() => `Picture this: you're the only one on shift, 300 guests arrive early, and the kitchen is behind. What do you do?`,
|
() => `Picture this: you're the only one on shift, 300 guests arrive early, and the kitchen is behind. What do you do?`,
|
||||||
() => `What's a piece of feedback that stuck with you — and what changed after it?`,
|
() => `What's a piece of feedback that stuck with you — and what changed after it?`,
|
||||||
@@ -377,7 +377,7 @@ const OWLIVER_QUESTIONS = {
|
|||||||
() => `That's everything I needed. Anything you want to add before I put this together?`,
|
() => `That's everything I needed. Anything you want to add before I put this together?`,
|
||||||
],
|
],
|
||||||
es: [
|
es: [
|
||||||
(name) => `Hola ${name} — un gusto conocerte. Cuéntame de ti, con tus propias palabras.`,
|
(name: string) => `Hola ${name} — un gusto conocerte. Cuéntame de ti, con tus propias palabras.`,
|
||||||
() => `Me gusta eso. Cuéntame de algo que superaste y de lo que estés realmente orgulloso.`,
|
() => `Me gusta eso. Cuéntame de algo que superaste y de lo que estés realmente orgulloso.`,
|
||||||
() => `Imagina: estás solo en el turno, llegan 300 invitados antes de tiempo y la cocina va atrasada. ¿Qué haces?`,
|
() => `Imagina: estás solo en el turno, llegan 300 invitados antes de tiempo y la cocina va atrasada. ¿Qué haces?`,
|
||||||
() => `¿Qué comentario se te quedó grabado — y qué cambió después?`,
|
() => `¿Qué comentario se te quedó grabado — y qué cambió después?`,
|
||||||
@@ -386,7 +386,7 @@ const OWLIVER_QUESTIONS = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function owliverQuestion(prompt) {
|
function owliverQuestion(prompt: string) {
|
||||||
const lang = isSpanish(prompt) ? 'es' : 'en';
|
const lang = isSpanish(prompt) ? 'es' : 'en';
|
||||||
const numberMatch = prompt.match(/you are on question (\d+)/);
|
const numberMatch = prompt.match(/you are on question (\d+)/);
|
||||||
const index = Math.min(5, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
const index = Math.min(5, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
|
||||||
@@ -398,7 +398,7 @@ function owliverQuestion(prompt) {
|
|||||||
/* ── 5. Interview evaluation ───────────────────────────────────────────── */
|
/* ── 5. Interview evaluation ───────────────────────────────────────────── */
|
||||||
|
|
||||||
/** Scores a transcript on how substantive the candidate's own answers are. */
|
/** Scores a transcript on how substantive the candidate's own answers are. */
|
||||||
function readTranscript(prompt) {
|
function readTranscript(prompt: string) {
|
||||||
const transcript = prompt.split('TRANSCRIPT:')[1] || '';
|
const transcript = prompt.split('TRANSCRIPT:')[1] || '';
|
||||||
const answers = transcript
|
const answers = transcript
|
||||||
.split('\n')
|
.split('\n')
|
||||||
@@ -412,7 +412,7 @@ function readTranscript(prompt) {
|
|||||||
return { answers, words, avgWords, specifics };
|
return { answers, words, avgWords, specifics };
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateInterview(prompt) {
|
function evaluateInterview(prompt: string) {
|
||||||
const { answers, avgWords, specifics } = readTranscript(prompt);
|
const { answers, avgWords, specifics } = readTranscript(prompt);
|
||||||
const fastMatch = prompt.match(/(\d+) candidate response\(s\) were suspiciously fast/);
|
const fastMatch = prompt.match(/(\d+) candidate response\(s\) were suspiciously fast/);
|
||||||
const fast = fastMatch ? Number(fastMatch[1]) : 0;
|
const fast = fastMatch ? Number(fastMatch[1]) : 0;
|
||||||
@@ -489,9 +489,14 @@ function evaluateInterview(prompt) {
|
|||||||
|
|
||||||
/* ── 6. Talent matching ────────────────────────────────────────────────── */
|
/* ── 6. Talent matching ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function matchTalent(prompt) {
|
function matchTalent(prompt: string) {
|
||||||
const block = prompt.match(/AVAILABLE TALENT \(JSON\):\s*(\[[\s\S]*?\])\s*\n\nYou are NEVER/);
|
const block = prompt.match(/AVAILABLE TALENT \(JSON\):\s*(\[[\s\S]*?\])\s*\n\nYou are NEVER/);
|
||||||
let pool = [];
|
/* Whatever the caller embedded in the prompt's talent-pool block, as
|
||||||
|
`JSON.parse` hands it back. Not `WorkerProfile[]`: this is a simulated
|
||||||
|
engine reading a blob out of free text, and the rows carry computed
|
||||||
|
fields like `match_score` that no entity declares. Typing it as a
|
||||||
|
record would assert a shape nothing validates. */
|
||||||
|
let pool: any[] = [];
|
||||||
try {
|
try {
|
||||||
pool = block ? JSON.parse(block[1]) : [];
|
pool = block ? JSON.parse(block[1]) : [];
|
||||||
} catch {
|
} catch {
|
||||||
@@ -513,9 +518,9 @@ function matchTalent(prompt) {
|
|||||||
? Math.min(20, ((p.experience_years || 0) / minYears) * 20)
|
? Math.min(20, ((p.experience_years || 0) / minYears) * 20)
|
||||||
: Math.min(20, (p.experience_years || 0) * 3);
|
: Math.min(20, (p.experience_years || 0) * 3);
|
||||||
|
|
||||||
const certs = (p.certifications || []).map((c) => c.toLowerCase());
|
const certs = (p.certifications || []).map((c: string) => c.toLowerCase());
|
||||||
const certFit = requiredCerts.length
|
const certFit = requiredCerts.length
|
||||||
? (requiredCerts.filter((r) => certs.some((c) => c.includes(r) || r.includes(c))).length / requiredCerts.length) * 15
|
? (requiredCerts.filter((r) => certs.some((c: string) => c.includes(r) || r.includes(c))).length / requiredCerts.length) * 15
|
||||||
: (certs.length ? 12 : 5);
|
: (certs.length ? 12 : 5);
|
||||||
|
|
||||||
const scoreFit = ((p.krow_score || 0) / 100) * 20;
|
const scoreFit = ((p.krow_score || 0) / 100) * 20;
|
||||||
@@ -547,7 +552,11 @@ function matchTalent(prompt) {
|
|||||||
/* ── 7. Owliver profile builder ────────────────────────────────────────── */
|
/* ── 7. Owliver profile builder ────────────────────────────────────────── */
|
||||||
|
|
||||||
/** Job titles a worker might name for themselves, most specific first. */
|
/** Job titles a worker might name for themselves, most specific first. */
|
||||||
const ROLE_TITLES = [
|
/* Explicit tuple, not `as const`: the entries are read as a pair — `.find(([re]) => re.test(…))`
|
||||||
|
then `role[1]` — and without it the element widens to `string | RegExp`, which has no `.test`.
|
||||||
|
`as const` would also work and would additionally make the array and its entries `readonly`,
|
||||||
|
a change to the type this module publishes for no benefit it uses. */
|
||||||
|
const ROLE_TITLES: [RegExp, string][] = [
|
||||||
[/banquet captain|captain/, 'Banquet Captain'],
|
[/banquet captain|captain/, 'Banquet Captain'],
|
||||||
[/executive chef/, 'Executive Chef'],
|
[/executive chef/, 'Executive Chef'],
|
||||||
[/sous chef/, 'Sous Chef'],
|
[/sous chef/, 'Sous Chef'],
|
||||||
@@ -563,7 +572,8 @@ const ROLE_TITLES = [
|
|||||||
[/manager/, 'Manager'],
|
[/manager/, 'Manager'],
|
||||||
];
|
];
|
||||||
|
|
||||||
const AVAILABILITY_TOKENS = [
|
/** Same pairing, same reason — matched by regex, collected by label. */
|
||||||
|
const AVAILABILITY_TOKENS: [RegExp, string][] = [
|
||||||
[/weekend|saturday|sunday|sábado|domingo/i, 'Weekends'],
|
[/weekend|saturday|sunday|sábado|domingo/i, 'Weekends'],
|
||||||
[/weekday|monday|tuesday|wednesday|thursday|friday|semana/i, 'Weekdays'],
|
[/weekday|monday|tuesday|wednesday|thursday|friday|semana/i, 'Weekdays'],
|
||||||
[/evening|night|noche|tarde/i, 'Evenings'],
|
[/evening|night|noche|tarde/i, 'Evenings'],
|
||||||
@@ -572,7 +582,7 @@ const AVAILABILITY_TOKENS = [
|
|||||||
[/on.?call|flexible/i, 'On-Call'],
|
[/on.?call|flexible/i, 'On-Call'],
|
||||||
];
|
];
|
||||||
|
|
||||||
function buildCareerDna(prompt) {
|
function buildCareerDna(prompt: string) {
|
||||||
const transcript = quotedBlock(prompt);
|
const transcript = quotedBlock(prompt);
|
||||||
const lower = transcript.toLowerCase();
|
const lower = transcript.toLowerCase();
|
||||||
const { answers, avgWords, specifics } = (() => {
|
const { answers, avgWords, specifics } = (() => {
|
||||||
@@ -696,7 +706,7 @@ function buildCareerDna(prompt) {
|
|||||||
|
|
||||||
/* ── 8. Proving Ground evaluator ───────────────────────────────────────── */
|
/* ── 8. Proving Ground evaluator ───────────────────────────────────────── */
|
||||||
|
|
||||||
function evaluateChallenge(prompt) {
|
function evaluateChallenge(prompt: string) {
|
||||||
const criteriaMatch = prompt.match(/rubric" object: (.+?)\.\n/);
|
const criteriaMatch = prompt.match(/rubric" object: (.+?)\.\n/);
|
||||||
const criteria = criteriaMatch
|
const criteria = criteriaMatch
|
||||||
? criteriaMatch[1].split(',').map((c) => c.trim()).filter(Boolean)
|
? criteriaMatch[1].split(',').map((c) => c.trim()).filter(Boolean)
|
||||||
@@ -743,7 +753,7 @@ function evaluateChallenge(prompt) {
|
|||||||
const verdict = base >= 70 ? 'verified' : base >= 50 ? 'needs_work' : 'failed';
|
const verdict = base >= 70 ? 'verified' : base >= 50 ? 'needs_work' : 'failed';
|
||||||
|
|
||||||
// Spread the overall score across the rubric so criteria are not identical.
|
// Spread the overall score across the rubric so criteria are not identical.
|
||||||
const rubric = {};
|
const rubric: Record<string, number> = {};
|
||||||
criteria.forEach((criterion, i) => {
|
criteria.forEach((criterion, i) => {
|
||||||
rubric[criterion] = clamp(base + (i % 3 === 0 ? 4 : i % 3 === 1 ? -3 : 1));
|
rubric[criterion] = clamp(base + (i % 3 === 0 ? 4 : i % 3 === 1 ? -3 : 1));
|
||||||
});
|
});
|
||||||
@@ -768,7 +778,15 @@ function evaluateChallenge(prompt) {
|
|||||||
* Recognizes each workflow by the phrase its prompt opens with. Order matters
|
* Recognizes each workflow by the phrase its prompt opens with. Order matters
|
||||||
* only in that every branch is mutually exclusive by design.
|
* only in that every branch is mutually exclusive by design.
|
||||||
*/
|
*/
|
||||||
const ROUTES = [
|
/* `[pattern, handler, latencyMs]`. Spelled out because all three positions are used for what
|
||||||
|
they are: `.test()` on the first, a call on the second, and `think(ms)` on the third. Left to
|
||||||
|
inference the element becomes the union of all three and none of those work.
|
||||||
|
|
||||||
|
The handler returns `any` deliberately. The ten handlers below return ten different shapes,
|
||||||
|
which is the point of a router — and `krowAi.js` relies on it: one caller does
|
||||||
|
`typeof res === 'string' ? res : res.text || String(res)`, which a precise union would
|
||||||
|
reject on every branch that has no `.text`. */
|
||||||
|
const ROUTES: [RegExp, (prompt: string) => any, number][] = [
|
||||||
[/^You are KROW's AI screening engine/m, screenCandidate, 1100],
|
[/^You are KROW's AI screening engine/m, screenCandidate, 1100],
|
||||||
[/^You are an expert hiring copywriter/m, generateJobDescription, 1400],
|
[/^You are an expert hiring copywriter/m, generateJobDescription, 1400],
|
||||||
[/Extract and infer a structured resume/m, buildResume, 1200],
|
[/Extract and infer a structured resume/m, buildResume, 1200],
|
||||||
@@ -782,8 +800,32 @@ const ROUTES = [
|
|||||||
() => 'Walk me through the exact order you would do that in — what happens first, and who do you tell?', 700],
|
() => 'Walk me through the exact order you would do that in — what happens first, and who do you tell?', 700],
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Drop-in replacement for `integrations.Core.InvokeLLM`. */
|
/**
|
||||||
export async function invokeLLM({ prompt = '', response_json_schema: schema } = {}) {
|
* What a caller may send. Every field optional, because the destructuring default `= {}`
|
||||||
|
* says a call with no argument at all is legal, and it is one this module answers.
|
||||||
|
*
|
||||||
|
* `model` and `file_urls` are not read here — the local engine routes on the prompt and
|
||||||
|
* ignores both — but callers send them, and a parameter type that omitted them would reject
|
||||||
|
* eight call sites in `krowAi.js` and one in `provingGround.ts` for passing a field the real
|
||||||
|
* integration accepts. Declaring them describes the call this function is a drop-in for.
|
||||||
|
*/
|
||||||
|
export interface InvokeLLMOptions {
|
||||||
|
prompt?: string;
|
||||||
|
response_json_schema?: any;
|
||||||
|
model?: string;
|
||||||
|
file_urls?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop-in replacement for `integrations.Core.InvokeLLM`.
|
||||||
|
*
|
||||||
|
* The return stays `any` on purpose. See the note on `ROUTES`: the shape depends on which
|
||||||
|
* handler matched, and narrowing it here would break callers that branch on the result at
|
||||||
|
* run time rather than by type.
|
||||||
|
*/
|
||||||
|
export async function invokeLLM(
|
||||||
|
{ prompt = '', response_json_schema: schema }: InvokeLLMOptions = {},
|
||||||
|
): Promise<any> {
|
||||||
const route = ROUTES.find(([pattern]) => pattern.test(prompt));
|
const route = ROUTES.find(([pattern]) => pattern.test(prompt));
|
||||||
|
|
||||||
if (!route) {
|
if (!route) {
|
||||||
@@ -800,7 +842,7 @@ export async function invokeLLM({ prompt = '', response_json_schema: schema } =
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Drop-in replacement for `integrations.Core.UploadFile`. */
|
/** Drop-in replacement for `integrations.Core.UploadFile`. */
|
||||||
export async function uploadFile({ file } = {}) {
|
export async function uploadFile({ file }: { file?: File } = {}) {
|
||||||
await think(500);
|
await think(500);
|
||||||
if (!file) return { file_url: '' };
|
if (!file) return { file_url: '' };
|
||||||
// A blob URL keeps uploaded media viewable for the rest of the session
|
// A blob URL keeps uploaded media viewable for the rest of the session
|
||||||
@@ -39,15 +39,15 @@ const WINDOW_DAYS = 56;
|
|||||||
* Local rather than UTC because a shift belongs to the day it was worked in the
|
* Local rather than UTC because a shift belongs to the day it was worked in the
|
||||||
* place it was worked, and `periodRange` windows on local day boundaries too.
|
* place it was worked, and `periodRange` windows on local day boundaries too.
|
||||||
*/
|
*/
|
||||||
function daysAgo(n, hour = 9, minute = 0, anchor = new Date()) {
|
function daysAgo(n: number, hour = 9, minute = 0, anchor = new Date()) {
|
||||||
const d = new Date(anchor.getTime());
|
const d = new Date(anchor.getTime());
|
||||||
d.setDate(d.getDate() - n);
|
d.setDate(d.getDate() - n);
|
||||||
d.setHours(hour, minute, 0, 0);
|
d.setHours(hour, minute, 0, 0);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
const round1 = (n) => Math.round(n * 10) / 10;
|
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||||
const round2 = (n) => Math.round(n * 100) / 100;
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||||
const HOUR = 60 * 60 * 1000;
|
const HOUR = 60 * 60 * 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,10 +105,21 @@ const ROSTER = [
|
|||||||
* Returning a plain record keeps every rule visible in one place instead of
|
* Returning a plain record keeps every rule visible in one place instead of
|
||||||
* spread across the generator.
|
* spread across the generator.
|
||||||
*/
|
*/
|
||||||
const BEHAVIOUR = {
|
/**
|
||||||
|
* One worker's shift outcome, as the rules below return it.
|
||||||
|
*
|
||||||
|
* `i` is the index of the shift counting back from the anchor, `weekday` the
|
||||||
|
* day it falls on. Rules that ignore the weekday take one argument, which is
|
||||||
|
* why the second is optional here.
|
||||||
|
*/
|
||||||
|
type ShiftBehaviour = (i: number, weekday?: number) => {
|
||||||
|
status: string; minutesLate: number; overtime: number; notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BEHAVIOUR: Record<string, ShiftBehaviour> = {
|
||||||
/* Reliable. One late arrival every couple of months, and overtime only on
|
/* Reliable. One late arrival every couple of months, and overtime only on
|
||||||
the nights events actually overrun. */
|
the nights events actually overrun. */
|
||||||
staff_marco: (i, weekday) => ({
|
staff_marco: (i: number, weekday?: number) => ({
|
||||||
status: i === 14 ? 'late' : 'present',
|
status: i === 14 ? 'late' : 'present',
|
||||||
minutesLate: i === 14 ? 9 : 0,
|
minutesLate: i === 14 ? 9 : 0,
|
||||||
/* Friday and Saturday events overrun; midweek ones do not. */
|
/* Friday and Saturday events overrun; midweek ones do not. */
|
||||||
@@ -124,7 +135,7 @@ const BEHAVIOUR = {
|
|||||||
* the six weeks before it — a change big enough to be worth surfacing and
|
* the six weeks before it — a change big enough to be worth surfacing and
|
||||||
* specific enough to act on.
|
* specific enough to act on.
|
||||||
*/
|
*/
|
||||||
staff_marcus: (i) => {
|
staff_marcus: (i: number) => {
|
||||||
if (i === 2 || i === 7) {
|
if (i === 2 || i === 7) {
|
||||||
return { status: 'absent', minutesLate: 0, overtime: 0, notes: 'Called in sick' };
|
return { status: 'absent', minutesLate: 0, overtime: 0, notes: 'Called in sick' };
|
||||||
}
|
}
|
||||||
@@ -145,7 +156,7 @@ const BEHAVIOUR = {
|
|||||||
* covers, on the three busiest shifts of each week. A steady climb rather
|
* covers, on the three busiest shifts of each week. A steady climb rather
|
||||||
* than a spike, which is exactly the shape that hides in a table of totals.
|
* than a spike, which is exactly the shape that hides in a table of totals.
|
||||||
*/
|
*/
|
||||||
staff_antoine: (i, weekday) => {
|
staff_antoine: (i: number, weekday?: number) => {
|
||||||
const weekIndex = Math.floor(i / 5);
|
const weekIndex = Math.floor(i / 5);
|
||||||
const busy = weekday === 4 || weekday === 5 || weekday === 6;
|
const busy = weekday === 4 || weekday === 5 || weekday === 6;
|
||||||
const overtime = busy ? Math.max(0.5, round1(3.5 - weekIndex * 0.45)) : 0;
|
const overtime = busy ? Math.max(0.5, round1(3.5 - weekIndex * 0.45)) : 0;
|
||||||
@@ -154,8 +165,8 @@ const BEHAVIOUR = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Every shift date for one worker, most recent first. */
|
/** Every shift date for one worker, most recent first. */
|
||||||
function shiftOffsets(weekdays, anchor) {
|
function shiftOffsets(weekdays: number[], anchor: Date) {
|
||||||
const offsets = [];
|
const offsets: number[] = [];
|
||||||
for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) {
|
for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) {
|
||||||
const day = daysAgo(offset, 9, 0, anchor).getDay();
|
const day = daysAgo(offset, 9, 0, anchor).getDay();
|
||||||
if (weekdays.includes(day)) offsets.push(offset);
|
if (weekdays.includes(day)) offsets.push(offset);
|
||||||
@@ -163,16 +174,16 @@ function shiftOffsets(weekdays, anchor) {
|
|||||||
return offsets;
|
return offsets;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
const localDate = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Date} [anchor] the day to count back from. Defaults to now, which is
|
* @param {Date} [anchor] the day to count back from. Defaults to now, which is
|
||||||
* the point of this collection; a caller passes one only to hold the window
|
* the point of this collection; a caller passes one only to hold the window
|
||||||
* still — see buildShiftsAt.
|
* still — see buildShiftsAt.
|
||||||
*/
|
*/
|
||||||
function buildShifts(anchor = new Date()) {
|
function buildShifts(anchor: Date = new Date()) {
|
||||||
const records = [];
|
const records: any[] = [];
|
||||||
|
|
||||||
for (const worker of ROSTER) {
|
for (const worker of ROSTER) {
|
||||||
const offsets = shiftOffsets(worker.weekdays, anchor);
|
const offsets = shiftOffsets(worker.weekdays, anchor);
|
||||||
@@ -251,6 +262,6 @@ export const SHIFT_RECORDS = buildShifts();
|
|||||||
* shifts built around one day and bucketed around another describe two
|
* shifts built around one day and bucketed around another describe two
|
||||||
* different windows.
|
* different windows.
|
||||||
*/
|
*/
|
||||||
export function buildShiftsAt(anchor) {
|
export function buildShiftsAt(anchor: Date) {
|
||||||
return buildShifts(anchor);
|
return buildShifts(anchor);
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
|
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
|
||||||
|
import type { PreferencesUpdateResult } from '@/types/user';
|
||||||
import { invokeLLM, uploadFile } from './aiEngine';
|
import { invokeLLM, uploadFile } from './aiEngine';
|
||||||
import { DEMO_USER } from './demoUser';
|
import { DEMO_USER } from './demoUser';
|
||||||
|
|
||||||
@@ -143,14 +144,18 @@ let authenticated = false;
|
|||||||
* A 401 here is the ordinary state of a signed-out visitor, not a failure: the
|
* A 401 here is the ordinary state of a signed-out visitor, not a failure: the
|
||||||
* app opens on the login page and this request is how it finds that out.
|
* app opens on the login page and this request is how it finds that out.
|
||||||
*/
|
*/
|
||||||
let hydration = request('GET', '/me')
|
/* `Promise<any> | null`, because the runtime already assigns both: the shared
|
||||||
|
first request, then `null` once it has been consumed or superseded by a
|
||||||
|
login. Inference alone would fix the type at `Promise<any>` and reject the
|
||||||
|
two `hydration = null` assignments that make the sharing work. */
|
||||||
|
let hydration: Promise<any> | null = request('GET', '/me')
|
||||||
.then((user) => {
|
.then((user) => {
|
||||||
currentUser = user;
|
currentUser = user;
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
cacheUser();
|
cacheUser();
|
||||||
return user;
|
return user;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((): null => {
|
||||||
authenticated = false;
|
authenticated = false;
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
@@ -204,7 +209,11 @@ const auth = {
|
|||||||
* by design: telling the two apart would say whether an address is
|
* by design: telling the two apart would say whether an address is
|
||||||
* registered. The caller shows that message as-is.
|
* registered. The caller shows that message as-is.
|
||||||
*/
|
*/
|
||||||
async login({ email, password, rememberMe = false }) {
|
async login({ email, password, rememberMe = false }: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
rememberMe?: boolean;
|
||||||
|
}) {
|
||||||
const user = await request('POST', '/auth/login', {
|
const user = await request('POST', '/auth/login', {
|
||||||
body: { email, password, remember_me: Boolean(rememberMe) },
|
body: { email, password, remember_me: Boolean(rememberMe) },
|
||||||
});
|
});
|
||||||
@@ -215,7 +224,7 @@ const auth = {
|
|||||||
return { ...user };
|
return { ...user };
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateMe(patch) {
|
async updateMe(patch: any) {
|
||||||
const user = await request('PATCH', '/me', { body: patch });
|
const user = await request('PATCH', '/me', { body: patch });
|
||||||
currentUser = user;
|
currentUser = user;
|
||||||
cacheUser();
|
cacheUser();
|
||||||
@@ -247,7 +256,7 @@ const auth = {
|
|||||||
* question the shape exists to answer is now answered by whether this
|
* question the shape exists to answer is now answered by whether this
|
||||||
* function resolved at all.
|
* function resolved at all.
|
||||||
*/
|
*/
|
||||||
async updatePreferences(patch) {
|
async updatePreferences(patch: any): Promise<PreferencesUpdateResult> {
|
||||||
const preferences = await request('PATCH', '/me/preferences', { body: patch });
|
const preferences = await request('PATCH', '/me/preferences', { body: patch });
|
||||||
currentUser = { ...currentUser, preferences };
|
currentUser = { ...currentUser, preferences };
|
||||||
cacheUser();
|
cacheUser();
|
||||||
@@ -274,7 +283,7 @@ const auth = {
|
|||||||
* reach the API must still leave the browser signed out locally, and the
|
* reach the API must still leave the browser signed out locally, and the
|
||||||
* cookie it keeps will be refused by every request it is sent on.
|
* cookie it keeps will be refused by every request it is sent on.
|
||||||
*/
|
*/
|
||||||
async logout(redirectTo = '/admin/login') {
|
async logout(redirectTo: string = '/admin/login') {
|
||||||
try {
|
try {
|
||||||
await request('POST', '/auth/logout');
|
await request('POST', '/auth/logout');
|
||||||
} catch {
|
} catch {
|
||||||
@@ -320,7 +329,7 @@ const workflows = {
|
|||||||
* across from the application by the server, because it is already the truth
|
* across from the application by the server, because it is already the truth
|
||||||
* about this person and retyping it here is how the two records drift apart.
|
* about this person and retyping it here is how the two records drift apart.
|
||||||
*/
|
*/
|
||||||
async hire(applicationId, body = {}) {
|
async hire(applicationId: string, body: any = {}) {
|
||||||
return request('POST', `/job-applications/${encodeURIComponent(applicationId)}/hire`, {
|
return request('POST', `/job-applications/${encodeURIComponent(applicationId)}/hire`, {
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
@@ -336,7 +345,7 @@ const workflows = {
|
|||||||
* inside the same transaction; a worker taken straight from the talent pool
|
* inside the same transaction; a worker taken straight from the talent pool
|
||||||
* with neither is placed without one rather than given an invented one.
|
* with neither is placed without one rather than given an invented one.
|
||||||
*/
|
*/
|
||||||
async assign(jobPostingId, workers = []) {
|
async assign(jobPostingId: string, workers: any[] = []) {
|
||||||
return request('POST', `/job-postings/${encodeURIComponent(jobPostingId)}/assignments`, {
|
return request('POST', `/job-postings/${encodeURIComponent(jobPostingId)}/assignments`, {
|
||||||
body: { workers },
|
body: { workers },
|
||||||
});
|
});
|
||||||
@@ -368,7 +377,7 @@ const workflows = {
|
|||||||
*/
|
*/
|
||||||
const owliver = {
|
const owliver = {
|
||||||
/** @param {any} request */
|
/** @param {any} request */
|
||||||
async suggestions({ page, query = '' } = {}) {
|
async suggestions({ page, query = '' }: { page?: string; query?: string } = {}) {
|
||||||
if (!page) return [];
|
if (!page) return [];
|
||||||
const typed = String(query || '').trim();
|
const typed = String(query || '').trim();
|
||||||
const data = await request('GET', '/owliver/suggestions', {
|
const data = await request('GET', '/owliver/suggestions', {
|
||||||
@@ -388,7 +397,7 @@ const integrations = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const analytics = {
|
const analytics = {
|
||||||
track({ eventName, properties } = {}) {
|
track({ eventName, properties }: { eventName?: string; properties?: any } = {}) {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.debug('[krow-demo] analytics', eventName, properties || {});
|
console.debug('[krow-demo] analytics', eventName, properties || {});
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { User } from '@/types/user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shape of a signed-in user, before the server has answered.
|
* The shape of a signed-in user, before the server has answered.
|
||||||
*
|
*
|
||||||
@@ -22,7 +24,7 @@
|
|||||||
* the server has never stored still has to resolve to something rather than to
|
* the server has never stored still has to resolve to something rather than to
|
||||||
* `undefined`.
|
* `undefined`.
|
||||||
*/
|
*/
|
||||||
export const DEMO_USER = {
|
export const DEMO_USER: User = {
|
||||||
id: 'user_demo',
|
id: 'user_demo',
|
||||||
full_name: 'Alex Rivera',
|
full_name: 'Alex Rivera',
|
||||||
email: 'demo@krow.app',
|
email: 'demo@krow.app',
|
||||||
@@ -37,3 +39,29 @@ export const DEMO_USER = {
|
|||||||
emailDigest: true,
|
emailDigest: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The employer account, and the reason there is a second one at all.
|
||||||
|
*
|
||||||
|
* `policy.go` has always had three roles — admin, employer, talent — and the
|
||||||
|
* fixture had one administrator, so two thirds of the authorization table was
|
||||||
|
* never exercised and there was nobody to sign in as to reach the employer
|
||||||
|
* console. This account is what makes that path reachable.
|
||||||
|
*
|
||||||
|
* Unlike `DEMO_USER` this is not a default shape for the first render: nothing
|
||||||
|
* in the running app reads it. It exists so that `seedData.User` names both
|
||||||
|
* accounts, which is what the backend seeder writes. See `seed.ts`.
|
||||||
|
*/
|
||||||
|
export const EMPLOYER_USER: User = {
|
||||||
|
id: 'user_employer',
|
||||||
|
full_name: 'Jordan Blake',
|
||||||
|
email: 'employer@krow.app',
|
||||||
|
role: 'employer',
|
||||||
|
account_type: 'employer',
|
||||||
|
created_date: '2026-06-01T09:00:00.000Z',
|
||||||
|
preferences: {
|
||||||
|
owliverDefault: true,
|
||||||
|
compactDensity: false,
|
||||||
|
emailDigest: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -27,6 +27,9 @@
|
|||||||
* did not answer.
|
* did not answer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ApiErrorResponse, KrowApiError } from '@/types/api';
|
||||||
|
import type { EntityName, EntityResourcePath } from '@/types/entities';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the API lives.
|
* Where the API lives.
|
||||||
*
|
*
|
||||||
@@ -93,7 +96,13 @@ if (import.meta.env?.DEV && /^https?:\/\//i.test(API_BASE_URL)) {
|
|||||||
* `user-activity` is three special cases wearing a trench coat, and a wrong
|
* `user-activity` is three special cases wearing a trench coat, and a wrong
|
||||||
* guess here is a 404 at runtime instead of a mistake anyone can see.
|
* guess here is a 404 at runtime instead of a mistake anyone can see.
|
||||||
*/
|
*/
|
||||||
const RESOURCE_PATHS = {
|
/* `Record<EntityName, EntityResourcePath>` is doing two checks at once, and
|
||||||
|
both were previously unavailable. Every one of the eighteen names must be
|
||||||
|
present as a key — so this map and `ENTITY_NAMES` in `base44Client.js` can no
|
||||||
|
longer drift apart silently — and every value must be one of the declared
|
||||||
|
paths, so a typo in a resource segment is a compile error rather than a 404
|
||||||
|
at run time. The object itself is unchanged, key for key. */
|
||||||
|
const RESOURCE_PATHS: Record<EntityName, EntityResourcePath> = {
|
||||||
JobPosting: 'job-postings',
|
JobPosting: 'job-postings',
|
||||||
JobApplication: 'job-applications',
|
JobApplication: 'job-applications',
|
||||||
AIInterview: 'ai-interviews',
|
AIInterview: 'ai-interviews',
|
||||||
@@ -128,7 +137,7 @@ const RESOURCE_PATHS = {
|
|||||||
* `got === undefined` against real values and matched nothing, and sending the
|
* `got === undefined` against real values and matched nothing, and sending the
|
||||||
* string "undefined" would be a filter on a value no column holds.
|
* string "undefined" would be a filter on a value no column holds.
|
||||||
*/
|
*/
|
||||||
function queryString(params) {
|
function queryString(params: Record<string, unknown>) {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
if (value === undefined) continue;
|
if (value === undefined) continue;
|
||||||
@@ -153,9 +162,14 @@ function queryString(params) {
|
|||||||
* ride along as properties — new information a local store never had, and
|
* ride along as properties — new information a local store never had, and
|
||||||
* additive, so nothing that only reads `.message` notices.
|
* additive, so nothing that only reads `.message` notices.
|
||||||
*/
|
*/
|
||||||
function apiError(status, payload) {
|
function apiError(status: number, payload: ApiErrorResponse | null): KrowApiError {
|
||||||
const body = payload?.error;
|
const body = payload?.error;
|
||||||
const error = new Error(body?.message || `Request failed with status ${status}`);
|
/* Cast, not `class KrowApiError extends Error`. The properties below are
|
||||||
|
assigned onto a plain Error exactly as they always were, so the prototype
|
||||||
|
chain, `instanceof Error`, and the `error.name === 'KrowApiError'` test
|
||||||
|
callers use all behave identically. A subclass would change all three, and
|
||||||
|
the emitted JavaScript with it. */
|
||||||
|
const error = new Error(body?.message || `Request failed with status ${status}`) as KrowApiError;
|
||||||
error.name = 'KrowApiError';
|
error.name = 'KrowApiError';
|
||||||
error.status = status;
|
error.status = status;
|
||||||
error.code = body?.code || 'internal';
|
error.code = body?.code || 'internal';
|
||||||
@@ -171,10 +185,14 @@ function apiError(status, payload) {
|
|||||||
* reads it (§4.2), and surfacing it would mean changing what the six methods
|
* reads it (§4.2), and surfacing it would mean changing what the six methods
|
||||||
* return, which is the one thing Phase 2D must not do.
|
* return, which is the one thing Phase 2D must not do.
|
||||||
*/
|
*/
|
||||||
async function request(method, path, { query, body } = {}) {
|
async function request(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
{ query, body }: { query?: Record<string, unknown>; body?: unknown } = {},
|
||||||
|
) {
|
||||||
const url = `${API_BASE_URL}${path}${query ? queryString(query) : ''}`;
|
const url = `${API_BASE_URL}${path}${query ? queryString(query) : ''}`;
|
||||||
|
|
||||||
let response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(url, {
|
response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
@@ -199,7 +217,7 @@ async function request(method, path, { query, body } = {}) {
|
|||||||
`Cannot reach the Krow API at ${API_BASE_URL}. Is the Go API running on ` +
|
`Cannot reach the Krow API at ${API_BASE_URL}. Is the Go API running on ` +
|
||||||
`127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` +
|
`127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` +
|
||||||
`(${method} ${path})`
|
`(${method} ${path})`
|
||||||
);
|
) as KrowApiError;
|
||||||
error.name = 'KrowApiError';
|
error.name = 'KrowApiError';
|
||||||
error.status = 0;
|
error.status = 0;
|
||||||
error.code = 'unreachable';
|
error.code = 'unreachable';
|
||||||
@@ -221,7 +239,7 @@ async function request(method, path, { query, body } = {}) {
|
|||||||
if (!response.ok) throw apiError(response.status, payload);
|
if (!response.ok) throw apiError(response.status, payload);
|
||||||
|
|
||||||
if (payload === null) {
|
if (payload === null) {
|
||||||
const error = new Error(`${method} ${path} returned no JSON body`);
|
const error = new Error(`${method} ${path} returned no JSON body`) as KrowApiError;
|
||||||
error.name = 'KrowApiError';
|
error.name = 'KrowApiError';
|
||||||
error.status = response.status;
|
error.status = response.status;
|
||||||
error.code = 'internal';
|
error.code = 'internal';
|
||||||
@@ -242,7 +260,7 @@ export { request };
|
|||||||
* before the first login and after a session expires — so callers need to tell
|
* before the first login and after a session expires — so callers need to tell
|
||||||
* it apart from a real failure rather than treating both as "something broke".
|
* it apart from a real failure rather than treating both as "something broke".
|
||||||
*/
|
*/
|
||||||
export function isUnauthenticated(error) {
|
export function isUnauthenticated(error: any) {
|
||||||
return Boolean(error) && (error.status === 401 || error.code === 'unauthorized');
|
return Boolean(error) && (error.status === 401 || error.code === 'unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,13 +274,33 @@ export function isUnauthenticated(error) {
|
|||||||
* `list(sort = '-created_date', limit = 100)`, and several call sites rely on
|
* `list(sort = '-created_date', limit = 100)`, and several call sites rely on
|
||||||
* them rather than passing their own.
|
* them rather than passing their own.
|
||||||
*/
|
*/
|
||||||
export function createEntity(name) {
|
export function createEntity(name: string) {
|
||||||
const path = RESOURCE_PATHS[name];
|
/* `name` stays `string`, and the cast below is deliberate. Typing the
|
||||||
|
parameter as `EntityName` would be tighter and would immediately break the
|
||||||
|
one caller — `base44Client.js` builds the entity map by mapping over a
|
||||||
|
plain string array — which is a file this phase does not touch.
|
||||||
|
|
||||||
|
The return type is left inferred, and that is a decision rather than an
|
||||||
|
omission. Annotating it `EntityClient<any>` was tried and reverted: it
|
||||||
|
makes `list()` return `Promise<any[]>` where inference gives
|
||||||
|
`Promise<any>`, and two call sites in `src/lib/agents/agentStore.js` do
|
||||||
|
|
||||||
|
const rows = qc.getQueryData(KEY) || (await …list('-created_date', 200));
|
||||||
|
|
||||||
|
React Query types `getQueryData` as `unknown`, and `unknown || any`
|
||||||
|
collapses to `any` while `unknown || any[]` stays `unknown` — so `.find`
|
||||||
|
on the next line stopped compiling. Two new errors in a file this phase is
|
||||||
|
not migrating, for no gain: the surface is structurally identical either
|
||||||
|
way, and the emitted JavaScript is byte-for-byte the same.
|
||||||
|
|
||||||
|
`EntityClientFor<K>` and the generated record types are ready for the phase
|
||||||
|
that migrates those consumers. Nothing here has to change then. */
|
||||||
|
const path = RESOURCE_PATHS[name as EntityName];
|
||||||
if (!path) throw new Error(`No API resource path is declared for entity ${name}`);
|
if (!path) throw new Error(`No API resource path is declared for entity ${name}`);
|
||||||
const base = `/${path}`;
|
const base = `/${path}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entityName: name,
|
entityName: name as EntityName,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `sort` is always sent, even when empty. `?sort=` is not the same as
|
* `sort` is always sent, even when empty. `?sort=` is not the same as
|
||||||
@@ -281,19 +319,19 @@ export function createEntity(name) {
|
|||||||
return request('GET', base, { query: { ...query, sort, limit } });
|
return request('GET', base, { query: { ...query, sort, limit } });
|
||||||
},
|
},
|
||||||
|
|
||||||
async get(id) {
|
async get(id: string) {
|
||||||
return request('GET', `${base}/${encodeURIComponent(id)}`);
|
return request('GET', `${base}/${encodeURIComponent(id)}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async create(data) {
|
async create(data: any) {
|
||||||
return request('POST', base, { body: data });
|
return request('POST', base, { body: data });
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(id, data) {
|
async update(id: string, data: any) {
|
||||||
return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data });
|
return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data });
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id) {
|
async delete(id: string) {
|
||||||
return request('DELETE', `${base}/${encodeURIComponent(id)}`);
|
return request('DELETE', `${base}/${encodeURIComponent(id)}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -305,8 +343,8 @@ export function createEntity(name) {
|
|||||||
* identical — the first rejection stops the run and the records before it
|
* identical — the first rejection stops the run and the records before it
|
||||||
* are already written.
|
* are already written.
|
||||||
*/
|
*/
|
||||||
async bulkCreate(records = []) {
|
async bulkCreate(records: any[] = []) {
|
||||||
const created = [];
|
const created: any[] = [];
|
||||||
for (const record of records) created.push(await this.create(record));
|
for (const record of records) created.push(await this.create(record));
|
||||||
return created;
|
return created;
|
||||||
},
|
},
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { SHIFT_RECORDS } from './attendanceSeed';
|
import { SHIFT_RECORDS } from './attendanceSeed';
|
||||||
import { DEMO_USER } from './demoUser';
|
import { DEMO_USER, EMPLOYER_USER } from './demoUser';
|
||||||
|
|
||||||
const iso = (date) => new Date(`${date}T09:00:00.000Z`).toISOString();
|
const iso = (date: string) => new Date(`${date}T09:00:00.000Z`).toISOString();
|
||||||
|
|
||||||
/* ── Reference data ────────────────────────────────────────────────────── */
|
/* ── Reference data ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -347,7 +347,7 @@ const JOB_POSTINGS = [
|
|||||||
|
|
||||||
/* ── Applications ──────────────────────────────────────────────────────── */
|
/* ── Applications ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const breakdown = (over) => ({
|
const breakdown = (over?: Record<string, number>) => ({
|
||||||
experience: 0, english: 0, reliability: 0, certifications: 0, availability: 0,
|
experience: 0, english: 0, reliability: 0, certifications: 0, availability: 0,
|
||||||
personality: 0, culture_fit: 0, communication_style: 0, attendance_expectations: 0,
|
personality: 0, culture_fit: 0, communication_style: 0, attendance_expectations: 0,
|
||||||
physical_requirements: 0, leadership_expectations: 0, job_related_answers: 0,
|
physical_requirements: 0, leadership_expectations: 0, job_related_answers: 0,
|
||||||
@@ -356,13 +356,13 @@ const breakdown = (over) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** An applicant who has not been screened yet — every AI field stays empty. */
|
/** An applicant who has not been screened yet — every AI field stays empty. */
|
||||||
const unscreened = (over) => ({
|
const unscreened = (over?: Record<string, any>) => ({
|
||||||
status: 'applied',
|
status: 'applied',
|
||||||
ai_score: 0,
|
ai_score: 0,
|
||||||
certifications: [],
|
certifications: [] as string[],
|
||||||
availability: [],
|
availability: [] as string[],
|
||||||
skills: [],
|
skills: [] as string[],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
created_date: iso('2026-08-04'),
|
created_date: iso('2026-08-04'),
|
||||||
updated_date: iso('2026-08-04'),
|
updated_date: iso('2026-08-04'),
|
||||||
@@ -428,7 +428,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['ServSafe', 'CPR / First Aid'],
|
certifications: ['ServSafe', 'CPR / First Aid'],
|
||||||
availability: ['Weekdays', 'Weekends', 'Evenings'],
|
availability: ['Weekdays', 'Weekends', 'Evenings'],
|
||||||
skills: ['Menu design', 'Brigade leadership', 'Food costing', 'Plated dinners', 'Offsite catering'],
|
skills: ['Menu design', 'Brigade leadership', 'Food costing', 'Plated dinners', 'Offsite catering'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 4.9,
|
client_rating: 4.9,
|
||||||
professional_summary:
|
professional_summary:
|
||||||
'Executive chef with 12 years across hotel banquets and high-end offsite catering. Has run brigades of 20+ and holds plate standards at 400 covers.',
|
'Executive chef with 12 years across hotel banquets and high-end offsite catering. Has run brigades of 20+ and holds plate standards at 400 covers.',
|
||||||
@@ -480,7 +480,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['Guard Card', 'CPR / First Aid'],
|
certifications: ['Guard Card', 'CPR / First Aid'],
|
||||||
availability: ['Weekends', 'Evenings', 'Overnight'],
|
availability: ['Weekends', 'Evenings', 'Overnight'],
|
||||||
skills: ['Crowd management', 'De-escalation', 'Incident reporting', 'Access control'],
|
skills: ['Crowd management', 'De-escalation', 'Incident reporting', 'Access control'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 4.8,
|
client_rating: 4.8,
|
||||||
professional_summary:
|
professional_summary:
|
||||||
'Licensed event security officer with five years across concerts and corporate activations. Known for defusing situations before they escalate.',
|
'Licensed event security officer with five years across concerts and corporate activations. Known for defusing situations before they escalate.',
|
||||||
@@ -514,7 +514,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['Guard Card'],
|
certifications: ['Guard Card'],
|
||||||
availability: ['Weekends', 'Evenings'],
|
availability: ['Weekends', 'Evenings'],
|
||||||
skills: ['Access control', 'Incident reporting'],
|
skills: ['Access control', 'Incident reporting'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
professional_summary: 'Venue security officer transitioning into private event work.',
|
professional_summary: 'Venue security officer transitioning into private event work.',
|
||||||
job_posting_id: 'job_security',
|
job_posting_id: 'job_security',
|
||||||
@@ -562,7 +562,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['RBS Alcohol Server', 'TIPS Certified'],
|
certifications: ['RBS Alcohol Server', 'TIPS Certified'],
|
||||||
availability: ['Weekdays', 'Evenings'],
|
availability: ['Weekdays', 'Evenings'],
|
||||||
skills: ['Batch cocktails', 'Craft cocktails', 'Bar setup', 'Inventory control'],
|
skills: ['Batch cocktails', 'Craft cocktails', 'Bar setup', 'Inventory control'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 4.7,
|
client_rating: 4.7,
|
||||||
professional_summary:
|
professional_summary:
|
||||||
'Corporate event bartender with six years on Bay Area tech campuses. Builds batched programs that hold quality at volume.',
|
'Corporate event bartender with six years on Bay Area tech campuses. Builds batched programs that hold quality at volume.',
|
||||||
@@ -605,7 +605,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['Food Handler Card'],
|
certifications: ['Food Handler Card'],
|
||||||
availability: ['Weekends', 'Evenings'],
|
availability: ['Weekends', 'Evenings'],
|
||||||
skills: ['Synchronized service', 'Tray service', 'Wine service', 'Guest relations'],
|
skills: ['Synchronized service', 'Tray service', 'Wine service', 'Guest relations'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 4.6,
|
client_rating: 4.6,
|
||||||
professional_summary:
|
professional_summary:
|
||||||
'Fine dining event server with four years of coursed banquet service and wine knowledge.',
|
'Fine dining event server with four years of coursed banquet service and wine knowledge.',
|
||||||
@@ -639,7 +639,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['Food Handler Card'],
|
certifications: ['Food Handler Card'],
|
||||||
availability: ['Weekends'],
|
availability: ['Weekends'],
|
||||||
skills: ['Banquet service', 'Tray service', 'Table resets'],
|
skills: ['Banquet service', 'Tray service', 'Table resets'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 4.4,
|
client_rating: 4.4,
|
||||||
professional_summary: 'Banquet server with three years of seated dinner service.',
|
professional_summary: 'Banquet server with three years of seated dinner service.',
|
||||||
selfie_url: 'https://i.pravatar.cc/240?img=44',
|
selfie_url: 'https://i.pravatar.cc/240?img=44',
|
||||||
@@ -673,7 +673,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: [],
|
certifications: [],
|
||||||
availability: ['Weekends', 'Evenings'],
|
availability: ['Weekends', 'Evenings'],
|
||||||
skills: ['Table service'],
|
skills: ['Table service'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
professional_summary: 'Restaurant server moving into event work.',
|
professional_summary: 'Restaurant server moving into event work.',
|
||||||
job_posting_id: 'job_server_fine',
|
job_posting_id: 'job_server_fine',
|
||||||
@@ -695,7 +695,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['TIPS Certified'],
|
certifications: ['TIPS Certified'],
|
||||||
availability: ['Weekends', 'Evenings'],
|
availability: ['Weekends', 'Evenings'],
|
||||||
skills: ['Classic cocktails', 'Bar setup', 'Speed pouring'],
|
skills: ['Classic cocktails', 'Bar setup', 'Speed pouring'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 4.2,
|
client_rating: 4.2,
|
||||||
professional_summary: 'Event bartender with three years of high-volume weekend service.',
|
professional_summary: 'Event bartender with three years of high-volume weekend service.',
|
||||||
job_posting_id: 'job_bartender',
|
job_posting_id: 'job_bartender',
|
||||||
@@ -728,7 +728,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['TIPS Certified'],
|
certifications: ['TIPS Certified'],
|
||||||
availability: ['Evenings'],
|
availability: ['Evenings'],
|
||||||
skills: ['Bar setup', 'Wine service'],
|
skills: ['Bar setup', 'Wine service'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 4.0,
|
client_rating: 4.0,
|
||||||
professional_summary: 'Barback stepping up to bartending at private events.',
|
professional_summary: 'Barback stepping up to bartending at private events.',
|
||||||
job_posting_id: 'job_bartender',
|
job_posting_id: 'job_bartender',
|
||||||
@@ -761,7 +761,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: [],
|
certifications: [],
|
||||||
availability: ['Weekends'],
|
availability: ['Weekends'],
|
||||||
skills: [],
|
skills: [],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
professional_summary: 'Looking for a first hospitality role.',
|
professional_summary: 'Looking for a first hospitality role.',
|
||||||
job_posting_id: 'job_bartender',
|
job_posting_id: 'job_bartender',
|
||||||
@@ -795,7 +795,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['Forklift Operator'],
|
certifications: ['Forklift Operator'],
|
||||||
availability: ['Weekdays', 'Early mornings'],
|
availability: ['Weekdays', 'Early mornings'],
|
||||||
skills: ['Order picking', 'Scanner operation', 'Pallet staging'],
|
skills: ['Order picking', 'Scanner operation', 'Pallet staging'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 3.9,
|
client_rating: 3.9,
|
||||||
professional_summary: 'Warehouse picker with forklift certification.',
|
professional_summary: 'Warehouse picker with forklift certification.',
|
||||||
job_posting_id: 'job_picker',
|
job_posting_id: 'job_picker',
|
||||||
@@ -827,7 +827,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: [],
|
certifications: [],
|
||||||
availability: ['Weekdays'],
|
availability: ['Weekdays'],
|
||||||
skills: ['Order picking'],
|
skills: ['Order picking'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
professional_summary: 'Fulfillment associate seeking steady warehouse shifts.',
|
professional_summary: 'Fulfillment associate seeking steady warehouse shifts.',
|
||||||
job_posting_id: 'job_picker',
|
job_posting_id: 'job_picker',
|
||||||
@@ -854,7 +854,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['Food Handler Card'],
|
certifications: ['Food Handler Card'],
|
||||||
availability: ['Weekdays', 'Weekends'],
|
availability: ['Weekdays', 'Weekends'],
|
||||||
skills: ['Banquet leadership', 'BEO reading', 'Floor coordination'],
|
skills: ['Banquet leadership', 'BEO reading', 'Floor coordination'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
professional_summary: 'Banquet server with lead-shift experience in hotel events.',
|
professional_summary: 'Banquet server with lead-shift experience in hotel events.',
|
||||||
job_posting_id: 'job_banquet',
|
job_posting_id: 'job_banquet',
|
||||||
@@ -874,7 +874,7 @@ const JOB_APPLICATIONS = [
|
|||||||
certifications: ['Food Handler Card'],
|
certifications: ['Food Handler Card'],
|
||||||
availability: ['Weekdays', 'Weekends', 'Evenings'],
|
availability: ['Weekdays', 'Weekends', 'Evenings'],
|
||||||
skills: ['Floor leadership', 'Client relations', 'Event reporting'],
|
skills: ['Floor leadership', 'Client relations', 'Event reporting'],
|
||||||
companies_worked: [],
|
companies_worked: [] as string[],
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
professional_summary: 'Hotel banquet captain with six years directing event floors.',
|
professional_summary: 'Hotel banquet captain with six years directing event floors.',
|
||||||
job_posting_id: 'job_banquet',
|
job_posting_id: 'job_banquet',
|
||||||
@@ -900,7 +900,7 @@ const JOB_APPLICATIONS = [
|
|||||||
|
|
||||||
/* ── AI interviews ─────────────────────────────────────────────────────── */
|
/* ── AI interviews ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const transcript = (candidate, role) => [
|
const transcript = (candidate: string, role: string) => [
|
||||||
{ role: 'assistant', content: `Hi ${candidate.split(' ')[0]} — thanks for making time. Tell me about the busiest ${role} shift you've worked and how you handled it.`, timestamp: iso('2026-08-04') },
|
{ role: 'assistant', content: `Hi ${candidate.split(' ')[0]} — thanks for making time. Tell me about the busiest ${role} shift you've worked and how you handled it.`, timestamp: iso('2026-08-04') },
|
||||||
{ role: 'user', content: 'We were short two people on a 300-guest event. I re-sectioned the floor, moved the strongest server to the head tables, and pulled the rest into a runner rotation so nothing sat under the lamps.', timestamp: iso('2026-08-04'), response_time_seconds: 11 },
|
{ role: 'user', content: 'We were short two people on a 300-guest event. I re-sectioned the floor, moved the strongest server to the head tables, and pulled the rest into a runner rotation so nothing sat under the lamps.', timestamp: iso('2026-08-04'), response_time_seconds: 11 },
|
||||||
{ role: 'assistant', content: 'What broke first, and what did you do the moment you noticed?', timestamp: iso('2026-08-04') },
|
{ role: 'assistant', content: 'What broke first, and what did you do the moment you noticed?', timestamp: iso('2026-08-04') },
|
||||||
@@ -919,7 +919,7 @@ const AI_INTERVIEWS = [
|
|||||||
verdict: 'maybe',
|
verdict: 'maybe',
|
||||||
hire_recommendation: 'Worth a trial shift on a coursed dinner before committing to the season.',
|
hire_recommendation: 'Worth a trial shift on a coursed dinner before committing to the season.',
|
||||||
integrity_score: 100,
|
integrity_score: 100,
|
||||||
ai_flags: [],
|
ai_flags: [] as string[],
|
||||||
category_scores: {
|
category_scores: {
|
||||||
communication: 78, confidence: 71, experience_relevance: 74, culture_fit: 76,
|
communication: 78, confidence: 71, experience_relevance: 74, culture_fit: 76,
|
||||||
problem_solving: 69, personality: 77, communication_style: 76,
|
problem_solving: 69, personality: 77, communication_style: 76,
|
||||||
@@ -947,11 +947,11 @@ const AI_INTERVIEWS = [
|
|||||||
verdict: 'maybe',
|
verdict: 'maybe',
|
||||||
hire_recommendation: '',
|
hire_recommendation: '',
|
||||||
integrity_score: 100,
|
integrity_score: 100,
|
||||||
ai_flags: [],
|
ai_flags: [] as string[],
|
||||||
category_scores: {},
|
category_scores: {},
|
||||||
strengths: [],
|
strengths: [] as string[],
|
||||||
concerns: [],
|
concerns: [] as string[],
|
||||||
best_fit_roles: [],
|
best_fit_roles: [] as string[],
|
||||||
summary: '',
|
summary: '',
|
||||||
reasoning: '',
|
reasoning: '',
|
||||||
created_date: iso('2026-08-05'),
|
created_date: iso('2026-08-05'),
|
||||||
@@ -967,11 +967,11 @@ const AI_INTERVIEWS = [
|
|||||||
verdict: 'maybe',
|
verdict: 'maybe',
|
||||||
hire_recommendation: '',
|
hire_recommendation: '',
|
||||||
integrity_score: 100,
|
integrity_score: 100,
|
||||||
ai_flags: [],
|
ai_flags: [] as string[],
|
||||||
category_scores: {},
|
category_scores: {},
|
||||||
strengths: [],
|
strengths: [] as string[],
|
||||||
concerns: [],
|
concerns: [] as string[],
|
||||||
best_fit_roles: [],
|
best_fit_roles: [] as string[],
|
||||||
summary: '',
|
summary: '',
|
||||||
reasoning: '',
|
reasoning: '',
|
||||||
created_date: iso('2026-08-05'),
|
created_date: iso('2026-08-05'),
|
||||||
@@ -987,11 +987,11 @@ const AI_INTERVIEWS = [
|
|||||||
verdict: 'maybe',
|
verdict: 'maybe',
|
||||||
hire_recommendation: '',
|
hire_recommendation: '',
|
||||||
integrity_score: 100,
|
integrity_score: 100,
|
||||||
ai_flags: [],
|
ai_flags: [] as string[],
|
||||||
category_scores: {},
|
category_scores: {},
|
||||||
strengths: [],
|
strengths: [] as string[],
|
||||||
concerns: [],
|
concerns: [] as string[],
|
||||||
best_fit_roles: [],
|
best_fit_roles: [] as string[],
|
||||||
summary: '',
|
summary: '',
|
||||||
reasoning: '',
|
reasoning: '',
|
||||||
created_date: iso('2026-08-03'),
|
created_date: iso('2026-08-03'),
|
||||||
@@ -1015,7 +1015,7 @@ const STAFF = [
|
|||||||
ai_score: 92,
|
ai_score: 92,
|
||||||
status: 'onboarding',
|
status: 'onboarding',
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
endorsed_skills: [],
|
endorsed_skills: [] as string[],
|
||||||
created_date: iso('2026-07-25'),
|
created_date: iso('2026-07-25'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1031,7 +1031,7 @@ const STAFF = [
|
|||||||
ai_score: 94,
|
ai_score: 94,
|
||||||
status: 'onboarding',
|
status: 'onboarding',
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
endorsed_skills: [],
|
endorsed_skills: [] as string[],
|
||||||
created_date: iso('2026-07-25'),
|
created_date: iso('2026-07-25'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1047,7 +1047,7 @@ const STAFF = [
|
|||||||
ai_score: 97,
|
ai_score: 97,
|
||||||
status: 'onboarding',
|
status: 'onboarding',
|
||||||
client_rating: 0,
|
client_rating: 0,
|
||||||
endorsed_skills: [],
|
endorsed_skills: [] as string[],
|
||||||
created_date: iso('2026-07-25'),
|
created_date: iso('2026-07-25'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -1085,7 +1085,7 @@ const COURSES = [
|
|||||||
{ criterion: 'Protects the table experience', weight: 30 },
|
{ criterion: 'Protects the table experience', weight: 30 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
|
||||||
quiz: [
|
quiz: [
|
||||||
{
|
{
|
||||||
question: 'A guest flags an allergy mid-service. What happens first?',
|
question: 'A guest flags an allergy mid-service. What happens first?',
|
||||||
@@ -1127,7 +1127,7 @@ const COURSES = [
|
|||||||
{ criterion: 'Escalates appropriately', weight: 30 },
|
{ criterion: 'Escalates appropriately', weight: 30 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
|
||||||
quiz: [
|
quiz: [
|
||||||
{
|
{
|
||||||
question: 'A guest shows clear signs of intoxication. What do you do?',
|
question: 'A guest shows clear signs of intoxication. What do you do?',
|
||||||
@@ -1151,7 +1151,7 @@ const COURSES = [
|
|||||||
proof_skill: 'Hazard Identification',
|
proof_skill: 'Hazard Identification',
|
||||||
skill_id: 'food_safety',
|
skill_id: 'food_safety',
|
||||||
target_level: 'beginner',
|
target_level: 'beginner',
|
||||||
required_level: null,
|
required_level: null as string | null,
|
||||||
completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'],
|
completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'],
|
||||||
verification_criteria: ['Identifies temperature violations', 'Spots cross-contamination risk', 'Notes obstruction hazards'],
|
verification_criteria: ['Identifies temperature violations', 'Spots cross-contamination risk', 'Notes obstruction hazards'],
|
||||||
challenge: {
|
challenge: {
|
||||||
@@ -1164,7 +1164,7 @@ const COURSES = [
|
|||||||
{ criterion: 'Notes obstruction hazards', weight: 20 },
|
{ criterion: 'Notes obstruction hazards', weight: 20 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
|
||||||
quiz: [
|
quiz: [
|
||||||
{
|
{
|
||||||
question: 'What is the danger zone for cold-held food?',
|
question: 'What is the danger zone for cold-held food?',
|
||||||
@@ -1201,7 +1201,7 @@ const COURSES = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
unlock_requirements: { min_shifts: 5, min_reliability: 70, required_badges: ['Service Fundamentals'] },
|
unlock_requirements: { min_shifts: 5, min_reliability: 70, required_badges: ['Service Fundamentals'] },
|
||||||
quiz: [],
|
quiz: [] as unknown[],
|
||||||
pass_score: 80,
|
pass_score: 80,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
created_date: iso('2026-06-16'),
|
created_date: iso('2026-06-16'),
|
||||||
@@ -1222,7 +1222,17 @@ const COURSES = [
|
|||||||
* what Owliver checks. `required_level` is the rung below, which is what makes
|
* what Owliver checks. `required_level` is the rung below, which is what makes
|
||||||
* the ladder a ladder rather than a menu.
|
* the ladder a ladder rather than a menu.
|
||||||
*/
|
*/
|
||||||
const MODULE_TABLE = [
|
/**
|
||||||
|
* One training module per row: skill, rung, title, minutes, challenge type,
|
||||||
|
* description, and the criteria it is marked against.
|
||||||
|
*
|
||||||
|
* Written out as a tuple because a bare array of mixed literals infers as an
|
||||||
|
* array of the UNION of its column types, so destructuring a row below gives
|
||||||
|
* every field `string | number | string[]` — and `SKILL_CATEGORY[skill_id]`
|
||||||
|
* then refuses a key that might be an array. The tuple says which column is
|
||||||
|
* which, which is what the reader already assumes.
|
||||||
|
*/
|
||||||
|
const MODULE_TABLE: [string, string, string, number, string, string, string[]][] = [
|
||||||
/* Server — the full four-rung ladder. */
|
/* Server — the full four-rung ladder. */
|
||||||
['server', 'beginner', 'Server Fundamentals', 18, 'roleplay', 'Sequence of service, tray discipline, and the first ninety seconds at a table.', ['Greets and sets expectations', 'Follows the service sequence', 'Handles the tray safely']],
|
['server', 'beginner', 'Server Fundamentals', 18, 'roleplay', 'Sequence of service, tray discipline, and the first ninety seconds at a table.', ['Greets and sets expectations', 'Follows the service sequence', 'Handles the tray safely']],
|
||||||
['server', 'beginner', 'Workplace Safety', 14, 'photo_identify', 'Spot the hazard before it becomes an incident — spills, traffic, hot pass.', ['Identifies floor hazards', 'Spots obstruction risks', 'Knows when to stop service']],
|
['server', 'beginner', 'Workplace Safety', 14, 'photo_identify', 'Spot the hazard before it becomes an incident — spills, traffic, hot pass.', ['Identifies floor hazards', 'Spots obstruction risks', 'Knows when to stop service']],
|
||||||
@@ -1283,7 +1293,7 @@ const MODULE_TABLE = [
|
|||||||
['food_safety', 'advanced', 'Incident Response & Reporting', 18, 'roleplay', 'A suspected foodborne incident, in the hour it matters.', ['Isolates the product', 'Documents accurately', 'Notifies the right people']],
|
['food_safety', 'advanced', 'Incident Response & Reporting', 18, 'roleplay', 'A suspected foodborne incident, in the hour it matters.', ['Isolates the product', 'Documents accurately', 'Notifies the right people']],
|
||||||
];
|
];
|
||||||
|
|
||||||
const SKILL_CATEGORY = {
|
const SKILL_CATEGORY: Record<string, string> = {
|
||||||
server: 'Service',
|
server: 'Service',
|
||||||
bartending: 'Bar',
|
bartending: 'Bar',
|
||||||
leadership: 'Leadership',
|
leadership: 'Leadership',
|
||||||
@@ -1294,20 +1304,20 @@ const SKILL_CATEGORY = {
|
|||||||
const LEVEL_ORDER = ['beginner', 'intermediate', 'advanced', 'expert'];
|
const LEVEL_ORDER = ['beginner', 'intermediate', 'advanced', 'expert'];
|
||||||
|
|
||||||
/** Level → the rung below it, which is what a module requires before it opens. */
|
/** Level → the rung below it, which is what a module requires before it opens. */
|
||||||
const requiredLevelFor = (level) => LEVEL_ORDER[LEVEL_ORDER.indexOf(level) - 1] || null;
|
const requiredLevelFor = (level: string) => LEVEL_ORDER[LEVEL_ORDER.indexOf(level) - 1] || null;
|
||||||
|
|
||||||
/** Difficulty is the existing field; level drives it so the two never disagree. */
|
/** Difficulty is the existing field; level drives it so the two never disagree. */
|
||||||
const DIFFICULTY_FOR = { beginner: 'beginner', intermediate: 'intermediate', advanced: 'advanced', expert: 'advanced' };
|
const DIFFICULTY_FOR: Record<string, string> = { beginner: 'beginner', intermediate: 'intermediate', advanced: 'advanced', expert: 'advanced' };
|
||||||
|
|
||||||
const XP_FOR = { beginner: 100, intermediate: 150, advanced: 220, expert: 300 };
|
const XP_FOR: Record<string, number> = { beginner: 100, intermediate: 150, advanced: 220, expert: 300 };
|
||||||
|
|
||||||
const PROMPT_FOR = {
|
const PROMPT_FOR: Record<string, (title: string) => string> = {
|
||||||
roleplay: (title) => `Work through a ${title.toLowerCase()} scenario with Owliver, start to finish.`,
|
roleplay: (title) => `Work through a ${title.toLowerCase()} scenario with Owliver, start to finish.`,
|
||||||
video: (title) => `Record yourself demonstrating ${title.toLowerCase()} as you would on a live shift.`,
|
video: (title) => `Record yourself demonstrating ${title.toLowerCase()} as you would on a live shift.`,
|
||||||
photo_identify: (title) => `Mark everything a ${title.toLowerCase()} check should catch in this photo.`,
|
photo_identify: (title) => `Mark everything a ${title.toLowerCase()} check should catch in this photo.`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
const slug = (title: string) => title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
||||||
|
|
||||||
const TRAINING_MODULES = MODULE_TABLE.map(
|
const TRAINING_MODULES = MODULE_TABLE.map(
|
||||||
([skill_id, target_level, title, minutes, type, description, criteria], i) => ({
|
([skill_id, target_level, title, minutes, type, description, criteria], i) => ({
|
||||||
@@ -1331,8 +1341,8 @@ const TRAINING_MODULES = MODULE_TABLE.map(
|
|||||||
ai_persona: type === 'roleplay' ? 'A guest or teammate in a live service situation.' : undefined,
|
ai_persona: type === 'roleplay' ? 'A guest or teammate in a live service situation.' : undefined,
|
||||||
rubric: (Array.isArray(criteria) ? criteria : []).map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })),
|
rubric: (Array.isArray(criteria) ? criteria : []).map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })),
|
||||||
},
|
},
|
||||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
|
||||||
quiz: [],
|
quiz: [] as unknown[],
|
||||||
pass_score: 70,
|
pass_score: 70,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
created_date: iso('2026-06-20'),
|
created_date: iso('2026-06-20'),
|
||||||
@@ -1344,7 +1354,7 @@ const TRAINING_MODULES = MODULE_TABLE.map(
|
|||||||
const COURSE_LIBRARY = [...COURSES, ...TRAINING_MODULES];
|
const COURSE_LIBRARY = [...COURSES, ...TRAINING_MODULES];
|
||||||
|
|
||||||
/** A module id from its skill and title, so profiles read like the ladder does. */
|
/** A module id from its skill and title, so profiles read like the ladder does. */
|
||||||
const mod = (skill, title) => `mod_${skill}_${slug(title)}`;
|
const mod = (skill: string, title: string) => `mod_${skill}_${slug(title)}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Completed training on a worker profile.
|
* Completed training on a worker profile.
|
||||||
@@ -1352,7 +1362,7 @@ const mod = (skill, title) => `mod_${skill}_${slug(title)}`;
|
|||||||
* Titles are looked up rather than repeated, so a renamed module cannot leave a
|
* Titles are looked up rather than repeated, so a renamed module cannot leave a
|
||||||
* profile claiming a course that no longer goes by that name.
|
* profile claiming a course that no longer goes by that name.
|
||||||
*/
|
*/
|
||||||
const completions = (ids, date, score = 88) =>
|
const completions = (ids: string[], date: string, score = 88) =>
|
||||||
ids.map((id) => ({
|
ids.map((id) => ({
|
||||||
course_id: id,
|
course_id: id,
|
||||||
title: COURSE_LIBRARY.find((c) => c.id === id)?.title || id,
|
title: COURSE_LIBRARY.find((c) => c.id === id)?.title || id,
|
||||||
@@ -1525,7 +1535,7 @@ const WORKER_PROFILES = [
|
|||||||
earned_badges: [
|
earned_badges: [
|
||||||
{ name: 'Service Fundamentals', level: 'bronze', course_id: 'course_service_basics', earned_date: iso('2026-07-28') },
|
{ name: 'Service Fundamentals', level: 'bronze', course_id: 'course_service_basics', earned_date: iso('2026-07-28') },
|
||||||
],
|
],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 6,
|
shifts_completed: 6,
|
||||||
attendance_score: 92,
|
attendance_score: 92,
|
||||||
performance_score: 0,
|
performance_score: 0,
|
||||||
@@ -1595,8 +1605,8 @@ const WORKER_PROFILES = [
|
|||||||
'2026-08-02',
|
'2026-08-02',
|
||||||
89
|
89
|
||||||
),
|
),
|
||||||
earned_badges: [],
|
earned_badges: [] as unknown[],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 38,
|
shifts_completed: 38,
|
||||||
attendance_score: 96,
|
attendance_score: 96,
|
||||||
performance_score: 88,
|
performance_score: 88,
|
||||||
@@ -1643,8 +1653,8 @@ const WORKER_PROFILES = [
|
|||||||
'2026-07-30',
|
'2026-07-30',
|
||||||
85
|
85
|
||||||
),
|
),
|
||||||
earned_badges: [],
|
earned_badges: [] as unknown[],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 21,
|
shifts_completed: 21,
|
||||||
attendance_score: 93,
|
attendance_score: 93,
|
||||||
performance_score: 81,
|
performance_score: 81,
|
||||||
@@ -1691,8 +1701,8 @@ const WORKER_PROFILES = [
|
|||||||
'2026-08-05',
|
'2026-08-05',
|
||||||
76
|
76
|
||||||
),
|
),
|
||||||
earned_badges: [],
|
earned_badges: [] as unknown[],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 4,
|
shifts_completed: 4,
|
||||||
attendance_score: 100,
|
attendance_score: 100,
|
||||||
performance_score: 0,
|
performance_score: 0,
|
||||||
@@ -1735,8 +1745,8 @@ const WORKER_PROFILES = [
|
|||||||
},
|
},
|
||||||
xp: 0,
|
xp: 0,
|
||||||
completed_courses: [],
|
completed_courses: [],
|
||||||
earned_badges: [],
|
earned_badges: [] as unknown[],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 0,
|
shifts_completed: 0,
|
||||||
attendance_score: 100,
|
attendance_score: 100,
|
||||||
performance_score: 0,
|
performance_score: 0,
|
||||||
@@ -1779,8 +1789,8 @@ const WORKER_PROFILES = [
|
|||||||
},
|
},
|
||||||
xp: 0,
|
xp: 0,
|
||||||
completed_courses: [],
|
completed_courses: [],
|
||||||
earned_badges: [],
|
earned_badges: [] as unknown[],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 0,
|
shifts_completed: 0,
|
||||||
attendance_score: 100,
|
attendance_score: 100,
|
||||||
performance_score: 0,
|
performance_score: 0,
|
||||||
@@ -1823,8 +1833,8 @@ const WORKER_PROFILES = [
|
|||||||
},
|
},
|
||||||
xp: 0,
|
xp: 0,
|
||||||
completed_courses: [],
|
completed_courses: [],
|
||||||
earned_badges: [],
|
earned_badges: [] as unknown[],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 0,
|
shifts_completed: 0,
|
||||||
attendance_score: 100,
|
attendance_score: 100,
|
||||||
performance_score: 0,
|
performance_score: 0,
|
||||||
@@ -1867,8 +1877,8 @@ const WORKER_PROFILES = [
|
|||||||
},
|
},
|
||||||
xp: 0,
|
xp: 0,
|
||||||
completed_courses: [],
|
completed_courses: [],
|
||||||
earned_badges: [],
|
earned_badges: [] as unknown[],
|
||||||
capabilities: [],
|
capabilities: [] as unknown[],
|
||||||
shifts_completed: 0,
|
shifts_completed: 0,
|
||||||
attendance_score: 100,
|
attendance_score: 100,
|
||||||
performance_score: 0,
|
performance_score: 0,
|
||||||
@@ -1980,8 +1990,12 @@ const USER_ACTIVITY = ACTIVITY_EVENTS.map(([event_type, user_email, user_name, a
|
|||||||
* candidate, interview and shift record below — into the production bundle.
|
* candidate, interview and shift record below — into the production bundle.
|
||||||
* The fixtures here are for the test scripts, and they still read
|
* The fixtures here are for the test scripts, and they still read
|
||||||
* `seedData.User` and `DEMO_USER` from this module, so both keep working.
|
* `seedData.User` and `DEMO_USER` from this module, so both keep working.
|
||||||
|
*
|
||||||
|
* `EMPLOYER_USER` travels the same way. It is seeded, never rendered: the
|
||||||
|
* backend writes both accounts so the employer console has somebody to sign
|
||||||
|
* in as, and nothing in the running app imports it.
|
||||||
*/
|
*/
|
||||||
export { DEMO_USER } from './demoUser';
|
export { DEMO_USER, EMPLOYER_USER } from './demoUser';
|
||||||
|
|
||||||
/* ── Assignments ───────────────────────────────────────────────────────────
|
/* ── Assignments ───────────────────────────────────────────────────────────
|
||||||
Who is on which position, and until when — the record that turns a hire into
|
Who is on which position, and until when — the record that turns a hire into
|
||||||
@@ -2014,7 +2028,7 @@ const ASSIGNMENTS = [
|
|||||||
worker_email: 'marco.rivera@email.com',
|
worker_email: 'marco.rivera@email.com',
|
||||||
worker_name: 'Marco Rivera',
|
worker_name: 'Marco Rivera',
|
||||||
starts_at: iso('2026-07-01'),
|
starts_at: iso('2026-07-01'),
|
||||||
ends_at: null,
|
ends_at: null as string | null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
source: 'seed',
|
source: 'seed',
|
||||||
match_score: 92,
|
match_score: 92,
|
||||||
@@ -2041,5 +2055,5 @@ export const seedData = {
|
|||||||
Assignment: ASSIGNMENTS,
|
Assignment: ASSIGNMENTS,
|
||||||
ShiftRecord: SHIFT_RECORDS,
|
ShiftRecord: SHIFT_RECORDS,
|
||||||
Evidence: EVIDENCE,
|
Evidence: EVIDENCE,
|
||||||
User: [DEMO_USER],
|
User: [DEMO_USER, EMPLOYER_USER],
|
||||||
};
|
};
|
||||||
@@ -21,7 +21,12 @@ const DefaultFallback = () => (
|
|||||||
* The attempted path travels in location state so signing in returns the
|
* The attempted path travels in location state so signing in returns the
|
||||||
* visitor to where they were going.
|
* visitor to where they were going.
|
||||||
*/
|
*/
|
||||||
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }) {
|
interface ProtectedRouteProps {
|
||||||
|
fallback?: any;
|
||||||
|
unauthenticatedElement?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProtectedRoute({ fallback = <DefaultFallback />, unauthenticatedElement }: ProtectedRouteProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
|
const { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
|
||||||
const signedOut = unauthenticatedElement ?? (
|
const signedOut = unauthenticatedElement ?? (
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const UserNotRegisteredError = () => {
|
const UserNotRegisteredError = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-b from-white to-slate-50">
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-b from-white to-slate-50">
|
||||||
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow-lg border border-slate-100">
|
<div className="max-w-md w-full p-8 bg-white rounded-lg shadow-lg border border-slate-100">
|
||||||
@@ -10,16 +12,16 @@ const UserNotRegisteredError = () => {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-slate-900 mb-4">Access Restricted</h1>
|
<h1 className="text-3xl font-bold text-slate-900 mb-4">{t('userNotRegisteredError.accessRestricted')}</h1>
|
||||||
<p className="text-slate-600 mb-8">
|
<p className="text-slate-600 mb-8">
|
||||||
You are not registered to use this application. Please contact the app administrator to request access.
|
{t('userNotRegisteredError.youAreNotRegisteredToUse')}
|
||||||
</p>
|
</p>
|
||||||
<div className="p-4 bg-slate-50 rounded-md text-sm text-slate-600">
|
<div className="p-4 bg-slate-50 rounded-md text-sm text-slate-600">
|
||||||
<p>If you believe this is an error, you can:</p>
|
<p>{t('userNotRegisteredError.ifYouBelieveThisIsAn')}</p>
|
||||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||||
<li>Verify you are logged in with the correct account</li>
|
<li>{t('userNotRegisteredError.verifyYouAreLoggedInWith')}</li>
|
||||||
<li>Contact the app administrator for access</li>
|
<li>{t('userNotRegisteredError.contactTheAppAdministratorForAccess')}</li>
|
||||||
<li>Try logging out and back in again</li>
|
<li>{t('userNotRegisteredError.tryLoggingOutAndBackIn')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared page furniture for the Admin product.
|
* Shared page furniture for the Admin product.
|
||||||
@@ -17,8 +18,18 @@ import { cn } from '@/lib/utils';
|
|||||||
* Controls live in the page rather than in the global chrome, which is why the
|
* Controls live in the page rather than in the global chrome, which is why the
|
||||||
* Admin header only needs one 56px bar.
|
* Admin header only needs one 56px bar.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
interface AdminPageProps {
|
||||||
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }) {
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
meta?: any;
|
||||||
|
actions?: any;
|
||||||
|
tabs?: any;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }: AdminPageProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-6', className)}>
|
<div className={cn('space-y-6', className)}>
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
|
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border/50 pb-4">
|
||||||
@@ -36,7 +47,7 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
|
|||||||
{meta && <span className="whitespace-nowrap text-body-sm tabular-nums text-ink-4">{meta}</span>}
|
{meta && <span className="whitespace-nowrap text-body-sm tabular-nums text-ink-4">{meta}</span>}
|
||||||
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
<span className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-semibold text-blue-600 dark:bg-blue-950/60 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
|
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
|
||||||
Live System
|
{t('shell.liveSystem')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{subtitle && <p className="text-body-sm text-ink-3 font-normal">{subtitle}</p>}
|
{subtitle && <p className="text-body-sm text-ink-3 font-normal">{subtitle}</p>}
|
||||||
@@ -57,8 +68,15 @@ export function AdminPage({ title, subtitle, meta, actions, tabs, children, clas
|
|||||||
* Sentence-case and small rather than a heavy all-caps overline: on a page with
|
* Sentence-case and small rather than a heavy all-caps overline: on a page with
|
||||||
* four or five sections, loud labels compete with the data they introduce.
|
* four or five sections, loud labels compete with the data they introduce.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
interface SectionTitleProps {
|
||||||
export function SectionTitle({ id, title, meta, action, className }) {
|
id?: any;
|
||||||
|
title?: string;
|
||||||
|
meta?: any;
|
||||||
|
action?: any;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionTitle({ id, title, meta, action, className }: SectionTitleProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
|
<div className={cn('flex flex-wrap items-baseline justify-between gap-2', className)}>
|
||||||
{/* The count wraps under the section name rather than competing with it
|
{/* The count wraps under the section name rather than competing with it
|
||||||
@@ -91,8 +109,15 @@ export function SectionTitle({ id, title, meta, action, className }) {
|
|||||||
* primary action is pushed to the end, so every management page has the same
|
* primary action is pushed to the end, so every management page has the same
|
||||||
* control geometry.
|
* control geometry.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
interface ToolbarProps {
|
||||||
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }) {
|
search?: any;
|
||||||
|
filters?: any;
|
||||||
|
actions?: any;
|
||||||
|
meta?: any;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }: ToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-2', className)}>
|
<div className={cn('space-y-2', className)}>
|
||||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
import {
|
import {
|
||||||
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
|
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -19,8 +20,10 @@ import { cn } from '@/lib/utils';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
|
/** Category to icon. Matched loosely, so a new "Head Chef" still gets the hat. */
|
||||||
/** @type {[RegExp, React.ComponentType<any>][]} */
|
/* `[pattern, icon]` pairs. Without the tuple the element widens to the union of
|
||||||
const GLYPHS = [
|
both positions, so `pattern.test` and `<Icon />` both stop working — the same
|
||||||
|
shape as `aiEngine`'s route table. */
|
||||||
|
const GLYPHS: [RegExp, ComponentType<any>][] = [
|
||||||
[/chef|cook|kitchen/i, ChefHat],
|
[/chef|cook|kitchen/i, ChefHat],
|
||||||
[/bartend|bar|mixolog/i, Wine],
|
[/bartend|bar|mixolog/i, Wine],
|
||||||
[/server|wait|host|banquet|concierge/i, ConciergeBell],
|
[/server|wait|host|banquet|concierge/i, ConciergeBell],
|
||||||
@@ -42,8 +45,15 @@ const SIZES = {
|
|||||||
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
|
lg: { box: 'h-14 w-14 rounded-xl', icon: 'h-6 w-6' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @param {any} props */
|
interface RoleGlyphProps {
|
||||||
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }) {
|
category?: any;
|
||||||
|
title?: string;
|
||||||
|
size?: any;
|
||||||
|
aiGenerated?: any;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }: RoleGlyphProps) {
|
||||||
const Icon = iconForRole(category, title);
|
const Icon = iconForRole(category, title);
|
||||||
const s = SIZES[size] || SIZES.md;
|
const s = SIZES[size] || SIZES.md;
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ import { ChevronRight, X } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { agentIconFor } from './icons';
|
import { agentIconFor } from './icons';
|
||||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Agent Configure workspace, as parts.
|
* The Agent Configure workspace, as parts.
|
||||||
@@ -44,7 +45,7 @@ import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
|||||||
* holds a ~380px track on the right of every Admin page, so below `xl` the
|
* holds a ~380px track on the right of every Admin page, so below `xl` the
|
||||||
* remaining width belongs to the editor and the rail becomes a strip above it.
|
* remaining width belongs to the editor and the rail becomes a strip above it.
|
||||||
*/
|
*/
|
||||||
export function Workspace({ rail, children, className }) {
|
export function Workspace({ rail, children, className }: any) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('w-full rounded-2xl border border-border bg-surface shadow-xs overflow-hidden', className)}>
|
<div className={cn('w-full rounded-2xl border border-border bg-surface shadow-xs overflow-hidden', className)}>
|
||||||
<div className="border-b border-border/80 bg-surface-subtle/40 px-4 py-3 sm:px-6">
|
<div className="border-b border-border/80 bg-surface-subtle/40 px-4 py-3 sm:px-6">
|
||||||
@@ -64,8 +65,7 @@ export function Workspace({ rail, children, className }) {
|
|||||||
* height without measuring it in JavaScript. Content stays mounted, which is
|
* height without measuring it in JavaScript. Content stays mounted, which is
|
||||||
* what keeps a section addressable by the rail while it is closed.
|
* what keeps a section addressable by the rail while it is closed.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
export function Collapse({ open, children, className }: any) {
|
||||||
export function Collapse({ open, children, className }) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -100,13 +100,13 @@ export function Collapse({ open, children, className }) {
|
|||||||
* Provides clear section navigation and leaf anchor shortcuts across the
|
* Provides clear section navigation and leaf anchor shortcuts across the
|
||||||
* configuration workspace.
|
* configuration workspace.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
export function Rail({ sections, activeId, onSelect }: any) {
|
||||||
export function Rail({ sections, activeId, onSelect }) {
|
const { t } = useTranslation();
|
||||||
const activeSection = sections.find((s) => s.id === activeId);
|
const activeSection = sections.find((s) => s.id === activeId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label="Agent configuration"
|
aria-label={t('agentCanvas.agentConfiguration')}
|
||||||
className="flex flex-wrap items-center justify-between gap-2.5"
|
className="flex flex-wrap items-center justify-between gap-2.5"
|
||||||
>
|
>
|
||||||
{/* Section anchor pills */}
|
{/* Section anchor pills */}
|
||||||
@@ -153,7 +153,7 @@ export function Rail({ sections, activeId, onSelect }) {
|
|||||||
{/* Sub-item quick jump leaves */}
|
{/* Sub-item quick jump leaves */}
|
||||||
{activeSection?.items?.length > 0 && (
|
{activeSection?.items?.length > 0 && (
|
||||||
<div className="hidden items-center gap-1.5 md:flex">
|
<div className="hidden items-center gap-1.5 md:flex">
|
||||||
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">Jump to:</span>
|
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">{t('agentCanvas.jumpTo')}</span>
|
||||||
{activeSection.items.map((item) => (
|
{activeSection.items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -181,11 +181,10 @@ export function Rail({ sections, activeId, onSelect }) {
|
|||||||
/**
|
/**
|
||||||
* One section of the document.
|
* One section of the document.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
|
||||||
export function DocSection({
|
export function DocSection({
|
||||||
id, icon: Icon, title, description, meta, open, onToggle, emphasis = false, last = false,
|
id, icon: Icon, title, description, meta, open, onToggle, emphasis = false, last = false,
|
||||||
children,
|
children,
|
||||||
}) {
|
}: any) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id={id}
|
id={id}
|
||||||
@@ -257,8 +256,7 @@ export function DocSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A labelled field in the document body. */
|
/** A labelled field in the document body. */
|
||||||
/** @param {any} props */
|
export function DocField({ id, label, hint, children, className }: any) {
|
||||||
export function DocField({ id, label, hint, children, className }) {
|
|
||||||
const reactId = React.useId();
|
const reactId = React.useId();
|
||||||
const controlId = id ? `${id}-control` : reactId;
|
const controlId = id ? `${id}-control` : reactId;
|
||||||
|
|
||||||
@@ -270,7 +268,7 @@ export function DocField({ id, label, hint, children, className }) {
|
|||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
{React.isValidElement(children)
|
{React.isValidElement<any>(children)
|
||||||
? React.cloneElement(children, { id: children.props.id || controlId })
|
? React.cloneElement(children, { id: children.props.id || controlId })
|
||||||
: children}
|
: children}
|
||||||
{hint && <p className="text-caption leading-relaxed text-ink-4">{hint}</p>}
|
{hint && <p className="text-caption leading-relaxed text-ink-4">{hint}</p>}
|
||||||
@@ -284,8 +282,7 @@ export function DocField({ id, label, hint, children, className }) {
|
|||||||
* One notch quieter than a section title and one louder than a row, which is
|
* One notch quieter than a section title and one louder than a row, which is
|
||||||
* exactly the level of the thing it names.
|
* exactly the level of the thing it names.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
export function GroupHead({ id, icon: Icon, title, count, action, className }: any) {
|
||||||
export function GroupHead({ id, icon: Icon, title, count, action, className }) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={id}
|
id={id}
|
||||||
@@ -310,8 +307,7 @@ export function GroupHead({ id, icon: Icon, title, count, action, className }) {
|
|||||||
* competing with the section that holds them; as rows they read as a list,
|
* competing with the section that holds them; as rows they read as a list,
|
||||||
* which is what they are.
|
* which is what they are.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }: any) {
|
||||||
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }) {
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={`group/item -mx-2 flex items-start gap-2.5 rounded-lg px-2 py-2
|
className={`group/item -mx-2 flex items-start gap-2.5 rounded-lg px-2 py-2
|
||||||
@@ -352,10 +348,9 @@ export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLa
|
|||||||
* a switch in a button makes one press mean two things. A setting that needs
|
* a switch in a button makes one press mean two things. A setting that needs
|
||||||
* space gets a chevron and opens underneath.
|
* space gets a chevron and opens underneath.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
|
||||||
export function SettingRow({
|
export function SettingRow({
|
||||||
id, icon: Icon, title, value, description, control, children, open, onToggle, last = false,
|
id, icon: Icon, title, value, description, control, children, open, onToggle, last = false,
|
||||||
}) {
|
}: any) {
|
||||||
const expandable = Boolean(children);
|
const expandable = Boolean(children);
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
@@ -412,8 +407,7 @@ export function SettingRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A page an agent covers, or one it could. */
|
/** A page an agent covers, or one it could. */
|
||||||
/** @param {any} props */
|
export function ScopeChip({ active, children, ...props }: any) {
|
||||||
export function ScopeChip({ active, children, ...props }) {
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -443,8 +437,7 @@ export function ScopeChip({ active, children, ...props }) {
|
|||||||
/* ── Identity ────────────────────────────────────────────────────────────── */
|
/* ── Identity ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/** The icons an agent may wear, as a picker. */
|
/** The icons an agent may wear, as a picker. */
|
||||||
/** @param {any} props */
|
export function IconPicker({ icons, value, onChange }: any) {
|
||||||
export function IconPicker({ icons, value, onChange }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-5 gap-1.5">
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
{icons.map((icon) => {
|
{icons.map((icon) => {
|
||||||
@@ -486,13 +479,13 @@ export function IconPicker({ icons, value, onChange }) {
|
|||||||
* same three things the switcher renders — icon, name, description — and
|
* same three things the switcher renders — icon, name, description — and
|
||||||
* nothing it does not.
|
* nothing it does not.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
export function AgentPreview({ name, description, icon }: any) {
|
||||||
export function AgentPreview({ name, description, icon }) {
|
const { t } = useTranslation();
|
||||||
const Icon = agentIconFor(icon);
|
const Icon = agentIconFor(icon);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-surface-subtle p-3">
|
<div className="rounded-xl border border-border bg-surface-subtle p-3">
|
||||||
<p className="text-overline uppercase text-ink-4">In Owliver</p>
|
<p className="text-overline uppercase text-ink-4">{t('agentCanvas.inOwliver')}</p>
|
||||||
<div className="mt-2 flex items-start gap-2.5 rounded-lg border border-border bg-surface p-2.5 shadow-xs">
|
<div className="mt-2 flex items-start gap-2.5 rounded-lg border border-border bg-surface p-2.5 shadow-xs">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces';
|
import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces';
|
||||||
import { getSkillsForPage } from '@/lib/skills/registry';
|
import { getSkillsForPage } from '@/lib/skills/registry';
|
||||||
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
|
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip,
|
AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip,
|
||||||
SettingRow, Workspace,
|
SettingRow, Workspace,
|
||||||
@@ -94,7 +95,6 @@ function useActiveSection(ids) {
|
|||||||
return [active, setActive];
|
return [active, setActive];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
|
||||||
export function AgentConfigure({
|
export function AgentConfigure({
|
||||||
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
|
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
|
||||||
/* Attaching and detaching a skill goes through the caller's existing write
|
/* Attaching and detaching a skill goes through the caller's existing write
|
||||||
@@ -107,7 +107,8 @@ export function AgentConfigure({
|
|||||||
/* The tools this deployment registers, from GET /api/v1/tools. Passed in
|
/* The tools this deployment registers, from GET /api/v1/tools. Passed in
|
||||||
rather than fetched here so this component stays a form over `fields`. */
|
rather than fetched here so this component stays a form over `fields`. */
|
||||||
toolCatalogue = [],
|
toolCatalogue = [],
|
||||||
}) {
|
}: any) {
|
||||||
|
const { t } = useTranslation();
|
||||||
/* Sections are open by default: this is a document, and one that greets its
|
/* Sections are open by default: this is a document, and one that greets its
|
||||||
author with four closed headers hides the thing they came to write. Closing
|
author with four closed headers hides the thing they came to write. Closing
|
||||||
is for focus, not for the initial reading. */
|
is for focus, not for the initial reading. */
|
||||||
@@ -241,17 +242,17 @@ export function AgentConfigure({
|
|||||||
<DocSection
|
<DocSection
|
||||||
id="identity"
|
id="identity"
|
||||||
icon={IdCard}
|
icon={IdCard}
|
||||||
title="Identity"
|
title={t('agentConfigure.identity')}
|
||||||
description="How this agent is presented to people using Owliver."
|
description={t('agentConfigure.howThisAgentIsPresentedTo')}
|
||||||
open={open.identity}
|
open={open.identity}
|
||||||
onToggle={() => toggleSection('identity')}
|
onToggle={() => toggleSection('identity')}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start">
|
||||||
<div className="lg:col-span-7 xl:col-span-7 space-y-3">
|
<div className="lg:col-span-7 xl:col-span-7 space-y-3">
|
||||||
<DocField id="identity-name" label="Name">
|
<DocField id="identity-name" label={t('agentConfigure.name')}>
|
||||||
<Input
|
<Input
|
||||||
value={fields.name}
|
value={fields.name}
|
||||||
placeholder="e.g. Positions Agent"
|
placeholder={t('agentConfigure.eGPositionsAgent')}
|
||||||
onChange={(e) => set({ name: e.target.value })}
|
onChange={(e) => set({ name: e.target.value })}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -259,12 +260,12 @@ export function AgentConfigure({
|
|||||||
|
|
||||||
<DocField
|
<DocField
|
||||||
id="identity-description"
|
id="identity-description"
|
||||||
label="Description"
|
label={t('agentConfigure.description')}
|
||||||
hint="One line, shown under the name when choosing an agent."
|
hint={t('agentConfigure.oneLineShownUnderTheName')}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={fields.description}
|
value={fields.description}
|
||||||
placeholder="e.g. Open roles — what they need and which are at risk."
|
placeholder={t('agentConfigure.eGOpenRolesWhatThey')}
|
||||||
onChange={(e) => set({ description: e.target.value })}
|
onChange={(e) => set({ description: e.target.value })}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -272,13 +273,13 @@ export function AgentConfigure({
|
|||||||
|
|
||||||
<DocField
|
<DocField
|
||||||
id="identity-trigger"
|
id="identity-trigger"
|
||||||
label="When to use it"
|
label={t('agentConfigure.whenToUseIt')}
|
||||||
hint="Helps a reader pick the right agent. Never matched against questions."
|
hint={t('agentConfigure.helpsAReaderPickTheRight')}
|
||||||
>
|
>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={fields.trigger}
|
value={fields.trigger}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="e.g. Use on Positions, for open roles and applicant flow."
|
placeholder={t('agentConfigure.eGUseOnPositionsFor')}
|
||||||
onChange={(e) => set({ trigger: e.target.value })}
|
onChange={(e) => set({ trigger: e.target.value })}
|
||||||
className="w-full resize-y"
|
className="w-full resize-y"
|
||||||
/>
|
/>
|
||||||
@@ -293,7 +294,7 @@ export function AgentConfigure({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div id="identity-icon" className="scroll-mt-28 rounded-xl border border-border bg-surface-subtle/50 p-3">
|
<div id="identity-icon" className="scroll-mt-28 rounded-xl border border-border bg-surface-subtle/50 p-3">
|
||||||
<p className="mb-1.5 text-body-sm font-medium text-ink-2">Icon</p>
|
<p className="mb-1.5 text-body-sm font-medium text-ink-2">{t('agentConfigure.icon')}</p>
|
||||||
<IconPicker
|
<IconPicker
|
||||||
icons={AGENT_ICONS}
|
icons={AGENT_ICONS}
|
||||||
value={fields.icon}
|
value={fields.icon}
|
||||||
@@ -308,8 +309,8 @@ export function AgentConfigure({
|
|||||||
<DocSection
|
<DocSection
|
||||||
id="instructions"
|
id="instructions"
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
title="Instructions"
|
title={t('agentConfigure.instructions')}
|
||||||
description="How this agent should answer, what it is responsible for, and what it should say when it cannot help."
|
description={t('agentConfigure.howThisAgentShouldAnswerWhat')}
|
||||||
meta={`${instructionWords} word${instructionWords === 1 ? '' : 's'}`}
|
meta={`${instructionWords} word${instructionWords === 1 ? '' : 's'}`}
|
||||||
open={open.instructions}
|
open={open.instructions}
|
||||||
onToggle={() => toggleSection('instructions')}
|
onToggle={() => toggleSection('instructions')}
|
||||||
@@ -334,7 +335,7 @@ export function AgentConfigure({
|
|||||||
<span className="text-caption tabular-nums text-ink-4">
|
<span className="text-caption tabular-nums text-ink-4">
|
||||||
{instructionWords} word{instructionWords === 1 ? '' : 's'}
|
{instructionWords} word{instructionWords === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-caption text-ink-4">Saved with the agent definition</span>
|
<span className="text-caption text-ink-4">{t('agentConfigure.savedWithTheAgentDefinition')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DocSection>
|
</DocSection>
|
||||||
@@ -343,8 +344,8 @@ export function AgentConfigure({
|
|||||||
<DocSection
|
<DocSection
|
||||||
id="capabilities"
|
id="capabilities"
|
||||||
icon={Boxes}
|
icon={Boxes}
|
||||||
title="Capabilities"
|
title={t('agentConfigure.capabilities')}
|
||||||
description="What this agent can reach and use when it answers."
|
description={t('agentConfigure.whatThisAgentCanReachAnd')}
|
||||||
meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`}
|
meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`}
|
||||||
open={open.capabilities}
|
open={open.capabilities}
|
||||||
onToggle={() => toggleSection('capabilities')}
|
onToggle={() => toggleSection('capabilities')}
|
||||||
@@ -355,12 +356,12 @@ export function AgentConfigure({
|
|||||||
<GroupHead
|
<GroupHead
|
||||||
id="capabilities-pages"
|
id="capabilities-pages"
|
||||||
icon={LayoutGrid}
|
icon={LayoutGrid}
|
||||||
title="Pages it covers"
|
title={t('agentConfigure.pagesItCovers')}
|
||||||
count={fields.pages.length}
|
count={fields.pages.length}
|
||||||
/>
|
/>
|
||||||
<p className="pb-2 text-caption leading-relaxed text-ink-3">
|
<p className="pb-2 text-caption leading-relaxed text-ink-3">
|
||||||
{coveredLabels.length
|
{coveredLabels.length
|
||||||
? <>Answers on <span className="font-medium text-ink-1">{coveredLabels.join(', ')}</span>. Anywhere else it is shown as constrained and declines.</>
|
? <>{t('agentConfigure.answersOn')} <span className="font-medium text-ink-1">{coveredLabels.join(', ')}</span>{t('agentConfigure.anywhereElseItIsShownAs')}</>
|
||||||
: 'No page selected — this agent has nowhere to answer yet.'}
|
: 'No page selected — this agent has nowhere to answer yet.'}
|
||||||
</p>
|
</p>
|
||||||
{/**
|
{/**
|
||||||
@@ -391,7 +392,7 @@ export function AgentConfigure({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{!fields.pages.length && (
|
{!fields.pages.length && (
|
||||||
<span className="text-body-sm text-ink-3">No surface registered.</span>
|
<span className="text-body-sm text-ink-3">{t('agentConfigure.noSurfaceRegistered')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -437,19 +438,18 @@ export function AgentConfigure({
|
|||||||
<GroupHead
|
<GroupHead
|
||||||
id="capabilities-skills"
|
id="capabilities-skills"
|
||||||
icon={Boxes}
|
icon={Boxes}
|
||||||
title="Skills"
|
title={t('agentConfigure.skills')}
|
||||||
count={fields.skills.length}
|
count={fields.skills.length}
|
||||||
action={onOpenSkills && (
|
action={onOpenSkills && (
|
||||||
<Button variant="ghost" size="sm" onClick={onOpenSkills}>
|
<Button variant="ghost" size="sm" onClick={onOpenSkills}>
|
||||||
Browse catalog
|
{t('agentConfigure.browseCatalog')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!fields.pages.length ? (
|
{!fields.pages.length ? (
|
||||||
<p className="mt-2 text-body-sm text-ink-3">
|
<p className="mt-2 text-body-sm text-ink-3">
|
||||||
Choose a page first. Skills belong to pages, so there are none to offer until
|
{t('agentConfigure.chooseAPageFirstSkillsBelong')}
|
||||||
this agent has somewhere to answer.
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 space-y-3">
|
<div className="mt-2 space-y-3">
|
||||||
@@ -479,7 +479,7 @@ export function AgentConfigure({
|
|||||||
|
|
||||||
{!fields.skills.length && (
|
{!fields.skills.length && (
|
||||||
<p className="text-body-sm text-ink-3">
|
<p className="text-body-sm text-ink-3">
|
||||||
No skills attached. This agent answers from the page's own reader only.
|
{t('agentConfigure.noSkillsAttachedThisAgentAnswers')}only.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -524,7 +524,7 @@ export function AgentConfigure({
|
|||||||
<GroupHead
|
<GroupHead
|
||||||
id="capabilities-tools"
|
id="capabilities-tools"
|
||||||
icon={Wrench}
|
icon={Wrench}
|
||||||
title="Tools"
|
title={t('agentConfigure.tools')}
|
||||||
count={fields.tools.length}
|
count={fields.tools.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -558,8 +558,7 @@ export function AgentConfigure({
|
|||||||
|
|
||||||
{!fields.tools.length && (
|
{!fields.tools.length && (
|
||||||
<p className="text-body-sm text-ink-3">
|
<p className="text-body-sm text-ink-3">
|
||||||
No tools attached. This agent can discuss its subject but cannot look
|
{t('agentConfigure.noToolsAttachedThisAgentCan')}
|
||||||
anything up.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -585,9 +584,7 @@ export function AgentConfigure({
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<p className="text-caption leading-relaxed text-ink-4">
|
<p className="text-caption leading-relaxed text-ink-4">
|
||||||
A tool marked <em>writes</em> can propose a change. Nothing is written until
|
{t('agentConfigure.aToolMarked')} <em>writes</em> {t('agentConfigure.canProposeAChangeNothingIs')}
|
||||||
somebody approves it, and the agent can only reach what you could reach
|
|
||||||
yourself.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -597,7 +594,7 @@ export function AgentConfigure({
|
|||||||
<GroupHead
|
<GroupHead
|
||||||
id="capabilities-knowledge"
|
id="capabilities-knowledge"
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
title="Knowledge"
|
title={t('agentConfigure.knowledge')}
|
||||||
count={fields.knowledge.length}
|
count={fields.knowledge.length}
|
||||||
action={(
|
action={(
|
||||||
<Button
|
<Button
|
||||||
@@ -610,7 +607,7 @@ export function AgentConfigure({
|
|||||||
],
|
],
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" /> Add knowledge
|
<Plus className="h-3.5 w-3.5" /> {t('agentConfigure.addKnowledge')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -623,10 +620,10 @@ export function AgentConfigure({
|
|||||||
className="animate-fade-in space-y-2.5 rounded-xl border border-border bg-surface-subtle p-3"
|
className="animate-fade-in space-y-2.5 rounded-xl border border-border bg-surface-subtle p-3"
|
||||||
>
|
>
|
||||||
<div className="grid gap-2.5 sm:grid-cols-[minmax(0,1fr)_9rem]">
|
<div className="grid gap-2.5 sm:grid-cols-[minmax(0,1fr)_9rem]">
|
||||||
<Field label="Title">
|
<Field label={t('agentConfigure.title')}>
|
||||||
<Input
|
<Input
|
||||||
value={entry.label}
|
value={entry.label}
|
||||||
placeholder="e.g. Overtime policy"
|
placeholder={t('agentConfigure.eGOvertimePolicy')}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields.knowledge];
|
const next = [...fields.knowledge];
|
||||||
next[i] = { ...entry, label: e.target.value };
|
next[i] = { ...entry, label: e.target.value };
|
||||||
@@ -634,7 +631,7 @@ export function AgentConfigure({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Kind">
|
<Field label={t('agentConfigure.kind')}>
|
||||||
<Select
|
<Select
|
||||||
value={entry.kind}
|
value={entry.kind}
|
||||||
onValueChange={(kind) => {
|
onValueChange={(kind) => {
|
||||||
@@ -656,7 +653,7 @@ export function AgentConfigure({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{entry.kind === 'link' ? (
|
{entry.kind === 'link' ? (
|
||||||
<Field label="Link">
|
<Field label={t('agentConfigure.link')}>
|
||||||
<Input
|
<Input
|
||||||
value={entry.url}
|
value={entry.url}
|
||||||
placeholder="https://…"
|
placeholder="https://…"
|
||||||
@@ -669,8 +666,8 @@ export function AgentConfigure({
|
|||||||
</Field>
|
</Field>
|
||||||
) : (
|
) : (
|
||||||
<Field
|
<Field
|
||||||
label="What it says"
|
label={t('agentConfigure.whatItSays')}
|
||||||
hint="Quoted back with its source named. Nothing is invented from it."
|
hint={t('agentConfigure.quotedBackWithItsSourceNamed')}
|
||||||
>
|
>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={entry.body}
|
value={entry.body}
|
||||||
@@ -691,14 +688,14 @@ export function AgentConfigure({
|
|||||||
knowledge: fields.knowledge.filter((_, x) => x !== i),
|
knowledge: fields.knowledge.filter((_, x) => x !== i),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" /> Remove
|
<Trash2 className="h-3.5 w-3.5" /> {t('agentConfigure.remove')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-body-sm text-ink-3">
|
<p className="text-body-sm text-ink-3">
|
||||||
No knowledge attached. Nothing is invented — this agent answers from skills only.
|
{t('agentConfigure.noKnowledgeAttachedNothingIsInvented')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -709,8 +706,8 @@ export function AgentConfigure({
|
|||||||
<DocSection
|
<DocSection
|
||||||
id="behavior"
|
id="behavior"
|
||||||
icon={SlidersHorizontal}
|
icon={SlidersHorizontal}
|
||||||
title="Behavior"
|
title={t('agentConfigure.behavior')}
|
||||||
description="How this agent works when it answers."
|
description={t('agentConfigure.howThisAgentWorksWhenIt')}
|
||||||
meta={reasoning?.label}
|
meta={reasoning?.label}
|
||||||
open={open.behavior}
|
open={open.behavior}
|
||||||
onToggle={() => toggleSection('behavior')}
|
onToggle={() => toggleSection('behavior')}
|
||||||
@@ -720,7 +717,7 @@ export function AgentConfigure({
|
|||||||
<SettingRow
|
<SettingRow
|
||||||
id="behavior-reasoning"
|
id="behavior-reasoning"
|
||||||
icon={Brain}
|
icon={Brain}
|
||||||
title="Reasoning"
|
title={t('agentConfigure.reasoning')}
|
||||||
description={reasoning?.summary || 'How much work an answer is worth.'}
|
description={reasoning?.summary || 'How much work an answer is worth.'}
|
||||||
control={(
|
control={(
|
||||||
<SegmentedToggle
|
<SegmentedToggle
|
||||||
@@ -736,9 +733,9 @@ export function AgentConfigure({
|
|||||||
<SettingRow
|
<SettingRow
|
||||||
id="behavior-starters"
|
id="behavior-starters"
|
||||||
icon={MessageSquare}
|
icon={MessageSquare}
|
||||||
title="Conversation starters"
|
title={t('agentConfigure.conversationStarters')}
|
||||||
value={starterCount ? `${starterCount} configured` : 'None'}
|
value={starterCount ? `${starterCount} configured` : 'None'}
|
||||||
description="Questions offered as chips when this agent opens."
|
description={t('agentConfigure.questionsOfferedAsChipsWhenThis')}
|
||||||
open={setting === 'behavior-starters'}
|
open={setting === 'behavior-starters'}
|
||||||
onToggle={() => toggleSetting('behavior-starters')}
|
onToggle={() => toggleSetting('behavior-starters')}
|
||||||
>
|
>
|
||||||
@@ -747,7 +744,7 @@ export function AgentConfigure({
|
|||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={starter.label}
|
value={starter.label}
|
||||||
placeholder="e.g. Which roles are at risk?"
|
placeholder={t('agentConfigure.eGWhichRolesAreAt')}
|
||||||
className="max-w-[30rem]"
|
className="max-w-[30rem]"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields.starters];
|
const next = [...fields.starters];
|
||||||
@@ -758,7 +755,7 @@ export function AgentConfigure({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Remove starter"
|
aria-label={t('agentConfigure.removeStarter')}
|
||||||
onClick={() => set({ starters: fields.starters.filter((_, x) => x !== i) })}
|
onClick={() => set({ starters: fields.starters.filter((_, x) => x !== i) })}
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
@@ -770,7 +767,7 @@ export function AgentConfigure({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => set({ starters: [...fields.starters, { label: '', prompt: '' }] })}
|
onClick={() => set({ starters: [...fields.starters, { label: '', prompt: '' }] })}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" /> Add starter
|
<Plus className="h-3.5 w-3.5" /> {t('agentConfigure.addStarter')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
@@ -778,22 +775,21 @@ export function AgentConfigure({
|
|||||||
<div id="behavior-web" className="scroll-mt-28">
|
<div id="behavior-web" className="scroll-mt-28">
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon={Globe}
|
icon={Globe}
|
||||||
title="Web search"
|
title={t('agentConfigure.webSearch')}
|
||||||
value={fields.webSearch ? 'On' : 'Off'}
|
value={fields.webSearch ? 'On' : 'Off'}
|
||||||
description="Whether this agent may look outside the workspace."
|
description={t('agentConfigure.whetherThisAgentMayLookOutside')}
|
||||||
control={(
|
control={(
|
||||||
<Switch
|
<Switch
|
||||||
checked={fields.webSearch}
|
checked={fields.webSearch}
|
||||||
onCheckedChange={(webSearch) => set({ webSearch })}
|
onCheckedChange={(webSearch) => set({ webSearch })}
|
||||||
aria-label="Allow web search"
|
aria-label={t('agentConfigure.allowWebSearch')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Collapse open={fields.webSearch}>
|
<Collapse open={fields.webSearch}>
|
||||||
<div className="pb-4">
|
<div className="pb-4">
|
||||||
<Alert tone="warning" title="No search provider is configured">
|
<Alert tone="warning" title={t('agentConfigure.noSearchProviderIsConfigured')}>
|
||||||
This deployment has no web search connected, so answers say plainly that
|
{t('agentConfigure.thisDeploymentHasNoWebSearch')}
|
||||||
nothing outside the workspace was consulted.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
@@ -802,9 +798,9 @@ export function AgentConfigure({
|
|||||||
<SettingRow
|
<SettingRow
|
||||||
id="behavior-subagents"
|
id="behavior-subagents"
|
||||||
icon={Layers}
|
icon={Layers}
|
||||||
title="Subagents"
|
title={t('agentConfigure.subagents')}
|
||||||
value={fields.subagents.length ? `${fields.subagents.length} configured` : 'None'}
|
value={fields.subagents.length ? `${fields.subagents.length} configured` : 'None'}
|
||||||
description="Other agents this one may hand a question to and report back from. Most need none."
|
description={t('agentConfigure.otherAgentsThisOneMayHand')}
|
||||||
open={setting === 'behavior-subagents'}
|
open={setting === 'behavior-subagents'}
|
||||||
onToggle={() => toggleSetting('behavior-subagents')}
|
onToggle={() => toggleSetting('behavior-subagents')}
|
||||||
last
|
last
|
||||||
@@ -837,7 +833,7 @@ export function AgentConfigure({
|
|||||||
&& set({ subagents: [...fields.subagents, id] })}
|
&& set({ subagents: [...fields.subagents, id] })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full sm:w-72">
|
<SelectTrigger className="w-full sm:w-72">
|
||||||
<SelectValue placeholder="Add a subagent..." />
|
<SelectValue placeholder={t('agentConfigure.addASubagent')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{availableSubagents
|
{availableSubagents
|
||||||
@@ -849,9 +845,7 @@ export function AgentConfigure({
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<p className="text-caption leading-relaxed text-ink-4">
|
<p className="text-caption leading-relaxed text-ink-4">
|
||||||
A subagent runs as you, with the same access you have and out of the same
|
{t('agentConfigure.aSubagentRunsAsYouWith')}
|
||||||
budget as the question that reached it — so delegating cannot read anything
|
|
||||||
you could not read yourself, and cannot buy more time by asking again.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
@@ -15,6 +15,7 @@ import { conversationStats, reviewRow } from '@/lib/agents/conversationInsights'
|
|||||||
import { allSkills } from '@/lib/skills/registry';
|
import { allSkills } from '@/lib/skills/registry';
|
||||||
import { usePreferences } from '@/lib/krowHooks';
|
import { usePreferences } from '@/lib/krowHooks';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What this agent has actually been asked.
|
* What this agent has actually been asked.
|
||||||
@@ -22,8 +23,7 @@ import { cn } from '@/lib/utils';
|
|||||||
* Real-time usage analytics computed directly from the local telemetry log.
|
* Real-time usage analytics computed directly from the local telemetry log.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @param {any} props */
|
function MetricCard({ icon: Icon, label, value, detail, highlight }: any) {
|
||||||
function MetricCard({ icon: Icon, label, value, detail, highlight }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-2">
|
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -45,8 +45,8 @@ function MetricCard({ icon: Icon, label, value, detail, highlight }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
export function AgentInsightsPanel({ agentId, agentName }: any) {
|
||||||
export function AgentInsightsPanel({ agentId, agentName }) {
|
const { t } = useTranslation();
|
||||||
const preferences = usePreferences();
|
const preferences = usePreferences();
|
||||||
|
|
||||||
const [records] = React.useState(() => readHistory());
|
const [records] = React.useState(() => readHistory());
|
||||||
@@ -74,7 +74,7 @@ export function AgentInsightsPanel({ agentId, agentName }) {
|
|||||||
<div className="w-full rounded-2xl border border-border bg-surface p-12 shadow-xs text-center">
|
<div className="w-full rounded-2xl border border-border bg-surface p-12 shadow-xs text-center">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={MessageSquare}
|
icon={MessageSquare}
|
||||||
title="No conversations with this agent yet"
|
title={t('agentInsightsPanel.noConversationsWithThisAgentYet')}
|
||||||
description={`Insights are counted from conversations held on this device. Ask ${agentName || 'this agent'} something in Owliver and the figures appear here.`}
|
description={`Insights are counted from conversations held on this device. Ask ${agentName || 'this agent'} something in Owliver and the figures appear here.`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,25 +87,25 @@ export function AgentInsightsPanel({ agentId, agentName }) {
|
|||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
icon={MessageSquare}
|
icon={MessageSquare}
|
||||||
label="Total Conversations"
|
label={t('agentInsightsPanel.totalConversations')}
|
||||||
value={stats.total}
|
value={stats.total}
|
||||||
detail={`Active across ${stats.activeDays} day${stats.activeDays === 1 ? '' : 's'}`}
|
detail={`Active across ${stats.activeDays} day${stats.activeDays === 1 ? '' : 's'}`}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
icon={HelpCircle}
|
icon={HelpCircle}
|
||||||
label="Questions Asked"
|
label={t('agentInsightsPanel.questionsAsked')}
|
||||||
value={stats.turns}
|
value={stats.turns}
|
||||||
detail={`${stats.averageTurns} per session average`}
|
detail={`${stats.averageTurns} per session average`}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
label="Surfaces Utilized"
|
label={t('agentInsightsPanel.surfacesUtilized')}
|
||||||
value={stats.pages}
|
value={stats.pages}
|
||||||
detail="Operational pages touched"
|
detail="Operational pages touched"
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
icon={ThumbsUp}
|
icon={ThumbsUp}
|
||||||
label="Feedback Rating"
|
label={t('agentInsightsPanel.feedbackRating')}
|
||||||
value={stats.feedback.score === null ? '—' : `${stats.feedback.score}%`}
|
value={stats.feedback.score === null ? '—' : `${stats.feedback.score}%`}
|
||||||
detail={
|
detail={
|
||||||
stats.feedback.score === null
|
stats.feedback.score === null
|
||||||
@@ -121,7 +121,7 @@ export function AgentInsightsPanel({ agentId, agentName }) {
|
|||||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BarChart3 className="h-4 w-4 text-krow-blue" />
|
<BarChart3 className="h-4 w-4 text-krow-blue" />
|
||||||
<h4 className="font-heading text-body font-semibold text-ink-1">Most Triggered Skills</h4>
|
<h4 className="font-heading text-body font-semibold text-ink-1">{t('agentInsightsPanel.mostTriggeredSkills')}</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stats.bySkill.length ? (
|
{stats.bySkill.length ? (
|
||||||
@@ -140,7 +140,7 @@ export function AgentInsightsPanel({ agentId, agentName }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3">
|
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3">
|
||||||
No skill executions logged yet. Conversations were handled directly by native page readers.
|
{t('agentInsightsPanel.noSkillExecutionsLoggedYetConversations')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,7 +149,7 @@ export function AgentInsightsPanel({ agentId, agentName }) {
|
|||||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Wrench className="h-4 w-4 text-krow-blue" />
|
<Wrench className="h-4 w-4 text-krow-blue" />
|
||||||
<h4 className="font-heading text-body font-semibold text-ink-1">Actions & Tool Usage</h4>
|
<h4 className="font-heading text-body font-semibold text-ink-1">{t('agentInsightsPanel.actionsToolUsage')}</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stats.byTool.length ? (
|
{stats.byTool.length ? (
|
||||||
@@ -168,7 +168,7 @@ export function AgentInsightsPanel({ agentId, agentName }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3">
|
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-4 text-body-sm text-ink-3">
|
||||||
No action tool executions recorded in current audit window.
|
{t('agentInsightsPanel.noActionToolExecutionsRecordedIn')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +180,7 @@ export function AgentInsightsPanel({ agentId, agentName }) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4 text-krow-blue" />
|
<Clock className="h-4 w-4 text-krow-blue" />
|
||||||
<h4 className="font-heading text-body font-semibold text-ink-1">
|
<h4 className="font-heading text-body font-semibold text-ink-1">
|
||||||
Recent Conversation Logs
|
{t('agentInsightsPanel.recentConversationLogs')}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,7 +196,7 @@ export function AgentInsightsPanel({ agentId, agentName }) {
|
|||||||
{row.title || 'General consultation'}
|
{row.title || 'General consultation'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-caption text-ink-4">
|
<p className="text-caption text-ink-4">
|
||||||
Surface:{' '}
|
{t('agentInsightsPanel.surface')}{' '}
|
||||||
<span className="font-medium text-ink-3">{row.page || 'Direct'}</span>
|
<span className="font-medium text-ink-3">{row.page || 'Direct'}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,11 +217,11 @@ export function AgentInsightsPanel({ agentId, agentName }) {
|
|||||||
>
|
>
|
||||||
{row.feedback.rating === 'up' ? (
|
{row.feedback.rating === 'up' ? (
|
||||||
<>
|
<>
|
||||||
<ThumbsUp className="h-2.5 w-2.5" /> Helpful
|
<ThumbsUp className="h-2.5 w-2.5" /> {t('agentInsightsPanel.helpful')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ThumbsDown className="h-2.5 w-2.5" /> Unhelpful
|
<ThumbsDown className="h-2.5 w-2.5" /> {t('agentInsightsPanel.unhelpful')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -26,6 +26,7 @@ import { agentCovers, agentScopedDisabled, classifyQuestion } from '@/lib/agents
|
|||||||
import { toolsForContext } from '@/lib/skills/tools';
|
import { toolsForContext } from '@/lib/skills/tools';
|
||||||
import { retrieveKnowledge } from '@/lib/agents/knowledge';
|
import { retrieveKnowledge } from '@/lib/agents/knowledge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What this agent would do, on a page, before it is published.
|
* What this agent would do, on a page, before it is published.
|
||||||
@@ -46,8 +47,8 @@ const CLASSIFICATION_COPY = {
|
|||||||
combined: 'Requires both structured records and knowledge retrieval.',
|
combined: 'Requires both structured records and knowledge retrieval.',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @param {any} props */
|
export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }: any) {
|
||||||
export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }) {
|
const { t } = useTranslation();
|
||||||
const [contextId, setContextId] = React.useState(
|
const [contextId, setContextId] = React.useState(
|
||||||
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|
||||||
|| CONTEXT_OPTIONS[0].contextId
|
|| CONTEXT_OPTIONS[0].contextId
|
||||||
@@ -103,10 +104,10 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive })
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-heading text-body font-semibold text-ink-1">
|
<h3 className="font-heading text-body font-semibold text-ink-1">
|
||||||
Simulation & Scope Diagnostics
|
{t('agentTestPanel.simulationScopeDiagnostics')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-caption text-ink-4">
|
<p className="text-caption text-ink-4">
|
||||||
Evaluate real-time intent routing and skill activation without publishing changes.
|
{t('agentTestPanel.evaluateRealTimeIntentRoutingAnd')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +115,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive })
|
|||||||
{dirty && (
|
{dirty && (
|
||||||
<Badge variant="warning" size="sm" className="gap-1.5">
|
<Badge variant="warning" size="sm" className="gap-1.5">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-warning animate-pulse" />
|
<span className="h-1.5 w-1.5 rounded-full bg-warning animate-pulse" />
|
||||||
Testing unsaved draft
|
{t('agentTestPanel.testingUnsavedDraft')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -124,7 +125,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive })
|
|||||||
<div className="grid gap-4 md:grid-cols-12 items-end">
|
<div className="grid gap-4 md:grid-cols-12 items-end">
|
||||||
<div className="md:col-span-4 space-y-1.5">
|
<div className="md:col-span-4 space-y-1.5">
|
||||||
<label className="block text-body-sm font-medium text-ink-2">
|
<label className="block text-body-sm font-medium text-ink-2">
|
||||||
Contextual Page
|
{t('agentTestPanel.contextualPage')}
|
||||||
</label>
|
</label>
|
||||||
<Select value={contextId} onValueChange={setContextId}>
|
<Select value={contextId} onValueChange={setContextId}>
|
||||||
<SelectTrigger className="w-full bg-surface">
|
<SelectTrigger className="w-full bg-surface">
|
||||||
@@ -142,14 +143,14 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive })
|
|||||||
|
|
||||||
<div className="md:col-span-8 space-y-1.5">
|
<div className="md:col-span-8 space-y-1.5">
|
||||||
<label className="block text-body-sm font-medium text-ink-2">
|
<label className="block text-body-sm font-medium text-ink-2">
|
||||||
Simulated User Query
|
{t('agentTestPanel.simulatedUserQuery')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-4" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-4" />
|
||||||
<Input
|
<Input
|
||||||
value={question}
|
value={question}
|
||||||
onChange={(e) => setQuestion(e.target.value)}
|
onChange={(e) => setQuestion(e.target.value)}
|
||||||
placeholder="e.g. Which roles are at risk?"
|
placeholder={t('agentTestPanel.eGWhichRolesAreAt')}
|
||||||
className="pl-9 w-full bg-surface"
|
className="pl-9 w-full bg-surface"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,7 +178,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive })
|
|||||||
hover:bg-surface-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
hover:bg-surface-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Play className="h-3 w-3 shrink-0" aria-hidden="true" />
|
<Play className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||||
Ask Owliver for real
|
{t('agentTestPanel.askOwliverForReal')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -186,7 +187,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive })
|
|||||||
{fields.starters.filter((s) => s.label).length > 0 && (
|
{fields.starters.filter((s) => s.label).length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-border/50">
|
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-border/50">
|
||||||
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">
|
<span className="text-caption font-medium uppercase tracking-wider text-ink-4 mr-1">
|
||||||
Suggested Starters:
|
{t('agentTestPanel.suggestedStarters')}
|
||||||
</span>
|
</span>
|
||||||
{fields.starters.filter((s) => s.label).map((s, i) => (
|
{fields.starters.filter((s) => s.label).map((s, i) => (
|
||||||
<button
|
<button
|
||||||
@@ -249,7 +250,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive })
|
|||||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||||
<div className="flex items-center gap-2 text-ink-1">
|
<div className="flex items-center gap-2 text-ink-1">
|
||||||
<Cpu className="h-4 w-4 text-krow-blue shrink-0" />
|
<Cpu className="h-4 w-4 text-krow-blue shrink-0" />
|
||||||
<h4 className="font-heading text-body font-semibold">Intent & Skill Routing</h4>
|
<h4 className="font-heading text-body font-semibold">{t('agentTestPanel.intentSkillRouting')}</h4>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-caption text-ink-3">
|
<p className="text-caption text-ink-3">
|
||||||
{CLASSIFICATION_COPY[result.classification] || CLASSIFICATION_COPY.structured}
|
{CLASSIFICATION_COPY[result.classification] || CLASSIFICATION_COPY.structured}
|
||||||
@@ -261,7 +262,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive })
|
|||||||
<span className="text-body-sm font-semibold text-krow-blue">
|
<span className="text-body-sm font-semibold text-krow-blue">
|
||||||
{result.matched.name}
|
{result.matched.name}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="info" size="sm">Matched</Badge>
|
<Badge variant="info" size="sm">{t('agentTestPanel.matched')}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-caption text-ink-3 leading-relaxed">
|
<p className="text-caption text-ink-3 leading-relaxed">
|
||||||
{result.matched.description}
|
{result.matched.description}
|
||||||
@@ -269,7 +270,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive })
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
||||||
No specialized skill claimed this exact wording. The native page reader will process the query.
|
{t('agentTestPanel.noSpecializedSkillClaimedThisExact')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -279,7 +280,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive })
|
|||||||
<div className="flex items-center justify-between gap-2 text-ink-1">
|
<div className="flex items-center justify-between gap-2 text-ink-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Layers className="h-4 w-4 text-krow-blue shrink-0" />
|
<Layers className="h-4 w-4 text-krow-blue shrink-0" />
|
||||||
<h4 className="font-heading text-body font-semibold">Reachable Page Skills</h4>
|
<h4 className="font-heading text-body font-semibold">{t('agentTestPanel.reachablePageSkills')}</h4>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
|
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
|
||||||
{result.scoped.length}
|
{result.scoped.length}
|
||||||
@@ -294,13 +295,13 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive })
|
|||||||
className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
|
className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle/30 px-3 py-2 text-body-sm"
|
||||||
>
|
>
|
||||||
<span className="truncate font-medium text-ink-2">{s.name}</span>
|
<span className="truncate font-medium text-ink-2">{s.name}</span>
|
||||||
<span className="text-caption text-ink-4 tabular-nums">Ready</span>
|
<span className="text-caption text-ink-4 tabular-nums">{t('agentTestPanel.ready')}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
||||||
No attached skills apply to {activePageLabel}.
|
{t('agentTestPanel.noAttachedSkillsApplyTo')} {activePageLabel}.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -310,7 +311,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive })
|
|||||||
<div className="flex items-center justify-between gap-2 text-ink-1">
|
<div className="flex items-center justify-between gap-2 text-ink-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Wrench className="h-4 w-4 text-krow-blue shrink-0" />
|
<Wrench className="h-4 w-4 text-krow-blue shrink-0" />
|
||||||
<h4 className="font-heading text-body font-semibold">Execution Actions & Tools</h4>
|
<h4 className="font-heading text-body font-semibold">{t('agentTestPanel.executionActionsTools')}</h4>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
|
<span className="rounded-full bg-surface-sunken px-2 py-0.5 text-caption font-semibold tabular-nums text-ink-3">
|
||||||
{result.tools.length}
|
{result.tools.length}
|
||||||
@@ -326,16 +327,16 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive })
|
|||||||
>
|
>
|
||||||
<span className="truncate font-medium text-ink-2">{tool.label}</span>
|
<span className="truncate font-medium text-ink-2">{tool.label}</span>
|
||||||
{tool.requiresApproval ? (
|
{tool.requiresApproval ? (
|
||||||
<Badge variant="warning" size="sm">Confirmation required</Badge>
|
<Badge variant="warning" size="sm">{t('agentTestPanel.confirmationRequired')}</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="neutral" size="sm">Read-only</Badge>
|
<Badge variant="neutral" size="sm">{t('agentTestPanel.readOnly')}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
<div className="rounded-xl border border-border/80 bg-surface-subtle/50 p-3 text-body-sm text-ink-3">
|
||||||
Read-only operations. No active mutations or system write actions.
|
{t('agentTestPanel.readOnlyOperationsNoActiveMutations')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -344,7 +345,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive })
|
|||||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
<div className="rounded-2xl border border-border bg-surface p-5 shadow-xs space-y-3">
|
||||||
<div className="flex items-center gap-2 text-ink-1">
|
<div className="flex items-center gap-2 text-ink-1">
|
||||||
<BookOpen className="h-4 w-4 text-krow-blue shrink-0" />
|
<BookOpen className="h-4 w-4 text-krow-blue shrink-0" />
|
||||||
<h4 className="font-heading text-body font-semibold">Referenced Knowledge</h4>
|
<h4 className="font-heading text-body font-semibold">{t('agentTestPanel.referencedKnowledge')}</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{result.knowledge.passages.length ? (
|
{result.knowledge.passages.length ? (
|
||||||
@@ -12,6 +12,7 @@ import { AgentTree } from './AgentTree';
|
|||||||
import { BoardSkillList } from './BoardSkillList';
|
import { BoardSkillList } from './BoardSkillList';
|
||||||
import { SkillCatalog } from './SkillCatalog';
|
import { SkillCatalog } from './SkillCatalog';
|
||||||
import { SkillDetails } from './SkillDetails';
|
import { SkillDetails } from './SkillDetails';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composing an agent: what it is made of, beside what it could be made of.
|
* Composing an agent: what it is made of, beside what it could be made of.
|
||||||
@@ -43,7 +44,6 @@ import { SkillDetails } from './SkillDetails';
|
|||||||
const OWLIVER = 'owliver';
|
const OWLIVER = 'owliver';
|
||||||
const BOARD = 'board';
|
const BOARD = 'board';
|
||||||
|
|
||||||
/** @param {any} props */
|
|
||||||
export function AgentSkillWorkspace({
|
export function AgentSkillWorkspace({
|
||||||
fields,
|
fields,
|
||||||
customSkills = [],
|
customSkills = [],
|
||||||
@@ -55,7 +55,8 @@ export function AgentSkillWorkspace({
|
|||||||
busy = false,
|
busy = false,
|
||||||
pendingId = null,
|
pendingId = null,
|
||||||
dirty = false,
|
dirty = false,
|
||||||
}) {
|
}: any) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [kind, setKind] = React.useState(OWLIVER);
|
const [kind, setKind] = React.useState(OWLIVER);
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
const [boardQuery, setBoardQuery] = React.useState('');
|
const [boardQuery, setBoardQuery] = React.useState('');
|
||||||
@@ -296,16 +297,14 @@ export function AgentSkillWorkspace({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full min-w-0 space-y-3">
|
<div className="w-full min-w-0 space-y-3">
|
||||||
{dirty && (
|
{dirty && (
|
||||||
<Alert tone="info" title="This agent has unsaved edits">
|
<Alert tone="info" title={t('agentSkillWorkspace.thisAgentHasUnsavedEdits')}>
|
||||||
Skill changes are being kept in the draft with them. Save the agent to store everything
|
{t('agentSkillWorkspace.skillChangesAreBeingKeptIn')}
|
||||||
together.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{unknown.length > 0 && (
|
{unknown.length > 0 && (
|
||||||
<Alert tone="warning" title={`${unknown.length} attached skill${unknown.length === 1 ? '' : 's'} not in the registry`}>
|
<Alert tone="warning" title={`${unknown.length} attached skill${unknown.length === 1 ? '' : 's'} not in the registry`}>
|
||||||
{unknown.join(', ')} — no definition with that id is registered, so it answers nothing.
|
{unknown.join(', ')} {t('agentSkillWorkspace.noDefinitionWithThatIdIs')}
|
||||||
Remove it from the tree, or restore the definition.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -340,7 +339,7 @@ export function AgentSkillWorkspace({
|
|||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<ListTree className="h-3.5 w-3.5" />
|
<ListTree className="h-3.5 w-3.5" />
|
||||||
Agent tree
|
{t('agentSkillWorkspace.agentTree')}
|
||||||
<span className="tabular-nums text-ink-4">
|
<span className="tabular-nums text-ink-4">
|
||||||
· {fields.skills.length} skill{fields.skills.length === 1 ? '' : 's'}
|
· {fields.skills.length} skill{fields.skills.length === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
@@ -408,12 +407,12 @@ export function AgentSkillWorkspace({
|
|||||||
onOpenChange={setTreeOpen}
|
onOpenChange={setTreeOpen}
|
||||||
side="left"
|
side="left"
|
||||||
size="sm"
|
size="sm"
|
||||||
title="Agent"
|
title={t('agentSkillWorkspace.agent')}
|
||||||
description="What this agent is made of."
|
description={t('agentSkillWorkspace.whatThisAgentIsMadeOf')}
|
||||||
icon={ListTree}
|
icon={ListTree}
|
||||||
footer={(
|
footer={(
|
||||||
<Button variant="outline" onClick={() => setTreeOpen(false)} className="w-full">
|
<Button variant="outline" onClick={() => setTreeOpen(false)} className="w-full">
|
||||||
<X className="h-3.5 w-3.5" /> Close
|
<X className="h-3.5 w-3.5" /> {t('agentSkillWorkspace.close')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { agentIconFor } from '@/components/agents/icons';
|
import { agentIconFor } from '@/components/agents/icons';
|
||||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||||
import { glyphForGroup } from './glyphs';
|
import { glyphForGroup } from './glyphs';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What this agent is made of.
|
* What this agent is made of.
|
||||||
@@ -109,6 +110,7 @@ function Branch({ id, icon: Icon, label, count, open, onToggle, children, hint,
|
|||||||
* skill while trying to look at it.
|
* skill while trying to look at it.
|
||||||
*/
|
*/
|
||||||
function SkillLeaf({ entry, id, selected, onSelect, onRemove, busy }) {
|
function SkillLeaf({ entry, id, selected, onSelect, onRemove, busy }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const Glyph = entry ? glyphForGroup(entry.group) : AlertTriangle;
|
const Glyph = entry ? glyphForGroup(entry.group) : AlertTriangle;
|
||||||
const name = entry?.name || id;
|
const name = entry?.name || id;
|
||||||
|
|
||||||
@@ -140,7 +142,7 @@ function SkillLeaf({ entry, id, selected, onSelect, onRemove, busy }) {
|
|||||||
cannot separate the tint from the surface can read either. */}
|
cannot separate the tint from the surface can read either. */}
|
||||||
<span className="sr-only">{selected ? ' — selected' : ''}</span>
|
<span className="sr-only">{selected ? ' — selected' : ''}</span>
|
||||||
{!entry && (
|
{!entry && (
|
||||||
<span className="shrink-0 text-[10px] font-medium text-warning">Unknown</span>
|
<span className="shrink-0 text-[10px] font-medium text-warning">{t('agentTree.unknown')}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -174,7 +176,6 @@ function StaticLeaf({ icon: Icon, label, detail = null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
|
||||||
export function AgentTree({
|
export function AgentTree({
|
||||||
agentName,
|
agentName,
|
||||||
agentIcon,
|
agentIcon,
|
||||||
@@ -191,11 +192,12 @@ export function AgentTree({
|
|||||||
onToggleBranch,
|
onToggleBranch,
|
||||||
busy = false,
|
busy = false,
|
||||||
className,
|
className,
|
||||||
}) {
|
}: any) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const Glyph = agentIconFor(agentIcon);
|
const Glyph = agentIconFor(agentIcon);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Agent composition" className={cn('min-w-0', className)}>
|
<nav aria-label={t('agentTree.agentComposition')} className={cn('min-w-0', className)}>
|
||||||
{/* The root. Not a disclosure: an agent that could be collapsed to
|
{/* The root. Not a disclosure: an agent that could be collapsed to
|
||||||
nothing would leave the panel showing a single closed row. */}
|
nothing would leave the panel showing a single closed row. */}
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-surface-subtle/60 px-2.5 py-2">
|
<div className="flex items-center gap-2 rounded-xl border border-border bg-surface-subtle/60 px-2.5 py-2">
|
||||||
@@ -211,7 +213,7 @@ export function AgentTree({
|
|||||||
<span className="block truncate font-heading text-body-sm font-semibold text-ink-1">
|
<span className="block truncate font-heading text-body-sm font-semibold text-ink-1">
|
||||||
{agentName || 'New agent'}
|
{agentName || 'New agent'}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[10px] uppercase tracking-wide text-ink-4">Agent</span>
|
<span className="block text-[10px] uppercase tracking-wide text-ink-4">{t('agentTree.agent')}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -221,11 +223,11 @@ export function AgentTree({
|
|||||||
<Branch
|
<Branch
|
||||||
id="surfaces"
|
id="surfaces"
|
||||||
icon={LayoutGrid}
|
icon={LayoutGrid}
|
||||||
label="Surfaces"
|
label={t('agentTree.surfaces')}
|
||||||
count={surfaces.length}
|
count={surfaces.length}
|
||||||
open={open.surfaces}
|
open={open.surfaces}
|
||||||
onToggle={() => onToggleBranch('surfaces')}
|
onToggle={() => onToggleBranch('surfaces')}
|
||||||
hint="No page selected — this agent has nowhere to answer yet."
|
hint={t('agentTree.noPageSelectedThisAgentHas')}
|
||||||
>
|
>
|
||||||
{surfaces.map((surface) => (
|
{surfaces.map((surface) => (
|
||||||
<StaticLeaf key={surface.id} icon={LayoutGrid} label={surface.label} />
|
<StaticLeaf key={surface.id} icon={LayoutGrid} label={surface.label} />
|
||||||
@@ -236,20 +238,20 @@ export function AgentTree({
|
|||||||
<Branch
|
<Branch
|
||||||
id="skills"
|
id="skills"
|
||||||
icon={Zap}
|
icon={Zap}
|
||||||
label="Skills"
|
label={t('agentTree.skills')}
|
||||||
count={skills.length + boardSkills.length}
|
count={skills.length + boardSkills.length}
|
||||||
open={open.skills}
|
open={open.skills}
|
||||||
onToggle={() => onToggleBranch('skills')}
|
onToggle={() => onToggleBranch('skills')}
|
||||||
hint="Nothing yet."
|
hint={t('agentTree.nothingYet')}
|
||||||
>
|
>
|
||||||
<Branch
|
<Branch
|
||||||
id="skills-owliver"
|
id="skills-owliver"
|
||||||
icon={Zap}
|
icon={Zap}
|
||||||
label="Owliver Skills"
|
label={t('agentTree.owliverSkills')}
|
||||||
count={skills.length}
|
count={skills.length}
|
||||||
open={open['skills-owliver']}
|
open={open['skills-owliver']}
|
||||||
onToggle={() => onToggleBranch('skills-owliver')}
|
onToggle={() => onToggleBranch('skills-owliver')}
|
||||||
hint="Nothing attached yet. Add one from the catalog."
|
hint={t('agentTree.nothingAttachedYetAddOneFrom')}
|
||||||
sub
|
sub
|
||||||
>
|
>
|
||||||
{skills.map((id) => (
|
{skills.map((id) => (
|
||||||
@@ -271,11 +273,11 @@ export function AgentTree({
|
|||||||
<Branch
|
<Branch
|
||||||
id="skills-board"
|
id="skills-board"
|
||||||
icon={LayoutTemplate}
|
icon={LayoutTemplate}
|
||||||
label="Board Skills"
|
label={t('agentTree.boardSkills')}
|
||||||
count={boardSkills.length}
|
count={boardSkills.length}
|
||||||
open={open['skills-board']}
|
open={open['skills-board']}
|
||||||
onToggle={() => onToggleBranch('skills-board')}
|
onToggle={() => onToggleBranch('skills-board')}
|
||||||
hint="None drawing on this agent's surfaces."
|
hint={t('agentTree.noneDrawingOnThisAgentsSurfaces')}
|
||||||
sub
|
sub
|
||||||
>
|
>
|
||||||
{boardSkills.map((entry) => (
|
{boardSkills.map((entry) => (
|
||||||
@@ -292,11 +294,11 @@ export function AgentTree({
|
|||||||
<Branch
|
<Branch
|
||||||
id="tools"
|
id="tools"
|
||||||
icon={Wrench}
|
icon={Wrench}
|
||||||
label="Tools"
|
label={t('agentTree.tools')}
|
||||||
count={tools.length}
|
count={tools.length}
|
||||||
open={open.tools}
|
open={open.tools}
|
||||||
onToggle={() => onToggleBranch('tools')}
|
onToggle={() => onToggleBranch('tools')}
|
||||||
hint="Tools arrive with skills. Attach a skill that declares one."
|
hint={t('agentTree.toolsArriveWithSkillsAttachA')}
|
||||||
>
|
>
|
||||||
{tools.map((tool) => (
|
{tools.map((tool) => (
|
||||||
<StaticLeaf key={tool.name} icon={Wrench} label={tool.label} detail={tool.via} />
|
<StaticLeaf key={tool.name} icon={Wrench} label={tool.label} detail={tool.via} />
|
||||||
@@ -306,11 +308,11 @@ export function AgentTree({
|
|||||||
<Branch
|
<Branch
|
||||||
id="knowledge"
|
id="knowledge"
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
label="Knowledge"
|
label={t('agentTree.knowledge')}
|
||||||
count={knowledge.length}
|
count={knowledge.length}
|
||||||
open={open.knowledge}
|
open={open.knowledge}
|
||||||
onToggle={() => onToggleBranch('knowledge')}
|
onToggle={() => onToggleBranch('knowledge')}
|
||||||
hint="Nothing attached. Add knowledge under Configure."
|
hint={t('agentTree.nothingAttachedAddKnowledgeUnderConfigure')}
|
||||||
>
|
>
|
||||||
{knowledge.map((entry, i) => (
|
{knowledge.map((entry, i) => (
|
||||||
<StaticLeaf
|
<StaticLeaf
|
||||||
@@ -3,6 +3,7 @@ import { LayoutTemplate, Minus, Pencil, Plus, SearchX } from 'lucide-react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Alert, Button, SearchInput, Switch } from '@/components/ds';
|
import { Alert, Button, SearchInput, Switch } from '@/components/ds';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Board skills, under the agent that stands beside them.
|
* Board skills, under the agent that stands beside them.
|
||||||
@@ -33,6 +34,7 @@ import { Alert, Button, SearchInput, Switch } from '@/components/ds';
|
|||||||
|
|
||||||
/** One Board skill: what it draws, where, and whether it is switched on. */
|
/** One Board skill: what it draws, where, and whether it is switched on. */
|
||||||
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = false, onAttach = null }) {
|
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = false, onAttach = null }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const switchId = `board-${entry.id}`;
|
const switchId = `board-${entry.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,7 +68,7 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = fals
|
|||||||
<LayoutTemplate className="h-3.5 w-3.5" />
|
<LayoutTemplate className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-3">
|
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-3">
|
||||||
Board Skill
|
{t('boardSkillList.boardSkill')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,8 +137,8 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = fals
|
|||||||
aria-label={`${attached ? 'Remove' : 'Add'} ${entry.name} ${attached ? 'from' : 'to'} this agent`}
|
aria-label={`${attached ? 'Remove' : 'Add'} ${entry.name} ${attached ? 'from' : 'to'} this agent`}
|
||||||
>
|
>
|
||||||
{attached
|
{attached
|
||||||
? <><Minus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Remove</>
|
? <><Minus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />{t('boardSkillList.remove')}</>
|
||||||
: <><Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Add to agent</>}
|
: <><Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />{t('boardSkillList.addToAgent')}</>}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -151,7 +153,7 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = fals
|
|||||||
to={`/admin/workspace/skills/${entry.id}`}
|
to={`/admin/workspace/skills/${entry.id}`}
|
||||||
className="inline-flex items-center gap-1 text-caption font-semibold text-krow-blue hover:text-krow-blue-dark transition-colors"
|
className="inline-flex items-center gap-1 text-caption font-semibold text-krow-blue hover:text-krow-blue-dark transition-colors"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3" aria-hidden="true" /> Edit
|
<Pencil className="h-3 w-3" aria-hidden="true" /> {t('boardSkillList.edit')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -159,11 +161,11 @@ function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = fals
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
|
||||||
export function BoardSkillList({
|
export function BoardSkillList({
|
||||||
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
|
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
|
||||||
attachedIds = null, onAttach = null,
|
attachedIds = null, onAttach = null,
|
||||||
}) {
|
}: any) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const searchId = React.useId();
|
const searchId = React.useId();
|
||||||
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
|
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
|
||||||
|
|
||||||
@@ -178,22 +180,19 @@ export function BoardSkillList({
|
|||||||
}, [entries, onSurfaces]);
|
}, [entries, onSurfaces]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Board skills" className="flex min-w-0 flex-col gap-4">
|
<section aria-label={t('boardSkillList.boardSkills')} className="flex min-w-0 flex-col gap-4">
|
||||||
<Alert tone="info" title="Two switches, two different questions">
|
<Alert tone="info" title={t('boardSkillList.twoSwitchesTwoDifferentQuestions')}>
|
||||||
<strong>Active</strong> is workspace-wide: off means off on every page, for every
|
<strong>{t('boardSkillList.active')}</strong> {t('boardSkillList.isWorkspaceWideOffMeansOff')} <strong>{t('boardSkillList.addToAgent')}</strong> {t('boardSkillList.isOwnershipOnceAnyAgentOwns')}
|
||||||
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>
|
</Alert>
|
||||||
|
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={searchId} className="sr-only">Search board skills</label>
|
<label htmlFor={searchId} className="sr-only">{t('boardSkillList.searchBoardSkills')}</label>
|
||||||
<SearchInput
|
<SearchInput
|
||||||
id={searchId}
|
id={searchId}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={onQueryChange}
|
onChange={onQueryChange}
|
||||||
placeholder="Search board skills, sections or pages..."
|
placeholder={t('boardSkillList.searchBoardSkillsSectionsOrPages')}
|
||||||
debounce={0}
|
debounce={0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +201,7 @@ export function BoardSkillList({
|
|||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 pt-1">
|
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 pt-1">
|
||||||
<p className="text-xs font-medium text-ink-3" aria-live="polite">
|
<p className="text-xs font-medium text-ink-3" aria-live="polite">
|
||||||
<span className="font-semibold text-ink-1">{ordered.length}</span> of {total} board skill{total === 1 ? '' : 's'}
|
<span className="font-semibold text-ink-1">{ordered.length}</span> of {total} {t('boardSkillList.boardSkill2')}{total === 1 ? '' : 's'}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/admin/workspace/skills/new"
|
to="/admin/workspace/skills/new"
|
||||||
@@ -211,7 +210,7 @@ export function BoardSkillList({
|
|||||||
hover:bg-krow-blue-tint/60 hover:border-krow-blue/40 focus-visible:outline-none
|
hover:bg-krow-blue-tint/60 hover:border-krow-blue/40 focus-visible:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> New board skill
|
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> {t('boardSkillList.newBoardSkill')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -236,20 +235,19 @@ export function BoardSkillList({
|
|||||||
{total > 0 ? (
|
{total > 0 ? (
|
||||||
<>
|
<>
|
||||||
<SearchX className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
|
<SearchX className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
|
||||||
<p className="mt-2.5 text-sm font-semibold text-ink-1">No board skills match that</p>
|
<p className="mt-2.5 text-sm font-semibold text-ink-1">{t('boardSkillList.noBoardSkillsMatchThat')}</p>
|
||||||
<p className="mt-1 text-xs text-ink-3">Try a different word.</p>
|
<p className="mt-1 text-xs text-ink-3">{t('boardSkillList.tryADifferentWord')}</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<LayoutTemplate className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
|
<LayoutTemplate className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
|
||||||
<p className="mt-2.5 text-sm font-semibold text-ink-1">No board skills yet</p>
|
<p className="mt-2.5 text-sm font-semibold text-ink-1">{t('boardSkillList.noBoardSkillsYet')}</p>
|
||||||
<p className="mx-auto mt-1 max-w-[46ch] text-xs leading-relaxed text-ink-3">
|
<p className="mx-auto mt-1 max-w-[46ch] text-xs leading-relaxed text-ink-3">
|
||||||
A board skill declares a section, where it sits on a page, and what it reads.
|
{t('boardSkillList.aBoardSkillDeclaresASection')}
|
||||||
Add one and the page it names renders it.
|
|
||||||
</p>
|
</p>
|
||||||
<Button asChild variant="outline" size="sm" className="mt-3.5">
|
<Button asChild variant="outline" size="sm" className="mt-3.5">
|
||||||
<Link to="/admin/workspace/skills/new">
|
<Link to="/admin/workspace/skills/new">
|
||||||
<Plus className="h-3.5 w-3.5" /> Add a board skill
|
<Plus className="h-3.5 w-3.5" /> {t('boardSkillList.addABoardSkill')}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -25,9 +25,9 @@ import { glyphForGroup } from './glyphs';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {any} */
|
/** @type {any} */
|
||||||
export const SkillCard = React.memo(/** @param {any} props */ ({
|
export const SkillCard = React.memo(({
|
||||||
entry, enabled, onOpen, onAddAgent, onTest, canTest = false, busy = false, isPending = false,
|
entry, enabled, onOpen, onAddAgent, onTest, canTest = false, busy = false, isPending = false,
|
||||||
}) => {
|
}: any) => {
|
||||||
const Glyph = glyphForGroup(entry.group);
|
const Glyph = glyphForGroup(entry.group);
|
||||||
const samplePrompt = entry.suggestions?.[0]?.label;
|
const samplePrompt = entry.suggestions?.[0]?.label;
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SearchInput } from '@/components/ds';
|
import { SearchInput } from '@/components/ds';
|
||||||
import { SkillCard } from './SkillCard';
|
import { SkillCard } from './SkillCard';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The catalog — every skill this workspace has, browsable.
|
* The catalog — every skill this workspace has, browsable.
|
||||||
@@ -23,12 +24,12 @@ import { SkillCard } from './SkillCard';
|
|||||||
* has, at every width, with no media query to get wrong.
|
* has, at every width, with no media query to get wrong.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @param {any} props */
|
function CategoryFilter({ groups, value, onChange, total }: any) {
|
||||||
function CategoryFilter({ groups, value, onChange, total }) {
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Filter skills by category"
|
aria-label={t('skillCatalog.filterSkillsByCategory')}
|
||||||
className="flex flex-nowrap items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-x-visible"
|
className="flex flex-nowrap items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-x-visible"
|
||||||
>
|
>
|
||||||
{[{ id: 'all', label: 'All', count: total }, ...groups].map((group) => {
|
{[{ id: 'all', label: 'All', count: total }, ...groups].map((group) => {
|
||||||
@@ -60,7 +61,6 @@ function CategoryFilter({ groups, value, onChange, total }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
|
||||||
export function SkillCatalog({
|
export function SkillCatalog({
|
||||||
entries,
|
entries,
|
||||||
groups,
|
groups,
|
||||||
@@ -76,19 +76,20 @@ export function SkillCatalog({
|
|||||||
testableIds,
|
testableIds,
|
||||||
busy = false,
|
busy = false,
|
||||||
pendingId = null,
|
pendingId = null,
|
||||||
}) {
|
}: any) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const searchId = React.useId();
|
const searchId = React.useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Skill catalog" className="flex min-w-0 flex-col gap-4">
|
<section aria-label={t('skillCatalog.skillCatalog')} className="flex min-w-0 flex-col gap-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={searchId} className="sr-only">Search skills</label>
|
<label htmlFor={searchId} className="sr-only">{t('skillCatalog.searchSkills')}</label>
|
||||||
<SearchInput
|
<SearchInput
|
||||||
id={searchId}
|
id={searchId}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={onQueryChange}
|
onChange={onQueryChange}
|
||||||
placeholder="Search skills, capabilities or pages..."
|
placeholder={t('skillCatalog.searchSkillsCapabilitiesOrPages')}
|
||||||
debounce={0}
|
debounce={0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +101,7 @@ export function SkillCatalog({
|
|||||||
{/* The count is read from the same array the cards come from, so a
|
{/* The count is read from the same array the cards come from, so a
|
||||||
heading cannot claim six above four cards. */}
|
heading cannot claim six above four cards. */}
|
||||||
<p className="text-caption font-medium text-ink-3" aria-live="polite">
|
<p className="text-caption font-medium text-ink-3" aria-live="polite">
|
||||||
<span className="font-bold text-ink-1">{entries.length}</span> of {total} capabilit{total === 1 ? 'y' : 'ies'} for this scope
|
<span className="font-bold text-ink-1">{entries.length}</span> of {total} capabilit{total === 1 ? 'y' : 'ies'} {t('skillCatalog.forThisScope')}
|
||||||
{attachedIds.size > 0 && <span className="font-semibold text-krow-blue"> · {attachedIds.size} enabled</span>}
|
{attachedIds.size > 0 && <span className="font-semibold text-krow-blue"> · {attachedIds.size} enabled</span>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -112,7 +113,7 @@ export function SkillCatalog({
|
|||||||
hover:border-krow-blue/40 hover:bg-krow-blue-tint focus-visible:outline-none
|
hover:border-krow-blue/40 hover:bg-krow-blue-tint focus-visible:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> New skill
|
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> {t('skillCatalog.newSkill')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,9 +136,9 @@ export function SkillCatalog({
|
|||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-dashed border-border bg-surface-subtle/50 px-4 py-12 text-center">
|
<div className="rounded-2xl border border-dashed border-border bg-surface-subtle/50 px-4 py-12 text-center">
|
||||||
<SearchX className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
|
<SearchX className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
|
||||||
<p className="mt-2.5 text-body-sm font-semibold text-ink-1">No skills match that</p>
|
<p className="mt-2.5 text-body-sm font-semibold text-ink-1">{t('skillCatalog.noSkillsMatchThat')}</p>
|
||||||
<p className="mt-1 text-caption text-ink-3">
|
<p className="mt-1 text-caption text-ink-3">
|
||||||
Try a different word, or widen the category filter.
|
{t('skillCatalog.tryADifferentWordOrWiden')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { testTargets } from '@/lib/agents/capabilityTest';
|
import { testTargets } from '@/lib/agents/capabilityTest';
|
||||||
import { SurfaceSelect, supportedSurfaces } from './SurfaceSelect';
|
import { SurfaceSelect, supportedSurfaces } from './SurfaceSelect';
|
||||||
import { glyphForGroup } from './glyphs';
|
import { glyphForGroup } from './glyphs';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One capability, configured — without leaving the workspace.
|
* One capability, configured — without leaving the workspace.
|
||||||
@@ -82,11 +83,11 @@ function TestState({ test, entryId }) {
|
|||||||
* button needs the same `surface` this renders, and two copies of that would be
|
* button needs the same `surface` this renders, and two copies of that would be
|
||||||
* two answers to "what am I about to enable".
|
* two answers to "what am I about to enable".
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
|
||||||
export function CapabilityBrief({
|
export function CapabilityBrief({
|
||||||
entry, attached, surface, onSurfaceChange, question, onQuestionChange,
|
entry, attached, surface, onSurfaceChange, question, onQuestionChange,
|
||||||
canTest, target, onTest, test, busy = false, showTest = true,
|
canTest, target, onTest, test, busy = false, showTest = true,
|
||||||
}) {
|
}: any) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { shapes, described } = entry.capabilities;
|
const { shapes, described } = entry.capabilities;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -94,12 +95,12 @@ export function CapabilityBrief({
|
|||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<Badge variant="neutral">{entry.groupLabel}</Badge>
|
<Badge variant="neutral">{entry.groupLabel}</Badge>
|
||||||
{attached
|
{attached
|
||||||
? <Badge variant="success">Enabled for this agent</Badge>
|
? <Badge variant="success">{t('skillDetails.enabledForThisAgent')}</Badge>
|
||||||
: <Badge variant="neutral">Not enabled</Badge>}
|
: <Badge variant="neutral">{t('skillDetails.notEnabled')}</Badge>}
|
||||||
{entry.custom && <Badge variant="info">Custom</Badge>}
|
{entry.custom && <Badge variant="info">{t('skillDetails.custom')}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Section icon={Sparkles} title="Available as">
|
<Section icon={Sparkles} title={t('skillDetails.availableAs')}>
|
||||||
<SurfaceSelect type={entry.type} value={surface} onChange={onSurfaceChange} name={entry.name} />
|
<SurfaceSelect type={entry.type} value={surface} onChange={onSurfaceChange} name={entry.name} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -114,15 +115,13 @@ export function CapabilityBrief({
|
|||||||
* its reach is the workspace rather than this agent.
|
* its reach is the workspace rather than this agent.
|
||||||
*/}
|
*/}
|
||||||
{(surface === 'board' || surface === 'both') && (
|
{(surface === 'board' || surface === 'both') && (
|
||||||
<Alert tone="warning" title="Board is switched on workspace-wide">
|
<Alert tone="warning" title={t('skillDetails.boardIsSwitchedOnWorkspaceWide')}>
|
||||||
Enabling Board here uses the real switch every page surface already reads, so the section
|
{t('skillDetails.enablingBoardHereUsesTheReal')}
|
||||||
genuinely appears — but it appears for the whole workspace, not only for this agent.
|
|
||||||
There is no per-agent Board runtime yet, and this does not pretend there is one.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{described.length > 0 && (
|
{described.length > 0 && (
|
||||||
<Section icon={MessageSquare} title="Capabilities" count={described.length}>
|
<Section icon={MessageSquare} title={t('skillDetails.capabilities')} count={described.length}>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{described.map((line) => (
|
{described.map((line) => (
|
||||||
<li key={line} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
|
<li key={line} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
|
||||||
@@ -135,7 +134,7 @@ export function CapabilityBrief({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{shapes.length > 0 && (
|
{shapes.length > 0 && (
|
||||||
<Section icon={MessageSquare} title="Answer shapes" count={shapes.length}>
|
<Section icon={MessageSquare} title={t('skillDetails.answerShapes')} count={shapes.length}>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{shapes.map((shape) => (
|
{shapes.map((shape) => (
|
||||||
<span
|
<span
|
||||||
@@ -153,7 +152,7 @@ export function CapabilityBrief({
|
|||||||
One list, not two: these are the same suggestions the panel offers on
|
One list, not two: these are the same suggestions the panel offers on
|
||||||
the page, so the test asks what the product would ask. */}
|
the page, so the test asks what the product would ask. */}
|
||||||
{entry.suggestions.length > 0 && (
|
{entry.suggestions.length > 0 && (
|
||||||
<Section icon={MessageSquare} title="What you can ask" count={entry.suggestions.length}>
|
<Section icon={MessageSquare} title={t('skillDetails.whatYouCanAsk')} count={entry.suggestions.length}>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{entry.suggestions.map((s) => {
|
{entry.suggestions.map((s) => {
|
||||||
const value = s.prompt || s.label;
|
const value = s.prompt || s.label;
|
||||||
@@ -175,7 +174,7 @@ export function CapabilityBrief({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
“{s.label}”
|
“{s.label}”
|
||||||
{picked && <span className="sr-only"> — chosen for the test</span>}
|
{picked && <span className="sr-only"> {t('skillDetails.chosenForTheTest')}</span>}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -184,7 +183,7 @@ export function CapabilityBrief({
|
|||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Section icon={LayoutGrid} title="Answers on" count={entry.surfaces.length}>
|
<Section icon={LayoutGrid} title={t('skillDetails.answersOn')} count={entry.surfaces.length}>
|
||||||
{entry.surfaces.length > 0 ? (
|
{entry.surfaces.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -198,17 +197,16 @@ export function CapabilityBrief({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-caption leading-relaxed text-ink-4">
|
<p className="text-caption leading-relaxed text-ink-4">
|
||||||
A skill still only answers on the pages it names. Enabling it cannot widen that —
|
{t('skillDetails.aSkillStillOnlyAnswersOn')}
|
||||||
the agent's own surfaces narrow it further.
|
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-body-sm text-ink-3">This skill names no page, so it answers nowhere.</p>
|
<p className="text-body-sm text-ink-3">{t('skillDetails.thisSkillNamesNoPageSo')}</p>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{entry.tools.length > 0 && (
|
{entry.tools.length > 0 && (
|
||||||
<Section icon={Wrench} title="Tools it reaches" count={entry.tools.length}>
|
<Section icon={Wrench} title={t('skillDetails.toolsItReaches')} count={entry.tools.length}>
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{entry.tools.map((tool) => (
|
{entry.tools.map((tool) => (
|
||||||
<li key={tool.name} className="rounded-lg border border-border bg-surface px-2.5 py-2">
|
<li key={tool.name} className="rounded-lg border border-border bg-surface px-2.5 py-2">
|
||||||
@@ -216,7 +214,7 @@ export function CapabilityBrief({
|
|||||||
{tool.label}
|
{tool.label}
|
||||||
{!tool.readOnly && (
|
{!tool.readOnly && (
|
||||||
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-semibold text-warning">
|
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-semibold text-warning">
|
||||||
Writes
|
{t('skillDetails.writes')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -232,17 +230,17 @@ export function CapabilityBrief({
|
|||||||
{/* Test: the button and the outcome. The question and the answer live in
|
{/* Test: the button and the outcome. The question and the answer live in
|
||||||
the Owliver panel, which is already on this page. */}
|
the Owliver panel, which is already on this page. */}
|
||||||
{showTest && (
|
{showTest && (
|
||||||
<Section icon={Play} title="Test">
|
<Section icon={Play} title={t('skillDetails.test')}>
|
||||||
<p className="text-caption leading-relaxed text-ink-3">
|
<p className="text-caption leading-relaxed text-ink-3">
|
||||||
{canTest
|
{canTest
|
||||||
? <>Runs in the Owliver panel on this page, against <span className="font-medium text-ink-2">{target.label}</span>. Testing changes nothing — the agent is only updated when you enable it.</>
|
? <>{t('skillDetails.runsInTheOwliverPanelOn')} <span className="font-medium text-ink-2">{target.label}</span>{t('skillDetails.testingChangesNothingTheAgentIs')}</>
|
||||||
: surface === 'board'
|
: surface === 'board'
|
||||||
? 'A Board section draws on a page rather than answering a question, so there is nothing to ask it here.'
|
? 'A Board section draws on a page rather than answering a question, so there is nothing to ask it here.'
|
||||||
: 'This capability answers on no page this agent covers, so there is nothing to test against.'}
|
: 'This capability answers on no page this agent covers, so there is nothing to test against.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={onTest} disabled={!canTest || busy} className="w-full">
|
<Button variant="outline" size="sm" onClick={onTest} disabled={!canTest || busy} className="w-full">
|
||||||
<Play className="h-3.5 w-3.5" /> Test in Owliver
|
<Play className="h-3.5 w-3.5" /> {t('skillDetails.testInOwliver')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<TestState test={test} entryId={entry.id} />
|
<TestState test={test} entryId={entry.id} />
|
||||||
@@ -252,11 +250,11 @@ export function CapabilityBrief({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
|
||||||
export function SkillDetails({
|
export function SkillDetails({
|
||||||
entry, open, onOpenChange, isEnabledOn, onToggle, busy = false,
|
entry, open, onOpenChange, isEnabledOn, onToggle, busy = false,
|
||||||
fields = null, onRunTest = null, test = null,
|
fields = null, onRunTest = null, test = null,
|
||||||
}) {
|
}: any) {
|
||||||
|
const { t } = useTranslation();
|
||||||
/**
|
/**
|
||||||
* The surface this would run on.
|
* The surface this would run on.
|
||||||
*
|
*
|
||||||
@@ -310,7 +308,7 @@ export function SkillDetails({
|
|||||||
size="default"
|
size="default"
|
||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('skillDetails.close')}</Button>
|
||||||
{/* The one control in this product that changes the agent's skills.
|
{/* The one control in this product that changes the agent's skills.
|
||||||
Last, under everything the decision should rest on. */}
|
Last, under everything the decision should rest on. */}
|
||||||
<Button
|
<Button
|
||||||
@@ -319,8 +317,8 @@ export function SkillDetails({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
{attached
|
{attached
|
||||||
? <><Minus className="h-3.5 w-3.5" /> Disable for this agent</>
|
? <><Minus className="h-3.5 w-3.5" /> {t('skillDetails.disableForThisAgent')}</>
|
||||||
: <><Plus className="h-3.5 w-3.5" /> Enable for this agent</>}
|
: <><Plus className="h-3.5 w-3.5" /> {t('skillDetails.enableForThisAgent')}</>}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -45,8 +45,7 @@ const OPTIONS = [
|
|||||||
export const supportedSurfaces = (type) =>
|
export const supportedSurfaces = (type) =>
|
||||||
OPTIONS.filter((o) => o.supported(type)).map((o) => o.id);
|
OPTIONS.filter((o) => o.supported(type)).map((o) => o.id);
|
||||||
|
|
||||||
/** @param {any} props */
|
export function SurfaceSelect({ type, value, onChange, name }: any) {
|
||||||
export function SurfaceSelect({ type, value, onChange, name }) {
|
|
||||||
return (
|
return (
|
||||||
<div role="radiogroup" aria-label={`Run ${name} as`} className="space-y-1.5">
|
<div role="radiogroup" aria-label={`Run ${name} as`} className="space-y-1.5">
|
||||||
{OPTIONS.map((option) => {
|
{OPTIONS.map((option) => {
|
||||||
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { agentIconFor } from '@/components/agents/icons';
|
import { agentIconFor } from '@/components/agents/icons';
|
||||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||||
import { useActiveAgent } from './AgentContext';
|
import { useActiveAgent } from './AgentContext';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who is answering, in Owliver's own header.
|
* Who is answering, in Owliver's own header.
|
||||||
@@ -50,15 +51,16 @@ function AgentGlyph({ agent, className = 'h-7 w-7' }) {
|
|||||||
* constrained reply arrive with no warning.
|
* constrained reply arrive with no warning.
|
||||||
*/
|
*/
|
||||||
function ConstrainedTag() {
|
function ConstrainedTag() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300">
|
<span className="inline-flex shrink-0 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-300">
|
||||||
Constrained
|
{t('agentBadge.constrained')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
export function AgentBadge({ page }: any) {
|
||||||
export function AgentBadge({ page }) {
|
const { t } = useTranslation();
|
||||||
const { agent, covers } = useActiveAgent();
|
const { agent, covers } = useActiveAgent();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,7 +71,7 @@ export function AgentBadge({ page }) {
|
|||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
|
<h2 className="truncate font-heading text-body-sm font-semibold leading-tight text-ink-1">
|
||||||
Owliver
|
{t('agentBadge.owliver')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>
|
<p className="truncate text-caption leading-tight text-ink-3">{page}</p>
|
||||||
@@ -3,6 +3,7 @@ import { ThumbsDown, ThumbsUp } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ResponseDocument } from './ResponseBlocks';
|
import { ResponseDocument } from './ResponseBlocks';
|
||||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The thinking state, shown between sending and the first block arriving.
|
* The thinking state, shown between sending and the first block arriving.
|
||||||
@@ -11,6 +12,7 @@ import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
|||||||
* reader, and the visually-hidden text is what tells one that a reply is coming.
|
* reader, and the visually-hidden text is what tells one that a reply is coming.
|
||||||
*/
|
*/
|
||||||
export function ThinkingIndicator() {
|
export function ThinkingIndicator() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<OwliverAvatar className="h-6 w-6" rounded="rounded-full" />
|
<OwliverAvatar className="h-6 w-6" rounded="rounded-full" />
|
||||||
@@ -23,7 +25,7 @@ export function ThinkingIndicator() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span className="sr-only">Owliver is preparing a response</span>
|
<span className="sr-only">{t('assistantMessage.owliverIsPreparingAResponse')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,9 +88,8 @@ function FeedbackControls({ feedback, onFeedback }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Message = React.memo(
|
export const Message = React.memo(
|
||||||
/** @param {any} props */
|
|
||||||
({ role, text, blocks, streaming, stopped, onPrompt, onConfirm = null,
|
({ role, text, blocks, streaming, stopped, onPrompt, onConfirm = null,
|
||||||
feedback = null, onFeedback = null }) => {
|
feedback = null, onFeedback = null }: any) => {
|
||||||
if (role === 'user') {
|
if (role === 'user') {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
@@ -6,6 +6,7 @@ import { useAssistantPanel } from './AssistantPanelContext';
|
|||||||
import { ResizeDivider } from './ResizeDivider';
|
import { ResizeDivider } from './ResizeDivider';
|
||||||
import { useIsPhone, useViewportWidth, useVisualViewport } from './viewport';
|
import { useIsPhone, useViewportWidth, useVisualViewport } from './viewport';
|
||||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const EXPANDED_WIDTH = 620;
|
const EXPANDED_WIDTH = 620;
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +34,7 @@ const MAX_VIEWPORT_SHARE = 0.42;
|
|||||||
* in the collapsed state, where there is nowhere else for it to live.
|
* in the collapsed state, where there is nowhere else for it to live.
|
||||||
*/
|
*/
|
||||||
function CollapsedTrigger({ page, onRestore }) {
|
function CollapsedTrigger({ page, onRestore }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -50,7 +52,7 @@ function CollapsedTrigger({ page, onRestore }) {
|
|||||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||||
>
|
>
|
||||||
<OwliverAvatar className="h-7 w-7" rounded="rounded-full" />
|
<OwliverAvatar className="h-7 w-7" rounded="rounded-full" />
|
||||||
<span className="text-body-sm font-semibold text-ink-1">Owliver</span>
|
<span className="text-body-sm font-semibold text-ink-1">{t('assistantPanel.owliver')}</span>
|
||||||
<PanelLeftOpen className="h-3.5 w-3.5 shrink-0 text-ink-4 group-hover:text-krow-blue" aria-hidden="true" />
|
<PanelLeftOpen className="h-3.5 w-3.5 shrink-0 text-ink-4 group-hover:text-krow-blue" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -81,6 +83,7 @@ function CollapsedTrigger({ page, onRestore }) {
|
|||||||
* instead of pushing the composer off the bottom of it.
|
* instead of pushing the composer off the bottom of it.
|
||||||
*/
|
*/
|
||||||
function MobileWorkspace({ context, onClose }) {
|
function MobileWorkspace({ context, onClose }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const viewport = useVisualViewport();
|
const viewport = useVisualViewport();
|
||||||
|
|
||||||
/* The page behind an overlay must not scroll: on a touch screen a drag that
|
/* The page behind an overlay must not scroll: on a touch screen a drag that
|
||||||
@@ -105,7 +108,7 @@ function MobileWorkspace({ context, onClose }) {
|
|||||||
pointer. */}
|
pointer. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close the Owliver workspace"
|
aria-label={t('assistant.closeWorkspace')}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute inset-0 bg-ink-1/30 backdrop-blur-[2px] motion-safe:animate-fade-in"
|
className="absolute inset-0 bg-ink-1/30 backdrop-blur-[2px] motion-safe:animate-fade-in"
|
||||||
/>
|
/>
|
||||||
@@ -119,7 +122,7 @@ function MobileWorkspace({ context, onClose }) {
|
|||||||
pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3"
|
pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Owliver workspace"
|
aria-label={t('assistant.workspace')}
|
||||||
>
|
>
|
||||||
<KrowAssistant
|
<KrowAssistant
|
||||||
key={context.id}
|
key={context.id}
|
||||||
@@ -180,6 +183,7 @@ function MobileWorkspace({ context, onClose }) {
|
|||||||
* application layout, never a fullscreen takeover.
|
* application layout, never a fullscreen takeover.
|
||||||
*/
|
*/
|
||||||
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
context, isOpen, isExpanded, width, minWidth, maxWidth,
|
context, isOpen, isExpanded, width, minWidth, maxWidth,
|
||||||
setWidth, resetWidth, open, close, expand, restore,
|
setWidth, resetWidth, open, close, expand, restore,
|
||||||
@@ -221,7 +225,7 @@ export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label="Owliver workspace"
|
aria-label={t('assistant.workspace')}
|
||||||
style={{ width: trackWidth }}
|
style={{ width: trackWidth }}
|
||||||
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2).
|
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2).
|
||||||
`min-w-0` — see (3).
|
`min-w-0` — see (3).
|
||||||
@@ -268,7 +268,7 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
|||||||
|
|
||||||
/* Width is written on every drag frame, so persistence is debounced rather
|
/* Width is written on every drag frame, so persistence is debounced rather
|
||||||
than hitting sessionStorage sixty times a second. */
|
than hitting sessionStorage sixty times a second. */
|
||||||
const setWidthState = React.useCallback((next) => {
|
const setWidthState: any = React.useCallback((next) => {
|
||||||
const clamped = clampWidth(next);
|
const clamped = clampWidth(next);
|
||||||
setWidth(clamped);
|
setWidth(clamped);
|
||||||
clearTimeout(setWidthState.timer);
|
clearTimeout(setWidthState.timer);
|
||||||
@@ -15,8 +15,7 @@ import { useAssistantPanel } from './AssistantPanelContext';
|
|||||||
* It self-hides on pages with no assistant, so a page can render it
|
* It self-hides on pages with no assistant, so a page can render it
|
||||||
* unconditionally and never needs to know the placement rules.
|
* unconditionally and never needs to know the placement rules.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
export function AssistantTrigger({ className = '', size = 'default' }: any) {
|
||||||
export function AssistantTrigger({ className = '', size = 'default' }) {
|
|
||||||
const { supported, isOpen, toggle, context } = useAssistantPanel();
|
const { supported, isOpen, toggle, context } = useAssistantPanel();
|
||||||
|
|
||||||
if (!supported) return null;
|
if (!supported) return null;
|
||||||
@@ -31,6 +31,8 @@ import { buildIntro } from './dynamic';
|
|||||||
import { agentScopedDisabled } from '@/lib/agents/runtime';
|
import { agentScopedDisabled } from '@/lib/agents/runtime';
|
||||||
import { buildOwliverContext } from '@/lib/agents/context';
|
import { buildOwliverContext } from '@/lib/agents/context';
|
||||||
import { AgentBadge } from './AgentBadge';
|
import { AgentBadge } from './AgentBadge';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useLanguage } from '@/lib/i18n/language';
|
||||||
import { useActiveAgent } from './AgentContext';
|
import { useActiveAgent } from './AgentContext';
|
||||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||||
import { PromptInput } from './PromptInput';
|
import { PromptInput } from './PromptInput';
|
||||||
@@ -65,12 +67,13 @@ const EMPTY_SUGGESTIONS = [];
|
|||||||
* half-remember.
|
* half-remember.
|
||||||
*/
|
*/
|
||||||
function HistoryView({ groups, currentId, onOpen, onForget }) {
|
function HistoryView({ groups, currentId, onOpen, onForget }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
if (!groups.length) {
|
if (!groups.length) {
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<p className="font-heading text-body font-semibold text-ink-1">No conversations yet</p>
|
<p className="font-heading text-body font-semibold text-ink-1">{t('krowAssistant.noConversationsYet')}</p>
|
||||||
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">
|
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">
|
||||||
Ask Owliver something and it will be here afterwards. History is kept on this device.
|
{t('krowAssistant.askOwliverSomethingAndItWill')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -178,6 +181,7 @@ export const shouldShowSuggestions = ({ focused, busy, view, count }) => Boolean
|
|||||||
* so a narrow panel on a small screen cannot push the composer off the bottom.
|
* so a narrow panel on a small screen cannot push the composer off the bottom.
|
||||||
*/
|
*/
|
||||||
function SuggestedQuestions({ prompts, open, onSelect }) {
|
function SuggestedQuestions({ prompts, open, onSelect }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -189,7 +193,7 @@ function SuggestedQuestions({ prompts, open, onSelect }) {
|
|||||||
aria-hidden={open ? undefined : 'true'}
|
aria-hidden={open ? undefined : 'true'}
|
||||||
>
|
>
|
||||||
<p className="px-1 pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
<p className="px-1 pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||||
Suggested questions
|
{t('krowAssistant.suggestedQuestions')}
|
||||||
</p>
|
</p>
|
||||||
{/* The existing chip component, and the existing submit path behind it —
|
{/* The existing chip component, and the existing submit path behind it —
|
||||||
`runPrompt` is the same handler a typed question goes through. */}
|
`runPrompt` is the same handler a typed question goes through. */}
|
||||||
@@ -403,6 +407,14 @@ export default function KrowAssistant({
|
|||||||
*/
|
*/
|
||||||
const [view, setView] = React.useState('chat');
|
const [view, setView] = React.useState('chat');
|
||||||
|
|
||||||
|
/* The account's language, chosen in the account menu rather than here. The
|
||||||
|
panel reads it and never sets it: it is an application setting that happens
|
||||||
|
to govern the one piece of text this product generates, not a property of
|
||||||
|
the conversation. It applies from the next answer onward — answers already
|
||||||
|
on screen stay as they were written. */
|
||||||
|
const language = useLanguage();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A skill asked for something to happen. The names come from the skill file;
|
* A skill asked for something to happen. The names come from the skill file;
|
||||||
* `runAction` decides what they mean and refuses anything the file did not
|
* `runAction` decides what they mean and refuses anything the file did not
|
||||||
@@ -637,6 +649,7 @@ export default function KrowAssistant({
|
|||||||
agentCoversPage,
|
agentCoversPage,
|
||||||
agentSuggestion: defaultAgent,
|
agentSuggestion: defaultAgent,
|
||||||
owliverContext,
|
owliverContext,
|
||||||
|
language,
|
||||||
});
|
});
|
||||||
|
|
||||||
/* A block inside an answer asking the next question, in place. Same entry
|
/* A block inside an answer asking the next question, in place. Same entry
|
||||||
@@ -679,9 +692,12 @@ export default function KrowAssistant({
|
|||||||
|
|
||||||
/* Greeting and suggestions come from live data, so they recompute only when
|
/* Greeting and suggestions come from live data, so they recompute only when
|
||||||
the data or the page actually changes. */
|
the data or the page actually changes. */
|
||||||
|
/* `t` is a dependency, not just an argument: react-i18next returns a new one
|
||||||
|
when the language changes, so the greeting recomputes on a switch rather
|
||||||
|
than staying in whichever language the panel first opened in. */
|
||||||
const intro = React.useMemo(
|
const intro = React.useMemo(
|
||||||
() => buildIntro(context.id, facts, userName),
|
() => buildIntro(context.id, facts, userName, t),
|
||||||
[context.id, facts, userName]
|
[context.id, facts, userName, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -908,7 +924,7 @@ export default function KrowAssistant({
|
|||||||
data-wide={expanded || undefined}
|
data-wide={expanded || undefined}
|
||||||
className={cn('relative flex flex-col overflow-hidden', className)}
|
className={cn('relative flex flex-col overflow-hidden', className)}
|
||||||
role="region"
|
role="region"
|
||||||
aria-label="Owliver assistant"
|
aria-label={t('assistant.assistant')}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
|
<div className="flex shrink-0 items-center gap-2.5 border-b border-white/60 px-3.5 py-2.5">
|
||||||
@@ -931,7 +947,7 @@ export default function KrowAssistant({
|
|||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
{/* The way back to a clean panel, positioned in front of History */}
|
{/* The way back to a clean panel, positioned in front of History */}
|
||||||
{canGoHome && (
|
{canGoHome && (
|
||||||
<IconButton icon={Home} label="Back to home" variant="ghost" size="sm" onClick={goHome} />
|
<IconButton icon={Home} label={t('assistant.backToHome')} variant="ghost" size="sm" onClick={goHome} />
|
||||||
)}
|
)}
|
||||||
{/* History lives with the other window controls rather than in the
|
{/* History lives with the other window controls rather than in the
|
||||||
body, so the layout of the panel is unchanged whether or not
|
body, so the layout of the panel is unchanged whether or not
|
||||||
@@ -947,13 +963,13 @@ export default function KrowAssistant({
|
|||||||
/>
|
/>
|
||||||
{expanded
|
{expanded
|
||||||
? onRestore && (
|
? onRestore && (
|
||||||
<IconButton icon={Minimize2} label="Restore the default workspace width" variant="ghost" size="sm" onClick={onRestore} />
|
<IconButton icon={Minimize2} label={t('assistant.restoreWidth')} variant="ghost" size="sm" onClick={onRestore} />
|
||||||
)
|
)
|
||||||
: onExpand && (
|
: onExpand && (
|
||||||
<IconButton icon={Maximize2} label="Expand the Owliver workspace" variant="ghost" size="sm" onClick={onExpand} />
|
<IconButton icon={Maximize2} label={t('assistant.expandWorkspace')} variant="ghost" size="sm" onClick={onExpand} />
|
||||||
)}
|
)}
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<IconButton icon={PanelRightClose} label="Collapse the Owliver workspace" variant="ghost" size="sm" onClick={onClose} />
|
<IconButton icon={PanelRightClose} label={t('assistant.collapseWorkspace')} variant="ghost" size="sm" onClick={onClose} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -989,7 +1005,7 @@ export default function KrowAssistant({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clearTest}
|
onClick={clearTest}
|
||||||
aria-label="Dismiss test indicator"
|
aria-label={t('assistant.dismissTestIndicator')}
|
||||||
className={`shrink-0 rounded p-0.5 text-ink-4 transition-colors hover:text-ink-2
|
className={`shrink-0 rounded p-0.5 text-ink-4 transition-colors hover:text-ink-2
|
||||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||||
>
|
>
|
||||||
@@ -1080,9 +1096,6 @@ export default function KrowAssistant({
|
|||||||
>
|
>
|
||||||
<SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} />
|
<SuggestedQuestions prompts={prompts} open={showSuggestions} onSelect={runPrompt} />
|
||||||
{composer}
|
{composer}
|
||||||
<p className="px-1 text-[10px] leading-tight text-ink-4">
|
|
||||||
Owliver reads this page's data. Check anything you act on.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Surface>
|
</Surface>
|
||||||
);
|
);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PromptChips — suggested prompts.
|
* PromptChips — suggested prompts.
|
||||||
@@ -23,14 +24,14 @@ import { cn } from '@/lib/utils';
|
|||||||
*
|
*
|
||||||
* Arrow keys move between chips, so the whole set is one tab stop.
|
* Arrow keys move between chips, so the whole set is one tab stop.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
|
||||||
export function PromptChips({
|
export function PromptChips({
|
||||||
prompts = [], onSelect, max = 0, align = 'center', className = '',
|
prompts = [], onSelect, max = 0, align = 'center', className = '',
|
||||||
/* Taken out of the tab order while the row is collapsed but still mounted:
|
/* 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
|
a chip inside a zero-height container is invisible, and a Tab that lands on
|
||||||
something invisible is a keyboard user losing their place. */
|
something invisible is a keyboard user losing their place. */
|
||||||
focusable = true,
|
focusable = true,
|
||||||
}) {
|
}: any) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const chipRefs = React.useRef([]);
|
const chipRefs = React.useRef([]);
|
||||||
const visible = max ? prompts.slice(0, max) : prompts;
|
const visible = max ? prompts.slice(0, max) : prompts;
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ export function PromptChips({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Suggested prompts"
|
aria-label={t('assistant.suggestedPrompts')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-wrap gap-1.5',
|
'flex flex-wrap gap-1.5',
|
||||||
align === 'center' ? 'justify-center' : 'justify-start',
|
align === 'center' ? 'justify-center' : 'justify-start',
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ArrowUp, Square } from 'lucide-react';
|
import { ArrowUp, Square } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PromptInput — the assistant's primary action.
|
* PromptInput — the assistant's primary action.
|
||||||
@@ -17,7 +18,6 @@ import { cn } from '@/lib/utils';
|
|||||||
* gain — the suggestion chips already teach the range of what can be asked, and
|
* gain — the suggestion chips already teach the range of what can be asked, and
|
||||||
* they do it without a timer or a rerender every four seconds.
|
* they do it without a timer or a rerender every four seconds.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
|
||||||
export function PromptInput({
|
export function PromptInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -28,7 +28,8 @@ export function PromptInput({
|
|||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
align = 'left',
|
align = 'left',
|
||||||
className = '',
|
className = '',
|
||||||
}) {
|
}: any) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const textareaRef = React.useRef(null);
|
const textareaRef = React.useRef(null);
|
||||||
const [focused, setFocused] = React.useState(false);
|
const [focused, setFocused] = React.useState(false);
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ export function PromptInput({
|
|||||||
onFocus={() => setFocused(true)}
|
onFocus={() => setFocused(true)}
|
||||||
onBlur={() => setFocused(false)}
|
onBlur={() => setFocused(false)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
aria-label="Ask Owliver"
|
aria-label={t('assistant.ask')}
|
||||||
aria-busy={busy || undefined}
|
aria-busy={busy || undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'block w-full resize-none bg-transparent leading-relaxed text-ink-1',
|
'block w-full resize-none bg-transparent leading-relaxed text-ink-1',
|
||||||
@@ -102,7 +103,7 @@ export function PromptInput({
|
|||||||
isCenter ? 'text-center' : 'text-left'
|
isCenter ? 'text-center' : 'text-left'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{busy ? 'Thinking…' : placeholders[0]}
|
{busy ? t('assistant.thinking') : placeholders[0]}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +112,7 @@ export function PromptInput({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onStop}
|
onClick={onStop}
|
||||||
aria-label="Stop generating"
|
aria-label={t('assistant.stopGenerating')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2',
|
'grid shrink-0 place-items-center rounded-full border border-border bg-surface text-ink-2',
|
||||||
'transition-colors duration-base hover:bg-surface-sunken focus-ring',
|
'transition-colors duration-base hover:bg-surface-sunken focus-ring',
|
||||||
@@ -124,7 +125,7 @@ export function PromptInput({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!value.trim()}
|
disabled={!value.trim()}
|
||||||
aria-label="Send message"
|
aria-label={t('assistant.sendMessage')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
|
'grid shrink-0 place-items-center rounded-full bg-krow-blue text-white',
|
||||||
'transition-all duration-base ease-out hover:bg-krow-blue-dark',
|
'transition-all duration-base ease-out hover:bg-krow-blue-dark',
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { GripVertical } from 'lucide-react';
|
import { GripVertical } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ResizeDivider — the drag handle between the dashboard and the Owliver panel.
|
* ResizeDivider — the drag handle between the dashboard and the Owliver panel.
|
||||||
@@ -15,6 +16,7 @@ import { cn } from '@/lib/utils';
|
|||||||
* limits, and the whole thing is exposed as a `separator` with live values.
|
* limits, and the whole thing is exposed as a `separator` with live values.
|
||||||
*/
|
*/
|
||||||
export function ResizeDivider({ width, min, max, onResize, onDoubleClick, className }) {
|
export function ResizeDivider({ width, min, max, onResize, onDoubleClick, className }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [dragging, setDragging] = React.useState(false);
|
const [dragging, setDragging] = React.useState(false);
|
||||||
|
|
||||||
/* Pointer events on the window rather than the element: once a drag starts the
|
/* Pointer events on the window rather than the element: once a drag starts the
|
||||||
@@ -66,7 +68,7 @@ export function ResizeDivider({ width, min, max, onResize, onDoubleClick, classN
|
|||||||
<div
|
<div
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-orientation="vertical"
|
aria-orientation="vertical"
|
||||||
aria-label="Resize Owliver panel"
|
aria-label={t('assistant.resizePanel')}
|
||||||
aria-valuenow={width}
|
aria-valuenow={width}
|
||||||
aria-valuemin={min}
|
aria-valuemin={min}
|
||||||
aria-valuemax={max}
|
aria-valuemax={max}
|
||||||
@@ -121,14 +121,14 @@ const TONE_BADGE = {
|
|||||||
|
|
||||||
/* ── Individual blocks ──────────────────────────────────────────────────── */
|
/* ── Individual blocks ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const TextBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const TextBlock = React.memo(({ block }: any) => (
|
||||||
<p className="text-body-sm leading-relaxed text-ink-2">
|
<p className="text-body-sm leading-relaxed text-ink-2">
|
||||||
<Inline value={block.text} />
|
<Inline value={block.text} />
|
||||||
</p>
|
</p>
|
||||||
));
|
));
|
||||||
TextBlock.displayName = 'TextBlock';
|
TextBlock.displayName = 'TextBlock';
|
||||||
|
|
||||||
const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const HeadingBlock = React.memo(({ block }: any) => (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
|
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
|
||||||
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
|
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
|
||||||
@@ -137,7 +137,7 @@ const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
|||||||
HeadingBlock.displayName = 'HeadingBlock';
|
HeadingBlock.displayName = 'HeadingBlock';
|
||||||
|
|
||||||
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
|
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
|
||||||
const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const KpisBlock = React.memo(({ block }: any) => (
|
||||||
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
|
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
|
||||||
{block.items.map((item) => (
|
{block.items.map((item) => (
|
||||||
<div
|
<div
|
||||||
@@ -172,7 +172,7 @@ const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
|||||||
KpisBlock.displayName = 'KpisBlock';
|
KpisBlock.displayName = 'KpisBlock';
|
||||||
|
|
||||||
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
|
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
|
||||||
const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const StatusBlock = React.memo(({ block }: any) => (
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{block.items.map((item) => (
|
{block.items.map((item) => (
|
||||||
<li key={item.label} className="flex items-start gap-2">
|
<li key={item.label} className="flex items-start gap-2">
|
||||||
@@ -204,7 +204,7 @@ const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
|||||||
));
|
));
|
||||||
StatusBlock.displayName = 'StatusBlock';
|
StatusBlock.displayName = 'StatusBlock';
|
||||||
|
|
||||||
const MetersBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const MetersBlock = React.memo(({ block }: any) => (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{block.items.map((item) => (
|
{block.items.map((item) => (
|
||||||
<div key={item.label} className="space-y-1">
|
<div key={item.label} className="space-y-1">
|
||||||
@@ -226,7 +226,7 @@ MetersBlock.displayName = 'MetersBlock';
|
|||||||
* A table. Scrolls horizontally inside its own container so a wide comparison
|
* A table. Scrolls horizontally inside its own container so a wide comparison
|
||||||
* never widens the panel.
|
* never widens the panel.
|
||||||
*/
|
*/
|
||||||
const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
const TableBlock = React.memo(({ block }: any) => {
|
||||||
const cell = (value) => {
|
const cell = (value) => {
|
||||||
if (value == null || value === '') return <span className="text-ink-4">—</span>;
|
if (value == null || value === '') return <span className="text-ink-4">—</span>;
|
||||||
if (typeof value === 'object') {
|
if (typeof value === 'object') {
|
||||||
@@ -287,7 +287,7 @@ const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
|||||||
TableBlock.displayName = 'TableBlock';
|
TableBlock.displayName = 'TableBlock';
|
||||||
|
|
||||||
/** Stage progression, widths relative to the largest stage. */
|
/** Stage progression, widths relative to the largest stage. */
|
||||||
const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
const FunnelBlock = React.memo(({ block }: any) => {
|
||||||
const max = Math.max(...block.steps.map((s) => s.count), 1);
|
const max = Math.max(...block.steps.map((s) => s.count), 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -314,7 +314,7 @@ const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
|||||||
});
|
});
|
||||||
FunnelBlock.displayName = 'FunnelBlock';
|
FunnelBlock.displayName = 'FunnelBlock';
|
||||||
|
|
||||||
const ListBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
const ListBlock = React.memo(({ block }: any) => {
|
||||||
const Tag = block.ordered ? 'ol' : 'ul';
|
const Tag = block.ordered ? 'ol' : 'ul';
|
||||||
return (
|
return (
|
||||||
<Tag className="space-y-1.5">
|
<Tag className="space-y-1.5">
|
||||||
@@ -357,7 +357,7 @@ ListBlock.displayName = 'ListBlock';
|
|||||||
* clickable and doing nothing. An item may carry a hint *and* an action: the
|
* clickable and doing nothing. An item may carry a hint *and* an action: the
|
||||||
* hint explains the record, the action opens it.
|
* hint explains the record, the action opens it.
|
||||||
*/
|
*/
|
||||||
const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt }) => (
|
const InsightsBlock = React.memo(({ block, onPrompt }: any) => (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{block.items.map((item, i) => {
|
{block.items.map((item, i) => {
|
||||||
const surface = cn(
|
const surface = cn(
|
||||||
@@ -434,7 +434,7 @@ const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt })
|
|||||||
InsightsBlock.displayName = 'InsightsBlock';
|
InsightsBlock.displayName = 'InsightsBlock';
|
||||||
|
|
||||||
/** Recommended steps — numbered, because order is the recommendation. */
|
/** Recommended steps — numbered, because order is the recommendation. */
|
||||||
const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const ActionsBlock = React.memo(({ block }: any) => (
|
||||||
<ol className="space-y-2">
|
<ol className="space-y-2">
|
||||||
{block.items.map((item, i) => (
|
{block.items.map((item, i) => (
|
||||||
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
|
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
|
||||||
@@ -453,7 +453,7 @@ const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
|||||||
));
|
));
|
||||||
ActionsBlock.displayName = 'ActionsBlock';
|
ActionsBlock.displayName = 'ActionsBlock';
|
||||||
|
|
||||||
const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const BadgesBlock = React.memo(({ block }: any) => (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{block.items.map((item) => (
|
{block.items.map((item) => (
|
||||||
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
|
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
|
||||||
@@ -464,7 +464,7 @@ const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
|||||||
));
|
));
|
||||||
BadgesBlock.displayName = 'BadgesBlock';
|
BadgesBlock.displayName = 'BadgesBlock';
|
||||||
|
|
||||||
const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const TimelineBlock = React.memo(({ block }: any) => (
|
||||||
<ol className="relative space-y-3">
|
<ol className="relative space-y-3">
|
||||||
{block.items.map((item, i) => (
|
{block.items.map((item, i) => (
|
||||||
<li key={i} className="flex gap-3">
|
<li key={i} className="flex gap-3">
|
||||||
@@ -497,7 +497,7 @@ const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
|||||||
));
|
));
|
||||||
TimelineBlock.displayName = 'TimelineBlock';
|
TimelineBlock.displayName = 'TimelineBlock';
|
||||||
|
|
||||||
const NoteBlock = React.memo(/** @param {any} props */ ({ block }) => (
|
const NoteBlock = React.memo(({ block }: any) => (
|
||||||
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
|
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
|
||||||
<Inline value={block.text} />
|
<Inline value={block.text} />
|
||||||
</p>
|
</p>
|
||||||
@@ -514,7 +514,7 @@ NoteBlock.displayName = 'NoteBlock';
|
|||||||
* shape the table does not carry renders nothing rather than something
|
* shape the table does not carry renders nothing rather than something
|
||||||
* improvised.
|
* improvised.
|
||||||
*/
|
*/
|
||||||
const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
const SkillSectionBlock = React.memo(({ block }: any) => {
|
||||||
const section = block.section;
|
const section = block.section;
|
||||||
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
|
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
|
||||||
|
|
||||||
@@ -564,7 +564,7 @@ SkillSectionBlock.displayName = 'SkillSectionBlock';
|
|||||||
* - **Not pretend to be finished.** Once approved, the block stays visible
|
* - **Not pretend to be finished.** Once approved, the block stays visible
|
||||||
* and says so. Replacing it with a tick would lose what was agreed to.
|
* and says so. Replacing it with a tick would lose what was agreed to.
|
||||||
*/
|
*/
|
||||||
const ConfirmationBlock = React.memo(/** @param {any} props */ ({ block, onConfirm }) => {
|
const ConfirmationBlock = React.memo(({ block, onConfirm }: any) => {
|
||||||
const [state, setState] = React.useState('pending');
|
const [state, setState] = React.useState('pending');
|
||||||
|
|
||||||
const approve = React.useCallback(() => {
|
const approve = React.useCallback(() => {
|
||||||
@@ -673,7 +673,7 @@ const RENDERERS = {
|
|||||||
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
||||||
* else breathes.
|
* else breathes.
|
||||||
*/
|
*/
|
||||||
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt, onConfirm }) => (
|
export const ResponseDocument = React.memo(({ blocks = [], streaming = false, onPrompt, onConfirm }: any) => (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{blocks.map((block, i) => {
|
{blocks.map((block, i) => {
|
||||||
const Renderer = RENDERERS[block.type];
|
const Renderer = RENDERERS[block.type];
|
||||||
@@ -20,7 +20,7 @@ export const doc = (...blocks) => ({
|
|||||||
export const text = (value) => value && { type: 'text', text: value };
|
export const text = (value) => value && { type: 'text', text: value };
|
||||||
|
|
||||||
/** A section label inside a response. */
|
/** A section label inside a response. */
|
||||||
export const heading = (value, sub) => value && { type: 'heading', text: value, sub };
|
export const heading = (value, sub?) => value && { type: 'heading', text: value, sub };
|
||||||
|
|
||||||
/** Headline figures. `items: [{ label, value, delta?, sub?, tone? }]` */
|
/** Headline figures. `items: [{ label, value, delta?, sub?, tone? }]` */
|
||||||
export const kpis = (items) => items?.length && { type: 'kpis', items };
|
export const kpis = (items) => items?.length && { type: 'kpis', items };
|
||||||
@@ -814,8 +814,19 @@ const firstName = (fullName) => String(fullName || '').trim().split(/\s+/)[0] ||
|
|||||||
* Everything the landing screen needs, computed from current data.
|
* Everything the landing screen needs, computed from current data.
|
||||||
* `userName` personalizes the greeting where a name is known.
|
* `userName` personalizes the greeting where a name is known.
|
||||||
*/
|
*/
|
||||||
export function buildIntro(contextId, facts, userName) {
|
export function buildIntro(contextId, facts, userName, t = null) {
|
||||||
const name = firstName(userName);
|
const name = firstName(userName);
|
||||||
|
|
||||||
|
/* `t` is optional so a caller that has not been converted yet still gets the
|
||||||
|
English it got before. Every map below keeps its English function; the
|
||||||
|
translated path is taken only where a key exists, which is what lets this
|
||||||
|
file be converted a context at a time instead of in one unreviewable
|
||||||
|
change. */
|
||||||
|
if (t) {
|
||||||
|
const translated = translatedIntro(contextId, facts, name, t);
|
||||||
|
if (translated) return translated;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: TITLES[contextId]?.(facts, name) ?? 'Owliver',
|
title: TITLES[contextId]?.(facts, name) ?? 'Owliver',
|
||||||
greeting: GREETINGS[contextId]?.(facts) ?? '',
|
greeting: GREETINGS[contextId]?.(facts) ?? '',
|
||||||
@@ -824,6 +835,54 @@ export function buildIntro(contextId, facts, userName) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The translated intro for a context, or null where there is not one yet.
|
||||||
|
*
|
||||||
|
* The greeting is NOT the same sentence with the words swapped. "Good morning"
|
||||||
|
* is one key per time of day rather than "Good " plus a translated noun,
|
||||||
|
* because Spanish says "Buenos días" and "Buenas tardes" — the adjective agrees
|
||||||
|
* with the noun, so a sentence assembled from parts is wrong in one of them.
|
||||||
|
* The same reasoning is why counts go through i18next's own plural forms rather
|
||||||
|
* than through `plural()`, which appends an English "s".
|
||||||
|
*/
|
||||||
|
function translatedIntro(contextId, facts, name, t) {
|
||||||
|
const part = timeOfDay(facts.today);
|
||||||
|
const title = name
|
||||||
|
? t(`assistant.greeting.${part}Named`, { name })
|
||||||
|
: t(`assistant.greeting.${part}`);
|
||||||
|
|
||||||
|
if (contextId === 'admin.controlCenter') {
|
||||||
|
/* The greeting picks ONE line by urgency, the same order the English map
|
||||||
|
uses — something unscored beats something merely recent, which beats
|
||||||
|
"steady". Rebuilt here rather than translated after the fact, because the
|
||||||
|
English version has already chosen its words and its plural by the time
|
||||||
|
it returns a string. */
|
||||||
|
const greeting = facts.unscoredProfiles?.length
|
||||||
|
? t('ccAssistant.unscored', { count: facts.unscoredProfiles.length })
|
||||||
|
: facts.activity24h?.length
|
||||||
|
? t('ccAssistant.events', { count: facts.activity24h.length })
|
||||||
|
: t('ccAssistant.steady', {
|
||||||
|
positions: t('assistant.controlCenter.positions', { count: facts.postings.length }),
|
||||||
|
applications: t('assistant.controlCenter.applications', { count: facts.total }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
greeting,
|
||||||
|
description: t('assistant.controlCenter.description', {
|
||||||
|
applications: t('assistant.controlCenter.applications', { count: facts.total }),
|
||||||
|
positions: t('assistant.controlCenter.positions', { count: facts.postings.length }),
|
||||||
|
}),
|
||||||
|
placeholders: [t('ccAssistant.placeholder')],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every other context still answers in English, deliberately: a half
|
||||||
|
translated intro that silently falls back is better than one assembled
|
||||||
|
from a key that does not exist. */
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Suggested prompts for the current page and data. */
|
/** Suggested prompts for the current page and data. */
|
||||||
export function buildPrompts(contextId, facts, workforce = null) {
|
export function buildPrompts(contextId, facts, workforce = null) {
|
||||||
return PROMPTS[contextId]?.(facts, workforce) ?? [];
|
return PROMPTS[contextId]?.(facts, workforce) ?? [];
|
||||||
@@ -15,7 +15,7 @@ import { PRIVILEGED_EVENTS, activitySignals } from '@/lib/activitySignals';
|
|||||||
|
|
||||||
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
|
export const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
|
||||||
|
|
||||||
export const plural = (n, word, irregular) =>
|
export const plural = (n, word, irregular?) =>
|
||||||
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
|
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
|
||||||
|
|
||||||
export const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
|
export const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
|
||||||
@@ -95,6 +95,14 @@ export function buildFacts({
|
|||||||
const transitions = funnel.slice(1).map((stage, i) => ({
|
const transitions = funnel.slice(1).map((stage, i) => ({
|
||||||
from: funnel[i].label,
|
from: funnel[i].label,
|
||||||
to: stage.label,
|
to: stage.label,
|
||||||
|
/* The same two stages by KEY. `from`/`to` are English labels and several
|
||||||
|
callers render them directly; the keys are what a translated surface
|
||||||
|
needs, because a label cannot be looked up in a catalogue — asking for
|
||||||
|
`stage.Interviewed` returns that string verbatim, which is how a raw key
|
||||||
|
reached the screen. Both are carried rather than one replaced, so no
|
||||||
|
existing reader changes behaviour. */
|
||||||
|
fromKey: funnel[i].key,
|
||||||
|
toKey: stage.key,
|
||||||
rate: pct(stage.count, funnel[i].count),
|
rate: pct(stage.count, funnel[i].count),
|
||||||
lost: Math.max(0, funnel[i].count - stage.count),
|
lost: Math.max(0, funnel[i].count - stage.count),
|
||||||
}));
|
}));
|
||||||
@@ -215,7 +223,7 @@ export function buildFacts({
|
|||||||
),
|
),
|
||||||
experienced: profiles.filter((p) => (p.experience_years || 0) >= 3),
|
experienced: profiles.filter((p) => (p.experience_years || 0) >= 3),
|
||||||
avgScore: avg(profiles.filter((p) => p.krow_score > 0).map((p) => p.krow_score)),
|
avgScore: avg(profiles.filter((p) => p.krow_score > 0).map((p) => p.krow_score)),
|
||||||
bands: Object.values(profiles.reduce((acc, p) => {
|
bands: Object.values(profiles.reduce<Record<string, { label: string; count: number }>>((acc, p) => {
|
||||||
const { label } = getScoreBand(p.krow_score || 0);
|
const { label } = getScoreBand(p.krow_score || 0);
|
||||||
acc[label] ||= { label, count: 0 };
|
acc[label] ||= { label, count: 0 };
|
||||||
acc[label].count += 1;
|
acc[label].count += 1;
|
||||||
@@ -241,7 +249,9 @@ export function buildFacts({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupHires = (key) => Object.values(hires.reduce((acc, h) => {
|
const groupHires = (key) => Object.values(hires.reduce<Record<string, {
|
||||||
|
name: string; count: number; scores: any[]; days: any[]; ratings: any[];
|
||||||
|
}>>((acc, h) => {
|
||||||
const name = h[key] || 'Unspecified';
|
const name = h[key] || 'Unspecified';
|
||||||
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
|
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
|
||||||
acc[name].count += 1;
|
acc[name].count += 1;
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
* capability: string | null, // capability id, or null for free text
|
* capability: string | null, // capability id, or null for free text
|
||||||
* question: string, // what the user typed, or the chip's prompt
|
* question: string, // what the user typed, or the chip's prompt
|
||||||
* facts: object, // dashboard fact sheet (see insights.js)
|
* facts: object, // dashboard fact sheet (see insights.js)
|
||||||
|
* language: string | null, // language tag to answer in (see language.js)
|
||||||
* signal: AbortSignal, // aborts an in-flight response
|
* signal: AbortSignal, // aborts an in-flight response
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
@@ -68,7 +69,7 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** The run itself. Public only so `stream` can wrap it; call `stream`. */
|
/** The run itself. Public only so `stream` can wrap it; call `stream`. */
|
||||||
async *run({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
|
async *run({ question, agent = null, confirmation = null, agentVersion = 0, language = null, signal }) {
|
||||||
/* No agent, no run. The panel resolves which agent covers the page before
|
/* No agent, no run. The panel resolves which agent covers the page before
|
||||||
calling; reaching here without one means the routing layer changed and
|
calling; reaching here without one means the routing layer changed and
|
||||||
this should say so rather than guess at an agent id. */
|
this should say so rather than guess at an agent id. */
|
||||||
@@ -98,6 +99,15 @@ export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
|||||||
stops an edit published mid-thread from silently changing which
|
stops an edit published mid-thread from silently changing which
|
||||||
agent is answering. */
|
agent is answering. */
|
||||||
agentVersion: agentVersion || undefined,
|
agentVersion: agentVersion || undefined,
|
||||||
|
/* The language to answer in. Omitted when it is the default, so an
|
||||||
|
English run sends exactly the body it sent before the selector
|
||||||
|
existed — and a deployment that does not know the field yet is
|
||||||
|
unaffected by the common case.
|
||||||
|
|
||||||
|
A tag, never a sentence. The server maps it onto a closed set and
|
||||||
|
an unrecognised one answers in English; the panel cannot write
|
||||||
|
prompt text from here, which is the point of sending a tag. */
|
||||||
|
language: language && language !== 'en' ? language : undefined,
|
||||||
}),
|
}),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
@@ -25,6 +25,7 @@ import { agentRequest } from '@/lib/agents/runtime';
|
|||||||
import { createAssistantProvider } from './provider';
|
import { createAssistantProvider } from './provider';
|
||||||
import { preferAgent, resolveIntent } from './routing';
|
import { preferAgent, resolveIntent } from './routing';
|
||||||
import { doc, text as textBlock, toSnapshots } from './blocks';
|
import { doc, text as textBlock, toSnapshots } from './blocks';
|
||||||
|
import { DEFAULT_LANGUAGE } from '@/lib/i18n/language';
|
||||||
|
|
||||||
/** One provider instance for the app's lifetime. */
|
/** One provider instance for the app's lifetime. */
|
||||||
const provider = createAssistantProvider();
|
const provider = createAssistantProvider();
|
||||||
@@ -199,14 +200,26 @@ const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/
|
|||||||
*
|
*
|
||||||
* Two rules, and the second is the one that matters. The suggestions are ranked
|
* 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
|
* 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
|
* is worth asking, it only decides what has already been said — and then what
|
||||||
* anything this thread has asked or already offered is removed.
|
* this thread has already covered is removed.
|
||||||
*
|
*
|
||||||
* Without that second rule the row repeats. A page carries a handful of intents
|
* 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
|
* 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.
|
* 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.
|
* ASKED AND OFFERED ARE NOT THE SAME THING, and treating them as one is what
|
||||||
|
* made the row appear exactly once per conversation. Every chip ever *shown*
|
||||||
|
* was being banned permanently, in a set that only grew; since the server keeps
|
||||||
|
* returning the top of the same small catalogue, the second turn's suggestions
|
||||||
|
* were all already in it, the filter emptied the list, and an empty list means
|
||||||
|
* no row. Every turn after the first was silent, whatever was asked.
|
||||||
|
*
|
||||||
|
* So the two are separated. A question this thread actually PUT is excluded for
|
||||||
|
* good — it has an answer on screen, and offering to repeat it is not a follow-
|
||||||
|
* up. A chip merely DISPLAYED and passed over is held back only from the turn
|
||||||
|
* directly after it, which is enough to stop the row redrawing verbatim under
|
||||||
|
* consecutive answers; beyond that it is offerable again, because a reader
|
||||||
|
* ignoring a suggestion is not the same as having covered it.
|
||||||
*
|
*
|
||||||
* There is NO fallback to the page's own ranking, and that is the correction a
|
* 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
|
* live run forced. Asking "Summarize hiring activity" matches nothing in the
|
||||||
@@ -220,18 +233,34 @@ const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/
|
|||||||
export async function nextSteps({ question, history, refresh }) {
|
export async function nextSteps({ question, history, refresh }) {
|
||||||
if (!refresh) return undefined;
|
if (!refresh) return undefined;
|
||||||
|
|
||||||
const used = new Set([asQuestion(question)]);
|
/* Permanent: what this thread has actually asked, including the question
|
||||||
|
being answered right now. */
|
||||||
|
const asked = new Set([asQuestion(question)]);
|
||||||
for (const message of history) {
|
for (const message of history) {
|
||||||
if (message.role === 'user') used.add(asQuestion(message.text));
|
if (message.role === 'user') asked.add(asQuestion(message.text));
|
||||||
for (const chip of message.followUp || []) used.add(asQuestion(chip.prompt || chip.label));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Transient: the row under the answer directly above this one, and only
|
||||||
|
that row. The immediately preceding assistant turn, whether or not it
|
||||||
|
carried chips — if it offered none there is nothing to hold back, and a
|
||||||
|
suggestion last seen further up the thread has had an answer between it
|
||||||
|
and now, so repeating it is not the verbatim redraw this guards against. */
|
||||||
|
const justOffered = new Set();
|
||||||
|
for (let i = history.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (history[i]?.role !== 'assistant') continue;
|
||||||
|
for (const chip of history[i].followUp || []) {
|
||||||
|
justOffered.add(asQuestion(chip.prompt || chip.label));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
const unused = (chips) => (chips || []).filter((chip) => {
|
const unused = (chips) => (chips || []).filter((chip) => {
|
||||||
const key = asQuestion(chip.prompt || chip.label);
|
const key = asQuestion(chip.prompt || chip.label);
|
||||||
if (!key || used.has(key)) return false;
|
if (!key || asked.has(key) || justOffered.has(key) || seen.has(key)) return false;
|
||||||
/* A list that repeats itself within one turn is the same defect at a
|
/* A list that repeats itself within one turn is the same defect at a
|
||||||
smaller scale. */
|
smaller scale. */
|
||||||
used.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -336,6 +365,11 @@ export function useConversation({
|
|||||||
/* The page's own name, recorded with an archived thread so History can say
|
/* The page's own name, recorded with an archived thread so History can say
|
||||||
where a conversation happened without resolving the context again. */
|
where a conversation happened without resolving the context again. */
|
||||||
pageLabel = '',
|
pageLabel = '',
|
||||||
|
/* The language the agent answers in — a tag from `language.js`, owned by the
|
||||||
|
header's selector. Read here only to put it on the request: nothing in this
|
||||||
|
hook branches on it, because translating is the model's job and not the
|
||||||
|
panel's. */
|
||||||
|
language = DEFAULT_LANGUAGE,
|
||||||
}) {
|
}) {
|
||||||
/**
|
/**
|
||||||
* One thread per agent per page.
|
* One thread per agent per page.
|
||||||
@@ -888,6 +922,9 @@ export function useConversation({
|
|||||||
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
|
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
|
||||||
owliverContext,
|
owliverContext,
|
||||||
agentVersion: pinnedVersionRef.current,
|
agentVersion: pinnedVersionRef.current,
|
||||||
|
/* The reader's choice, applied from the next answer onward. Answers
|
||||||
|
already on screen are left as they were written. */
|
||||||
|
language,
|
||||||
})) {
|
})) {
|
||||||
if (controller.signal.aborted) break;
|
if (controller.signal.aborted) break;
|
||||||
latest = snapshot;
|
latest = snapshot;
|
||||||
@@ -968,7 +1005,7 @@ export function useConversation({
|
|||||||
onScheduleInterview,
|
onScheduleInterview,
|
||||||
workforce, setFlow, disabledSkills,
|
workforce, setFlow, disabledSkills,
|
||||||
customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings,
|
customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings,
|
||||||
agent, agentCoversPage, agentSuggestion, owliverContext,
|
agent, agentCoversPage, agentSuggestion, owliverContext, language,
|
||||||
/* The layout session changes as a page's composition and the account's
|
/* 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
|
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
|
empty one from the first render — so a layout request falls through to
|
||||||
@@ -1018,6 +1055,9 @@ export function useConversation({
|
|||||||
owliverContext,
|
owliverContext,
|
||||||
confirmation: block.token,
|
confirmation: block.token,
|
||||||
agentVersion: pinnedVersionRef.current,
|
agentVersion: pinnedVersionRef.current,
|
||||||
|
/* The reader's choice, applied from the next answer onward. Answers
|
||||||
|
already on screen are left as they were written. */
|
||||||
|
language,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})) {
|
})) {
|
||||||
if (controller.signal.aborted) break;
|
if (controller.signal.aborted) break;
|
||||||
@@ -1037,7 +1077,7 @@ export function useConversation({
|
|||||||
setPending(null);
|
setPending(null);
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
}, [contextId, facts, agent, owliverContext, persist]);
|
}, [contextId, facts, agent, owliverContext, language, persist]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* States something in the thread without a question having been asked.
|
* States something in the thread without a question having been asked.
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from '@/components/ds/props';
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Animated Flowchart & Department Performance Component
|
* Animated Flowchart & Department Performance Component
|
||||||
@@ -104,7 +106,8 @@ function getDeptColor(index, avgScore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function DepartmentPerformance({ items = [], className = '' }) {
|
export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
|
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
|
||||||
const [activeDept, setActiveDept] = useState(null);
|
const [activeDept, setActiveDept] = useState(null);
|
||||||
|
|
||||||
@@ -134,10 +137,10 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-heading text-body font-semibold text-ink-1">
|
<h3 className="font-heading text-body font-semibold text-ink-1">
|
||||||
Department Performance & Hiring Flow
|
{t('departmentPerformance.departmentPerformanceHiringFlow')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-caption text-ink-3">
|
<p className="text-caption text-ink-3">
|
||||||
Interactive pipeline mapping AI score quality & hire velocity across departments
|
{t('departmentPerformance.interactivePipelineMappingAiScoreQuality')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +158,7 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Workflow className="h-3.5 w-3.5 text-blue-600" />
|
<Workflow className="h-3.5 w-3.5 text-blue-600" />
|
||||||
<span>Flowchart</span>
|
<span>{t('departmentPerformance.flowchart')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -168,7 +171,7 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TableIcon className="h-3.5 w-3.5 text-ink-3" />
|
<TableIcon className="h-3.5 w-3.5 text-ink-3" />
|
||||||
<span>Scorecard</span>
|
<span>{t('departmentPerformance.scorecard')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +183,7 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
<Trophy className="h-4 w-4" />
|
<Trophy className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Top Department</p>
|
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">{t('departmentPerformance.topDepartment')}</p>
|
||||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||||
{ranked[0]?.name || 'Chef'} <span className="text-blue-600 font-semibold">({ranked[0]?.avgScore || 97})</span>
|
{ranked[0]?.name || 'Chef'} <span className="text-blue-600 font-semibold">({ranked[0]?.avgScore || 97})</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -192,9 +195,9 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
<Building2 className="h-4 w-4" />
|
<Building2 className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Active Units</p>
|
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">{t('departmentPerformance.activeUnits')}</p>
|
||||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||||
{ranked.length} Departments
|
{ranked.length} {t('departmentPerformance.departments')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,7 +207,7 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Overall AI Index</p>
|
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">{t('departmentPerformance.overallAiIndex')}</p>
|
||||||
<p className="font-heading text-body-sm font-bold text-emerald-600 dark:text-emerald-400">
|
<p className="font-heading text-body-sm font-bold text-emerald-600 dark:text-emerald-400">
|
||||||
{overallAvgScore} / 100
|
{overallAvgScore} / 100
|
||||||
</p>
|
</p>
|
||||||
@@ -216,9 +219,9 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">Top Fill Velocity</p>
|
<p className="text-[10px] uppercase tracking-wider text-ink-4 font-semibold">{t('departmentPerformance.topFillVelocity')}</p>
|
||||||
<p className="font-heading text-body-sm font-bold text-ink-1">
|
<p className="font-heading text-body-sm font-bold text-ink-1">
|
||||||
{Math.min(...ranked.map((r) => r.avgTimeToHire || 2))}d Avg
|
{Math.min(...ranked.map((r) => r.avgTimeToHire || 2))}{t('departmentPerformance.dAvg')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,29 +257,29 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
<div>
|
<div>
|
||||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">
|
||||||
<span className="h-2 w-2 rounded-full bg-blue-600 animate-pulse" />
|
<span className="h-2 w-2 rounded-full bg-blue-600 animate-pulse" />
|
||||||
Pipeline Source
|
{t('departmentPerformance.pipelineSource')}
|
||||||
</span>
|
</span>
|
||||||
<h4 className="font-heading text-body-lg font-bold text-ink-1">
|
<h4 className="font-heading text-body-lg font-bold text-ink-1">
|
||||||
Talent Engine
|
{t('departmentPerformance.talentEngine')}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className="rounded-full bg-blue-100 dark:bg-blue-950 px-2.5 py-1 text-[10px] font-bold text-blue-700 dark:text-blue-400">
|
<span className="rounded-full bg-blue-100 dark:bg-blue-950 px-2.5 py-1 text-[10px] font-bold text-blue-700 dark:text-blue-400">
|
||||||
100% AI Verified Engine
|
{t('departmentPerformance.100AiVerifiedEngine')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-3 border-t border-border/60 pt-3 text-caption">
|
<div className="mt-3 grid grid-cols-2 gap-3 border-t border-border/60 pt-3 text-caption">
|
||||||
<div className="rounded-xl bg-surface-subtle p-2.5">
|
<div className="rounded-xl bg-surface-subtle p-2.5">
|
||||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Total Hires</p>
|
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">{t('departmentPerformance.totalHires')}</p>
|
||||||
<p className="font-heading text-body-lg font-bold tabular-nums text-ink-1 mt-0.5">
|
<p className="font-heading text-body-lg font-bold tabular-nums text-ink-1 mt-0.5">
|
||||||
{totalHires} <span className="text-caption font-normal text-ink-3">staff filled</span>
|
{totalHires} <span className="text-caption font-normal text-ink-3">{t('departmentPerformance.staffFilled')}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-surface-subtle p-2.5">
|
<div className="rounded-xl bg-surface-subtle p-2.5">
|
||||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Avg AI Score</p>
|
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">{t('departmentPerformance.avgAiScore')}</p>
|
||||||
<p className="font-heading text-body-lg font-bold tabular-nums text-blue-600 dark:text-blue-400 mt-0.5">
|
<p className="font-heading text-body-lg font-bold tabular-nums text-blue-600 dark:text-blue-400 mt-0.5">
|
||||||
{overallAvgScore} <span className="text-caption font-normal text-ink-3">/ 100</span>
|
{overallAvgScore} <span className="text-caption font-normal text-ink-3">/ 100</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -296,35 +299,35 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
|
||||||
Hiring Outcomes
|
{t('departmentPerformance.hiringOutcomes')}
|
||||||
</span>
|
</span>
|
||||||
<h4 className="font-heading text-body-lg font-bold text-ink-1">
|
<h4 className="font-heading text-body-lg font-bold text-ink-1">
|
||||||
Verified Quality Impact
|
{t('departmentPerformance.verifiedQualityImpact')}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className="rounded-full bg-emerald-100 dark:bg-emerald-950 px-2.5 py-1 text-[10px] font-bold text-emerald-700 dark:text-emerald-400">
|
<span className="rounded-full bg-emerald-100 dark:bg-emerald-950 px-2.5 py-1 text-[10px] font-bold text-emerald-700 dark:text-emerald-400">
|
||||||
100% Active Placement
|
{t('departmentPerformance.100ActivePlacement')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2 border-t border-border/60 pt-3 text-caption">
|
<div className="mt-3 grid grid-cols-3 gap-2 border-t border-border/60 pt-3 text-caption">
|
||||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Quality Tier</p>
|
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">{t('departmentPerformance.qualityTier')}</p>
|
||||||
<p className="font-semibold text-emerald-600 dark:text-emerald-400 mt-0.5 text-body-sm">
|
<p className="font-semibold text-emerald-600 dark:text-emerald-400 mt-0.5 text-body-sm">
|
||||||
95+ Top
|
{t('departmentPerformance.95Top')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Placement</p>
|
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">{t('departmentPerformance.placement')}</p>
|
||||||
<p className="font-semibold text-ink-1 mt-0.5 text-body-sm">
|
<p className="font-semibold text-ink-1 mt-0.5 text-body-sm">
|
||||||
100% Verified
|
{t('departmentPerformance.100Verified')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
<div className="rounded-xl bg-surface-subtle p-2 text-center">
|
||||||
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">Avg Rating</p>
|
<p className="text-[10px] text-ink-4 font-semibold uppercase tracking-wider">{t('departmentPerformance.avgRating')}</p>
|
||||||
<p className="font-semibold text-amber-600 dark:text-amber-400 mt-0.5 text-body-sm">
|
<p className="font-semibold text-amber-600 dark:text-amber-400 mt-0.5 text-body-sm">
|
||||||
4.9 / 5.0
|
4.9 / 5.0
|
||||||
</p>
|
</p>
|
||||||
@@ -337,9 +340,9 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h4 className="font-heading text-body-sm font-bold text-ink-1 uppercase tracking-wider text-[11px]">
|
<h4 className="font-heading text-body-sm font-bold text-ink-1 uppercase tracking-wider text-[11px]">
|
||||||
Department Performance Units ({ranked.length})
|
{t('departmentPerformance.departmentPerformanceUnits')}{ranked.length})
|
||||||
</h4>
|
</h4>
|
||||||
<span className="text-[10px] text-ink-4">Click any department card for detailed staff roster</span>
|
<span className="text-[10px] text-ink-4">{t('departmentPerformance.clickAnyDepartmentCardForDetailed')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -396,7 +399,7 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span className="flex items-center gap-0.5 text-emerald-600 dark:text-emerald-400 font-medium">
|
<span className="flex items-center gap-0.5 text-emerald-600 dark:text-emerald-400 font-medium">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
{dept.avgTimeToHire}d fill velocity
|
{dept.avgTimeToHire}{t('departmentPerformance.dFillVelocity')}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -459,11 +462,11 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
<table className="w-full min-w-[40rem] text-left text-body-sm">
|
<table className="w-full min-w-[40rem] text-left text-body-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface-subtle text-[10px] font-semibold uppercase tracking-wider text-ink-4">
|
<tr className="border-b border-border bg-surface-subtle text-[10px] font-semibold uppercase tracking-wider text-ink-4">
|
||||||
<th className="px-4 py-3">Rank</th>
|
<th className="px-4 py-3">{t('departmentPerformance.rank')}</th>
|
||||||
<th className="px-4 py-3">Department</th>
|
<th className="px-4 py-3">{t('departmentPerformance.department')}</th>
|
||||||
<th className="px-4 py-3">Roles Included</th>
|
<th className="px-4 py-3">{t('departmentPerformance.rolesIncluded')}</th>
|
||||||
<th className="px-4 py-3 text-right">Volume</th>
|
<th className="px-4 py-3 text-right">{t('departmentPerformance.volume')}</th>
|
||||||
<th className="px-4 py-3 text-right">Avg AI Score</th>
|
<th className="px-4 py-3 text-right">{t('departmentPerformance.avgAiScore')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
@@ -534,9 +537,9 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-heading text-title font-bold text-ink-1">
|
<h4 className="font-heading text-title font-bold text-ink-1">
|
||||||
{activeDept.name} Department
|
{activeDept.name} {t('departmentPerformance.department')}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-caption text-ink-3">Hiring pipeline & outcome analytics</p>
|
<p className="text-caption text-ink-3">{t('departmentPerformance.hiringPipelineOutcomeAnalytics')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -551,19 +554,19 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
{/* Quick Key Specs */}
|
{/* Quick Key Specs */}
|
||||||
<div className="grid grid-cols-3 gap-3 rounded-xl bg-surface-subtle p-3 text-center border border-border/50">
|
<div className="grid grid-cols-3 gap-3 rounded-xl bg-surface-subtle p-3 text-center border border-border/50">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-caption text-ink-4">Avg AI Score</p>
|
<p className="text-caption text-ink-4">{t('departmentPerformance.avgAiScore')}</p>
|
||||||
<p className="font-heading text-title font-bold text-blue-600 dark:text-blue-400">
|
<p className="font-heading text-title font-bold text-blue-600 dark:text-blue-400">
|
||||||
{activeDept.avgScore || '—'}
|
{activeDept.avgScore || '—'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-caption text-ink-4">Total Hires</p>
|
<p className="text-caption text-ink-4">{t('departmentPerformance.totalHires')}</p>
|
||||||
<p className="font-heading text-title font-bold text-ink-1">
|
<p className="font-heading text-title font-bold text-ink-1">
|
||||||
{activeDept.count}
|
{activeDept.count}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-caption text-ink-4">Avg Velocity</p>
|
<p className="text-caption text-ink-4">{t('departmentPerformance.avgVelocity')}</p>
|
||||||
<p className="font-heading text-title font-bold text-emerald-600 dark:text-emerald-400">
|
<p className="font-heading text-title font-bold text-emerald-600 dark:text-emerald-400">
|
||||||
{activeDept.avgTimeToHire ? `${activeDept.avgTimeToHire}d` : '3d'}
|
{activeDept.avgTimeToHire ? `${activeDept.avgTimeToHire}d` : '3d'}
|
||||||
</p>
|
</p>
|
||||||
@@ -574,7 +577,7 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
{activeDept.hiresList && activeDept.hiresList.length > 0 && (
|
{activeDept.hiresList && activeDept.hiresList.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h5 className="text-caption font-semibold uppercase tracking-wider text-ink-4">
|
<h5 className="text-caption font-semibold uppercase tracking-wider text-ink-4">
|
||||||
Hired Staff Members
|
{t('departmentPerformance.hiredStaffMembers')}
|
||||||
</h5>
|
</h5>
|
||||||
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
|
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||||
{activeDept.hiresList.map((h, i) => (
|
{activeDept.hiresList.map((h, i) => (
|
||||||
@@ -606,7 +609,7 @@ export function DepartmentPerformance({ items = [], className = '' }) {
|
|||||||
onClick={() => setActiveDept(null)}
|
onClick={() => setActiveDept(null)}
|
||||||
className="rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 hover:bg-blue-700 transition-colors"
|
className="rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
Close Inspection
|
{t('departmentPerformance.closeInspection')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from '@/components/ds/props';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BarChart } from '@mui/x-charts';
|
import { BarChart } from '@mui/x-charts';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
@@ -19,7 +20,7 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
|||||||
* component derives no figures of its own.
|
* component derives no figures of its own.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }) {
|
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }: DsProps) {
|
||||||
if (!stages.length) return null;
|
if (!stages.length) return null;
|
||||||
|
|
||||||
const total = stages[0]?.count || 1;
|
const total = stages[0]?.count || 1;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from '@/components/ds/props';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { LineChart } from '@mui/x-charts';
|
import { LineChart } from '@mui/x-charts';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -15,7 +16,7 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
|
|||||||
* `points: [{ label, hires, cumulative }]` — supplied by the page.
|
* `points: [{ label, hires, cumulative }]` — supplied by the page.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function HiringTrendChart({ points = [], emptyState, className = '' }) {
|
export function HiringTrendChart({ points = [], emptyState, className = '' }: DsProps) {
|
||||||
const enough = points.length >= 2;
|
const enough = points.length >= 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Avatar } from '@/components/ds/Avatar';
|
import { Avatar } from '@/components/ds/Avatar';
|
||||||
@@ -30,7 +31,7 @@ export function ActivityCard({
|
|||||||
variant = 'row',
|
variant = 'row',
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const iconTones = {
|
const iconTones = {
|
||||||
soft: 'bg-krow-blue-tint text-krow-blue',
|
soft: 'bg-krow-blue-tint text-krow-blue',
|
||||||
brand: 'bg-krow-blue text-white',
|
brand: 'bg-krow-blue text-white',
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
|
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
|
||||||
import { cva } from 'class-variance-authority';
|
import { cva } from 'class-variance-authority';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const alertVariants = cva('flex items-start gap-3 rounded-xl border p-4 animate-slide-down', {
|
const alertVariants = cva('flex items-start gap-3 rounded-xl border p-4 animate-slide-down', {
|
||||||
variants: {
|
variants: {
|
||||||
@@ -40,7 +42,8 @@ const ICON_COLORS = {
|
|||||||
* that persists is noise.
|
* that persists is noise.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) {
|
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }: DsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const Icon = icon || ICONS[tone];
|
const Icon = icon || ICONS[tone];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,7 +64,7 @@ export function Alert({ tone = 'info', title, children, icon, onDismiss, action,
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
aria-label="Dismiss"
|
aria-label={t('alert.dismiss')}
|
||||||
className="grid place-items-center w-6 h-6 rounded-md text-ink-4 hover:text-ink-1 hover:bg-black/5 transition-colors shrink-0 focus-ring-inset"
|
className="grid place-items-center w-6 h-6 rounded-md text-ink-4 hover:text-ink-1 hover:bg-black/5 transition-colors shrink-0 focus-ring-inset"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
<X className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cva } from 'class-variance-authority';
|
import { cva } from 'class-variance-authority';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -41,8 +42,7 @@ export function initialsFrom(name = '') {
|
|||||||
* first-class state rather than a fallback afterthought. A failed image load
|
* first-class state rather than a fallback afterthought. A failed image load
|
||||||
* degrades to initials instead of a broken-image icon.
|
* degrades to initials instead of a broken-image icon.
|
||||||
*/
|
*/
|
||||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
export const Avatar: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
|
||||||
export const Avatar = React.forwardRef(
|
|
||||||
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
|
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
|
||||||
const [failed, setFailed] = React.useState(false);
|
const [failed, setFailed] = React.useState(false);
|
||||||
const showImage = src && !failed;
|
const showImage = src && !failed;
|
||||||
@@ -91,7 +91,7 @@ Avatar.displayName = 'Avatar';
|
|||||||
* people in a tight row (applicants on a position, endorsers on a skill).
|
* people in a tight row (applicants on a position, endorsers on a skill).
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }) {
|
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }: DsProps) {
|
||||||
const visible = people.slice(0, max);
|
const visible = people.slice(0, max);
|
||||||
const overflow = people.length - visible.length;
|
const overflow = people.length - visible.length;
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ResponsiveContainer } from 'recharts';
|
import { ResponsiveContainer } from 'recharts';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -6,6 +7,7 @@ import { SectionHeader } from '@/components/ds/SectionHeader';
|
|||||||
import { EmptyState } from '@/components/ds/EmptyState';
|
import { EmptyState } from '@/components/ds/EmptyState';
|
||||||
import { SkeletonChart } from '@/components/ds/Skeleton';
|
import { SkeletonChart } from '@/components/ds/Skeleton';
|
||||||
import { BarChart3 } from 'lucide-react';
|
import { BarChart3 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chart palette. Recharts needs literal colours, not Tailwind classes, so
|
* The chart palette. Recharts needs literal colours, not Tailwind classes, so
|
||||||
@@ -38,7 +40,7 @@ export const AXIS_PROPS = {
|
|||||||
* white box that ignores the token set.
|
* white box that ignores the token set.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) {
|
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }: DsProps) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,7 +73,7 @@ export function ChartTooltip({ active, payload, label, valueFormatter, labelForm
|
|||||||
* A legend that reads as part of the card rather than as chart furniture.
|
* A legend that reads as part of the card rather than as chart furniture.
|
||||||
* Items: `{ label, color, value? }`.
|
* Items: `{ label, color, value? }`.
|
||||||
*/
|
*/
|
||||||
export function ChartLegend({ items = [], className }) {
|
export function ChartLegend({ items = [], className }: DsProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2', className)}>
|
<div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2', className)}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
@@ -118,7 +120,8 @@ export function ChartContainer({
|
|||||||
raw = false,
|
raw = false,
|
||||||
footer,
|
footer,
|
||||||
className,
|
className,
|
||||||
}) {
|
}: DsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
if (loading) return <SkeletonChart className={className} height={height} />;
|
if (loading) return <SkeletonChart className={className} height={height} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -158,7 +161,7 @@ export function ChartContainer({
|
|||||||
|
|
||||||
<div className={cn('flex-1 min-w-0', !legend && 'mt-4')} style={{ minHeight: height }}>
|
<div className={cn('flex-1 min-w-0', !legend && 'mt-4')} style={{ minHeight: height }}>
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<EmptyState icon={BarChart3} variant="filtered" title="Nothing to chart" description={emptyMessage} size="sm" />
|
<EmptyState icon={BarChart3} variant="filtered" title={t('chartContainer.nothingToChart')} description={emptyMessage} size="sm" />
|
||||||
) : raw ? (
|
) : raw ? (
|
||||||
children
|
children
|
||||||
) : (
|
) : (
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react';
|
import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -8,6 +9,7 @@ import { LoadingOverlay } from '@/components/ds/LoadingState';
|
|||||||
import { Pagination } from '@/components/ds/Pagination';
|
import { Pagination } from '@/components/ds/Pagination';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { usePreferences } from '@/lib/krowHooks';
|
import { usePreferences } from '@/lib/krowHooks';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DataTable — the app's one table.
|
* DataTable — the app's one table.
|
||||||
@@ -60,7 +62,8 @@ export function DataTable({
|
|||||||
className,
|
className,
|
||||||
rowClassName,
|
rowClassName,
|
||||||
stickyHeader = false,
|
stickyHeader = false,
|
||||||
}) {
|
}: DsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
/* ── Sorting ─────────────────────────────────────────────────────────── */
|
/* ── Sorting ─────────────────────────────────────────────────────────── */
|
||||||
/* Row height follows the account's density preference. Only the vertical
|
/* Row height follows the account's density preference. Only the vertical
|
||||||
padding changes — same columns, same type, same behaviour. */
|
padding changes — same columns, same type, same behaviour. */
|
||||||
@@ -204,7 +207,7 @@ export function DataTable({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false}
|
checked={allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false}
|
||||||
onCheckedChange={toggleAll}
|
onCheckedChange={toggleAll}
|
||||||
aria-label="Select all rows on this page"
|
aria-label={t('dataTable.selectAllRowsOnThisPage')}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
@@ -41,7 +42,7 @@ export function Drawer({
|
|||||||
className,
|
className,
|
||||||
/** Hides the header for a fully custom panel (the mobile nav does this). */
|
/** Hides the header for a fully custom panel (the mobile nav does this). */
|
||||||
bare = false,
|
bare = false,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const isVertical = side === 'top' || side === 'bottom';
|
const isVertical = side === 'top' || side === 'bottom';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -25,7 +26,7 @@ export function EmptyState({
|
|||||||
variant = 'empty',
|
variant = 'empty',
|
||||||
size = 'default',
|
size = 'default',
|
||||||
className,
|
className,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const renderAction = (spec, buttonVariant) => {
|
const renderAction = (spec, buttonVariant) => {
|
||||||
if (!spec) return null;
|
if (!spec) return null;
|
||||||
if (React.isValidElement(spec)) return spec;
|
if (React.isValidElement(spec)) return spec;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ export function Field({
|
|||||||
inline = false,
|
inline = false,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const reactId = React.useId();
|
const reactId = React.useId();
|
||||||
const id = htmlFor || reactId;
|
const id = htmlFor || reactId;
|
||||||
const hintId = hint ? `${id}-hint` : undefined;
|
const hintId = hint ? `${id}-hint` : undefined;
|
||||||
@@ -27,11 +28,15 @@ export function Field({
|
|||||||
const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
|
const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
|
||||||
|
|
||||||
// Only clone when the child is a single element that can accept the wiring.
|
// Only clone when the child is a single element that can accept the wiring.
|
||||||
|
/* `isValidElement` narrows to `ReactElement<unknown>`, whose `props` is
|
||||||
|
`unknown` — so reading `children.props.id` to preserve a caller's own id
|
||||||
|
does not compile. The cast says what the guard has already established and
|
||||||
|
what the runtime relies on: this is an element with props. */
|
||||||
const control = React.isValidElement(children)
|
const control = React.isValidElement(children)
|
||||||
? React.cloneElement(children, {
|
? React.cloneElement(children as React.ReactElement<Record<string, any>>, {
|
||||||
id: children.props.id || id,
|
id: (children.props as Record<string, any>).id || id,
|
||||||
'aria-describedby': children.props['aria-describedby'] || describedBy,
|
'aria-describedby': (children.props as Record<string, any>)['aria-describedby'] || describedBy,
|
||||||
'aria-invalid': error ? true : children.props['aria-invalid'],
|
'aria-invalid': error ? true : (children.props as Record<string, any>)['aria-invalid'],
|
||||||
})
|
})
|
||||||
: children;
|
: children;
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { SlidersHorizontal, X } from 'lucide-react';
|
import { SlidersHorizontal, X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -6,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { SearchInput } from '@/components/ds/SearchInput';
|
import { SearchInput } from '@/components/ds/SearchInput';
|
||||||
import { MultiSelect } from '@/components/ds/MultiSelect';
|
import { MultiSelect } from '@/components/ds/MultiSelect';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FilterBar — the search + filters row above a list, table or grid.
|
* FilterBar — the search + filters row above a list, table or grid.
|
||||||
@@ -37,7 +39,8 @@ export function FilterBar({
|
|||||||
/** Value that means "no filter" for select-type filters. */
|
/** Value that means "no filter" for select-type filters. */
|
||||||
allValue = 'all',
|
allValue = 'all',
|
||||||
className = '',
|
className = '',
|
||||||
}) {
|
}: DsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [expanded, setExpanded] = React.useState(false);
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
|
|
||||||
const isActive = (filter) => {
|
const isActive = (filter) => {
|
||||||
@@ -95,7 +98,7 @@ export function FilterBar({
|
|||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<SlidersHorizontal aria-hidden="true" />
|
<SlidersHorizontal aria-hidden="true" />
|
||||||
Filters
|
{t('filterBar.filters')}
|
||||||
</span>
|
</span>
|
||||||
{activeFilters.length > 0 && (
|
{activeFilters.length > 0 && (
|
||||||
<Badge variant="default" size="sm">{activeFilters.length}</Badge>
|
<Badge variant="default" size="sm">{activeFilters.length}</Badge>
|
||||||
@@ -180,7 +183,7 @@ export function FilterBar({
|
|||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
className="text-caption font-medium text-ink-3 hover:text-ink-1 hover:underline transition-colors focus-ring rounded-sm"
|
className="text-caption font-medium text-ink-3 hover:text-ink-1 hover:underline transition-colors focus-ring rounded-sm"
|
||||||
>
|
>
|
||||||
Clear all
|
{t('filterBar.clearAll')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -9,8 +9,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
|||||||
* `label`: an icon-only control with no accessible name is a bug, so the API
|
* `label`: an icon-only control with no accessible name is a bug, so the API
|
||||||
* makes it impossible to omit. The label doubles as the tooltip.
|
* makes it impossible to omit. The label doubles as the tooltip.
|
||||||
*/
|
*/
|
||||||
/** @type {React.ForwardRefExoticComponent<any>} */
|
export const IconButton: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
|
||||||
export const IconButton = React.forwardRef(
|
|
||||||
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
|
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
|
||||||
const sizeMap = {
|
const sizeMap = {
|
||||||
xs: 'icon-xs',
|
xs: 'icon-xs',
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { AlertTriangle, ChevronRight, Info, ShieldAlert, TrendingDown } from 'lucide-react';
|
import { AlertTriangle, ChevronRight, Info, ShieldAlert, TrendingDown } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -58,7 +59,7 @@ export function InsightRow({
|
|||||||
onClick = null,
|
onClick = null,
|
||||||
actionLabel = 'Review',
|
actionLabel = 'Review',
|
||||||
className = '',
|
className = '',
|
||||||
}) {
|
}: DsProps) {
|
||||||
const s = SEVERITY[severity] || SEVERITY.info;
|
const s = SEVERITY[severity] || SEVERITY.info;
|
||||||
const Comp = onClick ? 'button' : 'div';
|
const Comp = onClick ? 'button' : 'div';
|
||||||
|
|
||||||
@@ -107,6 +108,6 @@ export function InsightRow({
|
|||||||
|
|
||||||
/** The list container — dividers between rows, nothing else. */
|
/** The list container — dividers between rows, nothing else. */
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function InsightList({ children, className = '' }) {
|
export function InsightList({ children, className = '' }: DsProps) {
|
||||||
return <div className={cn('divide-y divide-border', className)}>{children}</div>;
|
return <div className={cn('divide-y divide-border', className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
||||||
@@ -32,7 +33,7 @@ export function KpiCard({
|
|||||||
loading = false,
|
loading = false,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const t = TONES[tone] || TONES.brand;
|
const t = TONES[tone] || TONES.brand;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -112,7 +113,7 @@ export function MetricCard({
|
|||||||
loading = false,
|
loading = false,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const t = TONES[tone] || TONES.neutral;
|
const t = TONES[tone] || TONES.neutral;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ const GAPS = { sm: 'gap-3', default: 'gap-4', lg: 'gap-6' };
|
|||||||
* chosen to avoid orphan cards (6 → 2/3/6 rather than 1/3/6).
|
* chosen to avoid orphan cards (6 → 2/3/6 rather than 1/3/6).
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function Grid({ cols = 3, gap = 'default', className, children, ...props }) {
|
export function Grid({ cols = 3, gap = 'default', className, children, ...props }: DsProps) {
|
||||||
const columns = {
|
const columns = {
|
||||||
1: 'grid-cols-1',
|
1: 'grid-cols-1',
|
||||||
2: 'grid-cols-1 sm:grid-cols-2',
|
2: 'grid-cols-1 sm:grid-cols-2',
|
||||||
@@ -38,7 +39,7 @@ export function Grid({ cols = 3, gap = 'default', className, children, ...props
|
|||||||
|
|
||||||
/** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */
|
/** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function Stack({ space = 'default', className, children, ...props }) {
|
export function Stack({ space = 'default', className, children, ...props }: DsProps) {
|
||||||
const spacing = {
|
const spacing = {
|
||||||
xs: 'space-y-2',
|
xs: 'space-y-2',
|
||||||
sm: 'space-y-3',
|
sm: 'space-y-3',
|
||||||
@@ -59,7 +60,7 @@ export function Stack({ space = 'default', className, children, ...props }) {
|
|||||||
* with its content at consistent spacing.
|
* with its content at consistent spacing.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) {
|
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }: DsProps) {
|
||||||
return (
|
return (
|
||||||
<section className={cn('space-y-3', className)}>
|
<section className={cn('space-y-3', className)}>
|
||||||
{title && (
|
{title && (
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -6,7 +7,7 @@ import { cn } from '@/lib/utils';
|
|||||||
* Spinner — the only spinner in the system.
|
* Spinner — the only spinner in the system.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function Spinner({ size = 'default', className = '', label = 'Loading' }) {
|
export function Spinner({ size = 'default', className = '', label = 'Loading' }: DsProps) {
|
||||||
const sizes = { xs: 'w-3.5 h-3.5', sm: 'w-4 h-4', default: 'w-6 h-6', lg: 'w-8 h-8' };
|
const sizes = { xs: 'w-3.5 h-3.5', sm: 'w-4 h-4', default: 'w-6 h-6', lg: 'w-8 h-8' };
|
||||||
return (
|
return (
|
||||||
<Loader2
|
<Loader2
|
||||||
@@ -25,7 +26,7 @@ export function Spinner({ size = 'default', className = '', label = 'Loading' })
|
|||||||
* final layout prevents the content jump a spinner always causes.
|
* final layout prevents the content jump a spinner always causes.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function LoadingState({ message = '', size = 'default', className = '' }) {
|
export function LoadingState({ message = '', size = 'default', className = '' }: DsProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -45,7 +46,7 @@ export function LoadingState({ message = '', size = 'default', className = '' })
|
|||||||
* card headers, or beside a control that is refreshing.
|
* card headers, or beside a control that is refreshing.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function InlineLoading({ message = 'Loading…', className = '' }) {
|
export function InlineLoading({ message = 'Loading…', className = '' }: DsProps) {
|
||||||
return (
|
return (
|
||||||
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
|
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
@@ -59,7 +60,7 @@ export function InlineLoading({ message = 'Loading…', className = '' }) {
|
|||||||
* height stable so the page does not jump. Used by DataTable when refetching.
|
* height stable so the page does not jump. Used by DataTable when refetching.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function LoadingOverlay({ show = false, message = '', className = '' }) {
|
export function LoadingOverlay({ show = false, message = '', className = '' }: DsProps) {
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -58,7 +59,7 @@ function Delta({ delta, label, invert }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function MetricStrip({ items = [], columns, className, loading = false }) {
|
export function MetricStrip({ items = [], columns, className, loading = false }: DsProps) {
|
||||||
const count = columns ?? Math.min(items.length, 6);
|
const count = columns ?? Math.min(items.length, 6);
|
||||||
|
|
||||||
const gridCols = {
|
const gridCols = {
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
@@ -39,7 +40,7 @@ export function Modal({
|
|||||||
className,
|
className,
|
||||||
/** Blocks closing — use while a submit is in flight. */
|
/** Blocks closing — use while a submit is in flight. */
|
||||||
busy = false,
|
busy = false,
|
||||||
}) {
|
}: DsProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
|
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
@@ -100,7 +101,7 @@ export function ConfirmModal({
|
|||||||
tone = 'destructive',
|
tone = 'destructive',
|
||||||
icon = null,
|
icon = null,
|
||||||
busy = false,
|
busy = false,
|
||||||
}) {
|
}: DsProps) {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Check, ChevronDown, X } from 'lucide-react';
|
import { Check, ChevronDown, X } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { SearchInput } from '@/components/ds/SearchInput';
|
import { SearchInput } from '@/components/ds/SearchInput';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MultiSelect — pick several values from a list.
|
* MultiSelect — pick several values from a list.
|
||||||
@@ -25,7 +27,8 @@ export function MultiSelect({
|
|||||||
className = '',
|
className = '',
|
||||||
align = 'start',
|
align = 'start',
|
||||||
emptyMessage = 'No options',
|
emptyMessage = 'No options',
|
||||||
}) {
|
}: DsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
|
|
||||||
@@ -111,7 +114,7 @@ export function MultiSelect({
|
|||||||
<SearchInput
|
<SearchInput
|
||||||
value={query}
|
value={query}
|
||||||
onChange={setQuery}
|
onChange={setQuery}
|
||||||
placeholder="Filter options"
|
placeholder={t('multiSelect.filterOptions')}
|
||||||
size="sm"
|
size="sm"
|
||||||
debounce={0}
|
debounce={0}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -162,7 +165,7 @@ export function MultiSelect({
|
|||||||
onClick={() => onChange?.([])}
|
onClick={() => onChange?.([])}
|
||||||
className="w-full rounded-md px-2 py-1.5 text-body-sm text-ink-3 hover:bg-surface-sunken hover:text-ink-1 transition-colors focus-ring-inset"
|
className="w-full rounded-md px-2 py-1.5 text-body-sm text-ink-3 hover:bg-surface-sunken hover:text-ink-1 transition-colors focus-ring-inset"
|
||||||
>
|
>
|
||||||
Clear selection
|
{t('multiSelect.clearSelection')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -22,7 +23,7 @@ export function PageHeader({
|
|||||||
size = 'title',
|
size = 'title',
|
||||||
animate = true,
|
animate = true,
|
||||||
className,
|
className,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const Wrapper = animate ? motion.div : 'div';
|
const Wrapper = animate ? motion.div : 'div';
|
||||||
const motionProps = animate
|
const motionProps = animate
|
||||||
? {
|
? {
|
||||||
@@ -68,7 +69,7 @@ export function PageHeader({
|
|||||||
* A dashboard-style greeting header where part of the title is emphasized —
|
* A dashboard-style greeting header where part of the title is emphasized —
|
||||||
* "Good day. Let's hire smarter."
|
* "Good day. Let's hire smarter."
|
||||||
*/
|
*/
|
||||||
export function HeroHeader({ lead, highlight, subtitle, actions, className }) {
|
export function HeroHeader({ lead, highlight, subtitle, actions, className }: DsProps) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -12 }}
|
initial={{ opacity: 0, y: -12 }}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a compact page list with ellipses: 1 … 4 5 6 … 20.
|
* Builds a compact page list with ellipses: 1 … 4 5 6 … 20.
|
||||||
@@ -24,7 +26,10 @@ export function buildPageRange(current, total, siblings = 1) {
|
|||||||
const showLeftEllipsis = left > 2;
|
const showLeftEllipsis = left > 2;
|
||||||
const showRightEllipsis = right < total - 1;
|
const showRightEllipsis = right < total - 1;
|
||||||
|
|
||||||
const pages = [1];
|
/* Page numbers with '…' markers between them — a real mixed array, which is
|
||||||
|
why the renderer tests `entry === '…'` before treating it as a number.
|
||||||
|
Inference took the seed `[1]` as `number[]` and rejected the markers. */
|
||||||
|
const pages: (number | string)[] = [1];
|
||||||
if (showLeftEllipsis) pages.push('…');
|
if (showLeftEllipsis) pages.push('…');
|
||||||
for (let p = Math.max(2, left); p <= Math.min(total - 1, right); p += 1) pages.push(p);
|
for (let p = Math.max(2, left); p <= Math.min(total - 1, right); p += 1) pages.push(p);
|
||||||
if (showRightEllipsis) pages.push('…');
|
if (showRightEllipsis) pages.push('…');
|
||||||
@@ -47,7 +52,8 @@ export function Pagination({
|
|||||||
onPageSizeChange,
|
onPageSizeChange,
|
||||||
pageSizeOptions = [10, 25, 50, 100],
|
pageSizeOptions = [10, 25, 50, 100],
|
||||||
className,
|
className,
|
||||||
}) {
|
}: DsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
if (pageCount <= 1 && !onPageSizeChange) return null;
|
if (pageCount <= 1 && !onPageSizeChange) return null;
|
||||||
|
|
||||||
const from = totalItems != null && pageSize ? (page - 1) * pageSize + 1 : null;
|
const from = totalItems != null && pageSize ? (page - 1) * pageSize + 1 : null;
|
||||||
@@ -56,20 +62,20 @@ export function Pagination({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label="Pagination"
|
aria-label={t('pagination.pagination')}
|
||||||
className={cn('flex flex-wrap items-center justify-between gap-3', className)}
|
className={cn('flex flex-wrap items-center justify-between gap-3', className)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{from != null && (
|
{from != null && (
|
||||||
<p className="text-caption text-ink-3 tabular-nums">
|
<p className="text-caption text-ink-3 tabular-nums">
|
||||||
Showing <span className="font-medium text-ink-2">{from}–{to}</span> of{' '}
|
{t('pagination.showing')} <span className="font-medium text-ink-2">{from}–{to}</span> of{' '}
|
||||||
<span className="font-medium text-ink-2">{totalItems}</span>
|
<span className="font-medium text-ink-2">{totalItems}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onPageSizeChange && (
|
{onPageSizeChange && (
|
||||||
<label className="flex items-center gap-1.5 text-caption text-ink-3">
|
<label className="flex items-center gap-1.5 text-caption text-ink-3">
|
||||||
<span className="hidden sm:inline">Rows</span>
|
<span className="hidden sm:inline">{t('pagination.rows')}</span>
|
||||||
<select
|
<select
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
@@ -89,7 +95,7 @@ export function Pagination({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
shape="rounded"
|
shape="rounded"
|
||||||
aria-label="Previous page"
|
aria-label={t('pagination.previousPage')}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
onClick={() => onPageChange?.(page - 1)}
|
onClick={() => onPageChange?.(page - 1)}
|
||||||
>
|
>
|
||||||
@@ -121,7 +127,7 @@ export function Pagination({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
shape="rounded"
|
shape="rounded"
|
||||||
aria-label="Next page"
|
aria-label={t('pagination.nextPage')}
|
||||||
disabled={page >= pageCount}
|
disabled={page >= pageCount}
|
||||||
onClick={() => onPageChange?.(page + 1)}
|
onClick={() => onPageChange?.(page + 1)}
|
||||||
>
|
>
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import type { DsProps } from './props';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const clamp = (n) => Math.max(0, Math.min(100, Number(n) || 0));
|
const clamp = (n) => Math.max(0, Math.min(100, Number(n) || 0));
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ export function ProgressBar({
|
|||||||
valueSuffix = '',
|
valueSuffix = '',
|
||||||
className,
|
className,
|
||||||
trackClassName,
|
trackClassName,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const pct = clamp((Number(value) / max) * 100);
|
const pct = clamp((Number(value) / max) * 100);
|
||||||
const resolvedTone = tone === 'score' ? toneForScore(pct) : tone;
|
const resolvedTone = tone === 'score' ? toneForScore(pct) : tone;
|
||||||
|
|
||||||
@@ -110,7 +112,7 @@ export function ProgressRing({
|
|||||||
/** Small text below the label. */
|
/** Small text below the label. */
|
||||||
footnote,
|
footnote,
|
||||||
className,
|
className,
|
||||||
}) {
|
}: DsProps) {
|
||||||
const radius = (size - strokeWidth) / 2;
|
const radius = (size - strokeWidth) / 2;
|
||||||
const circumference = 2 * Math.PI * radius;
|
const circumference = 2 * Math.PI * radius;
|
||||||
const ratio = Math.max(0, Math.min(1, (Number(value) - min) / (max - min)));
|
const ratio = Math.max(0, Math.min(1, (Number(value) - min) / (max - min)));
|
||||||
@@ -178,7 +180,7 @@ export function ProgressRing({
|
|||||||
* at a glance without relying on colour.
|
* at a glance without relying on colour.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function ProgressSteps({ steps = [], current = 0, className = '', onStepClick = null }) {
|
export function ProgressSteps({ steps = [], current = 0, className = '', onStepClick = null }: DsProps) {
|
||||||
const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
|
const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -243,13 +245,14 @@ export function ProgressSteps({ steps = [], current = 0, className = '', onStepC
|
|||||||
* dimensions are listed with a weight and a value.
|
* dimensions are listed with a weight and a value.
|
||||||
*/
|
*/
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function ScoreMeter({ label, value, weight, tone = 'score', className }) {
|
export function ScoreMeter({ label, value, weight, tone = 'score', className }: DsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-1.5', className)}>
|
<div className={cn('space-y-1.5', className)}>
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className="text-body-sm text-ink-2 truncate">{label}</span>
|
<span className="text-body-sm text-ink-2 truncate">{label}</span>
|
||||||
<span className="flex items-baseline gap-1.5 shrink-0">
|
<span className="flex items-baseline gap-1.5 shrink-0">
|
||||||
{weight != null && <span className="text-[10px] text-ink-4">{weight}% weight</span>}
|
{weight != null && <span className="text-[10px] text-ink-4">{weight}{t('progress.weight')}</span>}
|
||||||
<span className="text-body-sm font-semibold text-ink-1 tabular-nums">{value}</span>
|
<span className="text-body-sm font-semibold text-ink-1 tabular-nums">{value}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user