diff --git a/README.md b/README.md index 95091a3..522b1e4 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config | [`graph-store-sqlite`](packages/adapters/graph/store-sqlite/README.md) | The store port on SQLite through Drizzle: one table definition, committed drizzle-kit migrations applied the same way in every runtime, recursive SQL for the structural queries, against Drizzle's async SQLite database so any driver plugs in. | | [`graph-sqlite-node`](packages/adapters/graph/sqlite-node/README.md) | The SQLite store on Node's built-in `node:sqlite` through `drizzle-orm/node-sqlite`, for the command line and servers. | | [`graph-sqlite-wasm`](packages/adapters/graph/sqlite-wasm/README.md) | The SQLite store in the browser: SQLite wasm in a dedicated worker on OPFS `opfs-sahpool`, shared between tabs under a Web Lock with no special headers, served to the page over oRPC. | +| [`graph-react`](packages/ui/graph/react/README.md) | Headless React hooks and unstyled components: follow a stored model, derive statuses, the ready set, blocked chains, impact and explanations, project views, filter, select and navigate by keyboard. | +| [`graph-ui`](packages/ui/graph/ui/README.md) | Styled components over `graph-react` and an SVG sequence diagram, in Vanilla Extract with a typed theme contract and light and dark themes, shipped as compiled CSS. | +| [`graph-react-flow`](packages/ui/graph/react-flow/README.md) | React Flow views of the model: flowchart, swimlane with lanes as group nodes, and state machine, laid out by `graph-layout`, showing each status and the critical path; sequence projections drawn by `graph-ui`. | | [`process-graph`](packages/targets/process-graph/README.md) | The primary entry point: re-exports the schema, projection and domain APIs flat and the DAG API as the `dag` namespace. | ## Conventions diff --git a/eslint.shared.ts b/eslint.shared.ts index 8448de4..2b4e062 100644 --- a/eslint.shared.ts +++ b/eslint.shared.ts @@ -84,7 +84,11 @@ const dataFileLintConfig = defineConfig( files: ["**/package.json"], plugins: { depend, exadev }, rules: { - "depend/ban-dependencies": ["error", { allowed: ["lint-staged"] }], + // eslint-plugin-react is the plugin @exadev/eslint-config's React block is built on, so the React packages' lint needs it whatever replacement the ban list suggests. + "depend/ban-dependencies": [ + "error", + { allowed: ["lint-staged", "eslint-plugin-react"] }, + ], // A script turbo caches must not pass a fixing flag: turbo hashes a task's inputs before it runs, so a cached check that rewrites them passes in CI having fixed files only inside the runner, and a later cache hit replays a log describing a fix that was never applied. Checking stays in the cached `_lint`; fixing is the uncached `_lint:fix`. "exadev/no-fix-in-cached-task-script": "error", }, @@ -159,6 +163,11 @@ export interface PackageLintOptions { /** `@exadev/eslint-config`'s own `exadev/barrel-policy` rule defaults to `"banned"` in its recommended type-checked preset. Every published package here exposes exactly one barrel at `src/index.ts`, so this defaults to `"single"` instead. It explicitly overrides the base preset's own default, rather than merely relying on it. */ readonly barrelPolicy?: "banned" | "single" | "siblings" | "off"; + + /** + * Set for a package that renders React components: `.tsx` files get `@exadev/eslint-config`'s React block (eslint-plugin-react with the automatic JSX runtime, the hooks rules and the jsx-a11y rules). `version` is the React version the rules assume; pass React's own `version` export, so it follows the package's dependency. eslint-plugin-react's `"detect"` cannot be used, because it reads the file name through a context method ESLint 10 removed. + */ + readonly react?: { readonly version: string }; } export function packageLintConfig(options: PackageLintOptions): Config[] { @@ -173,11 +182,14 @@ export function packageLintConfig(options: PackageLintOptions): Config[] { isomorphicExemptions = [], additionalRestrictedSyntax = [], barrelPolicy = "single", + react, } = options; const typeScriptFiles = ["**/*.ts", "**/*.tsx"]; + const runtimeSrcFiles = ["src/**/*.ts", "src/**/*.tsx"]; const runtimeSrcExemptions = [ "src/**/*.test.ts", + "src/**/*.test.tsx", "src/test-support/**", ...isomorphicExemptions, ]; @@ -248,7 +260,7 @@ export function packageLintConfig(options: PackageLintOptions): Config[] { }, isomorphicImportPatterns.length > 0 ? { - files: ["src/**/*.ts"], + files: runtimeSrcFiles, ignores: [...runtimeSrcExemptions], rules: { "no-restricted-imports": [ @@ -260,7 +272,7 @@ export function packageLintConfig(options: PackageLintOptions): Config[] { : [], isomorphic ? { - files: ["src/**/*.ts"], + files: runtimeSrcFiles, ignores: [...runtimeSrcExemptions], rules: { "no-restricted-globals": [ @@ -272,13 +284,20 @@ export function packageLintConfig(options: PackageLintOptions): Config[] { : [], additionalRestrictedSyntax.length > 0 ? { - files: ["src/**/*.ts"], + files: runtimeSrcFiles, ignores: [...runtimeSrcExemptions], rules: { "no-restricted-syntax": ["error", ...additionalRestrictedSyntax], }, } : [], + react === undefined + ? [] + : [ + // Nested rather than spread: at runtime this is an array of config blocks, which defineConfig flattens. + exadev.configs.react, + { settings: { react: { version: react.version } } }, + ], dataFileLintConfig, // Last, deliberately: bundles eslint-config-prettier, turning off every stylistic rule that would otherwise fight the formatter. Placed earlier, a later config could re-enable one and the two would disagree forever, each "fixing" the other's output. prettierRecommended, diff --git a/knip.config.ts b/knip.config.ts index f97958e..e5c9aaf 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -24,6 +24,10 @@ const config: KnipConfig = { entry: ["src/index.{ts,tsx}"], project: ["src/**/*.{ts,tsx}!"], }, + "packages/ui/*/*": { + entry: ["src/index.{ts,tsx}"], + project: ["src/**/*.{ts,tsx}!"], + }, "packages/targets/*": { entry: ["src/index.{ts,tsx}"], project: ["src/**/*.{ts,tsx}!"], diff --git a/package.json b/package.json index 1976e0a..d72b9f9 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,10 @@ "eslint": "10.12.0", "eslint-config-prettier": "10.1.8", "eslint-plugin-depend": "1.5.0", + "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-prettier": "5.5.6", + "eslint-plugin-react": "7.37.5", + "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-yml": "3.8.1", "globals": "17.13.0", "husky": "9.1.7", diff --git a/packages/ui/graph/react-flow/AGENTS.md b/packages/ui/graph/react-flow/AGENTS.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/ui/graph/react-flow/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/ui/graph/react-flow/CLAUDE.md b/packages/ui/graph/react-flow/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/ui/graph/react-flow/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/ui/graph/react-flow/README.md b/packages/ui/graph/react-flow/README.md new file mode 100644 index 0000000..16246ea --- /dev/null +++ b/packages/ui/graph/react-flow/README.md @@ -0,0 +1,63 @@ +# @exadev/process-graph-react-flow + +[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/process-graph) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/process-graph/ci.yml?branch=main)](https://github.com/ExaDev/process-graph/actions) + +[React Flow](https://reactflow.dev) views of the process model: a flowchart, a swimlane with each lane a group node, and a state machine, laid out by `@exadev/process-graph-layout`, drawn with `@exadev/process-graph-ui`'s styled cards, and reasoned over with `@exadev/process-graph-react`'s hooks. Each element shows its status (ready, blocked, done, skipped or unknown) and what blocks it, and the critical path is highlighted on its cards and its steps. A sequence projection is drawn by graph-ui's SVG `SequenceDiagram`, since React Flow has no lifeline layout. A private workspace package, published as part of `process-graph`. `src/` imports nothing Node-only. + +```tsx +import "@exadev/process-graph-ui/style.css"; +import "@exadev/process-graph-react-flow/style.css"; +import { GraphTheme } from "@exadev/process-graph-ui"; +import { ProcessView } from "@exadev/process-graph-react-flow"; +import { noFacts } from "@exadev/process-graph-react"; + +const run = { model, overlay, facts: noFacts }; +const lanes = { id: "lanes", notation: "swimlane" } as const; + + +
+ +
+
; +``` + +## Components + +| Export | What it is | +| ------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `ProcessView` | Any projection: a sequence projection as graph-ui's `SequenceDiagram`, every other as a `ProcessFlow`. | +| `ProcessFlow` | A flowchart, swimlane or state machine on a React Flow canvas. | +| `toFlow` | The pure conversion of a projected, laid-out view and a run into React Flow nodes and edges. | +| `useLayout` | Lays out a view with `layout`, aborting a layout that is no longer wanted. | +| `nodeTypes` | The node components: `element` (graph-ui's element card with its handles) and `lane` (graph-ui's lane filling its group). | +| `edgeTypes` | The edge component: `transition`, a stepped line with an arrowhead and its label. | + +`ProcessFlow` takes the run (`{ model, overlay, facts }`), a flowchart, swimlane or state machine projection, and an accessible `label`. Optionally: `layout`, the options passed to `graph-layout` (direction, spacing and the engine); `filter`, an `ElementFilter` that hides the elements it leaves out and every edge with a hidden end; `colorMode`, React Flow's own; and `onSelect`, called with each element a click or key selects. Keep the run's parts, the projection and the layout options stable between renders: a change lays the view out again. + +## Layout + +Positions and sizes come from `layout(view, options)`. The engine is injectable, so a test, or an application that lays out off the main thread, passes its own `LayoutEngine` in `layout.engine`; it defaults to elkjs. Each element node gets the size the layout gave it and its handles up front, on the sides the layout direction implies, so React Flow routes edges without measuring the nodes first. In a swimlane each lane is a group node at its laid-out bounds, holding its participant's elements at positions relative to it; elements with no actor, such as the start and end, sit outside every lane. Nothing is drawn until the layout resolves, and a layout that fails throws to the nearest error boundary. + +## What is drawn + +- An element node is graph-ui's `ElementCard`: its label, its status as a badge and its colour on the card's leading edge, a critical path marker, and "Blocked by" with the open work at the end of its blocked chain. The card carries `data-element-id`, `data-kind`, `data-status` and `data-critical`. +- A transition edge carries `data-transitions` with the model transitions it stands for. In a swimlane a hand-off is drawn in the hand-off colour and carries `data-handoff`; an edge between consecutive elements of the critical path is drawn thicker in the critical colour and carries `data-critical`. A state machine transition is labelled `event [guard] / effect`. +- Selection: a click selects an element and a click on the canvas clears it; once the canvas has focus, the arrow keys, Home, End and Escape move the selection through the shown elements in model order and focus the selected node, as `useGraphSelection` does. + +## Styles + +React Flow's stylesheet is imported once, by the build, and bundled with this package's own rules into `dist/style.css`: import it beside graph-ui's stylesheet, and put the view inside a graph-ui theme. Every colour is read from graph-ui's theme contract, so a theme assigned to the contract restyles the canvas too. React Flow needs its container to have a size. + +## Commands + +```sh +pnpm build # vite build (JavaScript and CSS) and tsc -b (declarations) -> dist/ +pnpm test # unit tests in happy-dom, laid out by a stub engine +pnpm test:smoke # the built dist/ rendered as a real artefact +pnpm test:mutation # Stryker +``` diff --git a/packages/ui/graph/react-flow/eslint.config.ts b/packages/ui/graph/react-flow/eslint.config.ts new file mode 100644 index 0000000..5b6a264 --- /dev/null +++ b/packages/ui/graph/react-flow/eslint.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "eslint/config"; +import { version } from "react"; +import { packageLintConfig } from "../../../../eslint.shared.ts"; + +/** + * A browser React library: runtime `src/` imports nothing Node-only, browser globals are in scope, and `.tsx` files get the React, hooks and accessibility rules for the React this package installs. + */ +export default defineConfig( + packageLintConfig({ + tsconfigRootDir: import.meta.dirname, + isomorphic: true, + browserGlobals: true, + react: { version }, + }), +); diff --git a/packages/ui/graph/react-flow/package.json b/packages/ui/graph/react-flow/package.json new file mode 100644 index 0000000..f7c59f0 --- /dev/null +++ b/packages/ui/graph/react-flow/package.json @@ -0,0 +1,92 @@ +{ + "name": "@exadev/process-graph-react-flow", + "version": "0.0.0", + "private": true, + "description": "React Flow views of the process model: a flowchart, a swimlane with lanes as group nodes and a state machine, laid out by graph-layout and styled by graph-ui, showing each element's status and the critical path; a sequence projection is drawn by graph-ui's SVG sequence diagram.", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/process-graph.git", + "directory": "packages/ui/graph/react-flow" + }, + "homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/ui/graph/react-flow#readme", + "bugs": { + "url": "https://github.com/ExaDev/process-graph/issues" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./style.css": "./dist/style.css" + }, + "types": "./dist/index.d.ts", + "files": [ + "dist", + "!dist/*.tsbuildinfo" + ], + "sideEffects": [ + "*.css" + ], + "license": "MIT", + "scripts": { + "build": "turbo run _build", + "_build": "vite build && tsc -b tsconfig.build.json", + "lint": "turbo run _lint", + "_lint": "eslint . --cache --max-warnings 0", + "_lint:fix": "eslint . --fix --cache --max-warnings 0", + "typecheck": "turbo run _typecheck _typecheck:attw", + "_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit", + "_typecheck:attw": "attw --pack --profile esm-only --exclude-entrypoints ./style.css", + "test": "turbo run _test", + "_test": "vitest run --project unit", + "test:coverage": "turbo run _test:coverage", + "_test:coverage": "vitest run --project unit --coverage", + "test:smoke": "turbo run _test:smoke", + "_test:smoke": "vitest run --project smoke", + "test:mutation": "turbo run _test:mutation", + "_test:mutation": "stryker run stryker.config.ts" + }, + "keywords": [ + "process-model", + "react", + "react-flow", + "flowchart", + "swimlane", + "state-machine" + ], + "dependencies": { + "@exadev/process-graph-domain": "1.1.1", + "@exadev/process-graph-layout": "1.0.3", + "@exadev/process-graph-projection": "1.1.0", + "@exadev/process-graph-react": "0.0.0", + "@exadev/process-graph-schema": "1.1.0", + "@exadev/process-graph-ui": "0.0.0", + "@xyflow/react": "12.12.0" + }, + "devDependencies": { + "@arethetypeswrong/cli": "0.18.5", + "@stryker-mutator/core": "10.0.0", + "@stryker-mutator/typescript-checker": "10.0.0", + "@stryker-mutator/vitest-runner": "10.0.0", + "@testing-library/dom": "10.4.2", + "@testing-library/react": "16.3.3", + "@types/node": "26.6.4", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vanilla-extract/css": "1.21.2", + "@vanilla-extract/vite-plugin": "5.2.6", + "@vitest/coverage-v8": "5.0.3", + "eslint": "10.12.0", + "happy-dom": "20.14.5", + "react": "19.3.0", + "react-dom": "19.3.0", + "typescript": "6.0.3", + "vite": "8.3.2", + "vitest": "5.0.3" + }, + "peerDependencies": { + "react": "19.3.0", + "react-dom": "19.3.0" + } +} diff --git a/packages/ui/graph/react-flow/src/edges.tsx b/packages/ui/graph/react-flow/src/edges.tsx new file mode 100644 index 0000000..165ef26 --- /dev/null +++ b/packages/ui/graph/react-flow/src/edges.tsx @@ -0,0 +1,61 @@ +import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react"; +import type { ReactElement } from "react"; +import type { TransitionEdge } from "./flow.ts"; +import { criticalEdge, handoffEdge, transitionEdge } from "./flow.css.ts"; + +/** + * A transition: a stepped line with an arrowhead and its label, coloured as a hand-off or a step of the critical path. Its group carries `data-transitions` with the model transitions it stands for, and `data-handoff` and `data-critical` as they apply. + * @param props - The edge React Flow renders, with the transition's data. + * @returns The edge's SVG. + */ +export const TransitionEdgeView = ( + props: EdgeProps, +): ReactElement => { + const { + id, + data, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerEnd, + } = props; + const [path, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + const classes = [ + transitionEdge, + data?.handoff === true ? handoffEdge : undefined, + data?.critical === true ? criticalEdge : undefined, + ].filter((name) => name !== undefined); + + return ( + + + + ); +}; + +/** The edge components, by edge type. Defined once, outside any component, as React Flow requires. */ +export const edgeTypes = { + transition: TransitionEdgeView, +}; diff --git a/packages/ui/graph/react-flow/src/flow.css.ts b/packages/ui/graph/react-flow/src/flow.css.ts new file mode 100644 index 0000000..f528ad3 --- /dev/null +++ b/packages/ui/graph/react-flow/src/flow.css.ts @@ -0,0 +1,50 @@ +import { vars } from "@exadev/process-graph-ui"; +import { globalStyle, style } from "@vanilla-extract/css"; + +/** An element node: its card fills the box the layout gave it. */ +export const elementNode = style({ display: "flex" }); + +globalStyle(`${elementNode} > [data-element-id]`, { + flex: 1, + minWidth: 0, + overflow: "hidden", +}); + +/** A lane node: the lane fills the box the layout gave it, behind the elements it holds. */ +export const laneNode = style({ display: "flex" }); + +globalStyle(`${laneNode} > [data-lane]`, { + flex: 1, + backgroundColor: vars.colour.background, +}); + +/** A connection handle: present for React Flow's routing, not drawn. */ +export const handle = style({ opacity: 0 }); + +/** A transition's line, and its label in muted text. */ +export const transitionEdge = style({}); + +globalStyle(`${transitionEdge} .react-flow__edge-path`, { + stroke: vars.colour.muted, + strokeWidth: vars.stroke.thin, +}); + +globalStyle(`${transitionEdge} .react-flow__edge-text`, { + fill: vars.colour.muted, + fontSize: vars.font.sizeSmall, +}); + +/** A hand-off between actors, in the hand-off colour. */ +export const handoffEdge = style({}); + +globalStyle(`${handoffEdge} .react-flow__edge-path`, { + stroke: vars.colour.handoff, +}); + +/** A step of the critical path: drawn thicker, in the critical colour. */ +export const criticalEdge = style({}); + +globalStyle(`${criticalEdge} .react-flow__edge-path`, { + stroke: vars.colour.critical, + strokeWidth: vars.stroke.thick, +}); diff --git a/packages/ui/graph/react-flow/src/flow.ts b/packages/ui/graph/react-flow/src/flow.ts new file mode 100644 index 0000000..fe3e4ee --- /dev/null +++ b/packages/ui/graph/react-flow/src/flow.ts @@ -0,0 +1,291 @@ +import type { Status } from "@exadev/process-graph-domain"; +import type { + Direction, + LaneBounds, + LayoutResult, + Size, +} from "@exadev/process-graph-layout"; +import type { + Flowchart, + StateMachine, + StateMachineTransition, + Swimlane, +} from "@exadev/process-graph-projection"; +import type { NamedElement } from "@exadev/process-graph-react"; +import { elementLabel } from "@exadev/process-graph-react"; +import type { + Element, + IdSpace, + Model, + Point, +} from "@exadev/process-graph-schema"; +import { + MarkerType, + Position, + type Edge, + type Node, + type NodeHandle, +} from "@xyflow/react"; + +/** A view React Flow draws: a flowchart, a swimlane or a state machine, with its notation. */ +export type FlowView = + | { readonly notation: "flowchart"; readonly view: Flowchart } + | { readonly notation: "swimlane"; readonly view: Swimlane } + | { readonly notation: "stateMachine"; readonly view: StateMachine }; + +/** What an element node shows. Node and edge data are `Readonly` mapped types rather than interfaces, because React Flow requires data assignable to `Record`, which an interface is not. */ +export type ElementNodeData = Readonly<{ + element: Element; + status: Status; + critical: boolean; + /** The open work it waits on; absent when it is not blocked. */ + blockedBy?: readonly NamedElement[]; +}>; + +/** An element of the view, drawn as a styled element card. */ +export type ElementNode = Node< + ElementNodeData, + "element" +>; + +/** What a lane node shows. */ +export type LaneNodeData = Readonly<{ + participant: string; + label: string; +}>; + +/** A swimlane, drawn as a group node that holds the nodes of its participant's elements. */ +export type LaneNode = Node; + +/** A node of a process view. */ +export type ProcessNode = ElementNode | LaneNode; + +/** What a transition edge shows. */ +export type TransitionEdgeData = Readonly<{ + /** The model transitions the edge stands for. */ + transitions: readonly string[]; + label?: string; + /** Whether it passes work between actors; only a swimlane marks hand-offs. */ + handoff: boolean; + /** Whether it joins two consecutive elements of the critical path. */ + critical: boolean; +}>; + +/** A transition of the view, drawn as a labelled edge. */ +export type TransitionEdge = Edge; + +/** What a view is drawn from besides its projection and its layout: the run's state. */ +export interface FlowRun { + readonly model: Model; + readonly statuses: ReadonlyMap; + /** The critical path, in dependency order. */ + readonly criticalPath: readonly I["element"][]; + /** Each blocked element's blockers, from `useBlockers`. */ + readonly blockers: ReadonlyMap; + /** The elements to show; the others are hidden, and so is every edge with a hidden end. */ + readonly shown: ReadonlySet; + /** The direction the view was laid out in, which decides the side each edge leaves and enters a node. */ + readonly direction: Direction; +} + +/** The side an edge enters a node, and the side it leaves, for each layout direction. */ +const sides: Readonly< + Record +> = { + right: { target: Position.Left, source: Position.Right }, + down: { target: Position.Top, source: Position.Bottom }, + left: { target: Position.Right, source: Position.Left }, + up: { target: Position.Bottom, source: Position.Top }, +}; + +/** The middle of each side of a box, relative to the box. */ +const anchors: Readonly Point>> = { + [Position.Left]: (size) => ({ x: 0, y: size.height / 2 }), + [Position.Right]: (size) => ({ x: size.width, y: size.height / 2 }), + [Position.Top]: (size) => ({ x: size.width / 2, y: 0 }), + [Position.Bottom]: (size) => ({ x: size.width / 2, y: size.height }), +}; + +/** A node's handles, given up front so React Flow can route edges without measuring the node first. */ +const handlesOf = (direction: Direction, size: Size): NodeHandle[] => { + const { target, source } = sides[direction]; + + return [ + { type: "target", position: target, ...anchors[target](size) }, + { type: "source", position: source, ...anchors[source](size) }, + ]; +}; + +const laneId = (participant: string): string => `lane:${participant}`; + +const required = (value: V | undefined, what: string): V => { + if (value === undefined) { + throw new Error(`The layout has no ${what}`); + } + + return value; +}; + +/** The label of a state machine transition in the notation's form: `event [guard] / effect`. */ +const transitionLabel = ( + model: Model, + transition: StateMachineTransition, +): string | undefined => { + const event = model.events.find( + (candidate) => candidate.id === transition.event, + ); + const effect = model.actions.find( + (candidate) => candidate.id === transition.effect, + ); + const label = [ + event?.name, + transition.guard === undefined ? undefined : `[${transition.guard}]`, + effect === undefined ? undefined : `/ ${effect.name}`, + ] + .filter((part) => part !== undefined) + .join(" "); + + return label === "" ? undefined : label; +}; + +/** + * The React Flow nodes and edges of a laid-out view: a node per element at its laid-out position and size, a group node per swimlane lane holding its participant's elements, and an edge per drawn edge or transition. Each element node carries its status, whether it is on the critical path and what it is blocked by; each edge whether it is a hand-off and whether it is on the critical path. Group nodes come before the nodes they hold, as React Flow requires. + * @param flow - The projected view. + * @param placement - The view's layout. + * @param run - The run's state and the elements to show. + * @returns The nodes and edges. + * @throws Error when the layout lacks a position, size or lane the view needs. + */ +export const toFlow = ( + flow: FlowView, + placement: LayoutResult, + run: FlowRun, +): { + readonly nodes: ProcessNode[]; + readonly edges: TransitionEdge[]; +} => { + const { model, statuses, criticalPath, blockers, shown, direction } = run; + const positions = required(placement.views[flow.notation], "positions"); + const elements = new Map( + model.elements.map((element) => [element.id, element]), + ); + const step = new Map(criticalPath.map((id, index) => [id, index])); + const sideOf = sides[direction]; + + const laneOf = new Map>(); + const lanes: LaneNode[] = []; + if (flow.notation === "swimlane") { + flow.view.lanes.forEach((lane, index) => { + const bounds = required( + placement.lanes[index], + `lane for ${lane.participant}`, + ); + for (const node of lane.nodes) { + laneOf.set(node, bounds); + } + lanes.push({ + id: laneId(lane.participant), + type: "lane", + position: { x: bounds.x, y: bounds.y }, + width: bounds.width, + height: bounds.height, + selectable: false, + focusable: false, + draggable: false, + data: { participant: lane.participant, label: lane.label }, + }); + }); + } + + const ids = + flow.notation === "stateMachine" + ? flow.view.states.map((state) => state.id) + : flow.view.nodes.map((node) => node.id); + const labelOf = (id: I["element"]): string => + elementLabel(required(elements.get(id), `element ${id} in the model`)); + const elementNodes = ids.map((id): ElementNode => { + const element = required(elements.get(id), `element ${id} in the model`); + const status = required(statuses.get(id), `status for ${id}`); + const size = required(placement.sizes[id], `size for ${id}`); + const at = required(positions[id], `position for ${id}`); + const lane = laneOf.get(id); + const blockedBy = blockers.get(id); + + return { + id, + type: "element", + position: + lane === undefined ? at : { x: at.x - lane.x, y: at.y - lane.y }, + ...(lane === undefined + ? {} + : { parentId: laneId(lane.participant), extent: "parent" as const }), + width: size.width, + height: size.height, + handles: handlesOf(direction, size), + sourcePosition: sideOf.source, + targetPosition: sideOf.target, + hidden: !shown.has(id), + draggable: false, + ariaLabel: `${elementLabel(element)}, ${status}`, + data: { + element, + status, + critical: step.has(id), + ...(blockedBy === undefined + ? {} + : { + blockedBy: blockedBy.map((blocker) => ({ + id: blocker, + label: labelOf(blocker), + })), + }), + }, + }; + }); + + const handoffs = new Set( + flow.notation === "swimlane" + ? flow.view.handoffs.map((handoff) => handoff.transition) + : [], + ); + const onCriticalPath = (from: I["element"], to: I["element"]): boolean => { + const at = step.get(from); + + return at !== undefined && step.get(to) === at + 1; + }; + const drawn = + flow.notation === "stateMachine" + ? flow.view.transitions.map((transition) => ({ + from: transition.source, + to: transition.target, + transitions: transition.transitions, + label: transitionLabel(model, transition), + })) + : flow.view.edges.map((edge) => ({ + from: edge.from, + to: edge.to, + transitions: edge.transitions, + label: edge.label, + })); + const edges = drawn.map((edge): TransitionEdge => { + const critical = onCriticalPath(edge.from, edge.to); + const handoff = edge.transitions.some((id) => handoffs.has(id)); + + return { + id: `transition:${edge.transitions.join(",")}`, + type: "transition", + source: edge.from, + target: edge.to, + hidden: !shown.has(edge.from) || !shown.has(edge.to), + markerEnd: { type: MarkerType.ArrowClosed }, + data: { + transitions: edge.transitions, + ...(edge.label === undefined ? {} : { label: edge.label }), + handoff, + critical, + }, + }; + }); + + return { nodes: [...lanes, ...elementNodes], edges }; +}; diff --git a/packages/ui/graph/react-flow/src/flow.unit.test.ts b/packages/ui/graph/react-flow/src/flow.unit.test.ts new file mode 100644 index 0000000..8fddc04 --- /dev/null +++ b/packages/ui/graph/react-flow/src/flow.unit.test.ts @@ -0,0 +1,294 @@ +import { + blockedBy, + criticalPath, + deriveStatuses, +} from "@exadev/process-graph-domain"; +import { layout } from "@exadev/process-graph-layout"; +import { + toFlowchart, + toStateMachine, + toSwimlane, +} from "@exadev/process-graph-projection"; +import { noFacts } from "@exadev/process-graph-react"; +import { defineModel, type Overlay } from "@exadev/process-graph-schema"; +import { describe, expect, test } from "vitest"; +import { toFlow, type FlowRun, type FlowView } from "./flow.ts"; +import type { OrderIds } from "./test-support/ids.ts"; +import { order } from "./test-support/order.ts"; +import { stubEngine } from "./test-support/stub-engine.ts"; + +const submitted: Overlay = { "a-submit": "done" }; + +const runOf = (overlay: Overlay): FlowRun => { + const statuses = deriveStatuses(order, overlay, noFacts); + + return { + model: order, + statuses, + criticalPath: criticalPath(order, overlay, noFacts, () => 1).path, + blockers: new Map( + [...statuses] + .filter(([, status]) => status === "blocked") + .map(([id]) => [id, blockedBy(order, overlay, noFacts, id)]), + ), + shown: new Set(order.elements.map((element) => element.id)), + direction: "right", + }; +}; + +const defined = (value: V | undefined, what: string): V => { + if (value === undefined) { + throw new Error(`no ${what}`); + } + + return value; +}; + +const drawn = async (flow: FlowView) => { + const { engine } = stubEngine(); + const placement = await layout(flow.view, { engine }); + + return { placement, ...toFlow(flow, placement, runOf(submitted)) }; +}; + +describe("toFlow on a swimlane", () => { + const swimlane = { notation: "swimlane", view: toSwimlane(order) } as const; + + test("draws each lane as a group node at its laid-out bounds, before the nodes it holds", async () => { + const { nodes, placement } = await drawn(swimlane); + + const lanes = nodes.filter((node) => node.type === "lane"); + expect(lanes.map((lane) => lane.data.label)).toEqual([ + "Customer", + "Shop", + "Payment provider", + ]); + expect( + lanes.map(({ position, width, height }) => ({ + ...position, + width, + height, + })), + ).toEqual( + placement.lanes.map(({ x, y, width, height }) => ({ + x, + y, + width, + height, + })), + ); + expect(nodes.findIndex((node) => node.type === "element")).toBe( + lanes.length, + ); + }); + + test("places each element inside its lane, relative to it", async () => { + const { nodes, placement } = await drawn(swimlane); + + const request = nodes.find((node) => node.id === "a-request"); + const shop = defined(placement.lanes[1], "shop lane"); + const absolute = defined( + placement.views.swimlane?.["a-request"], + "position", + ); + expect(request?.parentId).toBe("lane:shop"); + expect(request?.position).toEqual({ + x: absolute.x - shop.x, + y: absolute.y - shop.y, + }); + expect(nodes.find((node) => node.id === "start")?.parentId).toBeUndefined(); + }); + + test("gives each element node its status, critical path flag and blockers", async () => { + const { nodes } = await drawn(swimlane); + + const data = new Map( + nodes.flatMap((node) => + node.type === "element" ? [[node.id, node.data]] : [], + ), + ); + expect(data.get("a-submit")).toMatchObject({ + status: "done", + critical: false, + }); + expect(data.get("a-request")).toMatchObject({ + status: "ready", + critical: true, + }); + expect(data.get("a-pack")?.blockedBy).toEqual([ + { id: "a-request", label: "Request payment" }, + ]); + expect(data.get("a-request")?.blockedBy).toBeUndefined(); + }); + + test("marks the hand-offs and the steps of the critical path on the edges", async () => { + const { edges } = await drawn(swimlane); + + expect( + edges + .filter((edge) => edge.data?.handoff === true) + .map((edge) => edge.id), + ).toEqual(["transition:t2", "transition:t3", "transition:t4"]); + expect( + edges + .filter((edge) => edge.data?.critical === true) + .map((edge) => edge.id), + ).toEqual([ + "transition:t3", + "transition:t4", + "transition:t5", + "transition:t7", + ]); + expect(edges.find((edge) => edge.id === "transition:t5")?.data?.label).toBe( + "authorised", + ); + }); + + test("gives every element node its size and the handles edges route to", async () => { + const { nodes, placement } = await drawn(swimlane); + + const request = nodes.find((node) => node.id === "a-request"); + const size = defined(placement.sizes["a-request"], "size"); + expect({ width: request?.width, height: request?.height }).toEqual(size); + expect(request?.handles).toEqual([ + { + type: "target", + position: "left", + x: 0, + y: size.height / 2, + }, + { + type: "source", + position: "right", + x: size.width, + y: size.height / 2, + }, + ]); + }); +}); + +describe("toFlow on a flowchart", () => { + test("draws no lanes and marks no hand-offs", async () => { + const { nodes, edges } = await drawn({ + notation: "flowchart", + view: toFlowchart(order), + }); + + expect(nodes.every((node) => node.type === "element")).toBe(true); + expect(nodes.every((node) => node.parentId === undefined)).toBe(true); + expect(edges.some((edge) => edge.data?.handoff === true)).toBe(false); + }); + + test("hides the elements a filter leaves out", async () => { + const flow = { notation: "flowchart", view: toFlowchart(order) } as const; + const { engine } = stubEngine(); + const placement = await layout(flow.view, { engine }); + const { nodes } = toFlow(flow, placement, { + ...runOf(submitted), + shown: new Set(["a-request"]), + }); + + expect( + nodes.filter((node) => node.hidden !== true).map((node) => node.id), + ).toEqual(["a-request"]); + }); +}); + +describe("critical steps", () => { + /** Three steps in a chain, and a shortcut from the first to the last that is not a step of the chain. */ + const chain = defineModel({ + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "a", label: "A" }, + { kind: "activity", id: "b", label: "B" }, + { kind: "activity", id: "c", label: "C" }, + ], + transitions: [ + { id: "ab", source: "a", target: "b" }, + { id: "bc", source: "b", target: "c" }, + { id: "ac", source: "a", target: "c" }, + ], + fragments: [], + projections: [], + }); + + test("only an edge between consecutive elements of the critical path is a critical step", async () => { + const flow = { notation: "flowchart", view: toFlowchart(chain) } as const; + const { engine } = stubEngine(); + const placement = await layout(flow.view, { engine }); + const statuses = deriveStatuses(chain, {}, noFacts); + const { edges } = toFlow(flow, placement, { + model: chain, + statuses, + criticalPath: criticalPath(chain, {}, noFacts, () => 1).path, + blockers: new Map(), + shown: new Set(statuses.keys()), + direction: "right", + }); + + expect( + edges + .filter((edge) => edge.data?.critical === true) + .map((edge) => edge.id), + ).toEqual(["transition:ab", "transition:bc"]); + }); +}); + +describe("toFlow on a state machine", () => { + /** A ticket's lifecycle, with an event, a guard and an effect on one transition. */ + const ticket = defineModel({ + participants: [ + { id: "agent", label: "Agent", kind: "role" }, + { id: "ticket", label: "Ticket", kind: "entity" }, + ], + events: [{ id: "ev-resolve", name: "resolve" }], + actions: [{ id: "act-notify", name: "notify" }], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { kind: "state", id: "open", label: "Open" }, + { kind: "state", id: "closed", label: "Closed" }, + ], + transitions: [ + { id: "t1", source: "start", target: "open" }, + { + id: "t2", + source: "open", + target: "closed", + trigger: "ev-resolve", + guard: "fixed", + effect: "act-notify", + }, + ], + fragments: [], + projections: [], + }); + + test("labels a transition with its event, guard and effect", async () => { + const flow = { + notation: "stateMachine", + view: toStateMachine(ticket, "ticket"), + } as const; + const { engine } = stubEngine(); + const placement = await layout(flow.view, { engine }); + const statuses = deriveStatuses(ticket, {}, noFacts); + const { nodes, edges } = toFlow(flow, placement, { + model: ticket, + statuses, + criticalPath: [], + blockers: new Map(), + shown: new Set(statuses.keys()), + direction: "down", + }); + + expect(nodes.map((node) => node.id)).toEqual(["start", "open", "closed"]); + expect(edges.map((edge) => edge.data?.label)).toEqual([ + undefined, + "resolve [fixed] / notify", + ]); + expect(nodes[1]?.targetPosition).toBe("top"); + }); +}); diff --git a/packages/ui/graph/react-flow/src/index.ts b/packages/ui/graph/react-flow/src/index.ts new file mode 100644 index 0000000..f1988e3 --- /dev/null +++ b/packages/ui/graph/react-flow/src/index.ts @@ -0,0 +1,29 @@ +/** + * React Flow views of the process model, composed from `@exadev/process-graph-react` and `@exadev/process-graph-ui` and laid out by `@exadev/process-graph-layout`. React Flow's stylesheet is imported once, by the build, and bundled with this package's own styles into `style.css`, which a consumer imports beside graph-ui's (`@exadev/process-graph-react-flow/style.css`). + */ + +export { + ProcessFlow, + type FlowProjection, + type ProcessFlowProps, +} from "./process-flow.tsx"; +export { ProcessView, type ProcessViewProps } from "./process-view.tsx"; +export { + toFlow, + type ElementNode, + type ElementNodeData, + type FlowRun, + type FlowView, + type LaneNode, + type LaneNodeData, + type ProcessNode, + type TransitionEdge, + type TransitionEdgeData, +} from "./flow.ts"; +export { + useLayout, + type FlowLayoutOptions, + type LayoutState, +} from "./use-layout.ts"; +export { ElementNodeView, LaneNodeView, nodeTypes } from "./nodes.tsx"; +export { TransitionEdgeView, edgeTypes } from "./edges.tsx"; diff --git a/packages/ui/graph/react-flow/src/nodes.tsx b/packages/ui/graph/react-flow/src/nodes.tsx new file mode 100644 index 0000000..cf4f64c --- /dev/null +++ b/packages/ui/graph/react-flow/src/nodes.tsx @@ -0,0 +1,65 @@ +import type { IdSpace } from "@exadev/process-graph-schema"; +import { ElementCard, Lane } from "@exadev/process-graph-ui"; +import { Handle, type NodeProps } from "@xyflow/react"; +import type { ReactElement } from "react"; +import type { ElementNode, LaneNode } from "./flow.ts"; +import { elementNode, handle, laneNode } from "./flow.css.ts"; + +/** + * An element of a process view: graph-ui's element card, with the handles React Flow routes edges to. + * @param props - The node React Flow renders, with the element's data. + * @returns The node's content. + */ +export const ElementNodeView = ( + props: NodeProps>, +): ReactElement => { + const { data, sourcePosition, targetPosition, selected } = props; + + return ( +
+ {targetPosition === undefined ? null : ( + + )} + + {sourcePosition === undefined ? null : ( + + )} +
+ ); +}; + +/** + * A swimlane: graph-ui's lane, filling the group node that holds its participant's elements. + * @param props - The node React Flow renders, with the lane's data. + * @returns The node's content. + */ +export const LaneNodeView = (props: NodeProps): ReactElement => { + const { data } = props; + + return ( +
+ +
+ ); +}; + +/** The node components, by node type. Defined once, outside any component, as React Flow requires. */ +export const nodeTypes = { + element: ElementNodeView, + lane: LaneNodeView, +}; diff --git a/packages/ui/graph/react-flow/src/process-flow.tsx b/packages/ui/graph/react-flow/src/process-flow.tsx new file mode 100644 index 0000000..ba6f0ea --- /dev/null +++ b/packages/ui/graph/react-flow/src/process-flow.tsx @@ -0,0 +1,161 @@ +import type { Direction } from "@exadev/process-graph-layout"; +import { + toFlowchart, + toStateMachine, + toSwimlane, +} from "@exadev/process-graph-projection"; +import { + emptyFilter, + filterElements, + useBlockers, + useCriticalPath, + useGraphSelection, + useStatuses, + type ElementFilter, + type Run, +} from "@exadev/process-graph-react"; +import type { IdSpace, Projection } from "@exadev/process-graph-schema"; +import { ReactFlow, type ColorMode } from "@xyflow/react"; +import { useCallback, useMemo, useRef, type ReactElement } from "react"; +import { edgeTypes } from "./edges.tsx"; +import { toFlow, type FlowView } from "./flow.ts"; +import { nodeTypes } from "./nodes.tsx"; +import { useLayout, type FlowLayoutOptions } from "./use-layout.ts"; + +/** A projection React Flow can draw: anything but a sequence diagram, which `SequenceDiagram` draws. */ +export type FlowProjection = Exclude< + Projection, + { readonly notation: "sequence" } +>; + +/** The direction a view is laid out in when its options name none: edges point right, as `graph-layout`'s own default. */ +const defaultDirection: Direction = "right"; + +const projectFlow = ( + run: Run, + projection: FlowProjection, +): FlowView => { + if (projection.notation === "stateMachine") { + return { + notation: "stateMachine", + view: toStateMachine(run.model, projection.subject), + }; + } + + return projection.notation === "swimlane" + ? { notation: "swimlane", view: toSwimlane(run.model) } + : { notation: "flowchart", view: toFlowchart(run.model) }; +}; + +export interface ProcessFlowProps { + /** The model, its overlay and facts. Keep each part stable between renders. */ + readonly run: Run; + /** The view to draw: a flowchart, a swimlane or a state machine. Keep it stable between renders. */ + readonly projection: FlowProjection; + /** The canvas's accessible name. */ + readonly label: string; + /** Direction, spacing and the layout engine, passed to `graph-layout`. The engine defaults to elkjs; a test injects a stub. Keep it stable between renders. */ + readonly layout?: FlowLayoutOptions | undefined; + /** Which elements to show. Defaults to all of them. */ + readonly filter?: ElementFilter | undefined; + /** React Flow's own colour mode, for its controls and background. Pair it with the graph-ui theme around the view. */ + readonly colorMode?: ColorMode | undefined; + /** Called with each element a click or key selects, and with `undefined` when the selection is cleared. */ + readonly onSelect?: ((id: I["element"] | undefined) => void) | undefined; +} + +/** + * A process view on a React Flow canvas: the projection laid out by `graph-layout`, each element as a styled card with its status and what blocks it, lanes as group nodes in a swimlane, and the critical path's elements and steps highlighted. Elements are selected by a click, or with the arrow, Home, End and Escape keys once the canvas has focus; the filter hides elements and the edges that touch them. Nothing is drawn until the layout resolves, and a layout that fails throws to the nearest error boundary. + * @param props - The run, the projection, a label, and optionally the layout, a filter, the colour mode and a selection listener. + * @returns The canvas, which fills its container. + */ +export const ProcessFlow = ( + props: ProcessFlowProps, +): ReactElement => { + const { + run, + projection, + label, + layout, + filter = emptyFilter, + colorMode, + onSelect, + } = props; + const direction = layout?.direction ?? defaultDirection; + const options = useMemo( + () => ({ ...layout, direction }), + [layout, direction], + ); + const flow = useMemo(() => projectFlow(run, projection), [run, projection]); + const placement = useLayout(flow.view, options); + const statuses = useStatuses(run); + const blockers = useBlockers(run); + const { path } = useCriticalPath(run); + const shown = useMemo( + () => filterElements(run.model, statuses, filter), + [run.model, statuses, filter], + ); + const canvas = useRef(null); + const focus = useCallback((id: string) => { + const node = canvas.current?.querySelector( + `.react-flow__node[data-id="${CSS.escape(id)}"]`, + ); + if (node instanceof HTMLElement) { + node.focus(); + } + }, []); + const { selected, select, containerProps } = useGraphSelection(shown, { + onChange: onSelect, + focus, + }); + + const drawn = useMemo(() => { + if (placement.status !== "placed") { + return undefined; + } + + return toFlow(flow, placement.result, { + model: run.model, + statuses, + criticalPath: path, + blockers, + shown: new Set(shown), + direction, + }); + }, [flow, placement, run.model, statuses, path, blockers, shown, direction]); + + if (placement.status === "failed") { + throw new Error(`${label} could not be laid out`, { + cause: placement.error, + }); + } + if (drawn === undefined) { + return

Laying out {label}

; + } + + return ( + + node.id === selected ? { ...node, selected: true } : node, + )} + edges={drawn.edges} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + {...(colorMode === undefined ? {} : { colorMode })} + nodesDraggable={false} + nodesConnectable={false} + fitView + onKeyDown={containerProps.onKeyDown} + onNodeClick={(_event, node) => { + if (node.type === "element") { + select(node.id); + } + }} + onPaneClick={() => { + select(undefined); + }} + /> + ); +}; diff --git a/packages/ui/graph/react-flow/src/process-flow.unit.test.tsx b/packages/ui/graph/react-flow/src/process-flow.unit.test.tsx new file mode 100644 index 0000000..c3780d3 --- /dev/null +++ b/packages/ui/graph/react-flow/src/process-flow.unit.test.tsx @@ -0,0 +1,156 @@ +import { toSwimlane } from "@exadev/process-graph-projection"; +import { noFacts, type Run } from "@exadev/process-graph-react"; +import type { Overlay } from "@exadev/process-graph-schema"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ProcessFlow } from "./process-flow.tsx"; +import type { OrderIds } from "./test-support/ids.ts"; +import { order } from "./test-support/order.ts"; +import { stubEngine } from "./test-support/stub-engine.ts"; + +afterEach(cleanup); + +const submitted: Overlay = { "a-submit": "done" }; +const run: Run = { model: order, overlay: submitted, facts: noFacts }; +const swimlane = { id: "p-lanes", notation: "swimlane" } as const; + +const renderSwimlane = (onSelect?: (id: string | undefined) => void) => { + const { engine } = stubEngine(); + const layout = { engine }; + + return render( +
+ +
, + ); +}; + +const nodeOf = (container: HTMLElement, id: string): HTMLElement => { + const node = container.querySelector(`.react-flow__node[data-id="${id}"]`); + if (!(node instanceof HTMLElement)) { + throw new Error(`no node ${id}`); + } + + return node; +}; + +describe("ProcessFlow", () => { + test("says it is laying out until the layout resolves", async () => { + renderSwimlane(); + + expect(screen.getByRole("status").textContent).toBe( + "Laying out Order process", + ); + await waitFor(() => { + expect(screen.queryByRole("status")).toBeNull(); + }); + }); + + test("draws the lanes and each element's card with its status", async () => { + const { container } = renderSwimlane(); + + await waitFor(() => { + expect(container.querySelectorAll(".react-flow__node")).toHaveLength( + order.elements.length + toSwimlane(order).lanes.length, + ); + }); + const lanes = [...container.querySelectorAll("[data-lane]")]; + expect(lanes.map((lane) => lane.textContent)).toEqual([ + "Customer", + "Shop", + "Payment provider", + ]); + expect( + within(nodeOf(container, "a-submit")).getByText("Done"), + ).toBeTruthy(); + expect( + within(nodeOf(container, "a-request")).getByText("Ready"), + ).toBeTruthy(); + expect( + within(nodeOf(container, "a-pack")).getByText( + "Blocked by Request payment", + ), + ).toBeTruthy(); + }); + + test("marks the critical path on its cards", async () => { + const { container } = renderSwimlane(); + + await waitFor(() => { + expect( + nodeOf(container, "a-request").querySelector("[data-critical]"), + ).not.toBeNull(); + }); + expect( + nodeOf(container, "a-cancel").querySelector("[data-critical]"), + ).toBeNull(); + }); + + test("draws the hand-offs and the critical steps as marked edges with their labels", async () => { + const { container } = renderSwimlane(); + + await waitFor(() => { + expect( + container.querySelectorAll("[data-transitions]").length, + ).toBeGreaterThan(0); + }); + const edges = [...container.querySelectorAll("[data-transitions]")]; + expect( + edges + .filter((edge) => edge.hasAttribute("data-handoff")) + .map((edge) => edge.getAttribute("data-transitions")), + ).toEqual(["t2", "t3", "t4"]); + expect( + edges + .filter((edge) => edge.hasAttribute("data-critical")) + .map((edge) => edge.getAttribute("data-transitions")), + ).toEqual(["t3", "t4", "t5", "t7"]); + const authorised = edges.find( + (edge) => edge.getAttribute("data-transitions") === "t5", + ); + expect(authorised?.textContent).toBe("authorised"); + }); + + test("selects an element on click and tells the listener", async () => { + const onSelect = vi.fn<(id: string | undefined) => void>(); + const { container } = renderSwimlane(onSelect); + await waitFor(() => { + nodeOf(container, "a-pack"); + }); + + fireEvent.click(nodeOf(container, "a-pack")); + + expect(onSelect).toHaveBeenLastCalledWith("a-pack"); + expect(nodeOf(container, "a-pack").classList.contains("selected")).toBe( + true, + ); + }); + + test("moves the selection and focus with the arrow keys", async () => { + const onSelect = vi.fn<(id: string | undefined) => void>(); + const { container } = renderSwimlane(onSelect); + await waitFor(() => { + nodeOf(container, "start"); + }); + const canvas = screen.getByLabelText("Order process"); + + fireEvent.keyDown(canvas, { key: "ArrowDown" }); + fireEvent.keyDown(canvas, { key: "ArrowDown" }); + + expect(onSelect.mock.calls).toEqual([["start"], ["a-submit"]]); + expect(document.activeElement).toBe(nodeOf(container, "a-submit")); + }); +}); diff --git a/packages/ui/graph/react-flow/src/process-view.tsx b/packages/ui/graph/react-flow/src/process-view.tsx new file mode 100644 index 0000000..96a886f --- /dev/null +++ b/packages/ui/graph/react-flow/src/process-view.tsx @@ -0,0 +1,71 @@ +import { toSequence } from "@exadev/process-graph-projection"; +import type { Run } from "@exadev/process-graph-react"; +import type { IdSpace, Projection } from "@exadev/process-graph-schema"; +import { SequenceDiagram } from "@exadev/process-graph-ui"; +import { useMemo, type ReactElement } from "react"; +import { ProcessFlow, type ProcessFlowProps } from "./process-flow.tsx"; + +type SequenceProjection = Extract< + Projection, + { readonly notation: "sequence" } +>; + +interface SequenceViewProps { + readonly run: Run; + readonly projection: SequenceProjection; + readonly label: string; +} + +const SequenceView = ( + props: SequenceViewProps, +): ReactElement => { + const { run, projection, label } = props; + const diagram = useMemo( + () => toSequence(run.model, projection.scenario), + [run.model, projection], + ); + const participants = useMemo( + () => + new Map( + run.model.participants.map((participant) => [ + participant.id, + participant.label, + ]), + ), + [run.model], + ); + + return ( + participants.get(actor) ?? actor} + /> + ); +}; + +export interface ProcessViewProps extends Omit< + ProcessFlowProps, + "projection" +> { + /** Any of the model's projections. */ + readonly projection: Projection; +} + +/** + * Any projection of a model: a sequence projection as graph-ui's SVG sequence diagram, since React Flow has no lifeline layout, and every other as a `ProcessFlow` canvas. + * @param props - As for `ProcessFlow`, with any projection. A sequence diagram uses only the run's model and the label, as its caption. + * @returns The view. + */ +export const ProcessView = ( + props: ProcessViewProps, +): ReactElement => { + const { projection, run, label } = props; + + return projection.notation === "sequence" ? ( + + ) : ( + + ); +}; diff --git a/packages/ui/graph/react-flow/src/process-view.unit.test.tsx b/packages/ui/graph/react-flow/src/process-view.unit.test.tsx new file mode 100644 index 0000000..41bd362 --- /dev/null +++ b/packages/ui/graph/react-flow/src/process-view.unit.test.tsx @@ -0,0 +1,116 @@ +import type { LayoutEngine } from "@exadev/process-graph-layout"; +import { noFacts, type Run } from "@exadev/process-graph-react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, test } from "vitest"; +import { ProcessFlow } from "./process-flow.tsx"; +import { ProcessView } from "./process-view.tsx"; +import type { OrderIds } from "./test-support/ids.ts"; +import { order } from "./test-support/order.ts"; +import { stubEngine } from "./test-support/stub-engine.ts"; + +afterEach(cleanup); + +const run: Run = { model: order, overlay: {}, facts: noFacts }; + +describe("ProcessView", () => { + test("draws a sequence projection as an SVG sequence diagram named by the label", () => { + render( + , + ); + + const figure = screen.getByRole("figure", { name: "Order shipped" }); + expect( + [...figure.querySelectorAll("[data-lifeline]")].map( + (line) => line.textContent, + ), + ).toEqual(["Customer", "Shop", "Payment provider"]); + }); + + test("draws any other projection on the canvas", async () => { + const { engine } = stubEngine(); + const layout = { engine }; + const { container } = render( + , + ); + + await waitFor(() => { + expect(container.querySelectorAll(".react-flow__node")).toHaveLength( + order.elements.length, + ); + }); + }); +}); + +describe("ProcessFlow filtering and failure", () => { + test("hides the elements a filter leaves out, with their edges", async () => { + const { engine } = stubEngine(); + const layout = { engine }; + const filter = { + statuses: new Set(["ready"] as const), + actors: new Set(), + sources: new Set(), + }; + const { container } = render( + , + ); + + await waitFor(() => { + expect( + [...container.querySelectorAll(".react-flow__node")].map((node) => + node.getAttribute("data-id"), + ), + ).toEqual(["a-request"]); + }); + expect(container.querySelectorAll("[data-transitions]")).toHaveLength(0); + }); + + test("throws a layout that fails to the nearest error boundary", async () => { + const failure = new Error("no room"); + const engine: LayoutEngine = { + arrange: async () => Promise.reject(failure), + }; + const layout = { engine }; + const root = createRoot(document.createElement("div")); + + // act rethrows what a render inside it threw, as an error boundary would receive it. + await expect( + act(async () => { + root.render( + , + ); + await new Promise((resolve) => { + setTimeout(resolve); + }); + }), + ).rejects.toMatchObject({ + message: "Order process could not be laid out", + cause: failure, + }); + root.unmount(); + }); +}); diff --git a/packages/ui/graph/react-flow/src/test-support/ids.ts b/packages/ui/graph/react-flow/src/test-support/ids.ts new file mode 100644 index 0000000..5a5d7a8 --- /dev/null +++ b/packages/ui/graph/react-flow/src/test-support/ids.ts @@ -0,0 +1,5 @@ +import type { Model } from "@exadev/process-graph-schema"; +import type { order } from "./order.ts"; + +/** The identifier spaces of the order fixture. */ +export type OrderIds = typeof order extends Model ? I : never; diff --git a/packages/ui/graph/react-flow/src/test-support/order.ts b/packages/ui/graph/react-flow/src/test-support/order.ts new file mode 100644 index 0000000..b5eb9d1 --- /dev/null +++ b/packages/ui/graph/react-flow/src/test-support/order.ts @@ -0,0 +1,101 @@ +import { defineModel } from "@exadev/process-graph-schema"; + +/** The order process from the doc, with every reference checked at compile time. */ +export const order = defineModel({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + { id: "provider", label: "Payment provider", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [ + { id: "ev-submitted", name: "order submitted" }, + { id: "ev-authorised", name: "payment authorised" }, + { id: "ev-declined", name: "payment declined" }, + ], + actions: [ + { id: "act-request", name: "request payment" }, + { id: "act-pack", name: "pack and ship" }, + { id: "act-cancel", name: "cancel order and notify" }, + ], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { + kind: "activity", + id: "a-submit", + label: "Submit order", + actor: "customer", + }, + { + kind: "activity", + id: "a-request", + label: "Request payment", + actor: "shop", + action: "act-request", + }, + { + kind: "activity", + id: "a-authorise", + label: "Authorise or decline", + actor: "provider", + }, + { + kind: "decision", + id: "d-authorised", + label: "Payment authorised?", + actor: "shop", + }, + { + kind: "activity", + id: "a-pack", + label: "Pack and ship", + actor: "shop", + action: "act-pack", + }, + { + kind: "activity", + id: "a-cancel", + label: "Cancel order and notify", + actor: "shop", + action: "act-cancel", + }, + { kind: "pseudo", id: "end", role: "final" }, + ], + transitions: [ + { id: "t1", source: "start", target: "a-submit", trigger: "ev-submitted" }, + { id: "t2", source: "a-submit", target: "a-request" }, + { id: "t3", source: "a-request", target: "a-authorise" }, + { id: "t4", source: "a-authorise", target: "d-authorised" }, + { + id: "t5", + source: "d-authorised", + target: "a-pack", + guard: "authorised", + trigger: "ev-authorised", + }, + { + id: "t6", + source: "d-authorised", + target: "a-cancel", + guard: "declined", + trigger: "ev-declined", + }, + { id: "t7", source: "a-pack", target: "end" }, + { id: "t8", source: "a-cancel", target: "end" }, + ], + fragments: [], + projections: [ + { id: "p-flow", notation: "flowchart" }, + { id: "p-lanes", notation: "swimlane" }, + { id: "p-state", notation: "stateMachine", subject: "order" }, + { + id: "p-seq", + notation: "sequence", + scenario: ["t1", "t2", "t3", "t4", "t5", "t7"], + }, + ], + views: { + flowchart: { "a-request": { x: 306, y: 100 } }, + }, +}); diff --git a/packages/ui/graph/react-flow/src/test-support/stub-engine.ts b/packages/ui/graph/react-flow/src/test-support/stub-engine.ts new file mode 100644 index 0000000..56ddc8c --- /dev/null +++ b/packages/ui/graph/react-flow/src/test-support/stub-engine.ts @@ -0,0 +1,60 @@ +import type { + EngineBox, + EngineGraph, + EngineNode, + LayoutEngine, +} from "@exadev/process-graph-layout"; + +/** + * A layout engine that needs no elkjs: it puts every leaf in one row, in the order the request lists them, each a layer gap after the one before, and wraps each group around its children with the request's padding. It records every request, so a test can see what was asked. + * @returns The engine and the requests it received. + */ +export const stubEngine = (): { + readonly engine: LayoutEngine; + readonly requests: EngineGraph[]; +} => { + const requests: EngineGraph[] = []; + const engine: LayoutEngine = { + arrange: async (graph) => { + requests.push(graph); + const boxes: EngineBox[] = []; + let x = 0; + const leaf = (node: EngineNode): EngineBox => { + const box = { + id: node.id, + x, + y: graph.groupPadding, + width: node.width, + height: node.height, + }; + x += node.width + graph.layerGap; + + return box; + }; + for (const node of graph.nodes) { + // A leaf, or a group with nothing in it, takes its own size in the row. + if (node.children === undefined || node.children.length === 0) { + boxes.push(leaf(node)); + continue; + } + const children = node.children.map(leaf); + const left = + Math.min(...children.map((child) => child.x)) - graph.groupPadding; + const right = + Math.max(...children.map((child) => child.x + child.width)) + + graph.groupPadding; + const bottom = + Math.max(...children.map((child) => child.y + child.height)) + + graph.groupPadding; + boxes.push( + { id: node.id, x: left, y: 0, width: right - left, height: bottom }, + ...children, + ); + } + + return Promise.resolve(boxes); + }, + }; + + return { engine, requests }; +}; diff --git a/packages/ui/graph/react-flow/src/use-layout.ts b/packages/ui/graph/react-flow/src/use-layout.ts new file mode 100644 index 0000000..1043d2c --- /dev/null +++ b/packages/ui/graph/react-flow/src/use-layout.ts @@ -0,0 +1,62 @@ +import { + layout, + type LayoutOptions, + type LayoutResult, + type LayoutView, +} from "@exadev/process-graph-layout"; +import type { IdSpace } from "@exadev/process-graph-schema"; +import { useEffect, useState } from "react"; + +/** Where laying out a view stands. */ +export type LayoutState = + | { readonly status: "pending" } + | { readonly status: "placed"; readonly result: LayoutResult } + | { readonly status: "failed"; readonly error: unknown }; + +/** The layout options a view is laid out with: everything `layout` takes but the signal, which the hook owns. */ +export type FlowLayoutOptions = Omit; + +const pending = { status: "pending" } as const; + +/** + * Lays out a view with `graph-layout`'s `layout`, again whenever the view or the options change, aborting a layout that is no longer wanted. + * @param view - The flowchart, swimlane or state machine to place. Keep it stable between renders. + * @param options - Direction, spacing and the engine. Keep it stable between renders. + * @returns `pending` until the layout resolves, then `placed` with the positions, sizes and lanes, or `failed` with what the engine threw. + */ +export const useLayout = ( + view: LayoutView, + options: FlowLayoutOptions, +): LayoutState => { + const [state, setState] = useState<{ + readonly view: LayoutView; + readonly options: FlowLayoutOptions; + readonly value: LayoutState; + }>({ view, options, value: pending }); + + useEffect(() => { + const controller = new AbortController(); + const settle = (value: LayoutState): void => { + setState({ view, options, value }); + }; + layout(view, { ...options, signal: controller.signal }).then( + (result) => { + settle({ status: "placed", result }); + }, + (error: unknown) => { + // An abort is this effect being cleaned up, not a failed layout. + if (!controller.signal.aborted) { + settle({ status: "failed", error }); + } + }, + ); + + return () => { + controller.abort(); + }; + }, [view, options]); + + return state.view === view && state.options === options + ? state.value + : pending; +}; diff --git a/packages/ui/graph/react-flow/stryker.config.ts b/packages/ui/graph/react-flow/stryker.config.ts new file mode 100644 index 0000000..75d17b7 --- /dev/null +++ b/packages/ui/graph/react-flow/stryker.config.ts @@ -0,0 +1,5 @@ +import { packageStrykerConfig } from "../../../../stryker.shared.ts"; + +export default packageStrykerConfig({ + additionalMutate: ["src/**/*.tsx", "!src/**/*.test.tsx"], +}); diff --git a/packages/ui/graph/react-flow/test/package.smoke.test.tsx b/packages/ui/graph/react-flow/test/package.smoke.test.tsx new file mode 100644 index 0000000..809ebdf --- /dev/null +++ b/packages/ui/graph/react-flow/test/package.smoke.test.tsx @@ -0,0 +1,69 @@ +import type { LayoutEngine } from "@exadev/process-graph-layout"; +import { noFacts } from "@exadev/process-graph-react"; +import { defineModel } from "@exadev/process-graph-schema"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +// Deliberately imports the built dist/, not src/: this is the check that catches a wrong exports map, a build that left Vanilla Extract calls in the JavaScript, or a stylesheet the build failed to emit. +import { ProcessFlow } from "../dist/index.js"; + +afterEach(cleanup); + +const model = defineModel({ + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "first", label: "First" }, + { kind: "activity", id: "second", label: "Second" }, + ], + transitions: [{ id: "t1", source: "first", target: "second" }], + fragments: [], + projections: [], +}); + +/** Every node in a row, without elkjs. */ +const engine: LayoutEngine = { + arrange: async (graph) => + Promise.resolve( + graph.nodes.map((node, index) => ({ + id: node.id, + x: index * (node.width + graph.layerGap), + y: 0, + width: node.width, + height: node.height, + })), + ), +}; +const layout = { engine }; + +test("the built package draws a flowchart's elements and edge", async () => { + const { container } = render( + , + ); + + await waitFor(() => { + expect(container.querySelector('[data-transitions="t1"]')).not.toBeNull(); + }); + expect( + container + .querySelector('[data-element-id="second"]') + ?.getAttribute("data-status"), + ).toBe("ready"); +}); + +test("the built stylesheet carries React Flow's rules", () => { + const stylesheet = readFileSync( + join(import.meta.dirname, "../dist/style.css"), + "utf8", + ); + + expect(stylesheet).toContain(".react-flow__node"); +}); diff --git a/packages/ui/graph/react-flow/tsconfig.build.json b/packages/ui/graph/react-flow/tsconfig.build.json new file mode 100644 index 0000000..79eb526 --- /dev/null +++ b/packages/ui/graph/react-flow/tsconfig.build.json @@ -0,0 +1,33 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": false, + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo" + }, + "references": [ + { + "path": "../react/tsconfig.build.json" + }, + { + "path": "../ui/tsconfig.build.json" + }, + { + "path": "../../../core/graph/layout/tsconfig.build.json" + }, + { + "path": "../../../core/graph/schema/tsconfig.build.json" + }, + { + "path": "../../../core/graph/projection/tsconfig.build.json" + }, + { + "path": "../../../core/graph/domain/tsconfig.build.json" + } + ] +} diff --git a/packages/ui/graph/react-flow/tsconfig.json b/packages/ui/graph/react-flow/tsconfig.json new file mode 100644 index 0000000..6bc4fe3 --- /dev/null +++ b/packages/ui/graph/react-flow/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": [], + "jsx": "react-jsx" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test-support/**"] +} diff --git a/packages/ui/graph/react-flow/tsconfig.node.json b/packages/ui/graph/react-flow/tsconfig.node.json new file mode 100644 index 0000000..287ce37 --- /dev/null +++ b/packages/ui/graph/react-flow/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["node"], + "jsx": "react-jsx" + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "test/**/*.ts", + "test/**/*.tsx", + "*.config.ts" + ] +} diff --git a/packages/ui/graph/react-flow/turbo.json b/packages/ui/graph/react-flow/turbo.json new file mode 100644 index 0000000..b0bb0a1 --- /dev/null +++ b/packages/ui/graph/react-flow/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turborepo.com/schema.json", + "extends": ["//"], + "tasks": { + "_build": { + "inputs": ["$TURBO_EXTENDS$", "vite.config.ts"] + } + } +} diff --git a/packages/ui/graph/react-flow/vite.config.ts b/packages/ui/graph/react-flow/vite.config.ts new file mode 100644 index 0000000..dac123b --- /dev/null +++ b/packages/ui/graph/react-flow/vite.config.ts @@ -0,0 +1,42 @@ +import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; +import { isAbsolute, resolve } from "node:path"; +import { defineConfig, type Plugin } from "vite"; + +const entry = resolve(import.meta.dirname, "src/index.ts"); + +/** + * Imports React Flow's own stylesheet once, into the entry, at build time only. The import lives here rather than in `src/index.ts` because TypeScript keeps a side-effect import in the emitted declarations, where every consumer's compiler would then have to resolve a CSS file. + */ +const reactFlowStylesheet: Plugin = { + name: "process-graph-react-flow-stylesheet", + transform: (code, id) => + id === entry + ? `import "@xyflow/react/dist/style.css";\n${code}` + : undefined, +}; + +/** + * The library build. Vanilla Extract runs here, at build time, and React Flow's stylesheet is bundled with this package's own rules, so `dist/style.css` is the one stylesheet a consumer imports for this package and no build plugin is needed. Declarations come from `tsc -b` afterwards, since Vite emits none. Every bare import other than a stylesheet stays an import, resolved from the consumer's own install as `package.json` declares it. + */ +export default defineConfig({ + plugins: [vanillaExtractPlugin(), reactFlowStylesheet], + oxc: { jsx: { runtime: "automatic" } }, + build: { + lib: { + entry, + formats: ["es"], + fileName: "index", + cssFileName: "style", + }, + rolldownOptions: { + external: (id) => + !id.startsWith(".") && + !isAbsolute(id) && + !id.startsWith("\0") && + !id.endsWith(".css"), + }, + target: "es2024", + minify: false, + sourcemap: false, + }, +}); diff --git a/packages/ui/graph/react-flow/vitest.config.ts b/packages/ui/graph/react-flow/vitest.config.ts new file mode 100644 index 0000000..3e4edee --- /dev/null +++ b/packages/ui/graph/react-flow/vitest.config.ts @@ -0,0 +1,30 @@ +import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; +import { defineConfig } from "vitest/config"; + +/** + * happy-dom, as the owner's other React packages use. The unit tests run the Vanilla Extract plugin and process CSS, so this package's own styles compile as they do in the build; layouts come from a stub engine, so no test needs elkjs. The smoke test renders the built `dist/`. + */ +export default defineConfig({ + plugins: [vanillaExtractPlugin()], + test: { + css: true, + projects: [ + { + extends: true, + test: { + name: "unit", + include: ["src/**/*.test.{ts,tsx}"], + environment: "happy-dom", + }, + }, + { + extends: true, + test: { + name: "smoke", + include: ["test/package.smoke.test.tsx"], + environment: "happy-dom", + }, + }, + ], + }, +}); diff --git a/packages/ui/graph/react/AGENTS.md b/packages/ui/graph/react/AGENTS.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/ui/graph/react/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/ui/graph/react/CLAUDE.md b/packages/ui/graph/react/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/ui/graph/react/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/ui/graph/react/README.md b/packages/ui/graph/react/README.md new file mode 100644 index 0000000..e11f551 --- /dev/null +++ b/packages/ui/graph/react/README.md @@ -0,0 +1,100 @@ +# @exadev/process-graph-react + +[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/process-graph) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/process-graph/ci.yml?branch=main)](https://github.com/ExaDev/process-graph/actions) + +Headless React hooks and unstyled function components over the process model. The hooks load and follow a model from a `GraphStore` client, derive its run with `@exadev/process-graph-domain`, project its views with `@exadev/process-graph-projection`, and hold selection and filter state. The components render semantic markup with ARIA roles and stable `data-*` attributes and take a class for each of their parts; they carry no styles and depend on no renderer. A private workspace package, published as part of `process-graph`. `src/` imports nothing Node-only. + +```tsx +import { + ElementCard, + noFacts, + useBlockers, + useGraphSelection, + useModel, + useStatuses, +} from "@exadev/process-graph-react"; + +const Board = ({ client }) => { + const state = useModel(client, "release"); + if (state.status !== "loaded") return null; + const { model, overlay } = state.stored; + return ; +}; + +const Cards = ({ run }) => { + const statuses = useStatuses(run); + const blockers = useBlockers(run); + const selection = useGraphSelection(run.model.elements.map((e) => e.id)); + return ( +
+ {run.model.elements.map((element) => { + const status = statuses.get(element.id); + return status === undefined ? null : ( + ({ id, label: id }))} + item={selection.itemProps(element.id)} + /> + ); + })} +
+ ); +}; +``` + +## Hooks + +| Hook | Result | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------- | +| `useModel(client, modelId)` | `loading`, then `loaded` with the stored model, overlay and revision, or `failed` with the error. | +| `useStatuses(run)` | Every element's `Status`, by `deriveStatuses`. | +| `useReadySet(run)` | The ready elements, by `readySet`. | +| `useBlockedChain(run, id)` | The chain of unfinished work behind an element and the blockers at its end. | +| `useBlockers(run)` | Each blocked element's blockers, for a view that shows them beside every element. | +| `useImpact(run, id)` | The live work downstream of an element, by `impact`. | +| `useWhyStatus(run, id)` | The `StatusExplanation` of an element, by `explainStatus`. | +| `useCriticalPath(run, weight?)` | The heaviest chain through the work ahead; each element weighs one step unless `weight` says otherwise. | +| `useProjection(model, projection)` | The view a projection names, as `{ notation, view }`. | +| `useElementFilter(initial?)` | A filter by status, actor and source, with toggles and `clear`. | +| `useGraphSelection(order, options?)` | The selected element, a setter, and container and item props for keyboard navigation. | + +A `Run` is `{ model, overlay, facts }`. `noFacts` is the facts port of a run with no facts source: every condition that reads a fact is indeterminate. Keep the three parts stable between renders; the hooks recompute when one of them changes identity. A hook that derives status throws what `graph-domain` throws (an illegal cycle, an unknown element), so an error boundary shows it. + +### Following a store + +`useModel` takes any `GraphStore` client from `@exadev/process-graph-store-contract`: an in-process router client, one over a `MessagePort` to the SQLite worker, or one over HTTP. It subscribes to the model's changes before loading it, loads again on every change newer than what it shows, and aborts both when the component unmounts or the client or model id changes. A model that does not exist, or is deleted, ends in `failed` with the store's `NOT_FOUND`. + +### Filtering + +An `ElementFilter` holds a set of statuses, actors and sources; an empty set does not constrain, so the empty filter shows everything. `filterElements` gives the shown elements in model order, `matchesFilter` tests one, and `filterOptions` lists the actors and source collections a model offers. A source collection is a source-qualified identifier up to its item number (`sourceScope("github:org/repo#12")` is `github:org/repo`). An element with no actor or no source never matches a non-empty actor or source set. + +### Keyboard navigation + +`useGraphSelection(order)` follows the WAI-ARIA listbox pattern. Spread `containerProps` on the element that holds the items and `itemProps(id)` on each item. The arrow keys move the selection through `order`, wrapping at either end; Home and End go to the first and last; Escape clears the selection. Only one item is a tab stop, the selected one or the first, and a keyboard move focuses the item it selects. Pass the order in which the items are drawn, after filtering. The options set the initial selection, an `onChange` listener called with each selection a click or key makes, and a `focus` function for a renderer that draws its own focusable items, such as a React Flow canvas. + +## Components + +| Component | Markup | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `StatusBadge` | A `span` with the status's label and `data-status`. | +| `ElementCard` | The element's label, status badge, a critical path marker and "Blocked by" text; `data-element-id`, `data-kind`, `data-status`, `data-critical`. | +| `TransitionItem` | An `li` reading "from to to", with its label and a hand-off marker; `data-transition-id`, `data-handoff`, `data-critical`. | +| `Lane` | A group named by its heading, holding one participant's work; `data-lane`. | +| `Legend` | A list named "Legend" with a swatch and label per status, and optionally the critical path; `data-status` on each item and swatch. | +| `FilterPanel` | A search form of checkbox groups for status, actor and source, and a clear button; `data-filter` on each group. | +| `DetailPanel` | A complementary region named by the element: its status, the rule that decided it, and its blockers and impact as sections. | + +Every component takes `classNames`, a class per named part (`ElementCardPart`, `LegendPart` and so on), or `className` for a single-part one. Each inner part also carries `data-part`. `statusLabels`, `ruleDescriptions` and `elementLabel` are the texts the components use, for a renderer that draws the same things another way. + +## Commands + +```sh +pnpm build # tsc -b -> dist/ (ESM + .d.ts) +pnpm test # unit tests in happy-dom +pnpm test:smoke # the built dist/ rendered as a real artefact +pnpm test:mutation # Stryker +``` diff --git a/packages/ui/graph/react/eslint.config.ts b/packages/ui/graph/react/eslint.config.ts new file mode 100644 index 0000000..5b6a264 --- /dev/null +++ b/packages/ui/graph/react/eslint.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "eslint/config"; +import { version } from "react"; +import { packageLintConfig } from "../../../../eslint.shared.ts"; + +/** + * A browser React library: runtime `src/` imports nothing Node-only, browser globals are in scope, and `.tsx` files get the React, hooks and accessibility rules for the React this package installs. + */ +export default defineConfig( + packageLintConfig({ + tsconfigRootDir: import.meta.dirname, + isomorphic: true, + browserGlobals: true, + react: { version }, + }), +); diff --git a/packages/ui/graph/react/package.json b/packages/ui/graph/react/package.json new file mode 100644 index 0000000..3ad39f6 --- /dev/null +++ b/packages/ui/graph/react/package.json @@ -0,0 +1,82 @@ +{ + "name": "@exadev/process-graph-react", + "version": "0.0.0", + "private": true, + "description": "Headless React hooks and unstyled components over the process model: load and follow a stored model, derive statuses, ready set, blocked chains, impact and explanations, select, filter and navigate by keyboard, and render elements, lanes, legends and panels as semantic markup.", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/process-graph.git", + "directory": "packages/ui/graph/react" + }, + "homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/ui/graph/react#readme", + "bugs": { + "url": "https://github.com/ExaDev/process-graph/issues" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "sideEffects": false, + "license": "MIT", + "scripts": { + "build": "turbo run _build", + "_build": "tsc -b tsconfig.build.json", + "lint": "turbo run _lint", + "_lint": "eslint . --cache --max-warnings 0", + "_lint:fix": "eslint . --fix --cache --max-warnings 0", + "typecheck": "turbo run _typecheck _typecheck:attw", + "_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit", + "_typecheck:attw": "attw --pack --profile esm-only", + "test": "turbo run _test", + "_test": "vitest run --project unit", + "test:coverage": "turbo run _test:coverage", + "_test:coverage": "vitest run --project unit --coverage", + "test:smoke": "turbo run _test:smoke", + "_test:smoke": "vitest run --project smoke", + "test:mutation": "turbo run _test:mutation", + "_test:mutation": "stryker run stryker.config.ts" + }, + "keywords": [ + "process-model", + "react", + "hooks", + "headless", + "dependency-graph" + ], + "dependencies": { + "@exadev/process-graph-dag": "1.1.0", + "@exadev/process-graph-domain": "1.1.1", + "@exadev/process-graph-projection": "1.1.0", + "@exadev/process-graph-schema": "1.1.0", + "@exadev/process-graph-store-contract": "1.0.3" + }, + "devDependencies": { + "@arethetypeswrong/cli": "0.18.5", + "@orpc/server": "2.0.0-beta.41", + "@stryker-mutator/core": "10.0.0", + "@stryker-mutator/typescript-checker": "10.0.0", + "@stryker-mutator/vitest-runner": "10.0.0", + "@testing-library/dom": "10.4.2", + "@testing-library/react": "16.3.3", + "@types/node": "26.6.4", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitest/coverage-v8": "5.0.3", + "eslint": "10.12.0", + "happy-dom": "20.14.5", + "react": "19.3.0", + "react-dom": "19.3.0", + "typescript": "6.0.3", + "vitest": "5.0.3" + }, + "peerDependencies": { + "react": "19.3.0" + } +} diff --git a/packages/ui/graph/react/src/analysis.ts b/packages/ui/graph/react/src/analysis.ts new file mode 100644 index 0000000..2fd525c --- /dev/null +++ b/packages/ui/graph/react/src/analysis.ts @@ -0,0 +1,170 @@ +import { + blockedBy, + criticalPath, + deriveStatuses, + explainStatus, + impact, + readySet, + type ChainLink, + type Status, + type StatusExplanation, +} from "@exadev/process-graph-domain"; +import type { CriticalPath } from "@exadev/process-graph-dag"; +import type { IdSpace } from "@exadev/process-graph-schema"; +import { useMemo } from "react"; +import type { Run } from "./run.ts"; + +/** + * The status of every element in a run, derived by `deriveStatuses`. + * @param run - The model, overlay and facts. + * @returns Each element's status, in model order. + * @throws CycleError when the model has an illegal cycle, so an error boundary shows it. + */ +export const useStatuses = ( + run: Run, +): ReadonlyMap => { + const { model, overlay, facts } = run; + + return useMemo( + () => deriveStatuses(model, overlay, facts), + [model, overlay, facts], + ); +}; + +/** + * The work that can start now, by `readySet`. + * @param run - The model, overlay and facts. + * @returns The ready elements, in model order. + */ +export const useReadySet = ( + run: Run, +): readonly I["element"][] => { + const { model, overlay, facts } = run; + + return useMemo( + () => readySet(model, overlay, facts), + [model, overlay, facts], + ); +}; + +/** + * Why an element has its status, by `explainStatus`. + * @param run - The model, overlay and facts. + * @param id - The element asked about, or `undefined` when nothing is. + * @returns The explanation, or `undefined` when `id` is. + * @throws UnknownNodeError when `id` is not an element of the model. + */ +export const useWhyStatus = ( + run: Run, + id: I["element"] | undefined, +): StatusExplanation | undefined => { + const { model, overlay, facts } = run; + + return useMemo( + () => + id === undefined ? undefined : explainStatus(model, overlay, facts, id), + [model, overlay, facts, id], + ); +}; + +/** The unfinished work behind an element. */ +export interface BlockedChain { + /** The element, then every unfinished need reached from it, each with the needs it still waits on; see `explainStatus`. */ + readonly chain: readonly ChainLink[]; + /** The open work at the end of the chain, which waits on nothing further: what to finish first. The same elements `blockedBy` returns. */ + readonly blockers: readonly I["element"][]; +} + +/** + * The unfinished work holding an element back: the whole chain and the blockers at its end. + * @param run - The model, overlay and facts. + * @param id - The element asked about, or `undefined` when nothing is. + * @returns The chain and its blockers; both empty when `id` is `undefined`. + * @throws UnknownNodeError when `id` is not an element of the model. + */ +export const useBlockedChain = ( + run: Run, + id: I["element"] | undefined, +): BlockedChain => { + const explanation = useWhyStatus(run, id); + + return useMemo(() => { + if (explanation === undefined) { + return { chain: [], blockers: [] }; + } + const [, ...behind] = explanation.chain; + + return { + chain: explanation.chain, + blockers: behind + .filter((link) => link.waitingOn.length === 0) + .map((link) => link.element), + }; + }, [explanation]); +}; + +/** + * What every blocked element is blocked by, by `blockedBy`, for a view that shows each element's blockers beside it. + * @param run - The model, overlay and facts. + * @returns The open work at the end of each blocked element's chain, keyed by the blocked element, in model order. Elements that are not blocked are absent. + */ +export const useBlockers = ( + run: Run, +): ReadonlyMap => { + const statuses = useStatuses(run); + const { model, overlay, facts } = run; + + return useMemo( + () => + new Map( + [...statuses] + .filter(([, status]) => status === "blocked") + .map(([id]) => [id, blockedBy(model, overlay, facts, id)]), + ), + [statuses, model, overlay, facts], + ); +}; + +/** + * The live work that transitively depends on an element, by `impact`. + * @param run - The model, overlay and facts. + * @param id - The element asked about, or `undefined` when nothing is. + * @returns The impacted elements; empty when `id` is `undefined`. + * @throws UnknownNodeError when `id` is not an element of the model. + */ +export const useImpact = ( + run: Run, + id: I["element"] | undefined, +): readonly I["element"][] => { + const { model, overlay, facts } = run; + + return useMemo( + () => (id === undefined ? [] : impact(model, overlay, facts, id)), + [model, overlay, facts, id], + ); +}; + +/** + * Every element weighs one step, so the critical path is the longest chain of work still ahead. + * @returns One. + */ +export const stepWeight = (): number => 1; + +/** + * The heaviest chain through the work still ahead, by `criticalPath`. + * @param run - The model, overlay and facts. + * @param weight - The cost of an element, finite and not negative. Keep it stable between renders. Defaults to `stepWeight`. + * @returns The path in dependency order and its total weight; empty when nothing is live. + * @throws RangeError when `weight` returns a negative or non-finite number. + */ +export const useCriticalPath = ( + run: Run, + weight: (id: I["element"]) => number = stepWeight, +): CriticalPath => { + const { model, overlay, facts } = run; + + return useMemo( + () => criticalPath(model, overlay, facts, weight), + [model, overlay, facts, weight], + ); +}; diff --git a/packages/ui/graph/react/src/analysis.unit.test.tsx b/packages/ui/graph/react/src/analysis.unit.test.tsx new file mode 100644 index 0000000..3bcfe37 --- /dev/null +++ b/packages/ui/graph/react/src/analysis.unit.test.tsx @@ -0,0 +1,105 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { + useBlockedChain, + useCriticalPath, + useImpact, + useReadySet, + useStatuses, + useWhyStatus, +} from "./analysis.ts"; +import { noFacts } from "./run.ts"; +import type { Overlay } from "@exadev/process-graph-schema"; +import type { OrderIds } from "./test-support/ids.ts"; +import { order } from "./test-support/order.ts"; + +/** The order process with the customer's submission done: requesting payment can start, and everything after it waits. */ +const submitted = { + model: order, + overlay: { "a-submit": "done" }, + facts: noFacts, +} as const; + +describe("reasoning hooks", () => { + test("useStatuses derives every element's status from the overlay", () => { + const { result } = renderHook(() => useStatuses(submitted)); + + expect(Object.fromEntries(result.current)).toEqual({ + start: "done", + "a-submit": "done", + "a-request": "ready", + "a-authorise": "blocked", + "d-authorised": "blocked", + "a-pack": "blocked", + "a-cancel": "blocked", + end: "blocked", + }); + }); + + test("useReadySet is the work that can start now", () => { + const { result } = renderHook(() => useReadySet(submitted)); + + expect(result.current).toEqual(["a-request"]); + }); + + test("useBlockedChain follows unfinished needs to the blocker at the end", () => { + const { result } = renderHook(() => useBlockedChain(submitted, "a-pack")); + + expect(result.current.blockers).toEqual(["a-request"]); + expect(result.current.chain.map((link) => link.element)).toEqual([ + "a-pack", + "a-request", + "a-authorise", + "d-authorised", + ]); + }); + + test("useBlockedChain is empty when nothing is asked about", () => { + const { result } = renderHook(() => useBlockedChain(submitted, undefined)); + + expect(result.current).toEqual({ chain: [], blockers: [] }); + }); + + test("useWhyStatus names the rule that decided the status", () => { + const { result } = renderHook(() => useWhyStatus(submitted, "a-authorise")); + + expect(result.current?.status).toBe("blocked"); + expect(result.current?.rule).toBe("waiting"); + }); + + test("useImpact is the live work downstream of an element", () => { + const { result } = renderHook(() => useImpact(submitted, "a-request")); + + expect(result.current).toEqual([ + "a-authorise", + "d-authorised", + "a-pack", + "a-cancel", + "end", + ]); + }); + + test("useCriticalPath counts each element as one step by default", () => { + const { result } = renderHook(() => useCriticalPath(submitted)); + + expect(result.current.path).toEqual([ + "a-request", + "a-authorise", + "d-authorised", + "a-pack", + "end", + ]); + expect(result.current.weight).toBe(result.current.path.length); + }); + + test("the hooks recompute when the overlay changes", () => { + const initial: Overlay = submitted.overlay; + const { result, rerender } = renderHook( + ({ overlay }) => useReadySet({ model: order, overlay, facts: noFacts }), + { initialProps: { overlay: initial } }, + ); + rerender({ overlay: { "a-submit": "done", "a-request": "done" } }); + + expect(result.current).toEqual(["a-authorise"]); + }); +}); diff --git a/packages/ui/graph/react/src/components.unit.test.tsx b/packages/ui/graph/react/src/components.unit.test.tsx new file mode 100644 index 0000000..75953dc --- /dev/null +++ b/packages/ui/graph/react/src/components.unit.test.tsx @@ -0,0 +1,258 @@ +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { Board } from "./test-support/board.tsx"; +import { order } from "./test-support/order.ts"; + +afterEach(cleanup); + +/** The board with the customer's submission done. */ +const renderSubmitted = () => + render(); + +const process = () => screen.getByRole("listbox", { name: "Order process" }); + +const card = (label: string): HTMLElement => { + const option = within(process()) + .getAllByRole("option") + .find((candidate) => candidate.textContent.startsWith(label)); + if (option === undefined) { + throw new Error(`no card for ${label}`); + } + + return option; +}; + +describe("the unstyled components", () => { + test("lanes group the work of each participant under its name", () => { + renderSubmitted(); + + const lanes = within(process()) + .getAllByRole("group") + .filter((group) => group.hasAttribute("data-lane")); + expect(lanes.map((lane) => lane.getAttribute("data-lane"))).toEqual([ + "customer", + "shop", + "provider", + ]); + expect( + lanes.map((lane) => lane.getAttribute("aria-labelledby")), + ).not.toContain(null); + const shop = within(process()).getByRole("group", { name: "Shop" }); + expect( + within(shop) + .getAllByRole("option") + .map((option) => option.getAttribute("data-element-id")), + ).toEqual(["a-request", "d-authorised", "a-pack", "a-cancel"]); + }); + + test("each card shows its element's status as a badge and in data-status", () => { + renderSubmitted(); + + expect(card("Submit order").getAttribute("data-status")).toBe("done"); + expect(within(card("Submit order")).getByText("Done")).toBeTruthy(); + expect(within(card("Request payment")).getByText("Ready")).toBeTruthy(); + expect( + within(card("Authorise or decline")).getByText("Blocked"), + ).toBeTruthy(); + }); + + test("a blocked element shows the open work it is blocked by", () => { + renderSubmitted(); + + expect( + within(card("Pack and ship")).getByText("Blocked by Request payment"), + ).toBeTruthy(); + expect(card("Request payment").textContent).not.toContain("Blocked by"); + }); + + test("the critical path is marked on its cards", () => { + renderSubmitted(); + + expect(card("Request payment").hasAttribute("data-critical")).toBe(true); + expect( + within(card("Pack and ship")).getByText("Critical path"), + ).toBeTruthy(); + expect(card("Cancel order and notify").hasAttribute("data-critical")).toBe( + false, + ); + }); + + test("hand-offs list the transitions that change actor", () => { + renderSubmitted(); + + const handoffs = within( + screen.getByRole("list", { name: "Hand-offs" }), + ).getAllByRole("listitem"); + expect(handoffs.map((item) => item.textContent)).toEqual([ + "Submit order to Request payment (hand-off)", + "Request payment to Authorise or decline (hand-off)", + "Authorise or decline to Payment authorised? (hand-off)", + ]); + expect(handoffs.every((item) => item.hasAttribute("data-handoff"))).toBe( + true, + ); + }); + + test("the legend names every status and the critical path", () => { + renderSubmitted(); + + const legend = screen.getByRole("list", { name: "Legend" }); + expect( + within(legend) + .getAllByRole("listitem") + .map((item) => item.textContent), + ).toEqual([ + "Done", + "Unknown", + "Skipped", + "Ready", + "Blocked", + "Critical path", + ]); + }); + + test("selecting a blocked element explains it in the detail panel", () => { + renderSubmitted(); + + fireEvent.click(card("Pack and ship")); + + const panel = screen.getByRole("complementary", { name: "Pack and ship" }); + expect(within(panel).getByText("Blocked")).toBeTruthy(); + expect( + within(panel).getByText("It is still waiting on unfinished work."), + ).toBeTruthy(); + const blockedBy = within(panel).getByRole("region", { name: "Blocked by" }); + expect(within(blockedBy).getByRole("listitem").textContent).toBe( + "Request payment", + ); + const impact = within(panel).getByRole("region", { name: "Impact" }); + expect( + within(impact) + .getAllByRole("listitem") + .map((item) => item.textContent), + ).toEqual(["End"]); + }); +}); + +describe("keyboard navigation", () => { + test("one card is a tab stop, the first until something is selected", () => { + renderSubmitted(); + + const stops = within(process()) + .getAllByRole("option") + .filter((option) => option.tabIndex === 0); + expect( + stops.map((option) => option.getAttribute("data-element-id")), + ).toEqual(["a-submit"]); + }); + + test("arrow keys move the selection and focus through the shown elements", () => { + renderSubmitted(); + + fireEvent.keyDown(process(), { key: "ArrowDown" }); + expect(card("Submit order").getAttribute("aria-selected")).toBe("true"); + expect(document.activeElement).toBe(card("Submit order")); + + fireEvent.keyDown(process(), { key: "ArrowRight" }); + expect(card("Request payment").getAttribute("aria-selected")).toBe("true"); + expect(card("Submit order").getAttribute("aria-selected")).toBe("false"); + expect(card("Request payment").tabIndex).toBe(0); + expect(document.activeElement).toBe(card("Request payment")); + + fireEvent.keyDown(process(), { key: "ArrowUp" }); + expect(document.activeElement).toBe(card("Submit order")); + }); + + test("the arrows wrap, Home and End jump to either end, Escape clears", () => { + renderSubmitted(); + + fireEvent.keyDown(process(), { key: "ArrowUp" }); + expect(document.activeElement?.getAttribute("data-element-id")).toBe("end"); + fireEvent.keyDown(process(), { key: "ArrowDown" }); + expect(document.activeElement?.getAttribute("data-element-id")).toBe( + "a-submit", + ); + fireEvent.keyDown(process(), { key: "End" }); + expect(document.activeElement?.getAttribute("data-element-id")).toBe("end"); + fireEvent.keyDown(process(), { key: "Home" }); + expect(document.activeElement?.getAttribute("data-element-id")).toBe( + "a-submit", + ); + + fireEvent.keyDown(process(), { key: "Escape" }); + expect( + within(process()) + .getAllByRole("option") + .some((option) => option.getAttribute("aria-selected") === "true"), + ).toBe(false); + }); +}); + +describe("filters", () => { + test("offer every status, the acting participants and no sources", () => { + renderSubmitted(); + + const form = screen.getByRole("search", { name: "Filter elements" }); + const groups = within(form).getAllByRole("group"); + expect(groups.map((group) => group.getAttribute("data-filter"))).toEqual([ + "status", + "actor", + ]); + expect( + within(screen.getByRole("group", { name: "Actor" })) + .getAllByRole("checkbox") + .map((box) => box.parentElement?.textContent), + ).toEqual(["Customer", "Shop", "Payment provider"]); + }); + + test("a status filter shows only elements with that status", () => { + renderSubmitted(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Blocked" })); + + expect( + within(process()) + .getAllByRole("option") + .map((option) => option.getAttribute("data-element-id")), + ).toEqual(["d-authorised", "a-pack", "a-cancel", "a-authorise", "end"]); + }); + + test("an actor filter combines with a status filter, and clearing shows everything", () => { + renderSubmitted(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Blocked" })); + fireEvent.click(screen.getByRole("checkbox", { name: "Payment provider" })); + expect( + within(process()) + .getAllByRole("option") + .map((option) => option.getAttribute("data-element-id")), + ).toEqual(["a-authorise"]); + + fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); + expect(within(process()).getAllByRole("option")).toHaveLength( + order.elements.length, + ); + expect( + screen.getByRole("checkbox", { name: "Blocked" }) + .checked, + ).toBe(false); + }); + + test("keyboard navigation moves only through the elements a filter shows", () => { + renderSubmitted(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Blocked" })); + fireEvent.keyDown(process(), { key: "ArrowDown" }); + fireEvent.keyDown(process(), { key: "ArrowDown" }); + + expect(document.activeElement?.getAttribute("data-element-id")).toBe( + "a-pack", + ); + }); +}); diff --git a/packages/ui/graph/react/src/detail-panel.tsx b/packages/ui/graph/react/src/detail-panel.tsx new file mode 100644 index 0000000..af34787 --- /dev/null +++ b/packages/ui/graph/react/src/detail-panel.tsx @@ -0,0 +1,114 @@ +import type { StatusExplanation } from "@exadev/process-graph-domain"; +import type { Element, IdSpace } from "@exadev/process-graph-schema"; +import { useId, type ReactElement } from "react"; +import { elementLabel, ruleDescriptions } from "./labels.ts"; +import { StatusBadge } from "./status-badge.tsx"; + +/** The parts of a detail panel a class can be given to. */ +export type DetailPanelPart = + | "root" + | "heading" + | "status" + | "reason" + | "section" + | "sectionHeading" + | "list"; + +export interface DetailPanelProps { + readonly element: Element; + /** Why the element has its status, from `useWhyStatus`. */ + readonly explanation: StatusExplanation; + /** The open work at the end of its blocked chain, from `useBlockedChain`. */ + readonly blockers: readonly I["element"][]; + /** The live work that depends on it, from `useImpact`. */ + readonly impact: readonly I["element"][]; + /** The display label of another element. */ + readonly labelOf: (id: I["element"]) => string; + readonly classNames?: Partial> | undefined; +} + +interface SectionProps { + readonly name: string; + readonly heading: string; + readonly items: readonly { readonly id: string; readonly label: string }[]; + readonly classNames: Partial>; +} + +const Section = ({ + name, + heading, + items, + classNames, +}: SectionProps): ReactElement | null => { + const id = useId(); + + return items.length === 0 ? null : ( +
+

