From d809fee526120db4baea48206ae7d1e8473990b3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 03:10:11 +0100 Subject: [PATCH 01/14] feat(react): let an owner keep the graph selection useGraphSelection takes a `selected` option: when the key is present the hook reads the selection from it, keeps none of its own and only reports through onChange what a click or key asks for. Several views of one model, such as a canvas and a list, can then share one selection held by their owner. --- packages/ui/graph/react/README.md | 2 +- packages/ui/graph/react/src/selection.ts | 18 ++++++++++++----- .../graph/react/src/selection.unit.test.tsx | 20 +++++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/ui/graph/react/README.md b/packages/ui/graph/react/README.md index e11f551..93a4275 100644 --- a/packages/ui/graph/react/README.md +++ b/packages/ui/graph/react/README.md @@ -74,7 +74,7 @@ An `ElementFilter` holds a set of statuses, actors and sources; an empty set doe ### 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. +`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. An owner that keeps the selection itself, so that several views of one model share it, passes it as `selected` (the key present, even when no element is selected): the hook then reads the selection from there, keeps none of its own, and only reports through `onChange` what a click or key asks for. ## Components diff --git a/packages/ui/graph/react/src/selection.ts b/packages/ui/graph/react/src/selection.ts index 900a0df..71c9a9f 100644 --- a/packages/ui/graph/react/src/selection.ts +++ b/packages/ui/graph/react/src/selection.ts @@ -34,8 +34,12 @@ export interface GraphSelection { /** How a selection starts and what it tells its owner. */ export interface GraphSelectionOptions { - /** The element selected at first. */ + /** The element selected at first, when the hook keeps the selection itself. */ readonly initial?: Id | undefined; + /** + * The selected element, when the owner keeps the selection: give this key, even as `undefined`, and the hook reads the selection from it and keeps none of its own, reporting each change a click or key asks for through `onChange`. Several views of one model then share one selection. Leave the key out for the hook to keep the selection, starting from `initial`. + */ + readonly selected?: 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. */ @@ -58,7 +62,7 @@ const moves: Readonly< /** * 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. + * @param options - The initial selection or the owner's 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 = ( @@ -66,14 +70,18 @@ export const useGraphSelection = ( options: GraphSelectionOptions = {}, ): GraphSelection => { const { initial, onChange, focus } = options; - const [chosen, setChosen] = useState(initial); + const controlled = "selected" in options; + const [kept, setKept] = useState(initial); + const chosen = controlled ? options.selected : kept; const items = useRef(new Map()); const select = useCallback( (id: Id | undefined) => { - setChosen(id); + if (!controlled) { + setKept(id); + } onChange?.(id); }, - [onChange], + [controlled, onChange], ); const selected = chosen !== undefined && order.includes(chosen) ? chosen : undefined; diff --git a/packages/ui/graph/react/src/selection.unit.test.tsx b/packages/ui/graph/react/src/selection.unit.test.tsx index 08a08b4..6330692 100644 --- a/packages/ui/graph/react/src/selection.unit.test.tsx +++ b/packages/ui/graph/react/src/selection.unit.test.tsx @@ -45,6 +45,26 @@ describe("useGraphSelection options", () => { expect(focus.mock.calls).toEqual([["first"], ["second"]]); }); + test("reads an owner's selection and leaves changing it to the owner", () => { + const onChange = vi.fn<(id: string | undefined) => void>(); + const { rerender } = render( + , + ); + + fireEvent.keyDown(screen.getByRole("listbox"), { key: "ArrowDown" }); + + expect(onChange.mock.calls).toEqual([["third"]]); + expect(screen.getByRole("status").textContent).toBe("second"); + + rerender(); + + expect(screen.getByRole("status").textContent).toBe("third"); + + rerender(); + + expect(screen.getByRole("status").textContent).toBe("none"); + }); + test("ignores keys it does not navigate with", () => { const onChange = vi.fn<(id: string | undefined) => void>(); render(); From 4e1637daafac4ad92308426e9166144420ef8610 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 03:10:26 +0100 Subject: [PATCH 02/14] feat(react-flow): show a selection the owner keeps ProcessFlow, and so ProcessView, takes a `selected` prop passed to useGraphSelection's option of the same name, so the canvas highlights the selection an app holds in its address or shares with other views, and reports the changes a click or key asks for through onSelect. --- packages/ui/graph/react-flow/README.md | 2 +- .../ui/graph/react-flow/src/process-flow.tsx | 5 +++ .../react-flow/src/process-flow.unit.test.tsx | 42 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/ui/graph/react-flow/README.md b/packages/ui/graph/react-flow/README.md index 16246ea..e6f07cd 100644 --- a/packages/ui/graph/react-flow/README.md +++ b/packages/ui/graph/react-flow/README.md @@ -37,7 +37,7 @@ const lanes = { id: "lanes", notation: "swimlane" } as const; | `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. +`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; `onSelect`, called with each element a click or key selects; and `selected`, for an owner that keeps the selection so the canvas and its other views share it (the key present, even as `undefined`, makes the canvas show it and only report changes through `onSelect`). Keep the run's parts, the projection and the layout options stable between renders: a change lays the view out again. ## Layout diff --git a/packages/ui/graph/react-flow/src/process-flow.tsx b/packages/ui/graph/react-flow/src/process-flow.tsx index ba6f0ea..2a08503 100644 --- a/packages/ui/graph/react-flow/src/process-flow.tsx +++ b/packages/ui/graph/react-flow/src/process-flow.tsx @@ -62,6 +62,10 @@ export interface ProcessFlowProps { 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; + /** + * The selected element, when the owner keeps the selection, as `useGraphSelection`'s option of the same name: give the key, even as `undefined`, and the canvas shows this selection and reports through `onSelect` what a click or key asks for, so the canvas and other views of the model share one selection. Leave it out for the canvas to keep its own. + */ + readonly selected?: I["element"] | undefined; } /** @@ -107,6 +111,7 @@ export const ProcessFlow = ( const { selected, select, containerProps } = useGraphSelection(shown, { onChange: onSelect, focus, + ...("selected" in props ? { selected: props.selected } : {}), }); const drawn = useMemo(() => { 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 index c3780d3..c05e2e4 100644 --- a/packages/ui/graph/react-flow/src/process-flow.unit.test.tsx +++ b/packages/ui/graph/react-flow/src/process-flow.unit.test.tsx @@ -153,4 +153,46 @@ describe("ProcessFlow", () => { expect(onSelect.mock.calls).toEqual([["start"], ["a-submit"]]); expect(document.activeElement).toBe(nodeOf(container, "a-submit")); }); + + test("shows the owner's selection and leaves changing it to the owner", async () => { + const onSelect = vi.fn<(id: string | undefined) => void>(); + const { engine } = stubEngine(); + const layout = { engine }; + const draw = (selected: OrderIds["element"] | undefined) => ( +
+ +
+ ); + const { container, rerender } = render(draw("a-pack")); + await waitFor(() => { + nodeOf(container, "a-pack"); + }); + + expect(nodeOf(container, "a-pack").classList.contains("selected")).toBe( + true, + ); + + fireEvent.click(nodeOf(container, "a-submit")); + + expect(onSelect).toHaveBeenLastCalledWith("a-submit"); + expect(nodeOf(container, "a-submit").classList.contains("selected")).toBe( + false, + ); + + rerender(draw("a-submit")); + + expect(nodeOf(container, "a-submit").classList.contains("selected")).toBe( + true, + ); + expect(nodeOf(container, "a-pack").classList.contains("selected")).toBe( + false, + ); + }); }); From d0496782733767b902468843d849bd59ea083c0d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 03:23:08 +0100 Subject: [PATCH 03/14] feat(react): follow a model the store does not have yet useModel shows a model whose load answers NOT_FOUND as `missing` and keeps following it, so the write that creates it, such as the first sync of a scope, or a save after a delete, loads it. Any other error still ends in `failed`. --- packages/ui/graph/react/README.md | 28 ++-- packages/ui/graph/react/package.json | 3 +- packages/ui/graph/react/src/model.ts | 31 +++- .../ui/graph/react/src/model.unit.test.tsx | 61 +++++++- pnpm-lock.yaml | 141 ++++++++++++------ 5 files changed, 190 insertions(+), 74 deletions(-) diff --git a/packages/ui/graph/react/README.md b/packages/ui/graph/react/README.md index 93a4275..9c6e780 100644 --- a/packages/ui/graph/react/README.md +++ b/packages/ui/graph/react/README.md @@ -48,25 +48,25 @@ const Cards = ({ run }) => { ## 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. | +| Hook | Result | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `useModel(client, modelId)` | `loading`, then `loaded` with the stored model, overlay and revision, `missing` while the store has no such model, 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`. +`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 the store does not have, yet or any more, is `missing` and still followed, so the write that creates it (a sync, a save) loads it; any other error ends in `failed`. ### Filtering diff --git a/packages/ui/graph/react/package.json b/packages/ui/graph/react/package.json index f186e9b..cb61bc8 100644 --- a/packages/ui/graph/react/package.json +++ b/packages/ui/graph/react/package.json @@ -55,7 +55,8 @@ "@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.1.0" + "@exadev/process-graph-store-contract": "1.1.0", + "@orpc/client": "2.0.0-beta.41" }, "devDependencies": { "@arethetypeswrong/cli": "0.18.5", diff --git a/packages/ui/graph/react/src/model.ts b/packages/ui/graph/react/src/model.ts index fb12b1b..657e309 100644 --- a/packages/ui/graph/react/src/model.ts +++ b/packages/ui/graph/react/src/model.ts @@ -2,11 +2,15 @@ import type { GraphStoreClient, StoredModel, } from "@exadev/process-graph-store-contract"; +import { ORPCError } from "@orpc/client"; import { useEffect, useState } from "react"; -/** Where a model being followed from a store stands. */ +/** + * Where a model being followed from a store stands. `missing` is a model the store does not have, yet or any more: it is still followed, so the first write that creates it, such as a sync, loads it. + */ export type ModelState = | { readonly status: "loading" } + | { readonly status: "missing" } | { readonly status: "loaded"; readonly stored: StoredModel } | { readonly status: "failed"; readonly error: unknown }; @@ -19,12 +23,16 @@ export interface ModelClient { } const loading: ModelState = { status: "loading" }; +const missing: ModelState = { status: "missing" }; + +/** Revisions are positive, so every change to a model is newer than this: the revision a missing model is treated as having. */ +const noRevision = 0; /** - * 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. + * 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. A model the store does not have is `missing` and still followed, so a model that is created later, or deleted and saved again, is loaded when it is. * @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`). + * @returns `loading` until the first load; then `loaded` with the model, its overlay and revision, `missing` while the store has no such model (its load answers `NOT_FOUND`), or `failed` with any other error a load or the subscription ended with. */ export const useModel = (client: ModelClient, modelId: string): ModelState => { const [state, setState] = useState<{ @@ -40,16 +48,25 @@ export const useModel = (client: ModelClient, modelId: string): ModelState => { setState({ client, modelId, value }); }; const load = async (): Promise => { - const stored = await client.v1.models.load({ modelId }, { signal }); - settle({ status: "loaded", stored }); + try { + const stored = await client.v1.models.load({ modelId }, { signal }); + settle({ status: "loaded", stored }); - return stored.revision; + return stored.revision; + } catch (error) { + if (error instanceof ORPCError && error.code === "NOT_FOUND") { + settle(missing); + + return noRevision; + } + throw error; + } }; 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. + // 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 answers NOT_FOUND, which shows it missing and keeps following it. if (change.kind === "missed" || change.revision > loaded) { loaded = await load(); } diff --git a/packages/ui/graph/react/src/model.unit.test.tsx b/packages/ui/graph/react/src/model.unit.test.tsx index 69eae8a..796fcfb 100644 --- a/packages/ui/graph/react/src/model.unit.test.tsx +++ b/packages/ui/graph/react/src/model.unit.test.tsx @@ -57,7 +57,7 @@ describe("useModel", () => { }); }); - test("fails when the model is deleted", async () => { + test("shows a deleted model as missing and loads it again when it is saved", async () => { const client = await storeWithOrder(); const { result } = renderHook(() => useModel(client, "order")); await waitFor(() => { @@ -67,16 +67,67 @@ describe("useModel", () => { await client.v1.models.delete({ modelId: "order" }); await waitFor(() => { - expect(result.current.status).toBe("failed"); + expect(result.current).toEqual({ status: "missing" }); + }); + + const { revision } = await client.v1.models.save({ + modelId: "order", + model: order, + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + status: "loaded", + stored: { revision }, + }); }); }); - test("fails for a model the store does not have", async () => { + test("follows a model the store does not have yet and loads it once it is created", async () => { const client = await storeWithOrder(); - const { result } = renderHook(() => useModel(client, "missing")); + const { result } = renderHook(() => useModel(client, "later")); await waitFor(() => { - expect(result.current.status).toBe("failed"); + expect(result.current).toEqual({ status: "missing" }); + }); + + const { revision } = await client.v1.items.upsert({ + modelId: "later", + items: { + participants: [], + activities: [ + { kind: "activity", id: "s:a#1", label: "First", source: "s:a#1" }, + ], + transitions: [], + overlay: {}, + }, + }); + + await waitFor(() => { + expect(result.current).toMatchObject({ + status: "loaded", + stored: { modelId: "later", revision }, + }); + }); + }); + + test("fails with any error other than a missing model", async () => { + const client = await storeWithOrder(); + const failing = { + v1: { + models: { + load: async () => Promise.reject(new Error("the store is closed")), + }, + changes: client.v1.changes, + }, + }; + const { result } = renderHook(() => useModel(failing, "order")); + + await waitFor(() => { + expect(result.current).toMatchObject({ + status: "failed", + error: new Error("the store is closed"), + }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5d1c70..0017fd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -273,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) packages/adapters/graph/source-github-issues: dependencies: @@ -313,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) packages/adapters/graph/sqlite-node: dependencies: @@ -362,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) packages/adapters/graph/sqlite-wasm: dependencies: @@ -393,7 +393,7 @@ importers: version: 26.6.4 '@vitest/browser-playwright': specifier: 5.0.3 - version: 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) + version: 5.0.3(playwright@1.63.0)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))(vitest@5.0.3) '@vitest/coverage-v8': specifier: 5.0.3 version: 5.0.3(@vitest/browser@5.0.3)(vitest@5.0.3) @@ -411,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) packages/adapters/graph/store-sqlite: dependencies: @@ -466,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) packages/core/graph/dag: devDependencies: @@ -499,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) packages/core/graph/domain: dependencies: @@ -542,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) packages/core/graph/layout: dependencies: @@ -585,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) packages/core/graph/projection: dependencies: @@ -625,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) packages/core/graph/schema: dependencies: @@ -665,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) packages/core/graph/source-contract: dependencies: @@ -717,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) packages/core/graph/store-contract: dependencies: @@ -772,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) packages/targets/process-graph: dependencies: @@ -818,7 +818,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) packages/ui/graph/react: dependencies: @@ -837,6 +837,9 @@ importers: '@exadev/process-graph-store-contract': specifier: 1.1.0 version: link:../../../core/graph/store-contract + '@orpc/client': + specifier: 2.0.0-beta.41 + version: 2.0.0-beta.41 devDependencies: '@arethetypeswrong/cli': specifier: 0.18.5 @@ -888,7 +891,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) packages/ui/graph/react-flow: dependencies: @@ -946,7 +949,7 @@ importers: 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) + version: 5.2.6(@types/node@26.6.4)(jiti@2.7.0)(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(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) @@ -967,10 +970,10 @@ importers: 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) + version: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(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)) + 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)(terser@5.51.2)(yaml@2.9.1)) packages/ui/graph/ui: dependencies: @@ -1022,7 +1025,7 @@ importers: 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) + version: 5.2.6(@types/node@26.6.4)(jiti@2.7.0)(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(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) @@ -1043,10 +1046,10 @@ importers: 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) + version: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(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)) + 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)(terser@5.51.2)(yaml@2.9.1)) packages: @@ -1892,6 +1895,9 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} @@ -3036,6 +3042,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} engines: {node: '>=4.0'} @@ -3150,6 +3159,9 @@ packages: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} @@ -5336,6 +5348,9 @@ packages: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -5526,6 +5541,11 @@ packages: resolution: {integrity: sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==} engines: {node: '>=14.16'} + terser@5.51.2: + resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==} + engines: {node: '>=10'} + hasBin: true + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -6891,6 +6911,12 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + optional: true + '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': @@ -7441,7 +7467,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(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: @@ -7774,12 +7800,12 @@ snapshots: 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)': + '@vanilla-extract/compiler@0.7.2(@types/node@26.6.4)(jiti@2.7.0)(supports-color@7.2.0)(terser@5.51.2)(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) + vite: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1) + vite-node: 6.0.0(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -7834,11 +7860,11 @@ snapshots: 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)': + '@vanilla-extract/vite-plugin@5.2.6(@types/node@26.6.4)(jiti@2.7.0)(supports-color@7.2.0)(terser@5.51.2)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(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/compiler': 0.7.2(@types/node@26.6.4)(jiti@2.7.0)(supports-color@7.2.0)(terser@5.51.2)(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) + vite: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -7855,30 +7881,30 @@ snapshots: - 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)': + '@vitest/browser-playwright@5.0.3(playwright@1.63.0)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(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)) + '@vitest/browser': 5.0.3(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(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)(terser@5.51.2)(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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@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/browser@5.0.3(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))(vitest@5.0.3)': dependencies: '@blazediff/core': 1.10.0 - '@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + '@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) '@vitest/ui': 5.0.3(vitest@5.0.3) '@vitest/utils': 5.0.3 magic-string: 1.4.2 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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) ws: 8.22.0 transitivePeerDependencies: - bufferutil @@ -7896,9 +7922,9 @@ 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)(happy-dom@20.14.5)(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)(terser@5.51.2)(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) + '@vitest/browser': 5.0.3(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))(vitest@5.0.3) '@vitest/istanbul-lib-coverage@1.0.2': {} @@ -7906,14 +7932,14 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.2 - '@vitest/mocker@5.0.3(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1))': + '@vitest/mocker@5.0.3(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.3 estree-walker: 3.0.3 magic-string: 1.4.2 optionalDependencies: - vite: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1) + vite: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1) '@vitest/pretty-format@5.0.3': dependencies: @@ -7929,7 +7955,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)(happy-dom@20.14.5)(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)(terser@5.51.2)(yaml@2.9.1)) '@vitest/utils@5.0.3': dependencies: @@ -8144,6 +8170,9 @@ snapshots: node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.3) + buffer-from@1.1.2: + optional: true + buffer-image-size@0.6.4: dependencies: '@types/node': 26.6.4 @@ -8257,6 +8286,9 @@ snapshots: commander@15.0.0: {} + commander@2.20.3: + optional: true + commander@8.3.0: {} comment-parser@1.4.8: {} @@ -10663,6 +10695,12 @@ snapshots: source-map-js@1.2.2: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + optional: true + source-map@0.6.1: {} source-map@0.7.6: {} @@ -10867,6 +10905,14 @@ snapshots: type-fest: 2.19.0 unique-string: 3.0.0 + terser@5.51.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 + commander: 2.20.3 + source-map-support: 0.5.21 + optional: true + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -11096,13 +11142,13 @@ snapshots: verkit@0.3.2: {} - vite-node@6.0.0(@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)(terser@5.51.2)(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) + vite: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -11117,7 +11163,7 @@ snapshots: - tsx - yaml - vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1): + vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -11128,12 +11174,13 @@ snapshots: '@types/node': 26.6.4 fsevents: 2.3.3 jiti: 2.7.0 + terser: 5.51.2 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@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)(terser@5.51.2)(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)) + '@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -11144,11 +11191,11 @@ snapshots: tinybench: 6.2.0 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1) + vite: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1) why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 26.6.4 - '@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/browser-playwright': 5.0.3(playwright@1.63.0)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(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 From 8a54a70f46b153ecc8895ed164ca03ceedd2b1f1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 03:27:03 +0100 Subject: [PATCH 04/14] feat: add graph-web package The web app, as a Vite PWA and a composition root: the GitHub issues source runs in the page and the SQLite wasm store in its own worker on the origin private file system, both reached through their contracts' oRPC clients. It picks scopes across organisations, syncs them page by page with progress, cancelling and resuming from a checkpoint kept on the device, and shows the model in each view with the ready, blocked, critical path, hand-off, cycle and problem lists, the selected element's why, blockers and impact, filters, run state controls, a form to add records by hand and expansion of the scope to a repository a dependency leads to. The address carries the view state, so a link reproduces the view. The token is kept in local storage on the device and sent only to GitHub's API. The service worker precaches the shell and the SQLite wasm, so the last sync renders offline, and a new version waits for the person to reload. The icons are rendered from one SVG at build time, so none is committed. --- README.md | 1 + knip.config.ts | 10 + packages/targets/graph-web/AGENTS.md | 1 + packages/targets/graph-web/CLAUDE.md | 1 + packages/targets/graph-web/README.md | 72 + packages/targets/graph-web/build/icons.ts | 136 + packages/targets/graph-web/eslint.config.ts | 16 + packages/targets/graph-web/index.html | 18 + packages/targets/graph-web/package.json | 79 + packages/targets/graph-web/src/add-form.tsx | 208 ++ packages/targets/graph-web/src/addition.ts | 143 + packages/targets/graph-web/src/app.css.ts | 250 ++ packages/targets/graph-web/src/app.tsx | 196 ++ packages/targets/graph-web/src/browser.ts | 166 ++ .../targets/graph-web/src/element-details.tsx | 121 + packages/targets/graph-web/src/failure.ts | 69 + packages/targets/graph-web/src/format.ts | 10 + .../targets/graph-web/src/github-issue.ts | 42 + packages/targets/graph-web/src/main.tsx | 64 + packages/targets/graph-web/src/notices.tsx | 131 + packages/targets/graph-web/src/ports.ts | 67 + packages/targets/graph-web/src/scope-input.ts | 67 + .../graph-web/src/scope-input.unit.test.ts | 32 + .../targets/graph-web/src/scope-picker.tsx | 113 + .../targets/graph-web/src/store.worker.ts | 4 + packages/targets/graph-web/src/sync-panel.tsx | 91 + packages/targets/graph-web/src/sync-record.ts | 30 + packages/targets/graph-web/src/sync.ts | 222 ++ packages/targets/graph-web/src/tabs.tsx | 89 + .../targets/graph-web/src/token-panel.tsx | 106 + packages/targets/graph-web/src/url-state.ts | 220 ++ .../graph-web/src/url-state.unit.test.ts | 107 + packages/targets/graph-web/src/use-sync.ts | 152 + .../targets/graph-web/src/view-boundary.tsx | 54 + .../targets/graph-web/src/view-state-hooks.ts | 42 + packages/targets/graph-web/src/views.ts | 103 + .../targets/graph-web/src/views.unit.test.ts | 128 + packages/targets/graph-web/src/work-lists.tsx | 287 ++ packages/targets/graph-web/src/workspace.tsx | 316 ++ .../targets/graph-web/test/app.smoke.test.ts | 89 + packages/targets/graph-web/tsconfig.json | 10 + packages/targets/graph-web/tsconfig.node.json | 16 + packages/targets/graph-web/turbo.json | 17 + packages/targets/graph-web/vite.config.ts | 112 + packages/targets/graph-web/vitest.config.ts | 29 + pnpm-lock.yaml | 2548 +++++++++++++++-- 46 files changed, 6605 insertions(+), 180 deletions(-) create mode 120000 packages/targets/graph-web/AGENTS.md create mode 120000 packages/targets/graph-web/CLAUDE.md create mode 100644 packages/targets/graph-web/README.md create mode 100644 packages/targets/graph-web/build/icons.ts create mode 100644 packages/targets/graph-web/eslint.config.ts create mode 100644 packages/targets/graph-web/index.html create mode 100644 packages/targets/graph-web/package.json create mode 100644 packages/targets/graph-web/src/add-form.tsx create mode 100644 packages/targets/graph-web/src/addition.ts create mode 100644 packages/targets/graph-web/src/app.css.ts create mode 100644 packages/targets/graph-web/src/app.tsx create mode 100644 packages/targets/graph-web/src/browser.ts create mode 100644 packages/targets/graph-web/src/element-details.tsx create mode 100644 packages/targets/graph-web/src/failure.ts create mode 100644 packages/targets/graph-web/src/format.ts create mode 100644 packages/targets/graph-web/src/github-issue.ts create mode 100644 packages/targets/graph-web/src/main.tsx create mode 100644 packages/targets/graph-web/src/notices.tsx create mode 100644 packages/targets/graph-web/src/ports.ts create mode 100644 packages/targets/graph-web/src/scope-input.ts create mode 100644 packages/targets/graph-web/src/scope-input.unit.test.ts create mode 100644 packages/targets/graph-web/src/scope-picker.tsx create mode 100644 packages/targets/graph-web/src/store.worker.ts create mode 100644 packages/targets/graph-web/src/sync-panel.tsx create mode 100644 packages/targets/graph-web/src/sync-record.ts create mode 100644 packages/targets/graph-web/src/sync.ts create mode 100644 packages/targets/graph-web/src/tabs.tsx create mode 100644 packages/targets/graph-web/src/token-panel.tsx create mode 100644 packages/targets/graph-web/src/url-state.ts create mode 100644 packages/targets/graph-web/src/url-state.unit.test.ts create mode 100644 packages/targets/graph-web/src/use-sync.ts create mode 100644 packages/targets/graph-web/src/view-boundary.tsx create mode 100644 packages/targets/graph-web/src/view-state-hooks.ts create mode 100644 packages/targets/graph-web/src/views.ts create mode 100644 packages/targets/graph-web/src/views.unit.test.ts create mode 100644 packages/targets/graph-web/src/work-lists.tsx create mode 100644 packages/targets/graph-web/src/workspace.tsx create mode 100644 packages/targets/graph-web/test/app.smoke.test.ts create mode 100644 packages/targets/graph-web/tsconfig.json create mode 100644 packages/targets/graph-web/tsconfig.node.json create mode 100644 packages/targets/graph-web/turbo.json create mode 100644 packages/targets/graph-web/vite.config.ts create mode 100644 packages/targets/graph-web/vitest.config.ts diff --git a/README.md b/README.md index b02ef80..cea1db8 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config | [`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. | +| [`graph-web`](packages/targets/graph-web/README.md) | The web app: an installable, offline-first PWA that syncs GitHub issues into SQLite on the device and shows the work in every view, with what is ready, what is blocked and why, linkable by address. | ## Conventions diff --git a/knip.config.ts b/knip.config.ts index e5c9aaf..c485e8d 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -32,6 +32,16 @@ const config: KnipConfig = { entry: ["src/index.{ts,tsx}"], project: ["src/**/*.{ts,tsx}!"], }, + // An app rather than a library: its entries are the page's module and the store's worker, which index.html and main.tsx load, and its build script, tests and Playwright suite. + "packages/targets/graph-web": { + entry: [ + "src/main.tsx!", + "src/store.worker.ts!", + "build/icons.ts", + "e2e/**/*.ts", + ], + project: ["src/**/*.{ts,tsx}!", "build/**/*.ts", "e2e/**/*.ts"], + }, }, ignoreDependencies: [ // Resolved by name via each semantic-release plugin's own `preset: "conventionalcommits"` option string. diff --git a/packages/targets/graph-web/AGENTS.md b/packages/targets/graph-web/AGENTS.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/targets/graph-web/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/targets/graph-web/CLAUDE.md b/packages/targets/graph-web/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/targets/graph-web/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/targets/graph-web/README.md b/packages/targets/graph-web/README.md new file mode 100644 index 0000000..4cc6c92 --- /dev/null +++ b/packages/targets/graph-web/README.md @@ -0,0 +1,72 @@ +# @exadev/process-graph-web + +[![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) + +The process-graph web app: an installable, offline-first PWA that syncs GitHub issues from any number of organisations and repositories into a SQLite store on the device and shows the work as a flowchart, swimlane, state machine or sequence diagram, with what is ready, what is blocked, what each piece of work holds up and why each has its status. It is a composition root and nothing else: the model, reasoning, projections, layout, source, store and components are the other packages of this repository, wired together in `src/main.tsx` through `AppPorts`. A private workspace package; it is deployed, not published. + +React 19, Vite, Vanilla Extract over graph-ui's theme contract, vite-plugin-pwa (Workbox). + +## Screens + +| Screen | What it does | +| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Token | Takes a GitHub token, says what it needs (read-only Issues and Metadata on a fine-grained token, or a classic token with no scopes for public repositories and `repo` for private ones) and where it goes. The field is write-only; a saved token is never shown again. | +| Scope | Any number of scopes: `owner`, `owner/repo`, a source-qualified scope such as `github:owner/repo`, or a pasted GitHub link. | +| Sync | Reads the scopes page by page and writes each page as it is read, announcing progress. Cancelling stops it between or during reads; a cancelled, rate-limited or interrupted sync resumes from its checkpoint, which is kept on the device so it survives a reload. | +| View | The model as a flowchart, swimlane, state machine or sequence diagram (`ProcessView` from graph-react-flow), filtered, with the critical path marked and a legend. A state machine follows an entity; a sequence follows the model's own scenario, else the critical path. | +| Lists | Ready, blocked (with what each waits on), the critical path, hand-offs between owners, cycles and structural problems: the command line's `ready`, `blocked`, `path`, `handoffs`, `cycles` and `validate`. | +| Details | The selected element's status and the rule that decided it, its blockers and what it holds up (`why`, `blocked`, `impact`), its issue on GitHub, buttons that set its run state (`set`), and, for an issue in a repository outside the scope, one that adds that repository and syncs it. | +| Filters | Status, actor and source collection, as graph-ui's `FilterPanel`; the view and the lists follow them. | +| Add | Adds a participant, activity, decision, state, event, artefact or transition by hand, as the command line's `add` does; the store refuses a change that would leave the model invalid. | +| Notices | Offline; a new version ready to load; parts of the link that could not be used; a sync that stopped (a rate limit with its reset time, a token GitHub refused, anything else); and the parts of the scope the source could not read. | + +The theme follows the system's colour scheme until a person picks light or dark. Every list and the canvas move their selection with the arrow keys, Home, End and Escape, the view and list tabs with the arrow keys, Home and End, and a skip link leads past the header. + +## The address + +The query string carries the view, so the address is always a link to what is on screen. A part at its default is left out, and a link that sets nothing opens the default view. + +| Parameter | Meaning | +| --------------------- | --------------------------------------------------------------------------- | +| `scope` (repeatable) | A source-qualified scope, in order. | +| `view` | `flowchart`, `swimlane` (the default), `state` or `sequence`. | +| `list` | `ready` (the default), `blocked`, `path`, `handoffs`, `cycles` or `issues`. | +| `select` | The selected element's identifier. | +| `status` (repeatable) | A status to show: `done`, `unknown`, `skipped`, `ready`, `blocked`. | +| `actor` (repeatable) | A participant whose work to show. | +| `source` (repeatable) | A source collection whose work to show, such as `github:owner/repo`. | +| `subject` | The entity a state machine follows. | +| `step` (repeatable) | A transition a sequence diagram follows, in order. | + +A value the app cannot use is left out and named in a notice. The scopes, sorted, name the model in the store, so the same scopes in any order are the same model, and someone opening a link reads its scopes into their own store. + +## Data and privacy + +The store is graph-sqlite-wasm: SQLite in a dedicated worker on the origin private file system, shared by every tab under a Web Lock, with each tab hearing the others' writes. It needs no `Cross-Origin-Opener-Policy` or `Cross-Origin-Embedder-Policy` headers, and neither the dev server nor the preview server sets them. + +The GitHub source runs in the page through the source contract's in-process oRPC client. The token is kept in the origin's local storage on this device. It is read when a sync starts, handed to the source's token port and sent only in the `Authorization` header of requests to `api.github.com`; it is never logged, shown or put in React state. The device also keeps each model's last sync record (its checkpoint and gaps) and the theme preference. + +## Offline and updates + +The service worker precaches the app shell: the page, scripts, styles, icons and the SQLite wasm. The model is read from the origin private file system, so the last sync renders offline and a reload offline works; syncing waits for the network. A new version of the service worker waits until the person chooses to reload into it from the notice, so a running sync is never cut short. + +## Building + +`pnpm build` runs `vite build`. `build/icons.ts` renders the icons at build time: one SVG glyph in the theme contract's colours, rasterised by resvg to the 192 and 512 pixel PNGs, `any` and `maskable`, that Chromium's install criteria ask for, and emitted into the bundle, so no generated image is committed. The manifest's theme and background colours come from graph-ui's theme values. `GRAPH_WEB_BASE` sets the base path the app is served under, such as `/process-graph/`; the manifest's `start_url` and `scope` follow it. + +## Tests + +The screen tests render the app with Testing Library in happy-dom against the store contract's in-memory fake and the source contract's fake source, each through its oRPC client, laid out by a stub engine. `test/app.smoke.test.ts` checks the built `dist/`: the manifest, the rendered icons and the precache. + +The end-to-end tests run Playwright in Chromium against `vite preview` of the production build. A local fixture server stands in for GitHub's GraphQL API; the browser's requests to `api.github.com` are routed to it, so the browser still addresses GitHub. They sync organisations and see the graph and lists, check that the token reaches no host but `api.github.com`, check installability (Chromium's own installability report, the manifest and an active service worker) and the absence of cross-origin isolation, reload offline and see the last sync, set a run state in one tab and see it in another, expand the scope on demand, and see a rate limit's reset time. + +## Commands + +```sh +pnpm build # vite build -> dist/ +pnpm dev # the Vite dev server +pnpm preview # the production build, served as a static host would +pnpm test # screen and unit tests in happy-dom +pnpm test:smoke # the built dist/ +pnpm test:e2e # Playwright against the production build (after pnpm build) +``` diff --git a/packages/targets/graph-web/build/icons.ts b/packages/targets/graph-web/build/icons.ts new file mode 100644 index 0000000..dc7320a --- /dev/null +++ b/packages/targets/graph-web/build/icons.ts @@ -0,0 +1,136 @@ +import { Resvg } from "@resvg/resvg-js"; +import type { Plugin } from "vite"; + +/** The colours an icon is drawn in, taken from the theme contract's values by the caller. */ +export interface IconColours { + /** The tile behind the glyph. */ + readonly tile: string; + /** The glyph: the process's steps and the flow between them. */ + readonly glyph: string; +} + +/** Whether an icon may be cropped to a platform's mask shape (`maskable`) or is shown as drawn (`any`). */ +export type IconPurpose = "any" | "maskable"; + +/** One icon file the app ships. */ +export interface IconFile { + /** The file's path below the app's base, as the manifest and the page name it. */ + readonly path: string; + /** The square edge in pixels, absent for the scalable SVG. */ + readonly size: number | undefined; + readonly purpose: IconPurpose; + readonly type: "image/png" | "image/svg+xml"; +} + +/** The icon edge Chromium's install criteria name for the launcher. */ +const launcherIconSize = 192; +/** The icon edge Chromium's install criteria name for the splash screen. */ +const splashIconSize = 512; + +/** The PNG edges a browser asks of an installable app. */ +export const pngIconSizes = [launcherIconSize, splashIconSize] as const; + +/** The square the glyph is drawn in, in SVG user units. Every coordinate in the glyph is in this square. */ +const canvas = 100; + +/** The rounding of the tile's corners for the `any` icon; a maskable icon is a full-bleed square that the platform crops. */ +const cornerRadius = 22; + +/** + * The glyph: a process that starts, branches into two steps and joins again, drawn as SVG in the `canvas` square. Every step lies inside the maskable safe zone, the centred circle of radius 40% of the edge that every mask shape keeps, so one glyph serves both purposes. + * @param colour - The glyph's colour. + */ +const glyph = (colour: string): string => + `` + + '' + + '' + + '' + + ""; + +/** + * The app's icon as SVG: the glyph on a tile. + * @param colours - The tile and glyph colours. + * @param purpose - `any` draws a rounded tile; `maskable` fills the square so a platform's mask never shows a transparent corner. + * @returns The SVG document. + */ +export const iconSvg = (colours: IconColours, purpose: IconPurpose): string => + [ + ``, + ``, + glyph(colours.glyph), + "", + ].join(""); + +/** + * Every icon file the app ships: the scalable SVG for the page, and a PNG of each install size for each purpose. + * @returns The files, in the order the manifest lists them. + */ +export const iconFiles = (): readonly IconFile[] => [ + { + path: "icons/icon.svg", + size: undefined, + purpose: "any", + type: "image/svg+xml", + }, + ...(["any", "maskable"] as const).flatMap((purpose) => + pngIconSizes.map((size): IconFile => ({ + path: `icons/${purpose}-${String(size)}.png`, + size, + purpose, + type: "image/png", + })), + ), +]; + +/** + * Renders one icon file's bytes: the SVG as text, or the SVG rasterised to its PNG edge by resvg. + * @param file - The icon file. + * @param colours - The tile and glyph colours. + * @returns The file's content. + */ +export const renderIcon = ( + file: IconFile, + colours: IconColours, +): string | Uint8Array => { + const svg = iconSvg(colours, file.purpose); + if (file.size === undefined) { + return svg; + } + + return new Resvg(svg, { fitTo: { mode: "width", value: file.size } }) + .render() + .asPng(); +}; + +/** + * The Vite plugin that makes the icons: at build time it renders every icon file and emits it into the bundle, where the service worker's precache finds it; the dev server answers the same paths from memory. Nothing is written to the source tree, so no generated image is ever committed. + * @param colours - The tile and glyph colours. + * @returns The plugin. + */ +export const pwaIcons = (colours: IconColours): Plugin => ({ + name: "process-graph-web-icons", + generateBundle() { + for (const file of iconFiles()) { + this.emitFile({ + type: "asset", + fileName: file.path, + source: renderIcon(file, colours), + }); + } + }, + configureServer(server) { + const base = server.config.base; + server.middlewares.use((request, response, next) => { + const file = iconFiles().find( + (candidate) => request.url === `${base}${candidate.path}`, + ); + if (file === undefined) { + next(); + + return; + } + response.setHeader("content-type", file.type); + response.end(renderIcon(file, colours)); + }); + }, +}); diff --git a/packages/targets/graph-web/eslint.config.ts b/packages/targets/graph-web/eslint.config.ts new file mode 100644 index 0000000..13ba924 --- /dev/null +++ b/packages/targets/graph-web/eslint.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "eslint/config"; +import { version } from "react"; +import { packageLintConfig } from "../../../eslint.shared.ts"; + +/** + * A browser app: runtime `src/` imports nothing Node-only and has the browser's globals, and `.tsx` files get the React, hooks and accessibility rules for the React this package installs. The build script, Vite and Playwright configuration and the end-to-end tests run in Node under `tsconfig.node.json`. Vite's output and Playwright's reports are produced files, not source. + */ +export default defineConfig( + packageLintConfig({ + tsconfigRootDir: import.meta.dirname, + isomorphic: true, + browserGlobals: true, + react: { version }, + additionalIgnores: ["dev-dist", "playwright-report", "test-results"], + }), +); diff --git a/packages/targets/graph-web/index.html b/packages/targets/graph-web/index.html new file mode 100644 index 0000000..678353a --- /dev/null +++ b/packages/targets/graph-web/index.html @@ -0,0 +1,18 @@ + + + + + + + + + Process graph + + +
+ + + diff --git a/packages/targets/graph-web/package.json b/packages/targets/graph-web/package.json new file mode 100644 index 0000000..013ac79 --- /dev/null +++ b/packages/targets/graph-web/package.json @@ -0,0 +1,79 @@ +{ + "name": "@exadev/process-graph-web", + "version": "0.0.0", + "private": true, + "description": "The process-graph web app: an installable, offline-first PWA that syncs GitHub issues into a SQLite store on the origin private file system and shows the work as a flowchart, swimlane, state machine or sequence diagram, with what is ready, what is blocked and why.", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/process-graph.git", + "directory": "packages/targets/graph-web" + }, + "homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/targets/graph-web#readme", + "bugs": { + "url": "https://github.com/ExaDev/process-graph/issues" + }, + "license": "MIT", + "scripts": { + "build": "turbo run _build", + "_build": "vite build", + "dev": "vite", + "preview": "vite preview", + "lint": "turbo run _lint", + "_lint": "eslint . --cache --max-warnings 0", + "_lint:fix": "eslint . --fix --cache --max-warnings 0", + "typecheck": "turbo run _typecheck", + "_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit", + "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:e2e": "turbo run _test:e2e", + "_test:e2e": "playwright test" + }, + "keywords": [ + "process-model", + "pwa", + "offline", + "github-issues", + "react" + ], + "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-react-flow": "0.0.0", + "@exadev/process-graph-schema": "1.1.0", + "@exadev/process-graph-source-contract": "1.0.1", + "@exadev/process-graph-source-github-issues": "1.0.1", + "@exadev/process-graph-sqlite-wasm": "1.0.2", + "@exadev/process-graph-store-contract": "1.0.3", + "@exadev/process-graph-ui": "0.0.0", + "@orpc/client": "2.0.0-beta.41", + "@orpc/server": "2.0.0-beta.41", + "react": "19.3.0", + "react-dom": "19.3.0", + "zod": "4.6.5" + }, + "devDependencies": { + "@playwright/test": "1.63.0", + "@resvg/resvg-js": "2.6.2", + "@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", + "typescript": "6.0.3", + "vite": "8.3.2", + "vite-plugin-pwa": "2.0.0", + "vitest": "5.0.3", + "workbox-window": "7.4.1" + } +} diff --git a/packages/targets/graph-web/src/add-form.tsx b/packages/targets/graph-web/src/add-form.tsx new file mode 100644 index 0000000..360e4df --- /dev/null +++ b/packages/targets/graph-web/src/add-form.tsx @@ -0,0 +1,208 @@ +import type { ParticipantKind } from "@exadev/process-graph-schema"; +import { useId, useState, type SubmitEvent, type ReactElement } from "react"; +import { + field, + input, + muted, + primaryButton, + row, + section, + sectionHeading, +} from "./app.css.ts"; +import { + additionKinds, + additionOf, + participantKinds, + type Addition, + type AdditionFields, + type AdditionKind, +} from "./addition.ts"; +import { failureOf } from "./failure.ts"; + +export interface AddFormProps { + /** Adds the record to the model; rejects with the store's reason when the result would be invalid. */ + readonly onAdd: (addition: Addition) => Promise; +} + +const blank: AdditionFields = { + id: "", + label: "", + participantKind: "role", + actor: "", + parent: "", + deliverable: false, + source: "", + target: "", + guard: "", + trigger: "", +}; + +/** A text field of the add form. */ +interface TextField { + readonly name: Exclude< + keyof AdditionFields, + "participantKind" | "deliverable" + >; + readonly label: string; + readonly required: boolean; +} + +const needed = (name: TextField["name"], label: string): TextField => ({ + name, + label, + required: true, +}); +const optional = (name: TextField["name"], label: string): TextField => ({ + name, + label: `${label} (optional)`, + required: false, +}); + +/** The text fields each kind asks for, mirroring the command line's `add` arguments (required) and options (optional). */ +const textFields: Readonly> = { + participant: [needed("id", "Identifier"), needed("label", "Label")], + activity: [ + needed("id", "Identifier"), + needed("label", "Label"), + optional("actor", "Actor"), + ], + decision: [ + needed("id", "Identifier"), + needed("label", "Label"), + optional("actor", "Actor"), + ], + state: [ + needed("id", "Identifier"), + needed("label", "Label"), + optional("parent", "Parent state"), + ], + event: [needed("id", "Identifier"), needed("label", "Name")], + artefact: [needed("id", "Identifier"), needed("label", "Name")], + transition: [ + needed("source", "From"), + needed("target", "To, which waits on the first"), + optional("id", "Identifier"), + optional("guard", "Guard"), + optional("trigger", "Trigger event"), + ], +}; + +const isKind = (value: string): value is AdditionKind => + additionKinds.some((kind) => kind === value); + +const isParticipantKind = (value: string): value is ParticipantKind => + participantKinds.some((kind) => kind === value); + +/** + * Adds a participant, activity, decision, state, event, artefact or transition to the model by hand, as the command line's `add` does. The store checks the whole model on save, so a duplicate identifier or a dangling reference is refused and shown, and nothing changes. + * @param props - What adding does. + * @returns The form. + */ +export const AddForm = (props: AddFormProps): ReactElement => { + const { onAdd } = props; + const [kind, setKind] = useState("activity"); + const [fields, setFields] = useState(blank); + const [outcome, setOutcome] = useState(undefined); + const headingId = useId(); + const prefix = useId(); + + const submit = (event: SubmitEvent): void => { + event.preventDefault(); + const addition = additionOf(kind, fields); + onAdd(addition).then( + () => { + setOutcome(`Added ${addition.record.id}.`); + setFields(blank); + }, + (error: unknown) => { + setOutcome(`Not added: ${failureOf(error).message}`); + }, + ); + }; + + return ( +
+

+ Add to the model +

+
+ + {kind === "participant" ? ( + + ) : null} + {textFields[kind].map(({ name, label, required }) => ( + + ))} + {kind === "artefact" ? ( + + ) : null} +
+ +
+
+ {outcome === undefined ? null : ( +

+ {outcome} +

+ )} +
+ ); +}; diff --git a/packages/targets/graph-web/src/addition.ts b/packages/targets/graph-web/src/addition.ts new file mode 100644 index 0000000..781604d --- /dev/null +++ b/packages/targets/graph-web/src/addition.ts @@ -0,0 +1,143 @@ +import type { + ArtefactDef, + Element, + EventDef, + Model, + OpenIds, + Participant, + ParticipantKind, + Transition, +} from "@exadev/process-graph-schema"; + +/** A record to add to the model, by the part of the model it belongs to, as the command line's `add` takes them. */ +export type Addition = + | { readonly kind: "participant"; readonly record: Participant } + | { readonly kind: "element"; readonly record: Element } + | { readonly kind: "event"; readonly record: EventDef } + | { readonly kind: "artefact"; readonly record: ArtefactDef } + | { + readonly kind: "transition"; + readonly record: Transition; + }; + +/** What can be added, as the command line names it after `add`. */ +export const additionKinds = [ + "participant", + "activity", + "decision", + "state", + "event", + "artefact", + "transition", +] as const; +export type AdditionKind = (typeof additionKinds)[number]; + +/** The participant kinds, as the command line's `--kind` takes them. */ +export const participantKinds: readonly ParticipantKind[] = [ + "role", + "system", + "entity", +]; + +/** The fields of the add form, each trimmed; an empty optional field is left out of the record. */ +export interface AdditionFields { + readonly id: string; + /** A label, or for an event or artefact its name. */ + readonly label: string; + readonly participantKind: ParticipantKind; + /** The actor of an activity or decision. */ + readonly actor: string; + /** The parent of a state. */ + readonly parent: string; + readonly deliverable: boolean; + readonly source: string; + readonly target: string; + readonly guard: string; + readonly trigger: string; +} + +/** + * Builds the record a form describes, as the command line's `add` does: a transition's identifier defaults to `->`, the form sources use. + * @param kind - What to add. + * @param fields - The form's fields. + * @returns The addition. + */ +export const additionOf = ( + kind: AdditionKind, + fields: AdditionFields, +): Addition => { + const id = fields.id.trim(); + const label = fields.label.trim(); + const actor = fields.actor.trim(); + const parent = fields.parent.trim(); + const guard = fields.guard.trim(); + const trigger = fields.trigger.trim(); + if (kind === "participant") { + return { + kind: "participant", + record: { id, label, kind: fields.participantKind }, + }; + } + if (kind === "activity" || kind === "decision") { + return { + kind: "element", + record: { kind, id, label, ...(actor === "" ? {} : { actor }) }, + }; + } + if (kind === "state") { + return { + kind: "element", + record: { kind, id, label, ...(parent === "" ? {} : { parent }) }, + }; + } + if (kind === "event") { + return { kind: "event", record: { id, name: label } }; + } + if (kind === "artefact") { + return { + kind: "artefact", + record: fields.deliverable + ? { id, name: label, deliverable: true } + : { id, name: label }, + }; + } + const source = fields.source.trim(); + const target = fields.target.trim(); + + return { + kind: "transition", + record: { + id: id === "" ? `${source}->${target}` : id, + source, + target, + ...(guard === "" ? {} : { guard }), + ...(trigger === "" ? {} : { trigger }), + }, + }; +}; + +/** + * The model with one record appended to the part it belongs to. + * @param model - The model. + * @param addition - The record. + * @returns The new model; the store checks it when it is saved. + */ +export const withAddition = ( + model: Model, + addition: Addition, +): Model => { + if (addition.kind === "participant") { + return { ...model, participants: [...model.participants, addition.record] }; + } + if (addition.kind === "element") { + return { ...model, elements: [...model.elements, addition.record] }; + } + if (addition.kind === "event") { + return { ...model, events: [...model.events, addition.record] }; + } + if (addition.kind === "artefact") { + return { ...model, artefacts: [...model.artefacts, addition.record] }; + } + + return { ...model, transitions: [...model.transitions, addition.record] }; +}; diff --git a/packages/targets/graph-web/src/app.css.ts b/packages/targets/graph-web/src/app.css.ts new file mode 100644 index 0000000..5a1ad92 --- /dev/null +++ b/packages/targets/graph-web/src/app.css.ts @@ -0,0 +1,250 @@ +import { vars } from "@exadev/process-graph-ui"; +import { globalStyle, style, styleVariants } from "@vanilla-extract/css"; + +// Every value comes from graph-ui's theme contract, so a theme assigned to the contract restyles the app shell with the views inside it. + +globalStyle("html, body, #root", { + margin: 0, + height: "100%", +}); + +/** The whole app: the theme's surface, a header, any notices, then the work. */ +export const shell = style({ + display: "flex", + flexDirection: "column", + minHeight: "100%", + boxSizing: "border-box", +}); + +export const header = style({ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: vars.space.medium, + padding: `${vars.space.medium} ${vars.space.large}`, + borderBottom: `${vars.stroke.thin} solid ${vars.colour.border}`, +}); + +export const title = style({ + margin: 0, + marginRight: "auto", + fontSize: vars.font.sizeHeading, + fontWeight: vars.font.weightStrong, +}); + +export const skipLink = style({ + position: "absolute", + left: vars.space.medium, + top: "-100%", + selectors: { + "&:focus": { top: vars.space.medium }, + }, +}); + +export const main = style({ + display: "flex", + flexDirection: "column", + gap: vars.space.large, + padding: vars.space.large, + flex: 1, + minHeight: 0, +}); + +/** A notice across the page: offline, an update, a failure. */ +const noticeBase = style({ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: vars.space.medium, + margin: 0, + padding: `${vars.space.medium} ${vars.space.large}`, + borderBottom: `${vars.stroke.thin} solid ${vars.colour.border}`, + background: vars.colour.surface, +}); + +export const notice = styleVariants({ + info: [ + noticeBase, + { borderLeft: `${vars.stroke.thick} solid ${vars.colour.accent}` }, + ], + warning: [ + noticeBase, + { borderLeft: `${vars.stroke.thick} solid ${vars.colour.critical}` }, + ], + error: [ + noticeBase, + { + borderLeft: `${vars.stroke.thick} solid ${vars.colour.status.blocked}`, + }, + ], +}); + +export const section = style({ + display: "flex", + flexDirection: "column", + gap: vars.space.medium, + padding: vars.space.large, + border: `${vars.stroke.thin} solid ${vars.colour.border}`, + borderRadius: vars.radius.medium, + background: vars.colour.surface, +}); + +export const sectionHeading = style({ + margin: 0, + fontSize: vars.font.sizeHeading, + fontWeight: vars.font.weightStrong, +}); + +export const muted = style({ + color: vars.colour.muted, + margin: 0, +}); + +export const row = style({ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: vars.space.medium, +}); + +export const button = style({ + font: "inherit", + color: vars.colour.text, + background: vars.colour.background, + border: `${vars.stroke.thin} solid ${vars.colour.border}`, + borderRadius: vars.radius.small, + padding: `${vars.space.small} ${vars.space.medium}`, + cursor: "pointer", + selectors: { + "&:disabled": { color: vars.colour.muted, cursor: "not-allowed" }, + "&:focus-visible": { + outline: `${vars.stroke.thick} solid ${vars.colour.focus}`, + outlineOffset: vars.stroke.thin, + }, + }, +}); + +export const primaryButton = style([ + button, + { + color: vars.colour.background, + background: vars.colour.accent, + borderColor: vars.colour.accent, + }, +]); + +export const input = style({ + font: "inherit", + color: vars.colour.text, + background: vars.colour.background, + border: `${vars.stroke.thin} solid ${vars.colour.border}`, + borderRadius: vars.radius.small, + padding: `${vars.space.small} ${vars.space.medium}`, + minWidth: 0, + selectors: { + "&:focus-visible": { + outline: `${vars.stroke.thick} solid ${vars.colour.focus}`, + outlineOffset: vars.stroke.thin, + }, + }, +}); + +export const field = style({ + display: "flex", + flexDirection: "column", + gap: vars.space.small, +}); + +export const list = style({ + margin: 0, + paddingLeft: vars.space.large, +}); + +export const chip = style({ + display: "inline-flex", + alignItems: "center", + gap: vars.space.small, + padding: `${vars.space.small} ${vars.space.medium}`, + border: `${vars.stroke.thin} solid ${vars.colour.border}`, + borderRadius: vars.radius.medium, + fontFamily: vars.font.mono, + fontSize: vars.font.sizeSmall, +}); + +/** The work: the view beside the lists and details, stacked on a narrow screen. */ +export const workspace = style({ + display: "grid", + gap: vars.space.large, + gridTemplateColumns: "minmax(0, 1fr)", + "@media": { + "(min-width: 64rem)": { + gridTemplateColumns: "minmax(0, 3fr) minmax(18rem, 1fr)", + }, + }, +}); + +export const sidebar = style({ + display: "flex", + flexDirection: "column", + gap: vars.space.large, + minWidth: 0, +}); + +/** React Flow fills its container, so the canvas needs a height of its own. */ +export const canvas = style({ + height: "70vh", + minHeight: "24rem", + border: `${vars.stroke.thin} solid ${vars.colour.border}`, + borderRadius: vars.radius.medium, + overflow: "auto", +}); + +export const tabList = style({ + display: "flex", + flexWrap: "wrap", + gap: vars.space.small, + borderBottom: `${vars.stroke.thin} solid ${vars.colour.border}`, +}); + +export const tab = style([ + button, + { + border: "none", + borderBottom: `${vars.stroke.thick} solid transparent`, + borderRadius: 0, + background: "transparent", + selectors: { + '&[aria-selected="true"]': { + borderBottomColor: vars.colour.accent, + fontWeight: vars.font.weightStrong, + }, + }, + }, +]); + +export const option = style({ + padding: `${vars.space.small} ${vars.space.medium}`, + borderRadius: vars.radius.small, + cursor: "pointer", + selectors: { + '&[aria-selected="true"]': { + outline: `${vars.stroke.thick} solid ${vars.colour.accent}`, + }, + "&:focus-visible": { + outline: `${vars.stroke.thick} solid ${vars.colour.focus}`, + }, + }, +}); + +export const listbox = style({ + listStyle: "none", + margin: 0, + padding: 0, + display: "flex", + flexDirection: "column", + gap: vars.space.small, +}); + +export const progress = style({ + width: "100%", +}); diff --git a/packages/targets/graph-web/src/app.tsx b/packages/targets/graph-web/src/app.tsx new file mode 100644 index 0000000..747d12b --- /dev/null +++ b/packages/targets/graph-web/src/app.tsx @@ -0,0 +1,196 @@ +import { GraphTheme } from "@exadev/process-graph-ui"; +import { useEffect, useId, useRef, useState, type ReactElement } from "react"; +import { + button, + header, + input, + main, + muted, + shell, + skipLink, + title, +} from "./app.css.ts"; +import { formatInstant } from "./format.ts"; +import { + FailureNotice, + GapsNotice, + LinkProblems, + OfflineNotice, + UpdateNotice, +} from "./notices.tsx"; +import type { AppPorts, ThemePreference } from "./ports.ts"; +import { ScopePicker } from "./scope-picker.tsx"; +import { SyncPanel } from "./sync-panel.tsx"; +import { TokenPanel } from "./token-panel.tsx"; +import { modelIdOf, type ViewState } from "./url-state.ts"; +import { useSync } from "./use-sync.ts"; +import { useObservable, useViewState } from "./view-state-hooks.ts"; +import { ModelArea } from "./workspace.tsx"; + +export interface AppProps { + readonly ports: AppPorts; +} + +const themeChoices: readonly { + readonly value: ThemePreference; + readonly label: string; +}[] = [ + { value: "system", label: "Follow the system" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, +]; + +const isPreference = (value: string): value is ThemePreference => + themeChoices.some((choice) => choice.value === value); + +/** A change of scope starts afresh: the selection and the followed path belong to the model being left. */ +const withScopes = ( + state: ViewState, + scopes: readonly string[], +): ViewState => ({ + ...state, + scopes, + selected: undefined, + scenario: [], +}); + +/** + * The process-graph app: choose scopes, sync them from GitHub into the store on this device, and see the work as a flowchart, swimlane, state machine or sequence diagram with what is ready, what is blocked and why. The address carries the view state, so the address is a link to what is on screen; the theme follows the system unless a person picks one. + * @param props - The ports the app reads and writes through. + * @returns The app. + */ +export const App = (props: AppProps): ReactElement => { + const { ports } = props; + const { state, problems, update } = useViewState(ports.address); + const online = useObservable(ports.online); + const prefersDark = useObservable(ports.prefersDark); + const updateAvailable = useObservable(ports.updates.available); + const [preference, setPreference] = useState(ports.themePreference.read); + const [hasToken, setHasToken] = useState( + () => ports.token.read() !== undefined, + ); + const [tokenOpen, setTokenOpen] = useState(false); + const syncWhenShown = useRef(undefined); + const themeId = useId(); + const mainId = useId(); + const modelId = modelIdOf(state.scopes); + const sync = useSync(ports, modelId, state.scopes); + const theme = + preference === "system" ? (prefersDark ? "dark" : "light") : preference; + const { start } = sync; + + // A scope added from an element's details is synced as soon as its model is the one shown, so expanding on demand is one action. + useEffect(() => { + if (syncWhenShown.current === modelId) { + syncWhenShown.current = undefined; + start(); + } + }, [modelId, start]); + + const gaps = + sync.state.phase === "idle" || sync.state.progress === undefined + ? (sync.record?.gaps ?? []) + : sync.state.progress.gaps; + + return ( + +
+ + Skip to the work + +
+

