{"_id":"@amoorydxb/forcefield","_rev":"2-8bb1e31b7d977e25fee406c809701754","name":"@amoorydxb/forcefield","dist-tags":{"latest":"0.1.1"},"versions":{"0.1.0":{"name":"@amoorydxb/forcefield","version":"0.1.0","keywords":["graph","force-directed","barnes-hut","quadtree","network","visualization","canvas"],"license":"MIT","_id":"@amoorydxb/forcefield@0.1.0","maintainers":[{"name":"amoorydxb","email":"omarbahabusalem@gmail.com"}],"dist":{"shasum":"b748ee01c864a090abbe77ae83f0e37a2298577c","tarball":"https://registry.npmjs.org/@amoorydxb/forcefield/-/forcefield-0.1.0.tgz","fileCount":84,"integrity":"sha512-12UtxJLYPjLWqL+itCSnDi96Tn7FeuT9oaFnnvV/CNEJvPYePxR7DtTMR1SSzMxxnrEQcNoSCKcEw8Kjb5D+JQ==","signatures":[{"sig":"MEUCIQDVWa1sT3259UGRd5hquhKOWU2qCU8cWR0sc/X9hYbf8gIgKw/ngbgfgnv3mwC4jalXxg02jbKb25cuANj0sRr6vIA=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":272374},"type":"module","types":"./dist/src/index.d.ts","engines":{"node":">=20"},"exports":{".":"./dist/src/index.js","./core":"./dist/src/core/index.js","./render":"./dist/src/render/index.js","./themes":"./dist/themes/index.js"},"gitHead":"4fb841c1818edade1e56527cc848f55cd1a2fa91","scripts":{"test":"npm run build:test && node --test build-test/test/*.test.js","build":"tsc -p tsconfig.json","check":"tsc -p tsconfig.json --noEmit","clean":"rm -rf dist build-test","serve":"python3 -m http.server 8902","prepare":"npm run build","demo:data":"node examples/adapters/synthetic-vault.mjs --out .demo-vault --force && node examples/adapters/markdown-tree.mjs --vault .demo-vault --out examples/tree/demo.graph.json","build:test":"tsc -p tsconfig.test.json"},"_npmUser":{"name":"amoorydxb","email":"omarbahabusalem@gmail.com"},"_npmVersion":"11.17.0","description":"A dependency-free force-directed graph engine: Barnes-Hut many-body simulation, velocity-Verlet integration, drag/pin/filter/zoom, behind a swappable renderer interface.","directories":{},"sideEffects":["./dist/themes/index.js"],"_nodeVersion":"26.4.0","_hasShrinkwrap":false,"devDependencies":{"typescript":"^6.0.0"},"_npmOperationalInternal":{"tmp":"tmp/forcefield_0.1.0_1787613721031_0.07827519615555611","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@amoorydxb/forcefield","version":"0.1.1","description":"A dependency-free force-directed graph engine: Barnes-Hut many-body simulation, velocity-Verlet integration, drag/pin/filter/zoom, behind a swappable renderer interface.","type":"module","license":"MIT","exports":{".":"./dist/src/index.js","./core":"./dist/src/core/index.js","./render":"./dist/src/render/index.js","./themes":"./dist/themes/index.js"},"types":"./dist/src/index.d.ts","sideEffects":["./dist/themes/index.js"],"engines":{"node":">=20"},"scripts":{"build":"tsc -p tsconfig.json","build:test":"tsc -p tsconfig.test.json","test":"npm run build:test && node --test build-test/test/*.test.js","check":"tsc -p tsconfig.json --noEmit","serve":"python3 -m http.server 8902","demo:data":"node examples/adapters/synthetic-vault.mjs --out .demo-vault --force && node examples/adapters/markdown-tree.mjs --vault .demo-vault --out examples/tree/demo.graph.json","clean":"rm -rf dist build-test","prepare":"npm run build"},"keywords":["graph","force-directed","barnes-hut","quadtree","network","visualization","canvas"],"devDependencies":{"typescript":"^6.0.0"},"gitHead":"68704e2d253c677b617e212769dec691b4c74cd8","_id":"@amoorydxb/forcefield@0.1.1","_nodeVersion":"26.4.0","_npmVersion":"11.17.0","dist":{"integrity":"sha512-aavrUKnEthdfbHo9xglt0jFvyEIRf5ElMWUGbswBwTy6AXP6VWQzUCd8hZ7cfWHf9g8uuEF76rnRd6ZV6FEQWQ==","shasum":"cdc08e1cebadc032e844af1fe6daef9929c79701","tarball":"https://registry.npmjs.org/@amoorydxb/forcefield/-/forcefield-0.1.1.tgz","fileCount":84,"unpackedSize":273543,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIFly3XszF+tROcmDVv4Xbi1IV2wSAsLKiwm36V6ctB9/AiEAxVZjUAwLtPH4ciBbfxzQIABuLT/V7qaD71CyIJiW2bU="}]},"_npmUser":{"name":"amoorydxb","email":"omarbahabusalem@gmail.com"},"directories":{},"maintainers":[{"name":"amoorydxb","email":"omarbahabusalem@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/forcefield_0.1.1_1787614340733_0.4860353352571285"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-24T23:22:00.722Z","modified":"2026-08-24T23:32:21.023Z","0.1.0":"2026-08-24T23:22:01.280Z","0.1.1":"2026-08-24T23:32:20.888Z"},"license":"MIT","keywords":["graph","force-directed","barnes-hut","quadtree","network","visualization","canvas"],"description":"A dependency-free force-directed graph engine: Barnes-Hut many-body simulation, velocity-Verlet integration, drag/pin/filter/zoom, behind a swappable renderer interface.","maintainers":[{"name":"amoorydxb","email":"omarbahabusalem@gmail.com"}],"readme":"# forcefield\n\n**[Live demo →](https://3moorydxb.github.io/forcefield/)** — drag a node, switch the theme, 2,864\nnodes at 60fps, right in the browser.\n\n![An Obsidian graph view next to the same hierarchy rendered by forcefield](shots/obsidian-vs-forcefield.png)\n\n*Same hierarchy, same four settings, side by side — not a benchmark win, just a well-known app's\ngraph view next to this engine's on identical data. What it actually shows: 2,864 nodes at 60fps in\nthe browser, measured live, not a screenshot claim. (Captured before the rename, so the right-hand\npanel is still labelled `graph-engine`.)*\n\nA force-directed graph engine: Barnes-Hut many-body simulation, velocity-Verlet integration,\ndrag / pin / filter / zoom, behind a swappable renderer interface.\n\n**Zero dependencies. Zero build dependencies.** TypeScript in, ESM out, nothing in\n`node_modules`.\n\n```bash\nnpm run build      # tsc\nnpm test           # 99 tests, node:test\nnpm run serve      # http://localhost:8902/examples/basic/\n```\n\n---\n\n## Use it in your own software\n\n**forcefield is a library, not an app.** It draws into a `<canvas>` you own, inside your product —\ndesktop, web, dashboard, whatever you are building. You supply the data and the look; the engine\nsupplies the physics and the interaction.\n\n- **Your data, through an adapter.** The engine never interprets your nodes. `NodeSpec.type` is\n  documented *\"your own category string, never interpreted here\"*, and `data` is your payload,\n  carried untouched. A folder of markdown notes, a codebase's import graph, an investigation that\n  grows while it runs — same engine, different adapter. See [Adapters](#adapters--pluggable-into-anything).\n- **Your look, through a theme.** Themes are plain data. Ship yours, or contribute one back.\n  See [Themes](#themes).\n- **Your renderer, if you outgrow the default.** Canvas2D ships; `Renderer` is an interface, so a\n  WebGL or SVG backend slots in without touching the simulation.\n\nIt already runs inside two unrelated applications on the same engine. That is the reason nothing\nproduct-specific is allowed into the core — the moment it learns what a \"note\" is, it stops working\nfor the other one.\n\n---\n\n## Install\n\n```bash\nnpm i @amoorydxb/forcefield\n```\n\nOr straight from git, if you would rather not go through npm:\n\n```bash\nnpm i github:3moorydxb/forcefield\n```\n\nThe git install runs `prepare` (`npm run build`) as part of installing — the one devDependency,\n`typescript`, compiles `dist/` right there, so a git install ends up with exactly the `dist/` the\npublished package ships. Newer npm versions print an `allow-scripts`-style warning when a git\ndependency runs a lifecycle script on install; that script is `prepare`, and it is what produces\n`dist/` — let it run. **Committing `dist/` to make the warning go away is not the fix.** This\nrepo's own Pages workflow exists because `dist/` being gitignored once meant a build step got\nskipped and the demo shipped blank — the fix there was \"always build first\", never \"check in the\noutput\". Same principle here: build on install, don't let a compiled copy drift out of sync with\nthe source that produced it.\n\n---\n\n## What it is for\n\nIt does not know what a node means. There is no node type, no colour and no filter here that\nbelongs to any one product; a consumer supplies its own vocabulary through `type`, `data` and a\n`Theme`, and the engine treats all of it as opaque.\n\nIt was built for two consumers at once, deliberately:\n\n| | a saved hierarchy | a live investigation |\n|---|---|---|\n| nodes | thousands, coordinates already stored | grows while you watch, no layout at all |\n| edges | strictly hierarchical | arbitrary, confidence-weighted |\n| needs | filter by type and branch, dim the background | insertion into a running simulation |\n\nThe second one is harder, and building for it makes the first one free. **Replaying stored\ncoordinates would have served the hierarchy and been useless to the live case** — and on the real\nhierarchy this was tested against, the stored coordinates only covered 55% of the nodes anyway.\n\n---\n\n## Quick start\n\n```ts\nimport { GraphView, Filters, themeByName } from '@amoorydxb/forcefield';\n\nconst view = new GraphView({ container: document.getElementById('app')! });\n\nview.load({\n  nodes: [{ id: 'a', type: 'person' }, { id: 'b', type: 'org' }],\n  links: [{ source: 'a', target: 'b', weight: 0.8 }],\n});\n\nview.fitWhenSettled();\nview.start();\n\n// Show one branch. Everything else keeps its coordinates and leaves the physics.\nview.filter(Filters.branch('a', { direction: 'out', maxDepth: 3 }));\n\n// Or keep it on screen and push it back instead.\nview.highlight(Filters.branch('a', { direction: 'out' }));\n\n// Switch themes at runtime. Setting `.theme` marks the frame dirty itself —\n// no separate invalidate() call needed.\nview.theme = themeByName('light')!;\n```\n\n`GraphView` is optional glue. `Simulation`, `Canvas2DRenderer` and `InteractionController` are\nusable on their own if you already own your render loop.\n\n---\n\n## Why this and not sigma.js or cosmograph\n\nBoth were read before anything was written here. Both are MIT, so licence was not the deciding\nfactor.\n\n**[sigma.js](https://github.com/jacomyal/sigma.js)** — a WebGL *renderer* built on graphology. It\nis the strongest renderer of the three and it is genuinely fast. But it does not simulate: you\nsupply coordinates. The usual pairing is `graphology-layout-forceatlas2`, where\n`barnesHutOptimize` **defaults to `false`** (so the default really is O(n²)) and which has **no\nnotion of a fixed or pinned node** — and pin-and-drag is the central requirement here, not a\nnice-to-have. Choosing sigma means writing the simulation anyway and inheriting a WebGL renderer\nplus a graph library to get it.\n\n**[cosmos.gl](https://github.com/cosmograph-org/cosmos)** — GPU force simulation, hundreds of\nthousands of points, genuinely excellent at what it does. It is the wrong shape for the live case:\nsimulation state lives in GPU textures and the API takes whole arrays\n(`setPointPositions`, `setLinks`) with **no incremental add**, so \"one node arrives\" means\nrebuilding every buffer. It also brings a luma.gl dependency and needs WebGL2 — and positions\nliving on the GPU makes CPU-side hit-testing, labelling and filtering awkward.\n\n**A third data point, worth more than either:** the closest comparable open-source tool — an OSINT\ngraph investigation platform — renders its case graph with `react-force-graph-2d`, i.e. **canvas\nplus d3-force**, and runs the layout in a **Web Worker**. It uses React Flow only for its pipeline\n*editor*, where a DOM node per element is the right call. Canvas + a real force simulation is what\nsomeone solving this exact problem independently arrived at.\n\n**So: the simulation is written here** — that was the actual requirement — and the renderer sits\nbehind a `Renderer` interface with a Canvas 2D implementation. At the scale this targets (single-\ndigit thousands) Canvas holds frame rate with room to spare and costs no shader compilation, no\ncontext-loss handling and no dependency.\n\n**Where that stops being true, say so plainly: past ~100k nodes, use cosmos.gl.** This engine is\nnot going to beat a GPU at brute force, and pretending otherwise would be the kind of claim that\ngets found out. `Renderer` is the seam; swapping in a WebGL backend changes nothing else.\n\n---\n\n## Measured\n\nReal numbers from `bench/`, not impressions. Reproduce with `node bench/tick.mjs` and\n`/bench/?n=1651`.\n\n### Simulation, headless (`node bench/tick.mjs`, Node 26, darwin/arm64, median of 60 ticks)\n\n| nodes | links | Barnes-Hut | ticks/s | exact O(n²) | speed-up | ms / (n·log₂n) |\n|---|---|---|---|---|---|---|\n| 500 | 695 | 0.423 ms | 2362 | 2.153 ms | 5.1× | 94.5 ns |\n| **1,651** | 2,311 | **1.852 ms** | **540** | 25.328 ms | **13.7×** | 104.9 ns |\n| **2,864** | 4,007 | **3.414 ms** | **293** | 79.828 ms | **23.4×** | 103.8 ns |\n| 6,000 | 8,399 | 8.336 ms | 120 | 390.602 ms | 46.9× | 110.7 ns |\n| 12,000 | 16,797 | 18.838 ms | 53 | — | — | 115.9 ns |\n| 25,000 | 34,999 | 42.419 ms | 24 | — | — | 116.1 ns |\n\nThe last column is time per node per log₂(n). It stays between 94 and 116 ns across a **50× range\nof graph sizes** — that is what O(n log n) looks like when it is real rather than claimed. The\n`theta = 0` column is the same code with the quadtree switched off, and at 2,864 nodes the naive\nversion costs **79.8 ms per tick — 12 fps, before drawing anything.**\n\n### Full frame, in-browser\n\n⚠️ **Measured on a browser with no GPU acceleration** (`getContext('webgl')` returns `null`, so\nCanvas 2D is rasterised entirely on the CPU, at dpr 1). These are therefore a **floor**, not a\nrepresentative figure for a normal machine.\n\n| graph | mode | fps | 1% low | sim | render call | drawn |\n|---|---|---|---|---|---|---|\n| 1,651 synthetic | physics every frame | 47.7 | 46.6 | 1.89 ms | 0.25 ms | 1651 nodes / 2308 links |\n| 1,651 synthetic | render only, settled | 50.9 | 49.5 | — | 0.26 ms | 1651 / 2308 |\n| **2,864 real hierarchy** | **physics every frame** | **51.2** | **50.0** | **3.16 ms** | **0.52 ms** | 2864 / 2795 |\n| 100 synthetic | physics every frame | 60.0 | 56.2 | 0.24 ms | 0.52 ms | 100 / 135 |\n\n**Read the last row before the others.** At 100 nodes the same harness hits a clean 60 — so the\nharness can do 60, and the gap at 1,651 is rasterisation, not the engine. The engine's own work\nper frame is 2.1 ms at 1,651 nodes and 3.7 ms at 2,864, i.e. **13% and 22% of a 60fps budget.**\nThe remaining ~19 ms is the software rasteriser painting the canvas, which happens after the\nmeasured call returns.\n\nThat finding is why `GraphView.redrawPolicy` defaults to `'on-change'`: a settled graph with\nnothing hovered and nothing selected **skips the draw entirely**, so idling costs nothing at all.\n\nOn the live demo, with GPU acceleration available, the same 2,864-node hierarchy holds **60fps\nsustained, 59.2 1%-low** — the number the demo link at the top is quoting.\n\n---\n\n## What it does\n\n### Simulation\nVelocity-Verlet, not the semi-implicit Euler most graph layouts use — it carries the previous\nacceleration and averages it with the new one, so a dragged node's neighbours trail it instead of\novershooting and snapping back.\n\nForces run in three phases per tick so they all see one quadtree: `pre` (centring, a pure\ntranslation, before the tree is built) → build → `relax` (collision, positional) → `force`\n(repulsion, springs, gravity). All of them are removable and replaceable; `Force` is a two-method\ninterface.\n\nAlpha decays geometrically and the graph settles. `reheat()` wakes it. `alphaTarget` **holds** it\nwarm — that distinction is what makes a drag feel alive rather than going limp halfway through the\ngesture.\n\n![Barnes-Hut quadtree over 1,651 nodes](shots/barnes-hut-quadtree.png)\n\n*1,651 nodes, 2,310 links, with the Barnes-Hut subdivision drawn. Cells subdivide only where\nnodes are dense — that is the O(n log n).*\n\n### Pinning\nA pinned node does not integrate, but it stays in the quadtree and stays on both ends of its\nsprings, so it goes on pushing the graph around while standing still. Centring stands down as\nsoon as anything is pinned, because a pin is the user saying \"this belongs here\" and a correction\nthat slides it is a pin that does not hold.\n\n### Filtering\n**Filtering never restarts the simulation.** It sets a hidden flag: hidden nodes leave the\nquadtree and every force, are not integrated, and keep their coordinates frozen. Clear the filter\nand they are exactly where they were.\n\nVerified in the browser on 2,864 real nodes: isolating a 46-node branch moved **0 nodes** and left\nalpha byte-identical.\n\n`Filters` composes — `ofType`, `branch` (following link direction), `within`, `search`, `degree`,\n`predicate`, `and` / `or` / `not`, `expand`.\n\n`filter()` removes; `highlight()` dims. Different questions: *show me only this branch* versus\n*where does this branch sit in the whole thing*.\n\nBoth are in the tree demo: click a node to isolate its subtree, tick **dim instead of hide** to see\nthe same branch in context.\n\n### Rendering\nCanvas 2D, behind an interface. Everything sharing a style goes into one path and is stroked or\nfilled once — thousands of driver state changes become about twenty. Off-screen nodes are culled;\nlabels are capped, gated on zoom, and chosen by degree.\n\nThree themes ship — `dark` (the default), `light`, `midnight-glow` — and each is validated at\nmodule load against a seven-rule contract: palette shape, WCAG contrast floors on every text and\nchrome colour, and (the rule with the teeth) that no two style buckets are ever distinguished by\ncolour alone. A theme that fails the contract does not fail quietly; the package fails to import.\n`view.theme = themeByName('light')` swaps at runtime — the examples' theme picker does exactly\nthat, plus re-deriving the page's own chrome from the theme and remembering the choice. See\n`themes/README.md` for the contract and how to add a theme.\n\nThe default (`dark`) palette is validated colourblind-safe (worst adjacent pair ΔE 8.4 protan). On\ndark all eight slots clear 3:1 against the surface; on light three do not, which is why the\nhovered node always gets a label and the examples ship a legend — identity is never carried by\ncolour alone. Replace `Theme.palette` with your own and nothing else changes.\n\nTypes are assigned palette slots **in fixed order over the whole graph, never cycled**, so\nfiltering down to three types does not repaint them, and a ninth type folds into `other` rather\nthan getting an invented hue.\n\n### Interaction\nDrag (the rest respond), pin/unpin on double-click, hover, click select, shift-drag marquee\nmulti-select, wheel zoom about the cursor, pan, two-finger pinch, and `p` / `f` / `Escape`.\n\n![mid-drag](shots/drag-mid-gesture.png)\n\n*Mid-gesture, mouse still down: the dragged node sits exactly under the cursor, alpha is held at\n0.3, and all 12 of its neighbours have moved.*\n\n---\n\n## One rule worth stating\n\n**Direction is an explicit field. It is never the sign of a number.**\n\nCSS clamps a negative `animation-duration` to `0s` — no error, no warning, `getAnimations()`\nreturns nothing. A sibling project encoded \"spin the other way\" as a negative duration and shipped\na six-layer animation in which three layers stood perfectly still, through a design review,\nunnoticed. \"Clamped to nothing\" and \"never written\" look identical.\n\nThe bug is not the clamp. It is overloading a magnitude to carry a direction. So here, magnitudes\n(durations, radii, masses, zoom factors) are positive and **validated**, direction is a string\nunion, and a negative magnitude throws instead of quietly becoming zero. See\n`src/core/direction.ts` and the tests that enforce it.\n\n---\n\n## Examples\n\n```bash\nnpm run build && npm run serve\n```\n\n- `/examples/basic/` — synthetic graph generated from a seed, so the benchmark is reproducible by\n  anyone with no data at all. Size, forces, θ, quadtree overlay, filtering.\n- `/examples/live/` — nodes inserted one at a time into a running simulation, confidence-weighted\n  edges. ![live](shots/live-insertion.png)\n- `/examples/tree/` — a large hierarchy. Defaults to a **synthetic** 2,864-note vault built from a\n  fixed seed, so the demo ships with no private data and is reproducible by anyone. `npm run serve`\n  does not generate it — run this once first:\n\n  ```bash\n  npm run demo:data     # synthetic-vault.mjs → markdown-tree.mjs → examples/tree/demo.graph.json\n  ```\n\n  Pass `?data=your-export.graph.json` to point it at your own vault's export instead — see\n  `examples/adapters/README.md`. Any `*.graph.json` you generate is `.gitignore`d, same as always.\n\n---\n\n## Themes\n\nShips three (`dark`, `light`, `midnight-glow`); adding a fourth is documented start-to-finish in\n`themes/README.md` — that folder is the whole contribution surface, and nothing in it depends on\n`src/`. The contract's own rule, in one sentence: **state is glyph + word + colour, never colour\nalone.**\n\n---\n\n## Adapters — pluggable into anything\n\n`type` and `data` on a node or link are the consumer's own vocabulary — the engine assigns `type` a\npalette slot and lets you filter by it, and never reads `data` at all. Whatever turns a real source\ninto `{ nodes, links }` is an adapter, and adapters live entirely in `examples/`, never in `src/`:\nthe engine stays at zero dependencies while an adapter is free to take its own.\n\nThree ship:\n\n- `markdown-tree.mjs` — a folder of markdown notes, each naming its parent with a wikilink.\n- `synthetic-vault.mjs` — writes a synthetic markdown vault to disk from a seed, so the tree demo\n  (and its benchmark) don't depend on anyone's real notes.\n- `codebase.mjs` — an import graph of a codebase, via `dependency-cruiser`. **Scope: TypeScript and\n  JavaScript only** — \"any codebase\" would be a later claim, this is the first one.\n\nSee `examples/adapters/README.md` for the exact field contract and how to write your own in five\nsteps.\n\n---\n\n## What v0.1.0 does not do\n\nHonest about the edges, not just the strengths:\n\n- **No WebGL renderer.** `Renderer` is an interface for exactly this reason, but only the Canvas 2D\n  implementation exists — the seam is real, the second backend is not written.\n- **No worker-thread simulation.** The tick runs on the main thread; a very large graph competes\n  with layout and paint for the same frame budget.\n- **`Canvas2DRenderer` and `InteractionController` have no unit tests.** They're exercised in the\n  browser and verified by screenshot (see `shots/`), which catches what a human eye catches and\n  nothing else — weaker than a real test.\n- **Touch is tested only via synthetic pointer events**, not a real device. Pinch and two-finger\n  pan work in that harness; nobody has put a phone on it yet.\n- Past ~100k nodes the honest answer is still cosmos.gl, not this — see \"Why this and not sigma.js\n  or cosmograph\" above.\n\n---\n\n## Layout\n\n```\nsrc/core/        graph · quadtree · simulation · filter · direction · forces/\nsrc/render/      renderer interface · canvas2d · camera · theme (re-exports themes/)\nsrc/interaction/ controller\nsrc/graphView.ts optional glue: view + loop\nthemes/          the Theme contract + dark/light/midnight-glow — the whole contribution surface\nexamples/        example pages and adapters — NOT part of the engine\nbench/           headless tick benchmark + in-browser frame benchmark\ntest/            99 tests, node:test, no dependencies\nsite/            static Pages landing — no imports from dist/, so it renders even if the build breaks\n.github/         Pages workflow: build, test, generate the synthetic demo graph, guard, deploy\n```\n\nIf a consumer's concept ever appears in `src/`, that is the bug.\n\n---\n\n## Contributing\n\n- New theme → `themes/README.md`.\n- New adapter → `examples/adapters/README.md`.\n- `npm test` has to pass — 99 tests, `node:test`, zero dependencies.\n- The engine itself takes none, ever, and that is not up for negotiation in a PR — an adapter or an\n  example may take its own.\n\n## Licence\n\nMIT.\n","readmeFilename":"README.md"}