+ {heading} +

+
    + {items.map((item) => ( +
  • + {item.label} +
  • + ))} +
+
+ ); +}; + +/** + * The details of one element: its status, the rule that decided it, what it is blocked by and what depends on it. The root is a complementary region named by the element's label; each list is a section carrying `data-section` with `blocked-by` or `impact`, shown only when it is not empty. + * @param props - The element, its explanation, blockers and impact. + * @returns The panel. + */ +export const DetailPanel = ( + props: DetailPanelProps, +): ReactElement => { + const { + element, + explanation, + blockers, + impact, + labelOf, + classNames = {}, + } = props; + + const heading = useId(); + const named = (ids: readonly I["element"][]) => + ids.map((id) => ({ id, label: labelOf(id) })); + + return ( + + ); +}; diff --git a/packages/ui/graph/react/src/element-card.tsx b/packages/ui/graph/react/src/element-card.tsx new file mode 100644 index 0000000..9bab3a4 --- /dev/null +++ b/packages/ui/graph/react/src/element-card.tsx @@ -0,0 +1,72 @@ +import type { Status } from "@exadev/process-graph-domain"; +import type { Element, IdSpace } from "@exadev/process-graph-schema"; +import type { ReactElement } from "react"; +import { elementLabel } from "./labels.ts"; +import type { SelectionItemProps } from "./selection.ts"; +import { StatusBadge } from "./status-badge.tsx"; + +/** The parts of an element card a class can be given to. */ +export type ElementCardPart = + "root" | "label" | "status" | "critical" | "blockers"; + +/** Another element named by a card, by id and display label. */ +export interface NamedElement { + readonly id: string; + readonly label: string; +} + +export interface ElementCardProps { + readonly element: Element; + readonly status: Status; + /** Whether the element is on the critical path. */ + readonly critical?: boolean | undefined; + /** The open work the element waits on, shown when it is not empty. */ + readonly blockedBy?: readonly NamedElement[] | undefined; + /** The props from `useGraphSelection().itemProps`, when the card is a navigable item. */ + readonly item?: SelectionItemProps | undefined; + readonly classNames?: Partial> | undefined; +} + +/** + * One element: its label, its status, whether it is on the critical path, and what it is blocked by. The root carries `data-element-id`, `data-kind`, `data-status` and, on the critical path, `data-critical`. + * @param props - The element, its status and what to show beside it. + * @returns The card. + */ +export const ElementCard = ( + props: ElementCardProps, +): ReactElement => { + const { + element, + status, + critical = false, + blockedBy = [], + item, + classNames = {}, + } = props; + + return ( +
+ + {elementLabel(element)} + + + {critical ? ( + + Critical path + + ) : null} + {blockedBy.length > 0 ? ( + + Blocked by {blockedBy.map((blocker) => blocker.label).join(", ")} + + ) : null} +
+ ); +}; diff --git a/packages/ui/graph/react/src/filter-panel.tsx b/packages/ui/graph/react/src/filter-panel.tsx new file mode 100644 index 0000000..90b135d --- /dev/null +++ b/packages/ui/graph/react/src/filter-panel.tsx @@ -0,0 +1,118 @@ +import { statuses, type Status } from "@exadev/process-graph-domain"; +import type { ReactElement } from "react"; +import type { ElementFilter, FilterOption, FilterOptions } from "./filter.ts"; +import { statusLabels } from "./labels.ts"; + +/** The parts of a filter panel a class can be given to. */ +export type FilterPanelPart = + "root" | "group" | "legend" | "option" | "checkbox" | "clear"; + +export interface FilterPanelProps { + readonly filter: ElementFilter; + /** The actors and sources to offer, from `filterOptions`. A group with no options is not shown. */ + readonly options: FilterOptions; + /** The statuses to offer. Defaults to every status. */ + readonly statuses?: readonly Status[] | undefined; + readonly onToggleStatus: (status: Status) => void; + readonly onToggleActor: (actor: string) => void; + readonly onToggleSource: (source: string) => void; + readonly onClear: () => void; + readonly classNames?: Partial> | undefined; +} + +interface GroupProps { + readonly name: string; + readonly legend: string; + readonly options: readonly FilterOption[]; + readonly checked: ReadonlySet; + readonly onToggle: (value: V) => void; + readonly classNames: Partial>; +} + +const Group = ({ + name, + legend, + options, + checked, + onToggle, + classNames, +}: GroupProps): ReactElement | null => + options.length === 0 ? null : ( +
+ {legend} + {options.map((option) => ( + + ))} +
+ ); + +/** + * Checkboxes for filtering a view by status, actor and source, and a button that clears them. Each group is a `fieldset` carrying `data-filter` with `status`, `actor` or `source`. + * @param props - The current filter, what to offer, and the handlers that change it. + * @returns A search form. + */ +export const FilterPanel = (props: FilterPanelProps): ReactElement => { + const { + filter, + options, + statuses: offered = statuses, + onToggleStatus, + onToggleActor, + onToggleSource, + onClear, + classNames = {}, + } = props; + + return ( +
{ + event.preventDefault(); + }} + > + ({ + value: status, + label: statusLabels[status], + }))} + checked={filter.statuses} + onToggle={onToggleStatus} + classNames={classNames} + /> + + + + + ); +}; diff --git a/packages/ui/graph/react/src/filter.ts b/packages/ui/graph/react/src/filter.ts new file mode 100644 index 0000000..56fb799 --- /dev/null +++ b/packages/ui/graph/react/src/filter.ts @@ -0,0 +1,201 @@ +import type { Status } from "@exadev/process-graph-domain"; +import type { Element, IdSpace, Model } from "@exadev/process-graph-schema"; +import { useCallback, useMemo, useState } from "react"; + +/** + * Which elements to show. Each set constrains one property, and an empty set does not constrain it, so the empty filter shows everything. An element is shown when it passes every set that is not empty. + */ +export interface ElementFilter { + /** Statuses to show. */ + readonly statuses: ReadonlySet; + /** Actors to show the work of, by participant id. An element with no actor, such as a start or end, never matches a non-empty set. */ + readonly actors: ReadonlySet; + /** Sources to show the work of, by `sourceScope`. An element bound to no source never matches a non-empty set. */ + readonly sources: ReadonlySet; +} + +/** The filter that shows every element. */ +export const emptyFilter: ElementFilter = { + statuses: new Set(), + actors: new Set(), + sources: new Set(), +}; + +/** The separator between a source-qualified identifier's collection and its item, as in `github:org/repo#12`. */ +const itemSeparator = "#"; + +/** + * The collection a source-qualified identifier belongs to: the identifier up to its item separator, so `github:org/repo#12` is in `github:org/repo`. An identifier with no item separator is its own collection. + * @param source - A source-qualified identifier. + * @returns The identifier's collection. + */ +export const sourceScope = (source: string): string => { + const at = source.lastIndexOf(itemSeparator); + + return at === -1 ? source : source.slice(0, at); +}; + +/** + * The actors an element's work belongs to: the actor of an activity or decision, and both ends of a message. + * @param element - The element. + * @returns Its actors' participant ids. + */ +export const actorsOf = ( + element: Element, +): readonly I["actor"][] => { + if (element.kind === "message") { + return [element.from, element.to]; + } + + return (element.kind === "activity" || element.kind === "decision") && + element.actor !== undefined + ? [element.actor] + : []; +}; + +const passes = ( + allowed: ReadonlySet, + values: readonly string[], +): boolean => allowed.size === 0 || values.some((value) => allowed.has(value)); + +/** + * Whether an element passes a filter. + * @param element - The element. + * @param status - The element's status in the run. + * @param filter - The filter. + * @returns True when the element is shown. + */ +export const matchesFilter = ( + element: Element, + status: Status, + filter: ElementFilter, +): boolean => + passes(filter.statuses, [status]) && + passes(filter.actors, actorsOf(element)) && + passes( + filter.sources, + element.kind === "activity" && element.source !== undefined + ? [sourceScope(element.source)] + : [], + ); + +/** + * The elements a filter shows. + * @param model - The model. + * @param statuses - Each element's status, from `useStatuses`. + * @param filter - The filter. + * @returns The shown elements' ids, in model order. + */ +export const filterElements = ( + model: Model, + statuses: ReadonlyMap, + filter: ElementFilter, +): readonly I["element"][] => + model.elements.flatMap((element) => { + const status = statuses.get(element.id); + + return status !== undefined && matchesFilter(element, status, filter) + ? [element.id] + : []; + }); + +/** A value a filter can select, and the text it is shown with. */ +export interface FilterOption { + readonly value: V; + readonly label: string; +} + +/** What a model's elements can be filtered by, beyond status. */ +export interface FilterOptions { + /** The roles and systems that are some element's actor, in declaration order. */ + readonly actors: readonly FilterOption[]; + /** The source collections of the model's bound activities, in order of first appearance. */ + readonly sources: readonly FilterOption[]; +} + +/** + * The actors and sources a model's elements can be filtered by. + * @param model - The model. + * @returns The options, each labelled for display. + */ +export const filterOptions = ( + model: Model, +): FilterOptions => { + const acting = new Set(model.elements.flatMap(actorsOf)); + const sources = new Set( + model.elements.flatMap((element) => + element.kind === "activity" && element.source !== undefined + ? [sourceScope(element.source)] + : [], + ), + ); + + return { + actors: model.participants + .filter((participant) => acting.has(participant.id)) + .map((participant) => ({ + value: participant.id, + label: participant.label, + })), + sources: [...sources].map((source) => ({ value: source, label: source })), + }; +}; + +/** A filter and the operations that change it. */ +export interface ElementFilterState { + readonly filter: ElementFilter; + /** Adds the status to the filter, or removes it when it is there. */ + readonly toggleStatus: (status: Status) => void; + /** Adds the actor to the filter, or removes it when it is there. */ + readonly toggleActor: (actor: string) => void; + /** Adds the source collection to the filter, or removes it when it is there. */ + readonly toggleSource: (source: string) => void; + /** Clears every constraint. */ + readonly clear: () => void; +} + +const toggled = (set: ReadonlySet, value: T): ReadonlySet => { + const next = new Set(set); + if (!next.delete(value)) { + next.add(value); + } + + return next; +}; + +/** + * Filter state for a view: which statuses, actors and sources to show. + * @param initial - The filter to start with. Defaults to `emptyFilter`. + * @returns The filter and its operations, each stable between renders. + */ +export const useElementFilter = ( + initial: ElementFilter = emptyFilter, +): ElementFilterState => { + const [filter, setFilter] = useState(initial); + const toggleStatus = useCallback((status: Status) => { + setFilter((current) => ({ + ...current, + statuses: toggled(current.statuses, status), + })); + }, []); + const toggleActor = useCallback((actor: string) => { + setFilter((current) => ({ + ...current, + actors: toggled(current.actors, actor), + })); + }, []); + const toggleSource = useCallback((source: string) => { + setFilter((current) => ({ + ...current, + sources: toggled(current.sources, source), + })); + }, []); + const clear = useCallback(() => { + setFilter(emptyFilter); + }, []); + + return useMemo( + () => ({ filter, toggleStatus, toggleActor, toggleSource, clear }), + [filter, toggleStatus, toggleActor, toggleSource, clear], + ); +}; diff --git a/packages/ui/graph/react/src/filter.unit.test.ts b/packages/ui/graph/react/src/filter.unit.test.ts new file mode 100644 index 0000000..193c477 --- /dev/null +++ b/packages/ui/graph/react/src/filter.unit.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "vitest"; +import { defineModel } from "@exadev/process-graph-schema"; +import { + emptyFilter, + filterElements, + filterOptions, + matchesFilter, + sourceScope, +} from "./filter.ts"; + +/** Two repositories' issues, one with an owner and one without. */ +const bound = defineModel({ + participants: [{ id: "dev", label: "Developer", kind: "role" }], + events: [], + actions: [], + artefacts: [], + elements: [ + { + kind: "activity", + id: "github:org/app#1", + label: "Build", + actor: "dev", + source: "github:org/app#1", + }, + { + kind: "activity", + id: "github:org/lib#7", + label: "Release", + source: "github:org/lib#7", + }, + { kind: "activity", id: "local", label: "Review" }, + ], + transitions: [], + fragments: [], + projections: [], +}); + +const ready = new Map( + bound.elements.map((element) => [element.id, "ready" as const]), +); + +describe("sourceScope", () => { + test("is the identifier up to its item number", () => { + expect(sourceScope("github:org/app#1")).toBe("github:org/app"); + }); + + test("is the whole identifier when it has no item number", () => { + expect(sourceScope("jira:PROJ")).toBe("jira:PROJ"); + }); +}); + +describe("filtering", () => { + test("the empty filter shows every element", () => { + expect(filterElements(bound, ready, emptyFilter)).toEqual( + bound.elements.map((element) => element.id), + ); + }); + + test("a source filter shows the work of that source only", () => { + expect( + filterElements(bound, ready, { + ...emptyFilter, + sources: new Set(["github:org/lib"]), + }), + ).toEqual(["github:org/lib#7"]); + }); + + test("an actor filter leaves out elements with no actor", () => { + expect( + filterElements(bound, ready, { + ...emptyFilter, + actors: new Set(["dev"]), + }), + ).toEqual(["github:org/app#1"]); + }); + + test("a message matches either of its ends", () => { + const message = { + kind: "message", + id: "m", + label: "Notify", + from: "dev", + to: "ops", + mode: "async", + } as const; + + expect( + matchesFilter(message, "ready", { + ...emptyFilter, + actors: new Set(["ops"]), + }), + ).toBe(true); + }); + + test("options list the acting participants and the source collections", () => { + expect(filterOptions(bound)).toEqual({ + actors: [{ value: "dev", label: "Developer" }], + sources: [ + { value: "github:org/app", label: "github:org/app" }, + { value: "github:org/lib", label: "github:org/lib" }, + ], + }); + }); +}); diff --git a/packages/ui/graph/react/src/index.ts b/packages/ui/graph/react/src/index.ts new file mode 100644 index 0000000..2b2e67f --- /dev/null +++ b/packages/ui/graph/react/src/index.ts @@ -0,0 +1,67 @@ +/** + * Headless React hooks and unstyled components over the process model. The hooks load and follow a model from a `GraphStore` client, derive statuses, the ready set, blocked chains, impact, explanations and the critical path from `@exadev/process-graph-domain`, project views with `@exadev/process-graph-projection`, and hold selection and filter state. The components render semantic markup with ARIA roles and stable `data-*` hooks, and take class names for their parts; they carry no styles. + */ +export { noFacts, type Run } from "./run.ts"; +export { useModel, type ModelClient, type ModelState } from "./model.ts"; +export { + stepWeight, + useBlockedChain, + useBlockers, + useCriticalPath, + useImpact, + useReadySet, + useStatuses, + useWhyStatus, + type BlockedChain, +} from "./analysis.ts"; +export { + project, + useProjection, + type ProjectedView, + type ViewsByNotation, +} from "./projection.ts"; +export { + actorsOf, + emptyFilter, + filterElements, + filterOptions, + matchesFilter, + sourceScope, + useElementFilter, + type ElementFilter, + type ElementFilterState, + type FilterOption, + type FilterOptions, +} from "./filter.ts"; +export { + useGraphSelection, + type GraphSelection, + type GraphSelectionOptions, + type SelectionContainerProps, + type SelectionItemProps, +} from "./selection.ts"; +export { elementLabel, ruleDescriptions, statusLabels } from "./labels.ts"; +export { StatusBadge, type StatusBadgeProps } from "./status-badge.tsx"; +export { + ElementCard, + type ElementCardPart, + type ElementCardProps, + type NamedElement, +} from "./element-card.tsx"; +export { + TransitionItem, + type TransitionItemPart, + type TransitionItemProps, +} from "./transition-item.tsx"; +export { Lane, type LanePart, type LaneProps } from "./lane.tsx"; +export { Legend, type LegendPart, type LegendProps } from "./legend.tsx"; +export { + FilterPanel, + type FilterPanelPart, + type FilterPanelProps, +} from "./filter-panel.tsx"; +export { + DetailPanel, + type DetailPanelPart, + type DetailPanelProps, +} from "./detail-panel.tsx"; diff --git a/packages/ui/graph/react/src/labels.ts b/packages/ui/graph/react/src/labels.ts new file mode 100644 index 0000000..ab18138 --- /dev/null +++ b/packages/ui/graph/react/src/labels.ts @@ -0,0 +1,45 @@ +import type { Status, StatusRule } from "@exadev/process-graph-domain"; +import type { + Element, + IdSpace, + PseudoRole, +} from "@exadev/process-graph-schema"; + +/** The text each status is shown with, so every component names a status the same way. */ +export const statusLabels: Readonly> = { + done: "Done", + ready: "Ready", + blocked: "Blocked", + skipped: "Skipped", + unknown: "Unknown", +}; + +/** One sentence per status rule, saying why an element has the status that rule decided. */ +export const ruleDescriptions: Readonly> = { + "overlay-done": "The run reports it done.", + "overlay-unknown": "The run reports its state as unknown.", + "unknown-need": + "Something it needs has an unknown state or an undecided condition.", + "all-cut": "Every path into it was cut, so it will not run.", + "needs-met": "Everything it needs is done.", + waiting: "It is still waiting on unfinished work.", +}; + +/** The labels the projections give pseudo-states, which carry no label of their own. */ +const pseudoLabels: Readonly> = { + initial: "Start", + final: "End", + fork: "Fork", + join: "Join", + history: "History", +}; + +/** + * The text an element is shown with: its own label, or for a pseudo-state the name of its role. + * @param element - The element to name. + * @returns The element's display label. + */ +export const elementLabel = ( + element: Element, +): string => + element.kind === "pseudo" ? pseudoLabels[element.role] : element.label; diff --git a/packages/ui/graph/react/src/lane.tsx b/packages/ui/graph/react/src/lane.tsx new file mode 100644 index 0000000..dc4a49b --- /dev/null +++ b/packages/ui/graph/react/src/lane.tsx @@ -0,0 +1,39 @@ +import { useId, type ReactElement, type ReactNode } from "react"; + +/** The parts of a lane a class can be given to. */ +export type LanePart = "root" | "heading" | "body"; + +export interface LaneProps { + /** The participant the lane belongs to. */ + readonly participant: string; + readonly label: string; + readonly children?: ReactNode; + readonly classNames?: Partial> | undefined; +} + +/** + * A swimlane: a group named by its heading, holding the work of one participant. The group carries `data-lane` with the participant's id. + * @param props - The participant, its label and the lane's content. + * @returns The lane. + */ +export const Lane = (props: LaneProps): ReactElement => { + const { participant, label, children, classNames = {} } = props; + + const heading = useId(); + + return ( +
+

+ {label} +

+
+ {children} +
+
+ ); +}; diff --git a/packages/ui/graph/react/src/legend.tsx b/packages/ui/graph/react/src/legend.tsx new file mode 100644 index 0000000..9bc8d73 --- /dev/null +++ b/packages/ui/graph/react/src/legend.tsx @@ -0,0 +1,58 @@ +import { statuses, type Status } from "@exadev/process-graph-domain"; +import type { ReactElement } from "react"; +import { statusLabels } from "./labels.ts"; + +/** The parts of a legend a class can be given to. */ +export type LegendPart = "root" | "item" | "swatch" | "label"; + +export interface LegendProps { + /** The statuses to explain. Defaults to every status. */ + readonly statuses?: readonly Status[] | undefined; + /** Whether to explain the critical path marking too. */ + readonly critical?: boolean | undefined; + readonly classNames?: Partial> | undefined; +} + +/** + * A key to the statuses a view colours: a labelled list with a swatch per status. Each item and its swatch carry `data-status`, or `data-critical` for the critical path entry, so a stylesheet colours the swatch by attribute. + * @param props - The statuses to list and the classes to give its parts. + * @returns The legend. + */ +export const Legend = (props: LegendProps): ReactElement => { + const { + statuses: shown = statuses, + critical = false, + classNames = {}, + } = props; + + return ( +
    + {shown.map((status) => ( +
  • +
  • + ))} + {critical ? ( +
  • +
  • + ) : null} +
+ ); +}; diff --git a/packages/ui/graph/react/src/model.ts b/packages/ui/graph/react/src/model.ts new file mode 100644 index 0000000..fb12b1b --- /dev/null +++ b/packages/ui/graph/react/src/model.ts @@ -0,0 +1,73 @@ +import type { + GraphStoreClient, + StoredModel, +} from "@exadev/process-graph-store-contract"; +import { useEffect, useState } from "react"; + +/** Where a model being followed from a store stands. */ +export type ModelState = + | { readonly status: "loading" } + | { readonly status: "loaded"; readonly stored: StoredModel } + | { readonly status: "failed"; readonly error: unknown }; + +/** The part of the `GraphStore` client `useModel` calls: loading a model and following its changes. */ +export interface ModelClient { + readonly v1: { + readonly models: Pick; + readonly changes: GraphStoreClient["v1"]["changes"]; + }; +} + +const loading: ModelState = { status: "loading" }; + +/** + * Loads a model from a store and follows it: every change the store reports loads the model again, so the result always reflects the store's latest revision. It subscribes before the first load, so no change between the two is missed. + * @param client - A `GraphStore` client, over any transport. Keep it stable between renders. + * @param modelId - The model to follow. + * @returns `loading` until the first load, then `loaded` with the model, its overlay and revision, or `failed` with the error a load or the subscription ended with (a model that does not exist, or was deleted, fails with the store's `NOT_FOUND`). + */ +export const useModel = (client: ModelClient, modelId: string): ModelState => { + const [state, setState] = useState<{ + readonly client: ModelClient; + readonly modelId: string; + readonly value: ModelState; + }>({ client, modelId, value: loading }); + + useEffect(() => { + const controller = new AbortController(); + const { signal } = controller; + const settle = (value: ModelState): void => { + setState({ client, modelId, value }); + }; + const load = async (): Promise => { + const stored = await client.v1.models.load({ modelId }, { signal }); + settle({ status: "loaded", stored }); + + return stored.revision; + }; + const follow = async (): Promise => { + const changes = await client.v1.changes({ modelId }, { signal }); + let loaded = await load(); + for await (const change of changes) { + // The subscription opened before the first load, so it can report a change that load already saw; revisions only grow, so anything newer than the loaded revision is not yet shown. `missed` carries no revision and always loads again. A deleted model's load fails with NOT_FOUND, which ends the subscription with the state to show. + if (change.kind === "missed" || change.revision > loaded) { + loaded = await load(); + } + } + }; + follow().catch((error: unknown) => { + // An abort is this effect being cleaned up, not a failure of the model. + if (!signal.aborted) { + settle({ status: "failed", error }); + } + }); + + return () => { + controller.abort(); + }; + }, [client, modelId]); + + return state.client === client && state.modelId === modelId + ? state.value + : loading; +}; diff --git a/packages/ui/graph/react/src/model.unit.test.tsx b/packages/ui/graph/react/src/model.unit.test.tsx new file mode 100644 index 0000000..69eae8a --- /dev/null +++ b/packages/ui/graph/react/src/model.unit.test.tsx @@ -0,0 +1,103 @@ +import { + createFakeGraphStore, + createGraphStoreRouter, +} from "@exadev/process-graph-store-contract"; +import { createRouterClient } from "@orpc/server"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { useModel } from "./model.ts"; +import { order } from "./test-support/order.ts"; + +afterEach(cleanup); + +const storeWithOrder = async () => { + const client = createRouterClient( + createGraphStoreRouter(createFakeGraphStore()), + ); + await client.v1.models.save({ modelId: "order", model: order }); + + return client; +}; + +describe("useModel", () => { + test("loads the model with its overlay and revision", async () => { + const client = await storeWithOrder(); + const { result } = renderHook(() => useModel(client, "order")); + + expect(result.current).toEqual({ status: "loading" }); + await waitFor(() => { + expect(result.current.status).toBe("loaded"); + }); + if (result.current.status !== "loaded") { + throw new Error("expected a loaded model"); + } + expect(result.current.stored.model.elements.map((e) => e.id)).toEqual( + order.elements.map((e) => e.id), + ); + expect(result.current.stored.overlay).toEqual({}); + }); + + test("follows later changes to the model's run state", async () => { + const client = await storeWithOrder(); + const { result } = renderHook(() => useModel(client, "order")); + await waitFor(() => { + expect(result.current.status).toBe("loaded"); + }); + + const { revision } = await client.v1.overlay.set({ + modelId: "order", + overlay: { "a-submit": "done" }, + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + status: "loaded", + stored: { revision, overlay: { "a-submit": "done" } }, + }); + }); + }); + + test("fails when the model is deleted", async () => { + const client = await storeWithOrder(); + const { result } = renderHook(() => useModel(client, "order")); + await waitFor(() => { + expect(result.current.status).toBe("loaded"); + }); + + await client.v1.models.delete({ modelId: "order" }); + + await waitFor(() => { + expect(result.current.status).toBe("failed"); + }); + }); + + test("fails for a model the store does not have", async () => { + const client = await storeWithOrder(); + const { result } = renderHook(() => useModel(client, "missing")); + + await waitFor(() => { + expect(result.current.status).toBe("failed"); + }); + }); + + test("starts loading again when the model id changes", async () => { + const client = await storeWithOrder(); + await client.v1.models.save({ modelId: "copy", model: order }); + const { result, rerender } = renderHook( + ({ modelId }) => useModel(client, modelId), + { initialProps: { modelId: "order" } }, + ); + await waitFor(() => { + expect(result.current.status).toBe("loaded"); + }); + + rerender({ modelId: "copy" }); + expect(result.current).toEqual({ status: "loading" }); + await waitFor(() => { + expect(result.current).toMatchObject({ + status: "loaded", + stored: { modelId: "copy" }, + }); + }); + }); +}); diff --git a/packages/ui/graph/react/src/projection.ts b/packages/ui/graph/react/src/projection.ts new file mode 100644 index 0000000..ebdacad --- /dev/null +++ b/packages/ui/graph/react/src/projection.ts @@ -0,0 +1,70 @@ +import { + toFlowchart, + toSequence, + toStateMachine, + toSwimlane, + type Flowchart, + type SequenceDiagram, + type StateMachine, + type Swimlane, +} from "@exadev/process-graph-projection"; +import type { IdSpace, Model, Projection } from "@exadev/process-graph-schema"; +import { useMemo } from "react"; + +/** The view each projection notation produces. */ +export interface ViewsByNotation { + readonly flowchart: Flowchart; + readonly swimlane: Swimlane; + readonly stateMachine: StateMachine; + readonly sequence: SequenceDiagram; +} + +/** A projection request together with the view it produced, discriminated by notation. */ +export type ProjectedView = { + readonly [N in keyof ViewsByNotation]: { + readonly notation: N; + readonly view: ViewsByNotation[N]; + }; +}[keyof ViewsByNotation]; + +/** + * Projects a model into the notation a projection names, with the input it records (a subject, a scenario). + * @param model - The model to project. + * @param projection - One of the model's declared projections, or one built the same way. + * @returns The notation and its view. + */ +export const project = ( + model: Model, + projection: Projection, +): ProjectedView => { + if (projection.notation === "stateMachine") { + return { + notation: "stateMachine", + view: toStateMachine(model, projection.subject), + }; + } + if (projection.notation === "sequence") { + return { + notation: "sequence", + view: toSequence(model, projection.scenario), + }; + } + + // A flowchart and a swimlane share one variant, which records nothing beyond its notation. + return projection.notation === "swimlane" + ? { notation: "swimlane", view: toSwimlane(model) } + : { notation: "flowchart", view: toFlowchart(model) }; +}; + +/** + * A model's view in one notation, projected again only when the model or the projection changes. + * @param model - The model to project. + * @param projection - The projection to draw. Keep it stable between renders, for instance one of `model.projections`. + * @returns The notation and its view. + * @throws Error when a state machine's subject is not a declared entity. + */ +export const useProjection = ( + model: Model, + projection: Projection, +): ProjectedView => + useMemo(() => project(model, projection), [model, projection]); diff --git a/packages/ui/graph/react/src/projection.unit.test.tsx b/packages/ui/graph/react/src/projection.unit.test.tsx new file mode 100644 index 0000000..8e3470e --- /dev/null +++ b/packages/ui/graph/react/src/projection.unit.test.tsx @@ -0,0 +1,64 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { useProjection } from "./projection.ts"; +import { order } from "./test-support/order.ts"; + +describe("useProjection", () => { + test("projects each of the model's declared projections into its notation", () => { + const notations = order.projections.map((projection) => { + const { result } = renderHook(() => useProjection(order, projection)); + + return result.current.notation; + }); + + expect(notations).toEqual(order.projections.map((p) => p.notation)); + }); + + test("a swimlane carries its lanes and hand-offs", () => { + const { result } = renderHook(() => + useProjection(order, { id: "lanes", notation: "swimlane" }), + ); + if (result.current.notation !== "swimlane") { + throw new Error("expected a swimlane"); + } + + expect(result.current.view.lanes.map((lane) => lane.label)).toEqual([ + "Customer", + "Shop", + "Payment provider", + ]); + expect( + result.current.view.handoffs.map((handoff) => handoff.transition), + ).toEqual(["t2", "t3", "t4"]); + }); + + test("a sequence follows the projection's scenario", () => { + const { result } = renderHook(() => + useProjection(order, { + id: "seq", + notation: "sequence", + scenario: ["t2", "t3"], + }), + ); + if (result.current.notation !== "sequence") { + throw new Error("expected a sequence diagram"); + } + + expect(result.current.view.lifelines).toEqual([ + "customer", + "shop", + "provider", + ]); + }); + + test("keeps the same view while its inputs are unchanged", () => { + const projection = { id: "flow", notation: "flowchart" } as const; + const { result, rerender } = renderHook(() => + useProjection(order, projection), + ); + const first = result.current; + rerender(); + + expect(result.current).toBe(first); + }); +}); diff --git a/packages/ui/graph/react/src/run.ts b/packages/ui/graph/react/src/run.ts new file mode 100644 index 0000000..b2bb82b --- /dev/null +++ b/packages/ui/graph/react/src/run.ts @@ -0,0 +1,17 @@ +import type { Facts } from "@exadev/process-graph-domain"; +import type { IdSpace, Model, Overlay } from "@exadev/process-graph-schema"; + +/** + * Everything the reasoning hooks read: the model, its run overlay and the injected source of facts that conditions and provided artefacts are answered from. Keep the three stable between renders (from state, a store or `useMemo`), since the hooks recompute whenever one of them changes identity. + */ +export interface Run { + readonly model: Model; + readonly overlay: Readonly>; + readonly facts: Facts; +} + +/** + * The facts port of a run that has no facts source: every key is not found, so a condition that reads one is indeterminate and no input artefact counts as provided. + * @returns `{ found: false }` for every key. + */ +export const noFacts: Facts = () => ({ found: false }); diff --git a/packages/ui/graph/react/src/selection.ts b/packages/ui/graph/react/src/selection.ts new file mode 100644 index 0000000..900a0df --- /dev/null +++ b/packages/ui/graph/react/src/selection.ts @@ -0,0 +1,138 @@ +import { + useCallback, + useMemo, + useRef, + useState, + type KeyboardEvent, + type RefCallback, +} from "react"; + +/** Props for the element that holds the selectable items: a listbox that moves the selection with the keyboard. */ +export interface SelectionContainerProps { + readonly role: "listbox"; + readonly onKeyDown: (event: KeyboardEvent) => void; +} + +/** Props for one selectable item: an option with a roving tab stop, selected by a click. */ +export interface SelectionItemProps { + readonly role: "option"; + readonly ref: RefCallback; + readonly tabIndex: 0 | -1; + readonly "aria-selected": boolean; + readonly onClick: () => void; +} + +/** The selected element of a view, how to change it, and the props that make a set of items keyboard navigable. */ +export interface GraphSelection { + /** The selected element, or `undefined` when none is or the selected one is no longer in `order`. */ + readonly selected: Id | undefined; + readonly select: (id: Id | undefined) => void; + readonly containerProps: SelectionContainerProps; + /** The props for the item of one element, to spread on it. */ + readonly itemProps: (id: Id) => SelectionItemProps; +} + +/** How a selection starts and what it tells its owner. */ +export interface GraphSelectionOptions { + /** The element selected at first. */ + readonly initial?: Id | undefined; + /** Called with each new selection a click or key makes, so a parent can follow it. */ + readonly onChange?: ((id: Id | undefined) => void) | undefined; + /** Focuses the item of an element after a keyboard move. Defaults to focusing the element `itemProps` was spread on; a renderer that draws its own focusable items, such as a canvas, passes its own. */ + readonly focus?: ((id: Id) => void) | undefined; +} + +/** What each navigation key does, given the items in order and the index of the selected one (-1 when none is). */ +const moves: Readonly< + Record number | undefined> +> = { + ArrowDown: (count, at) => (at + 1) % count, + ArrowRight: (count, at) => (at + 1) % count, + ArrowUp: (count, at) => (at <= 0 ? count : at) - 1, + ArrowLeft: (count, at) => (at <= 0 ? count : at) - 1, + Home: () => 0, + End: (count) => count - 1, + Escape: () => undefined, +}; + +/** + * Selection state with keyboard navigation over the elements of a view, following the listbox pattern of the WAI-ARIA Authoring Practices: the arrow keys move the selection to the next or previous element in `order`, wrapping at either end; Home and End move to the first and last; Escape clears it. Only one item is a tab stop: the selected one, or the first when none is. A keyboard move also focuses the item it selects. + * @param order - The navigable elements, in the order the keys move through them: the elements on show, after filtering. + * @param options - The initial selection, a listener for changes and how to focus an item. + * @returns The selection, a setter, and the container and item props. + */ +export const useGraphSelection = ( + order: readonly Id[], + options: GraphSelectionOptions = {}, +): GraphSelection => { + const { initial, onChange, focus } = options; + const [chosen, setChosen] = useState(initial); + const items = useRef(new Map()); + const select = useCallback( + (id: Id | undefined) => { + setChosen(id); + onChange?.(id); + }, + [onChange], + ); + const selected = + chosen !== undefined && order.includes(chosen) ? chosen : undefined; + const stop = selected ?? order[0]; + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + const move = moves[event.key]; + if (move === undefined || order.length === 0) { + return; + } + event.preventDefault(); + const index = move( + order.length, + selected === undefined ? -1 : order.indexOf(selected), + ); + const next = index === undefined ? undefined : order[index]; + select(next); + if (next === undefined) { + return; + } + if (focus === undefined) { + // An item rendered without its props has no element to focus. + items.current.get(next)?.focus(); + } else { + focus(next); + } + }, + [order, selected, select, focus], + ); + + const itemProps = useCallback( + (id: Id): SelectionItemProps => ({ + role: "option", + ref: (element) => { + if (element !== null) { + items.current.set(id, element); + } + + return () => { + items.current.delete(id); + }; + }, + tabIndex: id === stop ? 0 : -1, + "aria-selected": id === selected, + onClick: () => { + select(id); + }, + }), + [selected, stop, select], + ); + + return useMemo( + () => ({ + selected, + select, + containerProps: { role: "listbox", onKeyDown }, + itemProps, + }), + [selected, select, onKeyDown, itemProps], + ); +}; diff --git a/packages/ui/graph/react/src/selection.unit.test.tsx b/packages/ui/graph/react/src/selection.unit.test.tsx new file mode 100644 index 0000000..08a08b4 --- /dev/null +++ b/packages/ui/graph/react/src/selection.unit.test.tsx @@ -0,0 +1,56 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { useGraphSelection, type GraphSelectionOptions } from "./selection.ts"; + +afterEach(cleanup); + +const order = ["first", "second", "third"]; + +/** A list whose items are drawn without `itemProps`, as a canvas that renders its own items would. */ +const Canvas = (props: GraphSelectionOptions): ReactElement => { + const { selected, containerProps } = useGraphSelection(order, props); + + return ( +
+ {selected ?? "none"} +
+ ); +}; + +describe("useGraphSelection options", () => { + test("starts from the initial selection", () => { + render(); + + expect(screen.getByRole("status").textContent).toBe("second"); + }); + + test("tells onChange of each selection a key makes", () => { + const onChange = vi.fn<(id: string | undefined) => void>(); + render(); + + fireEvent.keyDown(screen.getByRole("listbox"), { key: "End" }); + fireEvent.keyDown(screen.getByRole("listbox"), { key: "Escape" }); + + expect(onChange.mock.calls).toEqual([["third"], [undefined]]); + }); + + test("hands a keyboard move to the focus function in place of the items' own focus", () => { + const focus = vi.fn<(id: string) => void>(); + render(); + + fireEvent.keyDown(screen.getByRole("listbox"), { key: "ArrowDown" }); + fireEvent.keyDown(screen.getByRole("listbox"), { key: "ArrowDown" }); + + expect(focus.mock.calls).toEqual([["first"], ["second"]]); + }); + + test("ignores keys it does not navigate with", () => { + const onChange = vi.fn<(id: string | undefined) => void>(); + render(); + + fireEvent.keyDown(screen.getByRole("listbox"), { key: "a" }); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/graph/react/src/status-badge.tsx b/packages/ui/graph/react/src/status-badge.tsx new file mode 100644 index 0000000..4034587 --- /dev/null +++ b/packages/ui/graph/react/src/status-badge.tsx @@ -0,0 +1,23 @@ +import type { Status } from "@exadev/process-graph-domain"; +import type { ReactElement } from "react"; +import { statusLabels } from "./labels.ts"; + +export interface StatusBadgeProps { + readonly status: Status; + readonly className?: string | undefined; +} + +/** + * A status as text, carrying the status in `data-status` so a stylesheet can colour it. + * @param props - The status and an optional class. + * @returns A `span` naming the status. + */ +export const StatusBadge = (props: StatusBadgeProps): ReactElement => { + const { status, className } = props; + + return ( + + {statusLabels[status]} + + ); +}; diff --git a/packages/ui/graph/react/src/test-support/board.tsx b/packages/ui/graph/react/src/test-support/board.tsx new file mode 100644 index 0000000..4a4bcfc --- /dev/null +++ b/packages/ui/graph/react/src/test-support/board.tsx @@ -0,0 +1,152 @@ +import type { Overlay } from "@exadev/process-graph-schema"; +import type { ReactElement } from "react"; +import { + useBlockedChain, + useBlockers, + useCriticalPath, + useImpact, + useStatuses, + useWhyStatus, +} from "../analysis.ts"; +import { DetailPanel } from "../detail-panel.tsx"; +import { ElementCard } from "../element-card.tsx"; +import { FilterPanel } from "../filter-panel.tsx"; +import { filterElements, filterOptions, useElementFilter } from "../filter.ts"; +import { elementLabel } from "../labels.ts"; +import { Lane } from "../lane.tsx"; +import { Legend } from "../legend.tsx"; +import { useProjection } from "../projection.ts"; +import { noFacts } from "../run.ts"; +import { useGraphSelection } from "../selection.ts"; +import { TransitionItem } from "../transition-item.tsx"; +import type { OrderIds } from "./ids.ts"; +import { order } from "./order.ts"; + +type OrderId = OrderIds["element"]; + +const elementOf = (id: OrderId) => { + const element = order.elements.find((candidate) => candidate.id === id); + if (element === undefined) { + throw new Error(`no element ${id}`); + } + + return element; +}; + +const labelOf = (id: OrderId): string => elementLabel(elementOf(id)); + +const transitionOf = (id: OrderIds["transition"]) => { + const transition = order.transitions.find((candidate) => candidate.id === id); + if (transition === undefined) { + throw new Error(`no transition ${id}`); + } + + return transition; +}; + +/** The order model's declared swimlane projection. */ +const swimlane = { id: "p-lanes", notation: "swimlane" } as const; + +interface BoardProps { + readonly overlay: Overlay; +} + +/** + * A consumer of every hook and component, as an application would write one: the order process as swimlanes of element cards, its hand-offs as a list, a filter panel, a legend and the selected element's details. + * @param props - The run overlay. + * @returns The board. + */ +export const Board = (props: BoardProps): ReactElement => { + const { overlay } = props; + const run = { model: order, overlay, facts: noFacts }; + const statuses = useStatuses(run); + const blockers = useBlockers(run); + const critical = new Set(useCriticalPath(run).path); + const { filter, toggleStatus, toggleActor, toggleSource, clear } = + useElementFilter(); + const shown = filterElements(order, statuses, filter); + const projected = useProjection(order, swimlane); + if (projected.notation !== "swimlane") { + throw new Error("expected a swimlane"); + } + const { lanes, unassigned, handoffs } = projected.view; + // The keys move through the cards in the order they are drawn: lane by lane, then the elements no lane holds. + const drawn = [...lanes.flatMap((lane) => lane.nodes), ...unassigned].filter( + (id) => shown.includes(id), + ); + const selection = useGraphSelection(drawn); + const explanation = useWhyStatus(run, selection.selected); + const chain = useBlockedChain(run, selection.selected); + const impacted = useImpact(run, selection.selected); + + const card = (id: OrderId): ReactElement | null => { + const status = statuses.get(id); + + return status === undefined || !shown.includes(id) ? null : ( + ({ + id: blocker, + label: labelOf(blocker), + }))} + item={selection.itemProps(id)} + /> + ); + }; + + return ( +
+ + +
+ {lanes.map((lane) => ( + + {lane.nodes.map(card)} + + ))} +
+ {unassigned.map(card)} +
+
+
    + {handoffs.map((handoff) => { + const transition = transitionOf(handoff.transition); + + return ( + + ); + })} +
+ {selection.selected === undefined || explanation === undefined ? null : ( + + )} +
+ ); +}; diff --git a/packages/ui/graph/react/src/test-support/ids.ts b/packages/ui/graph/react/src/test-support/ids.ts new file mode 100644 index 0000000..5a5d7a8 --- /dev/null +++ b/packages/ui/graph/react/src/test-support/ids.ts @@ -0,0 +1,5 @@ +import type { Model } from "@exadev/process-graph-schema"; +import type { order } from "./order.ts"; + +/** The identifier spaces of the order fixture. */ +export type OrderIds = typeof order extends Model ? I : never; diff --git a/packages/ui/graph/react/src/test-support/order.ts b/packages/ui/graph/react/src/test-support/order.ts new file mode 100644 index 0000000..b5eb9d1 --- /dev/null +++ b/packages/ui/graph/react/src/test-support/order.ts @@ -0,0 +1,101 @@ +import { defineModel } from "@exadev/process-graph-schema"; + +/** The order process from the doc, with every reference checked at compile time. */ +export const order = defineModel({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + { id: "provider", label: "Payment provider", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [ + { id: "ev-submitted", name: "order submitted" }, + { id: "ev-authorised", name: "payment authorised" }, + { id: "ev-declined", name: "payment declined" }, + ], + actions: [ + { id: "act-request", name: "request payment" }, + { id: "act-pack", name: "pack and ship" }, + { id: "act-cancel", name: "cancel order and notify" }, + ], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { + kind: "activity", + id: "a-submit", + label: "Submit order", + actor: "customer", + }, + { + kind: "activity", + id: "a-request", + label: "Request payment", + actor: "shop", + action: "act-request", + }, + { + kind: "activity", + id: "a-authorise", + label: "Authorise or decline", + actor: "provider", + }, + { + kind: "decision", + id: "d-authorised", + label: "Payment authorised?", + actor: "shop", + }, + { + kind: "activity", + id: "a-pack", + label: "Pack and ship", + actor: "shop", + action: "act-pack", + }, + { + kind: "activity", + id: "a-cancel", + label: "Cancel order and notify", + actor: "shop", + action: "act-cancel", + }, + { kind: "pseudo", id: "end", role: "final" }, + ], + transitions: [ + { id: "t1", source: "start", target: "a-submit", trigger: "ev-submitted" }, + { id: "t2", source: "a-submit", target: "a-request" }, + { id: "t3", source: "a-request", target: "a-authorise" }, + { id: "t4", source: "a-authorise", target: "d-authorised" }, + { + id: "t5", + source: "d-authorised", + target: "a-pack", + guard: "authorised", + trigger: "ev-authorised", + }, + { + id: "t6", + source: "d-authorised", + target: "a-cancel", + guard: "declined", + trigger: "ev-declined", + }, + { id: "t7", source: "a-pack", target: "end" }, + { id: "t8", source: "a-cancel", target: "end" }, + ], + fragments: [], + projections: [ + { id: "p-flow", notation: "flowchart" }, + { id: "p-lanes", notation: "swimlane" }, + { id: "p-state", notation: "stateMachine", subject: "order" }, + { + id: "p-seq", + notation: "sequence", + scenario: ["t1", "t2", "t3", "t4", "t5", "t7"], + }, + ], + views: { + flowchart: { "a-request": { x: 306, y: 100 } }, + }, +}); diff --git a/packages/ui/graph/react/src/transition-item.tsx b/packages/ui/graph/react/src/transition-item.tsx new file mode 100644 index 0000000..bd3ccbe --- /dev/null +++ b/packages/ui/graph/react/src/transition-item.tsx @@ -0,0 +1,61 @@ +import type { ReactElement } from "react"; + +/** The parts of a transition item a class can be given to. */ +export type TransitionItemPart = "root" | "from" | "to" | "label"; + +export interface TransitionItemProps { + /** The transition, or for an edge standing for several, the first. */ + readonly id: string; + /** The source element's display label. */ + readonly from: string; + /** The target element's display label. */ + readonly to: string; + /** The guard or message label, when there is one. */ + readonly label?: string | undefined; + /** Whether the transition passes work between actors. */ + readonly handoff?: boolean | undefined; + /** Whether both ends are on the critical path. */ + readonly critical?: boolean | undefined; + readonly classNames?: Partial> | undefined; +} + +/** + * One transition as a list item: where it comes from, where it goes and its label. The item carries `data-transition-id` and, as applicable, `data-handoff` and `data-critical`; a hand-off says so in its text. + * @param props - The transition's ends and label. + * @returns An `li`, to place in a list. + */ +export const TransitionItem = (props: TransitionItemProps): ReactElement => { + const { + id, + from, + to, + label, + handoff = false, + critical = false, + classNames = {}, + } = props; + + return ( +
  • + + {from} + {" "} + to{" "} + + {to} + + {label === undefined ? null : ( + + {" "} + ({label}) + + )} + {handoff ? " (hand-off)" : null} +
  • + ); +}; diff --git a/packages/ui/graph/react/stryker.config.ts b/packages/ui/graph/react/stryker.config.ts new file mode 100644 index 0000000..75d17b7 --- /dev/null +++ b/packages/ui/graph/react/stryker.config.ts @@ -0,0 +1,5 @@ +import { packageStrykerConfig } from "../../../../stryker.shared.ts"; + +export default packageStrykerConfig({ + additionalMutate: ["src/**/*.tsx", "!src/**/*.test.tsx"], +}); diff --git a/packages/ui/graph/react/test/package.smoke.test.tsx b/packages/ui/graph/react/test/package.smoke.test.tsx new file mode 100644 index 0000000..0949419 --- /dev/null +++ b/packages/ui/graph/react/test/package.smoke.test.tsx @@ -0,0 +1,37 @@ +import { defineModel } from "@exadev/process-graph-schema"; +import { cleanup, render, renderHook, screen } from "@testing-library/react"; +import { afterEach, expect, test } from "vitest"; +// Deliberately imports the built dist/, not src/: this is the check that catches a wrong exports map or a build misconfiguration that a unit test importing straight from src/ never touches. +import { noFacts, StatusBadge, useReadySet } from "../dist/index.js"; + +afterEach(cleanup); + +const model = defineModel({ + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "first", label: "First" }, + { kind: "activity", id: "second", label: "Second" }, + ], + transitions: [{ id: "t1", source: "first", target: "second" }], + fragments: [], + projections: [], +}); + +test("the built package derives the ready set", () => { + const { result } = renderHook(() => + useReadySet({ model, overlay: { first: "done" }, facts: noFacts }), + ); + + expect(result.current).toEqual(["second"]); +}); + +test("the built package renders a status badge", () => { + render(); + + expect(screen.getByText("Blocked").getAttribute("data-status")).toBe( + "blocked", + ); +}); diff --git a/packages/ui/graph/react/tsconfig.build.json b/packages/ui/graph/react/tsconfig.build.json new file mode 100644 index 0000000..54dd039 --- /dev/null +++ b/packages/ui/graph/react/tsconfig.build.json @@ -0,0 +1,26 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "declarationMap": false, + "rootDir": "src", + "outDir": "dist", + "rewriteRelativeImportExtensions": true + }, + "references": [ + { + "path": "../../../core/graph/schema/tsconfig.build.json" + }, + { + "path": "../../../core/graph/projection/tsconfig.build.json" + }, + { + "path": "../../../core/graph/domain/tsconfig.build.json" + }, + { + "path": "../../../core/graph/store-contract/tsconfig.build.json" + } + ] +} diff --git a/packages/ui/graph/react/tsconfig.json b/packages/ui/graph/react/tsconfig.json new file mode 100644 index 0000000..6bc4fe3 --- /dev/null +++ b/packages/ui/graph/react/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": [], + "jsx": "react-jsx" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test-support/**"] +} diff --git a/packages/ui/graph/react/tsconfig.node.json b/packages/ui/graph/react/tsconfig.node.json new file mode 100644 index 0000000..287ce37 --- /dev/null +++ b/packages/ui/graph/react/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["node"], + "jsx": "react-jsx" + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "test/**/*.ts", + "test/**/*.tsx", + "*.config.ts" + ] +} diff --git a/packages/ui/graph/react/vitest.config.ts b/packages/ui/graph/react/vitest.config.ts new file mode 100644 index 0000000..6aea9d4 --- /dev/null +++ b/packages/ui/graph/react/vitest.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "vitest/config"; + +/** + * happy-dom, as the owner's other React packages use: the tests render components and query them with Testing Library, and the smoke test renders the built `dist/` the same way. + */ +export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { + name: "unit", + include: ["src/**/*.test.{ts,tsx}"], + environment: "happy-dom", + }, + }, + { + extends: true, + test: { + name: "smoke", + include: ["test/package.smoke.test.tsx"], + environment: "happy-dom", + }, + }, + ], + }, +}); diff --git a/packages/ui/graph/ui/AGENTS.md b/packages/ui/graph/ui/AGENTS.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/ui/graph/ui/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/ui/graph/ui/CLAUDE.md b/packages/ui/graph/ui/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/ui/graph/ui/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/ui/graph/ui/README.md b/packages/ui/graph/ui/README.md new file mode 100644 index 0000000..762412a --- /dev/null +++ b/packages/ui/graph/ui/README.md @@ -0,0 +1,66 @@ +# @exadev/process-graph-ui + +[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/process-graph) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/process-graph/ci.yml?branch=main)](https://github.com/ExaDev/process-graph/actions) + +Styled React components for the process model, over the headless `@exadev/process-graph-react`, and an SVG sequence diagram. Styles are written in [Vanilla Extract](https://vanilla-extract.style) and compiled when this package builds: `dist/index.js` holds plain class names and `dist/style.css` every rule, so a consumer imports the stylesheet once and needs no build plugin. A private workspace package, published as part of `process-graph`. `src/` imports nothing Node-only. + +```tsx +import "@exadev/process-graph-ui/style.css"; +import { ElementCard, GraphTheme, Legend } from "@exadev/process-graph-ui"; + + + + +; +``` + +## Theme + +`vars` is the theme contract (`createThemeContract`): one CSS custom property for each value in `ThemeValues` (colours, each status's colour, spacing, radii, type and stroke widths). Every style reads the contract and nothing else. `lightTheme` and `darkTheme` are the classes that assign `lightValues` and `darkValues`; `GraphTheme` puts one of them on a `div` with `surfaceRoot`, which sets the page colours and type the components inherit. + +A consumer restyles the components without forking them by assigning the contract itself, in its own Vanilla Extract build: + +```ts +import { createTheme } from "@vanilla-extract/css"; +import { vars, lightValues } from "@exadev/process-graph-ui"; + +export const brandTheme = createTheme(vars, { + ...lightValues, + colour: { ...lightValues.colour, accent: "#7b2cbf" }, +}); +``` + +Each status colour reaches the WCAG 2.2 minimum text contrast (success criterion 1.4.3) on both the background and a surface, in both themes, and every status has its own colour; the tests compute this from the rendered themes. A status is never shown by colour alone: badges, cards and legend entries name it. + +## Components + +| Component | What it is | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `GraphTheme` | Applies `light` or `dark` to its content. | +| `StatusBadge` | The status's name in its colour, outlined. | +| `ElementCard` | A card whose leading edge carries the status colour, shaped by the element's kind, ringed on the critical path, outlined when selected. | +| `TransitionItem` | A transition, in the hand-off colour when it passes work between actors and the critical colour on the critical path. | +| `Lane` | A bordered swimlane with a small heading. | +| `Legend` | A swatch per status in its colour, and the critical path. | +| `FilterPanel` | Bordered checkbox groups for status, actor and source. | +| `DetailPanel` | The selected element's status, reason, blockers and impact on a raised surface. | +| `SequenceDiagram` | A sequence diagram from `toSequence`, drawn as SVG. | + +The styled components take the same props as their headless counterparts in `@exadev/process-graph-react`, without `classNames`, and render the same markup, roles and `data-*` attributes. `badge` and `card` are the Vanilla Extract recipes behind the badge and the card, for a renderer that draws them elsewhere, such as a React Flow node. `visuallyHidden` and `focusRing` are the shared utility classes. + +## Sequence diagram + +`SequenceDiagram` draws a lifeline per actor in order of first appearance and a message per row: an arrow between lifelines, a loop for a self-message, an open head for an asynchronous message and a dashed line for a reply. Each fragment is a frame with its operator and the guard of each operand, nested fragments inset within it. The arrangement follows the diagram's own order, since React Flow has no lifeline layout. The drawing is hidden from assistive technology, which reads the same steps from a visually hidden nested list, and the figure is named by its `title`. + +## Building + +`pnpm build` runs `vite build` with `@vanilla-extract/vite-plugin` in library mode, which writes `dist/index.js` and `dist/style.css`, then `tsc -b` for the declarations. Vite empties `dist/` first, so the build info `tsc -b` keeps is written inside `dist/` too: emptying it makes the declarations build again. Every bare import stays an import. The unit tests run the same plugin with CSS processing on, so they render the real class names against the real rules. + +## Commands + +```sh +pnpm build # vite build (JavaScript and CSS) and tsc -b (declarations) -> dist/ +pnpm test # unit tests in happy-dom +pnpm test:smoke # the built dist/ and its stylesheet rendered as a real artefact +pnpm test:mutation # Stryker +``` diff --git a/packages/ui/graph/ui/eslint.config.ts b/packages/ui/graph/ui/eslint.config.ts new file mode 100644 index 0000000..5b6a264 --- /dev/null +++ b/packages/ui/graph/ui/eslint.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "eslint/config"; +import { version } from "react"; +import { packageLintConfig } from "../../../../eslint.shared.ts"; + +/** + * A browser React library: runtime `src/` imports nothing Node-only, browser globals are in scope, and `.tsx` files get the React, hooks and accessibility rules for the React this package installs. + */ +export default defineConfig( + packageLintConfig({ + tsconfigRootDir: import.meta.dirname, + isomorphic: true, + browserGlobals: true, + react: { version }, + }), +); diff --git a/packages/ui/graph/ui/package.json b/packages/ui/graph/ui/package.json new file mode 100644 index 0000000..5e30004 --- /dev/null +++ b/packages/ui/graph/ui/package.json @@ -0,0 +1,88 @@ +{ + "name": "@exadev/process-graph-ui", + "version": "0.0.0", + "private": true, + "description": "Styled React components for the process model over the headless graph-react package, styled with Vanilla Extract: a typed theme contract with light and dark themes, recipes for variants, and an SVG sequence diagram. Ships compiled CSS, so a consumer needs no build plugin.", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/process-graph.git", + "directory": "packages/ui/graph/ui" + }, + "homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/ui/graph/ui#readme", + "bugs": { + "url": "https://github.com/ExaDev/process-graph/issues" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./style.css": "./dist/style.css" + }, + "types": "./dist/index.d.ts", + "files": [ + "dist", + "!dist/*.tsbuildinfo" + ], + "sideEffects": [ + "*.css" + ], + "license": "MIT", + "scripts": { + "build": "turbo run _build", + "_build": "vite build && tsc -b tsconfig.build.json", + "lint": "turbo run _lint", + "_lint": "eslint . --cache --max-warnings 0", + "_lint:fix": "eslint . --fix --cache --max-warnings 0", + "typecheck": "turbo run _typecheck _typecheck:attw", + "_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit", + "_typecheck:attw": "attw --pack --profile esm-only --exclude-entrypoints ./style.css", + "test": "turbo run _test", + "_test": "vitest run --project unit", + "test:coverage": "turbo run _test:coverage", + "_test:coverage": "vitest run --project unit --coverage", + "test:smoke": "turbo run _test:smoke", + "_test:smoke": "vitest run --project smoke", + "test:mutation": "turbo run _test:mutation", + "_test:mutation": "stryker run stryker.config.ts" + }, + "keywords": [ + "process-model", + "react", + "vanilla-extract", + "theme", + "sequence-diagram" + ], + "dependencies": { + "@exadev/process-graph-domain": "1.1.1", + "@exadev/process-graph-projection": "1.1.0", + "@exadev/process-graph-react": "0.0.0", + "@exadev/process-graph-schema": "1.1.0", + "@vanilla-extract/recipes": "0.5.7" + }, + "devDependencies": { + "@arethetypeswrong/cli": "0.18.5", + "@stryker-mutator/core": "10.0.0", + "@stryker-mutator/typescript-checker": "10.0.0", + "@stryker-mutator/vitest-runner": "10.0.0", + "@testing-library/dom": "10.4.2", + "@testing-library/react": "16.3.3", + "@types/node": "26.6.4", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vanilla-extract/css": "1.21.2", + "@vanilla-extract/vite-plugin": "5.2.6", + "@vitest/coverage-v8": "5.0.3", + "eslint": "10.12.0", + "happy-dom": "20.14.5", + "react": "19.3.0", + "react-dom": "19.3.0", + "typescript": "6.0.3", + "vite": "8.3.2", + "vitest": "5.0.3" + }, + "peerDependencies": { + "react": "19.3.0" + } +} diff --git a/packages/ui/graph/ui/src/base.css.ts b/packages/ui/graph/ui/src/base.css.ts new file mode 100644 index 0000000..6d44c2e --- /dev/null +++ b/packages/ui/graph/ui/src/base.css.ts @@ -0,0 +1,40 @@ +import { globalStyle, style } from "@vanilla-extract/css"; +import { vars } from "./theme.css.ts"; + +/** The element a theme class goes on: the page colours and type every component inherits. */ +export const surfaceRoot = style({ + backgroundColor: vars.colour.background, + color: vars.colour.text, + fontFamily: vars.font.body, + fontSize: vars.font.sizeBody, +}); + +/** Present to assistive technology, absent from the page: the text alternative of a drawing. */ +export const visuallyHidden = style({ + position: "absolute", + width: "1px", + height: "1px", + padding: 0, + margin: "-1px", + overflow: "hidden", + clip: "rect(0, 0, 0, 0)", + whiteSpace: "nowrap", + border: 0, +}); + +/** The focus ring every focusable part shows when focused from the keyboard. */ +export const focusRing = style({ + selectors: { + "&:focus-visible": { + outline: `${vars.stroke.thick} solid ${vars.colour.focus}`, + outlineOffset: vars.stroke.thin, + }, + }, +}); + +globalStyle( + `${surfaceRoot} *, ${surfaceRoot} *::before, ${surfaceRoot} *::after`, + { + boxSizing: "border-box", + }, +); diff --git a/packages/ui/graph/ui/src/components.css.ts b/packages/ui/graph/ui/src/components.css.ts new file mode 100644 index 0000000..f6c5213 --- /dev/null +++ b/packages/ui/graph/ui/src/components.css.ts @@ -0,0 +1,208 @@ +import { style } from "@vanilla-extract/css"; +import { recipe } from "@vanilla-extract/recipes"; +import { focusRing } from "./base.css.ts"; +import { byStatus, byStatusAttribute } from "./status.css.ts"; +import { vars } from "./theme.css.ts"; + +/** A status badge: the status's name in its colour, outlined in the same colour, so it reads by text and by colour. */ +export const badge = recipe({ + base: { + display: "inline-flex", + alignItems: "center", + padding: `0 ${vars.space.small}`, + border: `${vars.stroke.thin} solid currentColor`, + borderRadius: vars.radius.small, + fontSize: vars.font.sizeSmall, + fontWeight: vars.font.weightStrong, + whiteSpace: "nowrap", + }, + variants: { + status: byStatus((colour) => ({ color: colour })), + }, +}); + +/** An element card: a bordered surface whose leading edge carries the status colour, shaped by the element's kind. */ +export const card = recipe({ + base: [ + focusRing, + { + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: vars.space.small, + padding: vars.space.medium, + backgroundColor: vars.colour.surface, + border: `${vars.stroke.thin} solid ${vars.colour.border}`, + borderLeftWidth: vars.stroke.thick, + borderRadius: vars.radius.medium, + cursor: "default", + selectors: { + '&[aria-selected="true"]': { + outline: `${vars.stroke.thick} solid ${vars.colour.accent}`, + }, + }, + }, + ], + variants: { + status: byStatus((colour) => ({ borderLeftColor: colour })), + kind: { + activity: {}, + state: {}, + decision: { borderStyle: "dashed" }, + pseudo: { borderRadius: vars.space.large }, + message: { fontStyle: "italic" }, + }, + critical: { + true: { boxShadow: `0 0 0 ${vars.stroke.thick} ${vars.colour.critical}` }, + false: {}, + }, + }, +}); + +export const cardLabel = style({ fontWeight: vars.font.weightStrong }); + +export const criticalMarker = style({ + color: vars.colour.critical, + fontSize: vars.font.sizeSmall, + fontWeight: vars.font.weightStrong, +}); + +export const blockersNote = style({ + flexBasis: "100%", + color: vars.colour.muted, + fontSize: vars.font.sizeSmall, +}); + +/** A transition: muted text, in the hand-off colour when it passes work between actors and the critical colour on the critical path. */ +export const transition = style({ + color: vars.colour.muted, + selectors: { + "&[data-handoff]": { color: vars.colour.handoff }, + "&[data-critical]": { + color: vars.colour.critical, + fontWeight: vars.font.weightStrong, + }, + }, +}); + +export const transitionEnd = style({ color: vars.colour.text }); + +export const lane = style({ + border: `${vars.stroke.thin} solid ${vars.colour.border}`, + borderRadius: vars.radius.medium, + padding: vars.space.medium, +}); + +export const laneHeading = style({ + margin: `0 0 ${vars.space.medium}`, + color: vars.colour.muted, + fontSize: vars.font.sizeSmall, + textTransform: "uppercase", +}); + +export const laneBody = style({ + display: "flex", + flexWrap: "wrap", + gap: vars.space.medium, +}); + +export const legend = style({ + display: "flex", + flexWrap: "wrap", + gap: vars.space.large, + margin: 0, + padding: 0, + listStyle: "none", + fontSize: vars.font.sizeSmall, +}); + +export const legendItem = style({ + display: "inline-flex", + alignItems: "center", + gap: vars.space.small, +}); + +/** A legend swatch, coloured by its own `data-status`, or as the critical path for `data-critical`. */ +export const swatch = style({ + display: "inline-block", + width: vars.font.sizeSmall, + height: vars.font.sizeSmall, + borderRadius: vars.radius.small, + selectors: { + ...byStatusAttribute((colour) => ({ backgroundColor: colour })), + "&[data-critical]": { backgroundColor: vars.colour.critical }, + }, +}); + +export const filterPanel = style({ + display: "flex", + flexWrap: "wrap", + alignItems: "flex-start", + gap: vars.space.large, +}); + +export const filterGroup = style({ + display: "flex", + flexWrap: "wrap", + gap: vars.space.medium, + margin: 0, + border: `${vars.stroke.thin} solid ${vars.colour.border}`, + borderRadius: vars.radius.medium, + padding: vars.space.medium, +}); + +export const filterLegend = style({ + padding: `0 ${vars.space.small}`, + color: vars.colour.muted, + fontSize: vars.font.sizeSmall, +}); + +export const filterOption = style({ + display: "inline-flex", + alignItems: "center", + gap: vars.space.small, +}); + +export const checkbox = style([focusRing, { accentColor: vars.colour.accent }]); + +export const button = style([ + focusRing, + { + padding: `${vars.space.small} ${vars.space.medium}`, + color: vars.colour.accent, + backgroundColor: "transparent", + border: `${vars.stroke.thin} solid ${vars.colour.border}`, + borderRadius: vars.radius.small, + font: "inherit", + cursor: "pointer", + }, +]); + +export const detailPanel = style({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: vars.space.medium, + padding: vars.space.large, + backgroundColor: vars.colour.surface, + border: `${vars.stroke.thin} solid ${vars.colour.border}`, + borderRadius: vars.radius.medium, +}); + +export const detailHeading = style({ + margin: 0, + fontSize: vars.font.sizeHeading, +}); + +export const detailReason = style({ margin: 0 }); + +export const detailSectionHeading = style({ + margin: 0, + color: vars.colour.muted, + fontSize: vars.font.sizeSmall, +}); + +export const detailList = style({ + margin: `${vars.space.small} 0 0`, + paddingLeft: vars.space.large, +}); diff --git a/packages/ui/graph/ui/src/components.tsx b/packages/ui/graph/ui/src/components.tsx new file mode 100644 index 0000000..da37c51 --- /dev/null +++ b/packages/ui/graph/ui/src/components.tsx @@ -0,0 +1,200 @@ +import type { Status } from "@exadev/process-graph-domain"; +import { + DetailPanel as HeadlessDetailPanel, + ElementCard as HeadlessElementCard, + FilterPanel as HeadlessFilterPanel, + Lane as HeadlessLane, + Legend as HeadlessLegend, + StatusBadge as HeadlessStatusBadge, + TransitionItem as HeadlessTransitionItem, + type DetailPanelProps, + type ElementCardProps, + type FilterPanelProps, + type LaneProps, + type LegendProps, + type TransitionItemProps, +} from "@exadev/process-graph-react"; +import type { IdSpace } from "@exadev/process-graph-schema"; +import type { ReactElement, ReactNode } from "react"; +import { surfaceRoot } from "./base.css.ts"; +import { + badge, + blockersNote, + button, + card, + cardLabel, + checkbox, + criticalMarker, + detailHeading, + detailList, + detailPanel, + detailReason, + detailSectionHeading, + filterGroup, + filterLegend, + filterOption, + filterPanel, + lane, + laneBody, + laneHeading, + legend, + legendItem, + swatch, + transition, + transitionEnd, +} from "./components.css.ts"; +import { darkTheme, lightTheme } from "./theme.css.ts"; + +/** The themes this package ships. */ +export type ThemeName = "light" | "dark"; + +/** The class that assigns each shipped theme. */ +export const themes: Readonly> = { + light: lightTheme, + dark: darkTheme, +}; + +export interface GraphThemeProps { + readonly theme: ThemeName; + readonly children?: ReactNode; +} + +/** + * Applies a theme to everything inside it: assigns the theme contract's values and sets the page colours and type the components inherit. A consumer with its own theme puts its own `createTheme` class and `surfaceRoot` on an element instead. + * @param props - The theme and its content. + * @returns A `div` carrying the theme class and `data-theme`. + */ +export const GraphTheme = (props: GraphThemeProps): ReactElement => { + const { theme, children } = props; + + return ( +
    + {children} +
    + ); +}; + +export interface StatusBadgeProps { + readonly status: Status; +} + +/** + * A status badge in the status's colour. + * @param props - The status. + * @returns The badge. + */ +export const StatusBadge = (props: StatusBadgeProps): ReactElement => { + const { status } = props; + + return ; +}; + +/** + * An element card with its status colour, its kind's shape, the critical path ring and the selection outline. + * @param props - As for the headless `ElementCard`, without class names. + * @returns The card. + */ +export const ElementCard = ( + props: Omit, "classNames">, +): ReactElement => { + const { element, status, critical = false } = props; + + return ( + + ); +}; + +/** + * A transition, coloured as a hand-off or on the critical path. + * @param props - As for the headless `TransitionItem`, without class names. + * @returns An `li`. + */ +export const TransitionItem = ( + props: Omit, +): ReactElement => ( + +); + +/** + * A bordered swimlane with a small heading. + * @param props - As for the headless `Lane`, without class names. + * @returns The lane. + */ +export const Lane = (props: Omit): ReactElement => ( + +); + +/** + * A legend with a coloured swatch per status. + * @param props - As for the headless `Legend`, without class names. + * @returns The legend. + */ +export const Legend = ( + props: Omit, +): ReactElement => ( + +); + +/** + * A filter panel of bordered checkbox groups. + * @param props - As for the headless `FilterPanel`, without class names. + * @returns The form. + */ +export const FilterPanel = ( + props: Omit, +): ReactElement => ( + +); + +/** + * A detail panel on a raised surface. + * @param props - As for the headless `DetailPanel`, without class names. + * @returns The panel. + */ +export const DetailPanel = ( + props: Omit, "classNames">, +): ReactElement => { + const { explanation } = props; + + return ( + + ); +}; diff --git a/packages/ui/graph/ui/src/components.unit.test.tsx b/packages/ui/graph/ui/src/components.unit.test.tsx new file mode 100644 index 0000000..71768fc --- /dev/null +++ b/packages/ui/graph/ui/src/components.unit.test.tsx @@ -0,0 +1,88 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { card } from "./components.css.ts"; +import { + ElementCard, + GraphTheme, + Legend, + TransitionItem, +} from "./components.tsx"; +import { elementOf } from "./test-support/elements.ts"; +import { lightValues } from "./theme-values.ts"; + +afterEach(cleanup); + +const request = elementOf("a-request"); + +describe("the styled components", () => { + test("an element card takes the recipe's classes for its status, kind and critical path", () => { + render( + + + , + ); + + const root = document.querySelector('[data-element-id="a-request"]'); + expect(root?.className).toBe( + card({ status: "blocked", kind: "activity", critical: true }), + ); + expect(root?.className).not.toBe( + card({ status: "ready", kind: "activity", critical: true }), + ); + }); + + test("an element card's leading edge is drawn in its status colour", () => { + render( + + + , + ); + + const root = document.querySelector('[data-element-id="a-request"]'); + if (root === null) { + throw new Error("no card"); + } + expect(getComputedStyle(root).borderLeftColor).toBe( + lightValues.colour.status.ready, + ); + }); + + test("each legend swatch is coloured by its status", () => { + render( + + + , + ); + + const swatches = [...document.querySelectorAll('[data-part="swatch"]')]; + expect( + swatches.map((swatch) => getComputedStyle(swatch).backgroundColor), + ).toEqual([ + lightValues.colour.status.done, + lightValues.colour.status.unknown, + lightValues.colour.status.skipped, + lightValues.colour.status.ready, + lightValues.colour.status.blocked, + lightValues.colour.critical, + ]); + }); + + test("a hand-off is drawn in the hand-off colour", () => { + render( + +
      + +
    +
    , + ); + + expect(getComputedStyle(screen.getByRole("listitem")).color).toBe( + lightValues.colour.handoff, + ); + }); +}); diff --git a/packages/ui/graph/ui/src/contrast.unit.test.tsx b/packages/ui/graph/ui/src/contrast.unit.test.tsx new file mode 100644 index 0000000..0ec3c7f --- /dev/null +++ b/packages/ui/graph/ui/src/contrast.unit.test.tsx @@ -0,0 +1,141 @@ +import { statuses } from "@exadev/process-graph-domain"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { GraphTheme, StatusBadge } from "./components.tsx"; +import { vars } from "./theme.css.ts"; + +afterEach(cleanup); + +/** + * WCAG 2.2 success criterion 1.4.3, Contrast (Minimum): text and images of text need a contrast ratio of at least 4.5:1. A status is shown as text in its colour, so each status colour must reach it against every surface it is drawn on. https://www.w3.org/TR/WCAG22/#contrast-minimum + */ +const minimumTextContrast = 4.5; + +/** The constants of WCAG 2.2's relative luminance: https://www.w3.org/TR/WCAG22/#dfn-relative-luminance */ +const srgb = { + /** The largest value of an 8-bit channel. */ + channelMaximum: 255, + /** At or below this, a channel's linear value is proportional to it. */ + linearThreshold: 0.04045, + linearSlope: 12.92, + offset: 0.055, + scale: 1.055, + exponent: 2.4, + /** Each channel's share of the luminance. */ + weights: { red: 0.2126, green: 0.7152, blue: 0.0722 }, +}; + +/** A `#rrggbb` channel is written in base sixteen. */ +const hexadecimal = 16; + +/** The flare WCAG 2.2's contrast ratio adds to both luminances: https://www.w3.org/TR/WCAG22/#dfn-contrast-ratio */ +const flare = 0.05; + +const linear = (channel: number): number => { + const value = channel / srgb.channelMaximum; + + return value <= srgb.linearThreshold + ? value / srgb.linearSlope + : ((value + srgb.offset) / srgb.scale) ** srgb.exponent; +}; + +/** The relative luminance of a `#rrggbb` colour, by the WCAG 2.2 definition. */ +const luminance = (colour: string): number => { + const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/iu.exec(colour); + if (match === null) { + throw new Error(`${colour} is not a #rrggbb colour`); + } + const [red, green, blue] = match + .slice(1) + .map((hex) => linear(Number.parseInt(hex, hexadecimal))); + if (red === undefined || green === undefined || blue === undefined) { + throw new Error(`${colour} lacks a channel`); + } + + return ( + srgb.weights.red * red + + srgb.weights.green * green + + srgb.weights.blue * blue + ); +}; + +/** The contrast ratio of two colours, by the WCAG 2.2 definition: https://www.w3.org/TR/WCAG22/#dfn-contrast-ratio */ +const contrast = (first: string, second: string): number => { + const [lighter, darker] = [luminance(first), luminance(second)].sort( + (a, b) => b - a, + ); + if (lighter === undefined || darker === undefined) { + throw new Error("two colours are needed"); + } + + return (lighter + flare) / (darker + flare); +}; + +/** The value a contract variable resolves to on the rendered theme root. */ +const resolved = (root: Element, reference: string): string => { + const property = /^var\((--[^)]+)\)$/u.exec(reference)?.[1]; + if (property === undefined) { + throw new Error(`${reference} is not a var() reference`); + } + + return getComputedStyle(root).getPropertyValue(property).trim(); +}; + +describe.each(["light", "dark"] as const)( + "the %s theme's status colours", + (theme) => { + const rendered = () => { + render( + + {statuses.map((status) => ( + + ))} + , + ); + const root = document.querySelector(`[data-theme="${theme}"]`); + if (root === null) { + throw new Error("no theme root"); + } + + return root; + }; + + test.each(statuses)( + "%s text reaches the minimum contrast on the background and on a surface", + (status) => { + const root = rendered(); + const badge = root.querySelector(`[data-status="${status}"]`); + if (badge === null) { + throw new Error(`no ${status} badge`); + } + const colour = getComputedStyle(badge).color; + + for (const behind of [vars.colour.background, vars.colour.surface]) { + expect( + contrast(colour, resolved(root, behind)), + ).toBeGreaterThanOrEqual(minimumTextContrast); + } + }, + ); + + test("every status has a colour of its own", () => { + const root = rendered(); + const colours = statuses.map((status) => + resolved(root, vars.colour.status[status]), + ); + + expect(new Set(colours).size).toBe(statuses.length); + }); + + test("the critical path and hand-off marks reach the minimum contrast on a surface", () => { + const root = rendered(); + const surface = resolved(root, vars.colour.surface); + + for (const mark of [vars.colour.critical, vars.colour.handoff]) { + expect(contrast(resolved(root, mark), surface)).toBeGreaterThanOrEqual( + minimumTextContrast, + ); + } + }); + }, +); diff --git a/packages/ui/graph/ui/src/index.ts b/packages/ui/graph/ui/src/index.ts new file mode 100644 index 0000000..7ebe569 --- /dev/null +++ b/packages/ui/graph/ui/src/index.ts @@ -0,0 +1,25 @@ +/** + * Styled React components for the process model, over the headless `@exadev/process-graph-react`. Styles are Vanilla Extract, compiled at build time into `style.css`, which a consumer imports once (`@exadev/process-graph-ui/style.css`); no build plugin is needed to use the package. Every colour, space and font is a property of the theme contract `vars`, assigned by `lightTheme` or `darkTheme`, or by a consumer's own theme. + */ +export { vars, lightTheme, darkTheme } from "./theme.css.ts"; +export { lightValues, darkValues, type ThemeValues } from "./theme-values.ts"; +export { surfaceRoot, visuallyHidden, focusRing } from "./base.css.ts"; +export { badge, card } from "./components.css.ts"; +export { + DetailPanel, + ElementCard, + FilterPanel, + GraphTheme, + Lane, + Legend, + StatusBadge, + TransitionItem, + themes, + type GraphThemeProps, + type StatusBadgeProps, + type ThemeName, +} from "./components.tsx"; +export { + SequenceDiagram, + type SequenceDiagramProps, +} from "./sequence-diagram.tsx"; diff --git a/packages/ui/graph/ui/src/sequence-diagram.tsx b/packages/ui/graph/ui/src/sequence-diagram.tsx new file mode 100644 index 0000000..05215c5 --- /dev/null +++ b/packages/ui/graph/ui/src/sequence-diagram.tsx @@ -0,0 +1,386 @@ +import type { + SequenceDiagram as SequenceDiagramView, + SequenceItem, + SequenceMessage, +} from "@exadev/process-graph-projection"; +import type { IdSpace } from "@exadev/process-graph-schema"; +import { useId, type ReactElement } from "react"; +import { visuallyHidden } from "./base.css.ts"; +import { + arrowFilled, + arrowOpen, + diagramText, + fragmentDivider, + fragmentFrame, + fragmentTag, + lifelineHead, + lifelineLine, + messageLabel, + messageLine, + sequenceCaption, + sequenceFigure, + sequenceSvg, +} from "./sequence.css.ts"; + +/** The unit every dimension below is a multiple of: the same grid `graph-layout` places nodes on, so a sequence diagram and a laid-out view share a scale. One unit is also the gap between a label and its arrow, the inset of each level of fragment nesting, and the size of an arrowhead. */ +const GRID = 8; + +/** Each dimension of the drawing, in grid units. */ +const units = { + /** The space around the drawing. */ + margin: 2, + /** A lifeline's head: wide enough for a participant's name on one line. */ + headWidth: 16, + headHeight: 5, + /** The distance between neighbouring lifelines: a head's width plus room for a message label between them. */ + lifelineGap: 20, + /** One message, or a fragment's heading, per row: room for a label above the arrow. */ + row: 6, + /** A self-message's loop: how far it reaches to the right of its lifeline, and how far it drops before returning. */ + selfReach: 4, + selfDrop: 2, + /** A fragment's operator tag. */ + tagWidth: 6, + tagHeight: 3, +}; + +const MARGIN = units.margin * GRID; +const HEAD_WIDTH = units.headWidth * GRID; +const HEAD_HEIGHT = units.headHeight * GRID; +const LIFELINE_GRID = units.lifelineGap * GRID; +const ROW = units.row * GRID; +const SELF_REACH = units.selfReach * GRID; +const SELF_DROP = units.selfDrop * GRID; +const TAG_WIDTH = units.tagWidth * GRID; +const TAG_HEIGHT = units.tagHeight * GRID; + +/** A text baseline sits this far below the middle of the box it is centred in, so the glyphs look centred. */ +const BASELINE_DROP = GRID / 2; + +interface PlacedMessage { + readonly message: SequenceMessage; + readonly y: number; +} + +interface PlacedFragment { + readonly operator: string; + readonly depth: number; + readonly top: number; + readonly bottom: number; + /** Each operand's guard and the row it starts on. */ + readonly operands: readonly { readonly guard?: string; readonly y: number }[]; +} + +interface Placement { + readonly messages: readonly PlacedMessage[]; + readonly fragments: readonly PlacedFragment[]; + readonly rows: number; +} + +/** + * Gives each message a row and each fragment the rows it spans. A fragment takes a row of its own for its tag and first guard, and another for each later operand's guard, so no label overlaps an arrow. + */ +const place = ( + items: readonly SequenceItem[], +): Placement => { + const messages: PlacedMessage[] = []; + const fragments: PlacedFragment[] = []; + let row = 0; + const walk = (level: readonly SequenceItem[], depth: number): void => { + for (const item of level) { + if (item.kind === "message") { + row += 1; + messages.push({ message: item.message, y: row * ROW }); + continue; + } + const top = row * ROW + ROW / 2; + const operands: { guard?: string; y: number }[] = []; + for (const operand of item.operands) { + row += 1; + operands.push({ + ...(operand.guard === undefined ? {} : { guard: operand.guard }), + y: row * ROW, + }); + walk(operand.items, depth + 1); + } + fragments.push({ + operator: item.operator, + depth, + top, + bottom: row * ROW + ROW / 2, + operands, + }); + } + }; + walk(items, 0); + + return { messages, fragments, rows: row + 1 }; +}; + +const describeMessage = ( + message: SequenceMessage, + labelOf: (actor: I["actor"]) => string, +): string => + `${labelOf(message.from)} to ${labelOf(message.to)}: ${message.label}`; + +/** The text alternative: the items as nested lists, in scenario order. */ +const Steps = (props: { + readonly items: readonly SequenceItem[]; + readonly labelOf: (actor: I["actor"]) => string; +}): ReactElement => { + const { items, labelOf } = props; + + return ( +
      + {items.map((item) => + item.kind === "message" ? ( +
    1. + {describeMessage(item.message, labelOf)} +
    2. + ) : ( +
    3. + {item.operator} +
        + {item.operands.map((operand, index) => ( +
      1. + {operand.guard === undefined ? "" : `[${operand.guard}] `} + +
      2. + ))} +
      +
    4. + ), + )} +
    + ); +}; + +export interface SequenceDiagramProps { + /** The diagram from `toSequence`. */ + readonly diagram: SequenceDiagramView; + /** The name a lifeline is drawn with. */ + readonly labelOf: (actor: I["actor"]) => string; + /** The diagram's caption, which also names it for assistive technology. */ + readonly title: string; +} + +/** + * A sequence diagram drawn as SVG: a lifeline per actor in order of first appearance, a message per row as an arrow between lifelines (a loop for a self-message, dashed for a reply, an open head for an asynchronous message), and each fragment as a frame with its operator and operand guards. React Flow has no lifeline layout, so the arrangement is fixed by the diagram's own order. The drawing is hidden from assistive technology, which reads the same steps from a visually hidden list instead. + * @param props - The diagram, the lifelines' names and a caption. + * @returns A `figure` holding the caption, the drawing and its text alternative. + */ +export const SequenceDiagram = ( + props: SequenceDiagramProps, +): ReactElement => { + const { diagram, labelOf, title } = props; + const id = useId(); + const caption = `${id}-caption`; + const filled = `${id}-filled`; + const open = `${id}-open`; + const { messages, fragments, rows } = place(diagram.items); + const xOf = new Map( + diagram.lifelines.map((actor, index) => [ + actor, + MARGIN + HEAD_WIDTH / 2 + index * LIFELINE_GRID, + ]), + ); + const at = (actor: I["actor"]): number => { + const x = xOf.get(actor); + if (x === undefined) { + throw new Error( + `"${actor}" sends or receives a message but has no lifeline`, + ); + } + + return x; + }; + const width = + 2 * MARGIN + + HEAD_WIDTH + + Math.max(diagram.lifelines.length - 1, 0) * LIFELINE_GRID; + const top = MARGIN + HEAD_HEIGHT; + const height = top + rows * ROW + MARGIN; + + return ( +
    +
    + {title} +
    + +
    + +
    +
    + ); +}; diff --git a/packages/ui/graph/ui/src/sequence-diagram.unit.test.tsx b/packages/ui/graph/ui/src/sequence-diagram.unit.test.tsx new file mode 100644 index 0000000..22b1f52 --- /dev/null +++ b/packages/ui/graph/ui/src/sequence-diagram.unit.test.tsx @@ -0,0 +1,111 @@ +import { toSequence } from "@exadev/process-graph-projection"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { GraphTheme } from "./components.tsx"; +import { SequenceDiagram } from "./sequence-diagram.tsx"; +import { order } from "./test-support/order.ts"; + +afterEach(cleanup); + +const labelOf = (actor: string): string => { + const participant = order.participants.find((p) => p.id === actor); + if (participant === undefined) { + throw new Error(`no participant ${actor}`); + } + + return participant.label; +}; + +/** The shipped path of the order process, as the model's declared sequence projection records it. */ +const shipped = toSequence(order, ["t1", "t2", "t3", "t4", "t5", "t7"]); + +const renderShipped = () => + render( + + + , + ); + +describe("SequenceDiagram", () => { + test("is a figure named by its caption", () => { + renderShipped(); + + expect(screen.getByRole("figure", { name: "Order shipped" })).toBeTruthy(); + }); + + test("draws a lifeline per actor, in order of first appearance, headed by its name", () => { + const { container } = renderShipped(); + + const lifelines = [...container.querySelectorAll("[data-lifeline]")]; + expect(lifelines.map((line) => line.getAttribute("data-lifeline"))).toEqual( + ["customer", "shop", "provider"], + ); + expect(lifelines.map((line) => line.textContent)).toEqual([ + "Customer", + "Shop", + "Payment provider", + ]); + }); + + test("draws each message from its sender's lifeline to its receiver's, left to right in time order", () => { + const { container } = renderShipped(); + + const lines = [...container.querySelectorAll("[data-transition-id] line")]; + const ends = lines.map((line) => ({ + from: Number(line.getAttribute("x1")), + to: Number(line.getAttribute("x2")), + y: Number(line.getAttribute("y1")), + })); + const heads = new Map( + [...container.querySelectorAll("[data-lifeline] line")].map((line) => [ + line.parentElement?.getAttribute("data-lifeline"), + Number(line.getAttribute("x1")), + ]), + ); + expect(ends.map(({ from, to }) => [from, to])).toEqual([ + [heads.get("customer"), heads.get("shop")], + [heads.get("shop"), heads.get("provider")], + [heads.get("provider"), heads.get("shop")], + ]); + const rows = ends.map(({ y }) => y); + expect(rows).toEqual(rows.toSorted((a, b) => a - b)); + expect(new Set(rows).size).toBe(rows.length); + }); + + test("draws a self-message as a loop on its lifeline", () => { + const { container } = renderShipped(); + + const self = container.querySelector('[data-transition-id="t5"]'); + expect(self?.querySelector("path")).not.toBeNull(); + expect(self?.textContent).toBe("payment authorised"); + }); + + test("frames the decision's branch in an alt with its guard", () => { + const { container } = renderShipped(); + + const fragment = container.querySelector('[data-fragment="alt"]'); + expect(fragment?.textContent).toBe("alt[authorised]"); + }); + + test("the drawing is hidden from assistive technology, which reads the steps as a list", () => { + const { container } = renderShipped(); + + expect(container.querySelector("svg")?.getAttribute("aria-hidden")).toBe( + "true", + ); + const figure = screen.getByRole("figure", { name: "Order shipped" }); + const steps = within(figure).getAllByRole("listitem"); + expect(steps.map((step) => step.firstChild?.textContent)).toEqual([ + "Customer to Shop: Request payment", + "Shop to Payment provider: Authorise or decline", + "Payment provider to Shop: Payment authorised?", + "alt", + "[authorised] ", + "Shop to Shop: payment authorised", + ]); + }); +}); diff --git a/packages/ui/graph/ui/src/sequence.css.ts b/packages/ui/graph/ui/src/sequence.css.ts new file mode 100644 index 0000000..4aa2b60 --- /dev/null +++ b/packages/ui/graph/ui/src/sequence.css.ts @@ -0,0 +1,74 @@ +import { style } from "@vanilla-extract/css"; +import { vars } from "./theme.css.ts"; + +/** The dash of a lifeline, a reply and an operand divider: dash and gap of equal length, half a grid unit each. */ +const dashed = "4 4"; + +export const sequenceFigure = style({ margin: 0 }); + +export const sequenceCaption = style({ + marginBottom: vars.space.medium, + fontWeight: vars.font.weightStrong, +}); + +export const sequenceSvg = style({ display: "block", maxWidth: "100%" }); + +export const lifelineHead = style({ + fill: vars.colour.surface, + stroke: vars.colour.border, + strokeWidth: vars.stroke.thin, +}); + +export const lifelineLine = style({ + stroke: vars.colour.muted, + strokeWidth: vars.stroke.thin, + strokeDasharray: dashed, +}); + +export const diagramText = style({ + fill: vars.colour.text, + fontFamily: vars.font.body, + fontSize: vars.font.sizeBody, +}); + +export const messageLabel = style({ + fill: vars.colour.muted, + fontFamily: vars.font.body, + fontSize: vars.font.sizeSmall, +}); + +/** A message line; a reply is dashed, as the notation draws it. */ +export const messageLine = style({ + fill: "none", + stroke: vars.colour.text, + strokeWidth: vars.stroke.thin, + selectors: { + '&[data-mode="reply"]': { strokeDasharray: dashed }, + }, +}); + +export const arrowFilled = style({ fill: vars.colour.text }); + +export const arrowOpen = style({ + fill: "none", + stroke: vars.colour.text, + strokeWidth: vars.stroke.thin, +}); + +export const fragmentFrame = style({ + fill: "none", + stroke: vars.colour.border, + strokeWidth: vars.stroke.thin, +}); + +export const fragmentDivider = style({ + stroke: vars.colour.border, + strokeWidth: vars.stroke.thin, + strokeDasharray: dashed, +}); + +export const fragmentTag = style({ + fill: vars.colour.surface, + stroke: vars.colour.border, + strokeWidth: vars.stroke.thin, +}); diff --git a/packages/ui/graph/ui/src/status.css.ts b/packages/ui/graph/ui/src/status.css.ts new file mode 100644 index 0000000..5af2863 --- /dev/null +++ b/packages/ui/graph/ui/src/status.css.ts @@ -0,0 +1,33 @@ +import type { Status } from "@exadev/process-graph-domain"; +import type { StyleRule } from "@vanilla-extract/css"; +import { vars } from "./theme.css.ts"; + +/** + * One style per status, built by `rule` from the status's colour, so every status-coloured part covers every status. + * @param rule - The style for one status colour. + * @returns The style of each status. + */ +export const byStatus = ( + rule: (colour: string) => StyleRule, +): Record => ({ + done: rule(vars.colour.status.done), + ready: rule(vars.colour.status.ready), + blocked: rule(vars.colour.status.blocked), + skipped: rule(vars.colour.status.skipped), + unknown: rule(vars.colour.status.unknown), +}); + +/** + * Selectors that colour an element by the status in its `data-status` attribute, for a part whose status the styled component cannot see, such as a legend's swatches. + * @param rule - The style for one status colour. + * @returns A `selectors` block with one entry per status. + */ +export const byStatusAttribute = ( + rule: (colour: string) => StyleRule, +): Record => + Object.fromEntries( + Object.entries(byStatus(rule)).map(([status, style]) => [ + `&[data-status="${status}"]`, + style, + ]), + ); diff --git a/packages/ui/graph/ui/src/test-support/elements.ts b/packages/ui/graph/ui/src/test-support/elements.ts new file mode 100644 index 0000000..1ea5aff --- /dev/null +++ b/packages/ui/graph/ui/src/test-support/elements.ts @@ -0,0 +1,19 @@ +import type { Element } from "@exadev/process-graph-schema"; +import type { OrderIds } from "./ids.ts"; +import { order } from "./order.ts"; + +/** + * An element of the order fixture by id. + * @param id - The element's id. + * @returns The element. + */ +export const elementOf = ( + id: OrderIds["element"], +): Element => { + const element = order.elements.find((candidate) => candidate.id === id); + if (element === undefined) { + throw new Error(`no element ${id}`); + } + + return element; +}; diff --git a/packages/ui/graph/ui/src/test-support/ids.ts b/packages/ui/graph/ui/src/test-support/ids.ts new file mode 100644 index 0000000..5a5d7a8 --- /dev/null +++ b/packages/ui/graph/ui/src/test-support/ids.ts @@ -0,0 +1,5 @@ +import type { Model } from "@exadev/process-graph-schema"; +import type { order } from "./order.ts"; + +/** The identifier spaces of the order fixture. */ +export type OrderIds = typeof order extends Model ? I : never; diff --git a/packages/ui/graph/ui/src/test-support/order.ts b/packages/ui/graph/ui/src/test-support/order.ts new file mode 100644 index 0000000..b5eb9d1 --- /dev/null +++ b/packages/ui/graph/ui/src/test-support/order.ts @@ -0,0 +1,101 @@ +import { defineModel } from "@exadev/process-graph-schema"; + +/** The order process from the doc, with every reference checked at compile time. */ +export const order = defineModel({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + { id: "provider", label: "Payment provider", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [ + { id: "ev-submitted", name: "order submitted" }, + { id: "ev-authorised", name: "payment authorised" }, + { id: "ev-declined", name: "payment declined" }, + ], + actions: [ + { id: "act-request", name: "request payment" }, + { id: "act-pack", name: "pack and ship" }, + { id: "act-cancel", name: "cancel order and notify" }, + ], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { + kind: "activity", + id: "a-submit", + label: "Submit order", + actor: "customer", + }, + { + kind: "activity", + id: "a-request", + label: "Request payment", + actor: "shop", + action: "act-request", + }, + { + kind: "activity", + id: "a-authorise", + label: "Authorise or decline", + actor: "provider", + }, + { + kind: "decision", + id: "d-authorised", + label: "Payment authorised?", + actor: "shop", + }, + { + kind: "activity", + id: "a-pack", + label: "Pack and ship", + actor: "shop", + action: "act-pack", + }, + { + kind: "activity", + id: "a-cancel", + label: "Cancel order and notify", + actor: "shop", + action: "act-cancel", + }, + { kind: "pseudo", id: "end", role: "final" }, + ], + transitions: [ + { id: "t1", source: "start", target: "a-submit", trigger: "ev-submitted" }, + { id: "t2", source: "a-submit", target: "a-request" }, + { id: "t3", source: "a-request", target: "a-authorise" }, + { id: "t4", source: "a-authorise", target: "d-authorised" }, + { + id: "t5", + source: "d-authorised", + target: "a-pack", + guard: "authorised", + trigger: "ev-authorised", + }, + { + id: "t6", + source: "d-authorised", + target: "a-cancel", + guard: "declined", + trigger: "ev-declined", + }, + { id: "t7", source: "a-pack", target: "end" }, + { id: "t8", source: "a-cancel", target: "end" }, + ], + fragments: [], + projections: [ + { id: "p-flow", notation: "flowchart" }, + { id: "p-lanes", notation: "swimlane" }, + { id: "p-state", notation: "stateMachine", subject: "order" }, + { + id: "p-seq", + notation: "sequence", + scenario: ["t1", "t2", "t3", "t4", "t5", "t7"], + }, + ], + views: { + flowchart: { "a-request": { x: 306, y: 100 } }, + }, +}); diff --git a/packages/ui/graph/ui/src/theme-values.ts b/packages/ui/graph/ui/src/theme-values.ts new file mode 100644 index 0000000..1657a77 --- /dev/null +++ b/packages/ui/graph/ui/src/theme-values.ts @@ -0,0 +1,121 @@ +import type { Status } from "@exadev/process-graph-domain"; + +/** + * The shape of every theme: each leaf is a value a theme assigns. The theme contract is created from it, and `ThemeValues` derived from it, so the contract and the themes cannot disagree. + */ +export const themeShape = { + colour: { + /** Behind everything. */ + background: null, + /** Behind a card, a lane or a panel. */ + surface: null, + text: null, + /** Secondary text: lane headings, edge labels. */ + muted: null, + border: null, + /** Selection and links. */ + accent: null, + /** The focus ring. */ + focus: null, + /** The critical path. */ + critical: null, + /** A transition that passes work between actors. */ + handoff: null, + /** Each status, used as text and as a mark on `surface` and `background`. */ + status: { + done: null, + ready: null, + blocked: null, + skipped: null, + unknown: null, + } satisfies Record, + }, + space: { small: null, medium: null, large: null }, + radius: { small: null, medium: null }, + font: { + body: null, + mono: null, + sizeSmall: null, + sizeBody: null, + sizeHeading: null, + /** The weight of labels and headings that stand out from body text. */ + weightStrong: null, + }, + stroke: { + /** Borders and ordinary edges. */ + thin: null, + /** The critical path and the selection. */ + thick: null, + }, +}; + +/** A tree of the shape's keys with a CSS value at every leaf. */ +type Assigned = { + readonly [Key in keyof Shape]: Shape[Key] extends null + ? string + : Assigned; +}; + +/** Every value a theme assigns, as CSS values: one per leaf of `themeShape`, so a theme that leaves one out does not compile. */ +export type ThemeValues = Assigned; + +/** What both themes share: everything but colour. */ +const shared: Omit = { + space: { small: "0.25rem", medium: "0.5rem", large: "1rem" }, + radius: { small: "0.25rem", medium: "0.5rem" }, + font: { + body: 'system-ui, -apple-system, "Segoe UI", sans-serif', + mono: 'ui-monospace, "SFMono-Regular", Menlo, monospace', + sizeSmall: "0.75rem", + sizeBody: "0.875rem", + sizeHeading: "1rem", + weightStrong: "600", + }, + stroke: { thin: "1px", thick: "3px" }, +}; + +/** The light theme's values. Each status colour reads as text on both `surface` and `background`. */ +export const lightValues: ThemeValues = { + ...shared, + colour: { + background: "#ffffff", + surface: "#f6f8fa", + text: "#1f2328", + muted: "#59636e", + border: "#d1d9e0", + accent: "#0969da", + focus: "#0969da", + critical: "#9a6700", + handoff: "#6639ba", + status: { + done: "#1a7f37", + ready: "#0550ae", + blocked: "#cf222e", + skipped: "#59636e", + unknown: "#6639ba", + }, + }, +}; + +/** The dark theme's values. Each status colour reads as text on both `surface` and `background`. */ +export const darkValues: ThemeValues = { + ...shared, + colour: { + background: "#0d1117", + surface: "#161b22", + text: "#e6edf3", + muted: "#9198a1", + border: "#3d444d", + accent: "#4493f8", + focus: "#4493f8", + critical: "#d29922", + handoff: "#ab7df8", + status: { + done: "#3fb950", + ready: "#58a6ff", + blocked: "#f85149", + skipped: "#9198a1", + unknown: "#ab7df8", + }, + }, +}; diff --git a/packages/ui/graph/ui/src/theme.css.ts b/packages/ui/graph/ui/src/theme.css.ts new file mode 100644 index 0000000..0f71fc0 --- /dev/null +++ b/packages/ui/graph/ui/src/theme.css.ts @@ -0,0 +1,13 @@ +import { createTheme, createThemeContract } from "@vanilla-extract/css"; +import { darkValues, lightValues, themeShape } from "./theme-values.ts"; + +/** + * The theme contract: one CSS custom property per value of `ThemeValues`, read by every style in this package. A consumer restyles the components by assigning these properties, with its own `createTheme(vars, values)` class or `assignVars`, without forking them. + */ +export const vars = createThemeContract(themeShape); + +/** The class that assigns the light theme's values. */ +export const lightTheme = createTheme(vars, lightValues, "light"); + +/** The class that assigns the dark theme's values. */ +export const darkTheme = createTheme(vars, darkValues, "dark"); diff --git a/packages/ui/graph/ui/src/theme.unit.test.tsx b/packages/ui/graph/ui/src/theme.unit.test.tsx new file mode 100644 index 0000000..8e49c98 --- /dev/null +++ b/packages/ui/graph/ui/src/theme.unit.test.tsx @@ -0,0 +1,172 @@ +import { explainStatus } from "@exadev/process-graph-domain"; +import { toSequence } from "@exadev/process-graph-projection"; +import { noFacts } from "@exadev/process-graph-react"; +import { cleanup, render } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { afterEach, describe, expect, test } from "vitest"; +import { + DetailPanel, + ElementCard, + FilterPanel, + GraphTheme, + Lane, + Legend, + StatusBadge, + TransitionItem, + type ThemeName, +} from "./components.tsx"; +import { SequenceDiagram } from "./sequence-diagram.tsx"; +import { elementOf } from "./test-support/elements.ts"; +import { order } from "./test-support/order.ts"; +import { darkValues, lightValues, type ThemeValues } from "./theme-values.ts"; +import { vars } from "./theme.css.ts"; + +afterEach(cleanup); + +/** A tree of strings, the shape of the theme contract and of a theme's values. */ +interface Tree { + readonly [key: string]: string | Tree; +} + +/** + * Every leaf of a tree with the path that reaches it. + */ +const leaves = ( + tree: Tree, + path: readonly string[] = [], +): (readonly [readonly string[], string])[] => + Object.entries(tree).flatMap(([key, value]) => + typeof value === "string" + ? [[[...path, key], value] as const] + : leaves(value, [...path, key]), + ); + +const at = (tree: Tree, path: readonly string[]): string | undefined => { + let node: string | Tree | undefined = tree; + for (const key of path) { + node = typeof node === "object" ? node[key] : undefined; + } + + return typeof node === "string" ? node : undefined; +}; + +/** The custom property a contract value refers to: `var(--name)` names `--name`. */ +const propertyOf = (reference: string): string => { + const match = /^var\((--[^)]+)\)$/.exec(reference); + if (match?.[1] === undefined) { + throw new Error(`${reference} is not a var() reference`); + } + + return match[1]; +}; + +const valuesOf: Readonly> = { + light: lightValues, + dark: darkValues, +}; + +const pending = { "a-submit": "done" } as const; + +/** Each component this package styles, rendered with the order fixture. */ +const components: Readonly ReactElement>> = { + StatusBadge: () => , + ElementCard: () => ( + + ), + TransitionItem: () => ( +
      + +
    + ), + Lane: () => , + Legend: () => , + FilterPanel: () => ( + {}} + onToggleActor={() => {}} + onToggleSource={() => {}} + onClear={() => {}} + /> + ), + DetailPanel: () => ( + id} + /> + ), + SequenceDiagram: () => ( + actor} + title="Payment" + /> + ), +}; + +describe.each(["light", "dark"] as const)("the %s theme", (theme) => { + test.each(Object.keys(components))( + "%s renders with every contract variable resolved to the theme's value", + (name) => { + const Component = components[name]; + if (Component === undefined) { + throw new Error(`no component ${name}`); + } + render( + + + , + ); + + const root = document.querySelector(`[data-theme="${theme}"]`); + if (!(root instanceof HTMLElement) || root.firstElementChild === null) { + throw new Error(`${name} rendered nothing under the theme`); + } + const style = getComputedStyle(root); + for (const [path, reference] of leaves(vars)) { + expect( + style.getPropertyValue(propertyOf(reference)).trim(), + path.join("."), + ).toBe(at(valuesOf[theme], path)); + } + }, + ); +}); + +describe.each(["light", "dark"] as const)( + "status colours in the %s theme", + (theme) => { + test.each(["done", "ready", "blocked", "skipped", "unknown"] as const)( + "a %s badge is drawn in the status colour", + (status) => { + render( + + + , + ); + const badge = document.querySelector(`[data-status="${status}"]`); + if (badge === null) { + throw new Error("no badge"); + } + + expect(getComputedStyle(badge).color).toBe( + valuesOf[theme].colour.status[status], + ); + }, + ); + }, +); diff --git a/packages/ui/graph/ui/stryker.config.ts b/packages/ui/graph/ui/stryker.config.ts new file mode 100644 index 0000000..75d17b7 --- /dev/null +++ b/packages/ui/graph/ui/stryker.config.ts @@ -0,0 +1,5 @@ +import { packageStrykerConfig } from "../../../../stryker.shared.ts"; + +export default packageStrykerConfig({ + additionalMutate: ["src/**/*.tsx", "!src/**/*.test.tsx"], +}); diff --git a/packages/ui/graph/ui/test/package.smoke.test.tsx b/packages/ui/graph/ui/test/package.smoke.test.tsx new file mode 100644 index 0000000..acb0df8 --- /dev/null +++ b/packages/ui/graph/ui/test/package.smoke.test.tsx @@ -0,0 +1,29 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeAll, expect, test } from "vitest"; +// Deliberately imports the built dist/, not src/: this is the check that catches a wrong exports map, a build that left Vanilla Extract calls in the JavaScript, or a stylesheet the build failed to emit. +import { GraphTheme, StatusBadge, lightValues } from "../dist/index.js"; + +afterEach(cleanup); + +beforeAll(() => { + const style = document.createElement("style"); + style.textContent = readFileSync( + join(import.meta.dirname, "../dist/style.css"), + "utf8", + ); + document.head.append(style); +}); + +test("the built package's components take their colours from the compiled stylesheet", () => { + render( + + + , + ); + + expect(getComputedStyle(screen.getByText("Blocked")).color).toBe( + lightValues.colour.status.blocked, + ); +}); diff --git a/packages/ui/graph/ui/tsconfig.build.json b/packages/ui/graph/ui/tsconfig.build.json new file mode 100644 index 0000000..587a4c3 --- /dev/null +++ b/packages/ui/graph/ui/tsconfig.build.json @@ -0,0 +1,27 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": false, + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo" + }, + "references": [ + { + "path": "../react/tsconfig.build.json" + }, + { + "path": "../../../core/graph/schema/tsconfig.build.json" + }, + { + "path": "../../../core/graph/projection/tsconfig.build.json" + }, + { + "path": "../../../core/graph/domain/tsconfig.build.json" + } + ] +} diff --git a/packages/ui/graph/ui/tsconfig.json b/packages/ui/graph/ui/tsconfig.json new file mode 100644 index 0000000..6bc4fe3 --- /dev/null +++ b/packages/ui/graph/ui/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": [], + "jsx": "react-jsx" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test-support/**"] +} diff --git a/packages/ui/graph/ui/tsconfig.node.json b/packages/ui/graph/ui/tsconfig.node.json new file mode 100644 index 0000000..287ce37 --- /dev/null +++ b/packages/ui/graph/ui/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["node"], + "jsx": "react-jsx" + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "test/**/*.ts", + "test/**/*.tsx", + "*.config.ts" + ] +} diff --git a/packages/ui/graph/ui/turbo.json b/packages/ui/graph/ui/turbo.json new file mode 100644 index 0000000..b0bb0a1 --- /dev/null +++ b/packages/ui/graph/ui/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turborepo.com/schema.json", + "extends": ["//"], + "tasks": { + "_build": { + "inputs": ["$TURBO_EXTENDS$", "vite.config.ts"] + } + } +} diff --git a/packages/ui/graph/ui/vite.config.ts b/packages/ui/graph/ui/vite.config.ts new file mode 100644 index 0000000..48f2180 --- /dev/null +++ b/packages/ui/graph/ui/vite.config.ts @@ -0,0 +1,26 @@ +import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; +import { isAbsolute } from "node:path"; +import { defineConfig } from "vite"; + +/** + * The library build. Vanilla Extract runs here, at build time, so `dist/index.js` holds plain class names and `dist/style.css` every rule: a consumer imports the stylesheet once and needs no plugin of its own. Declarations come from `tsc -b` afterwards, since Vite emits none. Every bare import stays an import, resolved from the consumer's own install as `package.json` declares it. + */ +export default defineConfig({ + plugins: [vanillaExtractPlugin()], + oxc: { jsx: { runtime: "automatic" } }, + build: { + lib: { + entry: "src/index.ts", + formats: ["es"], + fileName: "index", + cssFileName: "style", + }, + rolldownOptions: { + external: (id) => + !id.startsWith(".") && !isAbsolute(id) && !id.startsWith("\0"), + }, + target: "es2024", + minify: false, + sourcemap: false, + }, +}); diff --git a/packages/ui/graph/ui/vitest.config.ts b/packages/ui/graph/ui/vitest.config.ts new file mode 100644 index 0000000..adbf5b3 --- /dev/null +++ b/packages/ui/graph/ui/vitest.config.ts @@ -0,0 +1,30 @@ +import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; +import { defineConfig } from "vitest/config"; + +/** + * happy-dom, as the owner's other React packages use. The unit tests run the Vanilla Extract plugin and process CSS, so the rendered components carry the real class names and the document holds the real rules, and a test can read the theme's custom properties back through `getComputedStyle`. The smoke test renders the built `dist/` with its compiled stylesheet. + */ +export default defineConfig({ + plugins: [vanillaExtractPlugin()], + test: { + css: true, + projects: [ + { + extends: true, + test: { + name: "unit", + include: ["src/**/*.test.{ts,tsx}"], + environment: "happy-dom", + }, + }, + { + extends: true, + test: { + name: "smoke", + include: ["test/package.smoke.test.tsx"], + environment: "happy-dom", + }, + }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0271058..c85284b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,7 +186,7 @@ importers: version: 8.0.3(supports-color@7.2.0) '@exadev/eslint-config': specifier: 2.36.3 - version: 2.36.3(@eslint/json@2.1.0)(@eslint/markdown@8.0.3(supports-color@7.2.0))(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript-eslint@8.71.0(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(typescript@6.0.3) + version: 2.36.3(@eslint/json@2.1.0)(@eslint/markdown@8.0.3(supports-color@7.2.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)))(eslint-plugin-react-hooks@7.1.1(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(eslint-plugin-react@7.37.5(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)))(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript-eslint@8.71.0(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(typescript@6.0.3) '@exadev/semantic-release-workspace': specifier: 3.1.29 version: 3.1.29(@semantic-release/changelog@7.0.0(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3)))(@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0))(@semantic-release/git@11.0.1(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0))(@semantic-release/github@12.0.10(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0))(@semantic-release/npm@13.2.0(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3)))(@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0))(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0)(typescript@6.0.3) @@ -226,9 +226,18 @@ importers: eslint-plugin-depend: specifier: 1.5.0 version: 1.5.0(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)) + eslint-plugin-jsx-a11y: + specifier: 6.10.2 + version: 6.10.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-prettier: specifier: 5.5.6 version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)))(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(prettier@3.9.9) + eslint-plugin-react: + specifier: 7.37.5 + version: 7.37.5(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)) + eslint-plugin-react-hooks: + specifier: 7.1.1 + version: 7.1.1(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) eslint-plugin-yml: specifier: 3.8.1 version: 3.8.1(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)) @@ -264,7 +273,7 @@ importers: version: 8.71.0(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) packages/adapters/graph/source-github-issues: dependencies: @@ -304,7 +313,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) packages/adapters/graph/sqlite-node: dependencies: @@ -353,7 +362,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) packages/adapters/graph/sqlite-wasm: dependencies: @@ -402,7 +411,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) packages/adapters/graph/store-sqlite: dependencies: @@ -457,7 +466,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) packages/core/graph/dag: devDependencies: @@ -490,7 +499,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) packages/core/graph/domain: dependencies: @@ -533,7 +542,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) packages/core/graph/layout: dependencies: @@ -576,7 +585,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) packages/core/graph/projection: dependencies: @@ -616,7 +625,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) packages/core/graph/schema: dependencies: @@ -656,7 +665,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) packages/core/graph/source-contract: dependencies: @@ -708,7 +717,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) packages/core/graph/store-contract: dependencies: @@ -763,7 +772,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) packages/targets/process-graph: dependencies: @@ -809,7 +818,235 @@ importers: version: 6.0.3 vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + + packages/ui/graph/react: + dependencies: + '@exadev/process-graph-dag': + specifier: 1.1.0 + version: link:../../../core/graph/dag + '@exadev/process-graph-domain': + specifier: 1.1.1 + version: link:../../../core/graph/domain + '@exadev/process-graph-projection': + specifier: 1.1.0 + version: link:../../../core/graph/projection + '@exadev/process-graph-schema': + specifier: 1.1.0 + version: link:../../../core/graph/schema + '@exadev/process-graph-store-contract': + specifier: 1.0.3 + version: link:../../../core/graph/store-contract + devDependencies: + '@arethetypeswrong/cli': + specifier: 0.18.5 + version: 0.18.5 + '@orpc/server': + specifier: 2.0.0-beta.41 + version: 2.0.0-beta.41 + '@stryker-mutator/core': + specifier: 10.0.0 + version: 10.0.0(@types/node@26.6.4) + '@stryker-mutator/typescript-checker': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(typescript@6.0.3) + '@stryker-mutator/vitest-runner': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(vitest@5.0.3) + '@testing-library/dom': + specifier: 10.4.2 + version: 10.4.2 + '@testing-library/react': + specifier: 16.3.3 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@types/node': + specifier: 26.6.4 + version: 26.6.4 + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: 19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitest/coverage-v8': + specifier: 5.0.3 + version: 5.0.3(@vitest/browser@5.0.3)(vitest@5.0.3) + eslint: + specifier: 10.12.0 + version: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + happy-dom: + specifier: 20.14.5 + version: 20.14.5 + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 5.0.3 + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + + packages/ui/graph/react-flow: + dependencies: + '@exadev/process-graph-domain': + specifier: 1.1.1 + version: link:../../../core/graph/domain + '@exadev/process-graph-layout': + specifier: 1.0.3 + version: link:../../../core/graph/layout + '@exadev/process-graph-projection': + specifier: 1.1.0 + version: link:../../../core/graph/projection + '@exadev/process-graph-react': + specifier: 0.0.0 + version: link:../react + '@exadev/process-graph-schema': + specifier: 1.1.0 + version: link:../../../core/graph/schema + '@exadev/process-graph-ui': + specifier: 0.0.0 + version: link:../ui + '@xyflow/react': + specifier: 12.12.0 + version: 12.12.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + devDependencies: + '@arethetypeswrong/cli': + specifier: 0.18.5 + version: 0.18.5 + '@stryker-mutator/core': + specifier: 10.0.0 + version: 10.0.0(@types/node@26.6.4) + '@stryker-mutator/typescript-checker': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(typescript@6.0.3) + '@stryker-mutator/vitest-runner': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(vitest@5.0.3) + '@testing-library/dom': + specifier: 10.4.2 + version: 10.4.2 + '@testing-library/react': + specifier: 16.3.3 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@types/node': + specifier: 26.6.4 + version: 26.6.4 + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: 19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vanilla-extract/css': + specifier: 1.21.2 + version: 1.21.2 + '@vanilla-extract/vite-plugin': + specifier: 5.2.6 + version: 5.2.6(@types/node@26.6.4)(jiti@2.7.0)(supports-color@7.2.0)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1))(yaml@2.9.1) + '@vitest/coverage-v8': + specifier: 5.0.3 + version: 5.0.3(@vitest/browser@5.0.3)(vitest@5.0.3) + eslint: + specifier: 10.12.0 + version: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + happy-dom: + specifier: 20.14.5 + version: 20.14.5 + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.3.2 + version: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1) + vitest: + specifier: 5.0.3 + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + + packages/ui/graph/ui: + dependencies: + '@exadev/process-graph-domain': + specifier: 1.1.1 + version: link:../../../core/graph/domain + '@exadev/process-graph-projection': + specifier: 1.1.0 + version: link:../../../core/graph/projection + '@exadev/process-graph-react': + specifier: 0.0.0 + version: link:../react + '@exadev/process-graph-schema': + specifier: 1.1.0 + version: link:../../../core/graph/schema + '@vanilla-extract/recipes': + specifier: 0.5.7 + version: 0.5.7(@vanilla-extract/css@1.21.2) + devDependencies: + '@arethetypeswrong/cli': + specifier: 0.18.5 + version: 0.18.5 + '@stryker-mutator/core': + specifier: 10.0.0 + version: 10.0.0(@types/node@26.6.4) + '@stryker-mutator/typescript-checker': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(typescript@6.0.3) + '@stryker-mutator/vitest-runner': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(vitest@5.0.3) + '@testing-library/dom': + specifier: 10.4.2 + version: 10.4.2 + '@testing-library/react': + specifier: 16.3.3 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@types/node': + specifier: 26.6.4 + version: 26.6.4 + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: 19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vanilla-extract/css': + specifier: 1.21.2 + version: 1.21.2 + '@vanilla-extract/vite-plugin': + specifier: 5.2.6 + version: 5.2.6(@types/node@26.6.4)(jiti@2.7.0)(supports-color@7.2.0)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1))(yaml@2.9.1) + '@vitest/coverage-v8': + specifier: 5.0.3 + version: 5.0.3(@vitest/browser@5.0.3)(vitest@5.0.3) + eslint: + specifier: 10.12.0 + version: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + happy-dom: + specifier: 20.14.5 + version: 20.14.5 + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.3.2 + version: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1) + vitest: + specifier: 5.0.3 + version: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) packages: @@ -845,14 +1082,26 @@ packages: resolution: {integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@8.0.5': resolution: {integrity: sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + '@babel/core@8.0.6': resolution: {integrity: sha512-5zwYt1V4ji3mXKKRIAkohSJ29WepxNwxQi+7y7P5AcVPh1dcxnZBU2cfxHnPwS+u9+lZnXPsv30TQaF2xg+Tfg==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + '@babel/generator@8.0.6': resolution: {integrity: sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==} engines: {node: ^22.18.0 || >=24.11.0} @@ -861,6 +1110,10 @@ packages: resolution: {integrity: sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@8.0.6': resolution: {integrity: sha512-HTrtZ4+iA0+kzdbCgvdSk1U4pEl3BvpGnlUbualCGNbrVhhQvhgBzurTUmqpWyvyHbWYWaIfdx2mOlL9LJjsew==} engines: {node: ^22.18.0 || >=24.11.0} @@ -871,6 +1124,10 @@ packages: peerDependencies: '@babel/core': ^8.0.0 + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + '@babel/helper-globals@8.0.6': resolution: {integrity: sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==} engines: {node: ^22.18.0 || >=24.11.0} @@ -879,10 +1136,20 @@ packages: resolution: {integrity: sha512-GLe05QD98BkNFTkjaqeqF9QSRoHLKrrB4tpoplyQuuPrQJ72rtmkM4wvqJLX/sjZPqkbK5MUYSsuUTqdJoOVjA==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@8.0.0': resolution: {integrity: sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@8.0.6': resolution: {integrity: sha512-YUi+3X0cb0v97xKhuqkLdrwQtP6XXv1eVbhLRCFyMwG481UskbMQk4mjH7K/Jg3oJ1KLskvFwwq4kF/QnABkWQ==} engines: {node: ^22.18.0 || >=24.11.0} @@ -893,6 +1160,10 @@ packages: resolution: {integrity: sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@8.0.1': resolution: {integrity: sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==} engines: {node: ^22.18.0 || >=24.11.0} @@ -925,10 +1196,18 @@ packages: resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@8.0.0': resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + '@babel/helpers@8.0.5': resolution: {integrity: sha512-fQtPOXjYOYv85PIdwotp2TJGVYOycX0PQq+l844fFAxOULtBy8BVF35GyeueX0r4KvDthqPH5xAI1clQPk/2uA==} engines: {node: ^22.18.0 || >=24.11.0} @@ -961,6 +1240,12 @@ packages: peerDependencies: '@babel/core': ^8.0.0 + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-typescript@8.0.3': resolution: {integrity: sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==} engines: {node: ^22.18.0 || >=24.11.0} @@ -1027,10 +1312,22 @@ packages: peerDependencies: '@babel/core': ^8.0.0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/template@8.0.0': resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@8.0.6': resolution: {integrity: sha512-ilYV+qLStGzSKLipJlETdvdtpK3rAuwfCmCoGKv15MU4EoeIuLkespSNoNLFGOPSciEHx9TLWtN5mv0fz5w5mQ==} engines: {node: ^22.18.0 || >=24.11.0} @@ -1160,6 +1457,9 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + '@es-joy/jsdoccomment@0.97.0': resolution: {integrity: sha512-EP8uoFfh6+GsdGCduYtmWAW0h7AO+Ayik9Vh5YbA2r/3N6lmJKkCNZX+q3QBXC1K6ixjQ/9igF2b7WVvLm063g==} engines: {node: ^22.22.2 || >=24.15.0} @@ -1579,9 +1879,15 @@ packages: '@types/node': optional: true + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.4.0-beta.0': resolution: {integrity: sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -2197,6 +2503,25 @@ packages: oxlint: optional: true + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@turbo/darwin-64@2.11.7': resolution: {integrity: sha512-Z2LFOxwbiJueD1miByXJS/BrW21vv6YaQXZustty5jEX4vQYw6zyh101Sz1QO7WG0mqBzKagZ/DoAeeujI0/Sw==} cpu: [x64] @@ -2230,9 +2555,30 @@ packages: '@tybys/wasm-util@0.10.4': resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.12': + resolution: {integrity: sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.9': + resolution: {integrity: sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -2272,9 +2618,23 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + '@typescript-eslint/eslint-plugin@8.71.0': resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2408,6 +2768,31 @@ packages: resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vanilla-extract/babel-plugin-debug-ids@1.2.2': + resolution: {integrity: sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw==} + + '@vanilla-extract/compiler@0.7.2': + resolution: {integrity: sha512-VslwMJ01taDhJmMo0+A0SWn2dloMnYi0+BbvLuXXlqK4ufVRwXsq/U/Fp8ANkw3jevmQXy+JL7VtIQdjEtylDQ==} + + '@vanilla-extract/css@1.21.2': + resolution: {integrity: sha512-ehF/tmv2MxQwOJB1DicALUqnjZTnmY9Y7J2ccKB578JzZpYeL6sLAUqbaXo1taw1Erp0qBq0I4Kejs0uU/pBfg==} + + '@vanilla-extract/integration@8.0.10': + resolution: {integrity: sha512-01IB5gbrgTe8IIrtfRXXTmACl5D8Enzqp2cKbCWaMKXmnoilXXVCPbJoA96q88PXkNDXsXepCxUugMvEmL3c7A==} + + '@vanilla-extract/private@1.0.9': + resolution: {integrity: sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==} + + '@vanilla-extract/recipes@0.5.7': + resolution: {integrity: sha512-Fvr+htdyb6LVUu+PhH61UFPhwkjgDEk8L4Zq9oIdte42sntpKrgFy90MyTRtGwjVALmrJ0pwRUVr8UoByYeW8A==} + peerDependencies: + '@vanilla-extract/css': ^1.0.0 + + '@vanilla-extract/vite-plugin@5.2.6': + resolution: {integrity: sha512-mGk0YEan+NyeoxEO51LfxOdtw2JBI+Yb840FGg+zvG6tVU3YfDgKGPiGuk5oT8jqzfsm7H89DyjOVCpl6Bnn0w==} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/browser-playwright@5.0.3': resolution: {integrity: sha512-rYBHwIFL/ZtI6dl0rbrAatScXCrvX+TL2BX2AGOigkC1Y6u3rIWw8KMQJJMS4drSa1ksMCmZU2wpgwdE7kqu3g==} peerDependencies: @@ -2461,6 +2846,22 @@ packages: '@vitest/utils@5.0.3': resolution: {integrity: sha512-77fDTZv8eu1CzlIfgPU2yrl5MXSUaUAOvgdZEeVm3X3WIxUubIX2SforqS6176lT32HCF6uBVMYVqfHxw7o6eg==} + '@xyflow/react@12.12.0': + resolution: {integrity: sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.83': + resolution: {integrity: sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2512,6 +2913,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -2533,16 +2938,73 @@ packages: argv-formatter@1.0.0: resolution: {integrity: sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + array-includes@3.2.0: + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + ast-v8-to-istanbul@1.0.7: resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -2558,6 +3020,9 @@ packages: bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + brace-expansion@5.0.12: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} @@ -2571,6 +3036,14 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + cacheable@2.5.0: resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} @@ -2578,6 +3051,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} @@ -2621,6 +3098,9 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + clean-stack@5.3.0: resolution: {integrity: sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==} engines: {node: '>=14.16'} @@ -2681,6 +3161,12 @@ packages: compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -2766,30 +3252,113 @@ packages: resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} engines: {node: '>=12'} - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} - decode-named-character-reference@1.3.0: - resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} - des.js@1.1.0: + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deep-object-diff@1.1.9: + resolution: {integrity: sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + des.js@1.1.0: resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} detect-libc@2.1.2: @@ -2810,6 +3379,13 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -2983,6 +3559,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + emojilib@2.4.0: resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} @@ -2990,6 +3569,10 @@ packages: resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} engines: {node: '>=14'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-ci@11.2.0: resolution: {integrity: sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA==} engines: {node: ^18.17 || >=20.6.1} @@ -3005,6 +3588,14 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -3013,6 +3604,10 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-iterator-helpers@1.4.0: + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} + engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -3020,6 +3615,18 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} + engines: {node: '>= 0.4'} + es-toolkit@1.52.0: resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} @@ -3068,6 +3675,12 @@ packages: '@eslint/json': ^2.1.0 eslint: '>=10.0.0' + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + eslint-plugin-prettier@5.5.6: resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3082,6 +3695,18 @@ packages: eslint-config-prettier: optional: true + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + eslint-plugin-tsdoc@0.5.4: resolution: {integrity: sha512-TEtalhim3aByMLXWmPRYadb91Y2K2yQWYPOXmp8x/NYtrHsE0/mdlDe9idWBB2oF+eUa88P5pgLIZbXjjjibDg==} peerDependencies: @@ -3141,6 +3766,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eval@0.1.8: + resolution: {integrity: sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==} + engines: {node: '>= 0.8'} + execa@10.0.1: resolution: {integrity: sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw==} engines: {node: '>=22'} @@ -3240,6 +3869,10 @@ packages: resolution: {integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==} engines: {node: '>=6'} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -3265,6 +3898,17 @@ packages: resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} engines: {node: '>=18'} + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -3297,6 +3941,10 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + get-tsconfig@4.14.3: resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} @@ -3318,6 +3966,10 @@ packages: resolution: {integrity: sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==} engines: {node: '>=18'} + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3333,6 +3985,14 @@ packages: engines: {node: '>=0.4.7'} hasBin: true + happy-dom@20.14.5: + resolution: {integrity: sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==} + engines: {node: '>=20.0.0'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -3341,10 +4001,21 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hashery@1.5.1: resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} engines: {node: '>=20'} @@ -3353,6 +4024,12 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -3443,25 +4120,81 @@ packages: resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} engines: {node: ^20.17.0 || >=22.9.0} + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-core-module@2.17.0: resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} engines: {node: '>= 0.4'} + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -3474,6 +4207,18 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3482,13 +4227,40 @@ packages: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + is-unicode-supported@2.1.0: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3496,10 +4268,17 @@ packages: resolution: {integrity: sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==} engines: {node: ^18.17 || >=20.6.1} + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + java-properties@1.0.2: resolution: {integrity: sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==} engines: {node: '>= 0.6.0'} + javascript-stringify@2.1.0: + resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -3569,6 +4348,10 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + katex@0.16.47: resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} hasBin: true @@ -3581,6 +4364,13 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3703,6 +4493,10 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -3710,6 +4504,13 @@ packages: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@1.4.2: resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} @@ -3782,6 +4583,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + media-query-parser@2.0.2: + resolution: {integrity: sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==} + meow@13.2.0: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} @@ -3902,9 +4706,18 @@ packages: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + modern-ahocorasick@1.1.0: + resolution: {integrity: sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==} + module-replacements@2.11.0: resolution: {integrity: sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA==} @@ -3957,6 +4770,10 @@ packages: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} + node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} + engines: {node: '>= 0.4'} + node-releases@2.0.57: resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} engines: {node: '>=18'} @@ -4063,6 +4880,26 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + obug@2.2.1: resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} @@ -4075,6 +4912,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} + engines: {node: '>= 0.4'} + oxc-parser@0.150.0: resolution: {integrity: sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4209,6 +5050,9 @@ packages: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.63.0: resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} engines: {node: '>=20'} @@ -4223,6 +5067,10 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -4240,6 +5088,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.1: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} @@ -4251,6 +5103,9 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -4288,6 +5143,21 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + read-package-up@11.0.0: resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} engines: {node: '>=18'} @@ -4307,6 +5177,14 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + registry-auth-token@5.1.1: resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} engines: {node: '>=14'} @@ -4319,6 +5197,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-like@0.1.2: + resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} + reserved-identifiers@1.2.0: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} engines: {node: '>=18'} @@ -4339,6 +5220,11 @@ packages: engines: {node: '>= 0.4'} hasBin: true + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + rolldown@1.2.12: resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4351,12 +5237,27 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + semantic-release@25.0.9: resolution: {integrity: sha512-bxve7csK0/Txr++CkfrmV+X1r4jqiSOw2WsSad9E2S68R+ZfLBwDn8IceM8WfiOmKQIHgsQc1cNA8Dzg7U75pg==} engines: {node: ^22.14.0 || >= 24.10.0} @@ -4366,11 +5267,27 @@ packages: resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==} engines: {node: '>=12'} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4451,6 +5368,10 @@ packages: std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + stream-combiner2@1.1.1: resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==} @@ -4470,6 +5391,29 @@ packages: resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} engines: {node: '>=20'} + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.1.0: + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -4677,6 +5621,22 @@ packages: resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} engines: {node: '>=20'} + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.5: + resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + typed-inject@5.0.0: resolution: {integrity: sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==} engines: {node: '>=18'} @@ -4702,6 +5662,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -4711,6 +5674,10 @@ packages: resolution: {integrity: sha512-PnKvRX+Fbxl/usVlTr/31tXIOVAH+8OdC9qaxUQcvurwEnoc3Vk/ipi2Ce8IjMxzK9byfSGdsUBjqqJGc1o5lw==} engines: {node: '>=14'} + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + underscore@1.13.8: resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} @@ -4780,6 +5747,11 @@ packages: resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4798,6 +5770,11 @@ packages: resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} engines: {node: '>=18.12.0'} + vite-node@6.0.0: + resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + vite@8.3.2: resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4892,11 +5869,31 @@ packages: web-worker@1.5.0: resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + which-command@0.1.0: resolution: {integrity: sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==} engines: {node: '>=22'} hasBin: true + which-typed-array@1.1.24: + resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==} + engines: {node: '>= 0.4'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -4942,6 +5939,9 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml-eslint-parser@2.1.0: resolution: {integrity: sha512-1zo9KRfp6vIAXBEhWAz09ex3hUwh3T+/6dGbGteHvNseA0Uk4FgQnPES55klZ6cDzSy9FpgKS0D/CoLUvCCj4w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -4975,9 +5975,30 @@ packages: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + zod@4.6.5: resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -5033,8 +6054,30 @@ snapshots: '@babel/helper-validator-identifier': 8.0.6 js-tokens: 10.0.0 + '@babel/compat-data@7.29.7': {} + '@babel/compat-data@8.0.5': {} + '@babel/core@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@7.2.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/core@8.0.6': dependencies: '@babel/code-frame': 8.0.6 @@ -5054,6 +6097,14 @@ snapshots: obug: 2.2.1 verkit: 0.3.2 + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/generator@8.0.6': dependencies: '@babel/parser': 8.0.6 @@ -5067,6 +6118,14 @@ snapshots: dependencies: '@babel/types': 8.0.6 + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.3 + lru-cache: 5.1.1 + semver: 6.3.1 + '@babel/helper-compilation-targets@8.0.6': dependencies: '@babel/compat-data': 8.0.5 @@ -5086,6 +6145,8 @@ snapshots: '@babel/traverse': 8.0.6 verkit: 0.3.2 + '@babel/helper-globals@7.29.7': {} + '@babel/helper-globals@8.0.6': {} '@babel/helper-member-expression-to-functions@8.0.5': @@ -5093,11 +6154,27 @@ snapshots: '@babel/traverse': 8.0.6 '@babel/types': 8.0.6 + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-imports@8.0.0': dependencies: '@babel/traverse': 8.0.6 '@babel/types': 8.0.6 + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@8.0.6(@babel/core@8.0.6)': dependencies: '@babel/core': 8.0.6 @@ -5109,6 +6186,8 @@ snapshots: dependencies: '@babel/types': 8.0.6 + '@babel/helper-plugin-utils@7.29.7': {} + '@babel/helper-plugin-utils@8.0.1(@babel/core@8.0.6)': dependencies: '@babel/core': 8.0.6 @@ -5133,8 +6212,15 @@ snapshots: '@babel/helper-validator-identifier@8.0.6': {} + '@babel/helper-validator-option@7.29.7': {} + '@babel/helper-validator-option@8.0.0': {} + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + '@babel/helpers@8.0.5': dependencies: '@babel/template': 8.0.0 @@ -5165,6 +6251,11 @@ snapshots: '@babel/core': 8.0.6 '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-typescript@8.0.3(@babel/core@8.0.6)': dependencies: '@babel/core': 8.0.6 @@ -5239,12 +6330,32 @@ snapshots: '@babel/plugin-transform-modules-commonjs': 8.0.1(@babel/core@8.0.6) '@babel/plugin-transform-typescript': 8.0.6(@babel/core@8.0.6) + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@babel/template@8.0.0': dependencies: '@babel/code-frame': 8.0.6 '@babel/parser': 8.0.6 '@babel/types': 8.0.6 + '@babel/traverse@7.29.8(supports-color@7.2.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + '@babel/traverse@8.0.6': dependencies: '@babel/code-frame': 8.0.6 @@ -5423,6 +6534,8 @@ snapshots: tslib: 2.8.1 optional: true + '@emotion/hash@0.9.2': {} + '@es-joy/jsdoccomment@0.97.0': dependencies: '@types/estree': 1.0.9 @@ -5568,7 +6681,7 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@exadev/eslint-config@2.36.3(@eslint/json@2.1.0)(@eslint/markdown@8.0.3(supports-color@7.2.0))(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript-eslint@8.71.0(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(typescript@6.0.3)': + '@exadev/eslint-config@2.36.3(@eslint/json@2.1.0)(@eslint/markdown@8.0.3(supports-color@7.2.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)))(eslint-plugin-react-hooks@7.1.1(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(eslint-plugin-react@7.37.5(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)))(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript-eslint@8.71.0(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@eslint/js': 10.0.1(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)) '@microsoft/tsdoc': 0.17.0 @@ -5587,6 +6700,9 @@ snapshots: optionalDependencies: '@eslint/json': 2.1.0 '@eslint/markdown': 8.0.3(supports-color@7.2.0) + eslint-plugin-jsx-a11y: 6.10.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)) + eslint-plugin-react: 7.37.5(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)) + eslint-plugin-react-hooks: 7.1.1(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) transitivePeerDependencies: - oxlint - supports-color @@ -5758,11 +6874,21 @@ snapshots: optionalDependencies: '@types/node': 26.6.4 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/gen-mapping@0.4.0-beta.0': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.6.0': {} @@ -6315,7 +7441,7 @@ snapshots: '@stryker-mutator/util': 10.0.0 semver: 7.8.5 tslib: 2.8.1 - vitest: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + vitest: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) '@stylistic/eslint-plugin@6.0.0-beta.6(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))': dependencies: @@ -6327,6 +7453,27 @@ snapshots: optionalDependencies: eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.2 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@turbo/darwin-64@2.11.7': optional: true @@ -6350,11 +7497,34 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.12': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.12 + + '@types/d3-zoom@3.0.9': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.12 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -6389,8 +7559,22 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + '@types/unist@3.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.2': + dependencies: + '@types/node': 26.6.4 + '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -6584,13 +7768,100 @@ snapshots: '@typescript-eslint/types': 8.71.0 eslint-visitor-keys: 5.0.1 + '@vanilla-extract/babel-plugin-debug-ids@1.2.2(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@vanilla-extract/compiler@0.7.2(@types/node@26.6.4)(jiti@2.7.0)(supports-color@7.2.0)(yaml@2.9.1)': + dependencies: + '@vanilla-extract/css': 1.21.2 + '@vanilla-extract/integration': 8.0.10(supports-color@7.2.0) + vite: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1) + vite-node: 6.0.0(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - babel-plugin-macros + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + '@vanilla-extract/css@1.21.2': + dependencies: + '@emotion/hash': 0.9.2 + '@vanilla-extract/private': 1.0.9 + css-what: 6.2.2 + csstype: 3.2.3 + dedent: 1.7.2 + deep-object-diff: 1.1.9 + deepmerge: 4.3.1 + lru-cache: 10.4.3 + media-query-parser: 2.0.2 + modern-ahocorasick: 1.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - babel-plugin-macros + + '@vanilla-extract/integration@8.0.10(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@vanilla-extract/babel-plugin-debug-ids': 1.2.2(supports-color@7.2.0) + '@vanilla-extract/css': 1.21.2 + dedent: 1.7.2 + esbuild: 0.25.12 + eval: 0.1.8 + find-up: 5.0.0 + javascript-stringify: 2.1.0 + mlly: 1.8.2 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + '@vanilla-extract/private@1.0.9': {} + + '@vanilla-extract/recipes@0.5.7(@vanilla-extract/css@1.21.2)': + dependencies: + '@vanilla-extract/css': 1.21.2 + + '@vanilla-extract/vite-plugin@5.2.6(@types/node@26.6.4)(jiti@2.7.0)(supports-color@7.2.0)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1))(yaml@2.9.1)': + dependencies: + '@vanilla-extract/compiler': 0.7.2(@types/node@26.6.4)(jiti@2.7.0)(supports-color@7.2.0)(yaml@2.9.1) + '@vanilla-extract/integration': 8.0.10(supports-color@7.2.0) + vite: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - babel-plugin-macros + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + '@vitest/browser-playwright@5.0.3(playwright@1.63.0)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.3)': dependencies: '@vitest/browser': 5.0.3(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.3) '@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) playwright: 1.63.0 tinyrainbow: 3.2.0 - vitest: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + vitest: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) transitivePeerDependencies: - bufferutil - msw @@ -6607,7 +7878,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.2.0 - vitest: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + vitest: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) ws: 8.22.0 transitivePeerDependencies: - bufferutil @@ -6625,7 +7896,7 @@ snapshots: obug: 2.2.1 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + vitest: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) optionalDependencies: '@vitest/browser': 5.0.3(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.3) @@ -6658,7 +7929,7 @@ snapshots: pathe: 2.0.3 sirv: 3.0.2 tinyrainbow: 3.2.0 - vitest: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + vitest: 5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) '@vitest/utils@5.0.3': dependencies: @@ -6666,6 +7937,31 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.2.0 + '@xyflow/react@12.12.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@xyflow/system': 0.0.83 + classcat: 5.0.5 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + zustand: 4.5.7(@types/react@19.3.0)(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.83': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.12 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.9 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: acorn: 8.18.0 @@ -6718,6 +8014,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -6730,16 +8028,93 @@ snapshots: argv-formatter@1.0.0: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + array-ify@1.0.0: {} + array-includes@3.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} + ast-v8-to-istanbul@1.0.7: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 js-tokens: 10.0.0 + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.13.0: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} baseline-browser-mapping@2.11.27: {} @@ -6748,6 +8123,11 @@ snapshots: bottleneck@2.19.5: {} + brace-expansion@1.1.21: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -6764,6 +8144,12 @@ snapshots: node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.3) + buffer-image-size@0.6.4: + dependencies: + '@types/node': 26.6.4 + + cac@7.0.0: {} + cacheable@2.5.0: dependencies: '@cacheable/memory': 2.2.0 @@ -6777,6 +8163,13 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6811,6 +8204,8 @@ snapshots: cjs-module-lexer@1.4.3: {} + classcat@5.0.5: {} + clean-stack@5.3.0: dependencies: escape-string-regexp: 5.0.0 @@ -6871,6 +8266,10 @@ snapshots: array-ify: 1.0.0 dot-prop: 5.3.0 + concat-map@0.0.1: {} + + confbox@0.1.8: {} + config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -6953,6 +8352,66 @@ snapshots: dependencies: type-fest: 1.4.0 + css-what@6.2.2: {} + + csstype@3.2.3: {} + + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 @@ -6963,10 +8422,28 @@ snapshots: dependencies: character-entities: 2.0.2 + dedent@1.7.2: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} + deep-object-diff@1.1.9: {} + + deepmerge@4.3.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + dequal@2.0.3: {} des.js@1.1.0: @@ -6988,6 +8465,12 @@ snapshots: dependencies: path-type: 4.0.0 + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dom-accessibility-api@0.5.16: {} + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 @@ -7022,10 +8505,14 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + emojilib@2.4.0: {} empathic@2.1.0: {} + entities@7.0.1: {} + env-ci@11.2.0: dependencies: execa: 8.0.1 @@ -7039,19 +8526,122 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.4 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.2 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.5 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.24 + es-define-property@1.0.1: {} es-errors@1.3.0: {} + es-iterator-helpers@1.4.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + es-module-lexer@2.3.2: {} es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 - es-toolkit@1.52.0: {} + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 - esbuild@0.25.12: + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.4 + + es-to-primitive@1.3.4: + dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es-toolkit@1.52.0: {} + + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 '@esbuild/android-arm': 0.25.12 @@ -7126,6 +8716,25 @@ snapshots: '@eslint/json': 2.1.0 eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + eslint-plugin-jsx-a11y@6.10.2(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.2.0 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.13.0 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + hasown: 2.0.4 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)))(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(prettier@3.9.9): dependencies: eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) @@ -7135,6 +8744,39 @@ snapshots: optionalDependencies: eslint-config-prettier: 10.1.8(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)) + eslint-plugin-react-hooks@7.1.1(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/parser': 7.29.9 + eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + hermes-parser: 0.25.1 + zod: 4.6.5 + zod-validation-error: 4.0.2(zod@4.6.5) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0)): + dependencies: + array-includes: 3.2.0 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.4.0 + eslint: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + estraverse: 5.3.0 + hasown: 2.0.4 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.7 + semver: 6.3.1 + string.prototype.matchall: 4.1.0 + string.prototype.repeat: 1.0.0 + eslint-plugin-tsdoc@0.5.4(eslint@10.12.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: '@microsoft/tsdoc': 0.17.1 @@ -7227,6 +8869,11 @@ snapshots: esutils@2.0.3: {} + eval@0.1.8: + dependencies: + '@types/node': 26.6.4 + require-like: 0.1.2 + execa@10.0.1: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -7347,6 +8994,10 @@ snapshots: flru@1.0.2: {} + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + format@0.2.2: {} formatly@0.7.1: @@ -7367,6 +9018,22 @@ snapshots: function-timeout@1.0.2: {} + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -7400,6 +9067,12 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + get-tsconfig@4.14.3: dependencies: resolve-pkg-maps: 1.0.0 @@ -7425,6 +9098,11 @@ snapshots: globals@17.13.0: {} + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + gopd@1.2.0: {} graceful-fs@4.2.10: {} @@ -7440,12 +9118,39 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + happy-dom@20.14.5: + dependencies: + '@types/node': 26.6.4 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.2 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + has-bigints@1.1.0: {} + has-flag@3.0.0: {} has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hashery@1.5.1: dependencies: hookified: 1.15.1 @@ -7454,6 +9159,12 @@ snapshots: dependencies: function-bind: 1.1.2 + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + highlight.js@10.7.3: {} hook-std@4.0.0: {} @@ -7530,34 +9241,142 @@ snapshots: ini@6.0.0: {} + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-arrayish@0.2.1: {} + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + is-core-module@2.17.0: dependencies: hasown: 2.0.4 + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + is-extglob@2.1.1: {} + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-number@7.0.0: {} is-obj@2.0.0: {} is-plain-obj@4.1.0: {} + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + is-stream@3.0.0: {} is-stream@4.0.1: {} + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.24 + is-unicode-supported@2.1.0: {} + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} + isexe@2.0.0: {} issue-parser@7.0.2: @@ -7568,8 +9387,19 @@ snapshots: lodash.isstring: 4.0.1 lodash.uniqby: 4.7.0 + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + java-properties@1.0.2: {} + javascript-stringify@2.1.0: {} + jiti@2.6.1: {} jiti@2.7.0: {} @@ -7621,6 +9451,13 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.2.0 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + katex@0.16.47: dependencies: commander: 8.3.0 @@ -7645,6 +9482,12 @@ snapshots: yaml: 2.9.1 zod: 4.6.5 + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -7741,10 +9584,20 @@ snapshots: longest-streak@3.1.0: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@10.4.3: {} lru-cache@11.5.3: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + magic-string@1.4.2: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -7918,6 +9771,10 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + media-query-parser@2.0.2: + dependencies: + '@babel/runtime': 7.29.7 + meow@13.2.0: {} merge-stream@2.0.0: {} @@ -8151,8 +10008,21 @@ snapshots: dependencies: brace-expansion: 5.0.12 + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.21 + minimist@1.2.8: {} + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + modern-ahocorasick@1.1.0: {} + module-replacements@2.11.0: {} mri@1.2.0: {} @@ -8196,6 +10066,13 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 + node-exports-info@1.6.2: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + node-releases@2.0.57: {} normalize-package-data@6.0.2: @@ -8229,6 +10106,38 @@ snapshots: object-inspect@1.13.4: {} + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + obug@2.2.1: {} onetime@6.0.0: @@ -8244,6 +10153,13 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + own-keys@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + oxc-parser@0.150.0: dependencies: '@oxc-project/types': 0.150.0 @@ -8391,6 +10307,12 @@ snapshots: find-up: 2.1.0 load-json-file: 4.0.0 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + playwright-core@1.63.0: {} playwright@1.63.0: @@ -8399,6 +10321,8 @@ snapshots: pngjs@7.0.0: {} + possible-typed-array-names@1.1.0: {} + postcss@8.5.28: dependencies: nanoid: 3.3.19 @@ -8413,6 +10337,12 @@ snapshots: prettier@3.9.9: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 @@ -8421,6 +10351,12 @@ snapshots: progress@2.0.3: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + proto-list@1.2.4: {} proxy-agent-negotiate@1.1.0: {} @@ -8451,6 +10387,17 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react@19.3.0: {} + read-package-up@11.0.0: dependencies: find-up-simple: 1.0.1 @@ -8489,6 +10436,26 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + registry-auth-token@5.1.1: dependencies: '@pnpm/npm-conf': 3.0.3 @@ -8497,6 +10464,8 @@ snapshots: require-from-string@2.0.2: {} + require-like@0.1.2: {} + reserved-identifiers@1.2.0: {} resolve-from@4.0.0: {} @@ -8512,6 +10481,15 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.17.0 + node-exports-info: 1.6.2 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + rolldown@1.2.12: dependencies: '@oxc-project/types': 0.152.0 @@ -8541,10 +10519,31 @@ snapshots: dependencies: mri: 1.2.0 + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + safer-buffer@2.1.2: {} + scheduler@0.28.0: {} + semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3): dependencies: '@semantic-release/commit-analyzer': 13.0.1(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0) @@ -8582,8 +10581,32 @@ snapshots: semver-regex@4.0.5: {} + semver@6.3.1: {} + semver@7.8.5: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -8671,6 +10694,11 @@ snapshots: std-env@4.3.0: {} + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + stream-combiner2@1.1.1: dependencies: duplexer2: 0.1.4 @@ -8695,6 +10723,57 @@ snapshots: get-east-asian-width: 1.7.0 strip-ansi: 7.2.0 + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.matchall@4.1.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -8869,6 +10948,38 @@ snapshots: dependencies: tagged-tag: 1.0.0 + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.5: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + typed-inject@5.0.0: {} typed-rest-client@2.3.1: @@ -8894,11 +11005,20 @@ snapshots: typescript@6.0.3: {} + ufo@1.6.4: {} + uglify-js@3.19.3: optional: true unbash@4.0.12: {} + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + underscore@1.13.8: {} undici-types@8.9.0: {} @@ -8959,6 +11079,10 @@ snapshots: url-join@5.0.0: {} + use-sync-external-store@1.7.0(react@19.3.0): + dependencies: + react: 19.3.0 + util-deprecate@1.0.2: {} validate-npm-package-license@3.0.4: @@ -8972,6 +11096,27 @@ snapshots: verkit@0.3.2: {} + vite-node@6.0.0(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1): + dependencies: + cac: 7.0.0 + es-module-lexer: 2.3.2 + obug: 2.2.1 + pathe: 2.0.3 + vite: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 @@ -8985,7 +11130,7 @@ snapshots: jiti: 2.7.0 yaml: 2.9.1 - vitest@5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)): + vitest@5.0.3(@types/node@26.6.4)(@vitest/browser-playwright@5.0.3)(@vitest/coverage-v8@5.0.3)(@vitest/ui@5.0.3)(happy-dom@20.14.5)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) @@ -9006,6 +11151,7 @@ snapshots: '@vitest/browser-playwright': 5.0.3(playwright@1.63.0)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.3) '@vitest/coverage-v8': 5.0.3(@vitest/browser@5.0.3)(vitest@5.0.3) '@vitest/ui': 5.0.3(vitest@5.0.3) + happy-dom: 20.14.5 transitivePeerDependencies: - msw @@ -9015,8 +11161,51 @@ snapshots: web-worker@1.5.0: {} + whatwg-mimetype@3.0.0: {} + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.24 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + which-command@0.1.0: {} + which-typed-array@1.1.24: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -9045,6 +11234,8 @@ snapshots: y18n@5.0.8: {} + yallist@3.1.1: {} + yaml-eslint-parser@2.1.0: dependencies: eslint-visitor-keys: 5.0.1 @@ -9079,6 +11270,17 @@ snapshots: yoctocolors@2.2.0: {} + zod-validation-error@4.0.2(zod@4.6.5): + dependencies: + zod: 4.6.5 + zod@4.6.5: {} + zustand@4.5.7(@types/react@19.3.0)(react@19.3.0): + dependencies: + use-sync-external-store: 1.7.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + react: 19.3.0 + zwitch@2.0.4: {} diff --git a/tsconfig.build.json b/tsconfig.build.json index 19cf48c..2c6a826 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -34,6 +34,15 @@ { "path": "packages/adapters/graph/sqlite-wasm/tsconfig.build.json" }, + { + "path": "packages/ui/graph/react/tsconfig.build.json" + }, + { + "path": "packages/ui/graph/ui/tsconfig.build.json" + }, + { + "path": "packages/ui/graph/react-flow/tsconfig.build.json" + }, { "path": "packages/targets/process-graph/tsconfig.build.json" }