Process graph

+ + + +
+ {online ? null : } + {updateAvailable ? ( + + ) : null} + + {sync.state.phase === "failed" ? ( + { + setTokenOpen(true); + }} + /> + ) : null} + +
+ {tokenOpen || !hasToken ? ( + { + ports.token.write(token); + setHasToken(true); + setTokenOpen(false); + }} + onForget={() => { + ports.token.clear(); + setHasToken(false); + }} + /> + ) : null} + { + update((current) => withScopes(current, scopes)); + }} + /> + {state.scopes.length === 0 ? ( +

+ Choose an organisation or repository to see its work. +

+ ) : ( + <> + + { + const scopes = state.scopes.includes(scope) + ? state.scopes + : [...state.scopes, scope]; + update((current) => withScopes(current, scopes)); + syncWhenShown.current = modelIdOf(scopes); + }} + /> + + )} +
+
+
+ ); +}; diff --git a/packages/targets/graph-web/src/browser.ts b/packages/targets/graph-web/src/browser.ts new file mode 100644 index 0000000..8c6f462 --- /dev/null +++ b/packages/targets/graph-web/src/browser.ts @@ -0,0 +1,166 @@ +import { syncRecordSchema, type SyncRecord } from "./sync-record.ts"; +import type { + AddressBar, + AppUpdates, + Observable, + SyncRecords, + ThemePreference, + ThemePreferenceStore, + TokenStore, +} from "./ports.ts"; + +/** The keys this app keeps in the origin's local storage, each prefixed so they never meet another app's on a shared origin. */ +const keys = { + token: "process-graph:github-token", + theme: "process-graph:theme", + syncRecord: (modelId: string) => `process-graph:sync:${modelId}`, +} as const; + +/** + * The GitHub token in the origin's local storage: on this device only, readable by this origin's pages and nothing else. The app sends it only to GitHub's API. + * @param storage - The storage to keep it in. + * @returns The token store. + */ +export const localTokenStore = (storage: Storage): TokenStore => ({ + read: () => storage.getItem(keys.token) ?? undefined, + write: (token) => { + storage.setItem(keys.token, token); + }, + clear: () => { + storage.removeItem(keys.token); + }, +}); + +/** + * Each model's last sync record in local storage, as JSON checked against its schema on the way back in. A record that no longer matches the schema (written by an older version of the app) reads as absent, which only costs the offer to resume. + * @param storage - The storage to keep the records in. + * @returns The records. + */ +export const localSyncRecords = (storage: Storage): SyncRecords => ({ + read: (modelId) => { + const text = storage.getItem(keys.syncRecord(modelId)); + if (text === null) { + return undefined; + } + const parsed = syncRecordSchema.safeParse(JSON.parse(text)); + + return parsed.success ? parsed.data : undefined; + }, + write: (record: SyncRecord) => { + storage.setItem(keys.syncRecord(record.modelId), JSON.stringify(record)); + }, +}); + +const preferences: readonly ThemePreference[] = ["system", "light", "dark"]; + +/** + * The theme preference in local storage; the system's theme until a person chooses one. + * @param storage - The storage to keep it in. + * @returns The preference store. + */ +export const localThemePreference = ( + storage: Storage, +): ThemePreferenceStore => ({ + read: () => + preferences.find( + (preference) => preference === storage.getItem(keys.theme), + ) ?? "system", + write: (preference) => { + storage.setItem(keys.theme, preference); + }, +}); + +/** + * An observable over a browser event: the value is read afresh whenever one of the events fires. + * @param target - What fires the events. + * @param events - The events that may change the value. + * @param read - Reads the current value. + */ +const fromEvents = ( + target: Readonly, + events: readonly string[], + read: () => T, +): Observable => ({ + get: read, + subscribe: (listener) => { + for (const event of events) { + target.addEventListener(event, listener); + } + + return () => { + for (const event of events) { + target.removeEventListener(event, listener); + } + }; + }, +}); + +/** + * Whether the browser believes it is online, following its `online` and `offline` events. + * @param window - The page's window. + */ +export const browserOnline = (window: Window): Observable => + fromEvents(window, ["online", "offline"], () => window.navigator.onLine); + +/** + * Whether the system prefers a dark colour scheme, following changes to it. + * @param window - The page's window. + */ +export const systemPrefersDark = (window: Window): Observable => { + const query = window.matchMedia("(prefers-color-scheme: dark)"); + + return fromEvents(query, ["change"], () => query.matches); +}; + +/** The event this module fires on the window when it replaces the address, since `history.replaceState` fires none. */ +const addressChanged = "process-graph:address"; + +/** + * The page's query string. Replacing it keeps the path and leaves no history entry, so the back button leaves the app rather than stepping through every filter change; the browser's own navigation still fires `popstate`. + * @param window - The page's window. + */ +export const browserAddressBar = (window: Window): AddressBar => ({ + search: fromEvents( + window, + ["popstate", addressChanged], + () => window.location.search, + ), + replace: (search) => { + if (search === window.location.search) { + return; + } + window.history.replaceState( + window.history.state, + "", + `${window.location.pathname}${search}${window.location.hash}`, + ); + window.dispatchEvent(new Event(addressChanged)); + }, +}); + +/** + * Registers the service worker and reports when a new version is waiting. The new version is activated only when the person asks (`apply`), never on its own, so a page is not taken over mid-sync. + * @param register - `registerSW` from vite-plugin-pwa's `virtual:pwa-register`, injected so this module carries no build-time import. + * @returns The updates port. + */ +export const serviceWorkerUpdates = ( + register: (options: { + readonly onNeedRefresh: () => void; + }) => (reloadPage?: boolean) => Promise, +): AppUpdates => { + const target = new EventTarget(); + let available = false; + const update = register({ + onNeedRefresh: () => { + available = true; + target.dispatchEvent(new Event("change")); + }, + }); + + return { + available: fromEvents(target, ["change"], () => available), + apply: () => { + void update(true); + }, + }; +}; diff --git a/packages/targets/graph-web/src/element-details.tsx b/packages/targets/graph-web/src/element-details.tsx new file mode 100644 index 0000000..8d7ec8f --- /dev/null +++ b/packages/targets/graph-web/src/element-details.tsx @@ -0,0 +1,121 @@ +import { + useBlockedChain, + useImpact, + useWhyStatus, + type Run, +} from "@exadev/process-graph-react"; +import type { OpenIds, OverlayStatus } from "@exadev/process-graph-schema"; +import { DetailPanel } from "@exadev/process-graph-ui"; +import { useId, useState, type ReactElement } from "react"; +import { button, muted, row, section, sectionHeading } from "./app.css.ts"; +import { failureOf } from "./failure.ts"; +import { githubIssueOf } from "./github-issue.ts"; + +/** The run states a person can set, and the text of each button. */ +const settable: readonly { + readonly state: OverlayStatus; + readonly label: string; +}[] = [ + { state: "done", label: "Mark done" }, + { state: "open", label: "Mark open" }, + { state: "unknown", label: "Mark unknown" }, +]; + +export interface ElementDetailsProps { + readonly run: Run; + /** The selected element. */ + readonly id: string; + readonly labelOf: (id: string) => string; + /** The scopes the model was synced from. */ + readonly scopes: readonly string[]; + /** Sets the element's run state in the store, as the command line's `set` does. */ + readonly onSetState: (id: string, state: OverlayStatus) => Promise; + /** Adds a scope and syncs it, so work outside the scope can be expanded on demand. */ + readonly onAddScope: (scope: string) => void; +} + +/** + * The selected element: why it has its status, what blocks it and what it holds up (the command line's `why`, `blocked` and `impact`), its issue on GitHub, controls to set its run state, and, for an issue in a repository outside the scope, a control that adds that repository to the scope. + * @param props - The run, the element and the actions. + * @returns The details, or a note when the element is not in the model. + */ +export const ElementDetails = (props: ElementDetailsProps): ReactElement => { + const { run, id, labelOf, scopes, onSetState, onAddScope } = props; + const explanation = useWhyStatus(run, id); + const { blockers } = useBlockedChain(run, id); + const impact = useImpact(run, id); + const [problem, setProblem] = useState(undefined); + const headingId = useId(); + const element = run.model.elements.find((candidate) => candidate.id === id); + if (element === undefined || explanation === undefined) { + return ( +

+ The selected element, {id}, is not in this model. +

+ ); + } + const issue = + element.kind === "activity" && element.source !== undefined + ? githubIssueOf(element.source) + : undefined; + const outside = + issue !== undefined && + !scopes.includes(issue.repositoryScope) && + !scopes.includes(issue.ownerScope); + + return ( +
+ +
+

+ Run state +

+ {settable.map(({ state, label }) => ( + + ))} +
+ {problem === undefined ? null : ( +

+ The run state was not changed: {problem} +

+ )} + {issue === undefined ? null : ( +
+ + Open the issue on GitHub + + {outside ? ( + + ) : null} +
+ )} +
+ ); +}; diff --git a/packages/targets/graph-web/src/failure.ts b/packages/targets/graph-web/src/failure.ts new file mode 100644 index 0000000..59dcb89 --- /dev/null +++ b/packages/targets/graph-web/src/failure.ts @@ -0,0 +1,69 @@ +import { ORPCError } from "@orpc/client"; + +/** + * Why a call to a source or the store failed, as the app shows it. The source contract's typed errors arrive as oRPC errors over any transport; anything else is shown by its message. + * + * - `unauthorised`: the source rejected the token. + * - `rate-limited`: the source refuses requests until `resetAt` (an ISO 8601 instant) when it said so. + * - `bad-request`: a scope or cursor the source does not accept. + * - `rejected`: the store refused a change that would leave the model invalid. + * - `other`: anything else. + */ +export type Failure = + | { readonly kind: "unauthorised"; readonly message: string } + | { + readonly kind: "rate-limited"; + readonly message: string; + readonly resetAt: string | undefined; + } + | { + readonly kind: "bad-request"; + readonly message: string; + readonly value: string | undefined; + } + | { readonly kind: "rejected"; readonly message: string } + | { readonly kind: "other"; readonly message: string }; + +const stringField = (data: unknown, field: string): string | undefined => { + if (typeof data !== "object" || data === null || !(field in data)) { + return undefined; + } + const value: unknown = Reflect.get(data, field); + + return typeof value === "string" ? value : undefined; +}; + +/** + * Classifies what a source or store call threw. + * @param error - The thrown value. + * @returns The failure to show. + */ +export const failureOf = (error: unknown): Failure => { + if (error instanceof ORPCError) { + if (error.code === "UNAUTHORIZED") { + return { kind: "unauthorised", message: error.message }; + } + if (error.code === "TOO_MANY_REQUESTS") { + return { + kind: "rate-limited", + message: error.message, + resetAt: stringField(error.data, "resetAt"), + }; + } + if (error.code === "BAD_REQUEST") { + return { + kind: "bad-request", + message: error.message, + value: stringField(error.data, "value"), + }; + } + if (error.code === "UNPROCESSABLE_CONTENT") { + return { kind: "rejected", message: error.message }; + } + } + + return { + kind: "other", + message: error instanceof Error ? error.message : String(error), + }; +}; diff --git a/packages/targets/graph-web/src/format.ts b/packages/targets/graph-web/src/format.ts new file mode 100644 index 0000000..5ba45b7 --- /dev/null +++ b/packages/targets/graph-web/src/format.ts @@ -0,0 +1,10 @@ +/** + * An instant as the person's locale writes a date and time, such as when a rate limit resets. + * @param iso - An ISO 8601 instant. + * @returns The instant in the browser's locale and time zone. + */ +export const formatInstant = (iso: string): string => + new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "long", + }).format(new Date(iso)); diff --git a/packages/targets/graph-web/src/github-issue.ts b/packages/targets/graph-web/src/github-issue.ts new file mode 100644 index 0000000..54565ab --- /dev/null +++ b/packages/targets/graph-web/src/github-issue.ts @@ -0,0 +1,42 @@ +import { githubSource } from "@exadev/process-graph-source-github-issues"; + +/** A GitHub issue an element is bound to: where it is on the web and the scopes that would read it. */ +export interface GitHubIssue { + /** The issue's page on github.com. */ + readonly url: string; + /** Its repository, `owner/repo`. */ + readonly repository: string; + /** The scope of its repository, `github:owner/repo`. */ + readonly repositoryScope: string; + /** The scope of its owner, `github:owner`. */ + readonly ownerScope: string; +} + +const issuePattern = new RegExp( + `^${githubSource}:(?[\\w.-]+)/(?[\\w.-]+)#(?[1-9]\\d*)$`, + "u", +); + +/** + * Reads a GitHub issue's source-qualified identifier, as the GitHub source binds activities: `github:owner/repo#12`. + * @param source - An activity's source binding. + * @returns The issue, or `undefined` when the binding is not a GitHub issue. + */ +export const githubIssueOf = (source: string): GitHubIssue | undefined => { + const groups = issuePattern.exec(source)?.groups; + if ( + groups?.owner === undefined || + groups.name === undefined || + groups.number === undefined + ) { + return undefined; + } + const { owner, name, number } = groups; + + return { + url: `https://github.com/${owner}/${name}/issues/${number}`, + repository: `${owner}/${name}`, + repositoryScope: `${githubSource}:${owner}/${name}`, + ownerScope: `${githubSource}:${owner}`, + }; +}; diff --git a/packages/targets/graph-web/src/main.tsx b/packages/targets/graph-web/src/main.tsx new file mode 100644 index 0000000..6b6f7f0 --- /dev/null +++ b/packages/targets/graph-web/src/main.tsx @@ -0,0 +1,64 @@ +import "@exadev/process-graph-ui/style.css"; +import "@exadev/process-graph-react-flow/style.css"; +import { createGraphSourceRouter } from "@exadev/process-graph-source-contract"; +import { + createGitHubIssuesSource, + githubSource, +} from "@exadev/process-graph-source-github-issues"; +import { connectGraphStore } from "@exadev/process-graph-sqlite-wasm"; +import { createRouterClient } from "@orpc/server"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { registerSW } from "virtual:pwa-register"; +import { App } from "./app.tsx"; +import { + browserAddressBar, + browserOnline, + localSyncRecords, + localThemePreference, + localTokenStore, + serviceWorkerUpdates, + systemPrefersDark, +} from "./browser.ts"; +import type { AppPorts } from "./ports.ts"; + +// The composition root: the only module that knows which store, sources and browser APIs the app runs on. Every screen reaches them through `AppPorts`. + +const root = document.getElementById("root"); +if (root === null) { + throw new Error("index.html must contain a #root element to mount into"); +} + +/** The store, in a dedicated worker on SQLite wasm over the origin private file system, reached over the store contract's oRPC client. */ +const store = connectGraphStore( + new Worker(new URL("store.worker.ts", import.meta.url), { type: "module" }), +); + +const ports: AppPorts = { + store, + // The GitHub source runs in the page, called through the source contract's in-process oRPC client. The token goes only into the adapter's Authorization header for requests to GitHub's API, and the fetch it calls reaches nothing else. + sources: (token) => (prefix) => + prefix === githubSource + ? createRouterClient( + createGraphSourceRouter( + createGitHubIssuesSource({ + token: async () => Promise.resolve(token), + fetch: async (url, init) => fetch(url, init), + }), + ), + ) + : undefined, + token: localTokenStore(window.localStorage), + syncRecords: localSyncRecords(window.localStorage), + themePreference: localThemePreference(window.localStorage), + address: browserAddressBar(window), + online: browserOnline(window), + prefersDark: systemPrefersDark(window), + updates: serviceWorkerUpdates(registerSW), +}; + +createRoot(root).render( + + + , +); diff --git a/packages/targets/graph-web/src/notices.tsx b/packages/targets/graph-web/src/notices.tsx new file mode 100644 index 0000000..b5c6040 --- /dev/null +++ b/packages/targets/graph-web/src/notices.tsx @@ -0,0 +1,131 @@ +import type { Gap } from "@exadev/process-graph-source-contract"; +import type { ReactElement } from "react"; +import { button, list, notice } from "./app.css.ts"; +import type { Failure } from "./failure.ts"; + +/** + * Says the page is offline: the last sync is still shown from this device, and syncing waits for the network. + * @returns The notice, a status region so a screen reader announces it. + */ +export const OfflineNotice = (): ReactElement => ( +

+ You are offline. The last sync on this device is shown; syncing resumes when + you are back online. +

