From e237b13ccc71a61c089e945fb748e3a620d3dede Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 6 Mar 2026 19:02:18 -0800 Subject: [PATCH 01/95] feat: make InMemoryGraphAdapter and defaultCrypto browser-compatible (B157) Replace hard node:crypto and node:stream imports with lazy-loaded fallbacks using top-level await import() in try/catch blocks. This allows the library to be imported in browser environments without crashing at module evaluation time. Key changes: - InMemoryGraphAdapter: new `hash` constructor option for injecting a synchronous SHA-1 function; Buffer-free hash helpers using TextEncoder + Uint8Array; node:stream dynamically imported in logNodesStream() only - defaultCrypto: lazy-loads node:crypto, throws helpful error when invoked without fallback in browsers - sha1sync: minimal sync SHA-1 implementation (~110 LOC) for browser content addressing, exported as @git-stunts/git-warp/sha1sync - browser.js: curated browser entry point re-exporting only browser-safe code, exported as @git-stunts/git-warp/browser - package.json: ./browser and ./sha1sync export map entries --- CHANGELOG.md | 4 + ROADMAP.md | 5 +- browser.js | 53 +++++++ package.json | 9 ++ src/domain/utils/defaultCrypto.js | 40 +++-- .../adapters/InMemoryGraphAdapter.js | 145 ++++++++++++++---- src/infrastructure/adapters/sha1sync.js | 113 ++++++++++++++ .../InMemoryGraphAdapter.browser.test.js | 51 ++++++ .../infrastructure/adapters/sha1sync.test.js | 43 ++++++ 9 files changed, 420 insertions(+), 43 deletions(-) create mode 100644 browser.js create mode 100644 src/infrastructure/adapters/sha1sync.js create mode 100644 test/unit/infrastructure/adapters/InMemoryGraphAdapter.browser.test.js create mode 100644 test/unit/infrastructure/adapters/sha1sync.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b6cd7120..db36d779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Browser-compatible `InMemoryGraphAdapter`** — Replaced hard `node:crypto` and `node:stream` imports with lazy-loaded fallbacks. A new `hash` constructor option lets callers inject a synchronous SHA-1 function for environments where `node:crypto` is unavailable (e.g. browsers). `node:stream` is now dynamically imported only in `logNodesStream()`. +- **Browser-safe `defaultCrypto`** — The domain-level crypto default now lazy-loads `node:crypto` via top-level `await import()` with a try/catch, so importing `WarpGraph` in a browser no longer crashes at module evaluation time. Callers must inject crypto via `WarpGraph.open({ crypto })` when `node:crypto` is unavailable. +- **`sha1sync` utility** (`@git-stunts/git-warp/sha1sync`) — Minimal synchronous SHA-1 implementation (~110 LOC) for browser content addressing with `InMemoryGraphAdapter`. Not for security — only for Git object ID computation. +- **`browser.js` entry point** (`@git-stunts/git-warp/browser`) — Curated re-export of browser-safe code: `WarpGraph`, `InMemoryGraphAdapter`, `WebCryptoAdapter`, CRDT primitives, errors, and `generateWriterId`. No `node:` imports in the critical path. - **Documentation enhancements in README.md** — Added a high-level Documentation Map, a detailed Graph Traversal Directory, an expanded Time-Travel (Seek) guide, and updated Runtime Compatibility information (Node.js, Bun, Deno). - **Local-First Applications use-case** — Added git-warp as a backend for LoFi software. diff --git a/ROADMAP.md b/ROADMAP.md index cfa2816b..309f76e9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -250,6 +250,7 @@ No hard dependencies. Pick up opportunistically after P2. |----|------|--------| | B155 | **`levels()` AS LIGHTWEIGHT `--view` LAYOUT** — `levels()` is exactly the Y-axis assignment a layered DAG layout needs. For simple DAGs, `levels()` + left-to-right X sweep could produce clean layouts without the 2.5MB ELK import. Offer `--view --layout=levels` as an instant rendering mode, reserving ELK for complex graphs. **Files:** `src/visualization/layouts/`, `bin/cli/commands/view.js` | M | | B156 | **STRUCTURAL DIFF VIA TRANSITIVE REDUCTION** — compute `transitiveReduction(stateA)` vs `transitiveReduction(stateB)` to produce a compact structural diff that strips implied edges and shows only "load-bearing" changes. Natural fit for H1 (Time-Travel Delta Engine) as `warp diff --mode=structural`. | L | +| B157 | ✅ **BROWSER COMPATIBILITY (Operation Browsa, Phase 1-3)** — Make `InMemoryGraphAdapter` and `defaultCrypto` browser-safe by lazy-loading `node:crypto`/`node:stream`. New `sha1sync` utility for browser content addressing. New `browser.js` entry point and `./browser`+`./sha1sync` package exports. | M | ### P6 — Documentation & Process @@ -382,10 +383,10 @@ B36 (P1) ──→ (improves velocity for B99, B19, B22, future tests) | **Milestone (M13)** | 1 | B116 (internal: DONE; wire-format: DEFERRED) | | **Milestone (M14)** | 16 | B130–B145 | | **Standalone** | 45 | B12, B19, B22, B28, B34–B37, B43, B48, B49, B53, B54, B57, B76, B79–B81, B83, B85–B88, B95–B99, B102–B104, B119, B123, B127–B129, B147, B149–B156 | -| **Standalone (done)** | 29 | B26, B44, B46, B47, B50–B52, B55, B71, B72, B77, B78, B82, B84, B89–B94, B100, B120–B122, B124, B125, B126, B146, B148 | +| **Standalone (done)** | 30 | B26, B44, B46, B47, B50–B52, B55, B71, B72, B77, B78, B82, B84, B89–B94, B100, B120–B122, B124, B125, B126, B146, B148, B157 | | **Deferred** | 7 | B4, B7, B16, B20, B21, B27, B101 | | **Rejected** | 7 | B5, B6, B13, B17, B18, B25, B45 | -| **Total tracked** | **133** total; 29 standalone done | | +| **Total tracked** | **134** total; 30 standalone done | | ### STANK.md Cross-Reference diff --git a/browser.js b/browser.js new file mode 100644 index 00000000..0b9d1b22 --- /dev/null +++ b/browser.js @@ -0,0 +1,53 @@ +/** + * Browser entry point for @git-stunts/git-warp. + * + * Re-exports only browser-safe code — no node:crypto, node:stream, + * or @git-stunts/plumbing imports. Use with InMemoryGraphAdapter + * and WebCryptoAdapter for fully in-browser WARP graph operation. + * + * @module browser + * + * @example + * ```js + * import { + * WarpGraph, + * InMemoryGraphAdapter, + * WebCryptoAdapter, + * generateWriterId, + * } from '@git-stunts/git-warp/browser'; + * import { sha1sync } from '@git-stunts/git-warp/sha1sync'; + * + * const adapter = new InMemoryGraphAdapter({ hash: sha1sync }); + * const crypto = new WebCryptoAdapter(); + * const graph = await WarpGraph.open({ + * persistence: adapter, + * graphName: 'demo', + * writerId: generateWriterId(), + * crypto, + * }); + * ``` + */ + +// Core API +export { default as WarpGraph } from './src/domain/WarpGraph.js'; +export { default as GraphNode } from './src/domain/entities/GraphNode.js'; + +// Browser-compatible adapters +export { default as InMemoryGraphAdapter } from './src/infrastructure/adapters/InMemoryGraphAdapter.js'; +export { default as WebCryptoAdapter } from './src/infrastructure/adapters/WebCryptoAdapter.js'; + +// CRDT primitives +export { createVersionVector } from './src/domain/crdt/VersionVector.js'; + +// Errors +export { default as WarpError } from './src/domain/errors/WarpError.js'; +export { + ForkError, + QueryError, + StorageError, + TraversalError, + SyncError, +} from './src/domain/errors/index.js'; + +// Utilities +export { generateWriterId } from './src/domain/utils/WriterId.js'; diff --git a/package.json b/package.json index 938d6af9..8dda2c52 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,14 @@ "import": "./src/visualization/index.js", "default": "./src/visualization/index.js" }, + "./browser": { + "import": "./browser.js", + "default": "./browser.js" + }, + "./sha1sync": { + "import": "./src/infrastructure/adapters/sha1sync.js", + "default": "./src/infrastructure/adapters/sha1sync.js" + }, "./package.json": "./package.json" }, "files": [ @@ -46,6 +54,7 @@ "bin/cli", "bin/git-warp", "src", + "browser.js", "index.js", "index.d.ts", "README.md", diff --git a/src/domain/utils/defaultCrypto.js b/src/domain/utils/defaultCrypto.js index d199cc5c..c9945ca3 100644 --- a/src/domain/utils/defaultCrypto.js +++ b/src/domain/utils/defaultCrypto.js @@ -6,30 +6,50 @@ * the infrastructure layer. This follows the same pattern as * defaultCodec.js and defaultClock.js. * - * Since git-warp requires Git (and therefore Node 22+, Deno, or Bun), - * node:crypto is always available. + * In Node/Bun/Deno, node:crypto loads normally. In browsers, + * the import fails silently and callers must inject crypto via + * WarpGraph.open({ crypto }). * * @module domain/utils/defaultCrypto */ -import { - createHash, - createHmac, - timingSafeEqual as nodeTimingSafeEqual, -} from 'node:crypto'; +/** @type {Function|null} */ +let _createHash = null; +/** @type {Function|null} */ +let _createHmac = null; +/** @type {Function|null} */ +let _timingSafeEqual = null; + +try { + const nodeCrypto = await import('node:crypto'); + _createHash = nodeCrypto.createHash; + _createHmac = nodeCrypto.createHmac; + _timingSafeEqual = nodeCrypto.timingSafeEqual; +} catch { + // Browser — caller must inject crypto via WarpGraph.open({ crypto }) +} /** @type {import('../../ports/CryptoPort.js').default} */ const defaultCrypto = { // eslint-disable-next-line @typescript-eslint/require-await -- async matches CryptoPort contract async hash(algorithm, data) { - return createHash(algorithm).update(data).digest('hex'); + if (!_createHash) { + throw new Error('No crypto available. Pass { crypto } to WarpGraph.open().'); + } + return _createHash(algorithm).update(data).digest('hex'); }, // eslint-disable-next-line @typescript-eslint/require-await -- async matches CryptoPort contract async hmac(algorithm, key, data) { - return createHmac(algorithm, key).update(data).digest(); + if (!_createHmac) { + throw new Error('No crypto available. Pass { crypto } to WarpGraph.open().'); + } + return _createHmac(algorithm, key).update(data).digest(); }, timingSafeEqual(a, b) { - return nodeTimingSafeEqual(a, b); + if (!_timingSafeEqual) { + throw new Error('No crypto available. Pass { crypto } to WarpGraph.open().'); + } + return _timingSafeEqual(a, b); }, }; diff --git a/src/infrastructure/adapters/InMemoryGraphAdapter.js b/src/infrastructure/adapters/InMemoryGraphAdapter.js index 34617904..04b34e3b 100644 --- a/src/infrastructure/adapters/InMemoryGraphAdapter.js +++ b/src/infrastructure/adapters/InMemoryGraphAdapter.js @@ -9,14 +9,93 @@ * SHA computation follows Git's object format so debugging is straightforward, * but cross-adapter SHA matching is NOT guaranteed. * + * Browser-compatible: the only Node-specific dependency (node:crypto) is + * lazy-loaded and can be replaced via the `hash` constructor option. + * * @module infrastructure/adapters/InMemoryGraphAdapter */ -import { createHash } from 'node:crypto'; -import { Readable } from 'node:stream'; import GraphPersistencePort from '../../ports/GraphPersistencePort.js'; import { validateOid, validateRef, validateLimit, validateConfigKey } from './adapterValidation.js'; +// ── Browser-safe byte helpers ──────────────────────────────────────── + +const _encoder = new TextEncoder(); + +/** + * Concatenates an array of Uint8Array instances into one. + * @param {Uint8Array[]} arrays + * @returns {Uint8Array} + */ +function concatBytes(arrays) { + const len = arrays.reduce((sum, a) => sum + a.length, 0); + const result = new Uint8Array(len); + let offset = 0; + for (const a of arrays) { + result.set(a, offset); + offset += a.length; + } + return result; +} + +/** + * Converts a hex string to a Uint8Array. + * @param {string} hex + * @returns {Uint8Array} + */ +function hexToBytes(hex) { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); + } + return bytes; +} + +/** + * Converts a string or Uint8Array to bytes. In Node, prefers Buffer for + * string inputs to preserve backwards compatibility with callers that + * expect Buffer instances from readBlob/readTree. + * @param {string|Uint8Array|Buffer} data + * @returns {Uint8Array} + */ +function toBytes(data) { + if (data instanceof Uint8Array) { + return data; + } + if (typeof data === 'string') { + if (typeof Buffer !== 'undefined') { + return Buffer.from(data); + } + return _encoder.encode(data); + } + throw new Error('Expected string or Uint8Array'); +} + +// ── Lazy node:crypto for default hash ──────────────────────────────── + +/** @type {Function|null} */ +let _nodeCreateHash = null; +try { + const nodeCrypto = await import('node:crypto'); + _nodeCreateHash = nodeCrypto.createHash; +} catch { + // Browser or non-Node runtime — hash must be injected via constructor +} + +/** + * Default hash function using node:crypto SHA-1. + * @param {Uint8Array} data + * @returns {string} 40-hex SHA + */ +function defaultHash(data) { + if (!_nodeCreateHash) { + throw new Error( + 'No hash function available. Pass { hash } to InMemoryGraphAdapter constructor.', + ); + } + return _nodeCreateHash('sha1').update(data).digest('hex'); +} + /** Well-known SHA for Git's empty tree. */ const EMPTY_TREE_OID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; @@ -24,12 +103,13 @@ const EMPTY_TREE_OID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; /** * Computes a Git blob SHA-1: `SHA1("blob " + len + "\0" + content)`. - * @param {Buffer} content + * @param {(data: Uint8Array) => string} hash + * @param {Uint8Array} content * @returns {string} 40-hex SHA */ -function hashBlob(content) { - const header = Buffer.from(`blob ${content.length}\0`); - return createHash('sha1').update(header).update(content).digest('hex'); +function hashBlob(hash, content) { + const header = _encoder.encode(`blob ${content.length}\0`); + return hash(concatBytes([header, content])); } /** @@ -38,27 +118,28 @@ function hashBlob(content) { * Each entry is: ` \0<20-byte binary OID>` * Entries are sorted by path (byte order), matching Git's canonical sort. * + * @param {(data: Uint8Array) => string} hash * @param {Array<{mode: string, path: string, oid: string}>} entries * @returns {string} 40-hex SHA */ -function hashTree(entries) { +function hashTree(hash, entries) { const sorted = [...entries].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); const parts = sorted.map(e => { - const prefix = Buffer.from(`${e.mode} ${e.path}\0`); - const oidBin = Buffer.from(e.oid, 'hex'); - return Buffer.concat([prefix, oidBin]); + const prefix = _encoder.encode(`${e.mode} ${e.path}\0`); + return concatBytes([prefix, hexToBytes(e.oid)]); }); - const body = Buffer.concat(parts); - const header = Buffer.from(`tree ${body.length}\0`); - return createHash('sha1').update(header).update(body).digest('hex'); + const body = concatBytes(parts); + const header = _encoder.encode(`tree ${body.length}\0`); + return hash(concatBytes([header, body])); } /** * Builds a Git-style commit string and hashes it. + * @param {(data: Uint8Array) => string} hash * @param {{treeOid: string, parents: string[], message: string, author: string, date: string}} opts * @returns {string} 40-hex SHA */ -function hashCommit({ treeOid, parents, message, author, date }) { +function hashCommit(hash, { treeOid, parents, message, author, date }) { const lines = [`tree ${treeOid}`]; for (const p of parents) { lines.push(`parent ${p}`); @@ -67,9 +148,9 @@ function hashCommit({ treeOid, parents, message, author, date }) { lines.push(`committer ${author} ${date}`); lines.push(''); lines.push(message); - const body = lines.join('\n'); - const header = `commit ${Buffer.byteLength(body)}\0`; - return createHash('sha1').update(header).update(body).digest('hex'); + const bodyBytes = _encoder.encode(lines.join('\n')); + const header = _encoder.encode(`commit ${bodyBytes.length}\0`); + return hash(concatBytes([header, bodyBytes])); } // ── Adapter ───────────────────────────────────────────────────────────── @@ -79,7 +160,7 @@ function hashCommit({ treeOid, parents, message, author, date }) { * * Data structures: * - `_commits` — Map - * - `_blobs` — Map + * - `_blobs` — Map * - `_trees` — Map> * - `_refs` — Map * - `_config` — Map @@ -88,16 +169,17 @@ function hashCommit({ treeOid, parents, message, author, date }) { */ export default class InMemoryGraphAdapter extends GraphPersistencePort { /** - * @param {{ author?: string, clock?: { now: () => number } }} [options] + * @param {{ author?: string, clock?: { now: () => number }, hash?: (data: Uint8Array) => string }} [options] */ - constructor({ author, clock } = {}) { + constructor({ author, clock, hash } = {}) { super(); this._author = author || 'InMemory '; this._clock = clock || { now: () => Date.now() }; + this._hash = hash || defaultHash; /** @type {Map} */ this._commits = new Map(); - /** @type {Map} */ + /** @type {Map} */ this._blobs = new Map(); /** @type {Map>} */ this._trees = new Map(); @@ -130,7 +212,7 @@ export default class InMemoryGraphAdapter extends GraphPersistencePort { const [mode, , oid] = meta.split(' '); return { mode, path, oid }; }); - const oid = hashTree(parsed); + const oid = hashTree(this._hash, parsed); this._trees.set(oid, parsed); return oid; } @@ -158,11 +240,11 @@ export default class InMemoryGraphAdapter extends GraphPersistencePort { /** * @param {string} treeOid - * @returns {Promise>} + * @returns {Promise>} */ async readTree(treeOid) { const oids = await this.readTreeOids(treeOid); - /** @type {Record} */ + /** @type {Record} */ const files = {}; for (const [path, oid] of Object.entries(oids)) { files[path] = await this.readBlob(oid); @@ -173,19 +255,19 @@ export default class InMemoryGraphAdapter extends GraphPersistencePort { // ── BlobPort ──────────────────────────────────────────────────────── /** - * @param {Buffer|string} content + * @param {Uint8Array|string} content * @returns {Promise} */ async writeBlob(content) { - const buf = Buffer.isBuffer(content) ? content : Buffer.from(content); - const oid = hashBlob(buf); - this._blobs.set(oid, buf); + const bytes = toBytes(content); + const oid = hashBlob(this._hash, bytes); + this._blobs.set(oid, bytes); return oid; } /** * @param {string} oid - * @returns {Promise} + * @returns {Promise} */ async readBlob(oid) { validateOid(oid); @@ -321,13 +403,14 @@ export default class InMemoryGraphAdapter extends GraphPersistencePort { /** * @param {{ ref: string, limit?: number, format?: string }} options - * @returns {Promise} + * @returns {Promise} */ async logNodesStream({ ref, limit = 1000000, format: _format }) { validateRef(ref); validateLimit(limit); const records = this._walkLog(ref, limit); const formatted = records.map(c => this._formatCommitRecord(c)).join('\0') + (records.length > 0 ? '\0' : ''); + const { Readable } = await import('node:stream'); return Readable.from([formatted]); } @@ -448,7 +531,7 @@ export default class InMemoryGraphAdapter extends GraphPersistencePort { */ _createCommit(treeOid, parents, message) { const date = new Date(this._clock.now()).toISOString(); - const sha = hashCommit({ + const sha = hashCommit(this._hash, { treeOid, parents, message, diff --git a/src/infrastructure/adapters/sha1sync.js b/src/infrastructure/adapters/sha1sync.js new file mode 100644 index 00000000..866a3e6f --- /dev/null +++ b/src/infrastructure/adapters/sha1sync.js @@ -0,0 +1,113 @@ +/** + * Synchronous SHA-1 for browser use with InMemoryGraphAdapter. + * + * This is a minimal, standards-compliant SHA-1 implementation used + * solely for Git content addressing (blob/tree/commit object IDs). + * It is NOT used for security purposes. + * + * @module infrastructure/adapters/sha1sync + */ + +/** + * Left-rotate a 32-bit integer by n bits. + * @param {number} x + * @param {number} n + * @returns {number} + */ +function rotl(x, n) { + return ((x << n) | (x >>> (32 - n))) >>> 0; +} + +/** + * Pads and parses a message into 512-bit blocks for SHA-1. + * @param {Uint8Array} msg + * @returns {Uint32Array[]} + */ +function preprocess(msg) { + const bitLen = msg.length * 8; + const totalBytes = msg.length + 1 + ((119 - (msg.length % 64)) % 64) + 8; + const padded = new Uint8Array(totalBytes); + padded.set(msg); + padded[msg.length] = 0x80; + const dv = new DataView(padded.buffer); + dv.setUint32(totalBytes - 4, bitLen, false); + + const blocks = []; + for (let i = 0; i < totalBytes; i += 64) { + const block = new Uint32Array(80); + for (let j = 0; j < 16; j++) { + block[j] = dv.getUint32(i + j * 4, false); + } + for (let j = 16; j < 80; j++) { + block[j] = rotl(block[j - 3] ^ block[j - 8] ^ block[j - 14] ^ block[j - 16], 1); + } + blocks.push(block); + } + return blocks; +} + +/** + * Processes a single 512-bit block, updating the hash state in-place. + * @param {number[]} state - Five-element hash state [h0..h4] + * @param {Uint32Array} w - 80-word expanded block + */ +function processBlock(state, w) { + let a = state[0]; + let b = state[1]; + let c = state[2]; + let d = state[3]; + let e = state[4]; + + for (let i = 0; i < 80; i++) { + let f; + let k; + if (i < 20) { + f = (b & c) | (~b & d); + k = 0x5A827999; + } else if (i < 40) { + f = b ^ c ^ d; + k = 0x6ED9EBA1; + } else if (i < 60) { + f = (b & c) | (b & d) | (c & d); + k = 0x8F1BBCDC; + } else { + f = b ^ c ^ d; + k = 0xCA62C1D6; + } + + const temp = (rotl(a, 5) + f + e + k + w[i]) >>> 0; + e = d; + d = c; + c = rotl(b, 30); + b = a; + a = temp; + } + + state[0] = (state[0] + a) >>> 0; + state[1] = (state[1] + b) >>> 0; + state[2] = (state[2] + c) >>> 0; + state[3] = (state[3] + d) >>> 0; + state[4] = (state[4] + e) >>> 0; +} + +/** + * Computes the SHA-1 hash of a Uint8Array, returning a 40-char hex string. + * + * @param {Uint8Array} data + * @returns {string} 40-hex SHA-1 digest + * + * @example + * import { sha1sync } from './sha1sync.js'; + * const hex = sha1sync(new TextEncoder().encode('hello')); + * // => 'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d' + */ +export function sha1sync(data) { + const blocks = preprocess(data); + const state = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0]; + + for (const w of blocks) { + processBlock(state, w); + } + + return state.map(v => v.toString(16).padStart(8, '0')).join(''); +} diff --git a/test/unit/infrastructure/adapters/InMemoryGraphAdapter.browser.test.js b/test/unit/infrastructure/adapters/InMemoryGraphAdapter.browser.test.js new file mode 100644 index 00000000..501c8565 --- /dev/null +++ b/test/unit/infrastructure/adapters/InMemoryGraphAdapter.browser.test.js @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import InMemoryGraphAdapter from '../../../../src/infrastructure/adapters/InMemoryGraphAdapter.js'; +import { sha1sync } from '../../../../src/infrastructure/adapters/sha1sync.js'; +import WarpGraph from '../../../../src/domain/WarpGraph.js'; +import WebCryptoAdapter from '../../../../src/infrastructure/adapters/WebCryptoAdapter.js'; + +describe('InMemoryGraphAdapter with injected hash (browser simulation)', () => { + it('basic operations work with sha1sync hash function', async () => { + const adapter = new InMemoryGraphAdapter({ hash: sha1sync }); + + const blobOid = await adapter.writeBlob('hello'); + const content = await adapter.readBlob(blobOid); + expect(new TextDecoder().decode(content)).toBe('hello'); + + const sha = await adapter.commitNode({ message: 'test commit' }); + expect(sha).toMatch(/^[0-9a-f]{40}$/); + + const info = await adapter.getNodeInfo(sha); + expect(info.message).toBe('test commit'); + }); + + it('produces identical SHAs to default node:crypto hash', async () => { + const clock = { now: () => 42 }; + const injected = new InMemoryGraphAdapter({ hash: sha1sync, clock }); + const defaultAdapter = new InMemoryGraphAdapter({ clock }); + + const sha1 = await injected.commitNode({ message: 'deterministic' }); + const sha2 = await defaultAdapter.commitNode({ message: 'deterministic' }); + expect(sha1).toBe(sha2); + }); + + it('WarpGraph works with injected hash and WebCryptoAdapter', async () => { + const persistence = new InMemoryGraphAdapter({ hash: sha1sync }); + const crypto = new WebCryptoAdapter(); + const graph = await WarpGraph.open({ + persistence, + graphName: 'browser-test', + writerId: 'alice', + crypto, + }); + + const patch = await graph.createPatch(); + patch.addNode('user:alice'); + patch.setProperty('user:alice', 'name', 'Alice'); + await patch.commit(); + + /** @type {any} */ + const state = await graph.materialize(); + expect(state.nodeAlive.entries.has('user:alice')).toBe(true); + }); +}); diff --git a/test/unit/infrastructure/adapters/sha1sync.test.js b/test/unit/infrastructure/adapters/sha1sync.test.js new file mode 100644 index 00000000..23674394 --- /dev/null +++ b/test/unit/infrastructure/adapters/sha1sync.test.js @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { createHash } from 'node:crypto'; +import { sha1sync } from '../../../../src/infrastructure/adapters/sha1sync.js'; + +describe('sha1sync', () => { + it('matches node:crypto for empty input', () => { + const expected = createHash('sha1').update(Buffer.alloc(0)).digest('hex'); + expect(sha1sync(new Uint8Array(0))).toBe(expected); + }); + + it('matches node:crypto for "hello"', () => { + const data = new TextEncoder().encode('hello'); + const expected = createHash('sha1').update(data).digest('hex'); + expect(sha1sync(data)).toBe(expected); + }); + + it('matches node:crypto for a Git blob header', () => { + const content = 'hello world'; + const blob = `blob ${content.length}\0${content}`; + const data = new TextEncoder().encode(blob); + const expected = createHash('sha1').update(data).digest('hex'); + expect(sha1sync(data)).toBe(expected); + }); + + it('matches node:crypto for binary data', () => { + const data = new Uint8Array(256); + for (let i = 0; i < 256; i++) data[i] = i; + const expected = createHash('sha1').update(data).digest('hex'); + expect(sha1sync(data)).toBe(expected); + }); + + it('matches node:crypto for exactly 64-byte input (one block)', () => { + const data = new Uint8Array(64).fill(0x41); // 64 'A' bytes + const expected = createHash('sha1').update(data).digest('hex'); + expect(sha1sync(data)).toBe(expected); + }); + + it('matches node:crypto for multi-block input', () => { + const data = new Uint8Array(1000).fill(0xFF); + const expected = createHash('sha1').update(data).digest('hex'); + expect(sha1sync(data)).toBe(expected); + }); +}); From 2d4ccd58d315ffb8b98537885bc1f16b79029370 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 6 Mar 2026 19:07:38 -0800 Subject: [PATCH 02/95] =?UTF-8?q?feat:=20add=20Browsa=20demo=20app=20?= =?UTF-8?q?=E2=80=94=204-viewport=20WARP=20graph=20in=20the=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vue 3 + Vite demo with 4 WarpGraph viewports running entirely in the browser. Each viewport has its own writer, colored nodes, sync controls, a time-travel ceiling slider, and per-node provenance inspection (Da Cone). All viewports share one InMemoryGraphAdapter with sha1sync hash function and WebCryptoAdapter — zero server, zero Docker, zero setup. Features: - 2x2 responsive grid with force-directed canvas graph rendering - Per-viewport add/remove nodes with color picker - Online/offline toggle per viewport - Bilateral sync between any viewport pair, or sync-all - Time slider (materialize at ceiling) with LIVE mode - Da Cone: click a node to inspect properties and edges - Vite stubs for node:crypto/stream/module and roaring (never invoked) - ESLint ignores demo/ directory (separate app conventions) --- demo/browsa/.gitignore | 2 + demo/browsa/index.html | 23 + demo/browsa/package-lock.json | 1499 ++++++++++++++++++ demo/browsa/package.json | 19 + demo/browsa/src/App.vue | 76 + demo/browsa/src/components/Controls.vue | 109 ++ demo/browsa/src/components/DaCone.vue | 175 ++ demo/browsa/src/components/GraphCanvas.vue | 224 +++ demo/browsa/src/components/GraphViewport.vue | 114 ++ demo/browsa/src/components/TimeSlider.vue | 108 ++ demo/browsa/src/main.js | 7 + demo/browsa/src/stores/graphStore.js | 252 +++ demo/browsa/src/stubs/empty.js | 3 + demo/browsa/src/stubs/node-crypto.js | 12 + demo/browsa/src/stubs/node-module.js | 7 + demo/browsa/src/stubs/node-stream.js | 9 + demo/browsa/src/sync/InProcessSyncBus.js | 62 + demo/browsa/vite.config.js | 34 + eslint.config.js | 1 + 19 files changed, 2736 insertions(+) create mode 100644 demo/browsa/.gitignore create mode 100644 demo/browsa/index.html create mode 100644 demo/browsa/package-lock.json create mode 100644 demo/browsa/package.json create mode 100644 demo/browsa/src/App.vue create mode 100644 demo/browsa/src/components/Controls.vue create mode 100644 demo/browsa/src/components/DaCone.vue create mode 100644 demo/browsa/src/components/GraphCanvas.vue create mode 100644 demo/browsa/src/components/GraphViewport.vue create mode 100644 demo/browsa/src/components/TimeSlider.vue create mode 100644 demo/browsa/src/main.js create mode 100644 demo/browsa/src/stores/graphStore.js create mode 100644 demo/browsa/src/stubs/empty.js create mode 100644 demo/browsa/src/stubs/node-crypto.js create mode 100644 demo/browsa/src/stubs/node-module.js create mode 100644 demo/browsa/src/stubs/node-stream.js create mode 100644 demo/browsa/src/sync/InProcessSyncBus.js create mode 100644 demo/browsa/vite.config.js diff --git a/demo/browsa/.gitignore b/demo/browsa/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/demo/browsa/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/demo/browsa/index.html b/demo/browsa/index.html new file mode 100644 index 00000000..dfad2ae0 --- /dev/null +++ b/demo/browsa/index.html @@ -0,0 +1,23 @@ + + + + + + Browsa -- git-warp in the Browser + + + +
+ + + diff --git a/demo/browsa/package-lock.json b/demo/browsa/package-lock.json new file mode 100644 index 00000000..91ae052c --- /dev/null +++ b/demo/browsa/package-lock.json @@ -0,0 +1,1499 @@ +{ + "name": "browsa-demo", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "browsa-demo", + "version": "0.0.0", + "dependencies": { + "pinia": "^3.0.2", + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "vite": "^6.3.5" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", + "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.29", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", + "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", + "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.29", + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", + "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", + "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", + "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", + "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/runtime-core": "3.5.29", + "@vue/shared": "3.5.29", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", + "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "vue": "3.5.29" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", + "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", + "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-sfc": "3.5.29", + "@vue/runtime-dom": "3.5.29", + "@vue/server-renderer": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/demo/browsa/package.json b/demo/browsa/package.json new file mode 100644 index 00000000..f3993704 --- /dev/null +++ b/demo/browsa/package.json @@ -0,0 +1,19 @@ +{ + "name": "browsa-demo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.13", + "pinia": "^3.0.2" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "vite": "^6.3.5" + } +} diff --git a/demo/browsa/src/App.vue b/demo/browsa/src/App.vue new file mode 100644 index 00000000..b8399a5f --- /dev/null +++ b/demo/browsa/src/App.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/demo/browsa/src/components/Controls.vue b/demo/browsa/src/components/Controls.vue new file mode 100644 index 00000000..782ddacd --- /dev/null +++ b/demo/browsa/src/components/Controls.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/demo/browsa/src/components/DaCone.vue b/demo/browsa/src/components/DaCone.vue new file mode 100644 index 00000000..07a1cb23 --- /dev/null +++ b/demo/browsa/src/components/DaCone.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/demo/browsa/src/components/GraphCanvas.vue b/demo/browsa/src/components/GraphCanvas.vue new file mode 100644 index 00000000..5be22348 --- /dev/null +++ b/demo/browsa/src/components/GraphCanvas.vue @@ -0,0 +1,224 @@ + + + diff --git a/demo/browsa/src/components/GraphViewport.vue b/demo/browsa/src/components/GraphViewport.vue new file mode 100644 index 00000000..e537b4bb --- /dev/null +++ b/demo/browsa/src/components/GraphViewport.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/demo/browsa/src/components/TimeSlider.vue b/demo/browsa/src/components/TimeSlider.vue new file mode 100644 index 00000000..8562bb2a --- /dev/null +++ b/demo/browsa/src/components/TimeSlider.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/demo/browsa/src/main.js b/demo/browsa/src/main.js new file mode 100644 index 00000000..56a060dc --- /dev/null +++ b/demo/browsa/src/main.js @@ -0,0 +1,7 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import App from './App.vue'; + +const app = createApp(App); +app.use(createPinia()); +app.mount('#app'); diff --git a/demo/browsa/src/stores/graphStore.js b/demo/browsa/src/stores/graphStore.js new file mode 100644 index 00000000..25d13bdc --- /dev/null +++ b/demo/browsa/src/stores/graphStore.js @@ -0,0 +1,252 @@ +import { defineStore } from 'pinia'; +import { ref, reactive } from 'vue'; +import { + WarpGraph, + InMemoryGraphAdapter, + WebCryptoAdapter, + generateWriterId, +} from '@git-stunts/git-warp/browser'; +import { sha1sync } from '@git-stunts/git-warp/sha1sync'; +import InProcessSyncBus from '../sync/InProcessSyncBus.js'; + +const VIEWPORT_COLORS = ['#ff7b72', '#79c0ff', '#7ee787', '#d2a8ff']; +const VIEWPORT_LABELS = ['Alpha', 'Beta', 'Gamma', 'Delta']; + +/** + * @typedef {Object} ViewportState + * @property {string} id + * @property {string} label + * @property {string} writerId + * @property {string} color + * @property {boolean} online + * @property {string|null} selectedNode + * @property {number} ceiling + * @property {number} maxCeiling + * @property {Array<{id: string, color: string, x: number, y: number}>} nodes + * @property {Array<{source: string, target: string, label: string}>} edges + * @property {import('@git-stunts/git-warp/browser').WarpGraph|null} graph + */ + +export const useGraphStore = defineStore('graph', () => { + const viewportIds = ref(['v0', 'v1', 'v2', 'v3']); + + /** @type {Record} */ + const viewports = reactive({}); + const syncBus = new InProcessSyncBus(); + + // All viewports share one persistence layer (simulating a shared Git repo) + const sharedPersistence = new InMemoryGraphAdapter({ hash: sha1sync }); + const sharedCrypto = new WebCryptoAdapter(); + + let _initialized = false; + + async function init() { + if (_initialized) { return; } + _initialized = true; + + for (let i = 0; i < 4; i++) { + const id = `v${i}`; + const writerId = generateWriterId(); + const graph = await WarpGraph.open({ + persistence: sharedPersistence, + graphName: 'browsa', + writerId, + crypto: sharedCrypto, + }); + + viewports[id] = { + id, + label: VIEWPORT_LABELS[i], + writerId, + color: VIEWPORT_COLORS[i], + online: true, + selectedNode: null, + ceiling: Infinity, + maxCeiling: 0, + nodes: [], + edges: [], + graph, + }; + + syncBus.register(id, graph); + } + } + + /** + * Add a colored node from a specific viewport. + * @param {string} viewportId + * @param {string} [nodeColor] + */ + async function addNode(viewportId, nodeColor) { + const vp = viewports[viewportId]; + if (!vp?.graph) { return; } + + const nodeId = `node:${vp.writerId.slice(0, 8)}-${Date.now().toString(36)}`; + const color = nodeColor || vp.color; + + const patch = await vp.graph.createPatch(); + patch.addNode(nodeId); + patch.setProperty(nodeId, 'color', color); + patch.setProperty(nodeId, 'label', nodeId.split(':')[1].slice(0, 6)); + await patch.commit(); + + await materializeViewport(viewportId); + } + + /** + * Add an edge between two nodes from a specific viewport. + * @param {string} viewportId + * @param {string} from + * @param {string} to + * @param {string} [label] + */ + async function addEdge(viewportId, from, to, label) { + const vp = viewports[viewportId]; + if (!vp?.graph) { return; } + + const patch = await vp.graph.createPatch(); + patch.addEdge(from, to, label || 'link'); + await patch.commit(); + + await materializeViewport(viewportId); + } + + /** + * Remove a node from a specific viewport. + * @param {string} viewportId + * @param {string} nodeId + */ + async function removeNode(viewportId, nodeId) { + const vp = viewports[viewportId]; + if (!vp?.graph) { return; } + + const patch = await vp.graph.createPatch(); + patch.removeNode(nodeId); + await patch.commit(); + + if (vp.selectedNode === nodeId) { + vp.selectedNode = null; + } + await materializeViewport(viewportId); + } + + /** + * Materialize the graph for a viewport and extract renderable state. + * @param {string} viewportId + */ + async function materializeViewport(viewportId) { + const vp = viewports[viewportId]; + if (!vp?.graph) { return; } + + const opts = vp.ceiling === Infinity ? {} : { ceiling: vp.ceiling }; + const state = await vp.graph.materialize(opts); + + // Extract alive nodes + const nodes = []; + for (const nodeId of state.nodeAlive.entries.keys()) { + const propKey = `${nodeId}\0color`; + const colorReg = state.prop.get(propKey); + const color = colorReg?.value || '#8b949e'; + + const labelKey = `${nodeId}\0label`; + const labelReg = state.prop.get(labelKey); + const label = labelReg?.value || nodeId.split(':')[1]?.slice(0, 6) || nodeId; + + nodes.push({ + id: nodeId, + color, + label, + x: 0, + y: 0, + }); + } + + // Extract alive edges + const edges = []; + for (const edgeKey of state.edgeAlive.entries.keys()) { + const parts = edgeKey.split('\0'); + if (parts.length >= 3) { + edges.push({ source: parts[0], target: parts[1], label: parts[2] }); + } + } + + // Update max ceiling from version vector + let maxTs = 0; + if (state.observedFrontier) { + for (const ts of state.observedFrontier.values()) { + if (ts > maxTs) { maxTs = ts; } + } + } + + vp.nodes = nodes; + vp.edges = edges; + vp.maxCeiling = maxTs; + } + + /** + * Set the time-travel ceiling for a viewport. + * @param {string} viewportId + * @param {number} ceiling + */ + async function setCeiling(viewportId, ceiling) { + const vp = viewports[viewportId]; + if (!vp) { return; } + vp.ceiling = ceiling; + await materializeViewport(viewportId); + } + + /** + * Toggle a viewport's online status. + * @param {string} viewportId + */ + function toggleOnline(viewportId) { + const vp = viewports[viewportId]; + if (vp) { vp.online = !vp.online; } + } + + /** + * Sync a specific viewport pair. + * @param {string} sourceId + * @param {string} targetId + */ + async function syncPair(sourceId, targetId) { + await syncBus.sync(sourceId, targetId); + await materializeViewport(sourceId); + await materializeViewport(targetId); + } + + /** + * Sync all viewports. + */ + async function syncAll() { + await syncBus.syncAll(); + for (const id of viewportIds.value) { + await materializeViewport(id); + } + } + + /** + * Select a node in a viewport (for Da Cone / provenance). + * @param {string} viewportId + * @param {string|null} nodeId + */ + function selectNode(viewportId, nodeId) { + const vp = viewports[viewportId]; + if (vp) { vp.selectedNode = nodeId; } + } + + return { + viewportIds, + viewports, + init, + addNode, + addEdge, + removeNode, + materializeViewport, + setCeiling, + toggleOnline, + syncPair, + syncAll, + selectNode, + }; +}); diff --git a/demo/browsa/src/stubs/empty.js b/demo/browsa/src/stubs/empty.js new file mode 100644 index 00000000..4debc9da --- /dev/null +++ b/demo/browsa/src/stubs/empty.js @@ -0,0 +1,3 @@ +// Stub module for Node-only packages that are lazy-loaded but never +// invoked in the browser code path. Vite aliases point here. +export default {}; diff --git a/demo/browsa/src/stubs/node-crypto.js b/demo/browsa/src/stubs/node-crypto.js new file mode 100644 index 00000000..a1219217 --- /dev/null +++ b/demo/browsa/src/stubs/node-crypto.js @@ -0,0 +1,12 @@ +// Stub for node:crypto in browser builds. +// These are never actually called in the browser code path — they exist +// only to satisfy Rollup's static import analysis. + +function notAvailable() { + throw new Error('node:crypto is not available in the browser'); +} + +export const createHash = notAvailable; +export const createHmac = notAvailable; +export const timingSafeEqual = notAvailable; +export default {}; diff --git a/demo/browsa/src/stubs/node-module.js b/demo/browsa/src/stubs/node-module.js new file mode 100644 index 00000000..14ea1622 --- /dev/null +++ b/demo/browsa/src/stubs/node-module.js @@ -0,0 +1,7 @@ +// Stub for node:module in browser builds. +function notAvailable() { + throw new Error('node:module is not available in the browser'); +} + +export const createRequire = notAvailable; +export default {}; diff --git a/demo/browsa/src/stubs/node-stream.js b/demo/browsa/src/stubs/node-stream.js new file mode 100644 index 00000000..352692bf --- /dev/null +++ b/demo/browsa/src/stubs/node-stream.js @@ -0,0 +1,9 @@ +// Stub for node:stream in browser builds. +function notAvailable() { + throw new Error('node:stream is not available in the browser'); +} + +export class Readable { + static from() { return notAvailable(); } +} +export default { Readable }; diff --git a/demo/browsa/src/sync/InProcessSyncBus.js b/demo/browsa/src/sync/InProcessSyncBus.js new file mode 100644 index 00000000..0637201e --- /dev/null +++ b/demo/browsa/src/sync/InProcessSyncBus.js @@ -0,0 +1,62 @@ +/** + * InProcessSyncBus — orchestrates sync between in-memory WarpGraph instances. + * + * Since all instances share the same InMemoryGraphAdapter, "sync" means + * ensuring each graph instance re-materializes to see all writers' patches. + * For the FETCH/PULL/PUSH/HTTP SYNC simulation, we use the actual + * SyncProtocol methods available on WarpGraph. + */ + +/** + * @typedef {{ id: string, graph: import('@git-stunts/git-warp/browser').WarpGraph }} Registration + */ + +export default class InProcessSyncBus { + constructor() { + /** @type {Map} */ + this._graphs = new Map(); + } + + /** + * @param {string} id + * @param {import('@git-stunts/git-warp/browser').WarpGraph} graph + */ + register(id, graph) { + this._graphs.set(id, graph); + } + + /** + * @param {string} id + */ + unregister(id) { + this._graphs.delete(id); + } + + /** + * Bilateral sync: both graphs re-materialize to see each other's patches. + * Since they share a persistence layer, patches are already visible — + * materialization is all that's needed. + * + * @param {string} a + * @param {string} b + * @returns {Promise} + */ + async sync(a, b) { + const ga = this._graphs.get(a); + const gb = this._graphs.get(b); + if (!ga || !gb) { + throw new Error(`Unknown viewport: ${!ga ? a : b}`); + } + await ga.materialize(); + await gb.materialize(); + } + + /** + * Sync all registered graphs — each re-materializes. + * @returns {Promise} + */ + async syncAll() { + const graphs = [...this._graphs.values()]; + await Promise.all(graphs.map((g) => g.materialize())); + } +} diff --git a/demo/browsa/vite.config.js b/demo/browsa/vite.config.js new file mode 100644 index 00000000..f63a9df3 --- /dev/null +++ b/demo/browsa/vite.config.js @@ -0,0 +1,34 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import { resolve } from 'node:path'; + +export default defineConfig({ + plugins: [vue()], + build: { + // Top-level await (used by InMemoryGraphAdapter and defaultCrypto lazy-loading) + // requires modern browser targets. + target: 'es2022', + }, + resolve: { + alias: { + '@git-stunts/git-warp/browser': resolve(__dirname, '../../browser.js'), + '@git-stunts/git-warp/sha1sync': resolve( + __dirname, + '../../src/infrastructure/adapters/sha1sync.js', + ), + // Stub out Node-only packages that are lazy-loaded but never + // actually invoked in the browser code path. + 'roaring': resolve(__dirname, 'src/stubs/empty.js'), + 'roaring-wasm': resolve(__dirname, 'src/stubs/empty.js'), + '@git-stunts/plumbing': resolve(__dirname, 'src/stubs/empty.js'), + '@git-stunts/git-cas': resolve(__dirname, 'src/stubs/empty.js'), + 'node:crypto': resolve(__dirname, 'src/stubs/node-crypto.js'), + 'node:stream': resolve(__dirname, 'src/stubs/node-stream.js'), + 'node:module': resolve(__dirname, 'src/stubs/node-module.js'), + }, + }, + optimizeDeps: { + include: ['cbor-x'], + exclude: ['roaring', 'roaring-wasm'], + }, +}); diff --git a/eslint.config.js b/eslint.config.js index 0521617e..14cad445 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,7 @@ export default tseslint.config( ignores: [ "node_modules/**", "coverage/**", + "demo/**", "examples/html/assets/**", "scripts/**", ], From 02d7c78628376873fb2df2e465954c1dfdd9902c Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 6 Mar 2026 19:15:15 -0800 Subject: [PATCH 03/95] feat(browsa): replace force-directed layout with ELK Sugiyama MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the hand-rolled force-directed canvas renderer for ELK's layered (Sugiyama) layout engine with inline SVG rendering. - Uses existing elkAdapter.js + elkLayout.js pipeline from the visualization module — no new layout code needed - Orthogonal edge routing with arrowheads via SVG markers - Colored node badges with glow effect on selection - ELK bundle (~1.4 MB) lazy-loaded as a separate chunk - Empty state placeholder text when no nodes exist --- demo/browsa/src/components/GraphCanvas.vue | 373 ++++++++++----------- demo/browsa/vite.config.js | 6 + 2 files changed, 185 insertions(+), 194 deletions(-) diff --git a/demo/browsa/src/components/GraphCanvas.vue b/demo/browsa/src/components/GraphCanvas.vue index 5be22348..7f8804cc 100644 --- a/demo/browsa/src/components/GraphCanvas.vue +++ b/demo/browsa/src/components/GraphCanvas.vue @@ -1,5 +1,7 @@ + + diff --git a/demo/browsa/vite.config.js b/demo/browsa/vite.config.js index f63a9df3..e19d099f 100644 --- a/demo/browsa/vite.config.js +++ b/demo/browsa/vite.config.js @@ -31,4 +31,10 @@ export default defineConfig({ include: ['cbor-x'], exclude: ['roaring', 'roaring-wasm'], }, + // elkjs resolves from the parent git-warp node_modules + server: { + fs: { + allow: [resolve(__dirname, '../..')], + }, + }, }); From d765720a6a71b1946c41e2c8fe2220630c8961a6 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 6 Mar 2026 19:20:41 -0800 Subject: [PATCH 04/95] feat(browsa): viewport-culled rendering with pan/zoom Only render SVG elements for nodes and edges that intersect the current camera viewport. ELK still layouts the full graph, but the DOM only contains what's visible. - Camera: pan (drag), zoom (scroll wheel) with cursor-anchored zoom - ResizeObserver tracks container dimensions - Cull margin (60px) prevents pop-in at viewport edges - Edge visibility: shown if either endpoint node is visible or any path point crosses the viewport - HUD counter: "visible/total" node count in bottom-right - Auto-fit on layout: centers and scales graph to fill viewport - grab/grabbing cursors for pan affordance --- demo/browsa/src/components/GraphCanvas.vue | 253 +++++++++++++++++---- 1 file changed, 207 insertions(+), 46 deletions(-) diff --git a/demo/browsa/src/components/GraphCanvas.vue b/demo/browsa/src/components/GraphCanvas.vue index 7f8804cc..493c1a40 100644 --- a/demo/browsa/src/components/GraphCanvas.vue +++ b/demo/browsa/src/components/GraphCanvas.vue @@ -1,5 +1,5 @@ @@ -195,7 +352,11 @@ onMounted(() => layout()); width: 100%; height: 100%; background: #0d1117; - cursor: default; + cursor: grab; + touch-action: none; +} +.graph-svg:active { + cursor: grabbing; } .node-group { cursor: pointer; From 1cf9151298fc66939ebc52d6577988e65251d5f9 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 6 Mar 2026 19:22:32 -0800 Subject: [PATCH 05/95] =?UTF-8?q?feat(browsa):=20object-pooled=20SVG=20ren?= =?UTF-8?q?dering=20=E2=80=94=20zero=20DOM=20churn=20on=20pan/zoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Vue's v-for culling (which creates/destroys DOM elements as nodes enter/leave the viewport) with fixed-size object pools. Node and edge pools use monotonic high-water marks — once a slot is allocated, it persists for the component's lifetime. Vue keys by slot index (0..N), so DOM elements are reused via attribute patching, never created or destroyed during camera movement. - nodePool/edgePool: computed arrays with HWM growth, never shrink - Active slots get current node/edge data; inactive slots get frozen sentinel objects positioned off-screen with v-show=false - EMPTY_NODE/EMPTY_EDGE: Object.freeze'd sentinels to avoid reactive overhead on inactive slots - Same viewport culling + pan/zoom as before, just zero GC pressure from DOM node lifecycle during interaction --- demo/browsa/src/components/GraphCanvas.vue | 118 ++++++++++++++------- 1 file changed, 79 insertions(+), 39 deletions(-) diff --git a/demo/browsa/src/components/GraphCanvas.vue b/demo/browsa/src/components/GraphCanvas.vue index 493c1a40..5322df71 100644 --- a/demo/browsa/src/components/GraphCanvas.vue +++ b/demo/browsa/src/components/GraphCanvas.vue @@ -11,7 +11,7 @@ const props = defineProps({ const emit = defineEmits(['select']); -// ── Full ELK layout (all nodes) ──────────────────────────────────── +// ── Full ELK layout (all nodes, computed once) ───────────────────── const allNodes = ref([]); const allEdges = ref([]); const fullWidth = ref(300); @@ -28,8 +28,6 @@ const zoom = ref(1); const MIN_ZOOM = 0.15; const MAX_ZOOM = 3; -// Inflate the cull rect by this margin so edge stubs and labels don't -// pop in right at the boundary. const CULL_MARGIN = 60; let resizeObs = null; @@ -39,7 +37,7 @@ let panStartY = 0; let camStartX = 0; let camStartY = 0; -// ── Visible rect in graph-space ──────────────────────────────────── +// ── Viewport rect in graph-space ─────────────────────────────────── const viewBox = computed(() => { const vw = containerW.value / zoom.value; const vh = containerH.value / zoom.value; @@ -57,22 +55,22 @@ function rectIntersects(ax, ay, aw, ah) { ); } -// ── Culled sets ──────────────────────────────────────────────────── +// ── Visibility pass ──────────────────────────────────────────────── const visibleNodes = computed(() => allNodes.value.filter((n) => rectIntersects(n.x + PADDING, n.y + PADDING, n.width, n.height), ), ); -const visibleNodeIds = computed(() => new Set(visibleNodes.value.map((n) => n.originalId))); +const visibleNodeIds = computed(() => + new Set(visibleNodes.value.map((n) => n.originalId)), +); const visibleEdges = computed(() => allEdges.value.filter((e) => { - // Show edge if either endpoint is visible if (visibleNodeIds.value.has(e.source) || visibleNodeIds.value.has(e.target)) { return true; } - // Also show if any point on the edge path is inside the viewport for (const s of e.sections || []) { for (const pt of [s.startPoint, s.endPoint, ...(s.bendPoints || [])]) { if (pt && rectIntersects(pt.x + PADDING, pt.y + PADDING, 0, 0)) { @@ -84,6 +82,50 @@ const visibleEdges = computed(() => }), ); +// ── Object pools (never shrink, only grow) ───────────────────────── +// Pool high-water marks: once allocated, slots persist for the life +// of the component. Vue keys by slot index, so DOM elements are +// reused — never created or destroyed during pan/zoom. +let nodePoolHWM = 0; +let edgePoolHWM = 0; + +const EMPTY_NODE = Object.freeze({ + active: false, originalId: '', x: -9999, y: -9999, + width: 0, height: 0, color: 'transparent', label: '', +}); +const EMPTY_EDGE = Object.freeze({ + active: false, id: '', points: '', +}); + +const nodePool = computed(() => { + const vis = visibleNodes.value; + // Grow pool if needed (never shrink) + nodePoolHWM = Math.max(nodePoolHWM, vis.length); + const slots = new Array(nodePoolHWM); + for (let i = 0; i < nodePoolHWM; i++) { + if (i < vis.length) { + slots[i] = { ...vis[i], active: true }; + } else { + slots[i] = EMPTY_NODE; + } + } + return slots; +}); + +const edgePool = computed(() => { + const vis = visibleEdges.value; + edgePoolHWM = Math.max(edgePoolHWM, vis.length); + const slots = new Array(edgePoolHWM); + for (let i = 0; i < edgePoolHWM; i++) { + if (i < vis.length) { + slots[i] = { active: true, id: vis[i].id, points: edgePointsStr(vis[i]) }; + } else { + slots[i] = EMPTY_EDGE; + } + } + return slots; +}); + const cullStats = computed(() => `${visibleNodes.value.length}/${allNodes.value.length}`, ); @@ -130,7 +172,6 @@ async function layout() { fullWidth.value = Math.max(positioned.width + PADDING * 2, 100); fullHeight.value = Math.max(positioned.height + PADDING * 2, 80); - // Auto-fit: center the graph in the viewport on first layout fitToView(); } @@ -176,14 +217,12 @@ function handleBgClick() { function onWheel(e) { e.preventDefault(); const rect = svgRef.value.getBoundingClientRect(); - // Mouse position in graph-space before zoom const mx = camX.value + ((e.clientX - rect.left) / zoom.value); const my = camY.value + ((e.clientY - rect.top) / zoom.value); const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom.value * factor)); - // Adjust camera so the point under the cursor stays fixed camX.value = mx - (e.clientX - rect.left) / newZoom; camY.value = my - (e.clientY - rect.top) / newZoom; zoom.value = newZoom; @@ -273,48 +312,49 @@ onUnmounted(() => { - - - - + + + + - + - {{ node.label }} + {{ slot.label }} @@ -332,7 +372,7 @@ onUnmounted(() => { + Node to start - + Date: Fri, 6 Mar 2026 19:52:04 -0800 Subject: [PATCH 06/95] fix(browsa): resolve materialization failures in browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues prevented materialize() from working in the browser: 1. **Buffer.byteLength in @git-stunts/trailer-codec**: The trailer codec's MessageNormalizer uses Buffer.byteLength() which doesn't exist in browsers. This caused detectMessageKind() to silently return null (swallowed by try/catch), making the patch walker think every commit was a non-patch and returning 0 patches. Fix: Vite plugin that replaces Buffer.byteLength() calls with TextEncoder-based equivalent, scoped only to trailer-codec files. A global Buffer polyfill was rejected because it breaks cbor-x, which detects Buffer and then expects Buffer.prototype.utf8Write (a V8-only method). 2. **Vue reactive Proxy vs ES private fields**: WarpGraph's ProvenanceIndex uses #private fields. Vue's reactive() wraps objects in Proxies, and private field access requires `this` to be the real instance — not a Proxy. This caused "Receiver must be an instance of class ProvenanceIndex" errors. Fix: markRaw() on WarpGraph instances to exclude them from Vue's reactivity system. 3. **SVG resize feedback loop**: The SVG element's intrinsic sizing caused the flex container to grow, triggering ResizeObserver, which changed the viewBox, which grew the SVG again — an infinite loop that pushed controls off-screen. Fix: position: absolute + overflow: hidden on the viewport body to decouple SVG sizing from flex layout. Also adds InsecureCryptoAdapter for plain HTTP contexts (Docker) and server.allowedHosts for Vite dev server access. --- demo/browsa/src/components/GraphCanvas.vue | 2 + demo/browsa/src/components/GraphViewport.vue | 2 + demo/browsa/src/stores/graphStore.js | 17 +++++-- demo/browsa/src/sync/InsecureCryptoAdapter.js | 49 +++++++++++++++++++ demo/browsa/vite.config.js | 35 ++++++++++++- 5 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 demo/browsa/src/sync/InsecureCryptoAdapter.js diff --git a/demo/browsa/src/components/GraphCanvas.vue b/demo/browsa/src/components/GraphCanvas.vue index 5322df71..80189fc6 100644 --- a/demo/browsa/src/components/GraphCanvas.vue +++ b/demo/browsa/src/components/GraphCanvas.vue @@ -389,6 +389,8 @@ onUnmounted(() => { diff --git a/demo/browsa/src/stores/graphStore.js b/demo/browsa/src/stores/graphStore.js index 7ca1eed6..d2b47ddf 100644 --- a/demo/browsa/src/stores/graphStore.js +++ b/demo/browsa/src/stores/graphStore.js @@ -28,6 +28,91 @@ const VIEWPORT_LABELS = ['Alpha', 'Beta', 'Gamma', 'Delta']; * @property {import('@git-stunts/git-warp/browser').WarpGraph|null} graph */ +/** + * @typedef {Object} Scenario + * @property {string} name + * @property {string} description + * @property {Array<{action: string, args: unknown[], delay?: number}>} steps + */ + +/** @type {Scenario[]} */ +const SCENARIOS = [ + { + name: 'Two Writers, One Graph', + description: 'Alpha and Beta each add nodes, then sync to see each other\'s work.', + steps: [ + { action: 'addNode', args: ['v0'], delay: 400 }, + { action: 'addNode', args: ['v0'], delay: 400 }, + { action: 'addNode', args: ['v1'], delay: 400 }, + { action: 'addNode', args: ['v1'], delay: 400 }, + { action: 'syncAll', args: [], delay: 600 }, + ], + }, + { + name: 'Offline Divergence', + description: 'All writers go offline, each adds nodes, then come online and sync — CRDT merge!', + steps: [ + { action: 'toggleOnline', args: ['v0'], delay: 200 }, + { action: 'toggleOnline', args: ['v1'], delay: 200 }, + { action: 'toggleOnline', args: ['v2'], delay: 200 }, + { action: 'toggleOnline', args: ['v3'], delay: 200 }, + { action: 'addNode', args: ['v0'], delay: 300 }, + { action: 'addNode', args: ['v0'], delay: 300 }, + { action: 'addNode', args: ['v1'], delay: 300 }, + { action: 'addNode', args: ['v1'], delay: 300 }, + { action: 'addNode', args: ['v2'], delay: 300 }, + { action: 'addNode', args: ['v3'], delay: 300 }, + { action: 'addNode', args: ['v3'], delay: 300 }, + { action: 'toggleOnline', args: ['v0'], delay: 400 }, + { action: 'toggleOnline', args: ['v1'], delay: 400 }, + { action: 'toggleOnline', args: ['v2'], delay: 400 }, + { action: 'toggleOnline', args: ['v3'], delay: 400 }, + { action: 'syncAll', args: [], delay: 600 }, + ], + }, + { + name: 'Add & Remove', + description: 'Alpha adds three nodes, removes the middle one, syncs to Beta.', + steps: [ + { action: 'addNode', args: ['v0'], delay: 400 }, + { action: 'addNode', args: ['v0'], delay: 400 }, + { action: 'addNode', args: ['v0'], delay: 400 }, + { action: 'removeLatestNode', args: ['v0', 1], delay: 600 }, + { action: 'syncPair', args: ['v0', 'v1'], delay: 600 }, + ], + }, + { + name: 'Edge Network', + description: 'Build a small connected graph across two writers.', + steps: [ + { action: 'addNode', args: ['v0'], delay: 300 }, + { action: 'addNode', args: ['v0'], delay: 300 }, + { action: 'addNode', args: ['v1'], delay: 300 }, + { action: 'syncAll', args: [], delay: 400 }, + { action: 'addEdgeBetweenLatest', args: ['v0', 0, 1], delay: 400 }, + { action: 'addEdgeBetweenLatest', args: ['v1', 1, 2], delay: 400 }, + { action: 'syncAll', args: [], delay: 600 }, + ], + }, + { + name: 'Time Travel', + description: 'Add nodes over time, then scrub the timeline to watch the graph grow.', + steps: [ + { action: 'addNode', args: ['v0'], delay: 400 }, + { action: 'addNode', args: ['v0'], delay: 400 }, + { action: 'addNode', args: ['v0'], delay: 400 }, + { action: 'addNode', args: ['v0'], delay: 400 }, + { action: 'addNode', args: ['v0'], delay: 400 }, + { action: 'setCeiling', args: ['v0', 1], delay: 600 }, + { action: 'setCeiling', args: ['v0', 2], delay: 600 }, + { action: 'setCeiling', args: ['v0', 3], delay: 600 }, + { action: 'setCeiling', args: ['v0', 4], delay: 600 }, + { action: 'setCeiling', args: ['v0', 5], delay: 600 }, + { action: 'setCeiling', args: ['v0', Infinity], delay: 400 }, + ], + }, +]; + export const useGraphStore = defineStore('graph', () => { const viewportIds = ref(['v0', 'v1', 'v2', 'v3']); @@ -150,9 +235,17 @@ export const useGraphStore = defineStore('graph', () => { const opts = vp.ceiling === Infinity ? {} : { ceiling: vp.ceiling }; const state = await vp.graph.materialize(opts); - // Extract alive nodes + // Extract alive nodes — must check tombstones, not just entries. + // ORSet.entries contains ALL elements (including tombstoned ones); + // an element is alive only if it has at least one non-tombstoned dot. const nodes = []; - for (const nodeId of state.nodeAlive.entries.keys()) { + for (const [nodeId, dots] of state.nodeAlive.entries) { + let alive = false; + for (const dot of dots) { + if (!state.nodeAlive.tombstones.has(dot)) { alive = true; break; } + } + if (!alive) { continue; } + const propKey = `${nodeId}\0color`; const colorReg = state.prop.get(propKey); const color = colorReg?.value || '#8b949e'; @@ -170,9 +263,15 @@ export const useGraphStore = defineStore('graph', () => { }); } - // Extract alive edges + // Extract alive edges — same tombstone check as nodes. const edges = []; - for (const edgeKey of state.edgeAlive.entries.keys()) { + for (const [edgeKey, dots] of state.edgeAlive.entries) { + let alive = false; + for (const dot of dots) { + if (!state.edgeAlive.tombstones.has(dot)) { alive = true; break; } + } + if (!alive) { continue; } + const parts = edgeKey.split('\0'); if (parts.length >= 3) { edges.push({ source: parts[0], target: parts[1], label: parts[2] }); @@ -244,6 +343,115 @@ export const useGraphStore = defineStore('graph', () => { if (vp) { vp.selectedNode = nodeId; } } + // ── Scenario runner ───────────────────────────────────────────────── + + const scenarios = ref(SCENARIOS); + const scenarioRunning = ref(false); + const scenarioStep = ref(-1); + /** @type {import('vue').Ref} */ + const scenarioName = ref(null); + let _scenarioAbort = new AbortController(); + + /** Ordered list of node IDs added during a scenario (for referencing by index). */ + let _scenarioNodes = []; + + /** + * Remove the nth node added during this scenario from a viewport. + * @param {string} viewportId + * @param {number} index + */ + async function removeLatestNode(viewportId, index) { + const nodeId = _scenarioNodes[index]; + if (nodeId) { + await removeNode(viewportId, nodeId); + } + } + + /** + * Add an edge between scenario nodes by index. + * @param {string} viewportId + * @param {number} fromIdx + * @param {number} toIdx + */ + async function addEdgeBetweenLatest(viewportId, fromIdx, toIdx) { + const from = _scenarioNodes[fromIdx]; + const to = _scenarioNodes[toIdx]; + if (from && to) { + await addEdge(viewportId, from, to); + } + } + + /** @type {Record} */ + const scenarioActions = { + addNode: async (/** @type {string} */ vpId, /** @type {string|undefined} */ color) => { + await addNode(vpId, color); + // Track the node we just added + const vp = viewports[vpId]; + if (vp?.nodes.length > 0) { + const newest = vp.nodes[vp.nodes.length - 1]; + if (!_scenarioNodes.includes(newest.id)) { + _scenarioNodes.push(newest.id); + } + } + }, + addEdge, + removeNode, + removeLatestNode, + addEdgeBetweenLatest, + syncPair, + syncAll, + toggleOnline, + setCeiling, + selectNode, + materializeViewport, + }; + + /** + * Run a scenario by index. + * @param {number} index + */ + async function runScenario(index) { + if (scenarioRunning.value) { stopScenario(); } + const scenario = SCENARIOS[index]; + if (!scenario) { return; } + + _scenarioAbort = new AbortController(); + scenarioRunning.value = true; + scenarioName.value = scenario.name; + scenarioStep.value = 0; + _scenarioNodes = []; + + for (let i = 0; i < scenario.steps.length; i++) { + if (_scenarioAbort.signal.aborted) { break; } + scenarioStep.value = i; + const step = scenario.steps[i]; + const fn = scenarioActions[step.action]; + if (fn) { + await fn(...step.args); + } + if (_scenarioAbort.signal.aborted) { break; } + const delay = step.delay || 300; + await new Promise((resolve) => { + const timer = setTimeout(resolve, delay); + _scenarioAbort.signal.addEventListener('abort', () => { + clearTimeout(timer); + resolve(undefined); + }, { once: true }); + }); + } + + scenarioRunning.value = false; + scenarioStep.value = -1; + scenarioName.value = null; + } + + function stopScenario() { + _scenarioAbort.abort(); + scenarioRunning.value = false; + scenarioStep.value = -1; + scenarioName.value = null; + } + return { viewportIds, viewports, @@ -257,5 +465,12 @@ export const useGraphStore = defineStore('graph', () => { syncPair, syncAll, selectNode, + // Scenarios + scenarios, + scenarioRunning, + scenarioStep, + scenarioName, + runScenario, + stopScenario, }; }); From 622f5292ed246975cfcd7fe7d00bf40d3e2c2575 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 6 Mar 2026 20:38:32 -0800 Subject: [PATCH 08/95] fix(types): widen TreePort.readTree() return from Buffer to Uint8Array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InMemoryGraphAdapter (browser-safe) returns Uint8Array from readTree(), but TreePort declared Buffer. Since Buffer extends Uint8Array, widening the port type is non-breaking — GitGraphAdapter's Buffer return is still assignable. Fixes 22 pre-existing TS2322 errors in test files using InMemoryGraphAdapter with TreePort-typed parameters. --- index.d.ts | 2 +- src/ports/TreePort.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/index.d.ts b/index.d.ts index 8ee27844..2283701b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -633,7 +633,7 @@ export class GitGraphAdapter extends GraphPersistencePort implements IndexStorag logNodes(options: ListNodesOptions & { format: string }): Promise; writeBlob(content: Buffer | string): Promise; writeTree(entries: string[]): Promise; - readTree(treeOid: string): Promise>; + readTree(treeOid: string): Promise>; readTreeOids(treeOid: string): Promise>; readBlob(oid: string): Promise; updateRef(ref: string, oid: string): Promise; diff --git a/src/ports/TreePort.js b/src/ports/TreePort.js index 6f61a5d3..56704411 100644 --- a/src/ports/TreePort.js +++ b/src/ports/TreePort.js @@ -21,7 +21,7 @@ export default class TreePort { /** * Reads a tree and returns a map of path to content. * @param {string} _treeOid - The tree OID to read - * @returns {Promise>} Map of file path to blob content + * @returns {Promise>} Map of file path to blob content * @throws {Error} If not implemented by a concrete adapter */ async readTree(_treeOid) { From ec3cf04b404dbd23c574e73df36f1378642c4b36 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 7 Mar 2026 16:56:44 -0800 Subject: [PATCH 09/95] refactor(browsa): rename DaCone to Inspector Rename the node inspector panel component from the informal "DaCone" to the professional "Inspector". Updates component file, CSS classes, imports, and template references across all three affected files. --- demo/browsa/src/components/GraphViewport.vue | 4 +-- .../components/{DaCone.vue => Inspector.vue} | 34 +++++++++---------- demo/browsa/src/stores/graphStore.js | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) rename demo/browsa/src/components/{DaCone.vue => Inspector.vue} (85%) diff --git a/demo/browsa/src/components/GraphViewport.vue b/demo/browsa/src/components/GraphViewport.vue index c89e9ffa..af1e9391 100644 --- a/demo/browsa/src/components/GraphViewport.vue +++ b/demo/browsa/src/components/GraphViewport.vue @@ -4,7 +4,7 @@ import { useGraphStore } from '../stores/graphStore.js'; import GraphCanvas from './GraphCanvas.vue'; import Controls from './Controls.vue'; import TimeSlider from './TimeSlider.vue'; -import DaCone from './DaCone.vue'; +import Inspector from './Inspector.vue'; const props = defineProps({ viewportId: String }); const store = useGraphStore(); @@ -37,7 +37,7 @@ const vp = computed(() => store.viewports[props.viewportId]); -