42 Commits

Author SHA1 Message Date
1ef0c6fc8a layout change ui fix
Some checks failed
CI / check (push) Failing after 5m6s
2026-09-29 16:59:13 +05:30
e676d259b2 docs: finalize TypeScript migration documentation
Some checks failed
CI / check (push) Failing after 5m5s
The migration is done and the documentation had not caught up. Two files,
no source changes.

README.md. Twenty-seven references still named modules by their old
extension — `main.jsx`, `krowHooks.js`, `AuthContext.jsx` and the rest.
Every one was checked to resolve under its new extension before being
touched. `store.js` is deliberately NOT among them: that file does not
exist under any extension, having been deleted when the transport moved
to HTTP, so "correcting" it to `.ts` would have replaced a visibly stale
reference with a plausible-looking false one. It stays as it is, with the
rest of that architecture section, for a separate pass.

Also: the assertion count 1691 -> 1693, since recapturing the baselines
replaced one check with three; and the Owliver skill count 18 -> 19,
which had been wrong since `create-employee-role` was added.

The "Known-failing checks" section is now "Check status", and the
rewrite is the part worth reading. It claimed two failures that no longer
exist, and my first attempt at replacing it merged two unrelated
histories into one sentence. They are now separate, because they are:

  - The 834/835 suite failure was `the seeded overtime climb is found`.
    It asserted against the live calendar — the oldest week in the window
    thinned as the week wore on and inflated the baseline every later
    week was compared against — so the climb was reported Sunday through
    Thursday and vanished on Friday and Saturday. That is a product
    defect, not a flaky assertion, and it was fixed in
    `src/lib/attendance.ts` at `88c412f` by dropping a leading week
    rostered well below the usual, only from the front so that a genuine
    collapse in the middle is still a finding.
  - The 59 type errors were resolved by this migration.
  - `the backend fixture is in step with this seed` failed for its own
    reasons and is recorded because it is easy to confuse with the first.
    Fixed at `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 the absence of
failures is stated rather than merely implied.

MIGRATION_BASELINE.md is appended to, never edited: 68 lines added, zero
removed, and its first 141 lines are byte-identical to the previous
commit. The 2026-09-11 measurements — 71 errors, 1641/1642, 302 files
linted — are the thing the migration was checked against, so bringing
them up to date would destroy the comparison rather than update it. The
new dated entry is the other end of it, and records how the three items
that document left open were each closed.

  typecheck   0 errors
  lint        exit 0, 0 errors, 289 warnings
  npm test    1693/1693
  build       exit 0, bundle 74d17e2d… unchanged
  seed:check  in step
  owliver     matches the baseline

No file under `src/` changed, which is why the bundle hash cannot move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-20 15:52:12 +05:30
34505d7cf6 chore: refresh owliver baseline
Commit B of the baseline remediation, and independent of Commit A.

The baseline recorded 23 skills; the runtime resolves 24. The one addition
is `create-employee-role`, which appears in the skill list and in the two
contexts that offer it, `admin.positions` and `admin.talentPool`. Three
lines, three insertions, no deletions.

The drift is older than either workstream and was never a symptom of them.
`src/skills/owliver/create-employee-role.md` exists on `main` and at
`dca1842`; it was introduced by `6249e00`/`e02a0c2`, while this baseline was
last written at `eaf08e0`/`b2e6868` — before the skill existed. It is absent
from `dca1842`'s file list and from every commit on this branch. The skill
files and the baseline are byte-identical between `main` and HEAD, so `main`
captures the same drift.

Refreshing it cannot hide a migration regression, which is the only reason
it is safe to write. `owliver-capture.mjs` loads seven modules — the skills
registry, `placement`, `owliverResolver`, `dynamic`, `routing`, `insights`
and `seed` — and none of them is among the files whose emitted JavaScript
differs from `main`. Identical inputs, identical capture. What a regression
would look like here is a removal or a moved route, and there is neither:
routes are byte-identical at 18, the eleven contexts are the same eleven
with the same fields, and no skill was lost.

Written with `node scripts/owliver-baseline.mjs --write`, the mechanism the
file's own header names. Verified before the write by capturing to a scratch
path and diffing, and after it by confirming the written file is byte-
identical to that independent capture.

  owliver-baseline.mjs   "Owliver behaviour matches the baseline." — exit 0
  typecheck              0 errors
  lint                   exit 0, 0 errors, 289 warnings
  npm test               1693/1693, exit 0
  build                  exit 0, bundle 74d17e2d… unchanged

Nothing under `src/` changed, no Owliver skill, agent, route or context was
edited, the capture scripts are untouched, and Commit A's five files are
byte-identical to `878a235`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-20 01:00:54 +05:30
878a23532a test(baseline): recapture the three HTML baselines that held invented people
Commit A of the baseline remediation. The Owliver baseline is deliberately
untouched and is Commit B.

Five of the six failing checks were the `DEMO_FILL` removal. `hiringRecords`
used to pad the hires list with five invented people so Hired History read
as a history rather than as three rows, and the padding reached Analytics
too, where "total hires" counted eight against a database holding three.
Because these baselines render with queries disabled, the padding was ALL
they contained. The sixth is Candidates becoming the final-selection queue,
which changed what its empty state says.

Verified before regenerating rather than after. Hired History's tag counts,
baseline -> now: `<td` 40->0, `<span` 51->13, `<div` 78->39, `<p` 26->7,
`<svg` 20->8. 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". Every generated file was checked for the
five invented names (none), for the intended new copy (present) and for its
node identities before it was put in place.

Renamed, because a file called `pre-migration` holding post-`dca1842`
markup is a lie in the filename. `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 — so the
`PAGES` table now carries each baseline's FILENAME rather than deriving one
suffix for all five.

The migration proof was replaced, not discarded. `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 render from another, so removing the invented hires moved
every count and the arithmetic stopped describing wrappers. 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 — so left in place it
would have stayed red for a new reason. Three checks read the render
directly instead and need no frozen file:

    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

They 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 carries identity and no styling.

Writing them first, against the OLD baseline, is what caught my own error:
the second check began as "one identity per composed node" and failed at
4 of 5. Hired History composes five nodes and `hired-extensions-top` is an
extension slot that renders nothing while no skill is attached to it, so a
one-to-one rule would have asserted that every slot is always filled. It
asserts uniqueness and no strays instead — a node addressed twice, or an
identity naming nothing the page composed, would break the editor's and
Owliver's ability to name a node. Had the baselines been recaptured first,
that mistake would have been invisible.

`carries node identity in the DOM` was documented as needing pre-migration
markup. It does not — it reads only the live render — so it is unchanged and
still passes. The dead `tally` helper is removed with its last caller.

  typecheck   0 errors
  lint        exit 0, 0 errors, 289 warnings
  npm test    1693/1693, exit 0 — was 1685/1691
  build       exit 0, bundle 74d17e2d… unchanged
  owliver     still DRIFTED — that is Commit B, untouched here

No file under `src/` changed, which is why the bundle hash cannot move.
`owliver-baseline.json` is byte-identical, the backend is untouched, and CI
configuration is unchanged. The suite total rises 1691 -> 1693 because one
assertion became three; CI asserts `passed == total` and a floor of 900,
both satisfied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-20 00:52:53 +05:30
3ddacf269a fix(seed): emit the users array the backend seeder reads
The fixture generator was a version behind the seeder, and the gap was
the `users` key.

`internal/seeder` unmarshals `seed.json` into `{demoUser, users,
entities}` and writes every account in `users`, falling back to
`demoUser` alone when the key is absent. `fixtureFrom` never emitted it —
its comment still claimed the Go loader "ignores the rest", which stopped
being true when the seeder gained the field. So `npm run seed:check`
reported stale on every run, and that is the seventh suite failure, the
one that pre-dates the TypeScript migration.

What made this worth investigating rather than regenerating: the
committed fixture was NOT wrong. It carries two accounts — the demo
administrator and `employer@krow.app` — and running `npm run seed:fixture`
against the old generator would have written a fixture without them.
`krow-backend/docs/deploy-9d3192a.md` says so in as many words, and tells
anyone deploying not to run it.

The accounts are not deleted from a live database by that — the only
DELETE in the seeder is the shift-record prune, and `upsertUser`'s ON
CONFLICT deliberately leaves `password_hash` alone. The damage lands on
the next fresh environment, where `user_employer` would simply never be
created, leaving nobody to sign in as to reach the employer console —
which is the exact gap `seeder.go` records the account as having been
added to close.

The matching frontend half already existed, unmerged, on
`feat/krow-employee-pages` (7a4b61d). That branch is a 53-file console
restructure that renames the same files this migration renamed, so it is
not mergeable here. Only the three files that carry the contract are
taken: `EMPLOYER_USER`, `seedData.User` naming both accounts, and the
generator emitting `users`.

The proof is that nothing had to be written. With the generator corrected
its output is byte-identical to the fixture already committed in
krow-backend — 168,293 bytes, `cmp` clean — so `seed.json` was never
opened for writing, and its md5 and mtime are untouched.

  typecheck     0 errors
  seed:check    "seed.json is in step with src/api/seed.js", exit 0
  npm test      1685/1691, up from 1684 — the fixture check now reads
                "24 applications, byte-identical". The remaining 6 are
                exactly the documented dca1842 baseline failures.
  lint          exit 0, 0 errors, 289 warnings
  build         exit 0, bundle 74d17e2d… unchanged

The bundle being unchanged is itself a check: `demoUser.ts` IS in the
production bundle, and `EMPLOYER_USER` is a new export of it. Nothing in
the app imports it, so it is tree-shaken out and not one byte reaches the
shipped code.

No baseline artifact touched. krow-backend not modified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-19 23:54:36 +05:30
2f9b3baa56 chore(ts-migration): remove the migration-only TypeScript configuration
`src` holds 321 TypeScript files and no JavaScript, so four settings that
existed only to let the two coexist have nothing left to act on. Each was
removed on its own and validated before the next.

  allowJs / checkJs   Removed together, because they are coupled: `checkJs`
                      without `allowJs` is TS5052, a configuration error
                      rather than a code one. Verified first that nothing
                      under `src` imports a `.js` module. The program still
                      resolves the same 321 files and still reports 0 errors.

                      This also closes a door. With `allowJs` on, a `.js`
                      file added under `src` would be compiled and bundled
                      silently; now it is a resolution failure, which is the
                      right outcome for a codebase that has finished
                      migrating.

  jsconfig.json       Deleted. It had been inert since `tsconfig.json`
                      appeared — TypeScript ignores a jsconfig wherever a
                      tsconfig sits beside it — and it still carried the old
                      `types: []` and the three stale excludes. A second,
                      unread copy of the options is an invitation to edit the
                      wrong file.

  components.json     `"tsx": false` -> `true`, so `npx shadcn add` emits
                      TSX. No runtime effect; it would have quietly
                      reintroduced `.jsx` into a repository that has none.

`README.md` documented `npm run typecheck` as `tsc -p ./jsconfig.json`
with `checkJs`, which named a file that no longer exists. Corrected, along
with a stale assertion count in the same table (835, against 1691 today).

No file under `src` changed, so there is nothing for a per-file emitted-JS
comparison to compare; the bundle hash is the check that matters and it is
unmoved.

  typecheck   0 errors, 321 files in the program
  lint        exit 0
  npm test    1684/1691, the same 7 failures
  owliver     unchanged; baselines still pinned, not recaptured
  build       exit 0, bundle 74d17e2d… identical

Left in place deliberately, reported rather than removed:
`scripts/ssr-resolve.mjs`. Its own comment says it can go once `src` holds
no `.js`, but that is only half the condition — the 203 literal `.js`
paths inside `skill-check.mjs` would all have to be renamed first, and
that is a large diff against the file that guards this migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 18:57:37 +05:30
e8207038dd refactor(ts-migration): type src/api and the first of src/lib for noImplicitAny
Phase 12 step 1, in progress. The flag is not enabled yet — these are the
annotations it will require, landed first so the switch itself is a
one-line commit with a number that is already zero.

`noImplicitAny` projects 3496 errors across 242 files. This clears 179 of
them in 7 files: all of `src/api` (107 -> 0) and `workforce`,
`hiringRecords` in `src/lib`.

The leverage is real and worth recording, because it shapes the rest of
the step. Eight annotations on `aiEngine`'s prompt readers cleared 27
errors: where a value is `any`, every callback beneath it — `.map((c) =>
…)`, `.filter((r) => …)` — has no contextual type and errors on its own.
Typing the source fixes the callbacks for free, so this works bottom-up,
sources first.

Types are taken from what already exists wherever possible. The generated
entity types fit `workforce` and `hiringRecords` without a single
cascade: `JobPosting`, `JobApplication`, `Staff`, `AIInterview`,
`WorkerProfile`, `Assignment`, `Course`. `PreferencesUpdateResult` in
`src/types/user.ts` already described `updatePreferences`'s return.
`buildInsights` takes the other builders' outputs, so its parameters are
`ReturnType<typeof byDepartment>` and friends rather than a restatement
that could drift.

Two things are recorded rather than fixed:

`buildHires` probes four fields that are not `staff` columns —
`timeToHire`, `score`, `company` and `department` are absent from
`information_schema` and from the generated `Staff`. They are read as
fallbacks, so at run time they are always `undefined` and the other
branch always wins. `HireSourceRow` writes them down as optional so the
dead fallbacks are visible; removing the reads would be a behaviour
change. `Hire` likewise widens `profile_tier`, because the builder's
default `'skilled'` is lower-case where the column's check constraint
spells it `'Skilled'`.

`aiEngine`'s talent pool stays `any[]`. It is `JSON.parse` output from a
block embedded in a prompt, carrying computed fields like `match_score`
that no entity declares — typing it `WorkerProfile[]` would assert a
shape nothing validates.

One mistake worth keeping. I replaced an inline lookup with a hoisted
`const URGENCY = {…}`, and per-file esbuild said the output was
unchanged: with `--minify-syntax` it inlines a single-use const straight
back. The production bundle disagreed — `43e7f268` against `74d17e2d`.
Reverted to a type assertion, which erases. Hoisting reads as a tidy-up
and is a real change to the emitted code; the two checks disagreeing is
exactly why both are run.

  typecheck   0 under the committed config; 3317 under the probe, from 3496
  lint        exit 0
  npm test    1684/1691, the same 7 failures
  build       exit 0, bundle back to 74d17e2d…
  emitted JS  7/7 identical

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 17:18:18 +05:30
21133a6064 chore(ts-migration): make the tsconfig root set authoritative
Phase 12 prerequisite. `exclude` still carried `src/components/ui`,
`src/api` and `src/lib` from `jsconfig.json`; each was meant to come off
as its tree was converted, in Phases 4, 5 and 6, and all three were
missed.

The miss was quiet by construction. Excluding a file only keeps it out of
the ROOT set — anything an included file imports is pulled in anyway — so
318 of 321 files were being checked regardless, and the omission cost
nothing visible. The three that were not checked are the ones nothing
under `src` imports: `api/seed.ts`, `api/attendanceSeed.ts` and
`lib/skills/positionFlow.ts`. They are not dead code. `scripts/` loads
them, `seed.ts` from six separate places, and they held four real errors
that no previous count in this migration has included.

All four were one cause. `MODULE_TABLE` is 36 rows of mixed literals, so
it infers as an array of the UNION of its column types and destructuring
a row gives every field `string | number | string[]` —
`SKILL_CATEGORY[skill_id]` then refuses a key that might be an array. It
is now written as the tuple it is. The migration plan predicted this
class of error for three other tables by name.

Doing this before the ratchet rather than after: a strictness flag
measured against an incomplete root set produces a number that grows
again later for reasons unrelated to the flag, and the whole method here
is that each step's error count means one thing.

  root set    321 files, was 318
  typecheck   0 errors, with every file in src now checked
  lint        exit 0
  npm test    1684/1691, the same 7 failures
  build       exit 0, identical bundle hash 74d17e2d…
  seed.ts     emitted JS identical

`allowJs`, `checkJs` and `jsconfig.json` are deliberately left alone —
they come off at final cleanup, after strictness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 16:54:27 +05:30
78f1b44c2e chore(ts-migration): remove the now-redundant @ts-ignore in the agent registry
`import.meta.glob` needed a suppression while `tsc` modelled only the
standard `ImportMeta`. Phase 1 set `types: ["vite/client"]`, which
declares Vite's additions, and the directive has had nothing to suppress
since. Phase 1 scheduled its removal for Phase 11; this is that.

Removing it is the point rather than tidying. `@ts-ignore` suppresses
whatever the next line produces, so one that no longer applies is a
directive that would silently swallow a real error on that line later.
Verified redundant before removing: typecheck is still 0, and the emitted
JavaScript is identical.

`src/vite-env.d.ts` described this suppression as present, so its comment
is corrected too.

`src/` now contains no `@ts-ignore` or `@ts-expect-error` at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:54:55 +05:30
34acd63a80 fix(ts-migration): clear the last six pre-existing type errors
These six pre-date the migration. They were unfixable while the files
holding them were JavaScript with `checkJs`, because the fix in each case
is an annotation JSDoc cannot express. `src/` is TypeScript now, so:

  - `setWidthState.timer` in `AssistantPanelContext` is a debounce handle
    hung on the callback itself, so it survives re-renders without a ref.
    TypeScript has no way to describe an expando on a `useCallback`
    result, so the binding is `any`.
  - `runAction(name, { skill, ...payload } = {})` — the `= {}` default
    types the parameter `{}`, so reading `skill` off it looked wrong.
  - `Progression` in `Positions` is a local presentational component
    whose `className` is omitted at both call sites, like its siblings in
    the same file.
  - `normalizeSection`'s `page` reads as required because it has no
    default, but `owliverConfig` deliberately calls it without one: `page`
    only builds a fallback `where` label, and that caller passes `where`
    explicitly, so the branch that would read it never runs. Stating the
    parameter object makes `page` optional, which is what the function has
    always accepted.

`npm run typecheck` is now 0 errors, from 71 when this migration started
and 20 when Phase 11 began. Nothing was suppressed to reach it: there is
no `@ts-ignore` or `@ts-expect-error` added anywhere in this branch.

4/4 byte-identical, suite and bundle unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:44:33 +05:30
284a7e7671 refactor(ts-migration): Phase 11 batch 11 — the data pages
The last nine admin pages. `src/` now contains no `.js` or `.jsx` at all.

One error, and it was the same kind of thing the whole phase has been
finding: `getBandMembers(min, max)` in `TalentPool` is called with one
argument for the top band, and its own body reads
`max == null || s < max`. The parameter has always been optional
everywhere except the signature. The marker erases.

9/9 byte-identical, and the bundle still hashes to 74d17e2d…

Three of these nine — `Analytics`, `Candidates`, `HiredHistory` — are
the pages the Owliver baselines cover, and they carry six of the seven
pre-existing suite failures. Those failures are unchanged and still match
`ecf5e75` line for line, which is the point: if a rename had altered what
these pages render, it would have altered how they fail, and it did not.
The baselines are untouched, as they have been in every batch.

Phase 11 is 122/122 files and eleven batches. Across all of them:

  type erasure  122/122 byte-identical
  bundle        74d17e2d… unchanged from `ecf5e75` in every batch
  npm test      1684/1691 with the same 7 failures, in every batch
  typecheck     20 errors at the start, 6 now

The remaining 6 all pre-date the migration. They are no longer in
JavaScript, so they are now fixable; that is the next commit, not this
one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:42:35 +05:30
a0f3f77900 refactor(ts-migration): Phase 11 batch 10 — the authoring and settings pages
Nine admin pages: `AgentDetail`, both skill editors, the three Workspace
pages, `SkillDevelopment`, `Settings` and `Profile`. 9/9 byte-identical,
bundle hash unchanged.

Six errors, five of them the same shape this phase has met repeatedly:
`Object.values(skill.ui || {}).flatMap((page) => page.sections)` yields
`unknown` because inference into the union parameter of `values` does not
distribute. Three copies of that line across the two editors and
`WorkspaceSkills`, plus one `Object.entries` and one `new Set` whose
element type reached a React `key`, where `unknown` is not allowed.

The sixth is worth its own line. `WorkspaceSkills.toggleSkill` builds a
preferences patch as `{ disabledSkills }` and then adds `customSkills` to
it conditionally, several lines later, when re-enabling a skill flips an
inactive definition back to active. The literal's inferred type does not
carry a key assigned after the fact, so the later write looked wrong.
The annotation names both keys and marks the conditional one optional,
which is what the function does.

Measured against `b99dc7c`:

  typecheck   6 errors, unchanged; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  113/113 byte-identical across Phase 11 so far

Nine files left in Phase 11, all data pages. No baseline artifact
touched — three of those nine are the pages the baselines cover, so the
recapture question arrives with the next batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:40:35 +05:30
b99dc7c576 refactor(ts-migration): Phase 11 batch 9 — the page composition tables
The eight `nodes` files under `src/pages/admin/*/`, which declare what
each admin page is made of. Renamed with no annotations needed at all:
zero new errors, 8/8 byte-identical, bundle hash unchanged.

They came through clean because everything they call was typed first —
`makeNode` and the registry in batch 1, the skill sections in batch 2.
A descriptor table is only as checkable as the constructor it feeds.

`positions/nodes.js` became `.tsx`, joining its seven `nodes.jsx`
siblings. That is the exact case `scripts/ssr-resolve.mjs` was written
for and says so in its comment: it is the only `.js` under `src/pages`,
`skill-check.mjs` loads it by literal path as `.js`, and the resolver
tries `.ts` and `.tsx` in turn. The suite loads it and still passes,
which is the first time that particular branch has been exercised.

Two of these files are also read as source TEXT rather than loaded —
`candidates/nodes.jsx` at `skill-check.mjs:6049` and
`talent-pool/nodes.jsx` at `:6298`, both through `resolveSourcePath`.
That is the second channel, the one that was missed in Phase 0 and found
by an ENOContent failure in Phase 4b-ii. Both resolve.

Measured against `7a95d95`:

  typecheck   6 errors, unchanged; no new error anywhere
  lint        exit 0
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  104/104 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:38:29 +05:30
7a95d95151 refactor(ts-migration): Phase 11 batch 8 — the assistant panel components
The thirteen `.jsx` files under `src/components/ai-assistant/`, which
completes that directory: 24/24 files migrated and all 24 linted under
their new extensions.

39 errors, and converting 21 `/** @param {any} props */` hatches to
`: any` cleared 33 of them — the same pattern as batch 6, and the same
reason: without the JSDoc, TypeScript infers every destructured prop as
required, so `ResponseBlocks` and `AssistantMessage` alone produced 31
complaints about call sites that were always correct.

The remaining six were already there before this batch and are
unchanged.

13/13 erase byte-identically. `.jsx` -> `.tsx` is the move that can drop
unused imports through esbuild's loader difference; it did not here, and
the bundle hash is unchanged.

Measured against `3f835ee`:

  typecheck   6 errors, unchanged; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings, 24/24 linted by name
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  96/96 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:37:09 +05:30
3f835eee93 refactor(ts-migration): Phase 11 batch 7 — the assistant's non-component modules
The eleven `.js` files under `src/components/ai-assistant/`: the blocks
format, contexts, routing, history, placement, viewport, the greeting
and prompt tables in `dynamic`, the derivations in `insights`, and
`uiEdit`, whose boundary batch 1 already typed.

79 errors, and two optional markers cleared 63 of them.

`plural(n, word, irregular)` is called with two arguments sixty times in
`dynamic.ts` and its own body reads `irregular || \`${word}s\``, so the
third parameter has always been optional in everything but the
signature. `heading(value, sub)` is the same: `sub` is spread into the
block and `undefined` is what most callers mean. Marking both optional
is a statement about the existing contract, and the markers erase — the
emitted signatures still read `plural=(n,word,irregular)` and
`heading=(value,sub)`, checked in the output rather than assumed.