+); + +export interface UpdateNoticeProps { + readonly onReload: () => void; +} + +/** + * Offers a new version of the app. It is never applied on its own, so a running sync is not cut short. + * @param props - What to do when the person chooses to reload. + * @returns The notice. + */ +export const UpdateNotice = (props: UpdateNoticeProps): ReactElement => ( +
+ A new version of the app is ready. + +
+); + +export interface LinkProblemsProps { + readonly problems: readonly string[]; +} + +/** + * Lists the parts of the page address the app could not use and left out. + * @param props - One sentence per problem. + * @returns The notice, or nothing when the address was understood. + */ +export const LinkProblems = (props: LinkProblemsProps): ReactElement | null => + props.problems.length === 0 ? null : ( +
+ Some of this link could not be used: +
    + {props.problems.map((problem) => ( +
  • {problem}
  • + ))} +
+
+ ); + +export interface FailureNoticeProps { + readonly failure: Failure; + /** Formats an instant for display, such as a rate limit's reset. */ + readonly formatTime: (iso: string) => string; + /** Offered when the failure is the token's: opens the token settings. */ + readonly onChangeToken: () => void; +} + +/** + * Explains why a sync stopped. A rate limit says when it resets; a rejected token offers to change it; anything else is shown by its message. What was read before the failure is kept, which the notice says. + * @param props - The failure, a time formatter and the token action. + * @returns The notice. + */ +export const FailureNotice = (props: FailureNoticeProps): ReactElement => { + const { failure, formatTime, onChangeToken } = props; + const kept = "What was read before it stopped is kept."; + + if (failure.kind === "rate-limited") { + return ( +

+ GitHub is rate limiting requests + {failure.resetAt === undefined + ? "; try again later." + : `; the limit resets at ${formatTime(failure.resetAt)}.`}{" "} + {kept} Resume the sync then. +

+ ); + } + if (failure.kind === "unauthorised") { + return ( +
+ + GitHub did not accept the token. It may have expired, been revoked or + lack access to these repositories. {kept} + + +
+ ); + } + + return ( +

+ The sync stopped: {failure.message} {kept} +

+ ); +}; + +export interface GapsNoticeProps { + readonly gaps: readonly Gap[]; +} + +const gapReasons: Readonly> = { + "not-found": "not found", + forbidden: "not visible to this token", +}; + +/** + * Lists the parts of the scope the last sync could not read, so a partial model is never mistaken for a whole one. + * @param props - The gaps, in the order met. + * @returns The notice, or nothing when every part was read. + */ +export const GapsNotice = (props: GapsNoticeProps): ReactElement | null => + props.gaps.length === 0 ? null : ( +
+ Part of the scope could not be read, so the graph is partial: +
    + {props.gaps.map((gap) => ( +
  • + {gap.target}: {gapReasons[gap.reason]}. {gap.message} +
  • + ))} +
+
+ ); diff --git a/packages/targets/graph-web/src/ports.ts b/packages/targets/graph-web/src/ports.ts new file mode 100644 index 0000000..2795c02 --- /dev/null +++ b/packages/targets/graph-web/src/ports.ts @@ -0,0 +1,67 @@ +import type { FlowLayoutOptions } from "@exadev/process-graph-react-flow"; +import type { GraphStoreClient } from "@exadev/process-graph-store-contract"; +import type { SyncRecord } from "./sync-record.ts"; +import type { SourceLookup } from "./sync.ts"; + +/** A value that changes over time and tells its listeners when it does, in the shape React's `useSyncExternalStore` reads. */ +export interface Observable { + readonly get: () => T; + /** Registers a listener and returns the function that removes it. */ + readonly subscribe: (listener: () => void) => () => void; +} + +/** The GitHub token kept on this device. It is read only to build the GitHub source, and never shown or logged. */ +export interface TokenStore { + readonly read: () => string | undefined; + readonly write: (token: string) => void; + readonly clear: () => void; +} + +/** The record of each model's last sync, kept on this device so an interrupted sync resumes after a reload. */ +export interface SyncRecords { + readonly read: (modelId: string) => SyncRecord | undefined; + readonly write: (record: SyncRecord) => void; +} + +/** Which theme a person chose: the system's, or one of the two always. */ +export type ThemePreference = "system" | "light" | "dark"; + +/** The theme preference, kept on this device. */ +export interface ThemePreferenceStore { + readonly read: () => ThemePreference; + readonly write: (preference: ThemePreference) => void; +} + +/** The page address's query string, which carries the view state. */ +export interface AddressBar { + readonly search: Observable; + /** Replaces the query string without adding a history entry or reloading. */ + readonly replace: (search: string) => void; +} + +/** A new version of the app, waiting for the person to choose to use it. */ +export interface AppUpdates { + readonly available: Observable; + /** Activates the new version and reloads the page into it. */ + readonly apply: () => void; +} + +/** + * Everything the app reads or writes outside React, injected at the composition root (`main.tsx`) so every screen runs against fakes in tests and against the browser in the app. + */ +export interface AppPorts { + readonly store: GraphStoreClient; + /** The sources the app can read, given the token to read GitHub with. */ + readonly sources: (token: string) => SourceLookup; + readonly token: TokenStore; + readonly syncRecords: SyncRecords; + readonly themePreference: ThemePreferenceStore; + readonly address: AddressBar; + /** Whether the browser believes it is online. */ + readonly online: Observable; + /** Whether the system prefers a dark colour scheme. */ + readonly prefersDark: Observable; + readonly updates: AppUpdates; + /** How views are laid out; the default is graph-layout's elkjs engine. Tests inject a stub engine. */ + readonly layout?: FlowLayoutOptions | undefined; +} diff --git a/packages/targets/graph-web/src/scope-input.ts b/packages/targets/graph-web/src/scope-input.ts new file mode 100644 index 0000000..4512477 --- /dev/null +++ b/packages/targets/graph-web/src/scope-input.ts @@ -0,0 +1,67 @@ +import { GraphSourceError } from "@exadev/process-graph-source-contract"; +import { + githubSource, + parseScope, +} from "@exadev/process-graph-source-github-issues"; +import { isQualifiedScope } from "./url-state.ts"; + +/** A scope typed or pasted by a person, read as a source-qualified scope, or why it cannot be. */ +export type ScopeInput = + | { readonly scope: string; readonly problem?: never } + | { readonly problem: string; readonly scope?: never }; + +const githubHost = "github.com"; + +/** + * The path segments of a GitHub web address that name an account or a repository: `https://github.com/owner` and `https://github.com/owner/repo`, with anything after the repository (`/issues/12`, `/pulls`) ignored. + */ +const fromGitHubUrl = (text: string): string | undefined => { + if (!URL.canParse(text)) { + return undefined; + } + const url = new URL(text); + if (url.hostname !== githubHost) { + return undefined; + } + const [owner, name] = url.pathname.split("/").filter((part) => part !== ""); + + return owner === undefined + ? undefined + : name === undefined + ? owner + : `${owner}/${name}`; +}; + +/** + * Reads what a person typed as a scope. A source-qualified scope is taken as it is; a GitHub web address, `owner/repo` or `owner` is read as a GitHub scope and checked as the GitHub source checks it. + * @param text - The text, trimmed of surrounding space. + * @returns The scope, or a sentence saying why the text is not one. + */ +export const readScopeInput = (text: string): ScopeInput => { + const trimmed = text.trim(); + if (trimmed === "") { + return { problem: "Type an organisation, a repository or a GitHub link." }; + } + const githubPath = fromGitHubUrl(trimmed); + const scope = + githubPath !== undefined + ? `${githubSource}:${githubPath}` + : isQualifiedScope(trimmed) + ? trimmed + : `${githubSource}:${trimmed}`; + if (scope.startsWith(`${githubSource}:`)) { + try { + parseScope(scope); + } catch (error) { + if (!(error instanceof GraphSourceError)) { + throw error; + } + + return { + problem: `"${trimmed}" is not a GitHub organisation, user or repository: write owner or owner/repo.`, + }; + } + } + + return { scope }; +}; diff --git a/packages/targets/graph-web/src/scope-input.unit.test.ts b/packages/targets/graph-web/src/scope-input.unit.test.ts new file mode 100644 index 0000000..f714e16 --- /dev/null +++ b/packages/targets/graph-web/src/scope-input.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; +import { readScopeInput } from "./scope-input.ts"; + +describe("readScopeInput", () => { + test.each([ + ["acme", "github:acme"], + [" acme/api ", "github:acme/api"], + ["github:acme/api", "github:acme/api"], + ["https://github.com/acme", "github:acme"], + ["https://github.com/acme/api/issues/12", "github:acme/api"], + ["linear:team-a", "linear:team-a"], + ])("reads %j as %j", (text, scope) => { + expect(readScopeInput(text)).toEqual({ scope }); + }); + + test("asks for something when nothing is typed", () => { + expect(readScopeInput(" ")).toEqual({ + problem: "Type an organisation, a repository or a GitHub link.", + }); + }); + + test("refuses what GitHub cannot name", () => { + expect(readScopeInput("acme/api/extra")).toEqual({ + problem: + '"acme/api/extra" is not a GitHub organisation, user or repository: write owner or owner/repo.', + }); + expect(readScopeInput("github:acme corp")).toEqual({ + problem: + '"github:acme corp" is not a GitHub organisation, user or repository: write owner or owner/repo.', + }); + }); +}); diff --git a/packages/targets/graph-web/src/scope-picker.tsx b/packages/targets/graph-web/src/scope-picker.tsx new file mode 100644 index 0000000..c7706f8 --- /dev/null +++ b/packages/targets/graph-web/src/scope-picker.tsx @@ -0,0 +1,113 @@ +import { useId, useState, type SubmitEvent, type ReactElement } from "react"; +import { + button, + chip, + field, + input, + muted, + primaryButton, + row, + section, + sectionHeading, +} from "./app.css.ts"; +import { readScopeInput } from "./scope-input.ts"; + +export interface ScopePickerProps { + /** The scopes shown, in order. */ + readonly scopes: readonly string[]; + /** Called with the new list when a scope is added or removed. */ + readonly onChange: (scopes: readonly string[]) => void; +} + +/** + * Chooses what to read: any number of organisations, users and repositories, across organisations, typed as `owner`, `owner/repo`, a source-qualified scope such as `github:owner/repo`, or pasted as a GitHub link. + * @param props - The scopes and the change listener. + * @returns The picker. + */ +export const ScopePicker = (props: ScopePickerProps): ReactElement => { + const { scopes, onChange } = props; + const [draft, setDraft] = useState(""); + const [problem, setProblem] = useState(undefined); + const headingId = useId(); + const inputId = useId(); + const problemId = useId(); + + const submit = (event: SubmitEvent): void => { + event.preventDefault(); + const read = readScopeInput(draft); + if (read.problem !== undefined) { + setProblem(read.problem); + + return; + } + setProblem(undefined); + setDraft(""); + if (!scopes.includes(read.scope)) { + onChange([...scopes, read.scope]); + } + }; + + return ( +
+

+ Scope +

+

+ Organisations, users and repositories to read issues from. An owner + reads every repository it owns; dependencies on issues elsewhere are + followed into their own repositories. +

+ {scopes.length === 0 ? ( +

Nothing chosen yet.

+ ) : ( +
    + {scopes.map((scope) => ( +
  • + {scope} + +
  • + ))} +
+ )} +
+ +
+ { + setDraft(event.target.value); + }} + /> + +
+ {problem === undefined ? null : ( + + )} +
+
+ ); +}; diff --git a/packages/targets/graph-web/src/store.worker.ts b/packages/targets/graph-web/src/store.worker.ts new file mode 100644 index 0000000..9f6b978 --- /dev/null +++ b/packages/targets/graph-web/src/store.worker.ts @@ -0,0 +1,4 @@ +import { serveGraphStoreInWorker } from "@exadev/process-graph-sqlite-wasm/worker"; + +/** The one database the app keeps on this origin. Every tab's worker serves it under this name, so they share it under its Web Lock and hear each other's changes. */ +serveGraphStoreInWorker({ name: "process-graph" }); diff --git a/packages/targets/graph-web/src/sync-panel.tsx b/packages/targets/graph-web/src/sync-panel.tsx new file mode 100644 index 0000000..09d022d --- /dev/null +++ b/packages/targets/graph-web/src/sync-panel.tsx @@ -0,0 +1,91 @@ +import { useId, type ReactElement } from "react"; +import { + button, + muted, + primaryButton, + row, + section, + sectionHeading, +} from "./app.css.ts"; +import type { SyncProgress } from "./sync.ts"; +import type { SyncHandle } from "./use-sync.ts"; + +export interface SyncPanelProps { + readonly sync: SyncHandle; + /** Whether a token is saved; without one there is nothing to sync with. */ + readonly hasToken: boolean; + readonly online: boolean; +} + +const summary = (progress: SyncProgress): string => + `${String(progress.pages)} ${progress.pages === 1 ? "page" : "pages"} read: ${String(progress.activities)} ${progress.activities === 1 ? "issue" : "issues"} and ${String(progress.transitions)} ${progress.transitions === 1 ? "dependency" : "dependencies"} written.`; + +/** + * Starts, follows, cancels and resumes the sync of the shown scopes. Progress is announced as it is written; a sync that stopped part way offers to carry on from where it stopped. + * @param props - The sync, whether a token is saved, and whether the page is online. + * @returns The panel. + */ +export const SyncPanel = (props: SyncPanelProps): ReactElement => { + const { sync, hasToken, online } = props; + const { state, record } = sync; + const headingId = useId(); + const running = state.phase === "running"; + const canSync = hasToken && online && !running; + const resumable = !running && record?.checkpoint !== undefined; + const progress = state.phase === "idle" ? undefined : state.progress; + + return ( +
+

+ Sync +

+
+ + {resumable ? ( + + ) : null} + {running ? ( + + ) : null} +
+

+ {running + ? progress === undefined + ? "Starting the sync..." + : `Syncing. ${summary(progress)}` + : state.phase === "finished" + ? `Sync finished. ${summary(state.progress)}` + : state.phase === "cancelled" + ? `Sync cancelled.${progress === undefined ? "" : ` ${summary(progress)}`} Resume to carry on.` + : resumable + ? "The last sync stopped before it finished." + : ""} +

+ {hasToken ? null :

Save a GitHub token to sync.

} + {online ? null :

Syncing needs the network.

} +
+ ); +}; diff --git a/packages/targets/graph-web/src/sync-record.ts b/packages/targets/graph-web/src/sync-record.ts new file mode 100644 index 0000000..e89c3ee --- /dev/null +++ b/packages/targets/graph-web/src/sync-record.ts @@ -0,0 +1,30 @@ +import { gapSchema } from "@exadev/process-graph-source-contract"; +import { z } from "zod"; + +/** + * Where a sync stopped: the model, every scope it was asked for in order, the prefix of the source to read next, and the cursor after the last page it wrote within that source's scopes (absent to read them from the start). Everything before it is in the store, so a sync started from it reads only what is left. + */ +export const checkpointSchema = z + .strictObject({ + modelId: z.string().min(1), + scopes: z.array(z.string().min(1)).readonly(), + source: z.string().min(1), + after: z.string().min(1).optional(), + }) + .readonly(); + +export type SyncCheckpoint = z.infer; + +/** + * What the app remembers about a model's last sync: where it stopped, when it did not finish, and the parts of the scope the sources could not read. Kept on the device, so after a reload, or offline, the app still says the model is incomplete and offers to carry on. + */ +export const syncRecordSchema = z + .strictObject({ + modelId: z.string().min(1), + /** Present while the sync has more to read. */ + checkpoint: checkpointSchema.optional(), + gaps: z.array(gapSchema).readonly(), + }) + .readonly(); + +export type SyncRecord = z.infer; diff --git a/packages/targets/graph-web/src/sync.ts b/packages/targets/graph-web/src/sync.ts new file mode 100644 index 0000000..760c216 --- /dev/null +++ b/packages/targets/graph-web/src/sync.ts @@ -0,0 +1,222 @@ +import type { + Gap, + GraphSourceClient, + SourceTransition, + SyncPage, +} from "@exadev/process-graph-source-contract"; +import type { GraphStoreClient } from "@exadev/process-graph-store-contract"; +import { ORPCError } from "@orpc/client"; +import type { SyncCheckpoint } from "./sync-record.ts"; + +/** Finds the source that reads scopes with an identifier prefix, such as `github`. */ +export type SourceLookup = (prefix: string) => GraphSourceClient | undefined; + +/** How far a sync has got. */ +export interface SyncProgress { + /** Pages read and written. */ + readonly pages: number; + /** Distinct bound activities written. */ + readonly activities: number; + /** Distinct transitions written. */ + readonly transitions: number; + /** The parts of the scope the sources could not read, in the order met. */ + readonly gaps: readonly Gap[]; + /** Where to resume; absent once every scope has been read. */ + readonly checkpoint: SyncCheckpoint | undefined; +} + +/** What to sync and where to. */ +export interface SyncRequest { + readonly store: GraphStoreClient; + readonly sources: SourceLookup; + readonly modelId: string; + readonly scopes: readonly string[]; + /** A checkpoint from an interrupted sync of the same model and scopes: the sync carries on from it. */ + readonly resume?: SyncCheckpoint | undefined; + /** Ends the sync between or during reads; what was written stays written. */ + readonly signal: AbortSignal; + /** Called after every page is written, with the progress so far. */ + readonly onProgress: (progress: SyncProgress) => void; +} + +/** Thrown when a scope names a source the app has no reader for. */ +export class UnknownSourceError extends Error { + /** + * @param prefix - The source prefix no reader serves. + */ + constructor(prefix: string) { + super(`No source reads "${prefix}" scopes.`); + this.name = "UnknownSourceError"; + } +} + +const sourceSeparator = ":"; + +/** The scopes of each source, in the order each source first appears. */ +const bySource = ( + scopes: readonly string[], +): readonly { + readonly prefix: string; + readonly scopes: readonly string[]; +}[] => { + const groups = new Map(); + for (const scope of scopes) { + const prefix = scope.slice(0, scope.indexOf(sourceSeparator)); + groups.set(prefix, [...(groups.get(prefix) ?? []), scope]); + } + + return [...groups].map(([prefix, grouped]) => ({ prefix, scopes: grouped })); +}; + +/** The identifiers of the elements a model in the store already has, or none when the store has no such model. */ +const storedElements = async ( + store: GraphStoreClient, + modelId: string, + signal: AbortSignal, +): Promise> => { + try { + const stored = await store.v1.models.load({ modelId }, { signal }); + + return new Set(stored.model.elements.map((element) => element.id)); + } catch (error) { + if (error instanceof ORPCError && error.code === "NOT_FOUND") { + return new Set(); + } + throw error; + } +}; + +/** + * Reads the scopes from their sources page by page and writes each page into the model as soon as it is read, so progress is kept as it is made: a sync that is cancelled, rate limited or closed with the tab leaves everything it read in the store, and a checkpoint to carry on from. + * + * A store rejects a write that leaves a transition pointing at an element the model lacks, and a source may report a dependency on work a later page brings. A transition is therefore held back until the model has both of its ends, and written with the page that completes it. One whose end never arrives is written with the last page, where the store's rejection says which reference is missing. + * + * Relations a source reports (sub-issues, assignees, repository membership) have no place in the store and are not kept, as the command line's sync does. + * @param request - The store, sources, model, scopes, an optional checkpoint, the signal and a progress listener. + * @returns The final progress, with no checkpoint. + * @throws UnknownSourceError for a scope whose source the app cannot read; an Error for a checkpoint naming a source none of the scopes use; and whatever a source or the store throws, after the pages before it are written. An abort rejects with the signal's reason. + */ +export const runSync = async (request: SyncRequest): Promise => { + const { store, sources, modelId, scopes, resume, signal, onProgress } = + request; + const groups = bySource(scopes).map((group) => { + const client = sources(group.prefix); + if (client === undefined) { + throw new UnknownSourceError(group.prefix); + } + + return { ...group, client }; + }); + const startGroup = + resume === undefined + ? 0 + : groups.findIndex((group) => group.prefix === resume.source); + if (startGroup === -1) { + throw new Error( + "The checkpoint names a source that none of the scopes use; start the sync again.", + ); + } + + const known = new Set(await storedElements(store, modelId, signal)); + const activities = new Set(); + const transitions = new Set(); + const gaps: Gap[] = []; + let held: readonly SourceTransition[] = []; + let pages = 0; + + const write = async ( + page: SyncPage, + checkpoint: SyncCheckpoint | undefined, + ): Promise => { + for (const activity of page.activities) { + known.add(activity.id); + } + const last = checkpoint === undefined; + const candidates = [...held, ...page.transitions]; + const ready = candidates.filter( + (transition) => + last || (known.has(transition.source) && known.has(transition.target)), + ); + held = candidates.filter((transition) => !ready.includes(transition)); + await store.v1.items.upsert( + { + modelId, + items: { + participants: page.participants, + activities: page.activities, + transitions: ready, + overlay: page.overlay, + }, + }, + { signal }, + ); + pages += 1; + for (const activity of page.activities) { + activities.add(activity.id); + } + for (const transition of ready) { + transitions.add(transition.id); + } + gaps.push(...page.gaps); + onProgress({ + pages, + activities: activities.size, + transitions: transitions.size, + gaps: [...gaps], + checkpoint, + }); + }; + + /** Writes each page an iterator yields, in order, each only after the one before is written. */ + const drain = async ( + pagesOf: Readonly>, + checkpointAfter: (page: SyncPage) => SyncCheckpoint | undefined, + ): Promise => { + const next = await pagesOf.next(); + if (next.done === true) { + return; + } + await write(next.value, checkpointAfter(next.value)); + await drain(pagesOf, checkpointAfter); + }; + + /** Reads the sources in order, each only after the one before has finished, since a source's failure must stop the ones after it. */ + const readFrom = async (index: number): Promise => { + const group = groups[index]; + if (group === undefined) { + return; + } + const after = index === startGroup ? resume?.after : undefined; + const following = groups[index + 1]; + const stream = await group.client.v1.sync.stream( + after === undefined + ? { scope: group.scopes } + : { scope: group.scopes, after }, + { signal }, + ); + const iterator = stream[Symbol.asyncIterator](); + try { + await drain(iterator, (page) => + page.next !== undefined + ? { modelId, scopes, source: group.prefix, after: page.next } + : following === undefined + ? undefined + : { modelId, scopes, source: following.prefix }, + ); + } catch (error) { + // Closing the stream stops the source reading pages nobody will write. + await iterator.return(); + throw error; + } + await readFrom(index + 1); + }; + await readFrom(startGroup); + + return { + pages, + activities: activities.size, + transitions: transitions.size, + gaps, + checkpoint: undefined, + }; +}; diff --git a/packages/targets/graph-web/src/tabs.tsx b/packages/targets/graph-web/src/tabs.tsx new file mode 100644 index 0000000..8d21c8e --- /dev/null +++ b/packages/targets/graph-web/src/tabs.tsx @@ -0,0 +1,89 @@ +import { useId, useRef, type KeyboardEvent, type ReactElement } from "react"; +import { tab as tabClass, tabList } from "./app.css.ts"; + +/** One tab: its value and the text it is shown with. */ +export interface TabOption { + readonly value: V; + readonly label: string; +} + +export interface TabsProps { + /** The tab list's accessible name. */ + readonly label: string; + readonly options: readonly TabOption[]; + readonly selected: V; + readonly onSelect: (value: V) => void; + /** The identifier of the panel the tabs control, so each tab names it in `aria-controls`. */ + readonly panelId: string; +} + +/** The tab a key moves to from position `index`, or `undefined` for a key the tabs do not handle. */ +const moved = ( + key: string, + index: number, + count: number, +): number | undefined => { + if (key === "ArrowRight") { + return (index + 1) % count; + } + if (key === "ArrowLeft") { + return (index - 1 + count) % count; + } + if (key === "Home") { + return 0; + } + + return key === "End" ? count - 1 : undefined; +}; + +/** + * A row of tabs following the WAI-ARIA tabs pattern with automatic activation: the selected tab is the one tab stop, and the arrow keys, Home and End select and focus another. + * @param props - The tab list's name, its options, the selected value, the listener and the controlled panel's id. + * @returns The tab list. + */ +export const Tabs = (props: TabsProps): ReactElement => { + const { label, options, selected, onSelect, panelId } = props; + const prefix = useId(); + const list = useRef(null); + + const onKeyDown = (event: KeyboardEvent): void => { + const index = options.findIndex((option) => option.value === selected); + const next = moved(event.key, index, options.length); + const option = next === undefined ? undefined : options[next]; + if (option === undefined || next === undefined) { + return; + } + event.preventDefault(); + onSelect(option.value); + const button = + list.current?.querySelectorAll('[role="tab"]')[next]; + button?.focus(); + }; + + return ( +
+ {options.map((option) => { + const active = option.value === selected; + + return ( + + ); + })} +
+ ); +}; diff --git a/packages/targets/graph-web/src/token-panel.tsx b/packages/targets/graph-web/src/token-panel.tsx new file mode 100644 index 0000000..511011e --- /dev/null +++ b/packages/targets/graph-web/src/token-panel.tsx @@ -0,0 +1,106 @@ +import { useId, useState, type SubmitEvent, type ReactElement } from "react"; +import { + button, + field, + input, + list, + muted, + primaryButton, + row, + section, + sectionHeading, +} from "./app.css.ts"; + +export interface TokenPanelProps { + /** Whether a token is kept on this device. The token itself is never passed to the screen. */ + readonly hasToken: boolean; + readonly onSave: (token: string) => void; + readonly onForget: () => void; +} + +/** + * Where a person gives the app a GitHub token, with what the token needs and where it goes. The field never shows a saved token: it is write-only, so the screen cannot leak it. + * @param props - Whether a token is saved, and what saving and forgetting do. + * @returns The panel. + */ +export const TokenPanel = (props: TokenPanelProps): ReactElement => { + const { hasToken, onSave, onForget } = props; + const [draft, setDraft] = useState(""); + const headingId = useId(); + const inputId = useId(); + const helpId = useId(); + + const submit = (event: SubmitEvent): void => { + event.preventDefault(); + const token = draft.trim(); + if (token === "") { + return; + } + onSave(token); + setDraft(""); + }; + + return ( +
+

+ GitHub token +

+
+

+ The app reads issues through GitHub's API with a token you give + it. It needs read access only: +

+
    +
  • + A fine-grained token with read-only Issues and{" "} + Metadata permissions on the repositories you want + to see, for each organisation that owns them. +
  • +
  • + Or a classic token with no scopes for public repositories, or the{" "} + repo scope to include private ones. +
  • +
+

+ The token is kept in this browser's storage on this device only. + It is sent to api.github.com and nowhere else, and never shown again + once saved. +

+
+
+ + { + setDraft(event.target.value); + }} + /> + + {hasToken ? ( + + ) : null} +
+

+ {hasToken + ? "A token is saved on this device." + : "No token is saved yet."} +

+
+ ); +}; diff --git a/packages/targets/graph-web/src/url-state.ts b/packages/targets/graph-web/src/url-state.ts new file mode 100644 index 0000000..76fdcc5 --- /dev/null +++ b/packages/targets/graph-web/src/url-state.ts @@ -0,0 +1,220 @@ +import { statuses, type Status } from "@exadev/process-graph-domain"; +import { emptyFilter, type ElementFilter } from "@exadev/process-graph-react"; + +/** The views the app draws, named as the command line's `show --view` names them. */ +export const viewKinds = [ + "flowchart", + "swimlane", + "state", + "sequence", +] as const; +export type ViewKind = (typeof viewKinds)[number]; + +/** The lists beside the view, one per question the command line answers about the whole model. */ +export const listTabs = [ + "ready", + "blocked", + "path", + "handoffs", + "cycles", + "issues", +] as const; +export type ListTab = (typeof listTabs)[number]; + +/** + * Everything a link to the app carries: what to read, how to show it and what is picked out. Two people opening the same link see the same view of the same scopes, each from their own store. + */ +export interface ViewState { + /** Source-qualified scopes, such as `github:owner` or `github:owner/repo`, in the order given, each once. */ + readonly scopes: readonly string[]; + readonly view: ViewKind; + /** The selected element's identifier. */ + readonly selected: string | undefined; + readonly filter: ElementFilter; + readonly tab: ListTab; + /** The entity a state machine follows; absent to use the model's own state machine projection. */ + readonly subject: string | undefined; + /** The transitions a sequence diagram follows, in order; empty to use the model's own sequence projection, or the critical path. */ + readonly scenario: readonly string[]; +} + +/** The view a link with no parameters opens. */ +export const defaultViewState: ViewState = { + scopes: [], + view: "swimlane", + selected: undefined, + filter: emptyFilter, + tab: "ready", + subject: undefined, + scenario: [], +}; + +/** + * The query parameters, one name per part of the state. A repeatable part repeats its parameter, in order: `?scope=github:a&scope=github:b/c`. + */ +export const parameters = { + scope: "scope", + view: "view", + selected: "select", + status: "status", + actor: "actor", + source: "source", + tab: "list", + subject: "subject", + scenario: "step", +} as const; + +/** What reading a link found, and anything in it that could not be used. */ +export interface ParsedViewState { + readonly state: ViewState; + /** One sentence per parameter value that was not understood and so was left out. */ + readonly problems: readonly string[]; +} + +/** The separator between a scope's source and the rest, as in `github:owner/repo`. */ +const sourceSeparator = ":"; + +/** + * Whether a string is source-qualified: a source name, a colon, and something after it. + * @param scope - The candidate scope. + */ +export const isQualifiedScope = (scope: string): boolean => { + const at = scope.indexOf(sourceSeparator); + + return at > 0 && at < scope.length - 1; +}; + +const unique = (values: readonly string[]): readonly string[] => [ + ...new Set(values), +]; + +const isMember = ( + values: readonly T[], + value: string, +): value is T => values.some((candidate) => candidate === value); + +/** + * Reads the state from a URL's query string. A value that cannot be used (an unknown view, a status that does not exist, a scope that is not source-qualified) is left out and named in `problems`, so a mistyped link shows what it ignored rather than failing to open. + * @param search - The query string, with or without its leading `?`. + * @returns The state, with the defaults for whatever the query does not set, and the problems. + */ +export const parseViewState = (search: string): ParsedViewState => { + const query = new URLSearchParams(search); + const problems: string[] = []; + const all = (name: string): readonly string[] => + query.getAll(name).filter((value) => value !== ""); + const one = (name: string): string | undefined => all(name).at(-1); + + const scopes = all(parameters.scope).filter((scope) => { + const qualified = isQualifiedScope(scope); + if (!qualified) { + problems.push( + `The scope "${scope}" names no source; write it as source:scope, such as github:owner/repo.`, + ); + } + + return qualified; + }); + + const viewValue = one(parameters.view); + let view = defaultViewState.view; + if (viewValue !== undefined) { + if (isMember(viewKinds, viewValue)) { + view = viewValue; + } else { + problems.push( + `The view "${viewValue}" is not one of ${viewKinds.join(", ")}.`, + ); + } + } + + const tabValue = one(parameters.tab); + let tab = defaultViewState.tab; + if (tabValue !== undefined) { + if (isMember(listTabs, tabValue)) { + tab = tabValue; + } else { + problems.push( + `The list "${tabValue}" is not one of ${listTabs.join(", ")}.`, + ); + } + } + + const filterStatuses = all(parameters.status).filter( + (value): value is Status => { + const known = isMember(statuses, value); + if (!known) { + problems.push( + `The status "${value}" is not one of ${statuses.join(", ")}.`, + ); + } + + return known; + }, + ); + + return { + state: { + scopes: unique(scopes), + view, + selected: one(parameters.selected), + filter: { + statuses: new Set(filterStatuses), + actors: new Set(all(parameters.actor)), + sources: new Set(all(parameters.source)), + }, + tab, + subject: one(parameters.subject), + scenario: all(parameters.scenario), + }, + problems, + }; +}; + +/** + * Writes the state as a query string, leaving out every part that has its default, so the link to the default view is empty. Repeated parameters keep their order, and set members are written sorted, so the same state always gives the same link. + * @param state - The state. + * @returns The query string with its leading `?`, or the empty string. + */ +export const serialiseViewState = (state: ViewState): string => { + const query = new URLSearchParams(); + for (const scope of state.scopes) { + query.append(parameters.scope, scope); + } + if (state.view !== defaultViewState.view) { + query.set(parameters.view, state.view); + } + if (state.tab !== defaultViewState.tab) { + query.set(parameters.tab, state.tab); + } + if (state.selected !== undefined) { + query.set(parameters.selected, state.selected); + } + const sets = [ + [parameters.status, state.filter.statuses], + [parameters.actor, state.filter.actors], + [parameters.source, state.filter.sources], + ] as const; + for (const [name, values] of sets) { + for (const value of [...values].sort()) { + query.append(name, value); + } + } + if (state.subject !== undefined) { + query.set(parameters.subject, state.subject); + } + for (const step of state.scenario) { + query.append(parameters.scenario, step); + } + const text = query.toString(); + + return text === "" ? "" : `?${text}`; +}; + +/** + * The store's name for the model a set of scopes is synced into: the scopes sorted and joined by spaces, so the same scopes in any order are the same model, and different scopes never share one. + * @param scopes - The scopes. + * @returns The model identifier. + */ +export const modelIdOf = (scopes: readonly string[]): string => + unique(scopes).toSorted().join(" "); diff --git a/packages/targets/graph-web/src/url-state.unit.test.ts b/packages/targets/graph-web/src/url-state.unit.test.ts new file mode 100644 index 0000000..8490ac3 --- /dev/null +++ b/packages/targets/graph-web/src/url-state.unit.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "vitest"; +import { + defaultViewState, + modelIdOf, + parseViewState, + serialiseViewState, + type ViewState, +} from "./url-state.ts"; + +const everything: ViewState = { + scopes: ["github:acme", "github:partner/sdk"], + view: "sequence", + selected: "github:acme/web#1", + filter: { + statuses: new Set(["ready", "blocked"]), + actors: new Set(["github:acme/web"]), + sources: new Set(["github:acme/api"]), + }, + tab: "handoffs", + subject: "order", + scenario: [ + "github:acme/api#2->github:acme/web#1", + "github:acme/web#1->github:acme/web#2", + ], +}; + +describe("view state in the address", () => { + test("the default view is the empty query, and the empty query is the default view", () => { + expect(serialiseViewState(defaultViewState)).toBe(""); + expect(parseViewState("")).toEqual({ + state: defaultViewState, + problems: [], + }); + }); + + test("writes every part as its own parameter, repeated parts in order and set members sorted", () => { + const query = new URLSearchParams(serialiseViewState(everything)); + + expect([...query]).toEqual([ + ["scope", "github:acme"], + ["scope", "github:partner/sdk"], + ["view", "sequence"], + ["list", "handoffs"], + ["select", "github:acme/web#1"], + ["status", "blocked"], + ["status", "ready"], + ["actor", "github:acme/web"], + ["source", "github:acme/api"], + ["subject", "order"], + ["step", "github:acme/api#2->github:acme/web#1"], + ["step", "github:acme/web#1->github:acme/web#2"], + ]); + }); + + test("reads back exactly the state it wrote", () => { + const { state, problems } = parseViewState(serialiseViewState(everything)); + + expect(problems).toEqual([]); + expect(state).toEqual(everything); + }); + + test("writes back exactly the link it read, whatever order its sets were in", () => { + const link = + "?scope=github%3Aacme&view=flowchart&select=github%3Aacme%2Fapi%232&status=done&status=blocked"; + const sorted = + "?scope=github%3Aacme&view=flowchart&select=github%3Aacme%2Fapi%232&status=blocked&status=done"; + + expect(serialiseViewState(parseViewState(link).state)).toBe(sorted); + expect(serialiseViewState(parseViewState(sorted).state)).toBe(sorted); + }); + + test("keeps each scope once, in the order first given", () => { + expect( + parseViewState("?scope=github:b&scope=github:a&scope=github:b").state + .scopes, + ).toEqual(["github:b", "github:a"]); + }); + + test("leaves out what it cannot use and names each problem", () => { + const { state, problems } = parseViewState( + "?scope=acme&scope=github:acme&view=gantt&list=everything&status=late&status=ready", + ); + + expect(state).toEqual({ + ...defaultViewState, + scopes: ["github:acme"], + filter: { ...defaultViewState.filter, statuses: new Set(["ready"]) }, + }); + expect(problems).toEqual([ + 'The scope "acme" names no source; write it as source:scope, such as github:owner/repo.', + 'The view "gantt" is not one of flowchart, swimlane, state, sequence.', + 'The list "everything" is not one of ready, blocked, path, handoffs, cycles, issues.', + 'The status "late" is not one of done, unknown, skipped, ready, blocked.', + ]); + }); +}); + +describe("modelIdOf", () => { + test("names the same model for the same scopes in any order, and never repeats a scope", () => { + expect(modelIdOf(["github:b", "github:a", "github:b"])).toBe( + "github:a github:b", + ); + expect(modelIdOf(["github:a", "github:b"])).toBe( + modelIdOf(["github:b", "github:a"]), + ); + }); +}); diff --git a/packages/targets/graph-web/src/use-sync.ts b/packages/targets/graph-web/src/use-sync.ts new file mode 100644 index 0000000..4e732b0 --- /dev/null +++ b/packages/targets/graph-web/src/use-sync.ts @@ -0,0 +1,152 @@ +import type { Gap } from "@exadev/process-graph-source-contract"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { failureOf, type Failure } from "./failure.ts"; +import type { AppPorts } from "./ports.ts"; +import type { SyncCheckpoint, SyncRecord } from "./sync-record.ts"; +import { runSync, type SyncProgress } from "./sync.ts"; + +/** Where the sync of the shown model stands in this page. */ +export type SyncState = + | { readonly phase: "idle" } + | { readonly phase: "running"; readonly progress: SyncProgress | undefined } + | { readonly phase: "finished"; readonly progress: SyncProgress } + | { readonly phase: "cancelled"; readonly progress: SyncProgress | undefined } + | { + readonly phase: "failed"; + readonly failure: Failure; + readonly progress: SyncProgress | undefined; + }; + +/** The sync of one model, and what can be done with it. */ +export interface SyncHandle { + readonly state: SyncState; + /** This model's last sync record on the device: where an interrupted sync stopped, and what the sources could not read. */ + readonly record: SyncRecord | undefined; + /** Reads every scope from the start. */ + readonly start: () => void; + /** Carries on from where the last sync stopped. */ + readonly resume: () => void; + /** Stops the running sync; what it has written stays written. */ + readonly cancel: () => void; +} + +const idle: SyncState = { phase: "idle" }; + +/** + * Runs and follows the sync of one model from its scopes. Progress is recorded on the device after every page, so a sync interrupted by a cancel, a rate limit, a closed tab or a reload can be resumed from its checkpoint. Changing the model or unmounting cancels a running sync. + * @param ports - The app's ports: the store, the sources, the token and the sync records. The token is read when a sync starts and handed straight to the sources, so it never enters React state. + * @param modelId - The model the scopes sync into. + * @param scopes - The scopes, in order. + * @returns The sync's state, its record and its controls. + */ +export const useSync = ( + ports: AppPorts, + modelId: string, + scopes: readonly string[], +): SyncHandle => { + const [state, setState] = useState<{ + readonly modelId: string; + readonly value: SyncState; + }>({ modelId, value: idle }); + const [record, setRecord] = useState(() => + ports.syncRecords.read(modelId), + ); + const [recordFor, setRecordFor] = useState(modelId); + const controller = useRef(undefined); + + if (recordFor !== modelId) { + setRecordFor(modelId); + setRecord(ports.syncRecords.read(modelId)); + } + + useEffect( + () => () => { + controller.current?.abort(); + }, + [modelId], + ); + + const run = useCallback( + (resume: SyncCheckpoint | undefined, earlierGaps: readonly Gap[]) => { + const token = ports.token.read(); + if (token === undefined) { + return; + } + controller.current?.abort(); + const current = new AbortController(); + controller.current = current; + const settle = (value: SyncState): void => { + if (controller.current === current) { + setState({ modelId, value }); + } + }; + const remember = (next: SyncRecord): void => { + ports.syncRecords.write(next); + if (controller.current === current) { + setRecord(next); + } + }; + let latest: SyncProgress | undefined; + const withEarlierGaps = (progress: SyncProgress): SyncProgress => ({ + ...progress, + gaps: [...earlierGaps, ...progress.gaps], + }); + settle({ phase: "running", progress: undefined }); + runSync({ + store: ports.store, + sources: ports.sources(token), + modelId, + scopes, + resume, + signal: current.signal, + onProgress: (progress) => { + latest = withEarlierGaps(progress); + remember({ + modelId, + gaps: latest.gaps, + ...(latest.checkpoint === undefined + ? {} + : { checkpoint: latest.checkpoint }), + }); + settle({ phase: "running", progress: latest }); + }, + }).then( + (progress) => { + const finished = withEarlierGaps(progress); + remember({ modelId, gaps: finished.gaps }); + settle({ phase: "finished", progress: finished }); + }, + (error: unknown) => { + settle( + current.signal.aborted + ? { phase: "cancelled", progress: latest } + : { + phase: "failed", + failure: failureOf(error), + progress: latest, + }, + ); + }, + ); + }, + [ports, modelId, scopes], + ); + + const start = useCallback(() => { + run(undefined, []); + }, [run]); + const resume = useCallback(() => { + run(record?.checkpoint, record?.gaps ?? []); + }, [run, record]); + const cancel = useCallback(() => { + controller.current?.abort(); + }, []); + + return { + state: state.modelId === modelId ? state.value : idle, + record, + start, + resume, + cancel, + }; +}; diff --git a/packages/targets/graph-web/src/view-boundary.tsx b/packages/targets/graph-web/src/view-boundary.tsx new file mode 100644 index 0000000..d3cb738 --- /dev/null +++ b/packages/targets/graph-web/src/view-boundary.tsx @@ -0,0 +1,54 @@ +import { Component, type ReactNode } from "react"; +import { notice } from "./app.css.ts"; + +export interface ViewBoundaryProps { + /** What the boundary shows a failure of, as in "The swimlane could not be drawn". */ + readonly name: string; + /** Changing it clears a shown failure, so a different view or model is tried afresh. */ + readonly resetKey: string; + readonly children: ReactNode; +} + +interface ViewBoundaryState { + readonly error: unknown; + readonly failed: boolean; +} + +/** + * Shows why a view could not be drawn, such as a layout that failed, in place of the view, and keeps the rest of the app working. React catches render errors only in a class component, so this is one. + */ +export class ViewBoundary extends Component< + ViewBoundaryProps, + ViewBoundaryState +> { + override state: ViewBoundaryState = { error: undefined, failed: false }; + + /** + * Records the error a child threw while rendering. + * @param error - What the child threw. + * @returns The failed state. + */ + static getDerivedStateFromError(error: unknown): ViewBoundaryState { + return { error, failed: true }; + } + + override componentDidUpdate(previous: ViewBoundaryProps): void { + if (previous.resetKey !== this.props.resetKey && this.state.failed) { + this.setState({ error: undefined, failed: false }); + } + } + + override render(): ReactNode { + const { error, failed } = this.state; + if (!failed) { + return this.props.children; + } + + return ( +

+ {this.props.name} could not be drawn:{" "} + {error instanceof Error ? error.message : String(error)} +

+ ); + } +} diff --git a/packages/targets/graph-web/src/view-state-hooks.ts b/packages/targets/graph-web/src/view-state-hooks.ts new file mode 100644 index 0000000..36c47d7 --- /dev/null +++ b/packages/targets/graph-web/src/view-state-hooks.ts @@ -0,0 +1,42 @@ +import { useCallback, useMemo, useSyncExternalStore } from "react"; +import type { AddressBar, Observable } from "./ports.ts"; +import { + parseViewState, + serialiseViewState, + type ParsedViewState, + type ViewState, +} from "./url-state.ts"; + +/** + * Reads an observable's current value and re-renders when it changes. + * @param observable - The value to follow. Keep it stable between renders. + * @returns The current value. + */ +export const useObservable = (observable: Observable): T => + useSyncExternalStore(observable.subscribe, observable.get); + +/** The view state read from the address, and the function that changes it. */ +export interface ViewStateHandle extends ParsedViewState { + /** Applies a change to the view state and writes the result to the address. */ + readonly update: (change: (state: ViewState) => ViewState) => void; +} + +/** + * Follows the view state in the page address: every change is written back to the address, so the address is always a link to what is on screen. + * @param address - The address bar port. Keep it stable between renders. + * @returns The state, what in the address could not be used, and `update`. + */ +export const useViewState = (address: AddressBar): ViewStateHandle => { + const search = useObservable(address.search); + const parsed = useMemo(() => parseViewState(search), [search]); + const update = useCallback( + (change: (state: ViewState) => ViewState) => { + address.replace( + serialiseViewState(change(parseViewState(address.search.get()).state)), + ); + }, + [address], + ); + + return { ...parsed, update }; +}; diff --git a/packages/targets/graph-web/src/views.ts b/packages/targets/graph-web/src/views.ts new file mode 100644 index 0000000..0ba8df1 --- /dev/null +++ b/packages/targets/graph-web/src/views.ts @@ -0,0 +1,103 @@ +import type { Model, OpenIds, Projection } from "@exadev/process-graph-schema"; +import type { ViewKind } from "./url-state.ts"; + +/** The projection a view draws, or why the model cannot be drawn that way. */ +export type ResolvedView = + | { readonly projection: Projection; readonly unavailable?: never } + | { readonly unavailable: string; readonly projection?: never }; + +/** What a view needs besides the model: the choices a link may carry and the critical path to fall back on. */ +export interface ViewInputs { + readonly view: ViewKind; + /** The entity a state machine follows, from the link. */ + readonly subject: string | undefined; + /** The transitions a sequence diagram follows, from the link. */ + readonly scenario: readonly string[]; + /** The critical path's elements, in dependency order. */ + readonly criticalPath: readonly string[]; +} + +/** The transitions that join consecutive elements of a path, in path order: the path as a scenario. */ +export const transitionsAlong = ( + model: Model, + path: readonly string[], +): readonly string[] => + path.slice(1).flatMap((target, index) => { + const source = path[index]; + const step = model.transitions.find( + (transition) => + transition.source === source && transition.target === target, + ); + + return step === undefined ? [] : [step.id]; + }); + +/** + * The projection to draw a view with. A flowchart and a swimlane need nothing more. A state machine follows the entity the link names, else the model's own state machine projection, else its first entity. A sequence diagram follows the transitions the link names, else the model's own sequence projection, else the critical path read as a scenario. + * @param model - The model. + * @param inputs - The view and what it may follow. + * @returns The projection, or a sentence saying why there is none. + */ +export const resolveView = ( + model: Model, + inputs: ViewInputs, +): ResolvedView => { + const { view, subject, scenario, criticalPath } = inputs; + if (view === "flowchart" || view === "swimlane") { + return { projection: { id: view, notation: view } }; + } + if (view === "state") { + const entities = model.participants.filter( + (participant) => participant.kind === "entity", + ); + const declared = model.projections.find( + (projection) => projection.notation === "stateMachine", + ); + const chosen = + subject ?? + (declared?.notation === "stateMachine" ? declared.subject : undefined) ?? + entities[0]?.id; + if (chosen === undefined) { + return { + unavailable: + "A state machine follows one entity through its states, and this model declares no entity. Issues from a source are work, not entities; add an entity to follow one.", + }; + } + if (!entities.some((entity) => entity.id === chosen)) { + return { + unavailable: `"${chosen}" is not an entity of this model, so it has no states to follow.`, + }; + } + + return { + projection: { id: "state", notation: "stateMachine", subject: chosen }, + }; + } + + const declared = model.projections.find( + (projection) => projection.notation === "sequence", + ); + const steps = + scenario.length > 0 + ? scenario + : declared?.notation === "sequence" + ? declared.scenario + : transitionsAlong(model, criticalPath); + if (steps.length === 0) { + return { + unavailable: + "A sequence diagram follows one path of transitions, and there is none to follow: no work is waiting on other work.", + }; + } + const known = new Set(model.transitions.map((transition) => transition.id)); + const unknown = steps.find((step) => !known.has(step)); + if (unknown !== undefined) { + return { + unavailable: `"${unknown}" is not a transition of this model, so the sequence cannot follow it.`, + }; + } + + return { + projection: { id: "sequence", notation: "sequence", scenario: steps }, + }; +}; diff --git a/packages/targets/graph-web/src/views.unit.test.ts b/packages/targets/graph-web/src/views.unit.test.ts new file mode 100644 index 0000000..47e5ca6 --- /dev/null +++ b/packages/targets/graph-web/src/views.unit.test.ts @@ -0,0 +1,128 @@ +import type { Model, OpenIds } from "@exadev/process-graph-schema"; +import { describe, expect, test } from "vitest"; +import { resolveView, transitionsAlong } from "./views.ts"; + +const model: Model = { + participants: [ + { id: "shop", label: "Shop", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [], + actions: [], + artefacts: [], + elements: [ + { + kind: "activity", + id: "a", + label: "A", + actor: "shop", + affects: ["order"], + }, + { + kind: "activity", + id: "b", + label: "B", + actor: "shop", + affects: ["order"], + }, + { kind: "activity", id: "c", label: "C", actor: "shop" }, + ], + transitions: [ + { id: "a->b", source: "a", target: "b" }, + { id: "b->c", source: "b", target: "c" }, + ], + fragments: [], + projections: [], +}; + +const noEntities: Model = { + ...model, + participants: [{ id: "shop", label: "Shop", kind: "system" }], + elements: model.elements.map((element) => + element.kind === "activity" ? { ...element, affects: [] } : element, + ), +}; + +const inputs = { + subject: undefined, + scenario: [], + criticalPath: ["a", "b", "c"], +} as const; + +describe("resolveView", () => { + test("draws a flowchart and a swimlane with nothing more", () => { + expect(resolveView(model, { ...inputs, view: "flowchart" })).toEqual({ + projection: { id: "flowchart", notation: "flowchart" }, + }); + expect(resolveView(model, { ...inputs, view: "swimlane" })).toEqual({ + projection: { id: "swimlane", notation: "swimlane" }, + }); + }); + + test("follows the model's first entity in a state machine, or the one the link names", () => { + expect(resolveView(model, { ...inputs, view: "state" })).toEqual({ + projection: { id: "state", notation: "stateMachine", subject: "order" }, + }); + expect( + resolveView(model, { ...inputs, view: "state", subject: "shop" }), + ).toEqual({ + unavailable: + '"shop" is not an entity of this model, so it has no states to follow.', + }); + }); + + test("says why a model with no entity has no state machine", () => { + expect(resolveView(noEntities, { ...inputs, view: "state" })).toEqual({ + unavailable: + "A state machine follows one entity through its states, and this model declares no entity. Issues from a source are work, not entities; add an entity to follow one.", + }); + }); + + test("follows the critical path in a sequence diagram unless the link names a scenario", () => { + expect(resolveView(model, { ...inputs, view: "sequence" })).toEqual({ + projection: { + id: "sequence", + notation: "sequence", + scenario: ["a->b", "b->c"], + }, + }); + expect( + resolveView(model, { ...inputs, view: "sequence", scenario: ["b->c"] }), + ).toEqual({ + projection: { id: "sequence", notation: "sequence", scenario: ["b->c"] }, + }); + expect( + resolveView(model, { ...inputs, view: "sequence", scenario: ["c->a"] }), + ).toEqual({ + unavailable: + '"c->a" is not a transition of this model, so the sequence cannot follow it.', + }); + }); + + test("prefers the model's own projections to the defaults", () => { + const declared: Model = { + ...model, + projections: [{ id: "path", notation: "sequence", scenario: ["a->b"] }], + }; + + expect(resolveView(declared, { ...inputs, view: "sequence" })).toEqual({ + projection: { id: "sequence", notation: "sequence", scenario: ["a->b"] }, + }); + }); + + test("says when there is no path to follow", () => { + expect( + resolveView(model, { ...inputs, view: "sequence", criticalPath: ["c"] }), + ).toEqual({ + unavailable: + "A sequence diagram follows one path of transitions, and there is none to follow: no work is waiting on other work.", + }); + }); +}); + +describe("transitionsAlong", () => { + test("reads a path as the transitions between its consecutive elements", () => { + expect(transitionsAlong(model, ["a", "b", "c"])).toEqual(["a->b", "b->c"]); + expect(transitionsAlong(model, ["a"])).toEqual([]); + }); +}); diff --git a/packages/targets/graph-web/src/work-lists.tsx b/packages/targets/graph-web/src/work-lists.tsx new file mode 100644 index 0000000..43099cf --- /dev/null +++ b/packages/targets/graph-web/src/work-lists.tsx @@ -0,0 +1,287 @@ +import type { Status } from "@exadev/process-graph-domain"; +import { handoffs } from "@exadev/process-graph-projection"; +import { + matchesFilter, + useBlockers, + useCriticalPath, + useGraphSelection, + useReadySet, + type ElementFilter, + type Run, +} from "@exadev/process-graph-react"; +import { + validate, + validateOverlay, + type OpenIds, +} from "@exadev/process-graph-schema"; +import { StatusBadge, visuallyHidden } from "@exadev/process-graph-ui"; +import { useId, useMemo, type ReactElement, type ReactNode } from "react"; +import { list, listbox, muted, option, section } from "./app.css.ts"; +import { Tabs } from "./tabs.tsx"; +import { listTabs, type ListTab } from "./url-state.ts"; + +/** Each list's tab label. */ +export const listLabels: Readonly> = { + ready: "Ready", + blocked: "Blocked", + path: "Critical path", + handoffs: "Hand-offs", + cycles: "Cycles", + issues: "Problems", +}; + +/** What every list reads: the run, each element's status and label, the filter, and the shared selection. */ +export interface ListContext { + readonly run: Run; + readonly statuses: ReadonlyMap; + readonly labelOf: (id: string) => string; + readonly filter: ElementFilter; + readonly selected: string | undefined; + readonly onSelect: (id: string | undefined) => void; +} + +interface ElementListProps extends ListContext { + readonly label: string; + readonly ids: readonly string[]; + /** What to say when the list is empty. */ + readonly empty: string; + /** Extra text after an element's label, such as what it waits on. */ + readonly detail?: ((id: string) => ReactNode) | undefined; +} + +/** A keyboard-navigable list of elements that shares the app's selection: the arrow keys, Home, End and Escape move it, as on the canvas. */ +const ElementList = (props: ElementListProps): ReactElement => { + const { label, ids, empty, detail, statuses, labelOf, selected, onSelect } = + props; + const selection = useGraphSelection(ids, { selected, onChange: onSelect }); + if (ids.length === 0) { + return

{empty}

; + } + + return ( +
    + {ids.map((id) => { + const status = statuses.get(id); + + return ( +
  • + {labelOf(id)}{" "} + {status === undefined ? null : } + {detail === undefined ? null : <> {detail(id)}} +
  • + ); + })} +
+ ); +}; + +/** The elements of `ids` the filter shows, in their given order. */ +const useShown = (context: ListContext, ids: readonly string[]) => { + const { run, statuses, filter } = context; + + return useMemo(() => { + const elements = new Map( + run.model.elements.map((element) => [element.id, element]), + ); + + return ids.filter((id) => { + const element = elements.get(id); + const status = statuses.get(id); + + return ( + element !== undefined && + status !== undefined && + matchesFilter(element, status, filter) + ); + }); + }, [run.model, statuses, filter, ids]); +}; + +const ReadyList = (context: ListContext): ReactElement => { + const ready = useReadySet(context.run); + const shown = useShown(context, ready); + + return ( + + ); +}; + +const BlockedList = (context: ListContext): ReactElement => { + const blockers = useBlockers(context.run); + const blocked = useMemo(() => [...blockers.keys()], [blockers]); + const shown = useShown(context, blocked); + + return ( + { + const waiting = blockers.get(id); + + return waiting === undefined + ? null + : `waits on ${waiting.map(context.labelOf).join(", ")}`; + }} + /> + ); +}; + +const PathList = (context: ListContext): ReactElement => { + const { path } = useCriticalPath(context.run); + + return ( + + ); +}; + +const HandoffList = (context: ListContext): ReactElement => { + const { run, labelOf } = context; + const found = useMemo(() => handoffs(run.model), [run.model]); + const participants = useMemo( + () => + new Map( + run.model.participants.map((participant) => [ + participant.id, + participant.label, + ]), + ), + [run.model], + ); + const transitions = useMemo( + () => + new Map( + run.model.transitions.map((transition) => [transition.id, transition]), + ), + [run.model], + ); + if (found.length === 0) { + return

No work passes from one owner to another.

; + } + + return ( +
    + {found.map((handoff) => { + const transition = transitions.get(handoff.transition); + + return ( +
  • + {participants.get(handoff.from) ?? handoff.from} hands over to{" "} + {participants.get(handoff.to) ?? handoff.to} + {transition === undefined + ? null + : `: ${labelOf(transition.source)} before ${labelOf(transition.target)}`} +
  • + ); + })} +
+ ); +}; + +const ProblemList = (context: ListContext): ReactElement => { + const { run } = context; + const problems = useMemo( + () => [...validate(run.model), ...validateOverlay(run.model, run.overlay)], + [run], + ); + if (problems.length === 0) { + return ( +

+ The model and its run state are valid: every reference resolves and no + identifier repeats. +

+ ); + } + + return ( +
    + {problems.map((problem) => ( +
  • + {problem.message} +
  • + ))} +
+ ); +}; + +/** The cycles list of a model whose statuses could be derived, which therefore has no illegal cycle; a model with one never reaches the lists. */ +const NoCycles = (): ReactElement => ( +

+ No cycle runs without a decision to leave it, so every element's status + can be decided. +

+); + +const panels: Readonly< + Record ReactElement> +> = { + ready: ReadyList, + blocked: BlockedList, + path: PathList, + handoffs: HandoffList, + cycles: NoCycles, + issues: ProblemList, +}; + +export interface WorkListsProps extends ListContext { + readonly tab: ListTab; + readonly onTab: (tab: ListTab) => void; +} + +/** + * The questions the command line answers about the whole model, as lists beside the view: what is ready, what is blocked and on what, the critical path, the hand-offs between owners, cycles and structural problems. Element lists follow the filter and share the app's selection. + * @param props - The run, statuses, labels, filter, selection and the open list. + * @returns The tabbed lists. + */ +export const WorkLists = (props: WorkListsProps): ReactElement => { + const { tab, onTab, ...context } = props; + const panelId = useId(); + const headingId = useId(); + const Panel = panels[tab]; + + return ( +
+

+ Work +

+ ({ value, label: listLabels[value] }))} + selected={tab} + onSelect={onTab} + panelId={panelId} + /> +
+ +
+
+ ); +}; diff --git a/packages/targets/graph-web/src/workspace.tsx b/packages/targets/graph-web/src/workspace.tsx new file mode 100644 index 0000000..beacd6e --- /dev/null +++ b/packages/targets/graph-web/src/workspace.tsx @@ -0,0 +1,316 @@ +import { illegalCycles, type Status } from "@exadev/process-graph-domain"; +import { + elementLabel, + emptyFilter, + filterOptions, + noFacts, + useCriticalPath, + useModel, + useStatuses, + type ElementFilter, + type Run, +} from "@exadev/process-graph-react"; +import { ProcessView } from "@exadev/process-graph-react-flow"; +import type { OpenIds, OverlayStatus } from "@exadev/process-graph-schema"; +import type { StoredModel } from "@exadev/process-graph-store-contract"; +import { FilterPanel, Legend } from "@exadev/process-graph-ui"; +import { useCallback, useId, useMemo, type ReactElement } from "react"; +import { AddForm } from "./add-form.tsx"; +import { withAddition, type Addition } from "./addition.ts"; +import { + canvas, + list, + muted, + notice, + section, + sidebar, + workspace, +} from "./app.css.ts"; +import { ElementDetails } from "./element-details.tsx"; +import { failureOf } from "./failure.ts"; +import type { AppPorts } from "./ports.ts"; +import { Tabs } from "./tabs.tsx"; +import { viewKinds, type ViewKind, type ViewState } from "./url-state.ts"; +import { ViewBoundary } from "./view-boundary.tsx"; +import { resolveView } from "./views.ts"; +import { WorkLists } from "./work-lists.tsx"; + +/** Each view's tab label. */ +export const viewLabels: Readonly> = { + flowchart: "Flowchart", + swimlane: "Swimlane", + state: "State machine", + sequence: "Sequence", +}; + +export interface ModelAreaProps { + readonly ports: AppPorts; + readonly modelId: string; + readonly state: ViewState; + readonly update: (change: (state: ViewState) => ViewState) => void; + /** The theme in use, for React Flow's own controls. */ + readonly colourScheme: "light" | "dark"; + /** Adds a scope and syncs it: expanding the model on demand to work it depends on outside the scope. */ + readonly onAddScope: (scope: string) => void; +} + +interface LoadedModelProps extends ModelAreaProps { + readonly stored: StoredModel; +} + +interface CyclesNoticeProps { + readonly cycles: readonly (readonly string[])[]; + readonly labelOf: (id: string) => string; +} + +/** The illegal cycles of a model, as the command line's `cycles` lists them: work that waits on itself with no decision to leave the loop, so nothing in it can be ready. */ +const CyclesNotice = (props: CyclesNoticeProps): ReactElement => ( +
+ + Some work waits on itself with no decision to leave the loop, so no status + can be worked out until one of these dependencies is removed: + +
    + {props.cycles.map((cycle) => ( +
  • + {cycle.map(props.labelOf).join(" waits on ")} +
  • + ))} +
+
+); + +interface WorkspaceProps extends LoadedModelProps { + readonly run: Run; + readonly labelOf: (id: string) => string; +} + +const toggled = (set: ReadonlySet, value: T): ReadonlySet => { + const next = new Set(set); + if (!next.delete(value)) { + next.add(value); + } + + return next; +}; + +/** The view, its filters, the lists and the selected element's details, side by side on a wide screen. */ +const Workspace = (props: WorkspaceProps): ReactElement => { + const { + ports, + modelId, + state, + update, + colourScheme, + onAddScope, + run, + labelOf, + } = props; + const statuses = useStatuses(run); + const { path } = useCriticalPath(run); + const options = useMemo(() => filterOptions(run.model), [run.model]); + const resolved = useMemo( + () => + resolveView(run.model, { + view: state.view, + subject: state.subject, + scenario: state.scenario, + criticalPath: path, + }), + [run.model, state.view, state.subject, state.scenario, path], + ); + const viewPanel = useId(); + const setFilter = useCallback( + (change: (filter: ElementFilter) => ElementFilter) => { + update((current) => ({ ...current, filter: change(current.filter) })); + }, + [update], + ); + const select = useCallback( + (id: string | undefined) => { + update((current) => ({ ...current, selected: id })); + }, + [update], + ); + const setState = useCallback( + async (id: string, value: OverlayStatus): Promise => { + await ports.store.v1.overlay.set({ modelId, overlay: { [id]: value } }); + }, + [ports.store, modelId], + ); + const add = useCallback( + async (addition: Addition): Promise => { + await ports.store.v1.models.save({ + modelId, + model: withAddition(run.model, addition), + }); + }, + [ports.store, modelId, run.model], + ); + const viewName = `${viewLabels[state.view]} of ${state.scopes.join(", ")}`; + + return ( +
+
+ ({ + value, + label: viewLabels[value], + }))} + selected={state.view} + onSelect={(view) => { + update((current) => ({ ...current, view })); + }} + panelId={viewPanel} + /> +
+ {resolved.projection === undefined ? ( +

+ {resolved.unavailable} +

+ ) : ( +
+ + + +
+ )} +
+ +
+
+ {state.selected === undefined ? null : ( + + )} + { + update((current) => ({ ...current, tab })); + }} + /> + { + setFilter((filter) => ({ + ...filter, + statuses: toggled(filter.statuses, status), + })); + }} + onToggleActor={(actor) => { + setFilter((filter) => ({ + ...filter, + actors: toggled(filter.actors, actor), + })); + }} + onToggleSource={(source) => { + setFilter((filter) => ({ + ...filter, + sources: toggled(filter.sources, source), + })); + }} + onClear={() => { + setFilter(() => emptyFilter); + }} + /> + +
+
+ ); +}; + +/** A loaded model: its cycles when it has any, since no status can be derived through one, and the workspace otherwise. */ +const LoadedModel = (props: LoadedModelProps): ReactElement => { + const { stored } = props; + const run = useMemo>( + () => ({ model: stored.model, overlay: stored.overlay, facts: noFacts }), + [stored], + ); + const cycles = useMemo(() => illegalCycles(run.model), [run.model]); + const labels = useMemo( + () => + new Map( + run.model.elements.map((element) => [ + element.id, + elementLabel(element), + ]), + ), + [run.model], + ); + const labelOf = useCallback( + (id: string): string => labels.get(id) ?? id, + [labels], + ); + + if (cycles.length > 0) { + return ; + } + + return ; +}; + +/** + * The model the scopes sync into, read from the store and followed: every write to it, from a sync here or in another tab, shows at once. Before the first sync the store has no such model, which is said plainly rather than shown as a failure, and the sync that creates it shows it. + * @param props - The ports, the model, the view state and its update. + * @returns The workspace, or what stands in for it while the model loads or when there is none. + */ +export const ModelArea = (props: ModelAreaProps): ReactElement => { + const { ports, modelId } = props; + const model = useModel(ports.store, modelId); + + if (model.status === "loading") { + return ( +

+ Opening the last sync on this device... +

+ ); + } + if (model.status === "missing") { + return ( +

+ Nothing has been synced for this scope on this device yet. Sync to read + it. +

+ ); + } + if (model.status === "failed") { + return ( +

+ The store could not be read: {failureOf(model.error).message} +

+ ); + } + + return ; +}; diff --git a/packages/targets/graph-web/test/app.smoke.test.ts b/packages/targets/graph-web/test/app.smoke.test.ts new file mode 100644 index 0000000..398d797 --- /dev/null +++ b/packages/targets/graph-web/test/app.smoke.test.ts @@ -0,0 +1,89 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { z } from "zod"; + +// Reads the built dist/, not src/: the check that the build emitted an installable app, with its icons rendered, its service worker precaching the shell and the SQLite wasm, and its pages pointing at both. + +const dist = join(import.meta.dirname, "../dist"); +const read = (path: string): string => readFileSync(join(dist, path), "utf8"); + +const manifestSchema = z.object({ + name: z.string(), + short_name: z.string(), + start_url: z.string(), + scope: z.string(), + display: z.literal("standalone"), + theme_color: z.string(), + background_color: z.string(), + icons: z.array( + z.object({ + src: z.string(), + sizes: z.string(), + type: z.string(), + purpose: z.string(), + }), + ), +}); + +/** The PNG signature, then the IHDR chunk, whose data starts with the width and height as big-endian 32-bit integers. */ +const pngSignature = "89504e470d0a1a0a"; +const widthOffset = 16; +const heightOffset = 20; + +describe("the built app", () => { + const manifest = manifestSchema.parse( + JSON.parse(read("manifest.webmanifest")), + ); + + test("has a manifest an installable app needs, scoped to the base path", () => { + expect(manifest).toMatchObject({ + name: "Process graph", + start_url: "/", + scope: "/", + display: "standalone", + }); + expect( + manifest.icons.map( + (icon) => `${icon.purpose} ${icon.sizes} ${icon.type}`, + ), + ).toEqual([ + "any 192x192 image/png", + "any 512x512 image/png", + "maskable 192x192 image/png", + "maskable 512x512 image/png", + ]); + }); + + test("rendered every icon the manifest names as a PNG of its stated size", () => { + for (const icon of manifest.icons) { + const bytes = readFileSync(join(dist, icon.src)); + expect(bytes.subarray(0, pngSignature.length / 2).toString("hex")).toBe( + pngSignature, + ); + expect( + `${String(bytes.readUInt32BE(widthOffset))}x${String(bytes.readUInt32BE(heightOffset))}`, + ).toBe(icon.sizes); + } + expect(existsSync(join(dist, "icons/icon.svg"))).toBe(true); + }); + + test("precaches the page, the icons and the SQLite wasm in its service worker", () => { + const worker = read("sw.js"); + + expect(worker).toContain('"index.html"'); + expect(worker).toMatch(/assets\/sqlite3-[\w-]+\.wasm/u); + for (const icon of manifest.icons) { + expect(worker).toContain(`"${icon.src}"`); + } + }); + + test("links the manifest and the icon from the page", () => { + const page = read("index.html"); + + expect(page).toContain( + '', + ); + expect(page).toContain('href="icons/icon.svg"'); + }); +}); diff --git a/packages/targets/graph-web/tsconfig.json b/packages/targets/graph-web/tsconfig.json new file mode 100644 index 0000000..ce5912b --- /dev/null +++ b/packages/targets/graph-web/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["vite/client", "vite-plugin-pwa/vanillajs"], + "jsx": "react-jsx" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test-support/**"] +} diff --git a/packages/targets/graph-web/tsconfig.node.json b/packages/targets/graph-web/tsconfig.node.json new file mode 100644 index 0000000..4cb4dc1 --- /dev/null +++ b/packages/targets/graph-web/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["node", "vite/client", "vite-plugin-pwa/vanillajs"], + "jsx": "react-jsx" + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "build/**/*.ts", + "test/**/*.ts", + "e2e/**/*.ts", + "*.config.ts" + ] +} diff --git a/packages/targets/graph-web/turbo.json b/packages/targets/graph-web/turbo.json new file mode 100644 index 0000000..4604188 --- /dev/null +++ b/packages/targets/graph-web/turbo.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://turborepo.com/schema.json", + "extends": ["//"], + "tasks": { + "_build": { + "inputs": ["$TURBO_EXTENDS$", "index.html", "build/**", "vite.config.ts"] + }, + "_test:e2e": { + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!test-results/**", + "!playwright-report/**" + ] + } + } +} diff --git a/packages/targets/graph-web/vite.config.ts b/packages/targets/graph-web/vite.config.ts new file mode 100644 index 0000000..21f66f0 --- /dev/null +++ b/packages/targets/graph-web/vite.config.ts @@ -0,0 +1,112 @@ +import { darkValues, lightValues } from "@exadev/process-graph-ui"; +import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; +import { defineConfig, type HtmlTagDescriptor, type Plugin } from "vite"; +import { VitePWA } from "vite-plugin-pwa"; +import { iconFiles, pwaIcons } from "./build/icons.ts"; + +/** + * The path the app is served under, from `GRAPH_WEB_BASE` (such as `/process-graph/` on a project site), else the root. It must start and end with a slash; the manifest's `start_url` and `scope` and the service worker's scope all follow it. + */ +const base = process.env.GRAPH_WEB_BASE ?? "/"; +if (!base.startsWith("/") || !base.endsWith("/")) { + throw new Error(`GRAPH_WEB_BASE must start and end with "/", not "${base}"`); +} + +/** The icon's tile in the theme's accent, its glyph in the page background, so the icon and the app read as one. */ +const iconColours = { + tile: lightValues.colour.accent, + glyph: lightValues.colour.background, +}; + +/** The browser chrome follows the page: each colour scheme's background from the theme contract's values. */ +const themeColourTags: Plugin = { + name: "process-graph-web-theme-colour", + transformIndexHtml: (): HtmlTagDescriptor[] => [ + { + tag: "meta", + attrs: { + name: "theme-color", + media: "(prefers-color-scheme: light)", + content: lightValues.colour.background, + }, + injectTo: "head", + }, + { + tag: "meta", + attrs: { + name: "theme-color", + media: "(prefers-color-scheme: dark)", + content: darkValues.colour.background, + }, + injectTo: "head", + }, + ], +}; + +/** + * The app build. Vanilla Extract compiles the app's own styles; graph-ui and graph-react-flow ship theirs compiled. The store runs in a module worker, so workers build as ES modules. SQLite's wasm module loads its `.wasm` file relative to itself, which dependency pre-bundling would break, so it is excluded from it. + * + * Neither the dev server nor the preview server sets `Cross-Origin-Opener-Policy` or `Cross-Origin-Embedder-Policy`: the store is built to work without cross-origin isolation (see graph-sqlite-wasm), and the end-to-end tests check that the page is not isolated. + * + * The service worker precaches the app shell, including the SQLite wasm and the icons, so the app opens offline and reads its last sync from the origin private file system. Requests to GitHub are cross-origin and never cached. A new version waits until the person chooses to reload (`registerType: "prompt"`), since taking over a page mid-sync would drop the sync. + */ +export default defineConfig({ + base, + plugins: [ + vanillaExtractPlugin(), + pwaIcons(iconColours), + themeColourTags, + VitePWA({ + registerType: "prompt", + injectRegister: false, + manifest: { + name: "Process graph", + short_name: "Process graph", + description: + "What is ready, what is blocked and why, across GitHub issues in several organisations and repositories, as a flowchart, swimlane, state machine or sequence diagram.", + theme_color: lightValues.colour.accent, + background_color: lightValues.colour.background, + lang: "en-GB", + display: "standalone", + start_url: base, + scope: base, + icons: iconFiles() + .filter((file) => file.size !== undefined) + .map((file) => ({ + src: file.path, + sizes: `${String(file.size)}x${String(file.size)}`, + type: file.type, + purpose: file.purpose, + })), + }, + workbox: { + globPatterns: ["**/*.{js,css,html,svg,png,wasm}"], + runtimeCaching: [], + }, + }), + ], + oxc: { jsx: { runtime: "automatic" } }, + worker: { format: "es" }, + optimizeDeps: { exclude: ["@sqlite.org/sqlite-wasm"] }, + build: { + target: "es2024", + rolldownOptions: { + output: { + // The largest dependencies each get a chunk of their own, so a new version of the app changes only the chunks whose code changed and the service worker fetches only those again, and no chunk outgrows the size Workbox precaches by default. + codeSplitting: { + groups: [ + { name: "elkjs", test: /[\\/]node_modules[\\/]elkjs[\\/]/u }, + { + name: "react-flow", + test: /[\\/]node_modules[\\/](@xyflow|d3-[\w-]+)[\\/]/u, + }, + { + name: "react", + test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/u, + }, + ], + }, + }, + }, + }, +}); diff --git a/packages/targets/graph-web/vitest.config.ts b/packages/targets/graph-web/vitest.config.ts new file mode 100644 index 0000000..dae8bad --- /dev/null +++ b/packages/targets/graph-web/vitest.config.ts @@ -0,0 +1,29 @@ +import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; +import { defineConfig } from "vitest/config"; + +/** + * happy-dom, as the UI packages use: the screen tests render the app against an in-memory store and source with Testing Library, laid out by a stub engine, with the Vanilla Extract plugin compiling the app's own styles. The smoke test reads the built `dist/`. The end-to-end tests are Playwright's, run by `test:e2e`, not here. + */ +export default defineConfig({ + plugins: [vanillaExtractPlugin()], + test: { + projects: [ + { + extends: true, + test: { + name: "unit", + include: ["src/**/*.test.{ts,tsx}"], + environment: "happy-dom", + }, + }, + { + extends: true, + test: { + name: "smoke", + include: ["test/**/*.smoke.test.ts"], + environment: "node", + }, + }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0017fd5..7c5bd39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -774,6 +774,106 @@ importers: 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)(terser@5.51.2)(yaml@2.9.1)) + packages/targets/graph-web: + 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:../../ui/graph/react + '@exadev/process-graph-react-flow': + specifier: 0.0.0 + version: link:../../ui/graph/react-flow + '@exadev/process-graph-schema': + specifier: 1.1.0 + version: link:../../core/graph/schema + '@exadev/process-graph-source-contract': + specifier: 1.0.1 + version: link:../../core/graph/source-contract + '@exadev/process-graph-source-github-issues': + specifier: 1.0.1 + version: link:../../adapters/graph/source-github-issues + '@exadev/process-graph-sqlite-wasm': + specifier: 1.0.2 + version: link:../../adapters/graph/sqlite-wasm + '@exadev/process-graph-store-contract': + specifier: 1.0.3 + version: link:../../core/graph/store-contract + '@exadev/process-graph-ui': + specifier: 0.0.0 + version: link:../../ui/graph/ui + '@orpc/client': + specifier: 2.0.0-beta.41 + version: 2.0.0-beta.41 + '@orpc/server': + specifier: 2.0.0-beta.41 + version: 2.0.0-beta.41 + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@playwright/test': + specifier: 1.63.0 + version: 1.63.0 + '@resvg/resvg-js': + specifier: 2.6.2 + version: 2.6.2 + '@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)(terser@5.51.2)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(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 + 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)(terser@5.51.2)(yaml@2.9.1) + vite-plugin-pwa: + specifier: 2.0.0 + version: 2.0.0(supports-color@7.2.0)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))(workbox-build@7.4.1(supports-color@7.2.0))(workbox-window@7.4.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)(terser@5.51.2)(yaml@2.9.1)) + workbox-window: + specifier: 7.4.1 + version: 7.4.1 + packages/targets/process-graph: dependencies: '@exadev/process-graph-dag': @@ -1068,6 +1168,12 @@ packages: '@andrewbranch/untar.js@1.0.4': resolution: {integrity: sha512-pVXSwPsLuw8IGLo2Di0EaOfsk+ntVvpkk942J/sHYIkwvtKUakEcPh7HBgZ6tuimgzKSEHgCvO4XgQ05DEbwDw==} + '@apideck/better-ajv-errors@0.3.7': + resolution: {integrity: sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==} + engines: {node: '>=10'} + peerDependencies: + ajv: '>=8' + '@arethetypeswrong/cli@0.18.5': resolution: {integrity: sha512-gM+8vRsQOD/Uc7EnBedUhkG5OCsDWE4uoak5QvomGpMpaky0Eh41p04nIMgrWb8EOmqZUJGc6zz9hsP6E56R7g==} engines: {node: '>=20'} @@ -1109,6 +1215,10 @@ packages: resolution: {integrity: sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@8.0.0': resolution: {integrity: sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==} engines: {node: ^22.18.0 || >=24.11.0} @@ -1121,12 +1231,29 @@ packages: resolution: {integrity: sha512-HTrtZ4+iA0+kzdbCgvdSk1U4pEl3BvpGnlUbualCGNbrVhhQvhgBzurTUmqpWyvyHbWYWaIfdx2mOlL9LJjsew==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-create-class-features-plugin@8.0.6': resolution: {integrity: sha512-Hy7wG2Pk1LnAkFMq0CbBk789XMTYVzYGxR7tKgk+TOpZckQRME3Tdou4mMT/8DFjJFxFZ8R6UsS4DR/duBKlVA==} engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: '@babel/core': ^8.0.0 + '@babel/helper-create-regexp-features-plugin@7.29.7': + resolution: {integrity: sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} @@ -1135,6 +1262,10 @@ packages: resolution: {integrity: sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@8.0.5': resolution: {integrity: sha512-GLe05QD98BkNFTkjaqeqF9QSRoHLKrrB4tpoplyQuuPrQJ72rtmkM4wvqJLX/sjZPqkbK5MUYSsuUTqdJoOVjA==} engines: {node: ^22.18.0 || >=24.11.0} @@ -1159,6 +1290,10 @@ packages: peerDependencies: '@babel/core': ^8.0.0 + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + '@babel/helper-optimise-call-expression@8.0.0': resolution: {integrity: sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg==} engines: {node: ^22.18.0 || >=24.11.0} @@ -1173,12 +1308,28 @@ packages: peerDependencies: '@babel/core': ^8.0.0 + '@babel/helper-remap-async-to-generator@7.29.7': + resolution: {integrity: sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-replace-supers@8.0.1': resolution: {integrity: sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q==} engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: '@babel/core': ^8.0.0 + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-skip-transparent-expression-wrappers@8.0.0': resolution: {integrity: sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw==} engines: {node: ^22.18.0 || >=24.11.0} @@ -1207,6 +1358,10 @@ packages: resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-wrap-function@7.29.7': + resolution: {integrity: sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==} + engines: {node: '>=6.9.0'} + '@babel/helpers@7.29.7': resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} @@ -1225,18 +1380,72 @@ packages: engines: {node: ^22.18.0 || >=24.11.0} hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7': + resolution: {integrity: sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7': + resolution: {integrity: sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7': + resolution: {integrity: sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7': + resolution: {integrity: sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7': + resolution: {integrity: sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7': + resolution: {integrity: sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/plugin-proposal-decorators@8.0.2': resolution: {integrity: sha512-+C6O6KKXU7BBq1GNaIkFJxrALUVGRcr+WeWm4OcuRl3h+l/CmNfcTLMrT2Lm3uvGBimBH/8pEBRrXJFLoO67Gg==} engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: '@babel/core': ^8.0.0 + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-decorators@8.0.1': resolution: {integrity: sha512-NI+0S/6MvR6GlcQFwjDZ+WIc2qvG6TXN534lYs9llNldwW4b7Dh6KTtk030FA0xWdYGs4t1lWo+OEWN8wGB+Nw==} engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: '@babel/core': ^8.0.0 + '@babel/plugin-syntax-import-assertions@7.29.7': + resolution: {integrity: sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-jsx@8.0.1': resolution: {integrity: sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==} engines: {node: ^22.18.0 || >=24.11.0} @@ -1255,106 +1464,429 @@ packages: peerDependencies: '@babel/core': ^8.0.0 - '@babel/plugin-transform-destructuring@8.0.5': - resolution: {integrity: sha512-evjxc5fGvpXG2WMGSalpX8IJZVumLIDgk5r5eRhvXRDqFX/GPWq1BeRfJalPCcwJfrR5A/wvegUhx9W5Nr7/mg==} - engines: {node: ^22.18.0 || >=24.11.0} + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^8.0.0 + '@babel/core': ^7.0.0 - '@babel/plugin-transform-explicit-resource-management@8.0.5': - resolution: {integrity: sha512-Vr20jk/ZxRGu30o0jYvcKl2Pf0tiyr8H9q2ZGxYD6yjOpv9DdP6//I85q0cSOdADZhsu/ZZoApjGVCfl4xnVJw==} - engines: {node: ^22.18.0 || >=24.11.0} + '@babel/plugin-transform-arrow-functions@7.29.7': + resolution: {integrity: sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==} + engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^8.0.0 + '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-modules-commonjs@8.0.1': - resolution: {integrity: sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA==} - engines: {node: ^22.18.0 || >=24.11.0} + '@babel/plugin-transform-async-generator-functions@7.29.7': + resolution: {integrity: sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==} + engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^8.0.0 + '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-display-name@8.0.1': - resolution: {integrity: sha512-soLishXlkyu6jcICPyO3HEP7A3GCzKEnn7XfvYrImuWEOwFAz93qShmWSYPf5ww0ZkO4By0zsN2bVIDF54fSdA==} - engines: {node: ^22.18.0 || >=24.11.0} + '@babel/plugin-transform-async-to-generator@7.29.7': + resolution: {integrity: sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==} + engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^8.0.0 + '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx-development@8.0.1': - resolution: {integrity: sha512-Hb+HUZpV9KFHjm+F+P3aLDMi8QXU9l3ROCQv20z18Me2sGyW5nNNR5YTevNlgHvCpFek3BnAwhDGq/BRndXViw==} - engines: {node: ^22.18.0 || >=24.11.0} + '@babel/plugin-transform-block-scoped-functions@7.29.7': + resolution: {integrity: sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==} + engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^8.0.0 + '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx@8.0.1': - resolution: {integrity: sha512-NgkoF7Uq+30TmOPDdNUimT0Nta02uVjqJRFNlVWKrbOCu/CkzfHa4aMnIs0lMpkMmZmWA1e42Va+F04i/pY1zw==} - engines: {node: ^22.18.0 || >=24.11.0} + '@babel/plugin-transform-block-scoping@7.29.7': + resolution: {integrity: sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==} + engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^8.0.0 + '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-pure-annotations@8.0.1': - resolution: {integrity: sha512-7/8UwU8hoPBurXa9tUiTTC8aACTRy5tCqLUtqikHp2eGiWoEB57AduOdbQ71OOMTEvawKrGhv3WfzkDpI+/oSg==} - engines: {node: ^22.18.0 || >=24.11.0} + '@babel/plugin-transform-class-properties@7.29.7': + resolution: {integrity: sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==} + engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^8.0.0 + '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-typescript@8.0.6': - resolution: {integrity: sha512-WF3yRlQtClzScvQbWct00ix63+w3DPiyU0WmCBU4YqK539n3VvJFElElpevoD8wfu6953CbiNoUZBi2B7/5jXw==} - engines: {node: ^22.18.0 || >=24.11.0} + '@babel/plugin-transform-class-static-block@7.29.7': + resolution: {integrity: sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==} + engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^8.0.0 + '@babel/core': ^7.12.0 - '@babel/preset-react@8.0.1': - resolution: {integrity: sha512-jrFuPp/pTddFZbtmWhdLNAYc6UMcpboeUPnw0BBrm4nOmcAko/1TRcFi1PzWCeOFRU+VaSiKmat87W1HvR7mIg==} - engines: {node: ^22.18.0 || >=24.11.0} + '@babel/plugin-transform-classes@7.29.7': + resolution: {integrity: sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==} + engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^8.0.0 + '@babel/core': ^7.0.0-0 - '@babel/preset-typescript@8.0.1': - resolution: {integrity: sha512-qrPhQIN1NLrPmzgazF9XKQqXrOcp/WJly+K+6ReFonn24FZqRJO7clxOJo6Ni75L+2vAqI3cHVU2OJLBxoPp5A==} + '@babel/plugin-transform-computed-properties@7.29.7': + resolution: {integrity: sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.29.7': + resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@8.0.5': + resolution: {integrity: sha512-evjxc5fGvpXG2WMGSalpX8IJZVumLIDgk5r5eRhvXRDqFX/GPWq1BeRfJalPCcwJfrR5A/wvegUhx9W5Nr7/mg==} engines: {node: ^22.18.0 || >=24.11.0} peerDependencies: '@babel/core': ^8.0.0 - '@babel/runtime@7.29.7': - resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + '@babel/plugin-transform-dotall-regex@7.29.7': + resolution: {integrity: sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==} engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 - '@babel/template@7.29.7': - resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + '@babel/plugin-transform-duplicate-keys@7.29.7': + resolution: {integrity: sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==} engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-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==} + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==} engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 - '@babel/traverse@8.0.6': - resolution: {integrity: sha512-ilYV+qLStGzSKLipJlETdvdtpK3rAuwfCmCoGKv15MU4EoeIuLkespSNoNLFGOPSciEHx9TLWtN5mv0fz5w5mQ==} - engines: {node: ^22.18.0 || >=24.11.0} + '@babel/plugin-transform-dynamic-import@7.29.7': + resolution: {integrity: sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 - '@babel/types@7.29.8': - resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + '@babel/plugin-transform-explicit-resource-management@7.29.7': + resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 - '@babel/types@8.0.6': - resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} + '@babel/plugin-transform-explicit-resource-management@8.0.5': + resolution: {integrity: sha512-Vr20jk/ZxRGu30o0jYvcKl2Pf0tiyr8H9q2ZGxYD6yjOpv9DdP6//I85q0cSOdADZhsu/ZZoApjGVCfl4xnVJw==} engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 - '@bcoe/v8-coverage@1.0.2': - resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} - engines: {node: '>=18'} + '@babel/plugin-transform-exponentiation-operator@7.29.7': + resolution: {integrity: sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 - '@blazediff/core@1.10.0': - resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + '@babel/plugin-transform-export-namespace-from@7.29.7': + resolution: {integrity: sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 - '@braidai/lang@1.1.2': - resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} + '@babel/plugin-transform-for-of@7.29.7': + resolution: {integrity: sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 - '@cacheable/memory@2.2.0': - resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} + '@babel/plugin-transform-function-name@7.29.7': + resolution: {integrity: sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.29.7': + resolution: {integrity: sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.29.7': + resolution: {integrity: sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7': + resolution: {integrity: sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.29.7': + resolution: {integrity: sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.29.7': + resolution: {integrity: sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@8.0.1': + resolution: {integrity: sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-modules-systemjs@7.29.8': + resolution: {integrity: sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-umd@7.29.7': + resolution: {integrity: sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.29.7': + resolution: {integrity: sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7': + resolution: {integrity: sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.29.7': + resolution: {integrity: sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.29.7': + resolution: {integrity: sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.29.7': + resolution: {integrity: sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.29.7': + resolution: {integrity: sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.29.7': + resolution: {integrity: sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.29.7': + resolution: {integrity: sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.29.7': + resolution: {integrity: sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.29.7': + resolution: {integrity: sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.29.7': + resolution: {integrity: sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@8.0.1': + resolution: {integrity: sha512-soLishXlkyu6jcICPyO3HEP7A3GCzKEnn7XfvYrImuWEOwFAz93qShmWSYPf5ww0ZkO4By0zsN2bVIDF54fSdA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-jsx-development@8.0.1': + resolution: {integrity: sha512-Hb+HUZpV9KFHjm+F+P3aLDMi8QXU9l3ROCQv20z18Me2sGyW5nNNR5YTevNlgHvCpFek3BnAwhDGq/BRndXViw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-jsx@8.0.1': + resolution: {integrity: sha512-NgkoF7Uq+30TmOPDdNUimT0Nta02uVjqJRFNlVWKrbOCu/CkzfHa4aMnIs0lMpkMmZmWA1e42Va+F04i/pY1zw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-pure-annotations@8.0.1': + resolution: {integrity: sha512-7/8UwU8hoPBurXa9tUiTTC8aACTRy5tCqLUtqikHp2eGiWoEB57AduOdbQ71OOMTEvawKrGhv3WfzkDpI+/oSg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-regenerator@7.29.8': + resolution: {integrity: sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regexp-modifiers@7.29.7': + resolution: {integrity: sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-reserved-words@7.29.7': + resolution: {integrity: sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.29.7': + resolution: {integrity: sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.29.8': + resolution: {integrity: sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.29.7': + resolution: {integrity: sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.29.7': + resolution: {integrity: sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.29.7': + resolution: {integrity: sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@8.0.6': + resolution: {integrity: sha512-WF3yRlQtClzScvQbWct00ix63+w3DPiyU0WmCBU4YqK539n3VvJFElElpevoD8wfu6953CbiNoUZBi2B7/5jXw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-unicode-escapes@7.29.7': + resolution: {integrity: sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.29.7': + resolution: {integrity: sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.29.7': + resolution: {integrity: sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7': + resolution: {integrity: sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.29.7': + resolution: {integrity: sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + + '@babel/preset-react@8.0.1': + resolution: {integrity: sha512-jrFuPp/pTddFZbtmWhdLNAYc6UMcpboeUPnw0BBrm4nOmcAko/1TRcFi1PzWCeOFRU+VaSiKmat87W1HvR7mIg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/preset-typescript@8.0.1': + resolution: {integrity: sha512-qrPhQIN1NLrPmzgazF9XKQqXrOcp/WJly+K+6ReFonn24FZqRJO7clxOJo6Ni75L+2vAqI3cHVU2OJLBxoPp5A==} + engines: {node: ^22.18.0 || >=24.11.0} + 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} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@babel/types@8.0.6': + resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@blazediff/core@1.10.0': + resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + + '@braidai/lang@1.1.2': + resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} + + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} @@ -1882,6 +2414,10 @@ packages: '@types/node': optional: true + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1929,6 +2465,13 @@ packages: '@microsoft/tsdoc@0.17.1': resolution: {integrity: sha512-VHyYyyoH63jFoO3shpw9Zfijk5v/9p/VrClW35z0/q6ooK7sSmxpzOkS4iZPxs3vO4XtBW4H38HRAKkAMIK7DA==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + '@napi-rs/wasm-runtime@1.2.4': resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -2249,6 +2792,11 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -2268,39 +2816,119 @@ packages: resolution: {integrity: sha512-4EDEmvxWtgsCnnVeBvtFIFZtUhPPt1+bA9JrSwU4Sa//6oKtzCSlGGXYJr44OD9aGISymbieJ4mCKHUygUDU+g==} engines: {node: '>=18'} - '@rolldown/binding-android-arm-eabi@1.2.12': - resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} - engines: {node: ^20.19.0 || >=22.12.0} + '@resvg/resvg-js-android-arm-eabi@2.6.2': + resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} + engines: {node: '>= 10'} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.12': - resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} - engines: {node: ^20.19.0 || >=22.12.0} + '@resvg/resvg-js-android-arm64@2.6.2': + resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} + engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.12': - resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} - engines: {node: ^20.19.0 || >=22.12.0} + '@resvg/resvg-js-darwin-arm64@2.6.2': + resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} + engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.12': - resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} - engines: {node: ^20.19.0 || >=22.12.0} + '@resvg/resvg-js-darwin-x64@2.6.2': + resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} + engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.12': - resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.2.12': - resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} - engines: {node: ^20.19.0 || >=22.12.0} + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@resvg/resvg-js@2.6.2': + resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} + engines: {node: '>= 10'} + + '@rolldown/binding-android-arm-eabi@1.2.12': + resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.12': + resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.12': + resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.12': + resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.12': + resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] @@ -2367,6 +2995,193 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/plugin-babel@6.1.0': + resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@types/babel__core': ^7.1.9 + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + '@types/babel__core': + optional: true + rollup: + optional: true + + '@rollup/plugin-node-resolve@16.0.3': + resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-replace@6.0.3': + resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-terser@1.0.0': + resolution: {integrity: sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.64.0': + resolution: {integrity: sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.64.0': + resolution: {integrity: sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.64.0': + resolution: {integrity: sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.64.0': + resolution: {integrity: sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.64.0': + resolution: {integrity: sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.64.0': + resolution: {integrity: sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.64.0': + resolution: {integrity: sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.64.0': + resolution: {integrity: sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.64.0': + resolution: {integrity: sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.64.0': + resolution: {integrity: sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.64.0': + resolution: {integrity: sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.64.0': + resolution: {integrity: sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.64.0': + resolution: {integrity: sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.64.0': + resolution: {integrity: sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.64.0': + resolution: {integrity: sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.64.0': + resolution: {integrity: sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.64.0': + resolution: {integrity: sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.64.0': + resolution: {integrity: sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.64.0': + resolution: {integrity: sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.64.0': + resolution: {integrity: sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.64.0': + resolution: {integrity: sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.64.0': + resolution: {integrity: sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.64.0': + resolution: {integrity: sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.64.0': + resolution: {integrity: sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.64.0': + resolution: {integrity: sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==} + cpu: [x64] + os: [win32] + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -2528,6 +3343,10 @@ packages: '@types/react-dom': optional: true + '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1': + resolution: {integrity: sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==} + engines: {node: '>=12'} + '@turbo/darwin-64@2.11.7': resolution: {integrity: sha512-Z2LFOxwbiJueD1miByXJS/BrW21vv6YaQXZustty5jEX4vQYw6zyh101Sz1QO7WG0mqBzKagZ/DoAeeujI0/Sw==} cpu: [x64] @@ -2632,6 +3451,12 @@ packages: '@types/react@19.3.0': resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -2996,6 +3821,13 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -3008,6 +3840,21 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.14.2: + resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -3029,6 +3876,9 @@ packages: brace-expansion@1.1.21: resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + brace-expansion@5.0.12: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} @@ -3170,6 +4020,10 @@ packages: resolution: {integrity: sha512-rKZTGo4fzKYna8UcL0isTg5wkBNla7bxTypLwZQXjIdi++IdP1OJ41rI5Mti3/jltkPujbu4i9LIARYA+zpotQ==} engines: {node: '>= 12.0.0'} + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -3232,6 +4086,10 @@ packages: resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} engines: {node: '>=22'} + core-js-compat@3.50.0: + resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} + engines: {node: '>=6.4.0'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3260,6 +4118,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-random-string@2.0.0: + resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} + engines: {node: '>=8'} + crypto-random-string@4.0.0: resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} engines: {node: '>=12'} @@ -3559,6 +4421,11 @@ packages: duplexer2@0.1.4: resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + electron-to-chromium@1.5.444: resolution: {integrity: sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==} @@ -3771,6 +4638,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -3778,6 +4648,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eta@4.6.0: + resolution: {integrity: sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==} + engines: {node: '>=20'} + eval@0.1.8: resolution: {integrity: sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==} engines: {node: '>= 0.8'} @@ -3851,6 +4725,9 @@ packages: file-entry-cache@11.1.5: resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -3885,6 +4762,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -3898,6 +4779,10 @@ packages: resolution: {integrity: sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==} engines: {node: '>=14.14'} + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3937,6 +4822,9 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-own-enumerable-property-symbols@3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -3970,6 +4858,12 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + global-directory@5.0.0: resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} engines: {node: '>=20'} @@ -4091,6 +4985,9 @@ packages: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -4199,6 +5096,9 @@ packages: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + is-negative-zero@2.0.3: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} @@ -4211,6 +5111,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-obj@1.0.1: + resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} + engines: {node: '>=0.10.0'} + is-obj@2.0.0: resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} engines: {node: '>=8'} @@ -4223,6 +5127,10 @@ packages: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} + is-regexp@1.0.0: + resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} + engines: {node: '>=0.10.0'} + is-set@2.0.3: resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} engines: {node: '>= 0.4'} @@ -4231,6 +5139,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4284,6 +5196,15 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + java-properties@1.0.2: resolution: {integrity: sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==} engines: {node: '>= 0.6.0'} @@ -4360,6 +5281,10 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -4383,6 +5308,10 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -4487,6 +5416,9 @@ packages: lodash.capitalize@4.2.1: resolution: {integrity: sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==} + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} @@ -4523,6 +5455,9 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.2: resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} @@ -4721,9 +5656,17 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -4979,6 +5922,9 @@ packages: resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} engines: {node: '>=4'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.8.0: resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} @@ -5036,6 +5982,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -5100,6 +6050,14 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + + pretty-bytes@6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} + 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} @@ -5193,14 +6151,32 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regenerate-unicode-properties@10.3.0: + resolution: {integrity: sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + regexpu-core@6.5.2: + resolution: {integrity: sha512-zkmVH92DlzjSFc4xKGZp/2BXIFrDIdBQBC8gUXKCsMgKYb1bkCkxfSuoW1werDjaTEMTbULkHtKFHe1L+Abnaw==} + engines: {node: '>=4'} + registry-auth-token@5.1.1: resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} engines: {node: '>=14'} + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.3: + resolution: {integrity: sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==} + hasBin: true + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -5242,6 +6218,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup@4.64.0: + resolution: {integrity: sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -5288,6 +6269,10 @@ packages: engines: {node: '>=10'} hasBin: true + serialize-javascript@7.1.2: + resolution: {integrity: sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==} + engines: {node: '>=20.0.0'} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -5340,6 +6325,10 @@ packages: resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} engines: {node: '>=8'} + smob@1.6.2: + resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} + engines: {node: '>=20.0.0'} + smol-toml@1.9.0: resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} engines: {node: '>= 18'} @@ -5359,6 +6348,10 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + source-map@0.8.0: + resolution: {integrity: sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==} + engines: {node: '>= 12'} + spawn-error-forwarder@1.0.0: resolution: {integrity: sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==} @@ -5432,6 +6425,10 @@ packages: string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + stringify-object@3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -5444,6 +6441,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-comments@2.0.1: + resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} + engines: {node: '>=10'} + strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} @@ -5533,10 +6534,18 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + temp-dir@2.0.0: + resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} + engines: {node: '>=8'} + temp-dir@3.0.0: resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==} engines: {node: '>=14.16'} + tempy@0.6.0: + resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==} + engines: {node: '>=10'} + tempy@3.2.0: resolution: {integrity: sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==} engines: {node: '>=14.16'} @@ -5625,6 +6634,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.16.0: + resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} + engines: {node: '>=10'} + type-fest@1.4.0: resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} engines: {node: '>=10'} @@ -5712,10 +6725,26 @@ packages: resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} engines: {node: '>=20.18.1'} + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -5728,6 +6757,10 @@ packages: resolution: {integrity: sha512-lzlXPoVB0Uy/FvTaUgX39RbixYiSKoc3JSoSf01+0c2B6FR3qdYIPN7Qjo/AV7n6sqLCk0509cT3LRj1oSZ1+A==} engines: {node: '>=20'} + unique-string@2.0.0: + resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} + engines: {node: '>=8'} + unique-string@3.0.0: resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} engines: {node: '>=12'} @@ -5754,6 +6787,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + upath@1.2.0: + resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} + engines: {node: '>=4'} + update-browserslist-db@1.3.3: resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} hasBin: true @@ -5795,6 +6832,18 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + vite-plugin-pwa@2.0.0: + resolution: {integrity: sha512-6G9dsPKT9QKYpeTuTLJM28zZL3HsXUTUM/QUZBEgQk2jbfrPJDq2i8gTewAKqi1c9Mt6MiidrL4SFI+HthiQMg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@vite-pwa/assets-generator': ^1.0.0 || ^2.0.0 + vite: ^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + workbox-build: ^7.4.1 + workbox-window: ^7.4.1 + peerDependenciesMeta: + '@vite-pwa/assets-generator': + optional: true + vite@8.3.2: resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5931,6 +6980,55 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + workbox-background-sync@7.4.1: + resolution: {integrity: sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==} + + workbox-broadcast-update@7.4.1: + resolution: {integrity: sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==} + + workbox-build@7.4.1: + resolution: {integrity: sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==} + engines: {node: '>=20.0.0'} + + workbox-cacheable-response@7.4.1: + resolution: {integrity: sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==} + + workbox-core@7.4.1: + resolution: {integrity: sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==} + + workbox-expiration@7.4.1: + resolution: {integrity: sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==} + + workbox-google-analytics@7.4.1: + resolution: {integrity: sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==} + + workbox-navigation-preload@7.4.1: + resolution: {integrity: sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==} + + workbox-precaching@7.4.1: + resolution: {integrity: sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==} + + workbox-range-requests@7.4.1: + resolution: {integrity: sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==} + + workbox-recipes@7.4.1: + resolution: {integrity: sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==} + + workbox-routing@7.4.1: + resolution: {integrity: sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==} + + workbox-strategies@7.4.1: + resolution: {integrity: sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==} + + workbox-streams@7.4.1: + resolution: {integrity: sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==} + + workbox-sw@7.4.1: + resolution: {integrity: sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==} + + workbox-window@7.4.1: + resolution: {integrity: sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -6042,6 +7140,12 @@ snapshots: '@andrewbranch/untar.js@1.0.4': {} + '@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)': + dependencies: + ajv: 8.20.0 + jsonpointer: 5.0.1 + leven: 3.1.0 + '@arethetypeswrong/cli@0.18.5': dependencies: '@arethetypeswrong/core': 0.18.5 @@ -6134,6 +7238,10 @@ snapshots: '@types/jsesc': 2.5.1 jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-annotate-as-pure@8.0.0': dependencies: '@babel/types': 8.0.6 @@ -6154,6 +7262,19 @@ snapshots: flru: 1.0.2 verkit: 0.3.2 + '@babel/helper-create-class-features-plugin@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-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-create-class-features-plugin@8.0.6(@babel/core@8.0.6)': dependencies: '@babel/core': 8.0.6 @@ -6165,10 +7286,35 @@ snapshots: '@babel/traverse': 8.0.6 verkit: 0.3.2 + '@babel/helper-create-regexp-features-plugin@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-annotate-as-pure': 7.29.7 + regexpu-core: 6.5.2 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@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-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + debug: 4.4.3(supports-color@7.2.0) + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.29.7': {} '@babel/helper-globals@8.0.6': {} + '@babel/helper-member-expression-to-functions@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-member-expression-to-functions@8.0.5': dependencies: '@babel/traverse': 8.0.6 @@ -6202,6 +7348,10 @@ snapshots: '@babel/helper-validator-identifier': 8.0.6 '@babel/traverse': 8.0.6 + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-optimise-call-expression@8.0.0': dependencies: '@babel/types': 8.0.6 @@ -6212,6 +7362,24 @@ snapshots: dependencies: '@babel/core': 8.0.6 + '@babel/helper-remap-async-to-generator@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-annotate-as-pure': 7.29.7 + '@babel/helper-wrap-function': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@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-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + '@babel/helper-replace-supers@8.0.1(@babel/core@8.0.6)': dependencies: '@babel/core': 8.0.6 @@ -6219,6 +7387,13 @@ snapshots: '@babel/helper-optimise-call-expression': 8.0.0 '@babel/traverse': 8.0.6 + '@babel/helper-skip-transparent-expression-wrappers@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-skip-transparent-expression-wrappers@8.0.0': dependencies: '@babel/traverse': 8.0.6 @@ -6234,69 +7409,406 @@ snapshots: '@babel/helper-validator-option@7.29.7': {} - '@babel/helper-validator-option@8.0.0': {} + '@babel/helper-validator-option@8.0.0': {} + + '@babel/helper-wrap-function@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@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 + '@babel/types': 8.0.6 + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/parser@8.0.6': + dependencies: + '@babel/types': 8.0.6 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@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-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@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-bugfix-safari-id-destructuring-collision-in-function-expression@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-bugfix-safari-rest-destructuring-rhs-array@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-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@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-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@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-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-decorators@8.0.2(@babel/core@8.0.6)': + dependencies: + '@babel/core': 8.0.6 + '@babel/helper-create-class-features-plugin': 8.0.6(@babel/core@8.0.6) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) + '@babel/plugin-syntax-decorators': 8.0.1(@babel/core@8.0.6) + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + + '@babel/plugin-syntax-decorators@8.0.1(@babel/core@8.0.6)': + dependencies: + '@babel/core': 8.0.6 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) + + '@babel/plugin-syntax-import-assertions@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-import-attributes@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-jsx@8.0.1(@babel/core@8.0.6)': + dependencies: + '@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 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-arrow-functions@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-transform-async-generator-functions@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-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@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-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@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-transform-block-scoping@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-transform-class-properties@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-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@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-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@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-annotate-as-pure': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@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/template': 7.29.7 + + '@babel/plugin-transform-destructuring@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-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-destructuring@8.0.5(@babel/core@8.0.6)': + dependencies: + '@babel/core': 8.0.6 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) + + '@babel/plugin-transform-dotall-regex@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-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-keys@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-transform-duplicate-named-capturing-groups-regex@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-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-dynamic-import@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-transform-explicit-resource-management@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-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-explicit-resource-management@8.0.5(@babel/core@8.0.6)': + dependencies: + '@babel/core': 8.0.6 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) + '@babel/plugin-transform-destructuring': 8.0.5(@babel/core@8.0.6) + + '@babel/plugin-transform-exponentiation-operator@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-transform-export-namespace-from@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-transform-for-of@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-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@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-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@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-transform-literals@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-transform-logical-assignment-operators@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-transform-member-expression-literals@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-transform-modules-amd@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-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@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-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@8.0.1(@babel/core@8.0.6)': + dependencies: + '@babel/core': 8.0.6 + '@babel/helper-module-transforms': 8.0.6(@babel/core@8.0.6) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) + + '@babel/plugin-transform-modules-systemjs@7.29.8(@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-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-umd@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-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color - '@babel/helpers@7.29.7': + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 - '@babel/helpers@8.0.5': + '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/template': 8.0.0 - '@babel/types': 8.0.6 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 - '@babel/parser@7.29.9': + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/types': 7.29.8 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 - '@babel/parser@8.0.6': + '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/types': 8.0.6 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-proposal-decorators@8.0.2(@babel/core@8.0.6)': + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 8.0.6 - '@babel/helper-create-class-features-plugin': 8.0.6(@babel/core@8.0.6) - '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) - '@babel/plugin-syntax-decorators': 8.0.1(@babel/core@8.0.6) + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color - '@babel/plugin-syntax-decorators@8.0.1(@babel/core@8.0.6)': + '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 8.0.6 - '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color - '@babel/plugin-syntax-jsx@8.0.1(@babel/core@8.0.6)': + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/core': 8.0.6 - '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-optional-chaining@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-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color - '@babel/plugin-syntax-typescript@8.0.3(@babel/core@8.0.6)': + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/core': 8.0.6 - '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring@8.0.5(@babel/core@8.0.6)': + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 8.0.6 - '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color - '@babel/plugin-transform-explicit-resource-management@8.0.5(@babel/core@8.0.6)': + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 8.0.6 - '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) - '@babel/plugin-transform-destructuring': 8.0.5(@babel/core@8.0.6) + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color - '@babel/plugin-transform-modules-commonjs@8.0.1(@babel/core@8.0.6)': + '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/core': 8.0.6 - '@babel/helper-module-transforms': 8.0.6(@babel/core@8.0.6) - '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-display-name@8.0.1(@babel/core@8.0.6)': dependencies: @@ -6323,6 +7835,50 @@ snapshots: '@babel/helper-annotate-as-pure': 8.0.0 '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.6) + '@babel/plugin-transform-regenerator@7.29.8(@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-transform-regexp-modifiers@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-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-reserved-words@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-transform-shorthand-properties@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-transform-spread@7.29.8(@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-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@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-transform-template-literals@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-transform-typeof-symbol@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-transform-typescript@8.0.6(@babel/core@8.0.6)': dependencies: '@babel/core': 8.0.6 @@ -6332,6 +7888,113 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers': 8.0.0 '@babel/plugin-syntax-typescript': 8.0.3(@babel/core@8.0.6) + '@babel/plugin-transform-unicode-escapes@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-transform-unicode-property-regex@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-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-regex@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-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-sets-regex@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-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-modules-systemjs': 7.29.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-spread': 7.29.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@7.2.0)) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + core-js-compat: 3.50.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@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/types': 7.29.8 + esutils: 2.0.3 + '@babel/preset-react@8.0.1(@babel/core@8.0.6)': dependencies: '@babel/core': 8.0.6 @@ -6894,6 +8557,8 @@ snapshots: optionalDependencies: '@types/node': 26.6.4 + '@isaacs/cliui@9.0.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -6915,7 +8580,6 @@ snapshots: dependencies: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - optional: true '@jridgewell/sourcemap-codec@1.6.0': {} @@ -6951,6 +8615,9 @@ snapshots: '@microsoft/tsdoc@0.17.1': {} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: '@emnapi/core': 1.11.2 @@ -7122,130 +8789,303 @@ snapshots: '@oxc-resolver/binding-android-arm-eabi@11.24.2': optional: true - '@oxc-resolver/binding-android-arm64@11.24.2': + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + + '@pkgr/core@0.3.6': {} + + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + + '@pnpm/config.env-replace@1.1.0': {} + + '@pnpm/network.ca-file@1.0.2': + dependencies: + graceful-fs: 4.2.10 + + '@pnpm/npm-conf@3.0.3': + dependencies: + '@pnpm/config.env-replace': 1.1.0 + '@pnpm/network.ca-file': 1.0.2 + config-chain: 1.1.13 + + '@polka/url@1.0.0-next.29': {} + + '@publint/pack@0.1.7': + dependencies: + tinyexec: 1.3.1 + + '@resvg/resvg-js-android-arm-eabi@2.6.2': + optional: true + + '@resvg/resvg-js-android-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-x64@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js@2.6.2': + optionalDependencies: + '@resvg/resvg-js-android-arm-eabi': 2.6.2 + '@resvg/resvg-js-android-arm64': 2.6.2 + '@resvg/resvg-js-darwin-arm64': 2.6.2 + '@resvg/resvg-js-darwin-x64': 2.6.2 + '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 + '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 + '@resvg/resvg-js-linux-arm64-musl': 2.6.2 + '@resvg/resvg-js-linux-x64-gnu': 2.6.2 + '@resvg/resvg-js-linux-x64-musl': 2.6.2 + '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 + '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 + '@resvg/resvg-js-win32-x64-msvc': 2.6.2 + + '@rolldown/binding-android-arm-eabi@1.2.12': optional: true - '@oxc-resolver/binding-darwin-arm64@11.24.2': + '@rolldown/binding-android-arm64@1.2.12': optional: true - '@oxc-resolver/binding-darwin-x64@11.24.2': + '@rolldown/binding-darwin-arm64@1.2.12': optional: true - '@oxc-resolver/binding-freebsd-x64@11.24.2': + '@rolldown/binding-darwin-x64@1.2.12': optional: true - '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + '@rolldown/binding-freebsd-x64@1.2.12': optional: true - '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': optional: true - '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + '@rolldown/binding-linux-arm64-gnu@1.2.12': optional: true - '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + '@rolldown/binding-linux-arm64-musl@1.2.12': optional: true - '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + '@rolldown/binding-linux-ppc64-gnu@1.2.12': optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + '@rolldown/binding-linux-s390x-gnu@1.2.12': optional: true - '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + '@rolldown/binding-linux-x64-gnu@1.2.12': optional: true - '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + '@rolldown/binding-linux-x64-musl@1.2.12': optional: true - '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + '@rolldown/binding-openharmony-arm64@1.2.12': optional: true - '@oxc-resolver/binding-linux-x64-musl@11.24.2': + '@rolldown/binding-win32-arm64-msvc@1.2.12': optional: true - '@oxc-resolver/binding-openharmony-arm64@11.24.2': + '@rolldown/binding-win32-x64-msvc@1.2.12': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.24.2': + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7(supports-color@7.2.0))(rollup@4.64.0)(supports-color@7.2.0)': dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@rollup/pluginutils': 5.4.0(rollup@4.64.0) + optionalDependencies: + rollup: 4.64.0 + transitivePeerDependencies: + - supports-color + + '@rollup/plugin-node-resolve@16.0.3(rollup@4.64.0)': + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@4.64.0) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.12 + optionalDependencies: + rollup: 4.64.0 + + '@rollup/plugin-replace@6.0.3(rollup@4.64.0)': + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@4.64.0) + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.64.0 + + '@rollup/plugin-terser@1.0.0(rollup@4.64.0)': + dependencies: + serialize-javascript: 7.1.2 + smob: 1.6.2 + terser: 5.51.2 + optionalDependencies: + rollup: 4.64.0 + + '@rollup/pluginutils@5.4.0(rollup@4.64.0)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.7 + optionalDependencies: + rollup: 4.64.0 + + '@rollup/rollup-android-arm-eabi@4.64.0': optional: true - '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + '@rollup/rollup-android-arm64@4.64.0': optional: true - '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + '@rollup/rollup-darwin-arm64@4.64.0': optional: true - '@pkgr/core@0.3.6': {} + '@rollup/rollup-darwin-x64@4.64.0': + optional: true - '@pnpm/config.env-replace@1.1.0': {} + '@rollup/rollup-freebsd-arm64@4.64.0': + optional: true - '@pnpm/network.ca-file@1.0.2': - dependencies: - graceful-fs: 4.2.10 + '@rollup/rollup-freebsd-x64@4.64.0': + optional: true - '@pnpm/npm-conf@3.0.3': - dependencies: - '@pnpm/config.env-replace': 1.1.0 - '@pnpm/network.ca-file': 1.0.2 - config-chain: 1.1.13 + '@rollup/rollup-linux-arm-gnueabihf@4.64.0': + optional: true - '@polka/url@1.0.0-next.29': {} + '@rollup/rollup-linux-arm-musleabihf@4.64.0': + optional: true - '@publint/pack@0.1.7': - dependencies: - tinyexec: 1.3.1 + '@rollup/rollup-linux-arm64-gnu@4.64.0': + optional: true - '@rolldown/binding-android-arm-eabi@1.2.12': + '@rollup/rollup-linux-arm64-musl@4.64.0': optional: true - '@rolldown/binding-android-arm64@1.2.12': + '@rollup/rollup-linux-loong64-gnu@4.64.0': optional: true - '@rolldown/binding-darwin-arm64@1.2.12': + '@rollup/rollup-linux-loong64-musl@4.64.0': optional: true - '@rolldown/binding-darwin-x64@1.2.12': + '@rollup/rollup-linux-ppc64-gnu@4.64.0': optional: true - '@rolldown/binding-freebsd-x64@1.2.12': + '@rollup/rollup-linux-ppc64-musl@4.64.0': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + '@rollup/rollup-linux-riscv64-gnu@4.64.0': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.12': + '@rollup/rollup-linux-riscv64-musl@4.64.0': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.12': + '@rollup/rollup-linux-s390x-gnu@4.64.0': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.12': + '@rollup/rollup-linux-x64-gnu@4.64.0': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.12': + '@rollup/rollup-linux-x64-musl@4.64.0': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.12': + '@rollup/rollup-openbsd-x64@4.64.0': optional: true - '@rolldown/binding-linux-x64-musl@1.2.12': + '@rollup/rollup-openharmony-arm64@4.64.0': optional: true - '@rolldown/binding-openharmony-arm64@1.2.12': + '@rollup/rollup-win32-arm64-msvc@4.64.0': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.12': + '@rollup/rollup-win32-ia32-msvc@4.64.0': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.12': + '@rollup/rollup-win32-x64-gnu@4.64.0': optional: true - '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-win32-x64-msvc@4.64.0': + optional: true '@sec-ant/readable-stream@0.4.1': {} @@ -7500,6 +9340,13 @@ snapshots: '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1': + dependencies: + ejs: 3.1.10 + json5: 2.2.3 + magic-string: 0.30.21 + string.prototype.matchall: 4.1.0 + '@turbo/darwin-64@2.11.7': optional: true @@ -7593,6 +9440,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/resolve@1.20.2': {} + + '@types/trusted-types@2.0.7': {} + '@types/unist@3.0.3': {} '@types/whatwg-mimetype@3.0.2': {} @@ -8131,6 +9982,10 @@ snapshots: async-function@1.0.0: {} + async@3.2.6: {} + + at-least-node@1.0.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -8139,6 +9994,30 @@ snapshots: axobject-query@4.1.0: {} + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.14.2(@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-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + core-js-compat: 3.50.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@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-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -8154,6 +10033,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -8170,8 +10053,7 @@ snapshots: node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.3) - buffer-from@1.1.2: - optional: true + buffer-from@1.1.2: {} buffer-image-size@0.6.4: dependencies: @@ -8286,13 +10168,14 @@ snapshots: commander@15.0.0: {} - commander@2.20.3: - optional: true + commander@2.20.3: {} commander@8.3.0: {} comment-parser@1.4.8: {} + common-tags@1.8.2: {} + compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -8351,6 +10234,10 @@ snapshots: cookie@2.0.1: {} + core-js-compat@3.50.0: + dependencies: + browserslist: 4.29.3 + core-util-is@1.0.3: {} cosmiconfig-typescript-loader@6.3.0(@types/node@26.6.4)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3): @@ -8380,6 +10267,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-random-string@2.0.0: {} + crypto-random-string@4.0.0: dependencies: type-fest: 1.4.0 @@ -8529,6 +10418,10 @@ snapshots: dependencies: readable-stream: 2.3.8 + ejs@3.1.10: + dependencies: + jake: 10.9.4 + electron-to-chromium@1.5.444: {} elkjs@0.12.0: {} @@ -8895,12 +10788,16 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 esutils@2.0.3: {} + eta@4.6.0: {} + eval@0.1.8: dependencies: '@types/node': 26.6.4 @@ -8996,6 +10893,10 @@ snapshots: dependencies: flat-cache: 6.1.23 + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -9030,6 +10931,11 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + format@0.2.2: {} formatly@0.7.1: @@ -9043,6 +10949,13 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fsevents@2.3.3: optional: true @@ -9085,6 +10998,8 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 + get-own-enumerable-property-symbols@3.0.2: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -9124,6 +11039,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.6 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + global-directory@5.0.0: dependencies: ini: 6.0.0 @@ -9243,6 +11167,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb@7.1.1: {} + ignore@5.3.2: {} ignore@7.0.12: {} @@ -9347,6 +11273,8 @@ snapshots: is-map@2.0.3: {} + is-module@1.0.0: {} + is-negative-zero@2.0.3: {} is-number-object@1.1.1: @@ -9356,6 +11284,8 @@ snapshots: is-number@7.0.0: {} + is-obj@1.0.1: {} + is-obj@2.0.0: {} is-plain-obj@4.1.0: {} @@ -9367,12 +11297,16 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + is-regexp@1.0.0: {} + is-set@2.0.3: {} is-shared-array-buffer@1.0.4: dependencies: call-bound: 1.0.4 + is-stream@2.0.1: {} + is-stream@3.0.0: {} is-stream@4.0.1: {} @@ -9428,6 +11362,16 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + java-properties@1.0.2: {} javascript-stringify@2.1.0: {} @@ -9483,6 +11427,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonpointer@5.0.1: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.2.0 @@ -9520,6 +11466,8 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + leven@3.1.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -9604,6 +11552,8 @@ snapshots: lodash.capitalize@4.2.1: {} + lodash.debounce@4.0.8: {} + lodash.escaperegexp@4.1.2: {} lodash.groupby@4.6.0: {} @@ -9630,6 +11580,10 @@ snapshots: lz-string@1.5.0: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.4.2: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -10044,8 +11998,14 @@ snapshots: dependencies: brace-expansion: 1.1.21 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.7 + minimist@1.2.8: {} + minipass@7.1.3: {} + mlly@1.8.2: dependencies: acorn: 8.18.0 @@ -10272,6 +12232,8 @@ snapshots: p-try@1.0.0: {} + package-json-from-dist@1.0.1: {} + package-manager-detector@1.8.0: {} parent-module@1.0.1: @@ -10322,6 +12284,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 + path-type@4.0.0: {} pathe@2.0.3: {} @@ -10369,6 +12336,10 @@ snapshots: prettier@3.9.9: {} + pretty-bytes@5.6.0: {} + + pretty-bytes@6.1.1: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -10479,6 +12450,12 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regenerate-unicode-properties@10.3.0: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.9 @@ -10488,10 +12465,25 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + regexpu-core@6.5.2: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.3.0 + regjsgen: 0.8.0 + regjsparser: 0.13.3 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + registry-auth-token@5.1.1: dependencies: '@pnpm/npm-conf': 3.0.3 + regjsgen@0.8.0: {} + + regjsparser@0.13.3: + dependencies: + jsesc: 3.1.0 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -10543,6 +12535,38 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.12 '@rolldown/binding-win32-x64-msvc': 1.2.12 + rollup@4.64.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.64.0 + '@rollup/rollup-android-arm64': 4.64.0 + '@rollup/rollup-darwin-arm64': 4.64.0 + '@rollup/rollup-darwin-x64': 4.64.0 + '@rollup/rollup-freebsd-arm64': 4.64.0 + '@rollup/rollup-freebsd-x64': 4.64.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.64.0 + '@rollup/rollup-linux-arm-musleabihf': 4.64.0 + '@rollup/rollup-linux-arm64-gnu': 4.64.0 + '@rollup/rollup-linux-arm64-musl': 4.64.0 + '@rollup/rollup-linux-loong64-gnu': 4.64.0 + '@rollup/rollup-linux-loong64-musl': 4.64.0 + '@rollup/rollup-linux-ppc64-gnu': 4.64.0 + '@rollup/rollup-linux-ppc64-musl': 4.64.0 + '@rollup/rollup-linux-riscv64-gnu': 4.64.0 + '@rollup/rollup-linux-riscv64-musl': 4.64.0 + '@rollup/rollup-linux-s390x-gnu': 4.64.0 + '@rollup/rollup-linux-x64-gnu': 4.64.0 + '@rollup/rollup-linux-x64-musl': 4.64.0 + '@rollup/rollup-openbsd-x64': 4.64.0 + '@rollup/rollup-openharmony-arm64': 4.64.0 + '@rollup/rollup-win32-arm64-msvc': 4.64.0 + '@rollup/rollup-win32-ia32-msvc': 4.64.0 + '@rollup/rollup-win32-x64-gnu': 4.64.0 + '@rollup/rollup-win32-x64-msvc': 4.64.0 + fsevents: 2.3.3 + rxjs@7.8.2: dependencies: tslib: 2.8.1 @@ -10617,6 +12641,8 @@ snapshots: semver@7.8.5: {} + serialize-javascript@7.1.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -10691,6 +12717,8 @@ snapshots: dependencies: unicode-emoji-modifier-base: 1.0.0 + smob@1.6.2: {} + smol-toml@1.9.0: {} source-map-js@1.2.2: {} @@ -10699,12 +12727,13 @@ snapshots: dependencies: buffer-from: 1.1.2 source-map: 0.6.1 - optional: true source-map@0.6.1: {} source-map@0.7.6: {} + source-map@0.8.0: {} + spawn-error-forwarder@1.0.0: {} spdx-correct@3.2.0: @@ -10816,6 +12845,12 @@ snapshots: dependencies: safe-buffer: 5.1.2 + stringify-object@3.3.0: + dependencies: + get-own-enumerable-property-symbols: 3.0.2 + is-obj: 1.0.1 + is-regexp: 1.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -10826,6 +12861,8 @@ snapshots: strip-bom@3.0.0: {} + strip-comments@2.0.1: {} + strip-final-newline@3.0.0: {} strip-final-newline@4.0.0: {} @@ -10896,8 +12933,17 @@ snapshots: tagged-tag@1.0.0: {} + temp-dir@2.0.0: {} + temp-dir@3.0.0: {} + tempy@0.6.0: + dependencies: + is-stream: 2.0.1 + temp-dir: 2.0.0 + type-fest: 0.16.0 + unique-string: 2.0.0 + tempy@3.2.0: dependencies: is-stream: 3.0.0 @@ -10911,7 +12957,6 @@ snapshots: acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 - optional: true thenify-all@1.6.0: dependencies: @@ -10984,6 +13029,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.16.0: {} + type-fest@1.4.0: {} type-fest@2.19.0: {} @@ -11073,14 +13120,29 @@ snapshots: undici@7.30.0: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} + unicode-emoji-modifier-base@1.0.0: {} + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + unicorn-magic@0.1.0: {} unicorn-magic@0.3.0: {} unicorn-magic@0.4.1: {} + unique-string@2.0.0: + dependencies: + crypto-random-string: 2.0.0 + unique-string@3.0.0: dependencies: crypto-random-string: 4.0.0 @@ -11113,6 +13175,8 @@ snapshots: universalify@2.0.1: {} + upath@1.2.0: {} + update-browserslist-db@1.3.3(browserslist@4.29.3): dependencies: browserslist: 4.29.3 @@ -11163,6 +13227,17 @@ snapshots: - tsx - yaml + vite-plugin-pwa@2.0.0(supports-color@7.2.0)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))(workbox-build@7.4.1(supports-color@7.2.0))(workbox-window@7.4.1): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + pretty-bytes: 6.1.1 + tinyglobby: 0.2.17 + vite: 8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1) + workbox-build: 7.4.1(supports-color@7.2.0) + workbox-window: 7.4.1 + transitivePeerDependencies: + - supports-color + vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 @@ -11263,6 +13338,119 @@ snapshots: wordwrap@1.0.0: {} + workbox-background-sync@7.4.1: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.1 + + workbox-broadcast-update@7.4.1: + dependencies: + workbox-core: 7.4.1 + + workbox-build@7.4.1(supports-color@7.2.0): + dependencies: + '@apideck/better-ajv-errors': 0.3.7(ajv@8.20.0) + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/preset-env': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/runtime': 7.29.7 + '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.7(supports-color@7.2.0))(rollup@4.64.0)(supports-color@7.2.0) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.64.0) + '@rollup/plugin-replace': 6.0.3(rollup@4.64.0) + '@rollup/plugin-terser': 1.0.0(rollup@4.64.0) + '@trickfilm400/rollup-plugin-off-main-thread': 3.0.0-pre1 + ajv: 8.20.0 + common-tags: 1.8.2 + eta: 4.6.0 + fast-json-stable-stringify: 2.1.0 + fs-extra: 9.1.0 + glob: 11.1.0 + pretty-bytes: 5.6.0 + rollup: 4.64.0 + source-map: 0.8.0 + stringify-object: 3.3.0 + strip-comments: 2.0.1 + tempy: 0.6.0 + upath: 1.2.0 + workbox-background-sync: 7.4.1 + workbox-broadcast-update: 7.4.1 + workbox-cacheable-response: 7.4.1 + workbox-core: 7.4.1 + workbox-expiration: 7.4.1 + workbox-google-analytics: 7.4.1 + workbox-navigation-preload: 7.4.1 + workbox-precaching: 7.4.1 + workbox-range-requests: 7.4.1 + workbox-recipes: 7.4.1 + workbox-routing: 7.4.1 + workbox-strategies: 7.4.1 + workbox-streams: 7.4.1 + workbox-sw: 7.4.1 + workbox-window: 7.4.1 + transitivePeerDependencies: + - '@types/babel__core' + - supports-color + + workbox-cacheable-response@7.4.1: + dependencies: + workbox-core: 7.4.1 + + workbox-core@7.4.1: {} + + workbox-expiration@7.4.1: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.1 + + workbox-google-analytics@7.4.1: + dependencies: + workbox-background-sync: 7.4.1 + workbox-core: 7.4.1 + workbox-routing: 7.4.1 + workbox-strategies: 7.4.1 + + workbox-navigation-preload@7.4.1: + dependencies: + workbox-core: 7.4.1 + + workbox-precaching@7.4.1: + dependencies: + workbox-core: 7.4.1 + workbox-routing: 7.4.1 + workbox-strategies: 7.4.1 + + workbox-range-requests@7.4.1: + dependencies: + workbox-core: 7.4.1 + + workbox-recipes@7.4.1: + dependencies: + workbox-cacheable-response: 7.4.1 + workbox-core: 7.4.1 + workbox-expiration: 7.4.1 + workbox-precaching: 7.4.1 + workbox-routing: 7.4.1 + workbox-strategies: 7.4.1 + + workbox-routing@7.4.1: + dependencies: + workbox-core: 7.4.1 + + workbox-strategies@7.4.1: + dependencies: + workbox-core: 7.4.1 + + workbox-streams@7.4.1: + dependencies: + workbox-core: 7.4.1 + workbox-routing: 7.4.1 + + workbox-sw@7.4.1: {} + + workbox-window@7.4.1: + dependencies: + '@types/trusted-types': 2.0.7 + workbox-core: 7.4.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 From 1fcffe62fe84a64795cde6e7c1420654f2fed74b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 03:27:22 +0100 Subject: [PATCH 05/14] test(web): run the app end to end in Chromium Playwright drives the production build under vite preview, with GitHub's GraphQL API answered by a local fixture server that the browser's requests to api.github.com are routed to. The tests sync organisations across their repositories, check the token reaches no other host, check installability and the absence of cross-origin isolation, reload offline, write in one tab and see it in another, expand the scope on demand and show a rate limit. The root test:e2e script runs them through turbo after the build, and the CI test job runs it in the Chromium it already installs for the SQLite wasm store's browser tests. --- .github/workflows/ci.yml | 4 +- .gitignore | 4 + package.json | 1 + .../targets/graph-web/e2e/app.e2e.test.ts | 254 +++++++++++++ .../targets/graph-web/e2e/github-fixture.ts | 335 ++++++++++++++++++ .../targets/graph-web/playwright.config.ts | 24 ++ turbo.json | 4 + 7 files changed, 624 insertions(+), 2 deletions(-) create mode 100644 packages/targets/graph-web/e2e/app.e2e.test.ts create mode 100644 packages/targets/graph-web/e2e/github-fixture.ts create mode 100644 packages/targets/graph-web/playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2871c92..4a9bca5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,8 +78,8 @@ jobs: - uses: ./.github/actions/setup-workspace with: task: test - # The SQLite wasm store's integration tests run in Chromium through Vitest's browser mode; Playwright installs the browser build its pinned version expects. - command: pnpm --filter @exadev/process-graph-sqlite-wasm exec playwright install --with-deps chromium && pnpm test:coverage && pnpm test:integration && pnpm test:smoke + # The SQLite wasm store's integration tests run in Chromium through Vitest's browser mode, and the web app's end-to-end tests in the same Chromium through Playwright, which pins the same version; Playwright installs the browser build that version expects. + command: pnpm --filter @exadev/process-graph-sqlite-wasm exec playwright install --with-deps chromium && pnpm test:coverage && pnpm test:integration && pnpm test:smoke && pnpm test:e2e knip: name: Knip diff --git a/.gitignore b/.gitignore index 1595179..eed9565 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ coverage .env .stryker-tmp reports +# Playwright's run output, and the service worker vite-plugin-pwa writes when it runs in development. +test-results +playwright-report +dev-dist # Un-ignore AGENTS.md/CLAUDE.md against a machine-wide global gitignore that excludes them everywhere by default. README.md symlinked to AGENTS.md and CLAUDE.md is this repository's deliberate "one instruction file" pattern, so these symlinks must be tracked. !AGENTS.md diff --git a/package.json b/package.json index d72b9f9..6d6fa09 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test:coverage": "turbo run _test:coverage", "test:integration": "turbo run _test:integration", "test:smoke": "turbo run _test:smoke", + "test:e2e": "turbo run _test:e2e", "test:mutation": "turbo run _test:mutation", "deps:lint": "syncpack lint", "deps:fix": "syncpack fix", diff --git a/packages/targets/graph-web/e2e/app.e2e.test.ts b/packages/targets/graph-web/e2e/app.e2e.test.ts new file mode 100644 index 0000000..df7e5f2 --- /dev/null +++ b/packages/targets/graph-web/e2e/app.e2e.test.ts @@ -0,0 +1,254 @@ +import { + expect, + test as base, + type Page, + type Request, +} from "@playwright/test"; +import { + fixtureToken, + rateLimitedToken, + startGitHubFixture, + type GitHubFixture, +} from "./github-fixture.ts"; + +const githubApiHost = "api.github.com"; + +/** Every test gets its own fixture server, and its browser context's requests to GitHub's API are answered by it. Playwright forwards the request from the test process, so the browser's own request still goes to api.github.com. */ +const test = base.extend<{ readonly github: GitHubFixture }>({ + github: [ + async ({ context }, use) => { + const fixture = await startGitHubFixture(); + await context.route(`https://${githubApiHost}/**`, async (route) => { + const response = await route.fetch({ url: fixture.url }); + await route.fulfill({ response }); + }); + await use(fixture); + await fixture.close(); + }, + { auto: true }, + ], +}); + +/** An element's card on the canvas. */ +const card = (page: Page, id: string) => + page.locator(`.react-flow__node [data-element-id="${id}"]`); + +/** Saves a token, adds the scopes and starts their sync. */ +const syncScopes = async ( + page: Page, + token: string, + scopes: readonly string[], +): Promise => { + await page.getByLabel("Token", { exact: true }).fill(token); + await page.getByRole("button", { name: "Save on this device" }).click(); + // Each scope is added after the one before, as a person would add them. + await scopes.reduce(async (previous, scope) => { + await previous; + await page + .getByLabel("Add an organisation, user or repository") + .fill(scope); + await page.getByRole("button", { name: "Add", exact: true }).click(); + }, Promise.resolve()); + await page.getByRole("button", { name: "Sync", exact: true }).click(); +}; + +const syncFinished = async (page: Page): Promise => { + await expect(page.locator("[data-progress]")).toContainText("Sync finished"); +}; + +/** Waits until a service worker is active for the page's scope, so a reload is served from its precache. */ +const serviceWorkerActive = async (page: Page): Promise => { + await page.evaluate(async () => { + await navigator.serviceWorker.ready; + }); +}; + +test("syncs organisations across their repositories, draws the work and lists what is ready and blocked", async ({ + page, +}) => { + await page.goto("/"); + await syncScopes(page, fixtureToken, ["acme", "github:ghost"]); + await syncFinished(page); + + await expect(card(page, "github:acme/web#2")).toHaveAttribute( + "data-status", + "blocked", + ); + await expect(card(page, "github:acme/api#1")).toHaveAttribute( + "data-status", + "done", + ); + // A dependency in another organisation's repository is followed across organisations. + await expect(card(page, "github:partner/sdk#1")).toHaveAttribute( + "data-status", + "ready", + ); + await expect( + page.getByRole("listbox", { name: "Ready to start" }), + ).toHaveText(/Build the endpoint.*Publish the SDK/su); + await page.getByRole("tab", { name: "Blocked" }).click(); + await expect( + page.getByRole("listbox", { name: "Blocked" }).getByRole("option"), + ).toHaveText([ + /Build the page.*waits on Build the endpoint, Publish the SDK/su, + /Ship the release.*waits on Build the endpoint, Publish the SDK/su, + ]); + // The owner that does not exist is a gap beside the rest. + await expect(page.locator('[data-notice="gaps"] [data-gap]')).toHaveText([ + /github:ghost: not found/u, + ]); + expect(new URL(page.url()).searchParams.getAll("scope")).toEqual([ + "github:acme", + "github:ghost", + ]); + expect(new URL(page.url()).searchParams.get("list")).toBe("blocked"); +}); + +test("sends the token to GitHub's API and to nothing else", async ({ + page, + context, +}) => { + const requests: Request[] = []; + context.on("request", (request) => { + requests.push(request); + }); + await page.goto("/"); + await syncScopes(page, fixtureToken, ["acme"]); + await syncFinished(page); + + const carrying = await Promise.all( + requests.map(async (request) => { + const headers = await request.allHeaders(); + const text = [ + request.url(), + request.postData() ?? "", + ...Object.values(headers), + ].join("\n"); + + return { + host: new URL(request.url()).hostname, + carries: text.includes(fixtureToken), + }; + }), + ); + const withToken = carrying.filter((request) => request.carries); + expect(withToken.length).toBeGreaterThan(0); + expect(new Set(withToken.map((request) => request.host))).toEqual( + new Set([githubApiHost]), + ); +}); + +test("is installable: a manifest with install icons, served without cross-origin isolation, and an active service worker", async ({ + page, + context, +}) => { + const response = await page.goto("/"); + expect(response?.headers()["cross-origin-opener-policy"]).toBeUndefined(); + expect(response?.headers()["cross-origin-embedder-policy"]).toBeUndefined(); + expect(await page.evaluate(() => crossOriginIsolated)).toBe(false); + await serviceWorkerActive(page); + + const session = await context.newCDPSession(page); + const { installabilityErrors } = await session.send( + "Page.getInstallabilityErrors", + ); + expect(installabilityErrors).toEqual([]); + const manifest: unknown = await ( + await page.request.get("/manifest.webmanifest") + ).json(); + expect(manifest).toMatchObject({ + name: "Process graph", + display: "standalone", + start_url: "/", + scope: "/", + }); + const icon = await page.request.get("/icons/maskable-512.png"); + expect(icon.headers()["content-type"]).toBe("image/png"); +}); + +test("shows the last sync after an offline reload", async ({ + page, + context, +}) => { + await page.goto("/"); + await serviceWorkerActive(page); + await syncScopes(page, fixtureToken, ["acme"]); + await syncFinished(page); + await expect(card(page, "github:acme/web#1")).toBeVisible(); + + await context.setOffline(true); + await page.reload(); + + await expect(page.locator('[data-notice="offline"]')).toBeVisible(); + await expect(card(page, "github:acme/web#1")).toHaveAttribute( + "data-status", + "blocked", + ); + await expect(page.getByRole("button", { name: "Sync again" })).toBeDisabled(); + await context.setOffline(false); +}); + +test("a run state set in one tab shows in another", async ({ + page, + context, +}) => { + await page.goto("/"); + await syncScopes(page, fixtureToken, ["acme"]); + await syncFinished(page); + + const other = await context.newPage(); + await other.goto(page.url()); + await expect(card(other, "github:acme/api#2")).toHaveAttribute( + "data-status", + "ready", + ); + await other + .getByRole("listbox", { name: "Ready to start" }) + .getByRole("option", { name: /Build the endpoint/u }) + .click(); + await other.getByRole("button", { name: "Mark done" }).click(); + + await expect(card(page, "github:acme/api#2")).toHaveAttribute( + "data-status", + "done", + ); + await expect( + page.getByRole("listbox", { name: "Ready to start" }), + ).not.toContainText("Build the endpoint"); +}); + +test("expands the scope on demand to a repository a dependency leads to", async ({ + page, +}) => { + await page.goto("/"); + await syncScopes(page, fixtureToken, ["acme"]); + await syncFinished(page); + + await page + .getByRole("listbox", { name: "Ready to start" }) + .getByRole("option", { name: /Publish the SDK/u }) + .click(); + await page + .getByRole("button", { name: "Add partner/sdk to the scope" }) + .click(); + + await syncFinished(page); + expect(new URL(page.url()).searchParams.getAll("scope")).toEqual([ + "github:acme", + "github:partner/sdk", + ]); + await expect(card(page, "github:partner/sdk#1")).toBeVisible(); +}); + +test("says when GitHub's rate limit resets", async ({ page }) => { + await page.goto("/"); + await syncScopes(page, rateLimitedToken, ["acme"]); + + await expect(page.locator('[data-notice="rate-limited"]')).toContainText( + "the limit resets at", + ); + // The tests run in UTC with British English formats (playwright.config.ts), so the reset instant reads the same everywhere. + await expect(page.locator('[data-notice="rate-limited"]')).toContainText( + "1 Jan 2100", + ); +}); diff --git a/packages/targets/graph-web/e2e/github-fixture.ts b/packages/targets/graph-web/e2e/github-fixture.ts new file mode 100644 index 0000000..24c46e9 --- /dev/null +++ b/packages/targets/graph-web/e2e/github-fixture.ts @@ -0,0 +1,335 @@ +import { createServer, type IncomingMessage, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +/** + * A stand-in for GitHub's GraphQL API, served on a local port for the end-to-end tests. It answers the three operations the GitHub issues source sends (`OwnerRepositories`, `RepositoryIssues` and `IssueConnection`) from a fixed set of issues, with the response shapes GitHub gives, and the same refusals: HTTP 401 for a token it does not know, and HTTP 403 with no requests remaining for the rate-limited token. CORS headers are the ones api.github.com sends, so a browser reads the rate limit headers as it would from GitHub. + */ + +type AccountType = "Organization" | "User"; + +interface FixtureIssue { + readonly owner: string; + readonly repository: string; + readonly number: number; + readonly title: string; + readonly state: "OPEN" | "CLOSED"; + readonly blockedBy: readonly (readonly [string, string, number])[]; + readonly assignees: readonly string[]; +} + +/** The accounts the fixture knows, and the repositories each owns, by name. */ +const accounts: Readonly< + Record< + string, + { readonly type: AccountType; readonly repositories: readonly string[] } + > +> = { + acme: { type: "Organization", repositories: ["api", "web"] }, + partner: { type: "Organization", repositories: ["sdk"] }, + alice: { type: "User", repositories: [] }, +}; + +/** + * The issues. `acme` holds the work: a schema, the endpoint on it, the page on the endpoint and the release on the page. The page also waits on an SDK in another organisation's repository, so a sync of `acme` follows that dependency across organisations into `partner/sdk`, which is outside the scope. + */ +export const fixtureIssues: readonly FixtureIssue[] = [ + { + owner: "acme", + repository: "api", + number: 1, + title: "Design the schema", + state: "CLOSED", + blockedBy: [], + assignees: [], + }, + { + owner: "acme", + repository: "api", + number: 2, + title: "Build the endpoint", + state: "OPEN", + blockedBy: [["acme", "api", 1]], + assignees: [], + }, + { + owner: "acme", + repository: "web", + number: 1, + title: "Build the page", + state: "OPEN", + blockedBy: [ + ["acme", "api", 2], + ["partner", "sdk", 1], + ], + assignees: [], + }, + { + owner: "acme", + repository: "web", + number: 2, + title: "Ship the release", + state: "OPEN", + blockedBy: [["acme", "web", 1]], + assignees: ["alice"], + }, + { + owner: "partner", + repository: "sdk", + number: 1, + title: "Publish the SDK", + state: "OPEN", + blockedBy: [], + assignees: [], + }, +]; + +/** The token the fixture accepts. It is made up, and the tests check it is sent only to GitHub's API host. */ +export const fixtureToken = "fixture-token-for-the-end-to-end-tests"; + +/** A token the fixture answers as GitHub answers an exhausted rate limit. */ +export const rateLimitedToken = "fixture-token-that-is-rate-limited"; + +/** When the fixture's rate limit resets, in seconds since the epoch, as `x-ratelimit-reset` gives it. */ +export const rateLimitResetSeconds = 4_102_444_800; + +/** The HTTP statuses the fixture answers with, as GitHub does. */ +const httpStatus = { + ok: 200, + noContent: 204, + unauthorised: 401, + forbidden: 403, + serverError: 500, +} as const; + +const corsHeaders = { + "access-control-allow-origin": "*", + "access-control-allow-headers": "authorization, content-type", + "access-control-allow-methods": "POST, OPTIONS", + "access-control-expose-headers": + "x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset, retry-after", +}; + +const ownerOf = (login: string) => { + const account = accounts[login]; + if (account === undefined) { + throw new Error(`the fixture has no account ${login}`); + } + + return { __typename: account.type, login }; +}; + +const refOf = (owner: string, repository: string, number: number) => { + const issue = fixtureIssues.find( + (candidate) => + candidate.owner === owner && + candidate.repository === repository && + candidate.number === number, + ); + if (issue === undefined) { + throw new Error( + `the fixture has no issue ${owner}/${repository}#${String(number)}`, + ); + } + + return { + number, + title: issue.title, + state: issue.state, + repository: { name: repository, owner: ownerOf(owner) }, + }; +}; + +const connection = (nodes: readonly T[]) => ({ + pageInfo: { hasNextPage: false, endCursor: null }, + nodes, +}); + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const stringOf = ( + record: Readonly>, + key: string, +): string => { + const value = record[key]; + if (typeof value !== "string") { + throw new Error(`the request's ${key} is not a string`); + } + + return value; +}; + +/** The `data` of a GraphQL response, and any errors beside it. */ +interface Answer { + readonly data: unknown; + readonly errors?: readonly { + readonly type: string; + readonly message: string; + }[]; +} + +const answer = ( + query: string, + variables: Readonly>, +): Answer => { + if (query.includes("query OwnerRepositories")) { + const login = stringOf(variables, "login"); + const account = accounts[login]; + if (account === undefined) { + return { + data: { repositoryOwner: null }, + errors: [ + { + type: "NOT_FOUND", + message: `Could not resolve to a RepositoryOwner with the login of '${login}'.`, + }, + ], + }; + } + + return { + data: { + repositoryOwner: { + ...ownerOf(login), + repositories: connection( + account.repositories.map((name) => ({ name })), + ), + }, + }, + }; + } + if (query.includes("query RepositoryIssues")) { + const owner = stringOf(variables, "owner"); + const name = stringOf(variables, "name"); + if (accounts[owner]?.repositories.includes(name) !== true) { + return { + data: { repository: null }, + errors: [ + { + type: "NOT_FOUND", + message: `Could not resolve to a Repository with the name '${owner}/${name}'.`, + }, + ], + }; + } + const issues = fixtureIssues + .filter((issue) => issue.owner === owner && issue.repository === name) + .map((issue) => ({ + ...refOf(issue.owner, issue.repository, issue.number), + assignees: connection(issue.assignees.map((login) => ({ login }))), + blockedBy: connection( + issue.blockedBy.map(([blockerOwner, repository, number]) => + refOf(blockerOwner, repository, number), + ), + ), + subIssues: connection([]), + })); + + return { + data: { + repository: { name, owner: ownerOf(owner), issues: connection(issues) }, + }, + }; + } + throw new Error( + "the fixture answers only the GitHub issues source's queries", + ); +}; + +const bodyOf = async (request: IncomingMessage): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of request) { + if (!(chunk instanceof Buffer)) { + throw new Error("the request body is not bytes"); + } + chunks.push(chunk); + } + const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + + return parsed; +}; + +/** A running fixture: where it listens, and how to stop it. */ +export interface GitHubFixture { + /** The GraphQL endpoint's URL. */ + readonly url: string; + readonly close: () => Promise; +} + +/** + * Starts the fixture on a free local port. + * @returns The running fixture. + */ +export const startGitHubFixture = async (): Promise => { + const server: Server = createServer((request, response) => { + const send = ( + status: number, + body: unknown, + headers: Readonly> = {}, + ): void => { + response.writeHead(status, { + ...corsHeaders, + "content-type": "application/json", + ...headers, + }); + response.end(JSON.stringify(body)); + }; + if (request.method === "OPTIONS") { + response.writeHead(httpStatus.noContent, corsHeaders); + response.end(); + + return; + } + const authorisation = request.headers.authorization; + if (authorisation === `Bearer ${rateLimitedToken}`) { + send( + httpStatus.forbidden, + { message: "API rate limit exceeded" }, + { + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(rateLimitResetSeconds), + }, + ); + + return; + } + if (authorisation !== `Bearer ${fixtureToken}`) { + send(httpStatus.unauthorised, { message: "Bad credentials" }); + + return; + } + bodyOf(request) + .then((body) => { + if (!isRecord(body) || !isRecord(body.variables)) { + throw new Error("the request is not a GraphQL request"); + } + send(httpStatus.ok, answer(stringOf(body, "query"), body.variables)); + }) + .catch((error: unknown) => { + send(httpStatus.serverError, { + message: error instanceof Error ? error.message : String(error), + }); + }); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address: AddressInfo | string | null = server.address(); + if (address === null || typeof address === "string") { + throw new Error("the fixture is not listening on a port"); + } + + return { + url: `http://127.0.0.1:${String(address.port)}/graphql`, + close: async () => + new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) { + resolve(); + } else { + reject(error); + } + }); + }), + }; +}; diff --git a/packages/targets/graph-web/playwright.config.ts b/packages/targets/graph-web/playwright.config.ts new file mode 100644 index 0000000..d74e381 --- /dev/null +++ b/packages/targets/graph-web/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "@playwright/test"; + +/** Vite's own default port for `vite preview`, pinned with `--strictPort` so the tests never drive another server that happens to hold it. */ +const previewPort = 4173; +const baseURL = `http://127.0.0.1:${String(previewPort)}`; + +/** + * End-to-end tests against the production build, served by `vite preview` as a static host would serve it, with no cross-origin isolation headers. The build itself comes from the `_build` task that `_test:e2e` depends on. Chromium is the browser CI installs for graph-sqlite-wasm's browser tests, at the version this Playwright expects. GitHub's API is answered by a local fixture server the tests start, reached by routing the browser's requests to api.github.com, so the browser still addresses GitHub and the token checks see the real request. + * + * `--host 127.0.0.1`: Vite binds `localhost`, which can resolve to `::1` only, where the IPv4 base URL would be refused. + */ +export default defineConfig({ + testDir: "./e2e", + forbidOnly: process.env.CI !== undefined, + reporter: "list", + // A fixed locale and time zone, so dates the app formats read the same on every machine. + use: { baseURL, locale: "en-GB", timezoneId: "UTC" }, + webServer: { + command: `pnpm exec vite preview --host 127.0.0.1 --port ${String(previewPort)} --strictPort`, + url: baseURL, + reuseExistingServer: false, + }, + projects: [{ name: "chromium", use: { browserName: "chromium" } }], +}); diff --git a/turbo.json b/turbo.json index 0dd8497..ec52686 100644 --- a/turbo.json +++ b/turbo.json @@ -53,6 +53,10 @@ "dependsOn": ["_build", "^_build", "^_test:smoke"], "outputs": [] }, + "_test:e2e": { + "dependsOn": ["_build", "^_build"], + "outputs": [] + }, "_test:mutation": { "dependsOn": ["_build", "^_build"], "outputs": ["reports/**"], From ce142b1656bd431d0410dab100833ffbee5b4ee1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 03:27:58 +0100 Subject: [PATCH 06/14] build(web): pin the ui packages at their released versions --- packages/targets/graph-web/package.json | 6 +++--- pnpm-lock.yaml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/targets/graph-web/package.json b/packages/targets/graph-web/package.json index 013ac79..10bbe80 100644 --- a/packages/targets/graph-web/package.json +++ b/packages/targets/graph-web/package.json @@ -43,14 +43,14 @@ "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-react-flow": "0.0.0", + "@exadev/process-graph-react": "1.0.0", + "@exadev/process-graph-react-flow": "1.0.0", "@exadev/process-graph-schema": "1.1.0", "@exadev/process-graph-source-contract": "1.0.1", "@exadev/process-graph-source-github-issues": "1.0.1", "@exadev/process-graph-sqlite-wasm": "1.0.2", "@exadev/process-graph-store-contract": "1.0.3", - "@exadev/process-graph-ui": "0.0.0", + "@exadev/process-graph-ui": "1.0.0", "@orpc/client": "2.0.0-beta.41", "@orpc/server": "2.0.0-beta.41", "react": "19.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c5bd39..523f3fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -783,10 +783,10 @@ importers: specifier: 1.1.0 version: link:../../core/graph/projection '@exadev/process-graph-react': - specifier: 0.0.0 + specifier: 1.0.0 version: link:../../ui/graph/react '@exadev/process-graph-react-flow': - specifier: 0.0.0 + specifier: 1.0.0 version: link:../../ui/graph/react-flow '@exadev/process-graph-schema': specifier: 1.1.0 @@ -804,7 +804,7 @@ importers: specifier: 1.0.3 version: link:../../core/graph/store-contract '@exadev/process-graph-ui': - specifier: 0.0.0 + specifier: 1.0.0 version: link:../../ui/graph/ui '@orpc/client': specifier: 2.0.0-beta.41 From 51ccf5bbdf909730393b9db90d128ddaa464fb61 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 03:32:22 +0100 Subject: [PATCH 07/14] refactor(web): export only what another module uses Knip finds the page's module from index.html through its Vite plugin, so the graph-web workspace names only the store's worker, the build script and the Playwright suite as entries of their own. --- knip.config.ts | 9 ++------- packages/targets/graph-web/src/app.css.ts | 4 ---- packages/targets/graph-web/src/sync-record.ts | 2 +- packages/targets/graph-web/src/tabs.tsx | 2 +- packages/targets/graph-web/src/url-state.ts | 2 +- packages/targets/graph-web/src/use-sync.ts | 2 +- packages/targets/graph-web/src/work-lists.tsx | 4 ++-- packages/targets/graph-web/src/workspace.tsx | 2 +- 8 files changed, 9 insertions(+), 18 deletions(-) diff --git a/knip.config.ts b/knip.config.ts index c485e8d..ae5b090 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -32,14 +32,9 @@ const config: KnipConfig = { entry: ["src/index.{ts,tsx}"], project: ["src/**/*.{ts,tsx}!"], }, - // An app rather than a library: its entries are the page's module and the store's worker, which index.html and main.tsx load, and its build script, tests and Playwright suite. + // An app rather than a library: knip's Vite plugin finds the page's module from index.html; the store's worker, which main.tsx starts by URL, the build script and the Playwright suite are entries of their own. "packages/targets/graph-web": { - entry: [ - "src/main.tsx!", - "src/store.worker.ts!", - "build/icons.ts", - "e2e/**/*.ts", - ], + entry: ["src/store.worker.ts!", "build/icons.ts", "e2e/**/*.ts"], project: ["src/**/*.{ts,tsx}!", "build/**/*.ts", "e2e/**/*.ts"], }, }, diff --git a/packages/targets/graph-web/src/app.css.ts b/packages/targets/graph-web/src/app.css.ts index 5a1ad92..10edc51 100644 --- a/packages/targets/graph-web/src/app.css.ts +++ b/packages/targets/graph-web/src/app.css.ts @@ -244,7 +244,3 @@ export const listbox = style({ flexDirection: "column", gap: vars.space.small, }); - -export const progress = style({ - width: "100%", -}); diff --git a/packages/targets/graph-web/src/sync-record.ts b/packages/targets/graph-web/src/sync-record.ts index e89c3ee..9c98c60 100644 --- a/packages/targets/graph-web/src/sync-record.ts +++ b/packages/targets/graph-web/src/sync-record.ts @@ -4,7 +4,7 @@ import { z } from "zod"; /** * Where a sync stopped: the model, every scope it was asked for in order, the prefix of the source to read next, and the cursor after the last page it wrote within that source's scopes (absent to read them from the start). Everything before it is in the store, so a sync started from it reads only what is left. */ -export const checkpointSchema = z +const checkpointSchema = z .strictObject({ modelId: z.string().min(1), scopes: z.array(z.string().min(1)).readonly(), diff --git a/packages/targets/graph-web/src/tabs.tsx b/packages/targets/graph-web/src/tabs.tsx index 8d21c8e..aa5249f 100644 --- a/packages/targets/graph-web/src/tabs.tsx +++ b/packages/targets/graph-web/src/tabs.tsx @@ -2,7 +2,7 @@ import { useId, useRef, type KeyboardEvent, type ReactElement } from "react"; import { tab as tabClass, tabList } from "./app.css.ts"; /** One tab: its value and the text it is shown with. */ -export interface TabOption { +interface TabOption { readonly value: V; readonly label: string; } diff --git a/packages/targets/graph-web/src/url-state.ts b/packages/targets/graph-web/src/url-state.ts index 76fdcc5..c98d039 100644 --- a/packages/targets/graph-web/src/url-state.ts +++ b/packages/targets/graph-web/src/url-state.ts @@ -52,7 +52,7 @@ export const defaultViewState: ViewState = { /** * The query parameters, one name per part of the state. A repeatable part repeats its parameter, in order: `?scope=github:a&scope=github:b/c`. */ -export const parameters = { +const parameters = { scope: "scope", view: "view", selected: "select", diff --git a/packages/targets/graph-web/src/use-sync.ts b/packages/targets/graph-web/src/use-sync.ts index 4e732b0..ff9b88d 100644 --- a/packages/targets/graph-web/src/use-sync.ts +++ b/packages/targets/graph-web/src/use-sync.ts @@ -6,7 +6,7 @@ import type { SyncCheckpoint, SyncRecord } from "./sync-record.ts"; import { runSync, type SyncProgress } from "./sync.ts"; /** Where the sync of the shown model stands in this page. */ -export type SyncState = +type SyncState = | { readonly phase: "idle" } | { readonly phase: "running"; readonly progress: SyncProgress | undefined } | { readonly phase: "finished"; readonly progress: SyncProgress } diff --git a/packages/targets/graph-web/src/work-lists.tsx b/packages/targets/graph-web/src/work-lists.tsx index 43099cf..928ccc6 100644 --- a/packages/targets/graph-web/src/work-lists.tsx +++ b/packages/targets/graph-web/src/work-lists.tsx @@ -21,7 +21,7 @@ import { Tabs } from "./tabs.tsx"; import { listTabs, type ListTab } from "./url-state.ts"; /** Each list's tab label. */ -export const listLabels: Readonly> = { +const listLabels: Readonly> = { ready: "Ready", blocked: "Blocked", path: "Critical path", @@ -31,7 +31,7 @@ export const listLabels: Readonly> = { }; /** What every list reads: the run, each element's status and label, the filter, and the shared selection. */ -export interface ListContext { +interface ListContext { readonly run: Run; readonly statuses: ReadonlyMap; readonly labelOf: (id: string) => string; diff --git a/packages/targets/graph-web/src/workspace.tsx b/packages/targets/graph-web/src/workspace.tsx index beacd6e..22d1599 100644 --- a/packages/targets/graph-web/src/workspace.tsx +++ b/packages/targets/graph-web/src/workspace.tsx @@ -36,7 +36,7 @@ import { resolveView } from "./views.ts"; import { WorkLists } from "./work-lists.tsx"; /** Each view's tab label. */ -export const viewLabels: Readonly> = { +const viewLabels: Readonly> = { flowchart: "Flowchart", swimlane: "Swimlane", state: "State machine", From 74166c62373b00f75b9b6d945b46b69f483c09cc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 03:32:29 +0100 Subject: [PATCH 08/14] test(web): cover each screen against the contracts' fakes The screen tests render the app with Testing Library over the store contract's fake store and the source contract's fake source, each called through its oRPC client, laid out by a stub engine: the token, scope, sync (progress, cancel, resume from the device's record), notices, theme, view, lists, details, filters, adding by hand and a model with an illegal cycle. The sync, the browser ports and the icon renderer have their own tests. --- .../graph-web/build/icons.unit.test.ts | 55 +++ .../targets/graph-web/src/app.unit.test.tsx | 372 ++++++++++++++++++ .../graph-web/src/browser.unit.test.ts | 127 ++++++ .../targets/graph-web/src/sync.unit.test.ts | 268 +++++++++++++ .../graph-web/src/test-support/ports.ts | 294 ++++++++++++++ .../graph-web/src/workspace.unit.test.tsx | 292 ++++++++++++++ packages/targets/graph-web/vitest.config.ts | 2 +- 7 files changed, 1409 insertions(+), 1 deletion(-) create mode 100644 packages/targets/graph-web/build/icons.unit.test.ts create mode 100644 packages/targets/graph-web/src/app.unit.test.tsx create mode 100644 packages/targets/graph-web/src/browser.unit.test.ts create mode 100644 packages/targets/graph-web/src/sync.unit.test.ts create mode 100644 packages/targets/graph-web/src/test-support/ports.ts create mode 100644 packages/targets/graph-web/src/workspace.unit.test.tsx diff --git a/packages/targets/graph-web/build/icons.unit.test.ts b/packages/targets/graph-web/build/icons.unit.test.ts new file mode 100644 index 0000000..ba02da1 --- /dev/null +++ b/packages/targets/graph-web/build/icons.unit.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "vitest"; +import { iconFiles, iconSvg, pngIconSizes, renderIcon } from "./icons.ts"; + +const colours = { tile: "#0969da", glyph: "#ffffff" }; +const [launcher, splash] = pngIconSizes; + +/** Where a PNG's IHDR chunk keeps the image's width and height, as big-endian 32-bit integers after the signature and the chunk's length and type. */ +const widthOffset = 16; +const heightOffset = 20; + +describe("the app's icons", () => { + test("are the scalable SVG and a PNG of each install size for each purpose", () => { + expect( + iconFiles().map((file) => [ + file.path, + file.size, + file.purpose, + file.type, + ]), + ).toEqual([ + ["icons/icon.svg", undefined, "any", "image/svg+xml"], + ["icons/any-192.png", launcher, "any", "image/png"], + ["icons/any-512.png", splash, "any", "image/png"], + ["icons/maskable-192.png", launcher, "maskable", "image/png"], + ["icons/maskable-512.png", splash, "maskable", "image/png"], + ]); + }); + + test("draw the tile in the theme's colours, rounded unless a platform masks it", () => { + const any = iconSvg(colours, "any"); + const maskable = iconSvg(colours, "maskable"); + + expect(any).toContain('rx="22" fill="#0969da"'); + expect(maskable).toContain('rx="0" fill="#0969da"'); + expect(any).toContain('stroke="#ffffff"'); + }); + + test("rasterise each PNG at its own size", () => { + for (const file of iconFiles()) { + const content = renderIcon(file, colours); + if (file.size === undefined) { + expect(content).toBe(iconSvg(colours, file.purpose)); + continue; + } + if (typeof content === "string") { + throw new Error(`${file.path} rendered as text`); + } + const view = new DataView(content.buffer, content.byteOffset); + expect([ + view.getUint32(widthOffset), + view.getUint32(heightOffset), + ]).toEqual([file.size, file.size]); + } + }); +}); diff --git a/packages/targets/graph-web/src/app.unit.test.tsx b/packages/targets/graph-web/src/app.unit.test.tsx new file mode 100644 index 0000000..4770dc4 --- /dev/null +++ b/packages/targets/graph-web/src/app.unit.test.tsx @@ -0,0 +1,372 @@ +import { + createFakeGraphSource, + GraphSourceError, + type GraphSourcePager, +} from "@exadev/process-graph-source-contract"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { App } from "./app.tsx"; +import { formatInstant } from "./format.ts"; +import { fakeScopes, testPorts, type TestPorts } from "./test-support/ports.ts"; +import { parseViewState } from "./url-state.ts"; + +afterEach(cleanup); + +const acme = "?scope=github%3Aacme"; + +const renderApp = (under: TestPorts) => render(); + +/** The address's view state, as the app last wrote it. */ +const addressOf = (under: TestPorts) => + parseViewState(under.address.observable.get()).state; + +/** Starts a sync and waits for it to finish. */ +const syncNow = async (): Promise => { + fireEvent.click(screen.getByRole("button", { name: "Sync" })); + await waitFor(() => { + expect(document.querySelector("[data-progress]")?.textContent).toContain( + "Sync finished", + ); + }); +}; + +/** An element's card on the canvas, once the view is laid out. */ +const cardOf = async (id: string): Promise => + waitFor(() => { + const card = document.querySelector( + `.react-flow__node [data-element-id="${id}"]`, + ); + if (card === null) { + throw new Error(`no card for ${id}`); + } + + return card; + }); + +describe("the token screen", () => { + test("is shown until a token is saved, says what the token needs and where it goes, and never shows it back", () => { + const ports = testPorts(); + renderApp(ports); + const panel = screen.getByRole("region", { name: "GitHub token" }); + + expect(panel.textContent).toContain( + "read-only Issues and Metadata permissions", + ); + expect(panel.textContent).toContain( + "It is sent to api.github.com and nowhere else", + ); + + fireEvent.change(within(panel).getByLabelText("Token"), { + target: { value: " secret-token " }, + }); + fireEvent.click( + within(panel).getByRole("button", { name: "Save on this device" }), + ); + + expect(ports.savedToken()).toBe("secret-token"); + expect(screen.queryByRole("region", { name: "GitHub token" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "GitHub token" })); + const reopened = screen.getByRole("region", { name: "GitHub token" }); + expect(within(reopened).getByLabelText("Replace the token")).toHaveProperty( + "value", + "", + ); + expect(document.body.textContent).not.toContain("secret-token"); + + fireEvent.click( + within(reopened).getByRole("button", { name: "Forget the token" }), + ); + expect(ports.savedToken()).toBeUndefined(); + }); +}); + +describe("the scope picker", () => { + test("adds organisations and repositories to the address, in order, and removes them", () => { + const ports = testPorts({ token: "t" }); + renderApp(ports); + const input = screen.getByLabelText( + "Add an organisation, user or repository", + ); + + fireEvent.change(input, { target: { value: "acme" } }); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + fireEvent.change(input, { + target: { value: "https://github.com/partner/sdk/issues/1" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + + expect(addressOf(ports).scopes).toEqual([ + "github:acme", + "github:partner/sdk", + ]); + + fireEvent.click(screen.getByRole("button", { name: "Remove github:acme" })); + expect(addressOf(ports).scopes).toEqual(["github:partner/sdk"]); + }); + + test("says why a scope cannot be added", () => { + renderApp(testPorts({ token: "t" })); + + fireEvent.change( + screen.getByLabelText("Add an organisation, user or repository"), + { + target: { value: "acme/api/extra" }, + }, + ); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + + expect(screen.getByRole("alert").textContent).toBe( + '"acme/api/extra" is not a GitHub organisation, user or repository: write owner or owner/repo.', + ); + }); +}); + +describe("the sync", () => { + test("says nothing is synced yet, then syncs with the saved token and shows the work", async () => { + const ports = testPorts({ token: "t", search: acme }); + renderApp(ports); + + expect( + (await screen.findByText(/Nothing has been synced for this scope/u)) + .textContent, + ).toContain("Sync to read it"); + await syncNow(); + + expect(document.querySelector("[data-progress]")?.textContent).toBe( + "Sync finished. 2 pages read: 5 issues and 4 dependencies written.", + ); + expect( + (await cardOf("github:acme/web#2")).getAttribute("data-status"), + ).toBe("blocked"); + expect(ports.tokensUsed).toEqual(["t"]); + expect(ports.records.get("github:acme")).toEqual({ + modelId: "github:acme", + gaps: [], + }); + }); + + test("is cancelled by the cancel button and resumed from where it stopped", async () => { + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const fake = createFakeGraphSource({ + source: "github", + scopes: fakeScopes, + }); + const paused: GraphSourcePager = { + source: "github", + page: async (request) => { + if (request.cursor !== undefined) { + await gate; + request.signal?.throwIfAborted(); + } + + return fake.page(request); + }, + }; + const ports = testPorts({ token: "t", search: acme, pager: paused }); + renderApp(ports); + + fireEvent.click(screen.getByRole("button", { name: "Sync" })); + await waitFor(() => { + expect(document.querySelector("[data-progress]")?.textContent).toBe( + "Syncing. 1 page read: 2 issues and 1 dependency written.", + ); + }); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + release(); + await waitFor(() => { + expect(document.querySelector("[data-progress]")?.textContent).toBe( + "Sync cancelled. 1 page read: 2 issues and 1 dependency written. Resume to carry on.", + ); + }); + expect(ports.records.get("github:acme")?.checkpoint).toEqual({ + modelId: "github:acme", + scopes: ["github:acme"], + source: "github", + after: "github%3Aacme@0", + }); + + fireEvent.click( + screen.getByRole("button", { name: "Resume where it stopped" }), + ); + await waitFor(() => { + expect(document.querySelector("[data-progress]")?.textContent).toBe( + "Sync finished. 1 page read: 4 issues and 3 dependencies written.", + ); + }); + expect(ports.records.get("github:acme")?.checkpoint).toBeUndefined(); + }); + + test("offers to resume a sync a reload interrupted, from the record on the device", () => { + renderApp( + testPorts({ + token: "t", + search: acme, + records: [ + { + modelId: "github:acme", + gaps: [], + checkpoint: { + modelId: "github:acme", + scopes: ["github:acme"], + source: "github", + after: "github%3Aacme@0", + }, + }, + ], + }), + ); + + expect( + screen.getByRole("button", { name: "Resume where it stopped" }), + ).toBeDefined(); + expect(document.querySelector("[data-progress]")?.textContent).toBe( + "The last sync stopped before it finished.", + ); + }); +}); + +describe("the notices", () => { + const failing = ( + failure: ConstructorParameters[0], + ) => + testPorts({ + token: "t", + search: acme, + pager: { + source: "github", + page: async () => Promise.reject(new GraphSourceError(failure)), + }, + }); + + test("says when GitHub's rate limit resets", async () => { + renderApp( + failing({ + kind: "rate-limited", + message: "rate limited", + resetAt: "2100-01-01T00:00:00.000Z", + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Sync" })); + + await waitFor(() => { + expect( + document.querySelector('[data-notice="rate-limited"]')?.textContent, + ).toBe( + `GitHub is rate limiting requests; the limit resets at ${formatInstant("2100-01-01T00:00:00.000Z")}. What was read before it stopped is kept. Resume the sync then.`, + ); + }); + }); + + test("offers to change a token GitHub refused", async () => { + renderApp(failing({ kind: "unauthorised", message: "Bad credentials" })); + fireEvent.click(screen.getByRole("button", { name: "Sync" })); + + const notice = await waitFor(() => { + const found = document.querySelector('[data-notice="unauthorised"]'); + if (!(found instanceof HTMLElement)) { + throw new Error("no notice yet"); + } + + return found; + }); + fireEvent.click( + within(notice).getByRole("button", { name: "Change the token" }), + ); + + expect(screen.getByRole("region", { name: "GitHub token" })).toBeDefined(); + }); + + test("lists the parts of the scope the source could not read", async () => { + renderApp( + testPorts({ token: "t", search: `${acme}&scope=github%3Aghost` }), + ); + await syncNow(); + + expect( + [...document.querySelectorAll('[data-notice="gaps"] [data-gap]')].map( + (gap) => gap.textContent, + ), + ).toEqual(["github:ghost: not found. github:ghost does not exist"]); + }); + + test("says when the page is offline and holds the sync until it is back", () => { + const ports = testPorts({ token: "t", search: acme }); + renderApp(ports); + + act(() => { + ports.online.set(false); + }); + + expect( + document.querySelector('[data-notice="offline"]')?.textContent, + ).toContain("You are offline"); + expect(screen.getByRole("button", { name: "Sync" })).toHaveProperty( + "disabled", + true, + ); + + act(() => { + ports.online.set(true); + }); + expect(document.querySelector('[data-notice="offline"]')).toBeNull(); + }); + + test("offers a new version and loads it only when asked", () => { + const ports = testPorts(); + renderApp(ports); + + act(() => { + ports.updateAvailable.set(true); + }); + expect(ports.updatesApplied()).toBe(0); + fireEvent.click(screen.getByRole("button", { name: "Reload to update" })); + + expect(ports.updatesApplied()).toBe(1); + }); + + test("names the parts of a link it could not use", () => { + renderApp(testPorts({ search: "?view=gantt&scope=acme" })); + + expect( + [...document.querySelectorAll('[data-notice="link"] li')].map( + (item) => item.textContent, + ), + ).toEqual([ + 'The scope "acme" names no source; write it as source:scope, such as github:owner/repo.', + 'The view "gantt" is not one of flowchart, swimlane, state, sequence.', + ]); + }); +}); + +describe("the theme", () => { + test("follows the system until a person picks one, and remembers the pick", () => { + const ports = testPorts(); + const { container } = renderApp(ports); + const themed = () => + container.querySelector("[data-theme]")?.getAttribute("data-theme"); + + expect(themed()).toBe("light"); + act(() => { + ports.prefersDark.set(true); + }); + expect(themed()).toBe("dark"); + + fireEvent.change(screen.getByLabelText("Theme"), { + target: { value: "light" }, + }); + + expect(themed()).toBe("light"); + expect(ports.ports.themePreference.read()).toBe("light"); + }); +}); diff --git a/packages/targets/graph-web/src/browser.unit.test.ts b/packages/targets/graph-web/src/browser.unit.test.ts new file mode 100644 index 0000000..119d3f1 --- /dev/null +++ b/packages/targets/graph-web/src/browser.unit.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + browserAddressBar, + browserOnline, + localSyncRecords, + localThemePreference, + localTokenStore, + serviceWorkerUpdates, +} from "./browser.ts"; + +afterEach(() => { + window.localStorage.clear(); + window.history.replaceState(null, "", "/"); +}); + +describe("the device's storage", () => { + test("keeps the token until it is cleared, under the app's own key", () => { + const tokens = localTokenStore(window.localStorage); + + expect(tokens.read()).toBeUndefined(); + tokens.write("a-token"); + expect(tokens.read()).toBe("a-token"); + expect(window.localStorage.getItem("process-graph:github-token")).toBe( + "a-token", + ); + tokens.clear(); + expect(tokens.read()).toBeUndefined(); + }); + + test("keeps each model's sync record, and reads a record that no longer fits its schema as absent", () => { + const records = localSyncRecords(window.localStorage); + const record = { + modelId: "github:acme", + gaps: [{ target: "github:ghost", reason: "not-found", message: "gone" }], + checkpoint: { + modelId: "github:acme", + scopes: ["github:acme"], + source: "github", + after: "cursor", + }, + } as const; + + records.write(record); + expect(records.read("github:acme")).toEqual(record); + expect(records.read("github:other")).toBeUndefined(); + + window.localStorage.setItem( + "process-graph:sync:github:old", + JSON.stringify({ modelId: "github:old", cursor: "from an older app" }), + ); + expect(records.read("github:old")).toBeUndefined(); + }); + + test("follows the system's theme until one is chosen", () => { + const theme = localThemePreference(window.localStorage); + + expect(theme.read()).toBe("system"); + theme.write("dark"); + expect(theme.read()).toBe("dark"); + window.localStorage.setItem("process-graph:theme", "sepia"); + expect(theme.read()).toBe("system"); + }); +}); + +describe("the address bar", () => { + test("replaces the query without a history entry and tells its listeners", () => { + window.history.replaceState(null, "", "/app/?view=flowchart#top"); + const address = browserAddressBar(window); + const listener = vi.fn<() => void>(); + const unsubscribe = address.search.subscribe(listener); + const entries = window.history.length; + + address.replace("?view=swimlane"); + + expect(address.search.get()).toBe("?view=swimlane"); + expect(window.location.pathname).toBe("/app/"); + expect(window.location.hash).toBe("#top"); + expect(window.history.length).toBe(entries); + expect(listener).toHaveBeenCalledTimes(1); + + address.replace("?view=swimlane"); + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + address.replace(""); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); + +describe("the network", () => { + test("follows the browser's online and offline events", () => { + const online = browserOnline(window); + const listener = vi.fn<() => void>(); + online.subscribe(listener); + + window.dispatchEvent(new Event("offline")); + window.dispatchEvent(new Event("online")); + + expect(listener).toHaveBeenCalledTimes(2); + expect(online.get()).toBe(window.navigator.onLine); + }); +}); + +describe("service worker updates", () => { + test("reports a waiting version and activates it only when asked", async () => { + let needRefresh: () => void = () => undefined; + const update = vi.fn<(reload?: boolean) => Promise>(async () => + Promise.resolve(), + ); + const updates = serviceWorkerUpdates((options) => { + needRefresh = options.onNeedRefresh; + + return update; + }); + const listener = vi.fn<() => void>(); + updates.available.subscribe(listener); + + expect(updates.available.get()).toBe(false); + needRefresh(); + expect(updates.available.get()).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + expect(update).not.toHaveBeenCalled(); + + updates.apply(); + await Promise.resolve(); + expect(update).toHaveBeenCalledWith(true); + }); +}); diff --git a/packages/targets/graph-web/src/sync.unit.test.ts b/packages/targets/graph-web/src/sync.unit.test.ts new file mode 100644 index 0000000..cb6828f --- /dev/null +++ b/packages/targets/graph-web/src/sync.unit.test.ts @@ -0,0 +1,268 @@ +import { + createFakeGraphSource, + createGraphSourceRouter, + GraphSourceError, + type GraphSourcePager, +} from "@exadev/process-graph-source-contract"; +import { + createFakeGraphStore, + createGraphStoreRouter, +} from "@exadev/process-graph-store-contract"; +import { createRouterClient } from "@orpc/server"; +import { describe, expect, test } from "vitest"; +import { failureOf } from "./failure.ts"; +import { runSync, UnknownSourceError, type SyncProgress } from "./sync.ts"; +import { apiPage, fakeScopes, pageOf } from "./test-support/ports.ts"; + +const newStore = () => + createRouterClient(createGraphStoreRouter(createFakeGraphStore())); + +const fakeGitHub = createFakeGraphSource({ + source: "github", + scopes: fakeScopes, +}); + +const lookup = (pager: GraphSourcePager) => (prefix: string) => + prefix === "github" + ? createRouterClient(createGraphSourceRouter(pager)) + : undefined; + +const modelId = "github:acme"; + +describe("runSync", () => { + test("writes every page as it is read, reports progress after each, and ends with no checkpoint", async () => { + const store = newStore(); + const progress: SyncProgress[] = []; + + const result = await runSync({ + store, + sources: lookup(fakeGitHub), + modelId, + scopes: ["github:acme"], + signal: new AbortController().signal, + onProgress: (step) => { + progress.push(step); + }, + }); + + expect( + progress.map((step) => [step.pages, step.checkpoint?.after]), + ).toEqual([ + [1, "github%3Aacme@0"], + [2, undefined], + ]); + expect(result).toEqual({ + pages: 2, + activities: 5, + transitions: 4, + gaps: [], + checkpoint: undefined, + }); + const stored = await store.v1.models.load({ modelId }); + expect( + stored.model.elements.map((element) => element.id).toSorted(), + ).toEqual([ + "github:acme/api#1", + "github:acme/api#2", + "github:acme/web#1", + "github:acme/web#2", + "github:partner/sdk#1", + ]); + expect(stored.overlay).toMatchObject({ + "github:acme/api#1": "done", + "github:acme/web#2": "open", + }); + }); + + test("resumes from a checkpoint with only the pages after it", async () => { + const store = newStore(); + const read: string[] = []; + const counting: GraphSourcePager = { + source: "github", + page: async (request) => { + read.push(request.cursor ?? "start"); + + return fakeGitHub.page(request); + }, + }; + await store.v1.items.upsert({ + modelId, + items: { + participants: apiPage.participants, + activities: apiPage.activities, + transitions: apiPage.transitions, + overlay: apiPage.overlay, + }, + }); + + const result = await runSync({ + store, + sources: lookup(counting), + modelId, + scopes: ["github:acme"], + resume: { + modelId, + scopes: ["github:acme"], + source: "github", + after: "github%3Aacme@0", + }, + signal: new AbortController().signal, + onProgress: () => undefined, + }); + + expect(read).toEqual(["github%3Aacme@0"]); + expect(result.pages).toBe(1); + const stored = await store.v1.models.load({ modelId }); + expect( + stored.model.transitions.map((transition) => transition.id), + ).toContain("github:acme/api#1->github:acme/api#2"); + }); + + test("holds a transition back until the page that brings its other end", async () => { + const store = newStore(); + const early = pageOf([ + { + id: "github:acme/web#1", + label: "Build the page", + repository: "github:acme/web", + state: "open", + blockedBy: ["github:acme/api#2"], + }, + ]); + const late = pageOf([ + { + id: "github:acme/api#2", + label: "Build the endpoint", + repository: "github:acme/api", + state: "open", + blockedBy: [], + }, + ]); + const progress: SyncProgress[] = []; + + await runSync({ + store, + sources: lookup( + createFakeGraphSource({ + source: "github", + scopes: { "github:acme": [early, late] }, + }), + ), + modelId, + scopes: ["github:acme"], + signal: new AbortController().signal, + onProgress: (step) => { + progress.push(step); + }, + }); + + expect(progress.map((step) => step.transitions)).toEqual([0, 1]); + const stored = await store.v1.models.load({ modelId }); + expect(stored.model.transitions).toEqual([ + { + id: "github:acme/api#2->github:acme/web#1", + source: "github:acme/api#2", + target: "github:acme/web#1", + }, + ]); + }); + + test("keeps the pages before a failure, reports where to resume, and passes the failure on", async () => { + const store = newStore(); + const progress: SyncProgress[] = []; + const limited: GraphSourcePager = { + source: "github", + page: async (request) => { + if (request.cursor !== undefined) { + throw new GraphSourceError({ + kind: "rate-limited", + message: "rate limited", + resetAt: "2100-01-01T00:00:00.000Z", + }); + } + + return fakeGitHub.page(request); + }, + }; + + const failure = await runSync({ + store, + sources: lookup(limited), + modelId, + scopes: ["github:acme"], + signal: new AbortController().signal, + onProgress: (step) => { + progress.push(step); + }, + }).catch((error: unknown) => failureOf(error)); + + expect(failure).toEqual({ + kind: "rate-limited", + message: "rate limited", + resetAt: "2100-01-01T00:00:00.000Z", + }); + expect(progress.at(-1)?.checkpoint).toEqual({ + modelId, + scopes: ["github:acme"], + source: "github", + after: "github%3Aacme@0", + }); + const stored = await store.v1.models.load({ modelId }); + expect(stored.model.elements.map((element) => element.id)).toEqual([ + "github:acme/api#1", + "github:acme/api#2", + ]); + }); + + test("reports the parts of the scope the source could not read", async () => { + const result = await runSync({ + store: newStore(), + sources: lookup(fakeGitHub), + modelId: "github:acme github:ghost", + scopes: ["github:acme", "github:ghost"], + signal: new AbortController().signal, + onProgress: () => undefined, + }); + + expect(result.gaps).toEqual([ + { + target: "github:ghost", + reason: "not-found", + message: "github:ghost does not exist", + }, + ]); + }); + + test("stops when its signal is aborted, keeping what it wrote", async () => { + const store = newStore(); + const controller = new AbortController(); + + const outcome = runSync({ + store, + sources: lookup(fakeGitHub), + modelId, + scopes: ["github:acme"], + signal: controller.signal, + onProgress: () => { + controller.abort(); + }, + }); + + await expect(outcome).rejects.toThrow(); + const stored = await store.v1.models.load({ modelId }); + expect(stored.model.elements).toHaveLength(2); + }); + + test("refuses a scope whose source the app cannot read", async () => { + await expect( + runSync({ + store: newStore(), + sources: lookup(fakeGitHub), + modelId: "linear:team", + scopes: ["linear:team"], + signal: new AbortController().signal, + onProgress: () => undefined, + }), + ).rejects.toThrow(UnknownSourceError); + }); +}); diff --git a/packages/targets/graph-web/src/test-support/ports.ts b/packages/targets/graph-web/src/test-support/ports.ts new file mode 100644 index 0000000..7bac4b0 --- /dev/null +++ b/packages/targets/graph-web/src/test-support/ports.ts @@ -0,0 +1,294 @@ +import type { FlowLayoutOptions } from "@exadev/process-graph-react-flow"; +import { + createFakeGraphSource, + createGraphSourceRouter, + type FakePage, + type GraphSourcePager, +} from "@exadev/process-graph-source-contract"; +import { + createFakeGraphStore, + createGraphStoreRouter, +} from "@exadev/process-graph-store-contract"; +import { createRouterClient } from "@orpc/server"; +import type { AppPorts, Observable, ThemePreference } from "../ports.ts"; +import type { SyncRecord } from "../sync-record.ts"; + +/** A value a test changes, observed as the app observes the browser. */ +interface Settable { + readonly observable: Observable; + readonly set: (value: T) => void; +} + +/** + * An observable value a test sets. + * @param initial - The first value. + */ +const settable = (initial: T): Settable => { + let value = initial; + const listeners = new Set<() => void>(); + + return { + observable: { + get: () => value, + subscribe: (listener) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + }, + set: (next) => { + value = next; + for (const listener of listeners) { + listener(); + } + }, + }; +}; + +type LayoutEngine = NonNullable; + +/** A layout engine that needs no elkjs: every node in one row, each a layer gap after the one before, and each group wrapped around its children. */ +const rowEngine: LayoutEngine = { + arrange: async (graph) => { + let x = 0; + const leaf = (node: (typeof graph.nodes)[number]) => { + const box = { + id: node.id, + x, + y: graph.groupPadding, + width: node.width, + height: node.height, + }; + x += node.width + graph.layerGap; + + return box; + }; + + return Promise.resolve( + graph.nodes.flatMap((node) => { + if (node.children === undefined || node.children.length === 0) { + return [leaf(node)]; + } + 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; + + return [ + { + id: node.id, + x: left, + y: 0, + width: right - left, + height: + graph.groupPadding * 2 + + Math.max(...children.map((child) => child.height)), + }, + ...children, + ]; + }), + ); + }, +}; + +/** One issue of the fake GitHub source. */ +interface Issue { + readonly id: string; + readonly label: string; + readonly repository: string; + readonly state: "done" | "open"; + readonly blockedBy: readonly string[]; +} + +const repositoryParticipant = (repository: string) => ({ + id: repository, + label: repository.slice(repository.indexOf(":") + 1), + kind: "system" as const, +}); + +/** + * A page of the fake GitHub source, built as the real adapter builds one: each issue a bound activity whose actor is its repository, each blocker a transition from it to the issue it blocks, and the state in the overlay. + * @param issues - The page's issues, with every blocker among them. + */ +export const pageOf = (issues: readonly Issue[]): FakePage => ({ + participants: [...new Set(issues.map((issue) => issue.repository))].map( + repositoryParticipant, + ), + activities: issues.map((issue) => ({ + kind: "activity", + id: issue.id, + label: issue.label, + source: issue.id, + actor: issue.repository, + })), + transitions: issues.flatMap((issue) => + issue.blockedBy.map((blocker) => ({ + id: `${blocker}->${issue.id}`, + source: blocker, + target: issue.id, + })), + ), + relations: [], + overlay: Object.fromEntries(issues.map((issue) => [issue.id, issue.state])), + gaps: [], +}); + +const sdk: Issue = { + id: "github:partner/sdk#1", + label: "Publish the SDK", + repository: "github:partner/sdk", + state: "open", + blockedBy: [], +}; + +/** The first page of the fake `github:acme` scope: the API repository's issues. */ +export const apiPage: FakePage = pageOf([ + { + id: "github:acme/api#1", + label: "Design the schema", + repository: "github:acme/api", + state: "done", + blockedBy: [], + }, + { + id: "github:acme/api#2", + label: "Build the endpoint", + repository: "github:acme/api", + state: "open", + blockedBy: ["github:acme/api#1"], + }, +]); + +/** The second page of the fake `github:acme` scope: the web repository's issues, with the blockers they reach in another repository and another organisation. */ +const webPage: FakePage = pageOf([ + { + id: "github:acme/api#2", + label: "Build the endpoint", + repository: "github:acme/api", + state: "open", + blockedBy: [], + }, + sdk, + { + id: "github:acme/web#1", + label: "Build the page", + repository: "github:acme/web", + state: "open", + blockedBy: ["github:acme/api#2", "github:partner/sdk#1"], + }, + { + id: "github:acme/web#2", + label: "Ship the release", + repository: "github:acme/web", + state: "open", + blockedBy: ["github:acme/web#1"], + }, +]); + +/** The fake GitHub source's scopes. */ +export const fakeScopes: Readonly> = { + "github:acme": [apiPage, webPage], + "github:partner/sdk": [pageOf([sdk])], +}; + +/** What a test can do to the ports beyond what the app does. */ +export interface TestPorts { + readonly ports: AppPorts; + readonly address: Settable; + readonly online: Settable; + readonly prefersDark: Settable; + readonly updateAvailable: Settable; + /** How many times the app asked to apply an update. */ + readonly updatesApplied: () => number; + /** The tokens the sources were built with, in order. */ + readonly tokensUsed: readonly string[]; + readonly records: ReadonlyMap; + readonly savedToken: () => string | undefined; +} + +export interface TestPortOptions { + readonly search?: string; + readonly token?: string; + /** The GitHub pager; the fake source over `fakeScopes` by default. */ + readonly pager?: GraphSourcePager; + readonly records?: readonly SyncRecord[]; +} + +/** + * The app's ports over in-memory fakes: the store contract's fake store and the source contract's fake source, each called through its oRPC client as in the app; an address, network, colour scheme and update that a test sets; and the device's storage in memory. + * @param options - The starting address, a saved token, the source's pager and sync records. + */ +export const testPorts = (options: TestPortOptions = {}): TestPorts => { + const address = settable(options.search ?? ""); + const online = settable(true); + const prefersDark = settable(false); + const updateAvailable = settable(false); + let applied = 0; + let token = options.token; + let theme: ThemePreference = "system"; + const tokensUsed: string[] = []; + const records = new Map( + (options.records ?? []).map((record) => [record.modelId, record]), + ); + const pager = + options.pager ?? + createFakeGraphSource({ source: "github", scopes: fakeScopes }); + + const ports: AppPorts = { + store: createRouterClient(createGraphStoreRouter(createFakeGraphStore())), + sources: (used) => (prefix) => { + tokensUsed.push(used); + + return prefix === "github" + ? createRouterClient(createGraphSourceRouter(pager)) + : undefined; + }, + token: { + read: () => token, + write: (next) => { + token = next; + }, + clear: () => { + token = undefined; + }, + }, + syncRecords: { + read: (modelId) => records.get(modelId), + write: (record) => { + records.set(record.modelId, record); + }, + }, + themePreference: { + read: () => theme, + write: (preference) => { + theme = preference; + }, + }, + address: { search: address.observable, replace: address.set }, + online: online.observable, + prefersDark: prefersDark.observable, + updates: { + available: updateAvailable.observable, + apply: () => { + applied += 1; + }, + }, + layout: { engine: rowEngine }, + }; + + return { + ports, + address, + online, + prefersDark, + updateAvailable, + updatesApplied: () => applied, + tokensUsed, + records, + savedToken: () => token, + }; +}; diff --git a/packages/targets/graph-web/src/workspace.unit.test.tsx b/packages/targets/graph-web/src/workspace.unit.test.tsx new file mode 100644 index 0000000..26bd0d5 --- /dev/null +++ b/packages/targets/graph-web/src/workspace.unit.test.tsx @@ -0,0 +1,292 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { App } from "./app.tsx"; +import { testPorts, type TestPorts } from "./test-support/ports.ts"; +import { parseViewState } from "./url-state.ts"; + +afterEach(cleanup); + +const acme = "?scope=github%3Aacme"; +const modelId = "github:acme"; + +const addressOf = (ports: TestPorts) => + parseViewState(ports.address.observable.get()).state; + +/** Renders the app on a store already synced from the fake `github:acme` scope. */ +const synced = async (search = acme): Promise => { + const ports = testPorts({ token: "t", search }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Sync" })); + await waitFor(() => { + expect(document.querySelector("[data-progress]")?.textContent).toContain( + "Sync finished", + ); + }); + + return ports; +}; + +const cardOf = async (id: string): Promise => + waitFor(() => { + const card = document.querySelector( + `.react-flow__node [data-element-id="${id}"]`, + ); + if (card === null) { + throw new Error(`no card for ${id}`); + } + + return card; + }); + +const optionsOf = (list: string): readonly string[] => + within(screen.getByRole("listbox", { name: list })) + .getAllByRole("option") + .map((option) => option.textContent); + +describe("the process view", () => { + test("draws the swimlane by default and switches view from its tabs, writing the view to the address", async () => { + const ports = await synced(); + await cardOf("github:acme/api#2"); + expect(document.querySelector('[data-view="swimlane"]')).not.toBeNull(); + + fireEvent.click(screen.getByRole("tab", { name: "Flowchart" })); + + expect(addressOf(ports).view).toBe("flowchart"); + expect(document.querySelector('[data-view="flowchart"]')).not.toBeNull(); + await cardOf("github:acme/api#2"); + }); + + test("moves between view tabs with the arrow keys", async () => { + const ports = await synced(); + const tabs = screen.getByRole("tablist", { name: "View" }); + + fireEvent.keyDown(within(tabs).getByRole("tab", { name: "Swimlane" }), { + key: "ArrowRight", + }); + + expect(addressOf(ports).view).toBe("state"); + expect(document.activeElement?.textContent).toBe("State machine"); + }); + + test("says why a model of issues has no state machine", async () => { + await synced(`${acme}&view=state`); + + expect( + within( + screen.getByRole("tabpanel", { name: /State machine of/u }), + ).getByRole("status").textContent, + ).toContain("this model declares no entity"); + }); + + test("follows the critical path in the sequence view", async () => { + await synced(`${acme}&view=sequence`); + + const figure = await screen.findByRole("figure"); + expect( + [...figure.querySelectorAll("[data-lifeline]")].map( + (line) => line.textContent, + ), + ).toEqual(["acme/api", "acme/web"]); + }); +}); + +describe("the lists", () => { + test("list the ready work and the blocked work with what it waits on", async () => { + const ports = await synced(); + + expect(optionsOf("Ready to start")).toEqual([ + "Build the endpoint Ready", + "Publish the SDK Ready", + ]); + fireEvent.click(screen.getByRole("tab", { name: "Blocked" })); + expect(addressOf(ports).tab).toBe("blocked"); + expect(optionsOf("Blocked")).toEqual([ + "Build the page Blocked waits on Build the endpoint, Publish the SDK", + "Ship the release Blocked waits on Build the endpoint, Publish the SDK", + ]); + }); + + test("list the critical path and the hand-offs between repositories", async () => { + await synced(); + + fireEvent.click(screen.getByRole("tab", { name: "Critical path" })); + expect(optionsOf("Critical path")).toEqual([ + "Build the endpoint Ready", + "Build the page Blocked", + "Ship the release Blocked", + ]); + fireEvent.click(screen.getByRole("tab", { name: "Hand-offs" })); + expect( + within(screen.getByRole("list", { name: "Hand-offs" })) + .getAllByRole("listitem") + .map((item) => item.textContent), + ).toEqual([ + "acme/api hands over to acme/web: Build the endpoint before Build the page", + "partner/sdk hands over to acme/web: Publish the SDK before Build the page", + ]); + }); + + test("share the selection with the address, the details and the keyboard", async () => { + const ports = await synced(); + const ready = screen.getByRole("listbox", { name: "Ready to start" }); + + fireEvent.keyDown(ready, { key: "ArrowDown" }); + + expect(addressOf(ports).selected).toBe("github:acme/api#2"); + const details = await screen.findByRole("complementary", { + name: "Build the endpoint", + }); + expect(details.textContent).toContain("Everything it needs is done."); + expect( + [...details.querySelectorAll('[data-section="impact"] li')].map( + (item) => item.textContent, + ), + ).toEqual(["Build the page", "Ship the release"]); + }); + + test("follow the filter in the address", async () => { + const ports = await synced(`${acme}&source=github%3Apartner%2Fsdk`); + + expect(optionsOf("Ready to start")).toEqual(["Publish the SDK Ready"]); + + fireEvent.click(screen.getByRole("button", { name: /clear/iu })); + + expect(addressOf(ports).filter.sources.size).toBe(0); + expect(optionsOf("Ready to start")).toEqual([ + "Build the endpoint Ready", + "Publish the SDK Ready", + ]); + }); +}); + +describe("the details", () => { + test("set an element's run state in the store", async () => { + const ports = await synced(`${acme}&select=github%3Aacme%2Fapi%232`); + + fireEvent.click(await screen.findByRole("button", { name: "Mark done" })); + + await waitFor(async () => { + const stored = await ports.ports.store.v1.models.load({ modelId }); + expect(stored.overlay["github:acme/api#2"]).toBe("done"); + }); + expect( + (await cardOf("github:acme/api#2")).getAttribute("data-status"), + ).toBe("done"); + }); + + test("link the issue on GitHub and add its repository to the scope when it is outside it, then sync it", async () => { + const ports = await synced(`${acme}&select=github%3Apartner%2Fsdk%231`); + + expect( + ( + await screen.findByRole("link", { name: "Open the issue on GitHub" }) + ).getAttribute("href"), + ).toBe("https://github.com/partner/sdk/issues/1"); + fireEvent.click( + screen.getByRole("button", { name: "Add partner/sdk to the scope" }), + ); + + expect(addressOf(ports).scopes).toEqual([ + "github:acme", + "github:partner/sdk", + ]); + await waitFor(() => { + expect(ports.records.get("github:acme github:partner/sdk")).toEqual({ + modelId: "github:acme github:partner/sdk", + gaps: [], + }); + }); + }); +}); + +describe("adding to the model", () => { + test("adds a record as the command line's add does, and shows the store's refusal", async () => { + const ports = await synced(); + const form = screen.getByRole("region", { name: "Add to the model" }); + + fireEvent.change(within(form).getByLabelText("Identifier"), { + target: { value: "review" }, + }); + fireEvent.change(within(form).getByLabelText("Label"), { + target: { value: "Review the release" }, + }); + fireEvent.click(within(form).getByRole("button", { name: "Add activity" })); + + expect(await within(form).findByRole("status")).toHaveProperty( + "textContent", + "Added review.", + ); + const stored = await ports.ports.store.v1.models.load({ modelId }); + expect(stored.model.elements.at(-1)).toEqual({ + kind: "activity", + id: "review", + label: "Review the release", + }); + + fireEvent.change(within(form).getByLabelText("What to add"), { + target: { value: "transition" }, + }); + fireEvent.change(within(form).getByLabelText("From"), { + target: { value: "nothing" }, + }); + fireEvent.change( + within(form).getByLabelText("To, which waits on the first"), + { + target: { value: "review" }, + }, + ); + fireEvent.click( + within(form).getByRole("button", { name: "Add transition" }), + ); + + await waitFor(() => { + expect(within(form).getByRole("status").textContent).toMatch( + /^Not added: /u, + ); + }); + }); +}); + +describe("a model with an illegal cycle", () => { + test("lists the cycle in place of the view, since no status can be worked out through it", async () => { + const ports = testPorts({ token: "t", search: acme }); + await ports.ports.store.v1.models.save({ + modelId, + model: { + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "a", label: "A" }, + { kind: "activity", id: "b", label: "B" }, + ], + transitions: [ + { id: "a->b", source: "a", target: "b" }, + { id: "b->a", source: "b", target: "a" }, + ], + fragments: [], + projections: [], + }, + }); + render(); + + const cycles = await screen.findByRole("list", { name: "Cycles" }); + expect( + within(cycles) + .getAllByRole("listitem") + .map((item) => item.textContent), + ).toEqual([ + expect.stringMatching( + /^(A waits on B waits on A|B waits on A waits on B)$/u, + ), + ]); + }); +}); diff --git a/packages/targets/graph-web/vitest.config.ts b/packages/targets/graph-web/vitest.config.ts index dae8bad..7686101 100644 --- a/packages/targets/graph-web/vitest.config.ts +++ b/packages/targets/graph-web/vitest.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ extends: true, test: { name: "unit", - include: ["src/**/*.test.{ts,tsx}"], + include: ["src/**/*.test.{ts,tsx}", "build/**/*.test.ts"], environment: "happy-dom", }, }, From 1925ed7d8ccd09622a9a1a89909995e7173333f4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 03:34:30 +0100 Subject: [PATCH 09/14] fix(web): colour links from the theme and drop the scope list's indent --- packages/targets/graph-web/src/app.css.ts | 15 +++++++++++++++ packages/targets/graph-web/src/scope-picker.tsx | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/targets/graph-web/src/app.css.ts b/packages/targets/graph-web/src/app.css.ts index 10edc51..be0b2b8 100644 --- a/packages/targets/graph-web/src/app.css.ts +++ b/packages/targets/graph-web/src/app.css.ts @@ -16,6 +16,11 @@ export const shell = style({ boxSizing: "border-box", }); +// Links take the theme's accent, which reads on the background in both themes, rather than the browser's default blue. +globalStyle(`${shell} a`, { + color: vars.colour.accent, +}); + export const header = style({ display: "flex", flexWrap: "wrap", @@ -160,6 +165,16 @@ export const list = style({ paddingLeft: vars.space.large, }); +/** A row of chips with no list markers or indent. */ +export const chipList = style({ + display: "flex", + flexWrap: "wrap", + gap: vars.space.medium, + listStyle: "none", + margin: 0, + padding: 0, +}); + export const chip = style({ display: "inline-flex", alignItems: "center", diff --git a/packages/targets/graph-web/src/scope-picker.tsx b/packages/targets/graph-web/src/scope-picker.tsx index c7706f8..d0845be 100644 --- a/packages/targets/graph-web/src/scope-picker.tsx +++ b/packages/targets/graph-web/src/scope-picker.tsx @@ -2,6 +2,7 @@ import { useId, useState, type SubmitEvent, type ReactElement } from "react"; import { button, chip, + chipList, field, input, muted, @@ -64,7 +65,7 @@ export const ScopePicker = (props: ScopePickerProps): ReactElement => { {scopes.length === 0 ? (

