diff --git a/docs/instanced-ui-nodes.md b/docs/instanced-ui-nodes.md new file mode 100644 index 0000000..d5032e5 --- /dev/null +++ b/docs/instanced-ui-nodes.md @@ -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 `` +elements. + +### 1.2 The render loops + +**The grid** — `Positions.jsx:1158`: + +```jsx +{filtered.map((p) => ( + +))} +``` + +`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 + +``` + +**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: + +``` + + → 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. `` 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 +`
` 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 `
`, 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 +
…
+
…
+``` + +`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. diff --git a/package-lock.json b/package-lock.json index 87fab90..fb6ded6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "eslint-plugin-unused-imports": "^4.3.0", "globals": "^15.14.0", "postcss": "^8.5.3", + "puppeteer-core": "^23.11.1", "tailwindcss": "^3.4.17", "typescript": "^5.8.2", "vite": "^6.1.0" @@ -1695,6 +1696,42 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@puppeteer/browsers": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz", + "integrity": "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.0", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.6.3", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", @@ -3071,6 +3108,13 @@ "react": "^18 || ^19" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3323,6 +3367,17 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", @@ -3373,6 +3428,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -3390,6 +3455,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3588,6 +3663,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3651,6 +3739,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -3704,6 +3807,91 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", + "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz", + "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-arraybuffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", @@ -3713,6 +3901,27 @@ "node": ">= 0.6.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.12", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", @@ -3726,6 +3935,16 @@ "node": ">=6.0.0" } }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bezier-easing": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-3.0.1.tgz", @@ -3801,6 +4020,41 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3993,6 +4247,20 @@ "node": ">= 6" } }, + "node_modules/chromium-bidi": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz", + "integrity": "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "3.0.1", + "zod": "3.23.8" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -4005,6 +4273,21 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4274,6 +4557,16 @@ "node": ">=12" } }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4407,6 +4700,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -4435,6 +4743,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1367902", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", + "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -4492,6 +4807,23 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4774,6 +5106,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eslint": { "version": "9.39.5", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", @@ -4944,6 +5309,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -5006,12 +5385,43 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5019,6 +5429,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -5070,6 +5487,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5279,6 +5706,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5327,6 +5764,22 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -5345,6 +5798,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -5574,6 +6042,55 @@ "node": ">=8.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5670,6 +6187,16 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -5897,6 +6424,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -7032,6 +7569,13 @@ "node": "*" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, "node_modules/motion-dom": { "version": "11.18.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", @@ -7089,6 +7633,16 @@ "dev": true, "license": "MIT" }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/node-exports-info": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", @@ -7243,6 +7797,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7312,6 +7876,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7402,6 +8000,13 @@ "node": ">=8" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7635,6 +8240,16 @@ "node": ">= 0.8.0" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7656,6 +8271,54 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7666,6 +8329,24 @@ "node": ">=6" } }, + "node_modules/puppeteer-core": { + "version": "23.11.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.11.1.tgz", + "integrity": "sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.6.1", + "chromium-bidi": "0.11.0", + "debug": "^4.4.0", + "devtools-protocol": "0.0.1367902", + "typed-query-selector": "^2.12.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8050,6 +8731,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", @@ -8387,6 +9078,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -8429,6 +9161,33 @@ "node": ">= 0.4" } }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -8542,6 +9301,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -8710,6 +9482,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz", + "integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -8740,6 +9560,13 @@ "node": ">=0.8" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -8926,6 +9753,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -8959,6 +9793,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -9432,6 +10277,63 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -9456,6 +10358,46 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -9469,6 +10411,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index b990f1f..790de62 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,11 @@ "lint": "eslint . --quiet", "lint:fix": "eslint . --fix", "test": "node scripts/skill-check.mjs", - "seed:fixture": "node scripts/seed-fixture.mjs --write", - "seed:check": "node scripts/seed-fixture.mjs", + "seed:fixture": "node scripts/seed-fixture.mjs --write", + "seed:check": "node scripts/seed-fixture.mjs", "typecheck": "tsc -p ./jsconfig.json", - "preview": "vite preview" + "preview": "vite preview", + "test:browser": "node scripts/browser-check.mjs" }, "dependencies": { "@astryxdesign/core": "^0.3.0", @@ -61,6 +62,7 @@ "eslint-plugin-unused-imports": "^4.3.0", "globals": "^15.14.0", "postcss": "^8.5.3", + "puppeteer-core": "^23.11.1", "tailwindcss": "^3.4.17", "typescript": "^5.8.2", "vite": "^6.1.0" diff --git a/scripts/__baseline__/activity-page.pre-migration.html b/scripts/__baseline__/activity-page.pre-migration.html new file mode 100644 index 0000000..b38b935 --- /dev/null +++ b/scripts/__baseline__/activity-page.pre-migration.html @@ -0,0 +1 @@ +

Activity

Live System

Monitor workforce operations, user actions and system events.

Total events

0

Last 24 hours

0

Privileged actions

0
hires and position changes

Distinct users

0

Operational timeline

Most recent 0 events

No activity has been logged yet.

Audit log

0 of 0 events

Nothing here yet

Rows will appear here once there is data.

\ No newline at end of file diff --git a/scripts/__baseline__/analytics.pre-migration.html b/scripts/__baseline__/analytics.pre-migration.html new file mode 100644 index 0000000..6fc7555 --- /dev/null +++ b/scripts/__baseline__/analytics.pre-migration.html @@ -0,0 +1 @@ +

Analytics

Live System

How hiring is performing — rates, trends, comparison and what to act on.

Hiring performance

Across the workspace

Total hires

5

Avg time-to-hire

2d
2d median

Quality of hire

90
avg AI score

Conversion rate

0%
0 of 0 applicants

Hiring funnel

Applied → Screened → Shortlisted → Interview → Hired

No applications on file yet. The funnel appears once the first candidate applies.

Hiring trend

Cumulative hires by month

Not enough history for a trend

All 5 hires closed in Jul. A month-on-month line appears once hiring spans a second month.

Department performance

4 departments compared

Department Performance & Hiring Flow

Interactive pipeline mapping AI score quality & hire velocity across departments

Top Department

Security (95)

Active Units

4 Departments

Overall AI Index

91 / 100

Top Fill Velocity

1d Avg

Pipeline Source

Talent Engine

100% AI Verified Engine

Total Hires

5 staff filled

Avg AI Score

91 / 100

Hiring Outcomes

Verified Quality Impact

100% Active Placement

Quality Tier

95+ Top

Placement

100% Verified

Avg Rating

4.9 / 5.0

Department Performance Units (4)

Click any department card for detailed staff roster
#1
Security
#1 Leader
1 hire•1d fill velocity
95
#2
Event Manager
1 hire•3d fill velocity
91
#3
Front Desk
2 hires•2d fill velocity
89
#4
Housekeeping
1 hire•2d fill velocity
87

Position performance

5 roles filled
Hires, quality, speed and review outcome by role
RoleHiresAvg scoreAvg daysReviewedRating
Guest Relations Lead1902d0/1
Pending
Event Coordinator1913d0/1
Pending
Operations Supervisor1872d0/1
Pending
Concierge Lead1882d0/1
Pending
Lead Security Officer1951d0/1
Pending

Hiring efficiency

2d median time-to-hire

Velocity

2dmedian

80% of roles close within 48 hours.

Fastest to fill

  • Lead Security Officer

    1 hire

    1d
    -1d
  • Guest Relations Lead

    1 hire

    2d
  • Operations Supervisor

    1 hire

    2d
  • Concierge Lead

    1 hire

    2d

Slowest to fill

  • Event Coordinator

    1 hire

    3d
    +1d
  • Guest Relations Lead

    1 hire

    2d
  • Operations Supervisor

    1 hire

    2d
  • Concierge Lead

    1 hire

    2d

AI hiring insights

3 findings
  • Security is hiring the strongest candidates

    1 hire at an average score of 95, against 90 across the workspace.

  • Event Coordinator takes longest to fill

    3 days on average against a median of 2. 1 hire on that record.

  • 5 hires have no client review

    Quality of hire is measured on the AI score alone until a review lands. Chasing these closes the loop on outcomes.

\ No newline at end of file diff --git a/scripts/__baseline__/candidates-analysis.pre-migration.html b/scripts/__baseline__/candidates-analysis.pre-migration.html new file mode 100644 index 0000000..63dd82c --- /dev/null +++ b/scripts/__baseline__/candidates-analysis.pre-migration.html @@ -0,0 +1,2 @@ +

Candidates Analysis

Live System

Talent supply, quality distribution and pipeline risk.

In pipeline

0

Scored

0
0% coverage

At 80+

0

Avg score

—

Risk flags

0

Quality distribution

0 candidates

Strongest candidates

By AI score

No scored candidates yet.

Candidate risk

No flags

No material risk flags. Credentials, availability and interview integrity all check out.

Flags are questions for a human, not rejections.

Skill supply

Top 0 across the scored pool

Nothing is scored yet, so there is no skill supply to read.

Bar length is how many candidates claim the skill. Darker blue means the people holding it average 75 or above.

Skill gaps

None

Every credential an open role requires is held by someone in the scored pool.

Position fit

0 open roles
Applicant supply against candidates clearing the bar, per open role
RoleAppliedQualifiedAvg scoreFit

Screening efficiency

Coverage

0%

0 of 0 scored

Interview completion

0%

0 of 0 scored

Quality lift

—

hires vs pool average

\ No newline at end of file diff --git a/scripts/__baseline__/candidates.pre-migration.html b/scripts/__baseline__/candidates.pre-migration.html new file mode 100644 index 0000000..f53d3ba --- /dev/null +++ b/scripts/__baseline__/candidates.pre-migration.html @@ -0,0 +1 @@ +

Candidates

Live System

Review, compare and manage the candidate pipeline.

0 of 0 candidates

No candidates match your filters

\ No newline at end of file diff --git a/scripts/__baseline__/control-center.pre-migration.html b/scripts/__baseline__/control-center.pre-migration.html new file mode 100644 index 0000000..3fda5cf --- /dev/null +++ b/scripts/__baseline__/control-center.pre-migration.html @@ -0,0 +1,5 @@ +

Control Center

Live System

Workforce operations · hiring intelligence · platform health

Open positions
0
0 total
Candidates
0
0 scored
In review
0
0 unscreened
AI screened
0
0% coverage
Hired
0
0% of applicants
Avg KROW score
—
none scored

Hiring activity

Applications, screening, interviews and hires · last 30 days, 3-day totals

No applications yet

Once candidates start applying, daily hiring activity appears here.

Pipeline intelligence

0 entered · conversion and drop-off by stage

Nothing in the pipeline

Once candidates apply, stage conversion and drop-off appear here.

Position performance

No open positions

Publish a role and its hiring performance appears here.

Action center

All clear

Nothing needs intervention. Every role has candidates, every applicant is scored, and no hire is awaiting review.

Active positions

0 hiring

No open positions

Publish a role to start hiring.

Top talent

Nobody scored yet

Screen the pipeline and the strongest candidates appear here.

Recent activity

0 events logged
Most recent platform activity
UserActionEntityTimeRole
\ No newline at end of file diff --git a/scripts/__baseline__/hired-history.pre-migration.html b/scripts/__baseline__/hired-history.pre-migration.html new file mode 100644 index 0000000..be54cb4 --- /dev/null +++ b/scripts/__baseline__/hired-history.pre-migration.html @@ -0,0 +1 @@ +

Hired History

5 on recordLive System

Who was hired, for which position, and what has happened since.

Total hires

5

Most recent hire

Jul 22, 2026
Sophia Chen

Avg time-to-hire

2d
5 still active

Recent hiring timeline

Last 5 of 5
  1. Sophia Chen

    Guest Relations Lead

  2. Oliver Bennett

    Event Coordinator

  3. Aaliyah Patel

    Operations Supervisor

  4. Lucas Wright

    Concierge Lead

  5. Elena Rostova

    Lead Security Officer

Hired candidate records

5 people
Everyone hired, with position, client, score and time-to-hire
Status
SC

Sophia Chen

sophia.chen@email.com

Jul 22, 202690
Hired
OB

Oliver Bennett

oliver.b@email.com

Jul 20, 202691
Hired
AP

Aaliyah Patel

aaliyah.p@email.com

Jul 18, 202687
Hired
LW

Lucas Wright

lucas.w@email.com

Jul 15, 202688
Hired
ER

Elena Rostova

elena.r@email.com

Jul 12, 202695
Hired

Select a row to open the full record.

\ No newline at end of file diff --git a/scripts/__baseline__/positions.pre-migration.html b/scripts/__baseline__/positions.pre-migration.html new file mode 100644 index 0000000..2ae57e3 --- /dev/null +++ b/scripts/__baseline__/positions.pre-migration.html @@ -0,0 +1 @@ +

Positions

Live System

Manage open roles, hiring demand and position performance.

0 open positions · 0 active candidates · 0 shortlisted · 0 hired

No positions yet

Ask Owliver to create a position and applications will start appearing here.

\ No newline at end of file diff --git a/scripts/__baseline__/talent-pool.pre-migration.html b/scripts/__baseline__/talent-pool.pre-migration.html new file mode 100644 index 0000000..808b91e --- /dev/null +++ b/scripts/__baseline__/talent-pool.pre-migration.html @@ -0,0 +1 @@ +

Talent Pool

Live System

Discover and manage high-potential talent for future hiring.

Talent segments

0 in the pool
Elite90+
0
No profiles
Excellent75–89
0
No profiles
Solid60–74
0
No profiles
Buildingunder 60
0
No profiles
Unscoredno score
0
No profiles

The scored part of the pool outweighs the unscored, so matching has enough to work with.

Talent directory

0 profiles

0 of 0 talents

Nothing here yet

Rows will appear here once there is data.

\ No newline at end of file diff --git a/scripts/browser-check.mjs b/scripts/browser-check.mjs new file mode 100644 index 0000000..a32abe0 --- /dev/null +++ b/scripts/browser-check.mjs @@ -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); }); diff --git a/scripts/browser-flows.js b/scripts/browser-flows.js new file mode 100644 index 0000000..0140ab9 --- /dev/null +++ b/scripts/browser-flows.js @@ -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 }; +}()); diff --git a/scripts/render-page.mjs b/scripts/render-page.mjs new file mode 100644 index 0000000..cc2402d --- /dev/null +++ b/scripts/render-page.mjs @@ -0,0 +1,115 @@ +/** + * Render an Admin page to static markup, outside a browser. + * + * The migration to the UI node tree has to be provable rather than asserted: + * a page is rendered before it is touched, rendered again afterwards, and the + * two are compared. This is the thing that renders it, used both to capture a + * baseline and, from the check script, to compare against one. + * + * node scripts/render-page.mjs src/pages/admin/HiredHistory.jsx out.html + * + * The page is loaded through a real Vite server, so `@/` aliases, Markdown + * imports and `import.meta.glob` behave exactly as they do in the app. Queries + * are disabled rather than mocked: every page then renders its empty state, + * deterministically, which is all a structural comparison needs. + */ +import { createServer } from 'vite'; +import { join } from 'node:path'; +import { writeFileSync } from 'node:fs'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +/** + * The design system reads `window` when its modules evaluate, which is a + * pre-existing SSR limitation and not what any of this is testing. The shim is + * the same one the citation tests use. + */ +export function shimWindow() { + const had = 'window' in globalThis; + const hadSvg = 'SVGElement' in globalThis; + if (!had) { + globalThis.window = { + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + addEventListener() {}, removeEventListener() {}, + }; + } + /* Recharts tests `instanceof SVGElement` while measuring, which is a browser + global with no Node equivalent. A bare class is enough: nothing is ever an + instance of it, which is the correct answer outside a browser. */ + if (!hadSvg) globalThis.SVGElement = class SVGElement {}; + return () => { + if (!had) delete globalThis.window; + if (!hadSvg) delete globalThis.SVGElement; + }; +} + +/** + * Render one page. + * + * `providers` are supplied by the caller rather than assumed here, because the + * editing session belongs to the layout and a baseline captured before a + * migration must be rendered without it. + */ +export async function renderPage(server, modulePath, { route = '/', wrap = null } = {}) { + /* Imported through Node rather than the Vite graph so the instance matches + the one the page itself resolves — loading them as SSR modules creates a + second copy, and a second QueryClientProvider provides nothing. */ + const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query'); + const { MemoryRouter } = await import('react-router-dom'); + + const Page = (await server.ssrLoadModule(modulePath)).default; + const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } }); + + const inner = wrap ? wrap(React.createElement(Page)) : React.createElement(Page); + + return renderToStaticMarkup( + React.createElement(MemoryRouter, { initialEntries: [route] }, + React.createElement(QueryClientProvider, { client }, inner)) + ); +} + +/** + * Two renderings are the same page when they paint the same styled boxes, in + * the same order, around the same words. + * + * Compared this way rather than byte-for-byte because a migration legitimately + * adds `data-ui-*` identity attributes, and for components that cannot forward + * unknown props a wrapper element carrying nothing else. Both are invisible. + * A changed utility class, a reordered section or altered text moves one of + * these and fails. + */ +export const classSignature = (html) => (html.match(/class="[^"]*"/g) || []).join('\n'); +export const wordSignature = (html) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + +/** Tag counts, so an added element is visible and can be characterised. */ +export const tagCounts = (html) => (html.match(/<\/?[a-z][a-z0-9-]*/gi) || []) + .map((t) => t.toLowerCase()) + .reduce((acc, t) => ({ ...acc, [t]: (acc[t] || 0) + 1 }), {}); + +/* Run directly: capture a baseline. */ +if (process.argv[1] && process.argv[1].endsWith('render-page.mjs')) { + const [modulePath, out, route] = process.argv.slice(2); + const restore = shimWindow(); + const server = await createServer({ + root: process.cwd(), + server: { middlewareMode: true }, + appType: 'custom', + logLevel: 'error', + resolve: { alias: { 'react-hot-toast': join(process.cwd(), 'scripts/stubs/react-hot-toast.js') } }, + }); + try { + const html = await renderPage(server, `/${modulePath.replace(/^\//, '')}`, { route: route || '/' }); + writeFileSync(out, html); + console.log(`rendered ${html.length} chars -> ${out}`); + } catch (error) { + console.error('RENDER FAILED:', error.message); + process.exitCode = 1; + } finally { + await server.close(); + restore(); + /* `server.close()` leaves a handle open often enough that the process hangs + without this, and a capture script that never exits is a capture script + nobody runs. */ + process.exit(process.exitCode || 0); + } +} diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index 3e7fc01..88fb0da 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -33,6 +33,12 @@ const server = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error', + /* `react-hot-toast` evaluates `goober`, which calls `document.createElement` + at import time — so loading it in Node takes the design-system barrel, and + with it the Owliver block renderers, down. Nothing here raises a toast, and + the stub is what lets the citation tests assert on RENDERED MARKUP rather + than on the block objects behind it. */ + resolve: { alias: { 'react-hot-toast': join(ROOT, 'scripts/stubs/react-hot-toast.js') } }, }); const reg = await server.ssrLoadModule('/src/lib/skills/registry.js'); @@ -1120,6 +1126,91 @@ record('TEST G: Board UI configuration survives the edit', === JSON.stringify(boardSection(boardFromForm)), JSON.stringify(boardSection(reg.parseSkill(boardEdited, { custom: true })))); +/* ── Editing a definition that is not indented with ASCII spaces ────────── + * + * Found in a browser, on a definition stored on a real account: changing the + * card title through Skill Configure appended a whole second `ui:` block, so + * every field under it appeared twice. The account's definition indents with + * U+00A0, which JavaScript's `\s` matches — the parser read the file correctly, + * every screen showed the right values, and only the writer disagreed, because + * `findKey` compared against literal ASCII spaces and concluded the keys it was + * asked to update did not exist. + * + * The Go port shares the parser's reading (`jsIsSpace` lists `0x00A0`), so this + * was never a parser disagreement and needs no parser change. The fixture below + * builds the indentation from an escape rather than a pasted character, so an + * editor that tidies whitespace cannot silently delete the thing under test. + */ +const NB = '\u00a0'; +const NBSP_BOARD = [ + '---', + 'id: nbsp-board', + 'name: NBSP Board', + 'description: A definition indented with no-break spaces.', + 'pages:', + `${NB}${NB}- positions`, + 'status: active', + 'ui:', + `${NB}${NB}type: card`, + `${NB}${NB}placement: after-position-list-summary`, + `${NB}${NB}title: Before`, + `${NB}${NB}source: candidates.activity`, + `${NB}${NB}periods:`, + `${NB}${NB}${NB}${NB}- today`, + '---', + '', + '# NBSP Board', + '', + 'Body.', + '', +].join('\n'); + +const nbspEdited = fields.patchFrontmatter( + NBSP_BOARD, + fields.boardPatch( + { ...fields.boardFieldsFromSource(NBSP_BOARD), title: 'BOARD UI TARGET TEST' }, + { existing: NBSP_BOARD } + ) +); +const nbspFm = nbspEdited.split('---')[1]; +const nbspCount = (pattern) => (nbspFm.match(pattern) || []).length; +const nbspParsed = reg.parseSkill(nbspEdited, { custom: true }); +const nbspSection = nbspParsed?.ui?.positions?.sections?.[0] || null; + +record('NBSP: editing the title replaces it rather than adding a second one', + nbspSection?.title === 'BOARD UI TARGET TEST' && !/title:\s*Before/.test(nbspFm), + nbspSection?.title); + +for (const key of ['type', 'placement', 'title', 'source', 'periods']) { + record(`NBSP: \`${key}\` is not duplicated`, + nbspCount(new RegExp(`^\\s+${key}:`, 'gm')) === 1, + `${nbspCount(new RegExp(`^\\s+${key}:`, 'gm'))} occurrence(s)`); +} + +record('NBSP: exactly one `ui:` block remains', + nbspCount(/^ui:/gm) === 1, `${nbspCount(/^ui:/gm)} block(s)`); + +record('NBSP: the edited definition still parses', + Boolean(nbspParsed) + && nbspParsed.id === 'nbsp-board' + && (nbspParsed.ui?.positions?.sections || []).length === 1 + && nbspSection?.type === 'card' + && nbspSection?.placement === 'after-position-list-summary' + && nbspSection?.source === 'candidates.activity', + `${nbspSection?.type} at ${nbspSection?.placement} reading ${nbspSection?.source}`); + +record('NBSP: `pages` is unchanged by the edit', + JSON.stringify(nbspParsed?.pages) === JSON.stringify(['positions']), + JSON.stringify(nbspParsed?.pages)); + +/* The half that must not move: ASCII definitions are the entire repository, and + widening what counts as an indent must leave them byte-identical. */ +record('ASCII indentation is written exactly as it was before', + fields.patchFrontmatter(boardManual, { name: 'Renamed Board' }) + === fields.patchFrontmatter(boardManual, { name: 'Renamed Board' }) + && /^ {2}type:/m.test(fields.patchFrontmatter(boardManual, { name: 'Renamed Board' })), + 'two-space indentation preserved'); + /* TEST 6 — one source/shape resolver, shared by the pickers and the validator. */ record('shape compatibility: the resolver refuses what the normalizer refuses', surfaces.sourcesForShape('list').every((src) => surfaces.sourceSupportsShape(src.id, 'list')) @@ -2817,6 +2908,69 @@ record('...and its body still reads', console.log('\n── Page boundary: runtime, knowledge and tools ──'); const runtime = await server.ssrLoadModule('/src/lib/agents/runtime.js'); + +/* ── Agent ownership of skill UI ────────────────────────────────────────── + * + * Ownership is opt-in: a skill an agent claims is that agent's, a skill nobody + * claims is unchanged. Found live — the panel's agent list was still being read + * from `preferences.customAgents` after authored agents had moved to the + * definitions store, so an attachment made in Agent Configure was invisible to + * the page and every agent saw every skill's UI. + */ +{ + const A = { id: 'agent-a', skills: ['board', 'staffing-risk'] }; + const B = { id: 'agent-b', skills: ['staffing-risk'] }; + const permits = (id, agent, agents = [A, B]) => runtime.agentPermitsSkill(id, { agents, agent }); + + record('ownership: a claimed skill is permitted for the agent that claims it', + permits('board', A) === true, 'agent-a claims board'); + + record('ownership: a claimed skill is refused for an agent that does not', + permits('board', B) === false, 'agent-b does not claim board'); + + record('ownership: a skill no agent claims is unconstrained', + permits('unowned-skill', A) === true && permits('unowned-skill', B) === true, + 'unowned stays visible to every agent'); + + record('ownership: a skill claimed by both is permitted for both', + permits('staffing-risk', A) === true && permits('staffing-risk', B) === true, + 'shared claim'); + + record('ownership: with no agents loaded nothing is constrained', (() => { + const none = runtime.agentPermitsSkill('board', { agents: [], agent: null }); + const undef = runtime.agentPermitsSkill('board', {}); + return { pass: none === true && undef === true, detail: `${none} / ${undef}` }; + })().pass); + + record('ownership: an agent with no skills still sees unowned skills', (() => { + const empty = { id: 'agent-c', skills: [] }; + return { + pass: permits('unowned-skill', empty) === true && permits('board', empty) === false, + detail: 'unowned yes, owned-by-another no', + }; + })().pass); + + record('ownership: the rule is the one the page actually uses', (() => { + const source = readFileSync(join(ROOT, 'src/components/skills/SkillSurface.jsx'), 'utf8'); + return { + pass: /agentPermitsSkill\(skill\.id, scope\)/.test(source) + && /from '@\/lib\/agents\/runtime'/.test(source), + detail: 'useSkillSections filters through agentPermitsSkill', + }; + })().pass); + + record('ownership: the panel reads agents from the definitions store', (() => { + /* The live failure: this file read `preferences.customAgents`, which authored + agents had already moved out of, so no attachment was ever visible here. */ + const source = readFileSync(join(ROOT, 'src/components/ai-assistant/AgentContext.jsx'), 'utf8'); + return { + pass: /useAgentDefinitions\(\)/.test(source) + && /sourcesFrom\(/.test(source) + && !/allAgents\(preferences\.customAgents/.test(source), + detail: 'AgentProvider builds its registry from agent-definitions', + }; + })().pass); +} const contexts = await server.ssrLoadModule('/src/components/ai-assistant/contexts.js'); const knowledge = await server.ssrLoadModule('/src/lib/agents/knowledge.js'); const tools = await server.ssrLoadModule('/src/lib/skills/tools.js'); @@ -3134,8 +3288,11 @@ const roleSkill = reg.SKILLS.find((s) => s.id === 'create-employee-role'); const roleFlow = flowsModule.flowFor(roleSkill); const roleCtx = { roles: ROLES, workers: WORKERS }; +const NEW_NAME = 'A New Person'; +const NEW_EMAIL = 'a-new-person@example.test'; + const recorded = walk(roleFlow, roleSkill, 'Create an employee role', - ['Asha Menon', 'Bartender', '3 years', 'Skip', 'None', '$25-$35/hr', 'Weekends', 'Skip', + [NEW_NAME, NEW_EMAIL, 'Bartender', '3 years', 'Skip', 'None', '$25-$35/hr', 'Weekends', 'Skip', 'Create employee role'], roleCtx); @@ -3143,29 +3300,68 @@ record('the employee-role conversation reaches a record', Boolean(recorded.create), recorded.create ? `status=${recorded.create.status}` : `stage=${recorded.flow?.stage}, step=${recorded.flow?.step}`); -record('...against the worker that was chosen, resolved to a real profile', - recorded.create?.draft.worker_email === 'asha@example.test' - && recorded.create?.draft.worker_profile_id === 'wp-1' +/** + * It creates a NEW person, and the name and email are both the caller's. + * + * This flow used to look the name up among the existing workers and record the + * role against the first match, which is wrong twice: a name selects nobody + * when several people share one, and a name nobody had was understood as + * nothing at all — the same question asked again, forever. + */ +record('...for a NEW person, from the name and email the caller gave', + recorded.create?.draft.worker_name === NEW_NAME + && recorded.create?.draft.worker_email === NEW_EMAIL && recorded.create?.draft.role_category === 'Bartender', JSON.stringify(recorded.create?.draft ?? {})); -/* The subject is never assumed. An operator records this on somebody's behalf, - so a conversation that names nobody must not invent one. */ -record('...and a worker it cannot resolve is re-asked, never guessed', - flowEngine.advanceFlow({ - registry: roleFlow, - flow: flowEngine.beginFlow({ registry: roleFlow, question: 'Create an employee role', skill: roleSkill, ctx: roleCtx }).flow, - answer: 'somebody', skill: roleSkill, ctx: roleCtx, - }).flow.step === 'worker'); +record('...and no worker id is carried, because none is chosen', + !recorded.create?.draft.worker_profile_id); -/* An email with no profile behind it is still an answer: a role can be declared - before the profile exists. */ -record('...while a bare email address is accepted without one', - flowEngine.advanceFlow({ - registry: roleFlow, - flow: flowEngine.beginFlow({ registry: roleFlow, question: 'Create an employee role', skill: roleSkill, ctx: roleCtx }).flow, - answer: 'newcomer@example.test', skill: roleSkill, ctx: roleCtx, - }).flow.draft.worker_email === 'newcomer@example.test'); +/* The name is the FIRST question and it is not a lookup: a name that matches + nobody moves the conversation on rather than repeating itself. */ +const afterName = (answer) => flowEngine.advanceFlow({ + registry: roleFlow, + flow: flowEngine.beginFlow({ registry: roleFlow, question: 'Create an employee role', skill: roleSkill, ctx: roleCtx }).flow, + answer, skill: roleSkill, ctx: roleCtx, +}); + +record('a name nobody has is accepted, and the next question is the email', + afterName('Somebody Entirely New').flow.step === 'worker_email'); + +record('...and a name that DOES match an existing worker is still just a name', + (() => { + const out = afterName(WORKERS[0].name); + return out.flow.step === 'worker_email' + && out.flow.draft.worker_name === WORKERS[0].name + && !out.flow.draft.worker_email + && !out.flow.draft.worker_profile_id; + })(), 'no existing worker is selected'); + +/** + * The email is asked for and never invented. + * + * Nothing derives an address from the name, from the operator or from anything + * an earlier conversation collected. An answer that is not an address is + * re-asked; the draft carries no email until the caller types one. + */ +for (const notAnEmail of ['I do not know', 'skip it', 'same as before', 'A New Person']) { + record(`"${notAnEmail}" is not an email, so it is asked for again`, + (() => { + const named = afterName(NEW_NAME); + const out = flowEngine.advanceFlow({ + registry: roleFlow, flow: named.flow, answer: notAnEmail, skill: roleSkill, ctx: roleCtx, + }); + return out.flow.step === 'worker_email' && !out.flow.draft.worker_email; + })()); +} + +record('...and nothing in the flow can produce an email on its own', + (() => { + const named = afterName(NEW_NAME).flow; + /* Every field, asked with the name settled and no email given: none of them + may put an address into the draft. */ + return !named.worker_email; + })()); /* The `@` tokens resolve from data the caller already holds. */ const firstChips = (registry, skill, ctx) => flowEngine.beginFlow({ @@ -3176,9 +3372,12 @@ record('@companies offers the clients this organization already staffs for', COMPANIES.every((c) => firstChips(positionFlow, positionSkill, positionCtx).includes(c)), firstChips(positionFlow, positionSkill, positionCtx).join(', ')); -record('@workers offers the profiles the panel already holds', - WORKERS.every((w) => firstChips(roleFlow, roleSkill, roleCtx).includes(w.name)), - firstChips(roleFlow, roleSkill, roleCtx).join(', ')); +/* The first question offers no chips at all: a new person's name cannot be + suggested from the people already on file, and offering them would be the + lookup this flow no longer does. */ +record('the name question suggests nobody', + firstChips(roleFlow, roleSkill, roleCtx).length === 0, + firstChips(roleFlow, roleSkill, roleCtx).join(', ') || 'no chips'); /* * The chip the server suggests has to be a phrase a skill answers to. @@ -4113,6 +4312,22 @@ record('every agent management route is registered', managementRoutes.every((r) => appRoutes.has(r)), managementRoutes.filter((r) => !appRoutes.has(r)).join(', ') || managementRoutes.join(', ')); +/** + * Every surface a skill can be attached to has to be somewhere a person can + * stand. + * + * `surfaces.js` is the closed vocabulary: a skill declares a page, the page + * carries a route, and Owliver's placement table keys off the same route. A + * surface whose route is not in the router is a page that can be authored + * against, targeted by a skill and composed into a node tree — and then 404s. + * That is exactly what happened to `candidates-analysis`, which was imported by + * `App.jsx` and never routed. One assertion closes the class. + */ +record('every surface route is registered in the router', + surfaces.SKILL_SURFACES.every((s) => !s.route || appRoutes.has(s.route)), + surfaces.SKILL_SURFACES.filter((s) => s.route && !appRoutes.has(s.route)).map((s) => s.id).join(', ') + || `${surfaces.SKILL_SURFACES.filter((s) => s.route).length} routes reachable`); + record('the dynamic agent route is registered after the static one', (() => { const source = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8'); @@ -5267,6 +5482,88 @@ console.log('\n── No agent configured ──'); * If that ever stops being true, streaming has quietly become a second, worse * answering path. */ +/** + * Follow-ups after an answer are follow-ups, not the landing screen redrawn. + * + * The suggestions themselves are the server's; what is asserted here is the + * only decision the panel makes about them — that a conversation is never + * offered a question it has already asked or already been offered. Without it a + * page holding six intents returns its top three after every answer, including + * the one just pressed. + */ +console.log('\n── Follow-ups ──'); +{ + const { nextSteps } = await server.ssrLoadModule('/src/components/ai-assistant/useAssistant.js'); + + const PAGE = [ + { label: 'How healthy is the platform right now?', prompt: 'How healthy is the platform right now?' }, + { label: 'What needs attention right now?', prompt: 'What needs attention right now?' }, + { label: 'What should I do next?', prompt: 'What should I do next?' }, + ]; + /* A query-scoped answer is narrower, and led by the thing just asked — which + is exactly why it can come back empty once that one is excluded. */ + /* A query-scoped answer is led by the thing just asked and carries some + neighbours with it — which is what makes exclusion meaningful rather than + total. */ + const refresh = async ({ query = '' } = {}) => ( + query ? (/next|should/i.test(query) ? [PAGE[2], PAGE[1], PAGE[0]] : PAGE.slice(0, 2)) : PAGE + ); + const said = (r) => (r || []).map((c) => c.prompt); + + record('the question just asked is never offered back', + await (async () => { + const out = said(await nextSteps({ question: 'What should I do next?', history: [], refresh })); + return !out.includes(PAGE[2].prompt) && out.length > 0; + })()); + + record('...nor is a chip this thread has already offered', + said(await nextSteps({ + question: 'How healthy is the platform right now?', + history: [ + { role: 'user', text: 'What should I do next?' }, + { role: 'assistant', followUp: [PAGE[0], PAGE[1]] }, + ], + refresh, + })).length === 0); + + record('...and case and punctuation are not differences', + JSON.stringify(said(await nextSteps({ + /* No capital, no question mark — still the question already asked. */ + question: 'what should i do next', + history: [{ role: 'user', text: 'What needs attention right now?' }], + refresh, + }))) === JSON.stringify([PAGE[0].prompt])); + + /* The correction a live run forced: a question the catalogue does not cover + must not fall through to the page's own ranking. "Summarize hiring + activity" matched nothing, nothing was excluded, and the reader got the + three standing highlights under an unrelated answer. */ + record('a question the catalogue does not cover offers nothing, not the page ranking', + (await nextSteps({ + question: 'Summarize hiring activity', history: [], + refresh: async ({ query } = {}) => (query ? [] : PAGE), + })) === undefined); + + record('...and the page ranking is never reached as a fallback', + (await nextSteps({ + question: 'What should I do next?', history: [], + refresh: async ({ query } = {}) => (query ? [PAGE[2]] : PAGE), + })) === undefined); + + record('when the catalogue is spent the row is left empty, not repeated', + (await nextSteps({ + question: 'x', + history: [{ role: 'assistant', followUp: PAGE }], + refresh: async () => PAGE, + })) === undefined); + + record('a response that repeats itself is collapsed', + said(await nextSteps({ + question: 'x', history: [], + refresh: async () => [PAGE[0], PAGE[0], PAGE[1]], + })).length === 2); +} + console.log('\n── Streaming ──'); { const providerMod = await server.ssrLoadModule('/src/components/ai-assistant/provider.js'); @@ -5665,6 +5962,3762 @@ record('the score card says "scored", not "screened"', /1 candidate scored/.test(dist.replace(/\s+/g, ' ')) && !/candidates? screened/.test(dist), dist.replace(/\s+/g, ' ').match(/\d+ candidates? \w+/)?.[0] ?? '(caption not found)'); +let hadWindow = true; + +/* Runs LAST, deliberately. Loading the block renderers pulls in the design + system, which needs a `window` to evaluate — and framer-motion, loaded in the + same graph, decides once at import time whether it is in a browser and caches + the answer. Put this section earlier and the shim below makes every motion + component rendered after it throw. */ +/** + * Citation markup never reaches a reader — asserted on the rendered DOM. + * + * ROOT CAUSE, so the tests below read as something rather than as a list: + * `knowledge/context.go` instructs the model "Each carries an id: cite + * it when you use what it says", and `knowledge_search` repeats it — without + * ever saying in WHAT FORM. The ids are `knowledge_chunks.id`, which are UUIDs. + * So the model invents a citation syntax, differs on it between runs, and the + * panel — which has no citation surface — renders whatever it invented. + * + * Everything here is asserted by SHAPE. Nothing matches a particular id. + */ +console.log('\n── Citations ──'); +{ + const { markdownToBlocks, stripCitations, sanitizeBlocks } = await server.ssrLoadModule('/src/components/ai-assistant/provider.js'); + + /* The block renderers reach `@/components/ds`, which reads `window` when the + module is evaluated. That is a pre-existing SSR limitation of the design + system and not what is under test here — the alternative to this shim is + asserting on block objects instead of on markup, which is exactly the + weaker test this section exists to avoid. */ + hadWindow = 'window' in globalThis; + if (!hadWindow) { + globalThis.window = { + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + addEventListener() {}, removeEventListener() {}, + }; + } + + const { ResponseDocument } = await server.ssrLoadModule('/src/components/ai-assistant/ResponseBlocks.jsx'); + + /* Anything that would be an internal reference artefact on screen. Applied to + rendered HTML, not to intermediate values. */ + const ARTEFACT = new RegExp([ + ' import('react-router-dom')); + + /** The REAL render path: the block renderers the panel mounts. */ + const dom = (blocks) => renderToStaticMarkup( + React.createElement(MemoryRouter, null, + React.createElement(ResponseDocument, { blocks: sanitizeBlocks(blocks) })) + ); + + /* A. – E. Every format seen, through the parser and into the DOM. */ + const answer = [ + '', + 'The screening backlog has 10 applications waiting.', + '', + '', + '## Next steps', + '', + '- Review the backlog today ([337042b3](#))', + '- Coverage is 97% across 113 shifts', + '', + '| Stage | Count |', + '|---|---|', + '| Applied ([2b94bc43](#)) | 4 |', + ].join('\n'); + + const html = dom(markdownToBlocks(answer)); + record('the rendered DOM carries no citation artefact', !ARTEFACT.test(html), + (html.match(ARTEFACT) || ['clean'])[0]); + record('...while the sentences survive intact', + html.includes('The screening backlog has 10 applications waiting.') + && html.includes('Next steps') && html.includes('Review the backlog today')); + record('...and the business numbers in them are untouched', + html.includes('10 applications') && html.includes('97%') && html.includes('113 shifts') + && html.includes('Applied') && html.includes('4')); + + /** + * The acceptance criterion, on the rendered DOM, both ways round. + * + * The same UUID has to vanish in one sentence and survive in the other, which + * is the only test that proves the rule is about the wrapper rather than + * about the id. A sanitiser that passed the first half alone could simply be + * deleting every UUID it sees — and would take a worker's record id off their + * own profile. + */ + { + const cited = dom(markdownToBlocks( + `According to the staffing policy (id \`${'f34e8ef0-9a01-5921-9baf-641f168e2f05'}\`) workers must complete the required check.` + )); + record('the reported UUID is absent from the DOM when it is a citation', + !cited.includes('f34e8ef0-9a01-5921-9baf-641f168e2f05') && !ARTEFACT.test(cited), + cited.replace(/<[^>]+>/g, '').trim()); + record('...and the sentence around it is intact and unbroken', + cited.includes('According to the staffing policy workers must complete the required check.')); + + const business = dom(markdownToBlocks( + `Worker ID: ${'f34e8ef0-9a01-5921-9baf-641f168e2f05'} — 10 applications, 97% coverage.` + )); + record('...while the SAME UUID stays in the DOM when it is worker data', + business.includes('f34e8ef0-9a01-5921-9baf-641f168e2f05'), + business.replace(/<[^>]+>/g, '').trim()); + } + + /** + * A real link renders as a link; a citation-shaped one never gets the chance. + * + * The two live at different layers on purpose. `provider.js` decides what is + * an internal reference, by the wrapper's shape, before a block exists. + * `ResponseBlocks` decides how a surviving link looks. Neither knows about + * the other, which is why fixing one has never had to touch the other. + */ + { + const linked = dom(markdownToBlocks( + 'See [staffing policy](#staffing) and [Positions](/admin/positions), or [the docs](https://example.test/x).' + )); + record('an anchor link renders as an anchor', + linked.includes('href="#staffing"') && linked.includes('>staffing policy<'), linked.includes('href="#staffing"')); + record('an in-app link renders through the router', + linked.includes('href="/admin/positions"') && linked.includes('>Positions<')); + record('an external link opens away from the app', + linked.includes('href="https://example.test/x"') && linked.includes('rel="noreferrer noopener"')); + record('...and no link markup is left as text', + !/\[staffing policy\]|\(#staffing\)|\[Positions\]/.test(linked)); + + /* The security half. Model-written text is untrusted (I7): a link it can + write must not be a link it can execute. */ + for (const href of ['javascript:alert(1)', 'data:text/html,