Those three `heading` errors landed in `lib/skills/workforceFlow.ts`,
already migrated and untouched here. Worth noting how that works: a
function's arity only starts being enforced on its callers once the file
defining it is TypeScript. Migrating a leaf makes claims about every
file that imports it, which is why this phase moves bottom-up.

The remaining nine were two `reduce` accumulators inferring `{}`, so
`Object.values` over them produced `unknown`. Both are now stated —
`{ label, count }` for the score bands, and the five-field hire grouping
— which is more useful than `any` and exactly what the lines below them
build.

Measured against `dde4ba6`:

  typecheck   6 errors, unchanged; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  83/83 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:35:15 +05:30
dde4ba62c6 refactor(ts-migration): Phase 11 batch 6 — agent and skill components
The fifteen files under `src/components/agents/` (including `skills/`)
and `src/components/skills/`. 15/15 erase byte-identically; bundle hash
unchanged.

Renaming raised 43 errors and 29 of them came from one thing: these
files carry 24 `/** @param {any} props */` annotations, one per
component, and JSDoc stops applying at the extension boundary. Without
them TypeScript infers every destructured prop as required, so a call
site passing seven of nine props is an error — which is how a file that
declared its props `any` ended up with fourteen complaints about missing
`className`. Restoring the author's own declaration as `: any` is not
blanket typing; it is the annotation that was already there, in the only
form that still works.

`Workspace` in `AgentCanvas` was the one component in that file its
author left without the hatch. It now matches its siblings.

The rest were five separate things:

  - `React.isValidElement(children)` no longer narrows enough to read
    `children.props.id`: React 19 types `ReactElement`'s props as
    `unknown`. `isValidElement<any>` says what the `cloneElement` call
    beneath it has always assumed. The migration plan predicted this
    site by name.
  - `useSkillSections(page, placement)` is called with one argument by
    `UiEditingProvider`, which its doc comment explicitly permits —
    "called with no placement it returns every section on the page". The
    parameter simply lacked its optional marker. The marker erases, so
    the emitted signature is unchanged.
  - `new Date(b.at) - new Date(a.at)` is valueOf coercion, which
    JavaScript performs and TypeScript refuses to describe. Cast rather
    than rewritten to `.getTime()`: that would change the emitted code,
    and this comparison orders the list.
  - `Object.values<any>` on a tally, the same inference gap as earlier
    batches, which also fixed a `ReactNode` complaint downstream of it.

All fifteen are linted under their new extensions, checked by name.

Measured against `446df7b`:

  typecheck   6 errors, down from 9; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings, 15/15 linted by name
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  72/72 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:32:14 +05:30
446df7b37b refactor(ts-migration): Phase 11 batch 5 — ui-tree and ui-editor
The eleven files that render and edit the node tree: six in
`src/components/ui-tree/`, five in `src/components/ui-editor/`. The
first `.jsx` -> `.tsx` of this phase.

Renaming raised six errors, all in one file, and one annotation cleared
all six. `UiNodeBoundary` is a class component declared
`extends React.Component` with no type arguments, so both its props and
its state are `{}` — which is why reading `this.props.node` and
`this.state.failed` looked wrong. `UiNodeBoundaryProps` and
`UiNodeBoundaryState` write down what the class already uses: `node` and
`children`, and `failed` plus `forNode`. `forNode` is what stops the
boundary staying latched after a broken node is hidden, so it is part of
the contract rather than an implementation detail.

Nothing else needed anything. That is the earlier batches paying off:
these files consume `lib/ui`, which was typed in batch 1, so the tree and
node values arriving here are already described.

The `.jsx` -> `.tsx` move is the one that can change emitted output —
esbuild's `tsx` loader elides unused imports where the `jsx` loader does
not, which cost a 942-byte bundle change in Phase 10. It did not happen
here: all eleven erase byte-identically, and the bundle hash is
unchanged.

ESLint now reports all eleven under their new extensions, checked by
name rather than by count. That is the failure Phase 0 existed to
prevent — `.tsx` outside the globs would have dropped them silently while
`eslint .` went on exiting 0.

Measured against `55ddaa1`:

  typecheck   9 errors, unchanged; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings, 11/11 linted by name
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  57/57 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:28:01 +05:30
55ddaa134a refactor(ts-migration): Phase 11 batch 4 — the agent layer
All thirteen modules under `src/lib/agents/`. Renamed and annotated; no
logic touched. 13/13 erase to byte-identical JavaScript, and the bundle
still hashes to 74d17e2d…

Project typecheck errors are now 9, down from the 20 this phase started
from. Nothing was suppressed: batch 3's `Frontmatter` cleared 4 and this
batch's annotations cleared 7 more that had been sitting in `agentStore`,
`runtime` and `useAgents` since before the migration.

Two findings, both recorded rather than fixed:

`agentSkillIds(agent)` takes one parameter and is called with two, at
`runtime.ts:83` and `:95`. Not a bug — its own doc comment says so:
subagent skills were deliberately removed from it, because CLAUDE.md §3
makes `subagents` a delegation list rather than a skill list, and
"`agents` is still accepted so every call site keeps working; it is no
longer read." The contract is restored with an overload signature, which
emits no JavaScript — confirmed by reading the emitted output, where
`agentSkillIds` still takes exactly one parameter. Worth knowing that
`agentScopedDisabledWith` therefore computes what `agentScopedDisabled`
computes; that is intended, and `skill-check.mjs` asserts the behaviour
at eleven call sites.

`useAgents` returns five different shapes from eleven `return`
statements, which is the latent problem the migration plan predicted
here. `AgentActionResult` writes them down, but open: `ok` plus four
optional fields. Nothing stops a caller reading `.agent` off a failure
and getting `undefined` — `AgentDetail.jsx` reads `.conflict` and
`.error` off the same value. Closing it properly needs `as const` on
eleven literals so `ok` stops widening to `boolean` and starts
discriminating, which is an edit to these function bodies and not
something a rename may do. The type is the record of the decision, not
the decision.

Writing that interface also corrected my own count. I described four
shapes; the compiler rejected `duplicate`'s `{ ...result, id }` and made
it five.

Other annotations: React Query v5 infers `void` for an unconstrained
`mutationFn` parameter, so both mutations in `agentStore` had their
variables stated; `existingIds = []` in `agentLifecycle` infers
`undefined[]`, which rejects `.includes(id)`, so it is `string[]`; two
accumulators and two inline JSDoc hatches restated as annotations.

Measured against `fc8d7ee`:

  typecheck   9 errors, down from 16; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash
  type erasure  46/46 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:25:35 +05:30
fc8d7eec52 refactor(ts-migration): Phase 11 batch 3 — the rest of the skills layer
The remaining fifteen modules under `src/lib/skills/`, including the
three under `flows/`. `src/lib/skills` now holds no JavaScript.

Renaming them raised 134 errors, which came from nineteen values, not
134 places. Eleven were accumulators or parameters written `= {}`, whose
type is then `{}` — an object with no properties — so every later read of
a key looked like a mistake. Five were `Object.entries`/`values` on a
dynamic value, which yields `unknown` rather than `any` because
inference into their union parameter does not distribute. The rest were
`reduce` accumulators in the same position.

Annotating the nineteen sources cleared all 134. Where the keys were
knowable they are written down rather than waved away: both `prefill`
accumulators in `actions.ts` name the fields their own following lines
assign, and `dataResolver`'s two event tallies are
`Record<string, number>`, which is what they are. Where the value is
genuinely whatever an author wrote — a parsed YAML mapping, a skill
context — it stays `any`.

The one structural addition is `Frontmatter`, the return of
`parseFrontmatter`. Its no-frontmatter early return hands back a literal
`{}`, so TypeScript took the common shape of the two returns, which has
no properties; that single empty object is what made twenty-five later
readings of `data` look wrong. Typing the return also resolved four
pre-existing errors in this file and four more that had cascaded into
`lib/agents/registry.js`, so the project total is 16, below the 20 this
phase started from. Nothing was suppressed to get there.

Measured against `e73929f`:

  typecheck   16 errors, down from 20; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  33/33 byte-identical, all of Phase 11 so far

CORRECTION to the previous two commits. Both claim the migrated files
emit "byte-identical minified JavaScript". That check was broken when it
ran and proved nothing: it passed `--loader=js`/`--loader=ts` to esbuild
on named files, and esbuild accepts `--loader` without an extension only
for stdin. Both sides errored, both outputs were empty, and `cmp` found
two empty files equal. Eighteen "IDENTICAL" lines meant eighteen pairs of
nothing.

Repaired here and re-run over all 33 files. Two further things had to
change for the check to mean anything. It now proves it can detect a
difference before it is trusted, against a pair of files differing in one
character. And it compares with `--minify-whitespace --minify-syntax`
rather than `--minify`: full minification renames locals, and esbuild's
choice of names shifts with token counts, so twelve files differed only
in whether a binding was called `g` or `u` — alpha-equivalent, at
identical byte counts. Stripping comments and whitespace while keeping
identifiers is the comparison that answers the actual question.

The result is that the substantive claim was true throughout, and is now
actually evidenced: all 33 files erase to byte-identical JavaScript. It
was never the only evidence either — the production bundle hash and the
1691-check suite were compared in every batch, both valid, and both
unchanged.

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:20:41 +05:30
e73929f47e refactor(ts-migration): Phase 11 batch 2 — the skills-layer leaves
The eight modules under `src/lib/skills/` that import nothing from their
own layer, plus `uiConfig`, which imports only `surfaces`. Renamed and
annotated; no logic touched.

All eight emit byte-identical minified JavaScript, and the production
bundle still hashes to `74d17e2d…`. Four of them are R100 — not one
character changed beyond the extension.

The annotations are four fixes of the same two kinds:

  - `Object.entries<any>` / `Object.values<any>` at three sites. Passing
    an `any` value to either yields `unknown`, not `any`, because
    inference into the union parameter of their signatures does not
    distribute — so `weightsOf`'s entries arrived unsortable and two
    `reduce` accumulators arrived un-addable. The explicit type argument
    restores what JavaScript had. No cast, no runtime change.
  - `FlowReply` as `assignmentPreview`'s return type. Two of its five
    branches genuinely return no `followUp` — "already fully staffed"
    and "nobody is both qualified and free" are answers with nothing to
    offer next — so `followUp` is optional, which is what `headcountSet`
    has always passed through.

One inert `/** @param {any} */` in `saveFeedback` became a real
annotation. Left as a comment it would have read as if it still did
something.

Measured against `eaa677f`, all unchanged:

  typecheck   20 errors, same files
  lint        exit 0, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash
  emitted JS  8/8 byte-identical

One number moved and it is a reporting artifact, recorded here so it is
not misread next time: ESLint's file count went 269 -> 261. `src/lib/**`
is in `ignores` for both config blocks and always has been, so no rule
has ever run on these files. A `.js` file there is still walked by
ESLint's default `**/*.js` glob and then ignored, which produces an entry
with zero messages; a `.ts` file matches no `files` pattern, so it is
never walked and produces no entry at all. Confirmed directly: linting
`registry.js` reports nothing, linting `yaml.ts` reports "File ignored
because no matching configuration was supplied." Zero rules applied
before, zero after. The counts that carry signal — 0 errors, 289
warnings — did not move.

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 13:55:59 +05:30
eaa677f815 refactor(ts-migration): Phase 11 batch 1 — the UI-node engine
Renames the ten modules under `src/lib/ui/` to TypeScript and annotates
them. No logic is touched: no reordered statements, no changed defaults,
no altered branches, no renamed locals, no edited strings.

The proof is mechanical rather than argued. esbuild's output for each of
the ten files, minified, is byte-for-byte what the `.js` file produced at
`ecf5e75`, and the production bundle hashes to `74d17e2d…` before and
after. `operations` and `patch` are pure renames — R100, not one
character changed.

What the annotations actually are:

  - Four `/** @type */` and `@param` JSDoc hatches the author had already
    written, restated as real annotations. These stop applying at the
    extension boundary, which is where most of the errors came from.
    `makeNode` keeps its declared `@returns {any}`; dropping it in favour
    of an inferred shape would have quietly narrowed a contract the
    author had deliberately left open.
  - `NodeTypeRegistry.types` as `declare`, not a field declaration. A
    plain one would emit a `defineProperty` under
    `useDefineForClassFields`, i.e. a change to the shipped JS. `declare`
    emits nothing, which the per-file comparison above confirms.
  - Two accumulators (`wants`, `params`) given the shape their own
    following lines build.
  - `Object.entries<any>(…)` at two sites. Inferring `any` into the
    union parameter of `entries` yields `unknown`, not `any`, so the
    rule objects arrived unreadable; an explicit type argument restores
    what JavaScript had, without a cast.
  - `UiEditMatch`, an open interface, as `matchUiEdit`'s return.

`UiEditMatch` is the one judgement call and it is deliberately weak.
`kind` is optional and the rest is an index signature, because the
nineteen return shapes share field names carrying different meanings and
the suite reads these objects in around forty places. Writing the real
discriminated union is a schema this phase has no mandate to invent, and
`kind` stays `string` rather than a literal union partly so that no
node-type name is ever written into a type — the engine check forbids
exactly that.

`kind` is optional for a reason worth recording: at run time the planners
guard with `if (subject.kind) return subject`, so a `kind`-less object
never escapes. TypeScript cannot see it, because every `kind` widens to
`string` and a property that is `string` in every member is not a
discriminant, so truthiness narrowing leaves a shape the function cannot
produce. Marking it required would have been true of the runtime and
rejected at five return sites, and the only fixes are edits to agent
logic. The weaker claim is the honest one.

Renaming these files also surfaced twenty errors in `uiEdit.js`, which is
still JavaScript: `.js` and `.ts` infer this union differently. Verified
as a property of the rename and not of any edit, by compiling the
verbatim `ecf5e75` contents under a `.ts` extension — same twenty. The
return annotation clears them.

Measured against `ecf5e75`, all unchanged:

  typecheck   20 errors, same files      (no new error anywhere)
  lint        exit 0, 269 files, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash
  emitted JS  10/10 byte-identical

The 7 failures and the `owliver-baseline.mjs` drift both pre-date this
commit — they are the parallel feature session's, present at `ecf5e75`
and measured there before this batch was applied. No baseline artifact
is touched; recapture waits until the agent migration is complete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 13:50:33 +05:30
ecf5e75d76 chore(ts-migration): migrate the app shell, routing and the last non-agent pages
Phase 10: `App`, `main`, `index.html`, and the four remaining `src/pages` files.

ROUTING IS PROVEN UNCHANGED, not assumed. The 49 `<Route>` elements were
fingerprinted before the rename and compared after: byte-identical, all 44
paths, all 13 `<Navigate>` redirects, the same nesting under `ProtectedRoute`
and `AdminRoute`, and the same provider order - AuthProvider, then
QueryClientProvider, then Router, with ScrollToTop and AuthenticatedApp inside
and both toasters as siblings. There is no lazy loading to preserve; there never
was any.

Two coordinated edits the rename forced. `main` imported `@/App.jsx` by explicit
extension, which stops resolving the moment `App` is `.tsx`; it is now
extensionless `@/App`. `index.html` pointed its module script at
`/src/main.jsx`; it points at `/src/main.tsx`. Both are required, and missing
either would have been a blank page rather than a type error.

ONE GENUINE SEMANTIC DIFFERENCE, INVESTIGATED AND ACCEPTED. esbuild elides
unused imports under the TypeScript loader but keeps them under the JavaScript
one, so `App` drops thirteen imports: `Layout`, `Overview`, `Positions`,
`Candidates`, `HiredHistory`, `TalentPool`, `UserTracking`, `Analytics`,
`Profile`, `WorkerProfile`, `KrowIdentity`, `Owliver` and `DesignSystem`. All
thirteen are dead - each has zero JSX uses in `App`, because every route that
once rendered them now `<Navigate>`s to an `/admin/*` equivalent. Before
accepting it I checked that none of the thirteen modules can do anything when
evaluated: no bare side-effect imports, no top-level calls, and every top-level
binding a literal or a function declaration. The consequence is 942 fewer bytes
in the index chunk and thirteen modules no longer evaluated at startup. Nothing
observable changes, and the routes those pages are reached through are
unaffected - they are reached through the `/admin` tree, which is untouched.

That is the first time in this migration the production bundle has changed for
a reason other than a comment, so it is recorded here rather than left to be
noticed later.

`CreatePosition` needed the only real typing. `vetting_criteria` is a `jsonb`
column holding the five weighting percentages the page edits, typed `unknown` by
the registry, and both the total and the three render sites read through it.
`onDone` is called with the created id, so it takes arguments - the generator
had classed it as zero-arg, and that rule is now narrowed to `onClose` alone.

The generator's entity-import rule was narrowed first, as instructed: it now
counts a type as used only when it appears in a type position inside a generated
interface, rather than anywhere in the file text. That is what produced four
unused-import lint errors in the previous batch.

Verified: tsc 21 -> 20, set-difference showing one removed and none added; zero
errors in any of the six files; all four pages emit byte-identical JavaScript;
route fingerprint identical; npm test 1684/1691 with the same seven failures;
Owliver baseline 59/59; lint 0 errors; no deferred agent-region file touched;
baseline artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 13:18:59 +05:30
fcdaa32f4d chore(ts-migration): migrate the non-agent pages to TypeScript
Phase 9, first batch: 18 pages. Seventeen emit byte-identical JavaScript, the
eighteenth differs only by a JSDoc cast becoming a real annotation, and the
production bundle is byte-identical to be49184.