Nothing chosen yet.

) : ( -
    +
      {scopes.map((scope) => (
    • {scope} From b3213e03bcbffc6bea01593dc1cfb368ca617b5d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 03:36:09 +0100 Subject: [PATCH 10/14] fix(web): report a linked selection the model does not have A link can select an element that is not in the model, because the scope is not synced yet or the issue has gone. The reasoning hooks throw on an unknown element, so the details panel is only drawn for an element the model declares, and the app says the selection is missing otherwise. --- .../targets/graph-web/src/element-details.tsx | 24 ++++++++++--------- packages/targets/graph-web/src/workspace.tsx | 14 +++++++++-- .../graph-web/src/workspace.unit.test.tsx | 11 +++++++++ 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/targets/graph-web/src/element-details.tsx b/packages/targets/graph-web/src/element-details.tsx index 8d7ec8f..7989108 100644 --- a/packages/targets/graph-web/src/element-details.tsx +++ b/packages/targets/graph-web/src/element-details.tsx @@ -4,7 +4,11 @@ import { useWhyStatus, type Run, } from "@exadev/process-graph-react"; -import type { OpenIds, OverlayStatus } from "@exadev/process-graph-schema"; +import type { + Element, + OpenIds, + OverlayStatus, +} from "@exadev/process-graph-schema"; import { DetailPanel } from "@exadev/process-graph-ui"; import { useId, useState, type ReactElement } from "react"; import { button, muted, row, section, sectionHeading } from "./app.css.ts"; @@ -23,8 +27,8 @@ const settable: readonly { export interface ElementDetailsProps { readonly run: Run; - /** The selected element. */ - readonly id: string; + /** The selected element, which the model declares. */ + readonly element: Element; readonly labelOf: (id: string) => string; /** The scopes the model was synced from. */ readonly scopes: readonly string[]; @@ -37,21 +41,19 @@ export interface ElementDetailsProps { /** * The selected element: why it has its status, what blocks it and what it holds up (the command line's `why`, `blocked` and `impact`), its issue on GitHub, controls to set its run state, and, for an issue in a repository outside the scope, a control that adds that repository to the scope. * @param props - The run, the element and the actions. - * @returns The details, or a note when the element is not in the model. + * @returns The details. */ export const ElementDetails = (props: ElementDetailsProps): ReactElement => { - const { run, id, labelOf, scopes, onSetState, onAddScope } = props; + const { run, element, labelOf, scopes, onSetState, onAddScope } = props; + const { id } = element; const explanation = useWhyStatus(run, id); const { blockers } = useBlockedChain(run, id); const impact = useImpact(run, id); const [problem, setProblem] = useState(undefined); const headingId = useId(); - const element = run.model.elements.find((candidate) => candidate.id === id); - if (element === undefined || explanation === undefined) { - return ( -

      - The selected element, {id}, is not in this model. -

      + if (explanation === undefined) { + throw new Error( + "unreachable: useWhyStatus explains every element it is given", ); } const issue = diff --git a/packages/targets/graph-web/src/workspace.tsx b/packages/targets/graph-web/src/workspace.tsx index 22d1599..f94c644 100644 --- a/packages/targets/graph-web/src/workspace.tsx +++ b/packages/targets/graph-web/src/workspace.tsx @@ -148,6 +148,10 @@ const Workspace = (props: WorkspaceProps): ReactElement => { [ports.store, modelId, run.model], ); const viewName = `${viewLabels[state.view]} of ${state.scopes.join(", ")}`; + // A link can name an element the model does not have (not synced yet, or gone); the reasoning hooks would throw on it, so it is reported rather than explained. + const selectedElement = run.model.elements.find( + (element) => element.id === state.selected, + ); return (
      @@ -197,10 +201,16 @@ const Workspace = (props: WorkspaceProps): ReactElement => {
      - {state.selected === undefined ? null : ( + {selectedElement === undefined ? ( + state.selected === undefined ? null : ( +

      + The selected element, {state.selected}, is not in this model. +

      + ) + ) : ( { }); }); }); + + test("say when a link selects an element the model does not have", async () => { + await synced(`${acme}&select=github%3Aacme%2Fapi%2399`); + + expect(document.querySelector('[data-screen="details"]')?.textContent).toBe( + "The selected element, github:acme/api#99, is not in this model.", + ); + expect( + screen.getByRole("listbox", { name: "Ready to start" }), + ).toBeDefined(); + }); }); describe("adding to the model", () => { From 63a37d380e149b447d5d13901d987e89508ab546 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 04:46:24 +0100 Subject: [PATCH 11/14] build(graph-web): depend on the current sibling releases --- packages/targets/graph-web/package.json | 14 +++++++------- pnpm-lock.yaml | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/targets/graph-web/package.json b/packages/targets/graph-web/package.json index 10bbe80..eae35a7 100644 --- a/packages/targets/graph-web/package.json +++ b/packages/targets/graph-web/package.json @@ -43,14 +43,14 @@ "dependencies": { "@exadev/process-graph-domain": "1.1.1", "@exadev/process-graph-projection": "1.1.0", - "@exadev/process-graph-react": "1.0.0", - "@exadev/process-graph-react-flow": "1.0.0", + "@exadev/process-graph-react": "1.0.1", + "@exadev/process-graph-react-flow": "1.0.1", "@exadev/process-graph-schema": "1.1.0", - "@exadev/process-graph-source-contract": "1.0.1", - "@exadev/process-graph-source-github-issues": "1.0.1", - "@exadev/process-graph-sqlite-wasm": "1.0.2", - "@exadev/process-graph-store-contract": "1.0.3", - "@exadev/process-graph-ui": "1.0.0", + "@exadev/process-graph-source-contract": "1.1.0", + "@exadev/process-graph-source-github-issues": "1.1.0", + "@exadev/process-graph-sqlite-wasm": "1.0.3", + "@exadev/process-graph-store-contract": "1.1.0", + "@exadev/process-graph-ui": "1.0.1", "@orpc/client": "2.0.0-beta.41", "@orpc/server": "2.0.0-beta.41", "react": "19.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 523f3fc..6edeb45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -783,28 +783,28 @@ importers: specifier: 1.1.0 version: link:../../core/graph/projection '@exadev/process-graph-react': - specifier: 1.0.0 + specifier: 1.0.1 version: link:../../ui/graph/react '@exadev/process-graph-react-flow': - specifier: 1.0.0 + specifier: 1.0.1 version: link:../../ui/graph/react-flow '@exadev/process-graph-schema': specifier: 1.1.0 version: link:../../core/graph/schema '@exadev/process-graph-source-contract': - specifier: 1.0.1 + specifier: 1.1.0 version: link:../../core/graph/source-contract '@exadev/process-graph-source-github-issues': - specifier: 1.0.1 + specifier: 1.1.0 version: link:../../adapters/graph/source-github-issues '@exadev/process-graph-sqlite-wasm': - specifier: 1.0.2 + specifier: 1.0.3 version: link:../../adapters/graph/sqlite-wasm '@exadev/process-graph-store-contract': - specifier: 1.0.3 + specifier: 1.1.0 version: link:../../core/graph/store-contract '@exadev/process-graph-ui': - specifier: 1.0.0 + specifier: 1.0.1 version: link:../../ui/graph/ui '@orpc/client': specifier: 2.0.0-beta.41 From c7612b55ef1b35d5349fef570cf7d82b8589163f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 05:28:55 +0100 Subject: [PATCH 12/14] test(web): answer the GitHub source's current queries in the e2e fixture The source now selects `blocking` beside `blockedBy` on every issue, lists issues by state, label and order variables, selects each repository's description, URL and archived flag, and reads Projects v2 items and the viewer's organisations. The fixture still answered the older shapes, so the source refused the owner listing at its own parse and every sync stopped before its first page. The fixture now answers every query the source sends, with `blocking` as the inverse of `blockedBy` and the listing narrowed by the states it is asked for. --- .../targets/graph-web/e2e/github-fixture.ts | 192 ++++++++++++++---- 1 file changed, 153 insertions(+), 39 deletions(-) diff --git a/packages/targets/graph-web/e2e/github-fixture.ts b/packages/targets/graph-web/e2e/github-fixture.ts index 24c46e9..1746dad 100644 --- a/packages/targets/graph-web/e2e/github-fixture.ts +++ b/packages/targets/graph-web/e2e/github-fixture.ts @@ -2,7 +2,7 @@ import { createServer, type IncomingMessage, type Server } from "node:http"; import type { AddressInfo } from "node:net"; /** - * A stand-in for GitHub's GraphQL API, served on a local port for the end-to-end tests. It answers the three operations the GitHub issues source sends (`OwnerRepositories`, `RepositoryIssues` and `IssueConnection`) from a fixed set of issues, with the response shapes GitHub gives, and the same refusals: HTTP 401 for a token it does not know, and HTTP 403 with no requests remaining for the rate-limited token. CORS headers are the ones api.github.com sends, so a browser reads the rate limit headers as it would from GitHub. + * A stand-in for GitHub's GraphQL API, served on a local port for the end-to-end tests. It answers the operations the GitHub issues source sends for a sync and a scope listing (`ViewerScopes`, `OwnerRepositories`, `RepositoryIssues` and `ProjectItems`) from a fixed set of issues, with every field the source selects and the response shapes GitHub gives, so a change to the source's queries that the fixture does not follow fails the tests at the source's own parse, and the same refusals: HTTP 401 for a token it does not know, and HTTP 403 with no requests remaining for the rate-limited token. CORS headers are the ones api.github.com sends, so a browser reads the rate limit headers as it would from GitHub. */ type AccountType = "Organization" | "User"; @@ -118,7 +118,7 @@ const ownerOf = (login: string) => { return { __typename: account.type, login }; }; -const refOf = (owner: string, repository: string, number: number) => { +const findIssue = (owner: string, repository: string, number: number) => { const issue = fixtureIssues.find( (candidate) => candidate.owner === owner && @@ -131,14 +131,16 @@ const refOf = (owner: string, repository: string, number: number) => { ); } - return { - number, - title: issue.title, - state: issue.state, - repository: { name: repository, owner: ownerOf(owner) }, - }; + return issue; }; +const refOf = (owner: string, repository: string, number: number) => ({ + number, + title: findIssue(owner, repository, number).title, + state: findIssue(owner, repository, number).state, + repository: { name: repository, owner: ownerOf(owner) }, +}); + const connection = (nodes: readonly T[]) => ({ pageInfo: { hasNextPage: false, endCursor: null }, nodes, @@ -168,23 +170,94 @@ interface Answer { }[]; } +/** The viewer the fixture token belongs to: a user who is a member of both organisations. */ +const viewer = { login: "alice", organisations: ["acme", "partner"] } as const; + +/** The Projects v2 boards, by owner and number: their items in board order, an issue by its owner, repository and number, or a draft that is not an issue. */ +const boards: Readonly< + Record +> = { + "acme/1": [["acme", "web", 2], "draft", ["acme", "api", 2]], +}; + +const sameIssue = + (owner: string, repository: string, number: number) => + (candidate: readonly [string, string, number]): boolean => + candidate[0] === owner && + candidate[1] === repository && + candidate[2] === number; + +/** An issue as a listing or a board item returns it: its fields and the first page of each connection. `blocking` is the inverse of every issue's `blockedBy`, as GitHub keeps the two. */ +const issueOf = (issue: FixtureIssue) => ({ + ...refOf(issue.owner, issue.repository, issue.number), + assignees: connection(issue.assignees.map((login) => ({ login }))), + blockedBy: connection( + issue.blockedBy.map(([owner, repository, number]) => + refOf(owner, repository, number), + ), + ), + blocking: connection( + fixtureIssues + .filter((other) => + other.blockedBy.some( + sameIssue(issue.owner, issue.repository, issue.number), + ), + ) + .map((other) => refOf(other.owner, other.repository, other.number)), + ), + subIssues: connection([]), +}); + +const notFound = (data: unknown, message: string): Answer => ({ + data, + errors: [{ type: "NOT_FOUND", message }], +}); + +/** GitHub's `IssueState` values the listing's `states` variable selects. */ +const statesOf = (variables: Readonly>) => { + const states = variables.states; + if ( + !Array.isArray(states) || + !states.every((state) => state === "OPEN" || state === "CLOSED") + ) { + throw new Error("the request's states are not GitHub issue states"); + } + + return new Set(states); +}; + const answer = ( query: string, variables: Readonly>, ): Answer => { + if (query.includes("query ViewerScopes")) { + return { + data: { + viewer: { + login: viewer.login, + name: "Alice", + url: `https://github.com/${viewer.login}`, + organizations: connection( + viewer.organisations.map((login) => ({ + login, + name: login, + description: null, + url: `https://github.com/${login}`, + archivedAt: null, + })), + ), + }, + }, + }; + } if (query.includes("query OwnerRepositories")) { const login = stringOf(variables, "login"); const account = accounts[login]; if (account === undefined) { - return { - data: { repositoryOwner: null }, - errors: [ - { - type: "NOT_FOUND", - message: `Could not resolve to a RepositoryOwner with the login of '${login}'.`, - }, - ], - }; + return notFound( + { repositoryOwner: null }, + `Could not resolve to a RepositoryOwner with the login of '${login}'.`, + ); } return { @@ -192,7 +265,12 @@ const answer = ( repositoryOwner: { ...ownerOf(login), repositories: connection( - account.repositories.map((name) => ({ name })), + account.repositories.map((name) => ({ + name, + description: null, + url: `https://github.com/${login}/${name}`, + isArchived: false, + })), ), }, }, @@ -202,28 +280,23 @@ const answer = ( const owner = stringOf(variables, "owner"); const name = stringOf(variables, "name"); if (accounts[owner]?.repositories.includes(name) !== true) { - return { - data: { repository: null }, - errors: [ - { - type: "NOT_FOUND", - message: `Could not resolve to a Repository with the name '${owner}/${name}'.`, - }, - ], - }; + return notFound( + { repository: null }, + `Could not resolve to a Repository with the name '${owner}/${name}'.`, + ); + } + if (variables.labels !== null) { + throw new Error("the fixture's issues carry no labels to filter by"); } + const states = statesOf(variables); const issues = fixtureIssues - .filter((issue) => issue.owner === owner && issue.repository === name) - .map((issue) => ({ - ...refOf(issue.owner, issue.repository, issue.number), - assignees: connection(issue.assignees.map((login) => ({ login }))), - blockedBy: connection( - issue.blockedBy.map(([blockerOwner, repository, number]) => - refOf(blockerOwner, repository, number), - ), - ), - subIssues: connection([]), - })); + .filter( + (issue) => + issue.owner === owner && + issue.repository === name && + states.has(issue.state), + ) + .map(issueOf); return { data: { @@ -231,8 +304,49 @@ const answer = ( }, }; } + if (query.includes("query ProjectItems")) { + const login = stringOf(variables, "login"); + const number = variables.number; + if (typeof number !== "number") { + throw new Error("the request's project number is not a number"); + } + const items = boards[`${login}/${String(number)}`]; + if (accounts[login] === undefined) { + return notFound( + { repositoryOwner: null }, + `Could not resolve to a RepositoryOwner with the login of '${login}'.`, + ); + } + if (items === undefined) { + return notFound( + { repositoryOwner: { ...ownerOf(login), projectV2: null } }, + `Could not resolve to a ProjectV2 with the number ${String(number)}.`, + ); + } + + return { + data: { + repositoryOwner: { + ...ownerOf(login), + projectV2: { + items: connection( + items.map((item) => ({ + content: + item === "draft" + ? { __typename: "DraftIssue" } + : { + __typename: "Issue", + ...issueOf(findIssue(...item)), + }, + })), + ), + }, + }, + }, + }; + } throw new Error( - "the fixture answers only the GitHub issues source's queries", + "the fixture answers only the GitHub issues source's sync and listing queries", ); }; From b75ad968ab903a7b04a33eecfb3ee6a0f295ca1e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 05:29:01 +0100 Subject: [PATCH 13/14] feat(web): read a pasted Projects v2 link as a board scope A github.com/orgs//projects/ or /users//projects/ address, with any view after it, becomes the source's github:/projects/ scope, and owner/projects/ is read as one too. Closes #40 --- .../targets/graph-web/src/app.unit.test.tsx | 64 ++++++++++++++++++- packages/targets/graph-web/src/scope-input.ts | 30 ++++++--- .../graph-web/src/scope-input.unit.test.ts | 17 ++++- 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/packages/targets/graph-web/src/app.unit.test.tsx b/packages/targets/graph-web/src/app.unit.test.tsx index 4770dc4..af382d2 100644 --- a/packages/targets/graph-web/src/app.unit.test.tsx +++ b/packages/targets/graph-web/src/app.unit.test.tsx @@ -124,11 +124,73 @@ describe("the scope picker", () => { fireEvent.click(screen.getByRole("button", { name: "Add" })); expect(screen.getByRole("alert").textContent).toBe( - '"acme/api/extra" is not a GitHub organisation, user or repository: write owner or owner/repo.', + '"acme/api/extra" is not a GitHub organisation, user, repository or project board: write owner, owner/repo or the link to a board.', ); }); }); +describe("the scope suggestions", () => { + test("list what the token reaches on request, an owner's repositories under it, and add one to the address", async () => { + const ports = testPorts({ + token: "t", + search: "?scope=github%3Aacme%2Fapi", + }); + renderApp(ports); + const suggestions = screen.getByRole("region", { name: "Suggestions" }); + const suggested = (name: string) => + within(suggestions) + .getAllByRole("list", { name }) + .flatMap((list) => within(list).queryAllByRole("listitem")) + .map((item) => item.getAttribute("data-suggestion")); + + expect(within(suggestions).queryByRole("list")).toBeNull(); + fireEvent.click( + within(suggestions).getByRole("button", { + name: "Show my account and organisations", + }), + ); + await waitFor(() => { + expect(suggested("Your scopes")).toEqual(["github:alice", "github:acme"]); + }); + + fireEvent.click( + within(suggestions).getByRole("button", { + name: "Show the repositories of github:acme", + }), + ); + await waitFor(() => { + expect(suggested("Inside github:acme")).toEqual(["github:acme/legacy"]); + }); + // The fake source lists two entries a page, so the third repository is on the next. + fireEvent.click(within(suggestions).getByRole("button", { name: "More" })); + await waitFor(() => { + // The repository already chosen is not offered again. + expect(suggested("Inside github:acme")).toEqual([ + "github:acme/legacy", + "github:acme/web", + ]); + }); + expect( + suggestions.querySelector('[data-suggestion="github:acme/legacy"]') + ?.textContent, + ).toContain("(archived)"); + + fireEvent.click( + within(suggestions).getByRole("button", { name: "Add github:acme/web" }), + ); + expect(addressOf(ports).scopes).toEqual([ + "github:acme/api", + "github:acme/web", + ]); + }); + + test("are not offered without a token", () => { + renderApp(testPorts()); + + expect(screen.queryByRole("region", { name: "Suggestions" })).toBeNull(); + }); +}); + describe("the sync", () => { test("says nothing is synced yet, then syncs with the saved token and shows the work", async () => { const ports = testPorts({ token: "t", search: acme }); diff --git a/packages/targets/graph-web/src/scope-input.ts b/packages/targets/graph-web/src/scope-input.ts index 4512477..ffb031f 100644 --- a/packages/targets/graph-web/src/scope-input.ts +++ b/packages/targets/graph-web/src/scope-input.ts @@ -12,8 +12,11 @@ export type ScopeInput = const githubHost = "github.com"; +/** The first path segment of a Projects v2 board's address, by whether an organisation or a user owns it. */ +const boardOwnerSegments: ReadonlySet = new Set(["orgs", "users"]); + /** - * The path segments of a GitHub web address that name an account or a repository: `https://github.com/owner` and `https://github.com/owner/repo`, with anything after the repository (`/issues/12`, `/pulls`) ignored. + * The scope path a GitHub web address names: `https://github.com/owner` an owner, `https://github.com/owner/repo` a repository, with anything after it (`/issues/12`, `/pulls`) ignored, and `https://github.com/orgs/owner/projects/1` or `/users/owner/projects/1` a Projects v2 board, with any view after it ignored. */ const fromGitHubUrl = (text: string): string | undefined => { if (!URL.canParse(text)) { @@ -23,17 +26,28 @@ const fromGitHubUrl = (text: string): string | undefined => { if (url.hostname !== githubHost) { return undefined; } - const [owner, name] = url.pathname.split("/").filter((part) => part !== ""); + const [first, second, third, fourth] = url.pathname + .split("/") + .filter((part) => part !== ""); + if ( + first !== undefined && + boardOwnerSegments.has(first) && + second !== undefined && + third === "projects" && + fourth !== undefined + ) { + return `${second}/projects/${fourth}`; + } - return owner === undefined + return first === undefined ? undefined - : name === undefined - ? owner - : `${owner}/${name}`; + : second === undefined + ? first + : `${first}/${second}`; }; /** - * Reads what a person typed as a scope. A source-qualified scope is taken as it is; a GitHub web address, `owner/repo` or `owner` is read as a GitHub scope and checked as the GitHub source checks it. + * Reads what a person typed as a scope. A source-qualified scope is taken as it is; a GitHub web address (of an owner, a repository or a Projects v2 board), `owner/repo`, `owner/projects/number` or `owner` is read as a GitHub scope and checked as the GitHub source checks it. * @param text - The text, trimmed of surrounding space. * @returns The scope, or a sentence saying why the text is not one. */ @@ -58,7 +72,7 @@ export const readScopeInput = (text: string): ScopeInput => { } return { - problem: `"${trimmed}" is not a GitHub organisation, user or repository: write owner or owner/repo.`, + problem: `"${trimmed}" is not a GitHub organisation, user, repository or project board: write owner, owner/repo or the link to a board.`, }; } } diff --git a/packages/targets/graph-web/src/scope-input.unit.test.ts b/packages/targets/graph-web/src/scope-input.unit.test.ts index f714e16..4707926 100644 --- a/packages/targets/graph-web/src/scope-input.unit.test.ts +++ b/packages/targets/graph-web/src/scope-input.unit.test.ts @@ -9,6 +9,12 @@ describe("readScopeInput", () => { ["https://github.com/acme", "github:acme"], ["https://github.com/acme/api/issues/12", "github:acme/api"], ["linear:team-a", "linear:team-a"], + ["https://github.com/orgs/acme/projects/7", "github:acme/projects/7"], + [ + "https://github.com/users/alice/projects/2/views/1", + "github:alice/projects/2", + ], + ["acme/projects/7", "github:acme/projects/7"], ])("reads %j as %j", (text, scope) => { expect(readScopeInput(text)).toEqual({ scope }); }); @@ -19,14 +25,21 @@ describe("readScopeInput", () => { }); }); + test("refuses a board number GitHub cannot have", () => { + expect(readScopeInput("https://github.com/orgs/acme/projects/0")).toEqual({ + problem: + '"https://github.com/orgs/acme/projects/0" is not a GitHub organisation, user, repository or project board: write owner, owner/repo or the link to a board.', + }); + }); + test("refuses what GitHub cannot name", () => { expect(readScopeInput("acme/api/extra")).toEqual({ problem: - '"acme/api/extra" is not a GitHub organisation, user or repository: write owner or owner/repo.', + '"acme/api/extra" is not a GitHub organisation, user, repository or project board: write owner, owner/repo or the link to a board.', }); expect(readScopeInput("github:acme corp")).toEqual({ problem: - '"github:acme corp" is not a GitHub organisation, user or repository: write owner or owner/repo.', + '"github:acme corp" is not a GitHub organisation, user, repository or project board: write owner, owner/repo or the link to a board.', }); }); }); From f56e964e4d18ccf230db0d043f5a101075d9737d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 05:29:12 +0100 Subject: [PATCH 14/14] feat(web): suggest scopes from what the token reaches With a token saved and the page online, the scope picker offers the viewer's account and organisations from the source's scopes.roots, and an owner's repositories from scopes.children, a page at a time and only when asked for, marking archived ones and leaving out scopes already chosen. The token is read for each listing and handed straight to the source, as a sync does. The end-to-end suite also syncs a Projects v2 board pasted as its link. --- packages/targets/graph-web/README.md | 24 +-- .../targets/graph-web/e2e/app.e2e.test.ts | 51 +++++ packages/targets/graph-web/src/app.tsx | 42 +++- .../graph-web/src/scope-suggestions.tsx | 196 ++++++++++++++++++ .../graph-web/src/test-support/ports.ts | 31 ++- 5 files changed, 330 insertions(+), 14 deletions(-) create mode 100644 packages/targets/graph-web/src/scope-suggestions.tsx diff --git a/packages/targets/graph-web/README.md b/packages/targets/graph-web/README.md index 4cc6c92..7bdd58a 100644 --- a/packages/targets/graph-web/README.md +++ b/packages/targets/graph-web/README.md @@ -8,17 +8,17 @@ React 19, Vite, Vanilla Extract over graph-ui's theme contract, vite-plugin-pwa ## Screens -| Screen | What it does | -| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Token | Takes a GitHub token, says what it needs (read-only Issues and Metadata on a fine-grained token, or a classic token with no scopes for public repositories and `repo` for private ones) and where it goes. The field is write-only; a saved token is never shown again. | -| Scope | Any number of scopes: `owner`, `owner/repo`, a source-qualified scope such as `github:owner/repo`, or a pasted GitHub link. | -| Sync | Reads the scopes page by page and writes each page as it is read, announcing progress. Cancelling stops it between or during reads; a cancelled, rate-limited or interrupted sync resumes from its checkpoint, which is kept on the device so it survives a reload. | -| View | The model as a flowchart, swimlane, state machine or sequence diagram (`ProcessView` from graph-react-flow), filtered, with the critical path marked and a legend. A state machine follows an entity; a sequence follows the model's own scenario, else the critical path. | -| Lists | Ready, blocked (with what each waits on), the critical path, hand-offs between owners, cycles and structural problems: the command line's `ready`, `blocked`, `path`, `handoffs`, `cycles` and `validate`. | -| Details | The selected element's status and the rule that decided it, its blockers and what it holds up (`why`, `blocked`, `impact`), its issue on GitHub, buttons that set its run state (`set`), and, for an issue in a repository outside the scope, one that adds that repository and syncs it. | -| Filters | Status, actor and source collection, as graph-ui's `FilterPanel`; the view and the lists follow them. | -| Add | Adds a participant, activity, decision, state, event, artefact or transition by hand, as the command line's `add` does; the store refuses a change that would leave the model invalid. | -| Notices | Offline; a new version ready to load; parts of the link that could not be used; a sync that stopped (a rate limit with its reset time, a token GitHub refused, anything else); and the parts of the scope the source could not read. | +| Screen | What it does | +| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Token | Takes a GitHub token, says what it needs (read-only Issues and Metadata on a fine-grained token, or a classic token with no scopes for public repositories and `repo` for private ones) and where it goes. The field is write-only; a saved token is never shown again. | +| Scope | Any number of scopes: `owner`, `owner/repo`, `owner/projects/number`, a source-qualified scope such as `github:owner/repo`, or a pasted GitHub link to an owner, a repository or a Projects v2 board. With a token, suggestions list the viewer's account and organisations and, on request, an owner's repositories (the source's `scopes.roots` and `scopes.children`), read only when asked for. | +| Sync | Reads the scopes page by page and writes each page as it is read, announcing progress. Cancelling stops it between or during reads; a cancelled, rate-limited or interrupted sync resumes from its checkpoint, which is kept on the device so it survives a reload. | +| View | The model as a flowchart, swimlane, state machine or sequence diagram (`ProcessView` from graph-react-flow), filtered, with the critical path marked and a legend. A state machine follows an entity; a sequence follows the model's own scenario, else the critical path. | +| Lists | Ready, blocked (with what each waits on), the critical path, hand-offs between owners, cycles and structural problems: the command line's `ready`, `blocked`, `path`, `handoffs`, `cycles` and `validate`. | +| Details | The selected element's status and the rule that decided it, its blockers and what it holds up (`why`, `blocked`, `impact`), its issue on GitHub, buttons that set its run state (`set`), and, for an issue in a repository outside the scope, one that adds that repository and syncs it. | +| Filters | Status, actor and source collection, as graph-ui's `FilterPanel`; the view and the lists follow them. | +| Add | Adds a participant, activity, decision, state, event, artefact or transition by hand, as the command line's `add` does; the store refuses a change that would leave the model invalid. | +| Notices | Offline; a new version ready to load; parts of the link that could not be used; a sync that stopped (a rate limit with its reset time, a token GitHub refused, anything else); and the parts of the scope the source could not read. | The theme follows the system's colour scheme until a person picks light or dark. Every list and the canvas move their selection with the arrow keys, Home, End and Escape, the view and list tabs with the arrow keys, Home and End, and a skip link leads past the header. @@ -58,7 +58,7 @@ The service worker precaches the app shell: the page, scripts, styles, icons and The screen tests render the app with Testing Library in happy-dom against the store contract's in-memory fake and the source contract's fake source, each through its oRPC client, laid out by a stub engine. `test/app.smoke.test.ts` checks the built `dist/`: the manifest, the rendered icons and the precache. -The end-to-end tests run Playwright in Chromium against `vite preview` of the production build. A local fixture server stands in for GitHub's GraphQL API; the browser's requests to `api.github.com` are routed to it, so the browser still addresses GitHub. They sync organisations and see the graph and lists, check that the token reaches no host but `api.github.com`, check installability (Chromium's own installability report, the manifest and an active service worker) and the absence of cross-origin isolation, reload offline and see the last sync, set a run state in one tab and see it in another, expand the scope on demand, and see a rate limit's reset time. +The end-to-end tests run Playwright in Chromium against `vite preview` of the production build. A local fixture server stands in for GitHub's GraphQL API; the browser's requests to `api.github.com` are routed to it, so the browser still addresses GitHub. They sync organisations and see the graph and lists, check that the token reaches no host but `api.github.com`, check installability (Chromium's own installability report, the manifest and an active service worker) and the absence of cross-origin isolation, reload offline and see the last sync, set a run state in one tab and see it in another, expand the scope on demand, sync a Projects v2 board pasted as its link, list scope suggestions, and see a rate limit's reset time. The fixture answers every query the GitHub source sends with every field it selects, so a change to those queries fails here at the source's own parse. ## Commands diff --git a/packages/targets/graph-web/e2e/app.e2e.test.ts b/packages/targets/graph-web/e2e/app.e2e.test.ts index df7e5f2..0ddaa7e 100644 --- a/packages/targets/graph-web/e2e/app.e2e.test.ts +++ b/packages/targets/graph-web/e2e/app.e2e.test.ts @@ -252,3 +252,54 @@ test("says when GitHub's rate limit resets", async ({ page }) => { "1 Jan 2100", ); }); + +test("syncs a Projects v2 board pasted as its link, passing over its draft items", async ({ + page, +}) => { + await page.goto("/"); + await syncScopes(page, fixtureToken, [ + "https://github.com/orgs/acme/projects/1", + ]); + await syncFinished(page); + + expect(new URL(page.url()).searchParams.getAll("scope")).toEqual([ + "github:acme/projects/1", + ]); + // The board's issues, and the issues they wait on, which are read whatever board they are on. + await expect(card(page, "github:acme/web#2")).toHaveAttribute( + "data-status", + "blocked", + ); + await expect(card(page, "github:acme/api#2")).toHaveAttribute( + "data-status", + "ready", + ); + await expect(card(page, "github:acme/web#1")).toBeVisible(); +}); + +test("suggests the organisations the token reaches and their repositories", async ({ + page, +}) => { + await page.goto("/"); + await page.getByLabel("Token", { exact: true }).fill(fixtureToken); + await page.getByRole("button", { name: "Save on this device" }).click(); + const suggestions = page.getByRole("region", { name: "Suggestions" }); + await suggestions + .getByRole("button", { name: "Show my account and organisations" }) + .click(); + await expect( + suggestions + .getByRole("list", { name: "Your scopes" }) + .getByRole("listitem"), + ).toHaveText([/^Alice/u, /^acme/u, /^partner/u]); + await suggestions + .getByRole("button", { name: "Show the repositories of github:acme" }) + .click(); + await suggestions + .getByRole("button", { name: "Add github:acme/web" }) + .click(); + + expect(new URL(page.url()).searchParams.getAll("scope")).toEqual([ + "github:acme/web", + ]); +}); diff --git a/packages/targets/graph-web/src/app.tsx b/packages/targets/graph-web/src/app.tsx index 747d12b..f86cead 100644 --- a/packages/targets/graph-web/src/app.tsx +++ b/packages/targets/graph-web/src/app.tsx @@ -1,5 +1,13 @@ +import { githubSource } from "@exadev/process-graph-source-github-issues"; import { GraphTheme } from "@exadev/process-graph-ui"; -import { useEffect, useId, useRef, useState, type ReactElement } from "react"; +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type ReactElement, +} from "react"; import { button, header, @@ -20,6 +28,7 @@ import { } from "./notices.tsx"; import type { AppPorts, ThemePreference } from "./ports.ts"; import { ScopePicker } from "./scope-picker.tsx"; +import { ScopeSuggestions, type ScopeLister } from "./scope-suggestions.tsx"; import { SyncPanel } from "./sync-panel.tsx"; import { TokenPanel } from "./token-panel.tsx"; import { modelIdOf, type ViewState } from "./url-state.ts"; @@ -78,6 +87,30 @@ export const App = (props: AppProps): ReactElement => { const theme = preference === "system" ? (prefersDark ? "dark" : "light") : preference; const { start } = sync; + // The token is read at the moment of each listing and handed straight to the source, as a sync does, so it never enters React state. + const listScopes: ScopeLister = useCallback( + async (parent, after) => { + const token = ports.token.read(); + const github = + token === undefined ? undefined : ports.sources(token)(githubSource); + if (github === undefined) { + throw new Error("Save a GitHub token to list what it reaches."); + } + const from = after === undefined ? {} : { after }; + + return parent === undefined + ? github.v1.scopes.roots(from) + : github.v1.scopes.children({ scope: parent, ...from }); + }, + [ports], + ); + const addScope = (scope: string): void => { + update((current) => + current.scopes.includes(scope) + ? current + : withScopes(current, [...current.scopes, scope]), + ); + }; // A scope added from an element's details is synced as soon as its model is the one shown, so expanding on demand is one action. useEffect(() => { @@ -166,6 +199,13 @@ export const App = (props: AppProps): ReactElement => { update((current) => withScopes(current, scopes)); }} /> + {hasToken && online ? ( + + ) : null} {state.scopes.length === 0 ? (

      Choose an organisation or repository to see its work. diff --git a/packages/targets/graph-web/src/scope-suggestions.tsx b/packages/targets/graph-web/src/scope-suggestions.tsx new file mode 100644 index 0000000..0179dee --- /dev/null +++ b/packages/targets/graph-web/src/scope-suggestions.tsx @@ -0,0 +1,196 @@ +import type { + ScopeEntry, + ScopeListing, +} from "@exadev/process-graph-source-contract"; +import { useId, useState, type ReactElement } from "react"; +import { + button, + chip, + chipList, + muted, + row, + section, + sectionHeading, +} from "./app.css.ts"; +import { failureOf } from "./failure.ts"; + +/** + * Lists scopes a page at a time: with no parent, the top-level scopes the token reaches (the source's `scopes.roots`); with one, the scopes inside it (`scopes.children`). `after` resumes a listing from an earlier page's `next`. + */ +export type ScopeLister = ( + parent: string | undefined, + after: string | undefined, +) => Promise; + +/** One listing as far as it has been read: its entries so far, where it resumes, and why it stopped if it failed. */ +interface Listing { + readonly entries: readonly ScopeEntry[]; + readonly next: string | undefined; + readonly loading: boolean; + readonly problem: string | undefined; +} + +const empty: Listing = { + entries: [], + next: undefined, + loading: false, + problem: undefined, +}; + +/** The key a listing is kept under: its parent scope, or this for the top level. */ +const topLevel = ""; + +export interface ScopeSuggestionsProps { + readonly list: ScopeLister; + /** The scopes already chosen, which are not offered again. */ + readonly scopes: readonly string[]; + readonly onAdd: (scope: string) => void; +} + +/** + * Suggests scopes from what the token reaches: the viewer's account and organisations, and an owner's repositories on request. Nothing is read until asked for, so opening the app spends no requests, and each listing pages on with "More". + * @param props - The lister, the chosen scopes and what adding does. + * @returns The suggestions. + */ +export const ScopeSuggestions = ( + props: ScopeSuggestionsProps, +): ReactElement => { + const { list, scopes, onAdd } = props; + const [listings, setListings] = useState>( + new Map(), + ); + const headingId = useId(); + + const read = (parent: string | undefined): void => { + const key = parent ?? topLevel; + const before = listings.get(key) ?? empty; + const update = (listing: Listing): void => { + setListings((current) => new Map(current).set(key, listing)); + }; + update({ ...before, loading: true, problem: undefined }); + list(parent, before.next).then( + (page) => { + update({ + entries: [...before.entries, ...page.entries], + next: page.next, + loading: false, + problem: + page.gaps.length === 0 + ? undefined + : page.gaps.map((gap) => gap.message).join(" "), + }); + }, + (error: unknown) => { + update({ + ...before, + loading: false, + problem: failureOf(error).message, + }); + }, + ); + }; + + const shown = ( + key: string, + parent: string | undefined, + ): ReactElement | null => { + const listing = listings.get(key); + if (listing === undefined) { + return null; + } + const offered = listing.entries.filter( + (entry) => !scopes.includes(entry.scope), + ); + + return ( +

      +
        + {offered.map((entry) => ( +
      • + {entry.label} + {entry.archived ? " (archived)" : ""} + + {parent === undefined ? ( + + ) : null} +
      • + ))} +
      + {listing.next === undefined ? null : ( + + )} + {listing.problem === undefined ? null : ( +

      + {listing.problem} +

      + )} +
      + ); + }; + + const owners = listings.get(topLevel)?.entries ?? []; + + return ( +
      +

      + Suggestions +

      + {listings.has(topLevel) ? null : ( +
      + +
      + )} + {shown(topLevel, undefined)} + {owners.map((owner) => ( +
      {shown(owner.scope, owner.scope)}
      + ))} +
      + ); +}; diff --git a/packages/targets/graph-web/src/test-support/ports.ts b/packages/targets/graph-web/src/test-support/ports.ts index 7bac4b0..e0ca604 100644 --- a/packages/targets/graph-web/src/test-support/ports.ts +++ b/packages/targets/graph-web/src/test-support/ports.ts @@ -4,6 +4,7 @@ import { createGraphSourceRouter, type FakePage, type GraphSourcePager, + type ScopeEntry, } from "@exadev/process-graph-source-contract"; import { createFakeGraphStore, @@ -189,6 +190,27 @@ const webPage: FakePage = pageOf([ }, ]); +const entry = (scope: string, label: string): ScopeEntry => ({ + scope, + label, + archived: false, +}); + +/** What the fake source's `scopes.roots` lists: the viewer and an organisation. */ +const fakeRoots: readonly ScopeEntry[] = [ + entry("github:alice", "alice"), + entry("github:acme", "acme"), +]; + +/** What the fake source's `scopes.children` lists inside each owner. */ +const fakeChildren: Readonly> = { + "github:acme": [ + entry("github:acme/api", "acme/api"), + { ...entry("github:acme/legacy", "acme/legacy"), archived: true }, + entry("github:acme/web", "acme/web"), + ], +}; + /** The fake GitHub source's scopes. */ export const fakeScopes: Readonly> = { "github:acme": [apiPage, webPage], @@ -236,7 +258,14 @@ export const testPorts = (options: TestPortOptions = {}): TestPorts => { ); const pager = options.pager ?? - createFakeGraphSource({ source: "github", scopes: fakeScopes }); + createFakeGraphSource({ + source: "github", + scopes: fakeScopes, + roots: fakeRoots, + children: fakeChildren, + // Smaller than the owner's repository listing, so a listing pages. + listingPageSize: 2, + }); const ports: AppPorts = { store: createRouterClient(createGraphStoreRouter(createFakeGraphStore())),