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/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..ae5b090 100644
--- a/knip.config.ts
+++ b/knip.config.ts
@@ -32,6 +32,11 @@ const config: KnipConfig = {
entry: ["src/index.{ts,tsx}"],
project: ["src/**/*.{ts,tsx}!"],
},
+ // 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/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/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/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..7bdd58a
--- /dev/null
+++ b/packages/targets/graph-web/README.md
@@ -0,0 +1,72 @@
+# @exadev/process-graph-web
+
+[](https://github.com/ExaDev/process-graph) [](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`, `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.
+
+## 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, 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
+
+```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 =>
+ [
+ `",
+ ].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/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/e2e/app.e2e.test.ts b/packages/targets/graph-web/e2e/app.e2e.test.ts
new file mode 100644
index 0000000..0ddaa7e
--- /dev/null
+++ b/packages/targets/graph-web/e2e/app.e2e.test.ts
@@ -0,0 +1,305 @@
+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",
+ );
+});
+
+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/e2e/github-fixture.ts b/packages/targets/graph-web/e2e/github-fixture.ts
new file mode 100644
index 0000000..1746dad
--- /dev/null
+++ b/packages/targets/graph-web/e2e/github-fixture.ts
@@ -0,0 +1,449 @@
+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 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";
+
+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 findIssue = (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 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,
+});
+
+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;
+ }[];
+}
+
+/** 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 notFound(
+ { repositoryOwner: null },
+ `Could not resolve to a RepositoryOwner with the login of '${login}'.`,
+ );
+ }
+
+ return {
+ data: {
+ repositoryOwner: {
+ ...ownerOf(login),
+ repositories: connection(
+ account.repositories.map((name) => ({
+ name,
+ description: null,
+ url: `https://github.com/${login}/${name}`,
+ isArchived: false,
+ })),
+ ),
+ },
+ },
+ };
+ }
+ if (query.includes("query RepositoryIssues")) {
+ const owner = stringOf(variables, "owner");
+ const name = stringOf(variables, "name");
+ if (accounts[owner]?.repositories.includes(name) !== true) {
+ 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 &&
+ states.has(issue.state),
+ )
+ .map(issueOf);
+
+ return {
+ data: {
+ repository: { name, owner: ownerOf(owner), issues: connection(issues) },
+ },
+ };
+ }
+ 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 sync and listing 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/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..eae35a7
--- /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": "1.0.1",
+ "@exadev/process-graph-react-flow": "1.0.1",
+ "@exadev/process-graph-schema": "1.1.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",
+ "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/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/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
+
+
+ {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..be0b2b8
--- /dev/null
+++ b/packages/targets/graph-web/src/app.css.ts
@@ -0,0 +1,261 @@
+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",
+});
+
+// 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",
+ 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,
+});
+
+/** 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",
+ 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,
+});
diff --git a/packages/targets/graph-web/src/app.tsx b/packages/targets/graph-web/src/app.tsx
new file mode 100644
index 0000000..f86cead
--- /dev/null
+++ b/packages/targets/graph-web/src/app.tsx
@@ -0,0 +1,236 @@
+import { githubSource } from "@exadev/process-graph-source-github-issues";
+import { GraphTheme } from "@exadev/process-graph-ui";
+import {
+ useCallback,
+ 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 { 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";
+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;
+ // 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(() => {
+ 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 (
+
+
+ );
+};
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.
+
+
+ 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:
+
+ );
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..ffb031f
--- /dev/null
+++ b/packages/targets/graph-web/src/scope-input.ts
@@ -0,0 +1,81 @@
+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 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 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)) {
+ return undefined;
+ }
+ const url = new URL(text);
+ if (url.hostname !== githubHost) {
+ return undefined;
+ }
+ 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 first === undefined
+ ? undefined
+ : 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 (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.
+ */
+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, repository or project board: write owner, owner/repo or the link to a board.`,
+ };
+ }
+ }
+
+ 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..4707926
--- /dev/null
+++ b/packages/targets/graph-web/src/scope-input.unit.test.ts
@@ -0,0 +1,45 @@
+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"],
+ ["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 });
+ });
+
+ test("asks for something when nothing is typed", () => {
+ expect(readScopeInput(" ")).toEqual({
+ problem: "Type an organisation, a repository or a GitHub link.",
+ });
+ });
+
+ 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, 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, repository or project board: write owner, owner/repo or the link to a board.',
+ });
+ });
+});
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..d0845be
--- /dev/null
+++ b/packages/targets/graph-web/src/scope-picker.tsx
@@ -0,0 +1,114 @@
+import { useId, useState, type SubmitEvent, type ReactElement } from "react";
+import {
+ button,
+ chip,
+ chipList,
+ 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}
+
+
+ ))}
+
+ )}
+
+
+ );
+};
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 (
+
+ );
+};
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..e0ca604
--- /dev/null
+++ b/packages/targets/graph-web/src/test-support/ports.ts
@@ -0,0 +1,323 @@
+import type { FlowLayoutOptions } from "@exadev/process-graph-react-flow";
+import {
+ createFakeGraphSource,
+ createGraphSourceRouter,
+ type FakePage,
+ type GraphSourcePager,
+ type ScopeEntry,
+} 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"],
+ },
+]);
+
+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],
+ "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,
+ roots: fakeRoots,
+ children: fakeChildren,
+ // Smaller than the owner's repository listing, so a listing pages.
+ listingPageSize: 2,
+ });
+
+ 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/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.
+
+
+
+
+ {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..c98d039
--- /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`.
+ */
+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..ff9b88d
--- /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. */
+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..928ccc6
--- /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. */
+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. */
+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
+ 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 (
+
+
+
+ );
+};
diff --git a/packages/targets/graph-web/src/workspace.tsx b/packages/targets/graph-web/src/workspace.tsx
new file mode 100644
index 0000000..f94c644
--- /dev/null
+++ b/packages/targets/graph-web/src/workspace.tsx
@@ -0,0 +1,326 @@
+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. */
+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(", ")}`;
+ // 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 (
+
+ );
+};
+
+/** 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 (
+