SCOPE, decided by imports. Twenty-two of the forty pages import `lib/skills`,
`lib/agents`, `ai-assistant`, `components/skills`, `ui-tree` or `ui-editor`, and
are deferred to Phase 11 with the rest of that region. `Owliver.jsx` is in THIS
batch despite the name: it imports only `krowHooks`, `krowScore`, `krowAi` and
`ui/button`, and is a profile-building conversation page rather than any part of
the agent runtime.

`DesignSystem` is the headline result. It was the single worst file in Phase 6 -
111 errors when the design system was first renamed - and it arrived here with
none, because those were never its errors: they were the primitives' inferred
props, and fixing them at the source fixed every consumer.

Three pages needed real work, and each was the author's own intent made
explicit:

  - `admin/Login` carried `useState(/** @type {{email?: string, password?: string}} */ ({}))`.
    JSDoc casts stop applying in a `.tsx` file, so that became a real type
    argument, and `validate`'s accumulator - built empty and filled per failed
    rule - needed the same shape. The login flow itself is untouched: the
    generic 401 message, the 429 branch and the `remember` field all stand.

  - `Owliver` gets `new Promise<void>`, because its `resolve()` takes no
    argument.

  - `Candidates` names the element type of a `Set` built from `any[]`, which
    otherwise infers `Set<unknown>` and makes every option a `ReactNode` error.

LINT CAUGHT A REGRESSION THE OTHER CHECKS DID NOT. The generator adds an entity
import when it sees the type NAME anywhere in the file, which for four pages
with no props at all left an import nothing used - four `unused-imports` ERRORS,
taking `npm run lint` from exit 0 to exit 1 while typecheck, tests, the bundle
and seventeen of eighteen emit comparisons all stayed green. Removed. The
generator's import rule is too eager and wants narrowing before the next batch.

Verified: tsc 21 -> 21, set-difference showing zero introduced and zero removed;
zero errors in any of the 18 pages; lint back to 0 errors and 289 warnings;
npm test 1684/1691 with the same seven failures; Owliver baseline 59/59;
production bundle byte-identical; baseline artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 12:47:04 +05:30
be49184006 chore(ts-migration): migrate root components and layouts to TypeScript
Phase 8, third batch: the three root components and both layouts. All five emit
byte-identical JavaScript and the production bundle is byte-identical to
3d5f54b.

Small batch, and almost all of it was the rename. `ScrollToTop`,
`UserNotRegisteredError` and both layouts take no props at all - the layouts
render through `<Outlet />` - so inference already described them.

`ProtectedRoute` gains the only real interface, and it clears a standing error.
Both its props have fallbacks - `fallback` defaults to a spinner and
`unauthenticatedElement` falls through `??` to the login redirect - and its one
call site is a bare `<ProtectedRoute />` in `App.jsx`. Typed optional, which is
what the defaults already said, that call site stops being an error: tsc goes
22 -> 21 with nothing introduced.

The auth boundary itself is untouched. The `authError.type === 'user_not_registered'`
branch, the `<Navigate to="/admin/login" state={{ from }}>` redirect and the
`isLoadingAuth || !authChecked` gate are all exactly as they were - which
matters, because `AdminLayout` is one of the files `skill-check.mjs` reads as
source text, and it is read through the resolver added in 02a2ab0.

Verified: tsc 22 -> 21, set-difference showing one removed and none added; zero
errors in any of the five; all five emit byte-identical JavaScript; production
bundle byte-identical; npm test 1684/1691 with the same seven failures; Owliver
baseline 59/59; lint 0 errors; baseline artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 12:42:06 +05:30
3d5f54bd5b chore(ts-migration): migrate components/forge and components/admin to TypeScript
Phase 8, second batch: 16 files. All 16 emit byte-identical JavaScript and the
production bundle is byte-identical to e7e1e98.

`ProfileView` and `CourseView` move from `components/krow/types.ts` to
`@/types/views`, because `components/forge` reads the same `jsonb` columns off
the same courses and profiles. `components/krow/types.ts` stays as a re-export
so that folder's imports are untouched. Two copies of one narrowing would be
two things to keep in step.

SCOPE, decided by imports rather than by folder name. `components/skills` was in
the batch as requested and is DEFERRED: it imports `lib/skills/registry`,
`lib/agents/runtime`, `ai-assistant/PageContext` and `ai-assistant/AgentContext`,
which makes it agent region by the same test this batch used. `ui-tree` and
`ui-editor` are deferred for the same reason - all eleven files drive the UI-node
system, which is Owliver's "move this card" capability. `forge` was checked and
kept: despite the name, it imports nothing from `lib/skills`, `lib/agents` or
`ai-assistant`. It is the worker learning product.

Twenty-six components now declare real props. Two corrections to the pattern
came out of this batch, both from call sites:

  - Optionality. Batch 1 made a prop required when it had no default. That is
    wrong here: `AdminPage` has seventeen call sites and most pass only `title`.
    Required is now reserved for entity-typed props and `children` - what a
    component genuinely cannot render without - and everything else is optional,
    which is what the JavaScript always allowed.

  - Callback arity. `() => void` was too strict: `onOpen`, `onCreate` and
    `onBrowse` are called WITH arguments, and `University` passes a `useState`
    setter straight through, which has one parameter and is therefore not
    assignable to a zero-parameter type. Callbacks take `(...args: any[])`.

`RoleGlyph`'s `GLYPHS` table gets `[RegExp, ComponentType<any>][]` - the same
widening `aiEngine`'s router had, where the element becomes the union of both
positions and neither `pattern.test` nor `<Icon />` works. The author had
already written that exact type as a JSDoc comment; it is now the real
annotation and the comment is gone.

`ForgeHeader` receives `onBrowse` and destructures `_onBrowse`, so the prop is
passed and silently dropped - the same shape as `TalentHero`'s `jobRecs` in the
previous batch. Recorded rather than changed.

Two automated passes were reverted rather than shipped. One added `?` to object
members inside component bodies, not just interface fields, producing
`TS1162: An object member cannot be declared optional` - it was rerun scoped to
`interface XProps` blocks. The other was the generator itself, which annotated
only the FIRST component in each file and so missed `SectionTitle` in
`PageShell`; rewritten to walk every match and splice in reverse, it went from
14 components to 26 and took the error count from 93 to 38.

Two runtime imports were caught by the emitted-JavaScript check and would not
have been caught any other way. The generator added `import * as React` to
`RoleGlyph` for a type-only reference - a real import in the bundle - now
`import type { ComponentType }`. Fixing that, I then removed the React import
`PageShell` genuinely had; restored.

Verified: tsc 22 -> 22, set-difference showing zero introduced and zero removed;
zero errors in any of the 16 files; all 16 emit byte-identical JavaScript;
production bundle byte-identical; npm test 1684/1691 with the same seven
failures; Owliver baseline 59/59; lint 0 errors; baseline artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 11:54:53 +05:30
e7e1e9873f chore(ts-migration): migrate components/krow to TypeScript with real prop types
Phase 8, first batch: 56 files, plus three supporting edits outside the folder.
All 56 emit byte-identical JavaScript and the production bundle is byte-identical
to 543da9d.

This batch establishes the pattern for the remaining component work: real prop
interfaces built from actual call sites, reusing the generated entity types.
Fifty-four components now declare what they take -
`CandidateCard({ application: JobApplication, jobTitle?: string, rank: number })`,
`MatchedCandidates({ job: JobPosting, profiles: ProfileView[], ... })` - rather
than carrying a compatibility bag. Callbacks are optional because call sites
omit them; entity props are required because call sites always pass them. Where
a call site proved otherwise, the call site won: `ScheduleInterviewModal`'s
`position`, `CandidateCard`'s `jobTitle`, `TalentDetailModal`'s `matchScore` and
`matchReasons` are optional because real callers omit them.

Two view types absorb what the registry cannot describe. `ProfileView` and
`CourseView` narrow the `jsonb` columns these components read through -
`experience`, `completed_courses`, `earned_badges`, `capabilities`,
`score_breakdown`, `challenge`, `quiz` - to arrays and objects, while every
field backed by a real column keeps the registry's type. They are declared once
for the folder, and their narrowed fields stay REQUIRED: the registry has those
columns NOT NULL, and making them optional broke assignment back to the entity
where `ChallengeRunner` hands a course to a mutation.

TWO REAL DEFECTS FOUND, BOTH PRESERVED RATHER THAN FIXED:

  1. `SuggestedTalent` calls `matchTalent.mutate({...}).then(...)`. React
     Query's `mutate` returns `void`, so that `.then` throws at run time, and
     `runMatch` is reachable from a button. `mutateAsync` is what the code
     means. Swapping it turns a crash into a working feature, which is a
     product change, not a migration one - so it is cast to compile and left
     behaving exactly as it did. This one deserves a fix on its own terms.

  2. `TalentPoolCard` renders a location row behind `profile.location &&`, but
     `worker_profiles` has no `location` column - the registry has none and the
     API cannot send one, so the row has never rendered. Recorded as an
     optional field on `ProfileView` with a note, the same treatment as
     `user.avatar_url` in Phase 7.

Also fixed, all type-only: the Web Speech API declared as the optional `Window`
members `AIInterviewModal` already feature-detects; four `new Promise<void>`
where `resolve()` takes no argument; `toast`'s options bag made optional on
every method, which callers had always omitted; five `krowHooks` query
arguments made optional, which callers had always omitted.

One automated pass was reverted rather than shipped. A local-component
annotator captured words out of the preceding JSDoc as prop names - producing
`interface FrameProps { one?: any; every?: any }` from a sentence about
"one surface, one padding, every step" - the same class of regex error as
Phase 6's. The whole folder was restored from the index and the sound steps
re-run, then local components were annotated with an index-spliced rewrite that
reads only the destructuring.

Verified: tsc 35 -> 22, set-difference showing thirteen removed and none added;
zero errors in any of the 56 files; all 56 emit byte-identical JavaScript;
production bundle byte-identical; npm test 1684/1691 with the same seven
failures; Owliver baseline 59/59; lint 0 errors; baseline artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 11:39:58 +05:30
543da9d4e7 chore(ts-migration): migrate hooks and auth context to TypeScript
Phase 7. Four files, and the production bundle is byte-identical.

This is the phase where the generated entity types finally pay off. React Query
v5 infers a `mutationFn`'s parameter as `void` when nothing constrains it, so
every destructuring in `krowHooks` needed its shape written down - and those
shapes are real contracts, not guesses. Thirteen mutations now name what they
take, reusing `JobApplication`, `JobPosting`, `WorkerProfile` and `Course` from
`@/types/entities`: `useHireCandidate` takes `{ application, job }`,
`useCompleteCourse` takes `{ profile, course, quizScore }`. Five `{ id, data }`
mutations share one `IdPatch`, where `data` stays `any` on purpose - a PATCH
body is whichever fields the caller is changing, and naming a subset would
describe one call site rather than the endpoint.

Two local types absorb places where an object gains fields after it is built,
which TypeScript does not allow on a literal. `AssignmentEntry` declares
`application_id` and `application` as the alternatives they are - an existing
application named by id, or one described for the server to file in the same
transaction. `LearningProfile` narrows four `jsonb` columns the learning
mutations append to and spread; what the elements hold is still unstated,
because it still is.

`AuthContextValue` writes down the twelve keys every consumer reads. Two are
permanently inert and say so. `user` is `any` rather than `User`, and that is a
narrow, documented exception: `auth.me()` resolves either to the server record
or to the localStorage mirror over `DEMO_USER`, and `admin/Profile` reads
`user.avatar_url`, which is neither a column on `users` nor in the `/me`
projection - so typing it `User` would be accurate about the server and would
turn an always-undefined read into a compile error in a file this phase does not
touch.

`authError` is typed `{ type?: string } | null` rather than `null`, and
TypeScript is the reason. Typed as the provider actually behaves - always
`null` - it made `ProtectedRoute`'s `authError.type === 'user_not_registered'`
a property access on `never`: correct, and a report that the branch cannot be
reached in this build. The branch and `UserNotRegisteredError` are real, so what
a consumer may be handed is what is written down. That this build never produces
one is current behaviour, not the contract.

`use-size` gets a `Size` interface and a typed ref - a small, entirely clear
contract.

Thirteen JSDoc `@param {any}` comments became real annotations. That was not
cosmetic: left in place they changed esbuild's parenthesisation under the `.ts`
loader and put two redundant bytes into the production bundle. Converting them
brought the bundle back to byte-identical, which is how the difference was found
at all.

Verified: tsc 35 -> 35, set-difference showing zero introduced and zero removed;
zero errors in any Phase 7 file; production bundle byte-identical to d440036;
npm test 1684/1691 with the same seven failures; lint 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 00:26:24 +05:30
d440036211 chore(ts-migration): migrate UI primitives and design system to TypeScript
Phase 6. 57 files: 23 vendored shadcn primitives, 30 design-system components,
4 charts. Plus `src/components/ds/props.ts`, which is types only.

Renaming these alone took typecheck from 37 to 1008, and the reason is worth
recording because it is the shape of every remaining phase.

These components had NO prop contract. No PropTypes, no validation: in the
JavaScript every prop was optional and every extra prop was spread onto the
underlying element. TypeScript infers a destructured parameter WITHOUT a default
as REQUIRED, so the moment the files became `.tsx` it invented a rule the
components never had and rejected several hundred call sites that have always
worked. That is the compiler describing its own inference, not a defect it
found.

Three mechanical fixes, each restoring a contract that already existed:

  - 57 JSDoc `@type {React.ForwardRefExoticComponent<any>}` annotations become
    real TypeScript annotations. Those comments were the previous authors'
    deliberate compatibility types; JSDoc stops applying in a `.tsx` file, so
    converting them preserves an intent that was already written down.

  - 61 `React.forwardRef(...)` calls gain `<any, any>`. Without generics `ref`
    infers `ForwardedRef<unknown>`, which no element's `Ref<T>` accepts - so
    every primitive that forwards a ref to a `div` failed on the ref, not the
    props.

  - 78 component signatures take `DsProps`, a documented alias for
    `Record<string, any>`. It exists so the decision is recorded once and is
    greppable when someone tightens it, rather than being 78 bare `any`s with
    no explanation between them. The prop NAMES are not lost: every component
    still destructures them by name, which is where a reader looks.

Four files needed real types rather than compatibility ones. `ds/toast` takes
react-hot-toast's own `ToastOptions`, which narrows `position` to its
`ToastPosition` union instead of widening to `string` - the widening was what
made all six calls unassignable. `ds/Pagination`'s page range is genuinely
`(number | string)[]`, because it interleaves page numbers with '…' markers that
the renderer tests for. `ds/Field` narrows `children.props` at three reads, and
`ds/Avatar` needed the ref generic.

Two of my own automated passes were wrong and were caught rather than shipped. A
props-interface generator dropped alternating props, because non-overlapping
regex matches consume the separating comma - it made things worse (83 file
errors to 146) and was reverted wholesale. A second pass missed every
multi-line signature whose defaults contain a `)`, such as `onClose = () => {}`;
that needed a brace matcher rather than a character class.

56 of 57 files emit byte-identical JavaScript. The one exception is `ds/toast`,
where a JSDoc type CAST - `/** @type {ToastPosition} */ ('bottom-center')` -
became a real annotation, so the emitted output loses a comment and a pair of
now-redundant parentheses. The value is `"bottom-center"` either way; the
minified outputs differ only in esbuild's choice of mangled local names.

Verified: tsc 37 -> 35, set-difference showing zero introduced and two removed;
zero errors remain in any Phase 6 file; npm test 1684/1691 with the same seven
failures; lint 0 errors; build succeeds with the API origin inlined; baseline
artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 23:59:49 +05:30
2b8f5746bd chore(ts-migration): migrate domain logic to TypeScript
Phase 5. Twelve modules under `src/lib` and `src/lib/admin`. All twelve emit
byte-identical JavaScript; eight needed no annotation at all.

Where the generated entity types fit, they are used. `positionModel` is typed
against `JobPosting` — and that is where TypeScript earned its keep. Annotating
the label functions made `experienceLabel`'s `years === ''` guard a comparison
the compiler called impossible, because the registry types
`min_experience_years` as `number`, which is correct for a record the API has
returned. The guard is not dead: the same functions are handed drafts, and an
untouched numeric form input yields `''` — which is why `toPositionPayload`
coerces all five numerics with `Number(...)`.

So the module now has two types rather than one. `PositionRecord` is a saved
posting with the registry's column types; `PositionDraft` widens the five
numerics to `number | string` and is taken by `toPositionPayload` alone. The
one comparison the split cannot express keeps its guard and carries a cast with
the reason written next to it. Deleting a live guard to satisfy a type would be
the type rewriting the code.

`workforce` keeps its records as `any`: 574 lines of demand and availability
arithmetic over profiles, postings, assignments and staff read largely through
jsonb columns the registry does not describe. What IS described is the module's
own contract — the `WorkforceContext` option bag and the `Availability` result,
whose two shapes differ by whether a worker's commitments are known.

Two of my own type declarations were too narrow and were caught by the
set-difference rather than by inspection. `activitySignals`' accumulator seeds
`{ email, name, count, privileged }` and I had named only the two counters;
`WorkforceContext` omitted `profiles` and `courses`, which `PositionDetail`
passes in a single call with three more. The bag now carries an index signature,
because that is what the call site assumes: callers hand the whole thing over
and each function picks what it needs.

`skillGraph` gains a `SkillLevel` interface with an optional `earned`, set in a
second pass that stops at the first incomplete rung — so the levels above the
gap never receive it, and optional is the honest description.

Verified: tsc 40 -> 37, zero introduced; all twelve emitted outputs
byte-identical; npm test 1684/1691 with the same seven failures; lint 0 errors;
build succeeds with the API origin inlined; baseline artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 23:46:26 +05:30
3e654c2bf7 chore(ts-migration): migrate seed fixtures to TypeScript
Phase 4c, completing `src/api`. Both files emit byte-identical JavaScript -
86,831 and 6,449 bytes - and neither needed a single annotation: they are data
and pure helpers, and inference already describes them.

Neither reaches the production bundle. No module under `src` imports either one;
`base44Client` mentions `attendanceSeed` in a comment and nothing more. They
exist for `skill-check.mjs`, `owliver-capture.mjs` and `seed-fixture.mjs`, which
is why this was the safest phase in the whole migration and why it was left
until the transport layer was done.

`npm run seed:check` still answers "seed.json is stale", which it has since
before this migration began - the backend fixture drifted from `src/api/seed`
independently of any of this. What matters here is that it ANSWERS: the
generator loaded the renamed module through the Phase 0 resolver rather than
failing to find it.

tsc unchanged at 40, npm test 1684/1691 with the same seven failures, lint 0
errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 23:00:29 +05:30
1133eca16a chore(ts-migration): migrate base44 client to TypeScript
Phase 4b-ii, and the last file in the transport layer. Type-only: the plain
emitted JavaScript is byte-identical at 9,302 bytes.

This file owns the session, so the annotations stay at the edges and nothing
about the auth model moves. `SESSION_KEY`, the localStorage mirror, the three
module-level mutables, the shared hydration promise, the `{ ...user }` spreads,
`window.location.href` on logout and `window.location.reload()` in
`resetDemoData` are all exactly as they were.

Nine sites, eight of them parameter types. The ninth is `hydration`, which needs
`Promise<any> | null` because the runtime already assigns both: the shared first
`GET /me`, then `null` once it has been consumed or superseded by a login.
Inference would have fixed it at `Promise<any>` and rejected the assignments
that make the sharing work.

`entities` is deliberately NOT annotated, and neither is `createEntity`'s
return. A probe run during the inspection showed what annotating it costs:
`Record<EntityName, EntityClient<any>>` makes `list()` return `Promise<any[]>`
where inference gives `Promise<any>`, and `agentStore.js` does
`qc.getQueryData(KEY) || (await ...list(...))`. React Query types
`getQueryData` as `unknown`; `unknown || any` collapses to `any` while
`unknown || any[]` stays `unknown`, and `.find` on the next line stops
compiling. Two new errors in a file this phase does not migrate. The generated
record types and `EntityClientFor<K>` are ready for the phase that migrates the
forty-five call sites in `krowHooks.js`.

The auth returns are likewise left inferred rather than typed `User`. That was
checked, not assumed: `admin/Profile.jsx` reads `user.avatar_url`, which is
neither a column on `users` nor part of the `/me` projection in `me.go` - it is
always undefined at run time. Typing the return would have turned a latent dead
branch into two compile errors in a file this phase does not touch. Worth
knowing about separately; not this commit's business.

One stale comment is kept verbatim - `@param {any} request` above
`owliver.suggestions`, which names a parameter that does not exist. Removing it
was the only remaining difference in the emitted output, and byte-identity is
worth more here than tidying a comment. Flagged for a later docs pass.

