A client-side graph tool for mapping relationships between dependent systems — built around GitHub orgs, repos, issues and Projects (v2), though the graph is generic enough to hold anything.
Stack: Vite 8 + React 19 (React Compiler), TypeScript 6 strict, Zod 4, Mantine 9
(via its native vanilla-extract integration), @xyflow/react, Dexie, zustand,
lz-string. Installable offline-capable PWA. There is no backend — a graph is
captured entirely by its URL, so sharing a view is copying the address bar.
Requires Node 22+ and pnpm.
pnpm install
pnpm devA GitHub Personal Access Token is needed for GitHub integration. It is stored
only in this browser's IndexedDB and used solely in the Authorization header
to api.github.com — never in the URL, the graph document, an export, or a
log. A classic token needs repo, read:org, read:project; a fine-grained
token needs the org's Projects read permission. Projects (v2) are GraphQL-only,
which is why a token is required even for public data.
pnpm build # tsc --noEmit && vite build (the CI gate)
pnpm typecheck # tsc --noEmit
pnpm lint # eslint . --max-warnings 0
pnpm test # vitest run (full suite)
pnpm test:watch- Single test:
pnpm exec vitest run <path>(e.g.src/domain) or by namepnpm exec vitest run -t "merge". Vitest runs two projects — anodeproject for pure logic and ajsdomproject forsrc/uiand*.smoke.test.ts; both are picked up automatically by path. - Format: no Prettier/Biome.
eslint --fixis the formatter and runs on staged*.{ts,tsx}via lint-staged at commit time. - Husky hooks: pre-commit → lint-staged; commit-msg → commitlint
(conventional commits); pre-push →
pnpm test. CI releases withHUSKY=0.
The code is layered so the pure, testable logic has no dependency on React, the network, or storage. Each layer depends only on the one(s) beneath it.
src/schema— Zod schemas: the single source of truth for the graph document, nodes, edges, and stored entities.src/domain— pure graph logic: an operation reducer, identity keys for deduplication, delta merge, and layout. No React, no IO, no imports fromsharing/storage/ui.src/sharing— the versioned, compressed URL codec (#g=) and JSON import/export.src/storage— async storage contracts (graphs + secrets) and their Dexie adapters; every read is re-validated through Zod at the boundary.src/github— the GraphQL client (pagination, rate-limit reporting, typed errors), materialisers, and the node-expansion registry.src/ui— the zustand store (wraps the domain reducer), the React Flow canvas, and the panels.
The GraphDocument is the unit that round-trips identically through the URL,
IndexedDB, and JSON export. Schemas are the contract across every boundary —
types are inferred (z.infer), never mirrored or asserted. View state
(selection, viewport) is ephemeral and never enters the document or URL.
These are enforced by config or were established during the build; violating them fails the gate or the review.
- Strict TypeScript.
strict,noUncheckedIndexedAccess,exactOptionalPropertyTypes,verbatimModuleSyntax. Narrow index access with an explicitif (x === undefined)check — never?? default. - No type assertions.
@typescript-eslint/consistent-type-assertionswithassertionStyle: "never"bansasand angle-bracket casts (eslint error). Narrow with a type guard or parse with Zod. Noany(useunknown). - Zod is the single source of truth.
export const X = z.object(...);export type X = z.infer<typeof X>. Derive types, don't hand-write them. - No defensive fallbacks. No
?? ""/?? []masking real absences, no try/catch that swallows an error into a default. Model absence explicitly asT | undefinedand handle it at the right layer. - Styling via the Mantine vanilla-extract integration. Write colocated
*.css.tsfiles that importvarsfrom@/ui/theme/vars(themeToVars). No inline colour literals, no CSS modules. Drive React Flow'scolorModefrom the Mantine scheme or its controls/minimap won't theme. - Tests are colocated as
*.unit.test.tsnext to the code they cover. - British English in all comments, docs, and commit messages. Straight quotes and plain hyphens in identifiers and paths.
- React Flow needs a definite canvas height. Its root has a forced inline
height: 100%that does not resolve against a flex-derived parent height — the canvas renders 0px tall and controls fly off-screen. The wrapper inAppShellusescalc(100dvh - HEADER_HEIGHT); don't switch it toheight: 100%. - The gate does not render React.
tsc/eslint/vitest/vite buildcompile and unit-test logic but never mount components, so runtime layout and mount-time bugs (a white-screen, a 0-height canvas, inspector deselection) slip through. Browser-verify after non-trivial UI work. - PWA service worker caches the previous deploy.
autoUpdateserves the last build on the first load after a deploy and activates the new one on the next reload — users see the update on their next visit. - Keep the repo public for free CI + deploy. Public repos get free
GitHub-hosted
ubuntu-latestrunners and free GitHub Pages. Switching to the self-hosted fleet breaks for public repos (org runner groups disallow public repos by default), and a private repo's Pages needs a paid plan. A visibility flip also disables Pages, which is whyconfigure-pagesruns withenablement: true.
Conventional commits (feat:, fix:, chore:, …), atomic per logical change,
enforced by commitlint. The husky hooks above gate every commit and push. CI
runs the same lint/typecheck/test, then semantic-release cuts a versioned
release and deploys to Pages on main.
Public repo → CI on ubuntu-latest → semantic-release → build → GitHub Pages
at https://exadev.github.io/graphle/. The Vite base is /graphle/ in CI and
/ in local dev (see vite.config.ts).
Manual graph editing, URL sharing, local persistence, and PAT-backed GitHub fetching with node expansion are done. Planned: user-defined node/edge categories with schema-driven appearance, computed properties, directed/ undirected and multi-edges, and loading graphs from remote URLs.