Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/targets/graph-web/AGENTS.md
1 change: 1 addition & 0 deletions packages/targets/graph-web/CLAUDE.md
72 changes: 72 additions & 0 deletions packages/targets/graph-web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# @exadev/process-graph-web

[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/process-graph) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/process-graph/ci.yml?branch=main)](https://github.com/ExaDev/process-graph/actions)

The process-graph web app: an installable, offline-first PWA that syncs GitHub issues from any number of organisations and repositories into a SQLite store on the device and shows the work as a flowchart, swimlane, state machine or sequence diagram, with what is ready, what is blocked, what each piece of work holds up and why each has its status. It is a composition root and nothing else: the model, reasoning, projections, layout, source, store and components are the other packages of this repository, wired together in `src/main.tsx` through `AppPorts`. A private workspace package; it is deployed, not published.

React 19, Vite, Vanilla Extract over graph-ui's theme contract, vite-plugin-pwa (Workbox).

## Screens

| Screen | What it does |
| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Token | Takes a GitHub token, says what it needs (read-only Issues and Metadata on a fine-grained token, or a classic token with no scopes for public repositories and `repo` for private ones) and where it goes. The field is write-only; a saved token is never shown again. |
| Scope | Any number of scopes: `owner`, `owner/repo`, `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)
```
136 changes: 136 additions & 0 deletions packages/targets/graph-web/build/icons.ts
Original file line number Diff line number Diff line change
@@ -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 =>
`<g stroke="${colour}" stroke-width="5" stroke-linecap="round" fill="${colour}">` +
'<path d="M22 50 L50 26 L78 50 L50 74 Z" fill="none"/>' +
'<circle cx="22" cy="50" r="8"/><circle cx="50" cy="26" r="8"/>' +
'<circle cx="50" cy="74" r="8"/><circle cx="78" cy="50" r="8"/>' +
"</g>";

/**
* 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 =>
[
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${String(canvas)} ${String(canvas)}">`,
`<rect width="${String(canvas)}" height="${String(canvas)}" rx="${String(purpose === "any" ? cornerRadius : 0)}" fill="${colours.tile}"/>`,
glyph(colours.glyph),
"</svg>",
].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));
});
},
});
Loading