Verified: tsc 42 -> 40 with a set-difference showing two removed and none added;
npm test 1684/1691, the same seven failures as the parent commit (six documented
as deliberate in scripts/__baseline__/README.md, one the stale backend fixture);
lint 0 errors; build succeeds with the API origin inlined. This is also the
first run of the suite with a source-text-inspected file migrated - the harness
fix in 02a2ab0 is what makes it possible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 22:59:15 +05:30
02a2ab05ef chore(ts-migration): resolve source-text reads across .js/.jsx/.ts/.tsx
The check suite consumes source files through two entirely separate channels,
and Phase 0 only hardened one of them.

`withSourceResolution` wraps `ssrLoadModule`, which covers the 159 module loads.
It does not and cannot see the other 30 sites, which read source as TEXT through
`readFileSync` to assert structural facts - "the panel imports no local
suggestion ranker", "no runtime path writes a definition". Renaming
`base44Client.js` to `.ts` is what surfaced the difference: ENOENT in the middle
of a suite that had been passing, from a line no grep for `ssrLoadModule` would
ever have found.

`resolveSourcePath()` in `ssr-resolve.mjs` reuses the existing `candidatesFor()`
ordering - the written path first, then `.ts`, then `.tsx` - and returns a path
relative to the root, so every call site keeps its `join(ROOT, ...)` as it was.
An unresolvable path comes back unchanged, which keeps the two absence
assertions honest: a check proving `store.js` is GONE still asks about the path
it means, and now also notices if the file returns under another extension.

Thirty-seven lines change, each a one-for-one replacement. No assertion text, no
record() message, no ordering, no logic.

The audit found the reads in four shapes, and two of them a path grep cannot
see:

  - direct       readFileSync(join(ROOT, 'src/x.jsx'), 'utf8')
  - via a const  const P = join(ROOT, 'src/x.jsx')
  - dir + name   ['node.js', 'patch.js'].map((f) => readFileSync(join(ROOT, 'src/lib/ui', f)))
  - path array   for (const f of files) readFileSync(join(ROOT, f))

The third and fourth hide thirteen filenames in adjacent arrays, which is why
the first estimate of this work was twenty-two files and the real number is
thirty-five.

One directory scan also filtered `/\.jsx?$/` over `src/pages/admin` and
`src/components/agents`. That one does not crash - it quietly matches nothing
once those directories are TypeScript, and the check passes having inspected an
empty set. Widened to `/\.[jt]sx?$/`. A silent shrink is worse than a failure,
and CI's FLOOR of 900 would not have caught it.

Deliberately NOT touched, because they are correctly extension-specific: the
`dist/assets` filter reads built bundles, which are `.js` whatever the source
was; `scripts/stubs/react-hot-toast.js` and `scripts/browser-flows.js` are
scripts, not migrated source; and every comment that mentions a `.js` filename
says something true about a file that still has that name.

Proven rather than assumed. `npm test` reports 1684/1691 before and after, the
same seven failures. Then `src/api/base44Client.js` - the exact file whose
rename broke the suite - was renamed to `.ts`, the suite re-run, and the check
that reads it as text passed: "the app does not import the seed fixture". The
rename was reverted and the file verified byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 22:56:45 +05:30
dca184289e feat(hiring): final-selection queue, honest seat counts, and human interviews
Authored in a parallel session alongside the TypeScript migration; committed
separately so the two never share a commit. No TypeScript migration file is
included here.

Candidates becomes the queue of hiring decisions waiting on a person, rather
than a second Talent Pool listing every application the org ever took. Final
selection is DERIVED - there is no `final_selection` value in the
`application_status` enum and none is added. The fact it reads is the existence
of an interview row, a NOT NULL foreign key, rather than
`job_applications.interview_id`, which the schema keeps as an unconstrained soft
reference precisely so it may dangle. `status = 'interview'` is set both when an
interview is arranged and when one is completed, so status alone cannot tell a
queue of people who have been interviewed from a queue of people merely booked
in.

Seats on a position are counted from the employment records instead of a stored
column. A `filled` counter would be a second source of truth, and the day it
disagreed with `staff` nothing could say which was lying. Someone who has left
frees their seat, and over-hiring floors at zero rather than going negative.

`DEMO_FILL` is gone. `hiringRecords.js` padded the hires list with five invented
people 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. Hires now come only from `staff`.

Both paths that file an application on somebody's behalf now carry
`worker_profile_id`, the link back to the talent-pool record. The column is
nullable, so omitting it saved cleanly and failed silently: the application
belonged to an email address rather than to a person, and the hire it became
could not be traced back to the profile it came from.

`HiredChronology` used to `return null` with no hires, taking the `chronology`
node identity out of the DOM with it - so on an honest empty dataset the section
could not be addressed by Owliver or the layout editor at all. It now renders an
empty state inside the section it keeps.

Nine new checks cover the above; `npm test` reports 1684/1691.

SIX SSR PARITY CHECKS FAIL ON PURPOSE, and `scripts/__baseline__/README.md`
documents each with verified tag counts. Five are the `DEMO_FILL` removal: the
Hired History and Analytics baselines were captured while the padding was in
effect and, because they render with queries disabled, the padding is all they
contain. The sixth is this change to what Candidates says. Do not regenerate
those baselines to clear them - two of the checks exist to prove the UI node
tree migration added exactly two `<div>`s, and that proof needs the baseline to
be pre-migration markup. Recapturing now would write post-migration markup into
a file named `pre-migration` and the check would compare the current render
against itself forever. The debt is held until the migration work lands, when
both files are recaptured together.

The seventh failure, the stale backend seed fixture, predates all of this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 22:52:51 +05:30
64140c7add chore(ts-migration): migrate ai engine to TypeScript
Phase 4b-i. Type-only, and the production bundle proves it: built from this
commit's parent and from this commit, all six chunk hashes match. The plain
emitted JavaScript is byte-identical at 34,095 bytes.

Four annotation sites, no logic touched:

  - `ROLE_TITLES` and `AVAILABILITY_TOKENS` get `[RegExp, string][]`. Left to
    inference the element widens to `string | RegExp`, which has no `.test`.
    Explicit tuples rather than `as const`, which would also have worked and
    would additionally have made the arrays readonly - a change to the type this
    module publishes for no benefit it uses.

  - `ROUTES` gets `[RegExp, (prompt: string) => any, number][]`, because all
    three positions are used for what they are: `.test()` on the first, a call
    on the second, `think(ms)` on the third.

  - `invokeLLM` gets a real parameter type. It inferred `{ prompt?: string }`
    from its own destructuring defaults, and that single inference was
    responsible for nine errors in files this commit does not touch - eight in
    `krowAi.js`, one in `provingGround.ts` - every one of them a caller passing
    `response_json_schema` or `model`, which the real integration accepts.
    Naming the options type fixes all nine from here.

  - `uploadFile` gets `{ file?: File }`.

The return type of `invokeLLM` stays `Promise<any>`, deliberately. The ten
handlers behind the router return ten different shapes, and `krowAi.js` branches
on the result at run time - `typeof res === 'string' ? res : res.text || String(res)`
- which a precise union would reject on every branch without a `.text`. The
looseness is the contract, not an omission.

`InvokeLLMOptions` is exported as a type only; the runtime exports are still
exactly `invokeLLM` and `uploadFile`.

Verified in isolation from the parallel feature work (f96f128 plus this file):
tsc 53 -> 37, a set-difference against the baseline showing sixteen removed and
none added; skill-check 1641/1642 with only the known stale-fixture failure;
Owliver baseline 59/59; lint 0 errors; build succeeds with the API origin
inlined; the nine Owliver baseline artifacts unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 17:23:28 +05:30
f96f128839 chore(ts-migration): migrate API transport to TypeScript
Phase 4a: `demoUser` and `httpClient`. Type-only. The production bundle is
byte-identical - built from this commit's parent and from this commit, all six
chunk hashes match.

`httpClient` is the contract boundary, so the annotations are deliberately
conservative:

  - `KrowApiError` is a CAST, not a class. The three error sites still build a
    plain `Error` and assign `.name`, `.status`, `.code` and `.details` onto it
    exactly as before. `class KrowApiError extends Error` would have read
    better and changed three things that callers depend on: the prototype
    chain, `instanceof`, and how `name` comes to be set.

  - `RESOURCE_PATHS` becomes `Record<EntityName, EntityResourcePath>`. That is
    the first thing in the repo to check the eighteen entity names against the
    eighteen in `ENTITY_NAMES`; until now the two lists agreed only by habit,
    and a divergence would have surfaced as `base44.entities.Whatever` being
    undefined with nothing to say why.

  - `createEntity`'s parameter stays `string` and its return stays inferred.
    Annotating the return `EntityClient<any>` was tried and reverted: it makes
    `list()` return `Promise<any[]>` where inference gives `Promise<any>`, and
    `agentStore.js` does `qc.getQueryData(KEY) || (await ...list(...))`. React
    Query types `getQueryData` as `unknown`; `unknown || any` collapses to
    `any`, `unknown || any[]` stays `unknown`, and `.find` on the next line
    stopped compiling. Two new errors in a file this phase does not migrate,
    for no gain. `EntityClientFor<K>` is ready for the phase that migrates
    those consumers.

Unchanged and verified in the emitted output: `credentials: 'include'`, both
header branches on `body === undefined`, the URLSearchParams query encoding with
its repeated-array and undefined-omission rules, the `payload.data` unwrap that
drops `meta`, the verbatim server message, `status: 0` / `code: 'unreachable'`
for a transport failure, the `-created_date` and `limit` defaults, path
construction through `encodeURIComponent`, and `bulkCreate`'s sequential
`this.create` loop.

`DEMO_USER` is annotated `User`, which does real work: unannotated,
`role: 'admin'` widens to `string` and the default shape did not satisfy the
type the app uses for the thing it defaults.

Verified in isolation from the parallel feature work (d1425f9 plus these two
files): tsc 64 -> 53, the eleven removed being exactly this file's, and a
set-difference against the baseline showing none added. skill-check 1641/1642
with only the known stale-fixture failure, Owliver baseline 59/59, lint 0
errors, build succeeds with the API origin inlined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-17 12:16:16 +05:30
d1425f974c chore(ts-migration): add generated entity types
Phase 3.5. Entity record shapes, generated from the backend's resource
registry rather than transcribed from it. Type-only: every touched file emits
byte-identical JavaScript, and the two type modules emit nothing at all.

`scripts/gen-entity-types.mjs` reads
`krow-backend/go-api/internal/domain/resources_gen.go` - itself generated out of
information_schema, so it cannot drift from the migrations - and writes
`src/types/entities.generated.ts`: 15 resources, 298 columns. It checks for
drift by default and rewrites with --write, the same arrangement seed-fixture.mjs
uses, and skips cleanly when the backend is not checked out beside this repo.

Field types come from `Column.SelectExpr()` in `domain/resource.go`, which is
what the read projection actually emits, not from the Postgres type. The two
differ: uuid and citext are cast to text, numeric to float8, dates and
timestamps to formatted strings, and - the case that justifies generating rather
than typing by hand - `user_activity.id` is an identity bigint cast to text, so
it arrives as a STRING. Written by hand it would have been called a number, and
nothing would have contradicted that until a comparison quietly stopped
matching.

The five Phase 3 leaf utilities swap their `any` placeholders for these records,
as `Partial<...>`: each takes `= {}` or guards every read because it renders
before the query resolves, and requiring the whole record would force those
defaults out - a behaviour change in a scoring path.

jsonb is where the generator stops. Thirteen columns across eight entities are
typed `unknown`, correctly: what sits inside a jsonb column is not in
information_schema and nothing on the backend declares it. Where a module reads
through one it is narrowed to `unknown[]`, `any[]` or `any` - what kind of value
it is, and nothing about its contents. An earlier draft declared the fields
these modules read off them; it was removed. That would have been inventing a
schema the database does not hold, with the compiler then defending the guess.

Not wired into skill-check.mjs: that file is being changed concurrently by
unrelated feature work. Adding `"types:check": "node scripts/gen-entity-types.mjs"`
beside the existing seed:check is the natural next step and is deliberately left
for when that file is quiet.

Verified in isolation from the parallel feature work (commit 1775395 plus these
eight files only): tsc 64 errors with an unchanged histogram, skill-check
1641/1642 with only the known stale-fixture failure, Owliver baseline 59/59,
lint 0 errors, production build succeeds with the API origin inlined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-15 11:49:56 +05:30
1775395256 chore(ts-migration): establish TypeScript migration checkpoint
Phases 0-3 of the JS/JSX -> TS/TSX migration. No runtime behaviour changes:
every converted file emits byte-identical JavaScript, verified file by file.

Phase 0 - harness hardening, before any rename:
  - scripts/ssr-resolve.mjs wraps `ssrLoadModule` so the ~170 literal module
    paths in the check scripts resolve .js/.jsx/.ts/.tsx. Without it the first
    rename would have silently destroyed the 1642-check suite that guards the
    Owliver flow.
  - eslint.config.js gains a TypeScript block. Its `files` globs listed only
    {js,mjs,cjs,jsx}, so a renamed file would have dropped out of the run while
    `eslint .` went on exiting 0 - the quietest failure mode available.
  - MIGRATION_BASELINE.md records the measured starting point, including the
    pre-existing seed-fixture failure and the already-broken standalone
    owliver-baseline.mjs, so neither is later mistaken for migration damage.

Phase 1 - tsconfig.json succeeds jsconfig.json, carrying every option across at
its old value. `types` moves from [] to ["vite/client"], which fixes the eight
import.meta errors; @types/node is deliberately excluded so setTimeout stays a
number in browser code. allowJs and checkJs stay on, strict stays off.

Phase 2 - src/types/{api,entities,user}.ts. Transport envelope, error shape,
the entity-name union (the same 18 names are written down twice today, in
httpClient and base44Client, with nothing checking they agree), and the user
record. Every field transcribed from the API contract, the migrations and the
/me projection in me.go - not inferred. Entity record shapes are deliberately
absent: derivable, but nothing consumes them yet.

Phase 3 - nine leaf utilities renamed to .ts with annotations added only where
they could be established from existing usage. Entity records are typed `any`
with a comment naming what they are, rather than a guessed interface.

Verified: tsc 64 errors (65 before; one pre-existing TS2559 genuinely fixed,
none introduced), lint unchanged at 0 errors, skill-check 1641/1642 with only
the known failure, Owliver baseline section 59/59 green, production build
succeeds with the API origin inlined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-11 15:52:01 +05:30
f522b6508e archive issue fix
Some checks failed
CI / check (push) Failing after 5m5s
2026-09-10 19:29:39 +05:30
6249e00a3a candidates and board ui agent issue
Some checks failed
CI / check (push) Failing after 4m58s
2026-09-05 10:46:06 +05:30
e02a0c23d4 Make a conversation a registry, and add the second one
Some checks failed
CI / check (push) Failing after 4m57s
`positionFlow.js` was five per-domain concerns in one file — a field table, an
`@`-token resolver, a sentence extractor, a commit vocabulary and a set of
outcome renderers — and only the control flow between them was general.
Everything else knew it was creating a job posting. Adding a second
conversation meant a second copy of all of it.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-09-02 15:29:48 +05:30
1a0dc7e5f1 Settle subagents: it means delegate to, not borrow skills from
Some checks failed
CI / check (push) Has been cancelled
The key had two incompatible meanings running at once. CLAUDE.md §3 defines
subagents as "keys of other specs this may DELEGATE to" and §6 as a tool call
from the parent's perspective — the subagent runs its own turn, as the same
caller, out of the parent's budget, and returns an answer. The backend
implements exactly that.

This side did something else: agentSkillIds folded one level of subagent skills
into the parent's carried set, so a parent silently gained everything its
subagents carried, and the UI described it that way — "other agents whose
skills this one may also use", "borrowing them cannot reach data this page does
not hold".

Both are defensible readings. Only one is the specification, and running both
meant krow-workforce-agent carried eight delegation tools AND the flattened
skills of those same eight agents — able to answer a question directly or to
ask an agent that had already lent it the means to answer. Two ways to do one
thing, differing in cost and in what the trajectory records.

So an agent carries what it declares. Reaching another agent is delegation,
which the runtime does with its own budget and its own trajectory.

The blast radius was one check, which is the useful part of the answer: only
"a subagent cycle terminates" depended on the folding, because that traversal
was the only thing that could loop. Nothing walks subagents here now, so the
cycle question moved to where it belongs — refused at publish by
definition.FindSubagentCycle, bounded at run time by the depth cap. The
replacement checks assert the new meaning rather than deleting the old ones,
because the previous behaviour reads as perfectly reasonable and will be
reinvented otherwise.

The `agents` parameter stays on agentSkillIds and is no longer read. Removing
it is a wider edit for no behavioural gain, and agentScopedDisabledWith exists
precisely to pass it.

924/924 checks pass; production build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-08-29 15:29:09 +05:30
1d35358dd6 Carry a routed question across the move, and let the Test tab actually test
TWO fixes, both found by using the product rather than reading it.

A question asked on a page that cannot answer it was lost. resolveIntent routes
to the page that can, and routing.js answered "That is on Candidates. Taking
you there now. Ask again once the page loads." — but the panel is page-scoped,
so it re-mounts on the new route and the navigation destroys the very message
explaining why the reader moved. What the reader saw was a different page and a
fresh greeting, with no trace of what they asked.

The question now travels with the destination. AssistantPanelContext sits ABOVE
the router and already has `ask` for exactly this — a page handing a question to
the panel — so the panel that mounts on the other side asks it. That is what
the reader wanted, and what "ask again once the page loads" was apologising for.
It cannot loop: resolveIntent only routes when the destination differs from the
page you are on, and answerableHere short-circuits before that.

The Test tab did not test. "Simulation & Scope Diagnostics" reads where a
question WOULD route — which skills are reachable, which tools are in scope,
what the classifier makes of it — and never calls the model. That is genuinely
useful and it is not what a tab called Test leads anyone to expect. A real test
did exist, but under Skills, as "Test in Owliver" on a capability card.

So the Simulated User Query the tab has always shown is now runnable, through
the same path that card uses: the existing Owliver panel, scoped to the draft's
own agent and disabled skills, so the answer comes from the agent being edited
rather than the published one. The diagnostics stay — they answer a different
and still useful question. The button is disabled when the agent does not cover
the selected page, with the reason in its title, rather than offering a run that
would decline.

924/924 checks pass and the production build is clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-08-29 15:13:53 +05:30
363 changed files with 26102 additions and 3756 deletions

209
MIGRATION_BASELINE.md Normal file
View 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.

View File

@@ -19,22 +19,22 @@ Recharts + MUI X Charts · react-hot-toast.
```
src/
main.jsx entry — mounts <App/>, imports index.css
App.jsx providers + the entire route table
main.tsx entry — mounts <App/>, imports index.css
App.tsx providers + the entire route table
index.css design tokens (HSL CSS variables) + utility layers
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
aiEngine.js local, deterministic InvokeLLM
seed.js demo dataset
attendanceSeed.js shift/attendance dataset
aiEngine.ts local, deterministic InvokeLLM
seed.ts demo dataset
attendanceSeed.ts shift/attendance dataset
lib/ domain logic and data hooks
krowHooks.js React Query hooks over the entity store
krowAi.js AI workflows
krowScore.js score engine
AuthContext.jsx auth provider
krowHooks.ts React Query hooks over the entity store
krowAi.ts AI workflows
krowScore.ts score engine
AuthContext.tsx auth provider
admin/ admin session, permissions, position insights
skills/ the Owliver skill system (registry, resolver, flows)
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/owliver/new`, `owliver/:id` | Owliver skill editor |
| `/admin/workspace/skill-development` | Skill Development |
| `*` | `lib/PageNotFound.jsx` |
| `*` | `lib/PageNotFound.tsx` |
Legacy top-level paths (`/overview`, `/positions`, `/candidates`, `/hired`, `/talent-pool`,
`/university`, `/analytics`, `/activity`, `/tracking`) all `Navigate` into their `/admin/*`
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"`.
> **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`, …)
> 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.
## 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:
| Contract | Demo implementation |
| --- | --- |
| `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.auth.*` | seeded employer/admin user, always signed in |
Because the seam did not move, everything above it — `lib/krowHooks.js`, `lib/krowAi.js`,
`lib/krowScore.js`, every page and component — is unchanged from the reference implementation.
Because the seam did not move, everything above it — `lib/krowHooks.ts`, `lib/krowAi.ts`,
`lib/krowScore.ts`, every page and component — is unchanged from the reference implementation.
## The AI engine
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`,
`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 |
| --- | --- | --- |
| `lib/skills/registry.js` | `import.meta.glob('/src/skills/**/*.md')` | 18 Owliver skills, 5 workforce training skills |
| `lib/agents/registry.js` | `import.meta.glob('/src/agents/**/*.md')` | 9 agent definitions |
| `lib/skills/registry.ts` | `import.meta.glob('/src/skills/**/*.md')` | 19 Owliver skills, 5 workforce training skills |
| `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
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
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
`src/api/attendanceSeed.js`.
`src/api/attendanceSeed.ts`.
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 |
`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
`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
`App.jsx`.
`App.tsx`.
## 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 lint` | ESLint — currently clean |
| `npm run lint:fix` | ESLint with `--fix` |
| `npm test` | `scripts/skill-check.mjs` — 835 assertions over the skill and agent systems |
| `npm run typecheck` | `tsc -p ./jsconfig.json` with `checkJs` |
| `npm test` | `scripts/skill-check.mjs` — 1693 assertions over the skill and agent systems |
| `npm run typecheck` | `tsc -p ./tsconfig.json` — 0 errors across 321 TypeScript files |
### 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.
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/layouts/AdminLayout.jsx` and a handful of agent files. Move any of them and update
`src/lib/skills/`, `src/lib/agents/`, `src/components/ai-assistant/`, `src/App.tsx`,
`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.
### 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.
- `npm run typecheck` — 59 errors, all pre-existing JSDoc/inference gaps in `lib/krowAi.js`,
`lib/skills/*`, `lib/positionModel.js` and a few components.
This section used to list two known failures — 834/835 on the suite, and 59 type
errors across `lib/krowAi`, `lib/skills/*` and `lib/positionModel`. Both are
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

