diff --git a/.gitattributes b/.gitattributes index e7c31c8..e6504ed 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,7 @@ # Normalize line endings: LF in the repo, auto-converted on checkout. * text=auto eol=lf -# Binary assets — never touch these. +# Binary assets - never touch these. *.png binary *.jpg binary *.ico binary diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 0c1020e..7034ceb 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to NetGraph -NetGraph is a vanilla-TypeScript single-page app — no framework. It's built with +NetGraph is a vanilla-TypeScript single-page app - no framework. It's built with [Vite](https://vitejs.dev/), styled with SCSS, and stores everything in the browser's `localStorage`; there's no backend. State lives in module-scope variables and the DOM is the renderer. @@ -13,7 +13,7 @@ see [README.md](../README.md). For the full architecture and conventions, see - [Node.js](https://nodejs.org/) 24 or newer (current LTS). -Node is only needed to **build or develop** NetGraph — not to run a built copy. +Node is only needed to **build or develop** NetGraph - not to run a built copy. The output is plain static files (and a single self-contained HTML file) that run in any modern browser. @@ -33,15 +33,15 @@ npm run dev # Vite dev server at http://localhost:5173 with hot reload ``` > If you commit a change that deletes or renames a module, restart `npm run dev` -> — Vite's HMR can't always reconcile a moved module graph and the page may go +> - Vite's HMR can't always reconcile a moved module graph and the page may go > blank until a fresh start. It's not a code bug; `tsc` and the tests stay green. ## Building ```sh npm run build # type-check, then build BOTH outputs -npm run build:web # multi-file build only → dist/ -npm run build:single # single-file build only → dist/download/netgraph.html +npm run build:web # multi-file build only -> dist/ +npm run build:single # single-file build only -> dist/download/netgraph.html npm run preview # serve the production build locally ``` @@ -49,14 +49,14 @@ There are two build outputs: - A **multi-file build** for hosting on a webserver (split JS/CSS for caching). - A **single self-contained `netgraph.html`** with JS, CSS, and the favicon all - inlined — one file to move around, run from disk, or put on a USB stick. + inlined - one file to move around, run from disk, or put on a USB stick. ``` dist/ -├── index.html ← multi-file build (serve this from a webserver) -├── assets/ ← JS, CSS, favicon -└── download/ - └── netgraph.html ← single-file standalone build +├-- index.html <- multi-file build (serve this from a webserver) +├-- assets/ <- JS, CSS, favicon +└-- download/ + └-- netgraph.html <- single-file standalone build ``` ## Tests & quality bar @@ -68,7 +68,7 @@ npx tsc --noEmit # type-check ``` The bar for any change is a **clean `npx tsc --noEmit`** and **passing -`npm test`**. There's no separate linter — `tsconfig.json` runs with `strict`, +`npm test`**. There's no separate linter - `tsconfig.json` runs with `strict`, `noUnusedLocals`, `noUnusedParameters`, and `noFallthroughCasesInSwitch`, so the compiler is the linter. @@ -81,9 +81,9 @@ than unit-tested. ## Project layout -Single entry point at `src/ts/main.ts`. The detailed map — state flow, the +Single entry point at `src/ts/main.ts`. The detailed map - state flow, the render pipeline, connection routing, and the data-validation / SVG-sanitizer -security model — lives in [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md). Start there before changing +security model - lives in [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md). Start there before changing routing, persistence, or anything that ingests untrusted data (imports, custom icons). @@ -91,8 +91,8 @@ icons). The repo has two long-lived branches: -- `main` — stable releases -- `develop` — active development +- `main` - stable releases +- `develop` - active development Create your own branch for your work, then open a pull request into `develop` (or `main` for an urgent fix). Before you open it: diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7fd73b0..ccbe3b0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -6,12 +6,12 @@ body: attributes: value: | Thanks for taking the time to file a bug. Please don't report security - issues here — see the [security policy](../blob/main/.github/SECURITY.md) + issues here - see the [security policy](../blob/main/.github/SECURITY.md) for private reporting. NetGraph stores everything in your browser's `localStorage`. If the app - won't load at all, a corrupt saved state is a common cause — exporting a - bundle (map dropdown → Export) before you try anything destructive is a + won't load at all, a corrupt saved state is a common cause - exporting a + bundle (map dropdown -> Export) before you try anything destructive is a good idea. - type: textarea id: what-happened diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 7f5c639..9467cb0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -5,7 +5,7 @@ body: - type: markdown attributes: value: | - NetGraph aims to stay a small, local-first, browser-only network mapper — + NetGraph aims to stay a small, local-first, browser-only network mapper - no backend, no accounts, no telemetry, all data in `localStorage`. It's built for an enthusiast mapping a home or lab network, not a sysadmin managing a fleet. Proposals that fit that shape are easiest to land. @@ -36,6 +36,6 @@ body: attributes: label: Scope check options: - - label: This keeps NetGraph local-first — no backend, accounts, or telemetry. + - label: This keeps NetGraph local-first - no backend, accounts, or telemetry. - label: I searched existing issues and this isn't already requested. required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6115bdf..3adb672 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,5 @@ @@ -19,7 +19,7 @@ See .github/CONTRIBUTING.md for the ground rules. PRs target `develop`. - [ ] One focused change, with a short description of the why (above). - [ ] Targets the `develop` branch (or `main` for an urgent fix). - [ ] Type-check is clean: `npx tsc --noEmit`. -- [ ] Tests pass: `npm test` — and new behavior is covered by a test where practical. +- [ ] Tests pass: `npm test` - and new behavior is covered by a test where practical. - [ ] Both builds succeed: `npm run build`. - [ ] Added a note under `## [Unreleased]` in `CHANGELOG.md`. - [ ] If behavior changed, kept the docs in sync: `README.md`, `docs/ARCHITECTURE.md`, and any in-app help. diff --git a/.github/SECURITY.md b/.github/SECURITY.md index d9e2080..0dc223f 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,7 +1,7 @@ # Security Policy NetGraph runs entirely in the browser with no backend, and it ingests untrusted -data in two places — imported `.json` bundles and uploaded custom icons (including +data in two places - imported `.json` bundles and uploaded custom icons (including SVG). That ingest surface is where NetGraph's own correctness has security weight, so reports are taken seriously. @@ -21,7 +21,7 @@ ship in the next tagged release. Report privately via either: -- GitHub's [private vulnerability reporting][advisories] — the **"Report a +- GitHub's [private vulnerability reporting][advisories] - the **"Report a vulnerability"** button under the repository's *Security* tab (preferred), or - email to **braininblack@gmail.com** with `[NetGraph security]` in the subject. @@ -31,7 +31,7 @@ Please include: self-hosted, or offline single-file copy), - your browser and OS, - a description of the issue and its impact, and -- a minimal reproduction or proof of concept if you have one — for ingest bugs, +- a minimal reproduction or proof of concept if you have one - for ingest bugs, the smallest import bundle or SVG icon that triggers it. You can expect an acknowledgement within **5 business days**. Once the issue is @@ -40,11 +40,11 @@ credit you in the release notes unless you prefer to stay anonymous. ## Scope -In scope — vulnerabilities **in NetGraph itself**, for example: +In scope - vulnerabilities **in NetGraph itself**, for example: - stored or reflected XSS through imported bundles, custom icons, or any user-controlled field that reaches the DOM (device names, tags, notes, ids), -- a way to get markup or script past the SVG sanitizer (`svg-sanitizer.ts`) — +- a way to get markup or script past the SVG sanitizer (`svg-sanitizer.ts`) - including mutation-XSS that only becomes live after HTML re-parsing, - attribute-injection through ids or other values interpolated into `data-*` attributes, @@ -53,7 +53,7 @@ In scope — vulnerabilities **in NetGraph itself**, for example: Out of scope: -- a malicious bundle that only damages **your own** local data — importing +- a malicious bundle that only damages **your own** local data - importing replaces your maps by design; export a backup first, - weaknesses in the browser, the operating system, or a hosting webserver you run yourself, diff --git a/.gitignore b/.gitignore index a073439..d71d0ea 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ dist-ssr *.sln *.sw? -# AI agent tooling — keep each contributor's assistant config local so it never +# AI agent tooling - keep each contributor's assistant config local so it never # pollutes the repo or disrupts anyone else's setup. Add your own tool here if # it isn't already listed. .claude/ @@ -41,6 +41,8 @@ dist-ssr CLAUDE.md CLAUDE.local.md GEMINI.md +# Workspace ASCII checker config (tool lives in the git-ignored ../.claude/) +.asciiignore -# Personal deploy tooling — ties to a specific server, kept out of the repo. +# Personal deploy tooling - ties to a specific server, kept out of the repo. scripts/deploy.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e2b108..5981a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,36 +15,36 @@ describes the app as it stands at that release. ### Added -- **Network mapping** — place modems, gateways, switches, access points, +- **Network mapping** - place modems, gateways, switches, access points, servers, VMs, and client devices on a pannable, zoomable canvas, each with a name, type, IP, MAC, domain, tags, and notes. -- **Self-routing connections** — orthogonal right-angle paths with rounded +- **Self-routing connections** - orthogonal right-angle paths with rounded corners that route around other device cards, fall back to U-shapes for awkward angles, and snap straight when cards line up. Wired or wireless, with optional labels and a port at each end (a jack number or a named port like WAN, LAN, or PoE). Multiple links to one device fan out so they don't overlap. -- **Connect mode** — fast wiring with three sub-modes: **Hub** (fan out from one +- **Connect mode** - fast wiring with three sub-modes: **Hub** (fan out from one device), **Single** (one link at a time), and **Advanced** (the editor opens after each link). A live preview line follows the cursor and turns amber to block duplicate connections. -- **Select mode** — lasso a group of devices (live selection as the box sweeps), +- **Select mode** - lasso a group of devices (live selection as the box sweeps), drag the whole group together, and copy, paste, duplicate, or delete the selection. -- **VMs & containers** — mark a device as hosted on a parent so the nesting is +- **VMs & containers** - mark a device as hosted on a parent so the nesting is explicit on the map. -- **Custom icons** — use the built-in Lucide set or upload your own SVG, PNG, or +- **Custom icons** - use the built-in Lucide set or upload your own SVG, PNG, or JPG; icons are shared across every map. Uploaded SVGs pass through an allow-list sanitizer. -- **Multiple maps** — keep separate maps (blank or seeded from a worked example) +- **Multiple maps** - keep separate maps (blank or seeded from a worked example) and switch between them, rename, or delete. -- **Import / export** — save and load maps as plain JSON for backup and sharing, +- **Import / export** - save and load maps as plain JSON for backup and sharing, with full validation of every imported record. -- **Offline copy** — *Download Offline Copy* bakes the entire app into a single +- **Offline copy** - *Download Offline Copy* bakes the entire app into a single self-contained HTML file that runs from disk or a USB stick, online or air-gapped. -- **Local-first storage** — everything lives in the browser's `localStorage`; +- **Local-first storage** - everything lives in the browser's `localStorage`; no backend, no accounts, no telemetry. Data never leaves the machine. -- **Touch support** — drag to pan, pinch to zoom, and long-press for context +- **Touch support** - drag to pan, pinch to zoom, and long-press for context menus on tablets. [Unreleased]: https://github.com/BrainInBlack/NetGraph/compare/v1.3.0...HEAD diff --git a/README.md b/README.md index 9fb738d..22a6d69 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,29 @@ # NetGraph -Map your home or lab network in the browser — add your devices, draw how they're wired, label the bits that matter — and keep it all **local**: no backend, no accounts, no telemetry. +Map your home or lab network in the browser - add your devices, draw how they're wired, label the bits that matter - and keep it all **local**: no backend, no accounts, no telemetry. ![License: PolyForm NC 1.0.0](https://img.shields.io/badge/license-PolyForm%20NC%201.0.0-blue) ![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white) ![Vite](https://img.shields.io/badge/built%20with-Vite-646cff?logo=vite&logoColor=white) ![Local-first](https://img.shields.io/badge/data-100%25%20local-brightgreen) ![Offline](https://img.shields.io/badge/offline-single--file%20copy-informational) [![CI](https://github.com/BrainInBlack/NetGraph/actions/workflows/ci.yml/badge.svg)](https://github.com/BrainInBlack/NetGraph/actions/workflows/ci.yml) ## Why -You want a picture of your home or lab network — what's plugged into what, which port, which VLAN — without standing up a server, making an account, or trusting a SaaS with your topology. NetGraph is a single-page app that runs entirely in your browser: you draw the map by hand, it routes the cables for you, and everything is saved to `localStorage` on your machine. Nothing is transmitted, ever. When you want it portable, one menu click bakes the whole app into a single self-contained HTML file you can run from a USB stick or an air-gapped box. +You want a picture of your home or lab network - what's plugged into what, which port, which VLAN - without standing up a server, making an account, or trusting a SaaS with your topology. NetGraph is a single-page app that runs entirely in your browser: you draw the map by hand, it routes the cables for you, and everything is saved to `localStorage` on your machine. Nothing is transmitted, ever. When you want it portable, one menu click bakes the whole app into a single self-contained HTML file you can run from a USB stick or an air-gapped box. ## Features -- **Every device type** — modems, gateways, switches, access points, servers, VMs, and client machines, each with its own icon. -- **Self-routing connections** — clean orthogonal right-angle paths that bend around other cards and snap straight when devices line up. Wired or wireless, with optional labels for port numbers, VLAN tags, or link speeds. -- **Connect mode** — wire up fast: **Hub** fan-out from one device, **Single** one-link-at-a-time, or **Advanced** (the editor opens after each link). A live preview line follows the cursor and turns amber to block duplicates. -- **Multi-select** — lasso a group, then copy, paste, duplicate, or delete it; drag any member to move the whole set together. -- **VMs & containers** — mark a device as hosted on a parent, so the nesting is explicit. -- **Custom icons** — the built-in [Lucide](https://lucide.dev/) set or your own SVG / PNG / JPG uploads, shared across every map. -- **Multiple maps** — keep home, the lab, and a friend's network side by side and switch in a click. -- **Import / export** — plain JSON, for backups and sharing. -- **Offline copy** — the *Download Offline Copy* menu saves the entire app as one standalone HTML file. -- **Local-first by design** — no backend, no accounts, no telemetry; your data never leaves the browser. +- **Every device type** - modems, gateways, switches, access points, servers, VMs, and client machines, each with its own icon. +- **Self-routing connections** - clean orthogonal right-angle paths that bend around other cards and snap straight when devices line up. Wired or wireless, with optional labels for port numbers, VLAN tags, or link speeds. +- **Connect mode** - wire up fast: **Hub** fan-out from one device, **Single** one-link-at-a-time, or **Advanced** (the editor opens after each link). A live preview line follows the cursor and turns amber to block duplicates. +- **Multi-select** - lasso a group, then copy, paste, duplicate, or delete it; drag any member to move the whole set together. +- **VMs & containers** - mark a device as hosted on a parent, so the nesting is explicit. +- **Custom icons** - the built-in [Lucide](https://lucide.dev/) set or your own SVG / PNG / JPG uploads, shared across every map. +- **Multiple maps** - keep home, the lab, and a friend's network side by side and switch in a click. +- **Import / export** - plain JSON, for backups and sharing. +- **Offline copy** - the *Download Offline Copy* menu saves the entire app as one standalone HTML file. +- **Local-first by design** - no backend, no accounts, no telemetry; your data never leaves the browser. ## Browser support -Any modern desktop browser (Chrome, Firefox, Safari, Edge). Tablets are supported — drag to pan, pinch to zoom, long-press for context menus. Phones aren't a target: the panels and dialogs assume a tablet-or-larger screen. +Any modern desktop browser (Chrome, Firefox, Safari, Edge). Tablets are supported - drag to pan, pinch to zoom, long-press for context menus. Phones aren't a target: the panels and dialogs assume a tablet-or-larger screen. ## Get it @@ -33,13 +33,13 @@ Open the hosted build at **[netgraph.khemul.de](https://netgraph.khemul.de)** in ### Offline copy -Already running NetGraph? Open the top-right menu → **Download Offline Copy**. It saves the whole app — JS, CSS, and icons inlined — as a single `netgraph.html`. Double-click it from your file manager or run it off a USB stick; it works online or fully air-gapped, and its data stays local to whatever browser opens it. +Already running NetGraph? Open the top-right menu -> **Download Offline Copy**. It saves the whole app - JS, CSS, and icons inlined - as a single `netgraph.html`. Double-click it from your file manager or run it off a USB stick; it works online or fully air-gapped, and its data stays local to whatever browser opens it. ## Usage -1. **Add a device.** Right-click the empty canvas (long-press on a tablet) → *Add Device*. Name and type are all you need; IP, MAC, domain, tags, and notes are optional. -2. **Connect two devices.** Right-click a device → *Add Connection* → pick the target. For wiring lots of links at once, use **connect mode** (button bottom-right, left of the zoom bar) and pick a sub-mode in the top-center pill. -3. **Label a connection.** Right-click a line → *Edit* to set its type (wired/wireless), a label, and the port at each end — a jack number or a named port like WAN, LAN, or PoE. Labels reveal on hover. +1. **Add a device.** Right-click the empty canvas (long-press on a tablet) -> *Add Device*. Name and type are all you need; IP, MAC, domain, tags, and notes are optional. +2. **Connect two devices.** Right-click a device -> *Add Connection* -> pick the target. For wiring lots of links at once, use **connect mode** (button bottom-right, left of the zoom bar) and pick a sub-mode in the top-center pill. +3. **Label a connection.** Right-click a line -> *Edit* to set its type (wired/wireless), a label, and the port at each end - a jack number or a named port like WAN, LAN, or PoE. Labels reveal on hover. 4. **Select a group.** The lasso button (bottom-right) enters *select mode*: box-select on empty canvas or tap devices to toggle, then *Copy* / *Duplicate* / *Delete* from the right-click menu. *Paste* anchors at the cursor. 5. **Move around.** Drag devices anywhere, pan by dragging empty space, zoom with the wheel / pinch / zoom bar. Click the percentage to fit everything in view. 6. **Manage maps & icons.** The top-right menu holds *New Map*, the map switcher, *Manage Icons*, *Import*, *Export*, and *Download Offline Copy*. @@ -48,18 +48,18 @@ Your map saves automatically as you go. The *?* button (bottom-left) opens a one ## Your data -NetGraph keeps everything in your browser's local storage, on this device. There's no server and no account — which is the point — but it also means: +NetGraph keeps everything in your browser's local storage, on this device. There's no server and no account - which is the point - but it also means: - Your maps are tied to **this browser**. Clearing site data, or opening NetGraph elsewhere, won't carry them over. - **Export is your backup.** Use *Export* before big changes, and to move a map to another machine. -- The offline copy is a full standalone snapshot — handy for a USB stick or an air-gapped machine. +- The offline copy is a full standalone snapshot - handy for a USB stick or an air-gapped machine. ## Security -NetGraph ingests untrusted data in exactly two places — imported JSON bundles and uploaded custom icons (including SVG). Both go through a single validation layer (`parse-shapes.ts`) and an allow-list SVG sanitizer hardened against mutation-XSS; nothing user-supplied reaches the DOM unsanitized. Found a hole? See the [security policy](.github/SECURITY.md) for private reporting — please don't open a public issue. +NetGraph ingests untrusted data in exactly two places - imported JSON bundles and uploaded custom icons (including SVG). Both go through a single validation layer (`parse-shapes.ts`) and an allow-list SVG sanitizer hardened against mutation-XSS; nothing user-supplied reaches the DOM unsanitized. Found a hole? See the [security policy](.github/SECURITY.md) for private reporting - please don't open a public issue. ## License -[PolyForm Noncommercial 1.0.0](LICENSE) © BrainInBlack — free for noncommercial use. +[PolyForm Noncommercial 1.0.0](LICENSE) (c) BrainInBlack - free for noncommercial use. Want to build NetGraph yourself or contribute? See [CONTRIBUTING.md](.github/CONTRIBUTING.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a2676b0..b938e86 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,7 +6,7 @@ anything that ingests untrusted data. ## What this is -A browser-based local network visualizer. Users manually map a home/lab network by adding devices and connections. All data lives in `localStorage` — no backend, no accounts, no telemetry. Target user: enthusiast, not a sysadmin. +A browser-based local network visualizer. Users manually map a home/lab network by adding devices and connections. All data lives in `localStorage` - no backend, no accounts, no telemetry. Target user: enthusiast, not a sysadmin. ## Commands @@ -14,17 +14,17 @@ A browser-based local network visualizer. Users manually map a home/lab network npm install # one-time npm run dev # Vite dev server on :5173 with HMR npm run build # tsc + both builds: dist/ (multi-file) + dist/download/netgraph.html (single-file) -npm run build:web # multi-file build only → dist/ -npm run build:single # single-file build only → dist/download/netgraph.html +npm run build:web # multi-file build only -> dist/ +npm run build:single # single-file build only -> dist/download/netgraph.html npm run preview # serve the production build -npm test # vitest run — single pass +npm test # vitest run - single pass npm run test:watch # vitest in watch mode ``` The build output is plain static files: deploy by serving `dist/` from any webserver, or hand someone the single-file `dist/download/netgraph.html`. -Tests run on **vitest** with the **happy-dom** environment (`vitest.config.ts`); test files are `src/**/*.test.ts`. Coverage focuses on pure / pure-ish modules — `parse-shapes`, `svg-sanitizer`, `path-geometry`, `collision`, `route`, `import-export`, `storage`, `graph/layout`, `ui/clipboard`, and the `util` helpers. DOM/pointer-heavy code (`renderer`, `zoom`, `connect-mode`, `select-mode`, `ui/*` modals) is not unit-tested. There's no linter beyond `tsc` — `tsconfig.json` runs with `strict`, `noUnusedLocals`, `noUnusedParameters`, and `noFallthroughCasesInSwitch`, so a clean `npx tsc --noEmit` plus passing `npm test` is the bar. +Tests run on **vitest** with the **happy-dom** environment (`vitest.config.ts`); test files are `src/**/*.test.ts`. Coverage focuses on pure / pure-ish modules - `parse-shapes`, `svg-sanitizer`, `path-geometry`, `collision`, `route`, `import-export`, `storage`, `graph/layout`, `ui/clipboard`, and the `util` helpers. DOM/pointer-heavy code (`renderer`, `zoom`, `connect-mode`, `select-mode`, `ui/*` modals) is not unit-tested. There's no linter beyond `tsc` - `tsconfig.json` runs with `strict`, `noUnusedLocals`, `noUnusedParameters`, and `noFallthroughCasesInSwitch`, so a clean `npx tsc --noEmit` plus passing `npm test` is the bar. Vite needs `lucide-static` and `sass` as peer deps; both are pinned in `package.json`. @@ -37,94 +37,94 @@ Vanilla TypeScript SPA, single entry at `src/ts/main.ts`. No framework. State li `src/ts/state.ts` is the single source of truth. ``` -user action → caller mutates state.maps/devices/links → setState(state) +user action -> caller mutates state.maps/devices/links -> setState(state) ↓ saveState() (localStorage) ↓ - scheduleRender() — rAF-coalesced + scheduleRender() - rAF-coalesced ↓ - render() → renderAll(map, sel) + render() -> renderAll(map, sel) ↓ hooks.onAfterRender() ``` -Callers follow a deliberately simple mutate-then-`setState` pattern: `getState()` returns the live reference, the caller mutates it directly (`state.maps.push(...)`), then calls `setState(state)` to trigger save + render. This is concise but means *the state has already changed by the time `setState` runs* — real rollback on quota errors isn't possible without a much bigger refactor. +Callers follow a deliberately simple mutate-then-`setState` pattern: `getState()` returns the live reference, the caller mutates it directly (`state.maps.push(...)`), then calls `setState(state)` to trigger save + render. This is concise but means *the state has already changed by the time `setState` runs* - real rollback on quota errors isn't possible without a much bigger refactor. -**Render coalescing.** `setState` and `setSelectedDeviceId` don't render synchronously — they call `scheduleRender()`, which dedupes every request in the same tick into one `requestAnimationFrame` callback. So a single user action that mutates state *and* changes selection renders once, not twice, and a drag's multiple `pointermove`s per frame collapse to one `renderAll`. The bare `render()` export stays synchronous for the init path, which reads laid-out geometry (`centerContent`) immediately after rendering and can't wait for a frame. +**Render coalescing.** `setState` and `setSelectedDeviceId` don't render synchronously - they call `scheduleRender()`, which dedupes every request in the same tick into one `requestAnimationFrame` callback. So a single user action that mutates state *and* changes selection renders once, not twice, and a drag's multiple `pointermove`s per frame collapse to one `renderAll`. The bare `render()` export stays synchronous for the init path, which reads laid-out geometry (`centerContent`) immediately after rendering and can't wait for a frame. **Hooks (`state.setHooks`)** are how the renderer talks back to the UI without creating a circular import. `main.ts` wires `onDeviceClick`, `onDeviceContextMenu`, and `onAfterRender` at init. The renderer fires `onDeviceClick(id)` from a card click; main.ts's hook implementation opens the panel. Anything that wants to react to *every* re-render (panel refresh after edits, etc.) lives in `onAfterRender`. ### Render pipeline (`src/ts/graph/renderer/`) -The renderer is split into `index.ts` (`renderAll` — the only export), `devices.ts` (device cards + drag), `connections.ts` (connection groups + SVG), and `geom.ts` (`DeviceGeom`, `readDeviceGeom`, endpoint fan-out math). +The renderer is split into `index.ts` (`renderAll` - the only export), `devices.ts` (device cards + drag), `connections.ts` (connection groups + SVG), and `geom.ts` (`DeviceGeom`, `readDeviceGeom`, endpoint fan-out math). -Runs on every `setState` — and during device drags, at every `pointermove` (~60 fps, coalesced to one render per frame by `scheduleRender`). Three optimizations make this viable: +Runs on every `setState` - and during device drags, at every `pointermove` (~60 fps, coalesced to one render per frame by `scheduleRender`). Three optimizations make this viable: -1. **Device diff/reuse**: existing `.device` DOM elements are indexed by id in a single `querySelectorAll` pass (a `Map` reused for both the removal sweep and per-device lookup — replacing the old per-device `querySelector` that made the whole render O(n²)). Elements are reused across renders; only `style.left/top` updates unconditionally. The drag handler also captures the dragged device's element once at `pointerdown` rather than re-finding it every frame. -2. **Content hash**: `deviceContentHash(device, map)` covers every field that affects the card's `innerHTML` (excluding position). Stored on `dataset.contentHash`. If the hash matches the last render's, `innerHTML` rewrite is skipped — so a drag (which only mutates x/y) does zero innerHTML work per frame. +1. **Device diff/reuse**: existing `.device` DOM elements are indexed by id in a single `querySelectorAll` pass (a `Map` reused for both the removal sweep and per-device lookup - replacing the old per-device `querySelector` that made the whole render O(n²)). Elements are reused across renders; only `style.left/top` updates unconditionally. The drag handler also captures the dragged device's element once at `pointerdown` rather than re-finding it every frame. +2. **Content hash**: `deviceContentHash(device, map)` covers every field that affects the card's `innerHTML` (excluding position). Stored on `dataset.contentHash`. If the hash matches the last render's, `innerHTML` rewrite is skipped - so a drag (which only mutates x/y) does zero innerHTML work per frame. 3. **Connection diff/reuse**: SVG `` elements are created once per link and only their `d` / `x` / `y` attributes update per render. Listeners attached at creation persist. Per-render `geomCache` makes device-geometry reads (`readDeviceGeom`) one-DOM-read-per-device-per-frame instead of one-per-link. -The detail panel (`src/ts/ui/sidebar.ts openPanel`) uses the same hash trick (`panelContentHash`) so dragging a selected device doesn't churn the side panel innerHTML 60×/sec. +The detail panel (`src/ts/ui/sidebar.ts openPanel`) uses the same hash trick (`panelContentHash`) so dragging a selected device doesn't churn the side panel innerHTML 60x/sec. ### Connection routing Path geometry is split across three pure modules plus the renderer: -- **`src/ts/graph/path-geometry.ts`** — `pathSegments()` returns a connection's axis-aligned skeleton: Z (3 segments) when both attach sides share an axis, L (2) for mixed axes, straight (1) when the perpendicular distance is below `MIN_PERPENDICULAR` (16 px). `segmentsToPath()` serializes that skeleton to an SVG `d` string with rounded Q corners. `labelAnchor()` computes label position for any segment count (straight, L, Z, or detour), centered on the *visible* span between the cards — it takes `startInset`/`endInset` (the path length hidden under each endpoint card) so the label sits in the middle of the gap, not pulled toward the larger node. `axisOf()` / `signOf()` are the shared side→axis and side→direction helpers. -- **`src/ts/graph/collision.ts`** — `segmentIntersectsRect`, `pathCollidesWith`, and `pathCollidesExcluding` (the last skips two endpoint rects by reference, so a path may legitimately start/end inside its own cards). Pure axis-aligned-geometry helpers. -- **`src/ts/graph/route.ts`** — `routeConnection()` wraps `pathSegments` with collision avoidance *and* face-away geometry handling. Order of operations: face-away short-circuit (`isFaceAway` → `uShapeFaceAway`) → default path → bend-nudge → multi-segment detour (Z or L variant). Pure function; it takes `allRects` (every device rect, built once per render and shared across all links) and skips the two endpoint rects by reference — the per-link obstacle list is materialized only when needed. Returns `{ segs, collides }` so the renderer flags residual collisions without re-scanning. Exit/entry points are pushed past overlapping obstacles via `pushPast`. -- **`renderer/connections.ts updateConnectionGroup`** — picks attach sides + bend position, then draws via `routeConnection(...)` → `segmentsToPath()`. +- **`src/ts/graph/path-geometry.ts`** - `pathSegments()` returns a connection's axis-aligned skeleton: Z (3 segments) when both attach sides share an axis, L (2) for mixed axes, straight (1) when the perpendicular distance is below `MIN_PERPENDICULAR` (16 px). `segmentsToPath()` serializes that skeleton to an SVG `d` string with rounded Q corners. `labelAnchor()` computes label position for any segment count (straight, L, Z, or detour), centered on the *visible* span between the cards - it takes `startInset`/`endInset` (the path length hidden under each endpoint card) so the label sits in the middle of the gap, not pulled toward the larger node. `axisOf()` / `signOf()` are the shared side->axis and side->direction helpers. +- **`src/ts/graph/collision.ts`** - `segmentIntersectsRect`, `pathCollidesWith`, and `pathCollidesExcluding` (the last skips two endpoint rects by reference, so a path may legitimately start/end inside its own cards). Pure axis-aligned-geometry helpers. +- **`src/ts/graph/route.ts`** - `routeConnection()` wraps `pathSegments` with collision avoidance *and* face-away geometry handling. Order of operations: face-away short-circuit (`isFaceAway` -> `uShapeFaceAway`) -> default path -> bend-nudge -> multi-segment detour (Z or L variant). Pure function; it takes `allRects` (every device rect, built once per render and shared across all links) and skips the two endpoint rects by reference - the per-link obstacle list is materialized only when needed. Returns `{ segs, collides }` so the renderer flags residual collisions without re-scanning. Exit/entry points are pushed past overlapping obstacles via `pushPast`. +- **`renderer/connections.ts updateConnectionGroup`** - picks attach sides + bend position, then draws via `routeConnection(...)` -> `segmentsToPath()`. Key behaviors: - **Endpoints stay at card centers** so the line visually attaches at the perimeter (cards cover the line ends). -- **Bend position** is computed from card *edges* (`center ± halfSize`), not centers, so the bend sits in the gap between cards even when card heights differ. If card edges cross on the projection axis (the face-away case), the renderer falls back to the midpoint of centers — but `route.ts` detects the same condition and routes a U-shape, so this fallback is effectively dead code that never reaches the user. -- **Straight-line fallback**: below `MIN_PERPENDICULAR` (16 px) perpendicular distance, the path collapses to a single straight horizontal/vertical segment — no tiny S-curve. -- **Lined-up straightening** (`renderer/connections.ts updateConnectionGroup`): for a *facing* same-axis connection, if the two cards overlap on the perpendicular axis — inset by `MIN_PERPENDICULAR` so the line lands on the flat part of each edge, not a rounded corner — both endpoints snap to the shared overlap midpoint, so a visually lined-up pair draws a straight segment instead of a small jog. The tolerated misalignment scales with card size (a tall card stays straight across a wider offset than a short one). Skipped when fan-out has spread the side's links along the perpendicular axis. +- **Bend position** is computed from card *edges* (`center +/- halfSize`), not centers, so the bend sits in the gap between cards even when card heights differ. If card edges cross on the projection axis (the face-away case), the renderer falls back to the midpoint of centers - but `route.ts` detects the same condition and routes a U-shape, so this fallback is effectively dead code that never reaches the user. +- **Straight-line fallback**: below `MIN_PERPENDICULAR` (16 px) perpendicular distance, the path collapses to a single straight horizontal/vertical segment - no tiny S-curve. +- **Lined-up straightening** (`renderer/connections.ts updateConnectionGroup`): for a *facing* same-axis connection, if the two cards overlap on the perpendicular axis - inset by `MIN_PERPENDICULAR` so the line lands on the flat part of each edge, not a rounded corner - both endpoints snap to the shared overlap midpoint, so a visually lined-up pair draws a straight segment instead of a small jog. The tolerated misalignment scales with card size (a tall card stays straight across a wider offset than a short one). Skipped when fan-out has spread the side's links along the perpendicular axis. - **Endpoint fan-out** (`computeEndpointOffsets`): when multiple connections meet at the same card side, they spread along the perpendicular axis with `ENDPOINT_GAP` (6 px) so they don't overlap. - **Connection labels** are positioned by `labelAnchor()` at the middle of the *visible* span (see above) and rendered with a feathered SVG filter (`` + `` + ``) for readability over any background. They're hover-revealed (`opacity: 0` until `.conn-group:hover`/`.active`). -- **Collision avoidance** (`route.ts`): connections actively route around non-endpoint device cards. Z-shapes try bend-nudge (shift bend to obstacle edges, closest-first) then 5-segment detour (exit past obstacles, jog perpendicular, cross, jog back, enter). L-shapes try nudge-to-3-segment (promote L into a Z-like path at obstacle edges along both axes) then 4-segment detour (exit past source obstacles, jog perpendicular, cross to target column/row, enter). Inner segments are validated collision-free; first/last segments under endpoint cards are tolerated (cards physically overlapping is a layout issue, not a routing failure). The `.conn-collision` flag (amber dashed line) is driven by the `collides` field `routeConnection` returns — set for any residual collision the router can't fully resolve. +- **Collision avoidance** (`route.ts`): connections actively route around non-endpoint device cards. Z-shapes try bend-nudge (shift bend to obstacle edges, closest-first) then 5-segment detour (exit past obstacles, jog perpendicular, cross, jog back, enter). L-shapes try nudge-to-3-segment (promote L into a Z-like path at obstacle edges along both axes) then 4-segment detour (exit past source obstacles, jog perpendicular, cross to target column/row, enter). Inner segments are validated collision-free; first/last segments under endpoint cards are tolerated (cards physically overlapping is a layout issue, not a routing failure). The `.conn-collision` flag (amber dashed line) is driven by the `collides` field `routeConnection` returns - set for any residual collision the router can't fully resolve. - **Face-away routing** (`route.ts uShapeFaceAway`): when `sourceSide` and `targetSide` share an axis and point in directions that would put the Z-shape bend *inside* the cards (`isFaceAway`), `routeConnection` short-circuits to a 5-segment U-shape that exits past each card's chosen edge, jogs perpendicular (above the higher top or below the lower bottom of both endpoint rects, plus any obstacle-edge candidates), and re-enters from the other side. This is what makes manually-picked sides like source='left' + target='right' (with target to the right of source) render correctly instead of bending through the cards. ### Data validation & persistence `src/ts/parse-shapes.ts` is the single source of truth for "what is a valid Device/Link/Map/CustomIcon". It's called from two places: -- `storage.ts migrate()` — when loading from `localStorage` on startup -- `import-export.ts parseImport()` — when the user imports a JSON file +- `storage.ts migrate()` - when loading from `localStorage` on startup +- `import-export.ts parseImport()` - when the user imports a JSON file Per-field caps are defined once (name, ip, mac, tags, etc.); SVG custom icons always go through `svg-sanitizer.ts`. There's no path where untrusted SVG content reaches the DOM without sanitization. -**ID validation (`validateId`).** Every id read from an untrusted bundle (device, link `id`/`sourceId`/`targetId`, map, custom-icon) is run through `validateId`, which enforces `ID_PATTERN` (`/^[a-zA-Z0-9_\-:]{1,128}$/`) and returns `null` on a miss — rejecting the record at ingest. This closes an attribute-injection XSS: ids are interpolated into `data-*` attributes, so a crafted id could otherwise break out of the attribute. `escapeHtml(...)` is also applied at every id sink (sidebar, toolbar, icon-manager, icon-picker, modals) as defense-in-depth, so neither layer is load-bearing alone. +**ID validation (`validateId`).** Every id read from an untrusted bundle (device, link `id`/`sourceId`/`targetId`, map, custom-icon) is run through `validateId`, which enforces `ID_PATTERN` (`/^[a-zA-Z0-9_\-:]{1,128}$/`) and returns `null` on a miss - rejecting the record at ingest. This closes an attribute-injection XSS: ids are interpolated into `data-*` attributes, so a crafted id could otherwise break out of the attribute. `escapeHtml(...)` is also applied at every id sink (sidebar, toolbar, icon-manager, icon-picker, modals) as defense-in-depth, so neither layer is load-bearing alone. -`svg-sanitizer.ts` is allow-list based: only specific SVG tags pass through, attributes are validated per-tag, `url(...)` references in paint/style attributes must be `#fragment` only (no `http://`, `data:`, `javascript:`). The url-value check also rejects CSS escapes (a backslash — `\75rl(` reconstructs `url(`) and bare-url CSS functions (`image-set`, `image`, `cross-fade`, `element`), which slip past a literal-`url(` scan. +`svg-sanitizer.ts` is allow-list based: only specific SVG tags pass through, attributes are validated per-tag, `url(...)` references in paint/style attributes must be `#fragment` only (no `http://`, `data:`, `javascript:`). The url-value check also rejects CSS escapes (a backslash - `\75rl(` reconstructs `url(`) and bare-url CSS functions (`image-set`, `image`, `cross-fade`, `element`), which slip past a literal-`url(` scan. -**mXSS defense — the walk must visit *all* child nodes, not just elements.** We parse as `image/svg+xml` (XML) but the app reinserts the result via `innerHTML` (HTML). A CDATA section or comment is inert in XML, survives `outerHTML` serialization intact, and on HTML re-parse can break out into live markup (e.g. `<![CDATA[]]>` yields a live `` in Chromium). So the sanitizer walks `childNodes` and drops everything that isn't an allow-listed element or a plain text node (text is safe — serialization escapes it). Never narrow this back to `.children`. +**mXSS defense - the walk must visit *all* child nodes, not just elements.** We parse as `image/svg+xml` (XML) but the app reinserts the result via `innerHTML` (HTML). A CDATA section or comment is inert in XML, survives `outerHTML` serialization intact, and on HTML re-parse can break out into live markup (e.g. `<![CDATA[]]>` yields a live `` in Chromium). So the sanitizer walks `childNodes` and drops everything that isn't an allow-listed element or a plain text node (text is safe - serialization escapes it). Never narrow this back to `.children`. -`sanitizeSvg` also enforces `MAX_SVG_LENGTH` (64 KB) on the source string and returns `null` if exceeded — the single size chokepoint for both ingest paths. Raster uploads have a separate, larger pre-downscale cap in `icon-upload.ts` (`IMAGE_MAX_BYTES`, 256 KB). `icon-upload.ts` is the shared module that the icon picker and the Manage Icons modal both consume — `readFileAsIcon` parses + sanitizes + downscales in one place so both flows produce identical `CustomIcon`s. +`sanitizeSvg` also enforces `MAX_SVG_LENGTH` (64 KB) on the source string and returns `null` if exceeded - the single size chokepoint for both ingest paths. Raster uploads have a separate, larger pre-downscale cap in `icon-upload.ts` (`IMAGE_MAX_BYTES`, 256 KB). `icon-upload.ts` is the shared module that the icon picker and the Manage Icons modal both consume - `readFileAsIcon` parses + sanitizes + downscales in one place so both flows produce identical `CustomIcon`s. ### Modals & overlays Two flavors: - **Single primary modal**: `#modal-overlay` in `index.html`. Used by `showModal()` in `modals/shared.ts` for one-at-a-time dialogs (Edit Device, Delete Confirm, New Map, etc.). Each dialog lives in its own file under `src/ts/ui/modals/`; `modals/index.ts` wires the `netgraph:*` events to them. -- **Stacked overlays**: created dynamically via `ensureStackedOverlay(id, zIndex)` in `util.ts`. Used by the icon picker, icon manager, and import flow — they need to sit *on top* of an existing modal without clobbering it. +- **Stacked overlays**: created dynamically via `ensureStackedOverlay(id, zIndex)` in `util.ts`. Used by the icon picker, icon manager, and import flow - they need to sit *on top* of an existing modal without clobbering it. Both pull `body.modal-active` via `pushModalLock` / `popModalLock`, which use a counter so nested modals work correctly. Z-index values are coordinated between TS (`Z_*` constants in `util.ts`) and SCSS (`$z-*` in `_variables.scss`). All modals are focus-trapped via `trapFocus(container)` and have `role="dialog"` + `aria-modal="true"`. -**Backdrop dismiss with selection-overshoot guard** (`bindOverlayDismiss` in `util.ts`). Every overlay (`#modal-overlay`, icon-picker, icon-manager) wires its click-to-dismiss through this helper. It tracks the `pointerdown` target and only dismisses if both the pointerdown *and* the click landed on the overlay itself — otherwise a text-selection drag that started inside the dialog and ended outside it (release on the gap around the modal) would trigger a click whose `target` is the overlay (the common ancestor of mousedown + mouseup) and close the dialog. Always use this helper when adding a new overlay; do not hand-roll the `e.target === overlay` check. +**Backdrop dismiss with selection-overshoot guard** (`bindOverlayDismiss` in `util.ts`). Every overlay (`#modal-overlay`, icon-picker, icon-manager) wires its click-to-dismiss through this helper. It tracks the `pointerdown` target and only dismisses if both the pointerdown *and* the click landed on the overlay itself - otherwise a text-selection drag that started inside the dialog and ended outside it (release on the gap around the modal) would trigger a click whose `target` is the overlay (the common ancestor of mousedown + mouseup) and close the dialog. Always use this helper when adding a new overlay; do not hand-roll the `e.target === overlay` check. -**ARIA radiogroup keyboard nav** (`bindRadioGroupKeys` in `util.ts`). Segmented controls in the device editor (card width) and connection editor (link type) are `role="radiogroup"` with ` - - + + + - + - +
- +
- @@ -88,7 +88,7 @@
- +