View File

@@ -2,7 +2,7 @@
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",

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

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

View File

@@ -3,16 +3,68 @@ import pluginJs from "@eslint/js";
import pluginReact from "eslint-plugin-react";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginUnusedImports from "eslint-plugin-unused-imports";
import tseslint from "typescript-eslint";
/**
* The rule set, written once and applied to JavaScript and TypeScript alike.
*
* Shared rather than duplicated because the two blocks below differ in exactly
* one thing — which parser reads the file — and a rule that applied to `.jsx`
* but not to its `.tsx` successor would make the TypeScript migration look like
* it was tidying the code up. It is not; it renames files and adds types.
*/
const rules = {
"no-unused-vars": "off",
"react/jsx-uses-vars": "error",
"react/jsx-uses-react": "error",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"react/prop-types": "off",
"react/react-in-jsx-scope": "off",
"react/no-unknown-property": [
"error",
{ ignore: ["cmdk-input-wrapper", "toast-close"] },
],
"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: [
"src/components/**/*.{js,mjs,cjs,jsx}",
"src/pages/**/*.{js,mjs,cjs,jsx}",
"src/layouts/**/*.{js,mjs,cjs,jsx}",
"src/hooks/**/*.{js,mjs,cjs,jsx}",
],
ignores: ["src/lib/**/*", "src/components/ui/**/*"],
files: filesWith("js,mjs,cjs,jsx"),
ignores,
...pluginJs.configs.recommended,
...pluginReact.configs.flat.recommended,
languageOptions: {
@@ -20,42 +72,50 @@ export default [
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
ecmaFeatures: { jsx: true },
},
},
settings: {
react: {
version: "detect",
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 },
},
},
plugins: {
react: pluginReact,
"react-hooks": pluginReactHooks,
"unused-imports": pluginUnusedImports,
},
settings,
plugins: { ...plugins, "@typescript-eslint": tseslint.plugin },
rules: {
"no-unused-vars": "off",
"react/jsx-uses-vars": "error",
"react/jsx-uses-react": "error",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"react/prop-types": "off",
"react/react-in-jsx-scope": "off",
"react/no-unknown-property": [
"error",
{ ignore: ["cmdk-input-wrapper", "toast-close"] },
],
"react-hooks/rules-of-hooks": "error",
...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",
},
},
];

View File

@@ -12,6 +12,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -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"]
}

1285
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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.
## 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
`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.
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.
## 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -9,6 +9,7 @@
"bartending-training",
"candidate-analysis",
"candidate-search",
"create-employee-role",
"create-position",
"customer-service-training",
"executive-summary",
@@ -161,6 +162,7 @@
"pageKey": "positions",
"route": "/admin/positions",
"skills": [
"create-employee-role",
"create-position",
"hiring-activity-assistant",
"staffing-risk"
@@ -707,6 +709,7 @@
"pageKey": "talent-pool",
"route": "/admin/talent-pool",
"skills": [
"create-employee-role",
"talent-pool-analysis"
],
"suggestions": [

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

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

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

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

View 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.');
}
}

View File

@@ -16,6 +16,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { createServer } from 'vite';
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
import { withSourceResolution } from './ssr-resolve.mjs';
const ROOT = process.cwd();
@@ -26,6 +27,11 @@ const server = await createServer({
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);
await server.close();

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

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

View File

@@ -20,6 +20,7 @@ import { createServer } from 'vite';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { withSourceResolution } from './ssr-resolve.mjs';
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) {
const { ShiftRecord, ...entities } = seedModule.seedData;
/* First key, so it is the first thing anyone opening the file reads. The Go
loader unmarshals into a struct of demoUser + entities and ignores the
rest, so this costs nothing on the reading side. */
return { _generated: GENERATED_NOTE, demoUser: seedModule.DEMO_USER, entities };
/* `_generated` first, so it is the first thing anyone opening the file reads.
`users` is the channel the seeder actually reads — `internal/seeder`
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. */
@@ -51,6 +73,9 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const server = await createServer({
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);
await server.close();

File diff suppressed because it is too large Load Diff

108
scripts/ssr-resolve.mjs Normal file
View 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;
}

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

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

View File

@@ -25,6 +25,7 @@ import WorkerProfile from '@/pages/WorkerProfile';
import KrowIdentity from '@/pages/KrowIdentity';
import Owliver from '@/pages/Owliver';
import EmployeeDashboard from '@/pages/EmployeeDashboard';
import OpportunityDetail from '@/pages/OpportunityDetail';
import DesignSystem from '@/pages/DesignSystem';
/* Admin product — its own layout and route tree, so the Admin redesign can
@@ -37,6 +38,7 @@ import AdminCandidateProfile from '@/pages/admin/CandidateProfile';
import AdminCandidatesAnalysis from '@/pages/admin/CandidatesAnalysis';
import AdminHiredHistory from '@/pages/admin/HiredHistory';
import AdminTalentPool from '@/pages/admin/TalentPool';
import AdminTalentProfile from '@/pages/admin/TalentProfile';
import AdminAnalytics from '@/pages/admin/Analytics';
import AdminActivity from '@/pages/admin/Activity';
import AdminProfile from '@/pages/admin/Profile';
@@ -98,8 +100,20 @@ const AuthenticatedApp = () => {
screening dimensions, which a 480px column cannot show without
hiding most of it. */}
<Route path="candidates/:id" element={<AdminCandidateProfile />} />
{/* Candidate Analysis. A sibling segment, not `candidates/:id`:
the analysis reads the whole pool, so it is not addressed by a
candidate id and must not be matched as one. The route is the
one the `candidates-analysis` surface declares, so the skill
table, the assistant placement table and this agree. */}
<Route path="candidates-analysis" element={<AdminCandidatesAnalysis />} />
<Route path="hired" element={<AdminHiredHistory />} />
<Route path="talent-pool" element={<AdminTalentPool />} />
{/* 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/:id" element={<CourseDetail />} />
<Route path="analytics" element={<AdminAnalytics />} />
@@ -131,6 +145,12 @@ const AuthenticatedApp = () => {
</Route>
{/* Redirect top-level legacy aliases to the unified global shell */}
{/* Employee (talent) opportunity flow — full-page routes, no drawer.
Standalone (outside AdminLayout/AdminRoute, which are the employer
shell and gate); authenticated via ProtectedRoute. */}
<Route path="/employee" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-5xl mx-auto px-4 py-8"><EmployeeDashboard /></div></div>} />
<Route path="/opportunities/:id" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-3xl mx-auto px-4 py-8"><OpportunityDetail /></div></div>} />
<Route path="/apply" element={<div className="min-h-screen bg-[#F8FAFC]"><div className="max-w-3xl mx-auto px-4 py-8"><Apply /></div></div>} />
<Route path="/overview" element={<Navigate to="/admin" replace />} />
<Route path="/positions" element={<Navigate to="/admin/positions" replace />} />
{/* `/positions` only matches the exact path, so the sub-routes need

View File

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

View File

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

View File

@@ -13,7 +13,7 @@
* 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. */
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 ──────────────────────────────────────────────── */
/** 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'));
return match ? match[1].trim() : '';
}
function numField(prompt, label) {
function numField(prompt: string, label: string): number {
const raw = field(prompt, label);
const match = raw.match(/-?\d+(\.\d+)?/);
return match ? Number(match[0]) : 0;
}
/** 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);
if (!raw || /^(none|not specified|not provided)$/i.test(raw)) return [];
return raw.split(',').map((s) => s.trim()).filter(Boolean);
}
/** 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*"""/);
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 ────────────────────────────────────────────── */
function screenCandidate(prompt) {
function screenCandidate(prompt: string) {
// Weights are stated inline: "experience 25%, english 20%, …"
const weightOf = (name) => {
const weightOf = (name: string) => {
const match = prompt.match(new RegExp(`${name} (\\d+)%`));
return match ? Number(match[1]) : 20;
};
@@ -184,7 +184,7 @@ function screenCandidate(prompt) {
/* ── 2. Job description generator ──────────────────────────────────────── */
const RESPONSIBILITY_LIBRARY = {
const RESPONSIBILITY_LIBRARY: Record<string, string[]> = {
Bartender: [
'Set up and break down a full service bar',
'Pour to spec at event pace without sacrificing presentation',
@@ -236,7 +236,7 @@ const DEFAULT_RESPONSIBILITIES = [
'Complete close-out duties before leaving',
];
function generateJobDescription(prompt) {
function generateJobDescription(prompt: string) {
const title = field(prompt, 'Role Title');
const category = field(prompt, 'Role Category');
const minYears = numField(prompt, 'Min Experience');
@@ -301,7 +301,7 @@ const CERT_KEYWORDS = [
['food handler', 'Food Handler Card'], ['forklift', 'Forklift Operator'], ['osha', 'OSHA 10'],
];
function buildResume(prompt) {
function buildResume(prompt: string) {
const text = quotedBlock(prompt);
const lower = text.toLowerCase();
@@ -343,14 +343,14 @@ function buildResume(prompt) {
const INTERVIEW_QUESTIONS = {
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?`,
() => `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?`,
() => `Last one — what does your schedule realistically look like, and what do you want to be doing a year from now?`,
],
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?`,
() => `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?`,
@@ -358,7 +358,7 @@ const INTERVIEW_QUESTIONS = {
],
};
function interviewQuestion(prompt) {
function interviewQuestion(prompt: string) {
const lang = isSpanish(prompt) ? 'es' : 'en';
const numberMatch = prompt.match(/Ask question (\d+) of 5/);
const index = Math.min(4, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
@@ -369,7 +369,7 @@ function interviewQuestion(prompt) {
const OWLIVER_QUESTIONS = {
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.`,
() => `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?`,
@@ -377,7 +377,7 @@ const OWLIVER_QUESTIONS = {
() => `That's everything I needed. Anything you want to add before I put this together?`,
],
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.`,
() => `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?`,
@@ -386,7 +386,7 @@ const OWLIVER_QUESTIONS = {
],
};
function owliverQuestion(prompt) {
function owliverQuestion(prompt: string) {
const lang = isSpanish(prompt) ? 'es' : 'en';
const numberMatch = prompt.match(/you are on question (\d+)/);
const index = Math.min(5, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
@@ -398,7 +398,7 @@ function owliverQuestion(prompt) {
/* ── 5. Interview evaluation ───────────────────────────────────────────── */
/** 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 answers = transcript
.split('\n')
@@ -412,7 +412,7 @@ function readTranscript(prompt) {
return { answers, words, avgWords, specifics };
}
function evaluateInterview(prompt) {
function evaluateInterview(prompt: string) {
const { answers, avgWords, specifics } = readTranscript(prompt);
const fastMatch = prompt.match(/(\d+) candidate response\(s\) were suspiciously fast/);
const fast = fastMatch ? Number(fastMatch[1]) : 0;
@@ -489,9 +489,14 @@ function evaluateInterview(prompt) {
/* ── 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/);
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 {
pool = block ? JSON.parse(block[1]) : [];
} catch {
@@ -513,9 +518,9 @@ function matchTalent(prompt) {
? Math.min(20, ((p.experience_years || 0) / minYears) * 20)
: 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
? (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);
const scoreFit = ((p.krow_score || 0) / 100) * 20;
@@ -547,7 +552,11 @@ function matchTalent(prompt) {
/* ── 7. Owliver profile builder ────────────────────────────────────────── */
/** 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'],
[/executive chef/, 'Executive Chef'],
[/sous chef/, 'Sous Chef'],
@@ -563,7 +572,8 @@ const ROLE_TITLES = [
[/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'],
[/weekday|monday|tuesday|wednesday|thursday|friday|semana/i, 'Weekdays'],
[/evening|night|noche|tarde/i, 'Evenings'],
@@ -572,7 +582,7 @@ const AVAILABILITY_TOKENS = [
[/on.?call|flexible/i, 'On-Call'],
];
function buildCareerDna(prompt) {
function buildCareerDna(prompt: string) {
const transcript = quotedBlock(prompt);
const lower = transcript.toLowerCase();
const { answers, avgWords, specifics } = (() => {
@@ -696,7 +706,7 @@ function buildCareerDna(prompt) {
/* ── 8. Proving Ground evaluator ───────────────────────────────────────── */
function evaluateChallenge(prompt) {
function evaluateChallenge(prompt: string) {
const criteriaMatch = prompt.match(/rubric" object: (.+?)\.\n/);
const criteria = criteriaMatch
? 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';
// Spread the overall score across the rubric so criteria are not identical.
const rubric = {};
const rubric: Record<string, number> = {};
criteria.forEach((criterion, i) => {
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
* 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 an expert hiring copywriter/m, generateJobDescription, 1400],
[/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],
];
/** 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));
if (!route) {
@@ -800,7 +842,7 @@ export async function invokeLLM({ prompt = '', response_json_schema: schema } =
}
/** Drop-in replacement for `integrations.Core.UploadFile`. */
export async function uploadFile({ file } = {}) {
export async function uploadFile({ file }: { file?: File } = {}) {
await think(500);
if (!file) return { file_url: '' };
// A blob URL keeps uploaded media viewable for the rest of the session

View File

@@ -39,15 +39,15 @@ const WINDOW_DAYS = 56;
* 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.
*/
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());
d.setDate(d.getDate() - n);
d.setHours(hour, minute, 0, 0);
return d;
}
const round1 = (n) => Math.round(n * 10) / 10;
const round2 = (n) => Math.round(n * 100) / 100;
const round1 = (n: number) => Math.round(n * 10) / 10;
const round2 = (n: number) => Math.round(n * 100) / 100;
const HOUR = 60 * 60 * 1000;
/**
@@ -105,10 +105,21 @@ const ROSTER = [
* Returning a plain record keeps every rule visible in one place instead of
* 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
the nights events actually overrun. */
staff_marco: (i, weekday) => ({
staff_marco: (i: number, weekday?: number) => ({
status: i === 14 ? 'late' : 'present',
minutesLate: i === 14 ? 9 : 0,
/* 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
* specific enough to act on.
*/
staff_marcus: (i) => {
staff_marcus: (i: number) => {
if (i === 2 || i === 7) {
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
* 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 busy = weekday === 4 || weekday === 5 || weekday === 6;
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. */
function shiftOffsets(weekdays, anchor) {
const offsets = [];
function shiftOffsets(weekdays: number[], anchor: Date) {
const offsets: number[] = [];
for (let offset = 0; offset <= WINDOW_DAYS; offset += 1) {
const day = daysAgo(offset, 9, 0, anchor).getDay();
if (weekdays.includes(day)) offsets.push(offset);
@@ -163,16 +174,16 @@ function shiftOffsets(weekdays, anchor) {
return offsets;
}
const pad = (n) => String(n).padStart(2, '0');
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const pad = (n: number) => String(n).padStart(2, '0');
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
* the point of this collection; a caller passes one only to hold the window
* still — see buildShiftsAt.
*/
function buildShifts(anchor = new Date()) {
const records = [];
function buildShifts(anchor: Date = new Date()) {
const records: any[] = [];
for (const worker of ROSTER) {
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
* different windows.
*/
export function buildShiftsAt(anchor) {
export function buildShiftsAt(anchor: Date) {
return buildShifts(anchor);
}

View File

@@ -22,6 +22,7 @@
*/
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
import type { PreferencesUpdateResult } from '@/types/user';
import { invokeLLM, uploadFile } from './aiEngine';
import { DEMO_USER } from './demoUser';
@@ -29,6 +30,11 @@ const ENTITY_NAMES = [
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
'Course', 'Badge', 'LearningPath', 'Certification', 'RoleCategory',
'UserActivity', 'Evidence', 'User',
/* What a worker declares they DO — role, experience, desired pay,
availability. The supply side of JobPosting, which is what the organization
needs filled. The two meet through JobApplication, not through a reference
between them. */
'EmployeeRole',
/* Who is on which position, and for how long. The record that turns "hired"
into workforce allocation: without it a position knows its demand and its
applicants but not who is actually covering it. */
@@ -138,14 +144,18 @@ let authenticated = false;
* 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.
*/
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) => {
currentUser = user;
authenticated = true;
cacheUser();
return user;
})
.catch(() => {
.catch((): null => {
authenticated = false;
return null;
});
@@ -199,7 +209,11 @@ const auth = {
* by design: telling the two apart would say whether an address 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', {
body: { email, password, remember_me: Boolean(rememberMe) },
});
@@ -210,7 +224,7 @@ const auth = {
return { ...user };
},
async updateMe(patch) {
async updateMe(patch: any) {
const user = await request('PATCH', '/me', { body: patch });
currentUser = user;
cacheUser();
@@ -242,7 +256,7 @@ const auth = {
* question the shape exists to answer is now answered by whether this
* function resolved at all.
*/
async updatePreferences(patch) {
async updatePreferences(patch: any): Promise<PreferencesUpdateResult> {
const preferences = await request('PATCH', '/me/preferences', { body: patch });
currentUser = { ...currentUser, preferences };
cacheUser();
@@ -269,7 +283,7 @@ const auth = {
* 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.
*/
async logout(redirectTo = '/admin/login') {
async logout(redirectTo: string = '/admin/login') {
try {
await request('POST', '/auth/logout');
} catch {
@@ -315,7 +329,7 @@ const workflows = {
* 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.
*/
async hire(applicationId, body = {}) {
async hire(applicationId: string, body: any = {}) {
return request('POST', `/job-applications/${encodeURIComponent(applicationId)}/hire`, {
body,
});
@@ -331,7 +345,7 @@ const workflows = {
* inside the same transaction; a worker taken straight from the talent pool
* 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`, {
body: { workers },
});
@@ -363,7 +377,7 @@ const workflows = {
*/
const owliver = {
/** @param {any} request */
async suggestions({ page, query = '' } = {}) {
async suggestions({ page, query = '' }: { page?: string; query?: string } = {}) {
if (!page) return [];
const typed = String(query || '').trim();
const data = await request('GET', '/owliver/suggestions', {
@@ -383,7 +397,7 @@ const integrations = {
};
const analytics = {
track({ eventName, properties } = {}) {
track({ eventName, properties }: { eventName?: string; properties?: any } = {}) {
if (import.meta.env.DEV) {
console.debug('[krow-demo] analytics', eventName, properties || {});
}

View File

@@ -1,3 +1,5 @@
import type { User } from '@/types/user';
/**
* 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
* `undefined`.
*/
export const DEMO_USER = {
export const DEMO_USER: User = {
id: 'user_demo',
full_name: 'Alex Rivera',
email: 'demo@krow.app',
@@ -37,3 +39,29 @@ export const DEMO_USER = {
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,
},
};

View File

@@ -27,6 +27,9 @@
* did not answer.
*/
import type { ApiErrorResponse, KrowApiError } from '@/types/api';
import type { EntityName, EntityResourcePath } from '@/types/entities';
/**
* Where the API lives.
*
@@ -93,12 +96,19 @@ if (import.meta.env?.DEV && /^https?:\/\//i.test(API_BASE_URL)) {
* `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.
*/
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',
JobApplication: 'job-applications',
AIInterview: 'ai-interviews',
Staff: 'staff',
WorkerProfile: 'worker-profiles',
EmployeeRole: 'employee-roles',
Course: 'courses',
Badge: 'badges',
LearningPath: 'learning-paths',
@@ -127,7 +137,7 @@ const RESOURCE_PATHS = {
* `got === undefined` against real values and matched nothing, and sending the
* 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();
for (const [key, value] of Object.entries(params)) {
if (value === undefined) continue;
@@ -152,9 +162,14 @@ function queryString(params) {
* ride along as properties — new information a local store never had, and
* 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 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.status = status;
error.code = body?.code || 'internal';
@@ -170,10 +185,14 @@ function apiError(status, payload) {
* 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.
*/
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) : ''}`;
let response;
let response: Response;
try {
response = await fetch(url, {
method,
@@ -198,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 ` +
`127.0.0.1:8080, and is the Vite dev server proxying /api to it? ` +
`(${method} ${path})`
);
) as KrowApiError;
error.name = 'KrowApiError';
error.status = 0;
error.code = 'unreachable';
@@ -220,7 +239,7 @@ async function request(method, path, { query, body } = {}) {
if (!response.ok) throw apiError(response.status, payload);
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.status = response.status;
error.code = 'internal';
@@ -241,7 +260,7 @@ export { request };
* 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".
*/
export function isUnauthenticated(error) {
export function isUnauthenticated(error: any) {
return Boolean(error) && (error.status === 401 || error.code === 'unauthorized');
}
@@ -255,13 +274,33 @@ export function isUnauthenticated(error) {
* `list(sort = '-created_date', limit = 100)`, and several call sites rely on
* them rather than passing their own.
*/
export function createEntity(name) {
const path = RESOURCE_PATHS[name];
export function createEntity(name: string) {
/* `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}`);
const base = `/${path}`;
return {
entityName: name,
entityName: name as EntityName,
/**
* `sort` is always sent, even when empty. `?sort=` is not the same as
@@ -280,19 +319,19 @@ export function createEntity(name) {
return request('GET', base, { query: { ...query, sort, limit } });
},
async get(id) {
async get(id: string) {
return request('GET', `${base}/${encodeURIComponent(id)}`);
},
async create(data) {
async create(data: any) {
return request('POST', base, { body: data });
},
async update(id, data) {
async update(id: string, data: any) {
return request('PATCH', `${base}/${encodeURIComponent(id)}`, { body: data });
},
async delete(id) {
async delete(id: string) {
return request('DELETE', `${base}/${encodeURIComponent(id)}`);
},
@@ -304,8 +343,8 @@ export function createEntity(name) {
* identical — the first rejection stops the run and the records before it
* are already written.
*/
async bulkCreate(records = []) {
const created = [];
async bulkCreate(records: any[] = []) {
const created: any[] = [];
for (const record of records) created.push(await this.create(record));
return created;
},

View File

@@ -9,9 +9,9 @@
*/
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 ────────────────────────────────────────────────────── */
@@ -347,7 +347,7 @@ const JOB_POSTINGS = [
/* ── Applications ──────────────────────────────────────────────────────── */
const breakdown = (over) => ({
const breakdown = (over?: Record<string, number>) => ({
experience: 0, english: 0, reliability: 0, certifications: 0, availability: 0,
personality: 0, culture_fit: 0, communication_style: 0, attendance_expectations: 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. */
const unscreened = (over) => ({
const unscreened = (over?: Record<string, any>) => ({
status: 'applied',
ai_score: 0,
certifications: [],
availability: [],
skills: [],
companies_worked: [],
certifications: [] as string[],
availability: [] as string[],
skills: [] as string[],
companies_worked: [] as string[],
client_rating: 0,
created_date: iso('2026-08-04'),
updated_date: iso('2026-08-04'),
@@ -428,7 +428,7 @@ const JOB_APPLICATIONS = [
certifications: ['ServSafe', 'CPR / First Aid'],
availability: ['Weekdays', 'Weekends', 'Evenings'],
skills: ['Menu design', 'Brigade leadership', 'Food costing', 'Plated dinners', 'Offsite catering'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 4.9,
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.',
@@ -480,7 +480,7 @@ const JOB_APPLICATIONS = [
certifications: ['Guard Card', 'CPR / First Aid'],
availability: ['Weekends', 'Evenings', 'Overnight'],
skills: ['Crowd management', 'De-escalation', 'Incident reporting', 'Access control'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 4.8,
professional_summary:
'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'],
availability: ['Weekends', 'Evenings'],
skills: ['Access control', 'Incident reporting'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 0,
professional_summary: 'Venue security officer transitioning into private event work.',
job_posting_id: 'job_security',
@@ -562,7 +562,7 @@ const JOB_APPLICATIONS = [
certifications: ['RBS Alcohol Server', 'TIPS Certified'],
availability: ['Weekdays', 'Evenings'],
skills: ['Batch cocktails', 'Craft cocktails', 'Bar setup', 'Inventory control'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 4.7,
professional_summary:
'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'],
availability: ['Weekends', 'Evenings'],
skills: ['Synchronized service', 'Tray service', 'Wine service', 'Guest relations'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 4.6,
professional_summary:
'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'],
availability: ['Weekends'],
skills: ['Banquet service', 'Tray service', 'Table resets'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 4.4,
professional_summary: 'Banquet server with three years of seated dinner service.',
selfie_url: 'https://i.pravatar.cc/240?img=44',
@@ -673,7 +673,7 @@ const JOB_APPLICATIONS = [
certifications: [],
availability: ['Weekends', 'Evenings'],
skills: ['Table service'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 0,
professional_summary: 'Restaurant server moving into event work.',
job_posting_id: 'job_server_fine',
@@ -695,7 +695,7 @@ const JOB_APPLICATIONS = [
certifications: ['TIPS Certified'],
availability: ['Weekends', 'Evenings'],
skills: ['Classic cocktails', 'Bar setup', 'Speed pouring'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 4.2,
professional_summary: 'Event bartender with three years of high-volume weekend service.',
job_posting_id: 'job_bartender',
@@ -728,7 +728,7 @@ const JOB_APPLICATIONS = [
certifications: ['TIPS Certified'],
availability: ['Evenings'],
skills: ['Bar setup', 'Wine service'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 4.0,
professional_summary: 'Barback stepping up to bartending at private events.',
job_posting_id: 'job_bartender',
@@ -761,7 +761,7 @@ const JOB_APPLICATIONS = [
certifications: [],
availability: ['Weekends'],
skills: [],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 0,
professional_summary: 'Looking for a first hospitality role.',
job_posting_id: 'job_bartender',
@@ -795,7 +795,7 @@ const JOB_APPLICATIONS = [
certifications: ['Forklift Operator'],
availability: ['Weekdays', 'Early mornings'],
skills: ['Order picking', 'Scanner operation', 'Pallet staging'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 3.9,
professional_summary: 'Warehouse picker with forklift certification.',
job_posting_id: 'job_picker',
@@ -827,7 +827,7 @@ const JOB_APPLICATIONS = [
certifications: [],
availability: ['Weekdays'],
skills: ['Order picking'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 0,
professional_summary: 'Fulfillment associate seeking steady warehouse shifts.',
job_posting_id: 'job_picker',
@@ -854,7 +854,7 @@ const JOB_APPLICATIONS = [
certifications: ['Food Handler Card'],
availability: ['Weekdays', 'Weekends'],
skills: ['Banquet leadership', 'BEO reading', 'Floor coordination'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 0,
professional_summary: 'Banquet server with lead-shift experience in hotel events.',
job_posting_id: 'job_banquet',
@@ -874,7 +874,7 @@ const JOB_APPLICATIONS = [
certifications: ['Food Handler Card'],
availability: ['Weekdays', 'Weekends', 'Evenings'],
skills: ['Floor leadership', 'Client relations', 'Event reporting'],
companies_worked: [],
companies_worked: [] as string[],
client_rating: 0,
professional_summary: 'Hotel banquet captain with six years directing event floors.',
job_posting_id: 'job_banquet',
@@ -900,7 +900,7 @@ const JOB_APPLICATIONS = [
/* ── 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: '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') },
@@ -919,7 +919,7 @@ const AI_INTERVIEWS = [
verdict: 'maybe',
hire_recommendation: 'Worth a trial shift on a coursed dinner before committing to the season.',
integrity_score: 100,
ai_flags: [],
ai_flags: [] as string[],
category_scores: {
communication: 78, confidence: 71, experience_relevance: 74, culture_fit: 76,
problem_solving: 69, personality: 77, communication_style: 76,
@@ -947,11 +947,11 @@ const AI_INTERVIEWS = [
verdict: 'maybe',
hire_recommendation: '',
integrity_score: 100,
ai_flags: [],
ai_flags: [] as string[],
category_scores: {},
strengths: [],
concerns: [],
best_fit_roles: [],
strengths: [] as string[],
concerns: [] as string[],
best_fit_roles: [] as string[],
summary: '',
reasoning: '',
created_date: iso('2026-08-05'),
@@ -967,11 +967,11 @@ const AI_INTERVIEWS = [
verdict: 'maybe',
hire_recommendation: '',
integrity_score: 100,
ai_flags: [],
ai_flags: [] as string[],
category_scores: {},
strengths: [],
concerns: [],
best_fit_roles: [],
strengths: [] as string[],
concerns: [] as string[],
best_fit_roles: [] as string[],
summary: '',
reasoning: '',
created_date: iso('2026-08-05'),
@@ -987,11 +987,11 @@ const AI_INTERVIEWS = [
verdict: 'maybe',
hire_recommendation: '',
integrity_score: 100,
ai_flags: [],
ai_flags: [] as string[],
category_scores: {},
strengths: [],
concerns: [],
best_fit_roles: [],
strengths: [] as string[],
concerns: [] as string[],
best_fit_roles: [] as string[],
summary: '',
reasoning: '',
created_date: iso('2026-08-03'),
@@ -1015,7 +1015,7 @@ const STAFF = [
ai_score: 92,
status: 'onboarding',
client_rating: 0,
endorsed_skills: [],
endorsed_skills: [] as string[],
created_date: iso('2026-07-25'),
},
{
@@ -1031,7 +1031,7 @@ const STAFF = [
ai_score: 94,
status: 'onboarding',
client_rating: 0,
endorsed_skills: [],
endorsed_skills: [] as string[],
created_date: iso('2026-07-25'),
},
{
@@ -1047,7 +1047,7 @@ const STAFF = [
ai_score: 97,
status: 'onboarding',
client_rating: 0,
endorsed_skills: [],
endorsed_skills: [] as string[],
created_date: iso('2026-07-25'),
},
];
@@ -1085,7 +1085,7 @@ const COURSES = [
{ 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: [
{
question: 'A guest flags an allergy mid-service. What happens first?',
@@ -1127,7 +1127,7 @@ const COURSES = [
{ 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: [
{
question: 'A guest shows clear signs of intoxication. What do you do?',
@@ -1151,7 +1151,7 @@ const COURSES = [
proof_skill: 'Hazard Identification',
skill_id: 'food_safety',
target_level: 'beginner',
required_level: null,
required_level: null as string | null,
completion_criteria: ['Work through the training material', 'Submit evidence for the challenge'],
verification_criteria: ['Identifies temperature violations', 'Spots cross-contamination risk', 'Notes obstruction hazards'],
challenge: {
@@ -1164,7 +1164,7 @@ const COURSES = [
{ 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: [
{
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'] },
quiz: [],
quiz: [] as unknown[],
pass_score: 80,
status: 'active',
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
* 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', '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']],
@@ -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']],
];
const SKILL_CATEGORY = {
const SKILL_CATEGORY: Record<string, string> = {
server: 'Service',
bartending: 'Bar',
leadership: 'Leadership',
@@ -1294,20 +1304,20 @@ const SKILL_CATEGORY = {
const LEVEL_ORDER = ['beginner', 'intermediate', 'advanced', 'expert'];
/** 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. */
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.`,
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.`,
};
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(
([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,
rubric: (Array.isArray(criteria) ? criteria : []).map((criterion, n) => ({ criterion, weight: n === 0 ? 40 : 30 })),
},
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
quiz: [],
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] as string[] },
quiz: [] as unknown[],
pass_score: 70,
status: 'active',
created_date: iso('2026-06-20'),
@@ -1344,7 +1354,7 @@ const TRAINING_MODULES = MODULE_TABLE.map(
const COURSE_LIBRARY = [...COURSES, ...TRAINING_MODULES];
/** 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.
@@ -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
* 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) => ({
course_id: id,
title: COURSE_LIBRARY.find((c) => c.id === id)?.title || id,
@@ -1525,7 +1535,7 @@ const WORKER_PROFILES = [
earned_badges: [
{ name: 'Service Fundamentals', level: 'bronze', course_id: 'course_service_basics', earned_date: iso('2026-07-28') },
],
capabilities: [],
capabilities: [] as unknown[],
shifts_completed: 6,
attendance_score: 92,
performance_score: 0,
@@ -1595,8 +1605,8 @@ const WORKER_PROFILES = [
'2026-08-02',
89
),
earned_badges: [],
capabilities: [],
earned_badges: [] as unknown[],
capabilities: [] as unknown[],
shifts_completed: 38,
attendance_score: 96,
performance_score: 88,
@@ -1643,8 +1653,8 @@ const WORKER_PROFILES = [
'2026-07-30',
85
),
earned_badges: [],
capabilities: [],
earned_badges: [] as unknown[],
capabilities: [] as unknown[],
shifts_completed: 21,
attendance_score: 93,
performance_score: 81,
@@ -1691,8 +1701,8 @@ const WORKER_PROFILES = [
'2026-08-05',
76
),
earned_badges: [],
capabilities: [],
earned_badges: [] as unknown[],
capabilities: [] as unknown[],
shifts_completed: 4,
attendance_score: 100,
performance_score: 0,
@@ -1735,8 +1745,8 @@ const WORKER_PROFILES = [
},
xp: 0,
completed_courses: [],
earned_badges: [],
capabilities: [],
earned_badges: [] as unknown[],
capabilities: [] as unknown[],
shifts_completed: 0,
attendance_score: 100,
performance_score: 0,
@@ -1779,8 +1789,8 @@ const WORKER_PROFILES = [
},
xp: 0,
completed_courses: [],
earned_badges: [],
capabilities: [],
earned_badges: [] as unknown[],
capabilities: [] as unknown[],
shifts_completed: 0,
attendance_score: 100,
performance_score: 0,
@@ -1823,8 +1833,8 @@ const WORKER_PROFILES = [
},
xp: 0,
completed_courses: [],
earned_badges: [],
capabilities: [],
earned_badges: [] as unknown[],
capabilities: [] as unknown[],
shifts_completed: 0,
attendance_score: 100,
performance_score: 0,
@@ -1867,8 +1877,8 @@ const WORKER_PROFILES = [
},
xp: 0,
completed_courses: [],
earned_badges: [],
capabilities: [],
earned_badges: [] as unknown[],
capabilities: [] as unknown[],
shifts_completed: 0,
attendance_score: 100,
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.
* The fixtures here are for the test scripts, and they still read
* `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 ───────────────────────────────────────────────────────────
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_name: 'Marco Rivera',
starts_at: iso('2026-07-01'),
ends_at: null,
ends_at: null as string | null,
status: 'active',
source: 'seed',
match_score: 92,
@@ -2041,5 +2055,5 @@ export const seedData = {
Assignment: ASSIGNMENTS,
ShiftRecord: SHIFT_RECORDS,
Evidence: EVIDENCE,
User: [DEMO_USER],
User: [DEMO_USER, EMPLOYER_USER],
};

View File

@@ -21,7 +21,12 @@ const DefaultFallback = () => (
* The attempted path travels in location state so signing in returns the
* 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 { isAuthenticated, isLoadingAuth, authChecked, authError, checkUserAuth } = useAuth();
const signedOut = unauthenticatedElement ?? (

View File

@@ -17,8 +17,17 @@ import { cn } from '@/lib/utils';
* Controls live in the page rather than in the global chrome, which is why the
* Admin header only needs one 56px bar.
*/
/** @param {any} props */
export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }) {
interface AdminPageProps {
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) {
return (
<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">
@@ -57,8 +66,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
* four or five sections, loud labels compete with the data they introduce.
*/
/** @param {any} props */
export function SectionTitle({ id, title, meta, action, className }) {
interface SectionTitleProps {
id?: any;
title?: string;
meta?: any;
action?: any;
className?: string;
}
export function SectionTitle({ id, title, meta, action, className }: SectionTitleProps) {
return (
<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
@@ -91,8 +107,15 @@ export function SectionTitle({ id, title, meta, action, className }) {
* primary action is pushed to the end, so every management page has the same
* control geometry.
*/
/** @param {any} props */
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }) {
interface ToolbarProps {
search?: any;
filters?: any;
actions?: any;
meta?: any;
className?: string;
}
export function Toolbar({ search = null, filters = null, actions = null, meta = null, className = '' }: ToolbarProps) {
return (
<div className={cn('space-y-2', className)}>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">

View File

@@ -1,3 +1,4 @@
import type { ComponentType } from 'react';
import {
Briefcase, ChefHat, ConciergeBell, HardHat, PackageSearch, ShieldCheck, Sparkles, Truck, Wine,
} 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. */
/** @type {[RegExp, React.ComponentType<any>][]} */
const GLYPHS = [
/* `[pattern, icon]` pairs. Without the tuple the element widens to the union of
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],
[/bartend|bar|mixolog/i, Wine],
[/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' },
};
/** @param {any} props */
export function RoleGlyph({ category, title, size = 'md', aiGenerated = false, className = '' }) {
interface RoleGlyphProps {
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 s = SIZES[size] || SIZES.md;

View File

@@ -44,7 +44,7 @@ import OwliverAvatar from '@/components/krow/OwliverAvatar';
* 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.
*/
export function Workspace({ rail, children, className }) {
export function Workspace({ rail, children, className }: any) {
return (
<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">
@@ -64,8 +64,7 @@ export function Workspace({ rail, children, className }) {
* height without measuring it in JavaScript. Content stays mounted, which is
* what keeps a section addressable by the rail while it is closed.
*/
/** @param {any} props */
export function Collapse({ open, children, className }) {
export function Collapse({ open, children, className }: any) {
return (
<div
className={cn(
@@ -100,8 +99,7 @@ export function Collapse({ open, children, className }) {
* Provides clear section navigation and leaf anchor shortcuts across the
* configuration workspace.
*/
/** @param {any} props */
export function Rail({ sections, activeId, onSelect }) {
export function Rail({ sections, activeId, onSelect }: any) {
const activeSection = sections.find((s) => s.id === activeId);
return (
@@ -181,11 +179,10 @@ export function Rail({ sections, activeId, onSelect }) {
/**
* One section of the document.
*/
/** @param {any} props */
export function DocSection({
id, icon: Icon, title, description, meta, open, onToggle, emphasis = false, last = false,
children,
}) {
}: any) {
return (
<section
id={id}
@@ -257,8 +254,7 @@ export function DocSection({
}
/** A labelled field in the document body. */
/** @param {any} props */
export function DocField({ id, label, hint, children, className }) {
export function DocField({ id, label, hint, children, className }: any) {
const reactId = React.useId();
const controlId = id ? `${id}-control` : reactId;
@@ -270,7 +266,7 @@ export function DocField({ id, label, hint, children, className }) {
>
{label}
</label>
{React.isValidElement(children)
{React.isValidElement<any>(children)
? React.cloneElement(children, { id: children.props.id || controlId })
: children}
{hint && <p className="text-caption leading-relaxed text-ink-4">{hint}</p>}
@@ -284,8 +280,7 @@ export function DocField({ id, label, hint, children, className }) {
* One notch quieter than a section title and one louder than a row, which is
* exactly the level of the thing it names.
*/
/** @param {any} props */
export function GroupHead({ id, icon: Icon, title, count, action, className }) {
export function GroupHead({ id, icon: Icon, title, count, action, className }: any) {
return (
<div
id={id}
@@ -310,8 +305,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,
* which is what they are.
*/
/** @param {any} props */
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }) {
export function ItemRow({ icon: Icon, title, detail, warning, onRemove, removeLabel }: any) {
return (
<li
className={`group/item -mx-2 flex items-start gap-2.5 rounded-lg px-2 py-2
@@ -352,10 +346,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
* space gets a chevron and opens underneath.
*/
/** @param {any} props */
export function SettingRow({
id, icon: Icon, title, value, description, control, children, open, onToggle, last = false,
}) {
}: any) {
const expandable = Boolean(children);
const body = (
@@ -412,8 +405,7 @@ export function SettingRow({
}
/** A page an agent covers, or one it could. */
/** @param {any} props */
export function ScopeChip({ active, children, ...props }) {
export function ScopeChip({ active, children, ...props }: any) {
return (
<button
type="button"
@@ -443,8 +435,7 @@ export function ScopeChip({ active, children, ...props }) {
/* ── Identity ────────────────────────────────────────────────────────────── */
/** The icons an agent may wear, as a picker. */
/** @param {any} props */
export function IconPicker({ icons, value, onChange }) {
export function IconPicker({ icons, value, onChange }: any) {
return (
<div className="grid grid-cols-5 gap-1.5">
{icons.map((icon) => {
@@ -486,8 +477,7 @@ export function IconPicker({ icons, value, onChange }) {
* same three things the switcher renders — icon, name, description — and
* nothing it does not.
*/
/** @param {any} props */
export function AgentPreview({ name, description, icon }) {
export function AgentPreview({ name, description, icon }: any) {
const Icon = agentIconFor(icon);
return (

View File

@@ -94,7 +94,6 @@ function useActiveSection(ids) {
return [active, setActive];
}
/** @param {any} props */
export function AgentConfigure({
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
/* Attaching and detaching a skill goes through the caller's existing write
@@ -107,7 +106,7 @@ export function AgentConfigure({
/* The tools this deployment registers, from GET /api/v1/tools. Passed in
rather than fetched here so this component stays a form over `fields`. */
toolCatalogue = [],
}) {
}: any) {
/* 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
is for focus, not for the initial reading. */
@@ -804,7 +803,7 @@ export function AgentConfigure({
icon={Layers}
title="Subagents"
value={fields.subagents.length ? `${fields.subagents.length} configured` : 'None'}
description="Other agents whose skills this one may also use. Most need none."
description="Other agents this one may hand a question to and report back from. Most need none."
open={setting === 'behavior-subagents'}
onToggle={() => toggleSetting('behavior-subagents')}
last
@@ -849,8 +848,9 @@ export function AgentConfigure({
</Select>
<p className="text-caption leading-relaxed text-ink-4">
A subagent&apos;s skills are still bounded by the current page — borrowing them
cannot reach data this page does not hold.
A subagent runs as you, with the same access you have and out of the same
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>
</div>
</SettingRow>

View File

@@ -22,8 +22,7 @@ import { cn } from '@/lib/utils';
* Real-time usage analytics computed directly from the local telemetry log.
*/
/** @param {any} props */
function MetricCard({ icon: Icon, label, value, detail, highlight }) {
function MetricCard({ icon: Icon, label, value, detail, highlight }: any) {
return (
<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">
@@ -45,8 +44,7 @@ function MetricCard({ icon: Icon, label, value, detail, highlight }) {
);
}
/** @param {any} props */
export function AgentInsightsPanel({ agentId, agentName }) {
export function AgentInsightsPanel({ agentId, agentName }: any) {
const preferences = usePreferences();
const [records] = React.useState(() => readHistory());

View File

@@ -46,8 +46,7 @@ const CLASSIFICATION_COPY = {
combined: 'Requires both structured records and knowledge retrieval.',
};
/** @param {any} props */
export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
export function AgentTestPanel({ fields, dirty, customSkills = [], onRunLive }: any) {
const [contextId, setContextId] = React.useState(
() => CONTEXT_OPTIONS.find((o) => fields.pages.includes(pageKeyForContext(o.contextId)))?.contextId
|| CONTEXT_OPTIONS[0].contextId
@@ -79,6 +78,7 @@ export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
return {
covers,
disabled,
pageOnly,
scoped,
matched,
@@ -152,6 +152,33 @@ export function AgentTestPanel({ fields, dirty, customSkills = [] }) {
className="pl-9 w-full bg-surface"
/>
</div>
{/* Everything else on this tab is a STATIC read of where a
question would route. Useful, and not the same as asking. The
tab is called Test, so a reader reasonably expects to be able
to run the thing — this is that, through the existing Owliver
panel and the draft's own scope, so the answer comes from the
agent being edited rather than the published one. */}
{onRunLive && (
<button
type="button"
onClick={() => onRunLive({
question,
scope: { contextId, disabledSkills: result.disabled, agent: draftAgent },
capability: null,
trace: { agentName: fields.name || 'This agent', surface: contextId },
})}
disabled={!question.trim() || !result.covers}
title={result.covers
? 'Ask this for real, in the Owliver panel'
: 'This agent does not cover the selected page'}
className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-border
bg-surface px-3 py-1.5 text-caption font-medium text-ink-2
hover:bg-surface-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Play className="h-3 w-3 shrink-0" aria-hidden="true" />
Ask Owliver for real
</button>
)}
</div>
</div>

View File

@@ -43,7 +43,6 @@ import { SkillDetails } from './SkillDetails';
const OWLIVER = 'owliver';
const BOARD = 'board';
/** @param {any} props */
export function AgentSkillWorkspace({
fields,
customSkills = [],
@@ -55,7 +54,7 @@ export function AgentSkillWorkspace({
busy = false,
pendingId = null,
dirty = false,
}) {
}: any) {
const [kind, setKind] = React.useState(OWLIVER);
const [query, setQuery] = React.useState('');
const [boardQuery, setBoardQuery] = React.useState('');
@@ -394,6 +393,10 @@ export function AgentSkillWorkspace({
busy={busy}
query={boardQuery}
onQueryChange={setBoardQuery}
/* Ownership, through the one handler that writes `agent.skills` —
the same one the Owliver catalog attaches with. */
attachedIds={attachedIds}
onAttach={onToggleSkill}
/>
)}
</div>

View File

@@ -174,7 +174,6 @@ function StaticLeaf({ icon: Icon, label, detail = null }) {
);
}
/** @param {any} props */
export function AgentTree({
agentName,
agentIcon,
@@ -191,7 +190,7 @@ export function AgentTree({
onToggleBranch,
busy = false,
className,
}) {
}: any) {
const Glyph = agentIconFor(agentIcon);
return (

View File

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

View File

@@ -25,9 +25,9 @@ import { glyphForGroup } from './glyphs';
*/
/** @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,
}) => {
}: any) => {
const Glyph = glyphForGroup(entry.group);
const samplePrompt = entry.suggestions?.[0]?.label;

View File

@@ -23,8 +23,7 @@ import { SkillCard } from './SkillCard';
* has, at every width, with no media query to get wrong.
*/
/** @param {any} props */
function CategoryFilter({ groups, value, onChange, total }) {
function CategoryFilter({ groups, value, onChange, total }: any) {
return (
<div
role="group"
@@ -60,7 +59,6 @@ function CategoryFilter({ groups, value, onChange, total }) {
);
}
/** @param {any} props */
export function SkillCatalog({
entries,
groups,
@@ -76,7 +74,7 @@ export function SkillCatalog({
testableIds,
busy = false,
pendingId = null,
}) {
}: any) {
const searchId = React.useId();
return (

View File

@@ -82,11 +82,10 @@ function TestState({ test, entryId }) {
* button needs the same `surface` this renders, and two copies of that would be
* two answers to "what am I about to enable".
*/
/** @param {any} props */
export function CapabilityBrief({
entry, attached, surface, onSurfaceChange, question, onQuestionChange,
canTest, target, onTest, test, busy = false, showTest = true,
}) {
}: any) {
const { shapes, described } = entry.capabilities;
return (
@@ -252,11 +251,10 @@ export function CapabilityBrief({
);
}
/** @param {any} props */
export function SkillDetails({
entry, open, onOpenChange, isEnabledOn, onToggle, busy = false,
fields = null, onRunTest = null, test = null,
}) {
}: any) {
/**
* The surface this would run on.
*

View File

@@ -45,8 +45,7 @@ const OPTIONS = [
export const supportedSurfaces = (type) =>
OPTIONS.filter((o) => o.supported(type)).map((o) => o.id);
/** @param {any} props */
export function SurfaceSelect({ type, value, onChange, name }) {
export function SurfaceSelect({ type, value, onChange, name }: any) {
return (
<div role="radiogroup" aria-label={`Run ${name} as`} className="space-y-1.5">
{OPTIONS.map((option) => {

View File

@@ -57,8 +57,7 @@ function ConstrainedTag() {
);
}
/** @param {any} props */
export function AgentBadge({ page }) {
export function AgentBadge({ page }: any) {
const { agent, covers } = useActiveAgent();
return (

View File

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

View File

@@ -86,9 +86,8 @@ function FeedbackControls({ feedback, onFeedback }) {
}
export const Message = React.memo(
/** @param {any} props */
({ role, text, blocks, streaming, stopped, onPrompt, onConfirm = null,
feedback = null, onFeedback = null }) => {
feedback = null, onFeedback = null }: any) => {
if (role === 'user') {
return (
<div className="flex justify-end">

View File

@@ -268,7 +268,7 @@ export function AssistantPanelProvider({ role, pathname, children }) {
/* Width is written on every drag frame, so persistence is debounced rather
than hitting sessionStorage sixty times a second. */
const setWidthState = React.useCallback((next) => {
const setWidthState: any = React.useCallback((next) => {
const clamped = clampWidth(next);
setWidth(clamped);
clearTimeout(setWidthState.timer);

View File

@@ -15,8 +15,7 @@ import { useAssistantPanel } from './AssistantPanelContext';
* It self-hides on pages with no assistant, so a page can render it
* unconditionally and never needs to know the placement rules.
*/
/** @param {any} props */
export function AssistantTrigger({ className = '', size = 'default' }) {
export function AssistantTrigger({ className = '', size = 'default' }: any) {
const { supported, isOpen, toggle, context } = useAssistantPanel();
if (!supported) return null;

View File

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

View File

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

View File

@@ -17,7 +17,6 @@ import { cn } from '@/lib/utils';
* 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.
*/
/** @param {any} props */
export function PromptInput({
value,
onChange,
@@ -28,7 +27,7 @@ export function PromptInput({
autoFocus = false,
align = 'left',
className = '',
}) {
}: any) {
const textareaRef = React.useRef(null);
const [focused, setFocused] = React.useState(false);

View File

@@ -22,10 +22,42 @@ import { usePageAction } from './PageContext';
* snapshot, and settled blocks must not re-render with it.
*/
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_)/g;
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_|\[[^\]\n]+\]\([^)\s]*\))/g;
const LINK = /^\[([^\]\n]+)\]\(([^)\s]*)\)$/;
/** Inline `**bold**` and `_italic_`. Kept deliberately small — structure is
* carried by blocks, not by markup inside a paragraph. */
/**
* Where a link in an answer is allowed to point.
*
* An allow-list, and it is a security boundary rather than a tidiness rule: the
* text being parsed here was written by a model, and a model reading a document
* that says "link to javascript:…" is exactly the injection §I7 of CLAUDE.md
* calls untrusted input. Anything not on this list renders as the plain text it
* came from — visible, inert, and obvious.
*
* `#` alone is deliberately absent. A bare empty anchor is the shape the
* citation sanitiser removes; one that reaches here is a link to nowhere, and
* showing its label as text is better than an anchor that does nothing.
*/
const isSafeHref = (href) => (
/^\/(?!\/)/.test(href) // in-app route
|| /^#[^\s]+$/.test(href) // an anchor on this page, but not a bare '#'
|| /^https?:\/\//i.test(href) // the open web
|| /^mailto:[^\s]+$/i.test(href)
);
/**
* Inline `**bold**`, `_italic_` and `[label](href)`.
*
* Links were the gap: `markdownToBlocks` never touched them, and this renderer
* had no case for them, so `[staffing policy](#staffing)` reached the reader as
* its own source. Structure is still carried by blocks rather than by markup —
* this stays three constructs, not a markdown library.
*
* A citation-shaped link never arrives here at all. `[337042b3](#)` is removed
* upstream in `provider.js`, by the wrapper's shape, before a block is built —
* so the two concerns stay apart: the sanitiser decides what is an internal
* reference, and this decides how a real link looks.
*/
function Inline({ value }) {
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
@@ -36,6 +68,33 @@ function Inline({ value }) {
if (part.startsWith('_') && part.endsWith('_')) {
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
}
const link = LINK.exec(part);
if (link) {
const [, label, href] = link;
if (!isSafeHref(href)) return part;
/* An in-app route goes through the router, like every other internal link
in this file — a full page load would throw away the conversation the
reader is being pointed away from. Everything else is an anchor, and
anything leaving the app opens away from it. */
const external = /^https?:\/\//i.test(href);
const className = 'font-medium text-krow-blue underline decoration-krow-blue/30 underline-offset-2'
+ ' transition-colors hover:decoration-krow-blue focus-visible:outline-none'
+ ' focus-visible:ring-2 focus-visible:ring-krow-blue/50 rounded-sm';
if (href.startsWith('/')) return <Link key={i} to={href} className={className}>{label}</Link>;
return (
<a
key={i}
href={href}
className={className}
{...(external ? { target: '_blank', rel: 'noreferrer noopener' } : null)}
>
{label}
</a>
);
}
return part;
});
}
@@ -62,14 +121,14 @@ const TONE_BADGE = {
/* ── 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">
<Inline value={block.text} />
</p>
));
TextBlock.displayName = 'TextBlock';
const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
const HeadingBlock = React.memo(({ block }: any) => (
<div className="space-y-0.5">
<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>}
@@ -78,7 +137,7 @@ const HeadingBlock = React.memo(/** @param {any} props */ ({ block }) => (
HeadingBlock.displayName = 'HeadingBlock';
/** 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">
{block.items.map((item) => (
<div
@@ -113,7 +172,7 @@ const KpisBlock = React.memo(/** @param {any} props */ ({ block }) => (
KpisBlock.displayName = 'KpisBlock';
/** 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">
{block.items.map((item) => (
<li key={item.label} className="flex items-start gap-2">
@@ -145,7 +204,7 @@ const StatusBlock = React.memo(/** @param {any} props */ ({ block }) => (
));
StatusBlock.displayName = 'StatusBlock';
const MetersBlock = React.memo(/** @param {any} props */ ({ block }) => (
const MetersBlock = React.memo(({ block }: any) => (
<div className="space-y-2.5">
{block.items.map((item) => (
<div key={item.label} className="space-y-1">
@@ -167,7 +226,7 @@ MetersBlock.displayName = 'MetersBlock';
* A table. Scrolls horizontally inside its own container so a wide comparison
* never widens the panel.
*/
const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
const TableBlock = React.memo(({ block }: any) => {
const cell = (value) => {
if (value == null || value === '') return <span className="text-ink-4">—</span>;
if (typeof value === 'object') {
@@ -228,7 +287,7 @@ const TableBlock = React.memo(/** @param {any} props */ ({ block }) => {
TableBlock.displayName = 'TableBlock';
/** 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);
return (
@@ -255,7 +314,7 @@ const FunnelBlock = React.memo(/** @param {any} props */ ({ block }) => {
});
FunnelBlock.displayName = 'FunnelBlock';
const ListBlock = React.memo(/** @param {any} props */ ({ block }) => {
const ListBlock = React.memo(({ block }: any) => {
const Tag = block.ordered ? 'ol' : 'ul';
return (
<Tag className="space-y-1.5">
@@ -298,7 +357,7 @@ ListBlock.displayName = 'ListBlock';
* clickable and doing nothing. An item may carry a hint *and* an action: the
* 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">
{block.items.map((item, i) => {
const surface = cn(
@@ -375,7 +434,7 @@ const InsightsBlock = React.memo(/** @param {any} props */ ({ block, onPrompt })
InsightsBlock.displayName = 'InsightsBlock';
/** 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">
{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">
@@ -394,7 +453,7 @@ const ActionsBlock = React.memo(/** @param {any} props */ ({ block }) => (
));
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">
{block.items.map((item) => (
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
@@ -405,7 +464,7 @@ const BadgesBlock = React.memo(/** @param {any} props */ ({ block }) => (
));
BadgesBlock.displayName = 'BadgesBlock';
const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
const TimelineBlock = React.memo(({ block }: any) => (
<ol className="relative space-y-3">
{block.items.map((item, i) => (
<li key={i} className="flex gap-3">
@@ -438,7 +497,7 @@ const TimelineBlock = React.memo(/** @param {any} props */ ({ block }) => (
));
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">
<Inline value={block.text} />
</p>
@@ -455,7 +514,7 @@ NoteBlock.displayName = 'NoteBlock';
* shape the table does not carry renders nothing rather than something
* improvised.
*/
const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
const SkillSectionBlock = React.memo(({ block }: any) => {
const section = block.section;
const Component = SECTION_COMPONENTS[section?.shape || section?.type];
@@ -505,7 +564,7 @@ SkillSectionBlock.displayName = 'SkillSectionBlock';
* - **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.
*/
const ConfirmationBlock = React.memo(/** @param {any} props */ ({ block, onConfirm }) => {
const ConfirmationBlock = React.memo(({ block, onConfirm }: any) => {
const [state, setState] = React.useState('pending');
const approve = React.useCallback(() => {
@@ -614,7 +673,7 @@ const RENDERERS = {
* blocks has consistent rhythm — a heading hugs what follows it, everything
* 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">
{blocks.map((block, i) => {
const Renderer = RENDERERS[block.type];

View File

@@ -20,7 +20,7 @@ export const doc = (...blocks) => ({
export const text = (value) => value && { type: 'text', text: value };
/** 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? }]` */
export const kpis = (items) => items?.length && { type: 'kpis', items };

View File

@@ -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 plural = (n, word, irregular) =>
export const plural = (n, word, irregular?) =>
`${n} ${n === 1 ? word : irregular || `${word}s`}`;
export const verb = (n, singular, plural_) => (n === 1 ? singular : plural_);
@@ -215,7 +215,7 @@ export function buildFacts({
),
experienced: profiles.filter((p) => (p.experience_years || 0) >= 3),
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);
acc[label] ||= { label, count: 0 };
acc[label].count += 1;
@@ -241,7 +241,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';
acc[name] ||= { name, count: 0, scores: [], days: [], ratings: [] };
acc[name].count += 1;

View File

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

View File

@@ -1,4 +1,5 @@
import { doc, heading, insights, list, note, skillSection, text } from './blocks';
import { resolveUiEdit } from './uiEdit';
import { ASSISTANT_CONTEXTS } from './contexts';
import { poolFor } from '@/lib/workforce';
import { matchSkill } from '@/lib/skills/registry';
@@ -23,7 +24,8 @@ import {
buildSkillPrefill, buildTrainingPrefill, extractReviewSubject,
extractSkillName, findCourseByName,
} from '@/lib/skills/actions';
import { beginPositionFlow } from '@/lib/skills/positionFlow';
import { beginFlow } from '@/lib/skills/conversationFlow';
import { flowFor } from '@/lib/skills/flows';
/**
* Intent routing — deciding whether a question belongs to the page you are on.
@@ -159,7 +161,7 @@ export function answerableHere(context, question) {
export function navigationAnswer(destination) {
return doc(
text(`That is on **${destination.page}**. Taking you there now.`),
note('Ask again once the page loads and I will answer from it.')
note('Bringing your question with me — I will answer it from that page.')
);
}
@@ -633,7 +635,7 @@ function declaredAnswer({ skill, capability, question, skillContext }) {
*/
function resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
skillContext = null,
companies = [], postings = null, skillContext = null,
}) {
/**
* One question, one skill, then one way of answering it.
@@ -728,15 +730,21 @@ function resolveSkill({
}
/**
* Create Position is collected in the conversation, not in a form.
* A conversational skill is collected in the chat, not in a form.
*
* Nothing opens and nothing is navigated to: the skill's questions come back
* as a reply and its answers as chips, and the position is written at the end
* as a reply and its answers as chips, and the record is written at the end
* from what the conversation gathered. `flow` is the state that turn carries
* forward — the panel keeps it and feeds the next answer back in.
*
* Which conversation is the SKILL'S OWN `flow:` declaration, resolved through
* `FLOWS`. This used to be `if (skill.id === 'create-position')`, which made a
* second conversational skill a change to the router rather than a file on
* disk — precisely the `if agent_key == ...` shape §2's I6 rules out.
*/
if (skill.id === 'create-position') {
return { kind: 'skill', skill, ...beginPositionFlow({ question, skill, roles }) };
const registry = flowFor(skill);
if (registry) {
return { kind: 'skill', skill, ...beginFlow({ registry, question, skill, ctx: { roles, companies, postings } }) };
}
/**
@@ -855,6 +863,11 @@ function resolveDraftAction(question, workforce, positionId = null) {
export function resolveIntent({
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
courses = [], workforce = null, skillContext = null,
/* The clients this organization already staffs for, offered as chips on the
company question. Read off the postings the caller can already see, so it
expands nobody's view — see the `@companies` note in flows/position.js. */
companies = [],
postings = null,
/**
* The active agent, and where the reader is.
*
@@ -869,6 +882,14 @@ export function resolveIntent({
was one. Only the draft flow reads it; a typed question carries none and
resolves exactly as it always did. */
positionId = null,
/**
* The layout session for this page, when there is one.
*
* Carries the tree on screen and whether something is already being
* previewed. Absent — or on a page that composes no tree — every branch below
* resolves exactly as it did before this existed.
*/
ui = null,
}) {
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
@@ -900,9 +921,21 @@ export function resolveIntent({
const draftIntent = resolveDraftAction(question, workforce || { positions: [] }, positionId);
if (draftIntent) return draftIntent;
/**
* 1b. Changing the page itself.
*
* Ahead of the skills because a request to hide a section is about the
* interface, and a skill trigger reading the same words would answer about
* the data behind it. Tightly gated: `resolveUiEdit` returns null unless the
* page composes a tree AND the words name something on it, a registered
* panel type, or the layout — so an ordinary question is never taken.
*/
const uiIntent = resolveUiEdit({ question, ui });
if (uiIntent) return uiIntent;
/* 2. Current page skills — specific triggers, ahead of the general reader. */
const skill = resolveSkill({
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses, companies, postings,
/* The envelope travels beside the collections rather than replacing them:
a resolver reads records, and the envelope says where the reader is. A
source that needs a position still finds it exactly where it always was. */

View File

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

View File

@@ -6,9 +6,8 @@ import {
useUserActivity, useWorkerProfile, useWorkerProfiles,
} from '@/lib/krowHooks';
import { skillsForContext } from '@/lib/skills/registry';
import {
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
} from '@/lib/skills/positionFlow';
import { advanceFlow } from '@/lib/skills/conversationFlow';
import { flowFor } from '@/lib/skills/flows';
import {
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
publishedReply, weightsSetReply, weightsUnchangedReply,
@@ -186,6 +185,60 @@ function normalizeDraftChip(chip) {
return chip;
}
/**
* One question, reduced to what it asks.
*
* Case, surrounding space and a trailing question mark are not differences, so
* "What should I do next?" and "what should i do next" are one question and are
* not offered twice.
*/
const asQuestion = (value) => String(value || '').trim().toLowerCase().replace(/[?.!]+$/, '');
/**
* The chips to offer after an answer: what this conversation has not covered.
*
* Two rules, and the second is the one that matters. The suggestions are ranked
* by the SERVER against the question just asked — the panel does not decide what
* is worth asking, it only decides what has already been said — and then
* anything this thread has asked or already offered is removed.
*
* Without that second rule the row repeats. A page carries a handful of intents
* and the top of that list barely moves between turns, so the same three chips
* come back after every answer, including the one the reader has just pressed.
* Removing what has been used leaves genuinely new ground each time and runs out
* honestly rather than looping.
*
* There is NO fallback to the page's own ranking, and that is the correction a
* live run forced. Asking "Summarize hiring activity" matches nothing in the
* catalogue, so nothing was excluded, so the fallback returned the page's top
* three — and the reader got "How healthy is the platform right now?" under an
* answer about hiring activity, which is the generic-catalogue behaviour this
* function exists to end. A page ranking is what to ask on a PAGE; it is not a
* follow-up to anything. When the conversation has no next question, the honest
* answer is none.
*/
export async function nextSteps({ question, history, refresh }) {
if (!refresh) return undefined;
const used = new Set([asQuestion(question)]);
for (const message of history) {
if (message.role === 'user') used.add(asQuestion(message.text));
for (const chip of message.followUp || []) used.add(asQuestion(chip.prompt || chip.label));
}
const unused = (chips) => (chips || []).filter((chip) => {
const key = asQuestion(chip.prompt || chip.label);
if (!key || used.has(key)) return false;
/* A list that repeats itself within one turn is the same defect at a
smaller scale. */
used.add(key);
return true;
});
const onTopic = unused(await refresh({ query: question }));
return onTopic.length ? onTopic : undefined;
}
/**
* A stored thread, with any completed-then-reopen action stripped out.
*
@@ -237,7 +290,24 @@ function withoutAuthoringActions(messages = []) {
* routing applies to both without either knowing it exists.
*/
export function useConversation({
contextId, facts, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
contextId, facts, onNavigate, onAction, onRefreshSuggestions,
/**
* How each conversation's record gets written, keyed by the flow's id.
*
* A single `onCreatePosition` prop was the last place the panel named one
* kind of record. A second conversation needed a second prop, a second branch
* at the write, and a second set of outcome renderers — three edits to answer
* "and now employee roles too". This is one entry in a map.
*/
flowWriters = {},
/**
* The page's layout session, when the surface has one.
*
* Read for the tree Owliver inspects and called to preview or keep a change.
* Absent on every page that composes no tree, and every branch that touches
* it checks first — so the panel behaves exactly as it did before on those.
*/
uiEditing = null,
onAssignWorkers, onScheduleInterview,
/* Finishing a draft: the same two mutations the Create Position form calls.
Passed in rather than reached for, so this layer still writes nothing
@@ -245,6 +315,15 @@ export function useConversation({
onUpdatePosition, onGenerateDescription,
workforce = null, disabledSkills = [], customSkills = [],
roles = [], skillCategories = [], courses = [], skillContext = null,
/* The clients this organization already staffs for, offered as chips on the
company question. Derived from postings the caller can already read. */
companies = [],
/* The caller's own postings, which is where role-to-certification relevance
is observed from. See `certificationsForRole`. */
postings = null,
/* The worker profiles a declared role can be recorded against, as
`{ id, name, email }`. Same rule: already-loaded, already-permitted rows. */
workers = [],
/**
* The active agent and where the reader is.
*
@@ -475,7 +554,10 @@ export function useConversation({
intent = {
kind: 'flow',
skill,
...advancePositionFlow({ flow: flowRef.current, answer: text, skill, roles }),
...advanceFlow({
registry: flowFor(skill), flow: flowRef.current, answer: text, skill,
ctx: { roles, companies, workers, postings },
}),
};
}
}
@@ -490,11 +572,28 @@ export function useConversation({
question: text,
contextId: turnContext,
disabledSkills: turnDisabled,
customSkills, roles, skillCategories,
customSkills, roles, skillCategories, companies, postings,
courses, workforce, skillContext, positionId,
agent: turnAgent,
agentCoversPage: turnCovers,
agentSuggestion, owliverContext,
ui: uiEditing
? {
available: Boolean(uiEditing.tree?.length),
tree: uiEditing.tree,
previewing: uiEditing.previewing,
role: uiEditing.role || null,
registry: uiEditing.registry || undefined,
/* Where the reader is standing. What can be added here is a
property of the page, not of the registry, and this is how
the conversation learns it. */
page: uiEditing.page || null,
/* What this session last changed, so "change it back" has an
"it". A node id and nothing else — see `focus` on the
editing provider. */
focus: uiEditing.focus || null,
}
: null,
}),
/* Only while a real agent is behind the panel. With the local
simulator there is nothing better to defer TO, and deferring
@@ -503,19 +602,56 @@ export function useConversation({
);
}
/**
* A layout change, acted on before the reply says what happened.
*
* Three kinds, and the split is the safety property: a preview is shown and
* nothing is stored; an apply keeps what was already shown; an answer — a
* question back, or a refusal — changes nothing at all. Owliver never
* applies in the same turn it proposes.
*
* `propose` validates against the tree on screen and refuses rather than
* previewing something that could not be kept, so a refusal here is
* reported in the words the engine gave rather than a generic apology.
*/
if (intent.kind === 'ui-preview' && uiEditing) {
const result = uiEditing.propose(intent.op);
if (!result.ok) {
intent = {
...intent,
kind: 'ui-answer',
doc: doc(textBlock(result.problems[0]?.message || 'That change is not possible here.')),
followUp: undefined,
};
}
} else if (intent.kind === 'ui-apply' && uiEditing) {
const result = await uiEditing.apply();
if (!result.ok) {
intent = { ...intent, doc: doc(textBlock('That change could not be saved.')) };
}
} else if (intent.kind === 'ui-discard' && uiEditing) {
uiEditing.discard();
}
/**
* The one step that writes. It happens before the reply rather than after,
* because the reply is the outcome — "Position created successfully" has to
* be true when it is said.
*/
if (intent.kind === 'flow' && intent.create) {
/* The skill says which conversation this is, so the write and the wording
of its outcome both come from that registry rather than from a name
hardcoded here. */
const registry = flowFor(intent.skill);
let created = null;
/* Kept, not swallowed. The reply states the outcome, and "it did not
work" is a worse outcome to state than the reason it did not: a
required field, a refused role, or an API that is not running. */
let failure = null;
try {
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
created = await flowWriters[registry.id]?.(
intent.create.draft, intent.skill, intent.create.status
);
} catch (error) {
created = null;
failure = error;
@@ -551,19 +687,16 @@ export function useConversation({
intent = {
...intent,
flow: null,
doc: positionCreatedReply(created),
followUp: [...createdFollowUp(created), ...refreshed],
doc: registry.outcome.created(created),
followUp: [...registry.outcome.followUp(created), ...refreshed],
};
} else {
/* Keep the answers: the summary is still there to try again from. */
intent = {
...intent,
flow: { ...intent.flow, stage: 'review' },
doc: positionFailedReply(failure?.message),
followUp: [
{ label: 'Create position', prompt: 'Create position' },
{ label: 'Change details', prompt: 'Change details' },
],
doc: registry.outcome.failed(failure?.message),
followUp: registry.outcome.retryChips,
};
}
}
@@ -723,12 +856,20 @@ export function useConversation({
abortRef.current = null;
}
/* Navigate after the reply is on screen, so the user reads why they moved.
The panel re-resolves its context from the new route, which is what
makes the next question answer from the page they land on. */
/* Navigate after the reply is on screen — except the reply does not
survive the move. The panel is page-scoped, so it re-mounts on the new
route and the message explaining why the reader moved is destroyed by
the navigation that message was explaining. The reader landed somewhere
else with a fresh greeting and no trace of what they asked.
So the question travels with the destination. The panel on the other
side asks it, which is what the reader wanted in the first place and
what the old copy ("ask again once the page loads") was apologising
for. Re-asking cannot loop: resolveIntent only navigates when the
destination differs from the current page. */
if (controller.signal.aborted) return;
if (intent.kind === 'navigate') onNavigate?.(intent.destination);
if (intent.kind === 'navigate') onNavigate?.(intent.destination, text);
/* A skill's action runs after its reply, for the same reason: the user
should read why the form opened before it opens. */
if (intent.kind === 'skill' && intent.action) onAction?.(intent.action, intent.skill);
@@ -757,9 +898,53 @@ export function useConversation({
// half-written answer loses what they were already reading. Stopping
// before the first block, though, should leave no empty turn behind.
if (latest.length) {
/**
* What to ask NEXT — which is not the same as what is worth asking.
*
* Every other path in this file ends its turn with `followUp`; the
* agent path was the one that did not, so an agent answer was the only
* kind that left the chip row empty.
*
* The first attempt at fixing that asked for the page's untyped
* suggestions, and they are ranked by signal rather than by the
* conversation — so a page with six intents offered its top three, and
* offered the same three after every answer, including the one that had
* just been asked. Three standing highlights repeated verbatim are not
* follow-ups; they are the landing screen redrawn under a reply.
*
* So the question is passed to the server as the query, which ranks the
* same catalogue against what was actually asked, and anything this
* thread has already asked or already offered is removed. What is left
* is what this conversation has not covered yet — which is what a
* follow-up is. When nothing is left, nothing is shown: a panel with
* nothing new to suggest should say so by being quiet, not by repeating
* itself.
*
* A stopped run is offered nothing. The reader interrupted the answer,
* so the next step it implies has not been established.
*/
let followUp;
if (!controller.signal.aborted) {
try {
followUp = await nextSteps({
question: text,
history: messagesRef.current,
refresh: onRefreshSuggestions,
});
} catch {
/* The answer arrived; failing to fetch what to ask next is not a
reason to withhold it. */
}
}
const next = [
...messagesRef.current,
{ role: 'assistant', blocks: latest, stopped: controller.signal.aborted || undefined },
{
role: 'assistant',
blocks: latest,
stopped: controller.signal.aborted || undefined,
...(followUp ? { followUp } : null),
},
];
messagesRef.current = next;
persist(next);
@@ -777,13 +962,18 @@ export function useConversation({
setPending(null);
abortRef.current = null;
}
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
}, [contextId, facts, persist, onNavigate, onAction, flowWriters, onRefreshSuggestions,
onUpdatePosition,
onGenerateDescription, onAssignWorkers,
onScheduleInterview,
workforce, setFlow, disabledSkills,
customSkills, roles, skillCategories, courses, skillContext,
agent, agentCoversPage, agentSuggestion, owliverContext]);
customSkills, roles, skillCategories, courses, skillContext, companies, workers, postings,
agent, agentCoversPage, agentSuggestion, owliverContext,
/* The layout session changes as a page's composition and the account's
skills resolve, and a stale one means the tree Owliver inspects is the
empty one from the first render — so a layout request falls through to
the model and comes back as "I don't cover that". */
uiEditing]);
const stop = React.useCallback(() => abortRef.current?.abort(), []);

View File

@@ -1,3 +1,4 @@
import type { DsProps } from '@/components/ds/props';
import React, { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
@@ -104,7 +105,7 @@ function getDeptColor(index, avgScore) {
}
/** @param {any} props */
export function DepartmentPerformance({ items = [], className = '' }) {
export function DepartmentPerformance({ items = [], className = '' }: DsProps) {
const [viewMode, setViewMode] = useState('flowchart'); // 'flowchart' | 'table'
const [activeDept, setActiveDept] = useState(null);

View File

@@ -1,3 +1,4 @@
import type { DsProps } from '@/components/ds/props';
import React from 'react';
import { BarChart } from '@mui/x-charts';
import { ChevronRight } from 'lucide-react';
@@ -19,7 +20,7 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
* component derives no figures of its own.
*/
/** @param {any} props */
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }) {
export function HiringFlow({ stages = [], transitions = [], weakestKey, className = '' }: DsProps) {
if (!stages.length) return null;
const total = stages[0]?.count || 1;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from '@/components/ds/props';
import React from 'react';
import { LineChart } from '@mui/x-charts';
import { cn } from '@/lib/utils';
@@ -15,7 +16,7 @@ import { chartSx, TONES, tooltipSx } from './muiChartTheme';
* `points: [{ label, hires, cumulative }]` — supplied by the page.
*/
/** @param {any} props */
export function HiringTrendChart({ points = [], emptyState, className = '' }) {
export function HiringTrendChart({ points = [], emptyState, className = '' }: DsProps) {
const enough = points.length >= 2;
return (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Avatar } from '@/components/ds/Avatar';
@@ -30,7 +31,7 @@ export function ActivityCard({
variant = 'row',
onClick,
className,
}) {
}: DsProps) {
const iconTones = {
soft: 'bg-krow-blue-tint text-krow-blue',
brand: 'bg-krow-blue text-white',

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
import { cva } from 'class-variance-authority';
@@ -40,7 +41,7 @@ const ICON_COLORS = {
* that persists is noise.
*/
/** @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 Icon = icon || ICONS[tone];
return (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cva } from 'class-variance-authority';
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
* degrades to initials instead of a broken-image icon.
*/
/** @type {React.ForwardRefExoticComponent<any>} */
export const Avatar = React.forwardRef(
export const Avatar: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
const [failed, setFailed] = React.useState(false);
const showImage = src && !failed;
@@ -91,7 +91,7 @@ Avatar.displayName = 'Avatar';
* people in a tight row (applicants on a position, endorsers on a skill).
*/
/** @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 overflow = people.length - visible.length;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { ResponsiveContainer } from 'recharts';
import { cn } from '@/lib/utils';
@@ -38,7 +39,7 @@ export const AXIS_PROPS = {
* white box that ignores the token set.
*/
/** @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;
return (
@@ -71,7 +72,7 @@ export function ChartTooltip({ active, payload, label, valueFormatter, labelForm
* A legend that reads as part of the card rather than as chart furniture.
* Items: `{ label, color, value? }`.
*/
export function ChartLegend({ items = [], className }) {
export function ChartLegend({ items = [], className }: DsProps) {
return (
<div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2', className)}>
{items.map((item) => (
@@ -118,7 +119,7 @@ export function ChartContainer({
raw = false,
footer,
className,
}) {
}: DsProps) {
if (loading) return <SkeletonChart className={className} height={height} />;
return (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -60,7 +61,7 @@ export function DataTable({
className,
rowClassName,
stickyHeader = false,
}) {
}: DsProps) {
/* ── Sorting ─────────────────────────────────────────────────────────── */
/* Row height follows the account's density preference. Only the vertical
padding changes — same columns, same type, same behaviour. */

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
@@ -41,7 +42,7 @@ export function Drawer({
className,
/** Hides the header for a fully custom panel (the mobile nav does this). */
bare = false,
}) {
}: DsProps) {
const isVertical = side === 'top' || side === 'bottom';
return (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -25,7 +26,7 @@ export function EmptyState({
variant = 'empty',
size = 'default',
className,
}) {
}: DsProps) {
const renderAction = (spec, buttonVariant) => {
if (!spec) return null;
if (React.isValidElement(spec)) return spec;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
@@ -19,7 +20,7 @@ export function Field({
inline = false,
className,
children,
}) {
}: DsProps) {
const reactId = React.useId();
const id = htmlFor || reactId;
const hintId = hint ? `${id}-hint` : undefined;
@@ -27,11 +28,15 @@ export function Field({
const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
// 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)
? React.cloneElement(children, {
id: children.props.id || id,
'aria-describedby': children.props['aria-describedby'] || describedBy,
'aria-invalid': error ? true : children.props['aria-invalid'],
? React.cloneElement(children as React.ReactElement<Record<string, any>>, {
id: (children.props as Record<string, any>).id || id,
'aria-describedby': (children.props as Record<string, any>)['aria-describedby'] || describedBy,
'aria-invalid': error ? true : (children.props as Record<string, any>)['aria-invalid'],
})
: children;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { SlidersHorizontal, X } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -37,7 +38,7 @@ export function FilterBar({
/** Value that means "no filter" for select-type filters. */
allValue = 'all',
className = '',
}) {
}: DsProps) {
const [expanded, setExpanded] = React.useState(false);
const isActive = (filter) => {

View File

@@ -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
* makes it impossible to omit. The label doubles as the tooltip.
*/
/** @type {React.ForwardRefExoticComponent<any>} */
export const IconButton = React.forwardRef(
export const IconButton: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
const sizeMap = {
xs: 'icon-xs',

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { AlertTriangle, ChevronRight, Info, ShieldAlert, TrendingDown } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -58,7 +59,7 @@ export function InsightRow({
onClick = null,
actionLabel = 'Review',
className = '',
}) {
}: DsProps) {
const s = SEVERITY[severity] || SEVERITY.info;
const Comp = onClick ? 'button' : 'div';
@@ -107,6 +108,6 @@ export function InsightRow({
/** The list container — dividers between rows, nothing else. */
/** @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>;
}

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { motion } from 'framer-motion';
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
@@ -32,7 +33,7 @@ export function KpiCard({
loading = false,
onClick,
className,
}) {
}: DsProps) {
const t = TONES[tone] || TONES.brand;
if (loading) {
@@ -112,7 +113,7 @@ export function MetricCard({
loading = false,
onClick,
className,
}) {
}: DsProps) {
const t = TONES[tone] || TONES.neutral;
if (loading) {

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
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).
*/
/** @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 = {
1: 'grid-cols-1',
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. */
/** @param {any} props */
export function Stack({ space = 'default', className, children, ...props }) {
export function Stack({ space = 'default', className, children, ...props }: DsProps) {
const spacing = {
xs: 'space-y-2',
sm: 'space-y-3',
@@ -59,7 +60,7 @@ export function Stack({ space = 'default', className, children, ...props }) {
* with its content at consistent spacing.
*/
/** @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 (
<section className={cn('space-y-3', className)}>
{title && (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -6,7 +7,7 @@ import { cn } from '@/lib/utils';
* Spinner — the only spinner in the system.
*/
/** @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' };
return (
<Loader2
@@ -25,7 +26,7 @@ export function Spinner({ size = 'default', className = '', label = 'Loading' })
* final layout prevents the content jump a spinner always causes.
*/
/** @param {any} props */
export function LoadingState({ message = '', size = 'default', className = '' }) {
export function LoadingState({ message = '', size = 'default', className = '' }: DsProps) {
return (
<div
className={cn(
@@ -45,7 +46,7 @@ export function LoadingState({ message = '', size = 'default', className = '' })
* card headers, or beside a control that is refreshing.
*/
/** @param {any} props */
export function InlineLoading({ message = 'Loading…', className = '' }) {
export function InlineLoading({ message = 'Loading…', className = '' }: DsProps) {
return (
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
<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.
*/
/** @param {any} props */
export function LoadingOverlay({ show = false, message = '', className = '' }) {
export function LoadingOverlay({ show = false, message = '', className = '' }: DsProps) {
if (!show) return null;
return (
<div

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -58,7 +59,7 @@ function Delta({ delta, label, invert }) {
}
/** @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 gridCols = {

Some files were not shown because too many files have changed in this diff Show More