+ Composable ESLint flat-config presets — assemble a linting setup from + small, focused building blocks instead of one monolithic config. +
+
+ Modern ESLint flat config is just an ordered array of config objects, but
+ wiring up plugins, parsers, and rule sets by hand is repetitive and easy
+ to get wrong. @robonen/eslint ships a curated set of presets
+ — base, typescript, vue,
+ vitest, imports, node,
+ regexp, and stylistic — and a single
+ compose() helper that flattens them into one config array.
+ Pick the presets your project needs, layer your own overrides on top, and
+ you have a consistent, type-safe lint setup in a few lines.
+
+ Mix and match focused presets per language and tool. Each preset is a + plain flat-config array — no magic, no hidden state. +
++ Append inline config objects after presets. Later entries win, exactly + as ESLint flat-config semantics intend. +
+
+ compose() skips false/null/undefined
+ entries, so feature flags and conditional spreads just work.
+
+ Exported FlatConfig and Rules types give you
+ editor autocomplete and type-checked overrides in
+ eslint.config.ts.
+
+ Install the package alongside ESLint and jiti (so ESLint can
+ load a TypeScript eslint.config.ts).
+
+ Create eslint.config.ts in your project root and compose the
+ presets you want:
+
+ Add inline config objects after the presets to tweak rules — later + entries override earlier ones: +
+@robonen/oxlint.
+ + Shared, strict TypeScript configurations — a small set of layered + presets you extend instead of copying compiler options between packages. +
+
+ Every package in a monorepo wants the same modern, strict TypeScript
+ baseline, but keeping a dozen tsconfig.json files in sync by
+ hand drifts almost immediately. @robonen/tsconfig ships one
+ carefully tuned base config and three environment layers
+ — dom, vue, and node — that extend
+ it. Point your package's extends at the right preset and you
+ inherit a consistent, bundler-first, type-check-only setup with no local
+ compiler options to maintain.
+
+ base → dom → vue, plus
+ a sibling node layer. Extend the one that matches the
+ environment; everything else is inherited.
+
+ strict plus noUncheckedIndexedAccess,
+ noImplicitOverride, noImplicitReturns and
+ noFallthroughCasesInSwitch are on out of the box.
+
+ module: Preserve with Bundler resolution,
+ verbatimModuleSyntax and isolatedModules.
+ Emit is noEmit — declarations come from
+ tsdown.
+
+ Browser src (DOM, no Node globals) and tooling files
+ (node types, no DOM) split into separate projects wired
+ with project references.
+
+ Add the package as a dev dependency. It ships only JSON presets — no + runtime code. +
+
+ Pick the preset that matches the package and extend it from your
+ tsconfig.json:
+
+ Vue SFC packages extend the vue layer (adds
+ jsx: preserve and strict
+ vueCompilerOptions) and can declare path aliases inline:
+
+ Note: path aliases resolve relative
+ to the tsconfig.json location —
+ baseUrl is intentionally omitted
+ (deprecated, removed in TypeScript 7.0).
+
+ Most packages mix browser src with Node tooling files
+ (vite.config.ts, vitest.config.ts,
+ tsdown.config.ts). Split them into two projects wired with
+ references so src never sees Node globals and config files
+ never see DOM, then type-check the whole package with
+ tsc -b / vue-tsc -b:
+
base, dom, vue and
+ node, with what each layer adds.
+ composite and
+ tsBuildInfoFile wiring.
+ base preset turns on.
+
+ Shared tsdown build configuration for every @robonen
+ package — one source of truth for output formats, declarations, and
+ bundle hygiene.
+
+ Every library in this monorepo ships dual ESM/CJS builds with type
+ declarations, a clean dist, and a consistent license
+ banner. Re-declaring that in each package's
+ tsdown.config.ts is repetitive and drifts over time.
+ @robonen/tsdown exports a single
+ sharedConfig object you spread into
+ defineConfig, then add only what is package-specific —
+ usually just entry and tsconfig.
+
+ Emits both esm and cjs formats so packages
+ work in modern bundlers and legacy require setups alike.
+
+ dts: true generates .d.ts declarations on
+ every build — no separate type pipeline to maintain.
+
+ clean: true wipes dist first and
+ hash: false keeps file names deterministic for
+ predictable publishing.
+
+ It is a plain object typed as InlineConfig — spread it,
+ override any field, and let editor autocomplete guide you.
+
+ Add the config package and tsdown itself as dev
+ dependencies:
+
+ Create tsdown.config.ts in your package, spread
+ sharedConfig, and supply your entry points:
+
+ Because sharedConfig is a normal object, you can override
+ or extend any field after spreading — add plugins, extra entries, or
+ tweak the declaration options:
+
+ Every primitive in @robonen/crdt rests on one small idea: if all replicas agree on a
+ deterministic total order over operations, then applying the same set of operations
+ — in any order, with duplicates, after any delay — always produces the same state. This page builds
+ that mental model from the ground up: sites and replicas, Lamport clocks and op ids, the single
+ tie-break that resolves every conflict, version vectors for deduplication and deltas, and the three
+ algebraic properties that make convergence inevitable rather than hopeful.
+
+ A replica is one copy of the shared state — a browser tab, a mobile app, a server
+ process. Each replica is owned by exactly one site, identified by a
+ SiteId (just a string). The site id is the thing that makes one replica distinguishable
+ from every other, so it must be unique across all participants. Use createSiteId to mint
+ one when a session begins; it trades on randomness for uniqueness, not secrecy, so there's no crypto
+ dependency.
+
+ Replicas never share mutable memory. They evolve independently and communicate only by exchanging + operations — small, self-describing facts like "insert this character" or "set this + key". The whole job of a CRDT is to make sure that once two replicas have seen the same operations, + they hold the same state, no matter what the network did to the messages in between. +
+
+ For replicas to talk about the same operation — to deduplicate it, to refer to it as a causal
+ dependency, to break ties against it — every operation needs a stable, globally unique name. That
+ name is an OpId: a per-site counter (its Lamport clock) tagged with the
+ site that produced it.
+
+ Because the counter is local to a site and the id carries that site, two replicas can generate ids
+ completely independently and never collide. There's no coordination, no central allocator, no UUID
+ round-trips — uniqueness falls out of the structure. opIdToString gives the canonical
+ site@clock form, handy as a map key or for logging.
+
+ A bare per-site counter is unique, but it isn't enough to compare two operations from different
+ sites in a meaningful way. LamportClock fixes that. It hands out monotonically
+ increasing ids via tick(), and — crucially — it observe()s the clocks of
+ remote operations it learns about, jumping its own counter ahead so that anything it produces next is
+ numbered after what it has already seen.
+
+ This is the Lamport happens-before rule in miniature: if operation + A causally precedes B (B was generated by a replica that had + already seen A), then A's clock is strictly less than B's. The converse isn't guaranteed — two ops + with unrelated clocks may simply be concurrent, produced by replicas that hadn't yet + heard from each other. That's fine, and expected: concurrency is exactly the situation a CRDT exists + to resolve. +
+
+ Lamport clocks give a partial order — they leave concurrent operations incomparable. But to
+ converge, every replica must agree on a single total order so that any two
+ operations can be ranked the same way everywhere. compareOpId is that total order, and it
+ is the only conflict-resolution rule in the entire library:
+
+ That second rule is the quiet hero of the whole design. The choice of winner doesn't matter; what + matters is that every replica makes the same choice. Because site ids are unique and string + comparison is deterministic, two replicas resolving the same concurrent edit will always pick the + same survivor. That single shared decision is what lets a last-writer-wins register and a sequence + CRDT, built by different code, nonetheless agree on the final document. +
+
+ Why one rule for everything?
+ LwwRegister uses
+ compareOpId to pick the surviving value;
+ Rga uses it to break ties between concurrent inserts at
+ the same position; MarkStore uses it to decide which
+ formatting wins per character. One total order, applied consistently, is what turns a pile of
+ independent primitives into a coherent, converging system.
+
+ Op ids order operations; a VersionVector summarizes which operations a replica
+ has seen. It maps each known site to the highest clock observed from it. Its power comes from one
+ assumption: per-site clocks are dense — a site emits 1, 2, 3, … with no
+ gaps. Given that, "highest clock seen from site X" implies "every op from X up to that clock has been
+ seen", so a single integer per site captures the entire causal history.
+
+ Networks redeliver. Because operations are idempotent (more on that below), re-applying one is
+ harmless — but vv.has(id) lets you skip the work entirely. If the vector already covers
+ an op's site and clock, you've seen it; drop it before it ever touches your state. This is the first
+ line of defense that keeps duplicate messages from doing anything observable.
+
+ The same vector drives efficient sync. When a peer tells you its version vector, you compare it
+ against your own op log and send back only the operations it's missing — never the whole
+ document. A site with clock 4 in their vector but 9 in yours means ops
+ 5 through 9 are the delta. Version vectors are tiny and serialize to a plain
+ { site: clock } object, so they're cheap to ship as the "here's what I have" handshake.
+
+ Density matters.
+ VersionVector only works because clocks arrive without
+ gaps. If you generate ids with a raw LamportClock, deliver
+ them in order per site (the Replica's causal buffer does
+ this for you) so a single high-water mark per site can stand in for the full set of seen ops.
+
+ Everything above exists to guarantee three algebraic properties of operations. They're the formal + promise behind "it just converges", and they're verified by property tests across the package. +
++ Order of application doesn't change the result. A replica can integrate operations as they arrive, + in whatever sequence the network delivers them. +
++ Applying the same operation twice is the same as applying it once. Redelivery and retries are safe; + version vectors make them free. +
++ Same set of operations, same final state — full stop. Two replicas that have seen the same ops are + byte-for-byte identical. +
++ Commutativity and idempotency are local properties of how a single replica integrates an + operation. Convergence is the global consequence: if integration is both order-independent + and duplicate-safe, then the state of a replica is a pure function of the set of operations + it has seen, with no dependence on path or timing. That's why a CRDT tolerates the worst a network + can do — reordering, duplication, partition, arbitrary delay — and still lands every participant on + the same document. +
+
+ With the model in hand, the rest of the library reads as direct applications of it. The same
+ OpId that names an operation is the value compareOpId ranks; the same
+ Lamport clock that produced it advances when you observe a peer; the same dense clocks that make ids
+ unique make version vectors a one-integer-per-site summary. From here:
+
+ @robonen/crdt is a small set of independent data structures, each convergent on
+ its own. You can use a single primitive in isolation — a last-writer-wins setting, an ordered
+ list, a collaborative string — or compose them into something bigger. This page walks through
+ each one with a construction example and a small converging scenario.
+
+ Every primitive leans on one shared idea:
+
+ LwwRegister and
+ LwwMap — single values and keyed maps where the
+ write with the highest op id wins.
+
+ keyBetween /
+ keysBetween — fractional indexing to place or move
+ an item with a single string key.
+
+ Rga — a replicated growable array: an ordered
+ sequence CRDT with tombstones and a deterministic insert tie-break.
+
+ MarkStore — lightweight Peritext formatting spans
+ anchored to character op ids, resolved per character by highest op id.
+
+ A OpId, and a write only takes effect
+ if its id is strictly later than the current one by compareOpId. That single rule
+ gives you the three convergence properties for free — applying writes is
+ commutative (a later write always beats an earlier one regardless of arrival
+ order), idempotent (re-applying a write is a no-op), and
+ convergent (every replica ends on the same winning write).
+
+ set(value, id) returns true when the write won and
+ false when it was superseded, which is handy for skipping downstream work.
+
+ set and delete on the
+ same key converge to whichever has the higher op id — deleting is just another timestamped
+ write that happens to hide the value. get, has, keys,
+ and toEntries all skip tombstoned entries, so the map reads like a plain map even
+ though deletions are retained internally for convergence.
+
+ Why keep tombstones? If a delete simply dropped the entry,
+ a concurrent set arriving afterward would resurrect
+ the key — the two replicas would disagree on whether it exists. Retaining the delete as a
+ timestamped tombstone lets compareOpId decide the
+ winner deterministically, the same way it does for live values.
+
+ Ordering a collaborative list with integer indices is a trap: insert at position 2 and every
+ index after it shifts, so two replicas inserting concurrently clobber each other's positions.
+
+ Pass null for an open bound: keyBetween(null, x) is "before
+ x", keyBetween(x, null) is "after x", and
+ keyBetween(null, null) seeds an empty list. The result is always strictly between
+ the bounds, so there is unlimited room to keep subdividing — you never run out of space to
+ insert between two adjacent items.
+
+ n keys at once,
+ all strictly between the bounds and in ascending order — useful for seeding a list or
+ bulk-inserting a run of items. Because a key is just a value on the item, moving
+ an item is a single-field write: compute a new key between its new neighbors and re-sort.
+ Nothing else in the list is touched, which is exactly what makes concurrent reorders converge
+ cleanly (they reduce to independent
+
+ Heads up:
+ keyBetween requires lower < upper
+ and throws otherwise. Two replicas independently generating a key between the
+ same neighbors can produce identical keys; pair the key with the item's op id as a
+ secondary sort to keep ordering deterministic, or let
+
+ OpId, a value, and an originLeft: the id of the element it was
+ inserted after (null means the start of the sequence). Deletion never
+ removes a node; it sets a tombstone flag, so the node lives on as a stable
+ anchor that later inserts and marks can still reference.
+
+ integrateInsert(id, value, originLeft) and integrateDelete(id) are
+ both idempotent — re-integrating an op you've already seen is a no-op that safely returns
+ true. Read the visible state with toArray(); use
+ visible() to get the surviving nodes (and their ids) for cursor anchoring, and
+ length for the visible count.
+
+ The interesting case is two replicas inserting at the same origin at the same time.
+ Both new elements claim the slot right after the same left neighbor — so which goes first? RGA
+ resolves this deterministically: among elements sharing an origin, the one with the
+ higher op id is placed first (compareOpId > 0 scans past it).
+ Because every replica applies the identical comparison, they all settle on the same order
+ without any coordination.
+
+ RGA requires inserts to be integrated in causal order: an element's
+ originLeft must already be present, or there's no anchor to insert after. Rather
+ than guess, integrateInsert returns false when the origin is missing
+ and integrateDelete returns false for an unknown target — the signal
+ to buffer the op and retry once its dependency lands. (At a higher level,
+
+ Garbage collection. Tombstones accumulate. When every
+ replica has fully synced and nothing is in flight, gc(stable, keep?)
+ drops deleted nodes whose insert is covered by a stable
+ keep to protect ids still
+ referenced elsewhere, such as mark span endpoints.
+
+ Formatting in a collaborative editor can't be stored by offset — insert a character and every
+ offset after it shifts, so a "bold from 3 to 7" range would drift onto the wrong text.
+ MarkSpan anchors to the OpId of its first and last
+ characters (an inclusive range), so the span moves with the text it covers as the sequence
+ grows and shrinks around it.
+
+ A span's value is a JSON-serializable MarkValue — pass
+ true (or attributes like a color string) to apply the mark, and
+ null or false to clear it.
+
+ add(span) just records a span (idempotent by span id). The real work is
+ resolve(order): given the character op ids in document order — typically
+ rga.visible().map(n => n.id) — it returns one Map<type, value>
+ of active marks per character. For each character and mark type, the covering span with the
+ highest op id wins, so concurrent formatting converges by the same
+ compareOpId rule as everything else; a winning null/false
+ span clears the mark.
+
+ A CRDT primitive on its own guarantees that the same set of operations converges. + Replication is the layer that makes sure every replica eventually holds that same set — + despite messages arriving out of order, twice, or after a long offline gap. This package + does it without a central server, a global lock, or full-state diffing: each replica keeps + an append-only op log keyed by a version vector, and the two sides exchange only the + operations the other is missing. +
+
+ The pieces fit together in one direction. OpLog stores ops and tracks a
+ VersionVector; Replica wraps a log plus a Lamport clock and a
+ causal buffer, integrating local and remote ops into your domain state; and the
+ sync helpers turn version vectors and op batches into bytes for any transport.
+
+ Every operation carries a globally-unique OpId — a per-site
+ Lamport clock value tagged
+ with the site that produced it ({ site, clock }). Two facts make replication
+ work, and both flow from that id:
+
+ Replication therefore reduces to a set-reconciliation problem: get both replicas to the + same set of ops. Convergence of the resulting state is the primitive's job; getting the + ops there efficiently is this layer's. +
+
+ A a@5, it has necessarily seen a@1…a@4
+ too. So has(id) is just get(id.site) >= id.clock — an O(1)
+ check, no per-op bookkeeping.
+
+ Two operations follow directly. Dedup: an incoming op whose id the vector + already covers can be ignored. Delta: given a remote vector, the set of ops + the remote lacks is exactly those whose id the vector does not cover. +
+
+ id (the HasOpId constraint), so the same log stores RGA inserts,
+ LWW writes, mark spans, or anything else you give it.
+
+ append consults the vector first and returns false if the op is a
+ duplicate, so the log never stores the same id twice. delta(remote) walks the
+ log once and keeps every op the remote vector hasn't covered — this is the heart of
+ "exchange only the delta".
+
+ LamportClock, an OpLog, and a pending buffer, and you give it a
+ single handler — integrate(op) — that applies an op to your domain state and
+ returns false when the op's causal dependencies aren't present yet.
+
+ Call nextId() to tick the clock and mint a fresh, causally-later
+ OpId, build your op around it, then hand it to commitLocal(op).
+ That logs it, integrates it into local state, and notifies onUpdate listeners
+ with origin 'local'. Because nextId advances a Lamport clock that
+ also tracks observed remote ops, locally-generated ids are always ordered after everything
+ the replica has seen.
+
+ receive(ops) is the inbound path. For each op it advances the clock past the
+ remote id (clock.observe), skips anything already logged or already buffered,
+ then drains the buffer — integrating whatever is now causally ready, retrying until no
+ further progress is possible. It returns the ops it actually applied (in apply order) and
+ notifies listeners with origin 'remote'.
+
+ delta(remoteVector) forwards to the log: the ops this replica holds that the
+ remote, described by its version, has not seen. The whole round-trip is two
+ deltas — one per direction.
+
+ Here is the README's converging-string example expanded end to end. Two replicas type + concurrently, then each side sends the other exactly the ops it lacks. After both deltas, + they hold the identical op set and therefore the identical string. +
+
+ Note the asymmetry that makes this efficient: a.replica.delta(b.replica.version)
+ is computed against B's vector, so it returns only what B is missing — not A's
+ entire history. On a long document this is the difference between sending two characters and
+ re-sending the whole file.
+
+ You could swap the two receive lines, run them
+ repeatedly, or interleave them with more edits — the result is the same. Each side only ever
+ adds ops it hasn't seen, and compareOpId places
+ each op in its deterministic position regardless of arrival order. That is convergence,
+ and the property tests assert it across randomized schedules.
+
+ Some ops can't be applied the instant they arrive. An RGA insert references an
+ originLeft — the element it goes after — and a delete references the element it
+ tombstones. If that target hasn't been integrated yet (a later op overtook an earlier one in
+ transit), the insert has nowhere to anchor.
+
+ The handler signals this by returning false from integrate:
+ integrateInsert returns
+ false when its origin is absent, and integrateDelete returns
+ false when its target is unknown. Replica.receive treats a
+ false as "not ready yet": it keeps the op in a pending buffer and re-runs the
+ buffer every time new ops land, until either the op integrates or its dependency finally
+ arrives. Nothing is lost; nothing is applied prematurely.
+
+ Internally the drain loop sweeps the buffer repeatedly: each successful integration may
+ unblock another buffered op, so it keeps looping while it makes progress. This is why a
+ single receive of a batch delivered in any order still settles to the right
+ state — the buffer absorbs the disorder.
+
+ The sync module is the only part that touches bytes, and it stays small on
+ purpose. There are two things to put on the wire — a version vector (the "what do you have?"
+ handshake) and a batch of ops (the delta or a full snapshot) — and a helper for each
+ direction:
+
decodeStateVector — a VersionVector ⇄ Uint8Array.
+ decodeOps — an op
+ batch (the delta or a full snapshot) ⇄ Uint8Array.
+ encodeJson / decodeJson — the lower-level pair the others build on.
+
+ The v1 format is JSON encoded to bytes — simple and debuggable. A compact varint format is a
+ later optimization that changes the bytes, not the API, so code written against these
+ functions keeps working. Because the result is just a Uint8Array, the transport
+ is entirely up to you: WebSocket, HTTP, BroadcastChannel, a file on disk.
+
+ Put the pieces together and a full reconciliation between two peers is four messages: +
+encodeStateVector(replica.version).encodeOps(replica.delta(theirVector)).
+ receive()s the decoded delta.
+ This generalizes cleanly. For live collaboration, also forward each locally-committed op as
+ it happens (subscribe with onUpdate, encode the op, broadcast it); peers that
+ receive an op out of causal order simply buffer it. For catch-up after an offline gap, the
+ state-vector handshake above replays exactly the missed ops. The same machinery covers both.
+
+ Version vectors assume each site's clocks are dense (1, 2, 3, …). That holds automatically
+ when ids come from Replica.nextId(). If you mint
+ ids yourself, never skip a value for a site — a gap would make
+ delta believe a missing op was already delivered.
+
commitLocal, receive, delta, and
+ onUpdate.
+
+ Reading about convergence only gets you so far — the intuition lands when you
+ watch two replicas disagree and then reconcile. Below is a live, two-replica
+ editor backed by the real
+ Replica A and replica B each own a private copy of a + shared document. Type something different into each, click Apply to commit + those edits locally (they diverge), then Sync to exchange deltas and + converge. The readout under each side shows its current value, how many local ops its log + has produced, and its Lamport clock. +
+Spin up two fresh replicas to start editing.
+ +
+ Try the canonical experiment: type cat on A and dog on B, apply
+ both, then sync. The result is the same six characters on both sides, every time — the order
+ is decided by op id, not by who synced first. Reset and try it again to confirm it's
+ deterministic.
+
+ There's no mock here. Each side is a real Rga<string> wrapped in a
+ Replica<CharOp>. The Replica owns the Lamport clock, the
+ append-only op log, the causal buffer, and delta computation; the Rga holds the
+ actual character sequence with tombstones. We pass one handler — integrate —
+ that applies an op to the RGA.
+
+ A local edit is just an op: call replica.nextId() to mint a fresh op id (which
+ ticks that site's Lamport clock), build the insert or delete, and pass it to
+ commitLocal. That integrates the op into the RGA and appends it to the log in
+ one step. Because A and B edit before any sync, they produce ops with overlapping clock
+ values but different site ids — genuinely concurrent operations.
+
+ Sync is a delta exchange driven by version vectors. Each replica's
+ version records the highest clock it has seen per site;
+ delta(remoteVersion) returns exactly the ops the remote is missing.
+ receive then dedups, integrates, and — crucially — buffers any op
+ whose causal dependency hasn't arrived yet, retrying it automatically once that dependency
+ lands.
+
+ The demo never special-cases conflicts, because the data structure can't have any. Three + properties, each verified by the package's property tests, guarantee that every replica + reaches the same state regardless of message order, duplication, or delay. +
+
+ A-then-B and B-then-A produce the same sequence. Concurrent inserts at the same origin are
+ ordered by compareOpId, so order of arrival
+ doesn't matter.
+
+ Receiving the same op twice is a no-op. The op log's version vector dedups on
+ id, and integrateInsert
+ short-circuits if the id is already present.
+
+ An insert can't integrate before its originLeft,
+ nor a delete before its target. receive buffers
+ such ops and retries them, so out-of-order delivery still converges.
+
+ Everything hinges on one comparison. When two replicas insert characters at the same
+ position concurrently, Rga.integrateInsert walks past any existing siblings
+ whose op id sorts higher and splices the new node in — so the final order is fully
+ determined by compareOpId: higher Lamport clock first, with the site id as a
+ deterministic tie-break. Every replica runs the same comparison on the same ids, so they all
+ agree on the same order without a coordinator.
+
+ That's also why deletes are tombstones rather than removals: a delete only flips a node's
+ deleted flag, so a concurrent insert that anchored to that node still has a
+ valid origin. The character disappears from toArray(), but the structure stays
+ intact for convergence. Tombstones are reclaimed later via
+ Rga.gc
delta filters by the peer's version
+ vector.
+ onUpdate subscription used to drive UI.
+ + Framework-agnostic CRDT primitives — an RGA sequence, last-writer-wins registers, + fractional indexing, and version vectors that converge no matter the order, duplicates, + or delays in which operations arrive. +
+
+ Collaborative state is hard because two replicas can edit the same document at once,
+ offline, with messages that arrive out of order or twice. A CRDT solves this by construction:
+ every primitive here is commutative, idempotent, and convergent, so applying
+ the same set of operations in any order yields the same state — a property verified by
+ property tests. It's the convergence engine behind @robonen/editor, but stays
+ fully domain-agnostic, ships zero runtime dependencies, and runs in both Node and the browser.
+
+ One deterministic tie-break — compareOpId (higher
+ Lamport clock wins; site id breaks ties) — is shared by every primitive, so LWW and RGA agree
+ on the same final state.
+
+ Replica.receive dedups, holds ops whose dependencies
+ haven't arrived yet (an insert before its origin), and retries them automatically as they land.
+
+ Version vectors let each side request exactly the ops it's missing via
+ delta(version), with a transport-agnostic wire format.
+
+ No runtime deps, no framework lock-in. Compose the primitives yourself, or lean on
+ Replica to tie a clock, op log, and buffer together.
+
Add the package with your preferred package manager.
++ Two replicas edit a string independently, then exchange only the operations each is missing + and converge to the same result. +
+New to CRDTs? Work through the guide and finish in the live playground.
++ Encoding utilities for TypeScript — a dependency-free QR Code generator + and the Reed-Solomon error-correction primitives that power it. +
+
+ Generating a QR Code correctly is deceptively hard: segment-mode selection,
+ version sizing, Reed-Solomon ECC over a finite field, block interleaving, and
+ the eight masking patterns each have to be just right for a scanner to read the
+ result. @robonen/encoding packages all of that into a small set of
+ pure functions and immutable classes, with zero runtime dependencies and an
+ output you render however you like — SVG, canvas, a DOM grid, or a terminal.
+
+ encodeText and encodeBinary pick the smallest
+ version and optimal segment modes for you, then hand back an immutable
+ QrCode grid.
+
+ A QrCode is just a square of modules. Read each one with
+ getModule(x, y) and draw to SVG, canvas, or anything else —
+ no rendering opinions baked in.
+
+ The GF(2^8) error-correction core — multiply,
+ computeDivisor, computeRemainder — is exported
+ on its own, reusable beyond QR.
+
+ Tree-shakeable ESM and CJS builds with no third-party runtime deps, hot + loops backed by typed arrays, and end-to-end TypeScript types. +
+
+ Encode a string into a QrCode and walk its modules to render
+ it. EccMap.M selects the medium (~15% recovery) error-correction
+ level; EccMap.L, .Q, and .H are also
+ available.
+
+ Need raw error-correction codewords without the QR machinery? The + Reed-Solomon primitives stand on their own: +
+size, getModule, and getType.
+
+ A lightweight, type-safe fetch wrapper with interceptors, retry,
+ timeout, and a composable plugin system — V8-optimized internals, zero runtime
+ dependencies beyond the standard library.
+
+ globalThis.fetch is great primitive plumbing, but every app
+ re-implements the same layer on top of it: JSON parsing, throwing on
+ 4xx/5xx, base URLs, query strings, retries, timeouts,
+ auth headers. @robonen/fetch is that layer — small, fully typed,
+ and built so attaching features costs nothing on the hot path.
+
+ Response data, request options, and plugin-contributed fields are all inferred — + the parsed body comes back typed, no casting required. +
+
+ Plain objects are JSON-serialized; FormData/Blob/streams
+ pass through untouched. Responses are decoded from Content-Type or
+ forced via responseType.
+
+ Built-in retry and per-attempt timeout with sensible defaults, and non-2xx
+ responses reject with a rich FetchError carrying status, request,
+ and parsed body.
+
+ Lifecycle hooks plus a typed, composable plugin system with onion-style
+ execute middleware — composed once, with zero per-request overhead
+ beyond the hooks themselves.
+
+ Import the default $fetch instance — it is backed by
+ globalThis.fetch and ready to use. The first type parameter types the
+ parsed body.
+
+ Use create (or its alias extend) to derive instances with
+ a baseURL, default headers, retry policy, and plugins. Configuration is
+ merged down the chain; the child wins on conflicts.
+
+ The full API reference is listed below. A few good places to start: +
+createFetchdefinePluginexecute middleware into a
+ reusable plugin.
+ FetchErrorbuildURLdetectResponseType+ Platform-dependent utilities for browser and multi-runtime JavaScript — focus management, + ARIA isolation, animation lifecycle tracking, and environment-safe globals. +
+
+ Most utility libraries stop at the platform boundary: the moment you need to reach for the
+ DOM, shadow roots, aria-hidden, or globalThis, you are on your own.
+ @robonen/platform fills that gap. It packages the gritty, well-tested
+ primitives that overlays, dialogs, and editors depend on — focus guards, tabbable-edge
+ detection, sibling hiding for screen readers, and CSS animation settling — and ships them
+ SSR-aware and dependency-free. It is the low-level layer that powers
+
+ Shadow-DOM-aware active-element lookup, scroll-free focusing, and first/last tabbable-edge
+ detection via a fast TreeWalker — the bones of any focus trap.
+
+ hideOthers marks every sibling
+ aria-hidden, ref-counted across layers, preserving
+ aria-live regions. A dependency-free port of aria-hidden.
+
+ Detect running animations and transitions, then settle exit animations cleanly with + fill-mode flash prevention — so unmounts wait for the CSS to finish. +
+
+ A resolved _global and an
+ isClient flag that work across Node, Bun, Deno, and the
+ browser — guards baked in so SSR never throws.
+
+ The package splits along the platform boundary. Browser-only helpers live under
+ /browsers; runtime-agnostic helpers live under /multi.
+
| Entry | +Scope | +What you get | +
|---|---|---|
@robonen/platform/browsers |
+ DOM | +Focus, tabbable edges, hideOthers, animation lifecycle |
+
@robonen/platform/multi |
+ Any runtime | +_global, isClient |
+
+ A typical overlay flow: capture the focused element, hide siblings from assistive tech, drop + focus onto the first tabbable target, and tear it all down on close. +
+
+ On the cross-runtime side, reach for a safe global and a reliable client check without
+ sprinkling typeof window guards through your code:
+
hideOthers already no-ops when document is
+ undefined, and /multi is import-safe everywhere.
+ Browse the full API reference below, or jump straight to the building blocks:
++ A platform-independent standard library of tools, utilities, and helpers for TypeScript — + arrays, async, math, data structures, and patterns, all tree-shakeable and fully typed. +
+
+ Every project ends up reimplementing the same handful of helpers: dedupe an array,
+ clamp a number, group records by a key, retry a flaky request. @robonen/stdlib
+ collects those building blocks into one cohesive, dependency-free package. It runs the same
+ in Node, the browser, and the edge — there are no platform globals, no polyfills, and no
+ runtime dependencies. Import only what you use; the rest is shaken out of your bundle.
+
+ Generic signatures preserve element and key types end to end, so inference keeps
+ working through groupBy, partition, zip, and friends.
+
+ No transitive dependencies and no platform assumptions. The same code runs in Node, + the browser, Deno, Bun, and edge runtimes. +
++ Each utility is a standalone export with no shared side effects. Import a single + function and ship only that function. +
+
+ Beyond array and math helpers you get data structures
+ (Deque, BinaryHeap, LinkedList) and patterns
+ (StateMachine, PubSub, Command).
+
+ Import named utilities directly from the package root. Each one is small, predictable, + and focused on a single job. +
++ The library is organized into focused modules. A few of the most-used building blocks: +
+groupBy, partition,
+ unique, zip, cluster, range.
+ tryIt, retry, pool,
+ sleep for control flow that doesn't fight you.
+ clamp, lerp, remap,
+ each with a BigInt variant.
+ debounce, throttle,
+ memoize, once, compose, pipe.
+ Deque,
+ PriorityQueue, StateMachine, PubSub and more.
+ + Browse the full API reference below, or jump straight to a popular utility: +
+
+ A shared Renovate configuration preset — one line in your
+ renovate.json and dependency updates run on autopilot.
+
+ Every repository ends up re-deriving the same Renovate setup: which update
+ types to auto-merge, when to schedule the noise, how to phrase commit
+ messages, who to assign reviews to. @robonen/renovate captures
+ those decisions once as a single default.json preset that any
+ repo can extend, so the policy lives in one place instead of being copied
+ and slowly drifting across projects.
+
+ Extend it via
+ github>robonen/tools//infra/renovate/default.json
+ — no copy-pasted config, and updates to the policy roll out to every
+ consumer.
+
+ Non-major updates are grouped via group:allNonMajor, so you
+ review one consolidated PR instead of a wall of individual bumps.
+
+ Minor, patch, pin, and digest updates auto-approve and auto-merge on a + 1–3 AM schedule — safe upgrades land overnight, majors still wait for you. +
+
+ Every update commits as chore with a
+ bump range strategy, and reviews are assigned straight from
+ CODEOWNERS.
+
+ You don't import this package in code — Renovate reads it from the repo by
+ reference. Add the package to your workspace if you want the bundled
+ renovate-config-validator available locally:
+
+ Create (or edit) renovate.json in your repository root and
+ extend the preset:
+
+ That's the whole setup. To diverge from the shared policy, append your own
+ packageRules after the extend — for example, opt out of
+ auto-merging major upgrades:
+
renovate-config-validator before
+ committing — the package ships it as a test script against
+ default.json.
+ + A headless, block-based rich-text editor for Vue 3 — in the spirit of + Tiptap / ProseMirror / Editor.js, but with a registry-driven schema and a + hand-built CRDT for collaboration (no Yjs / Loro / Automerge). +
+
+ Most editors force a trade: the structured, block-first authoring of Editor.js, or the
+ document fidelity of ProseMirror where native cross-block selection and arrow navigation
+ just work. @robonen/editor takes the ProseMirror route — a single
+ contenteditable surface — and layers a modular block registry on top, so blocks
+ and inline marks are added without touching the core. The model, schema, state, commands and
+ keymap are entirely DOM-free and Vue-free; the Vue layer only renders and handles input.
+ Every edit is a step-based transaction with an exact inverse, which gives you real undo/redo
+ and — because the same steps drive the CRDT — conflict-free collaboration for free.
+
+ Ships behavior and DOM structure (data-block-*
+ hooks), never styling. Bring your own CSS and own the look completely.
+
+ defineBlock /
+ defineMark register into an immutable schema —
+ add a custom block or mark with no core changes.
+
+ Every edit is a step with an exact inverse, powering reliable undo/redo and a single source + of truth for both local edits and sync. +
+
+ RGA text, fractional-indexed blocks, Peritext-style marks and presence behind a
+ CrdtProvider — over any transport.
+
+ The editor depends on @robonen/crdt for the built-in collaboration provider, and
+ on vue as a peer.
+
+ Create a registry, build an editor around its state, and mount EditorRoot. Its
+ default slot renders EditorContent (the single contenteditable), so
+ this is a fully working editor with all built-in blocks and marks.
+
+ Provide your own slot to add UI around the editable surface — the bubble toolbar floats over a
+ selection, and the slash menu opens when you type / at the start of a line.
+
+ Commands are (state, dispatch?, view?) => boolean functions that power the
+ keymap, the UI, and programmatic edits. Run one with editor.command(...); omit
+ the dispatch to dry-run it for active/disabled state.
+
+ createDefaultRegistry() wires up a full set out of the box —
+ blocks: paragraph, heading (1–6),
+ bulleted-list / numbered-list / todo-list,
+ blockquote, code-block, callout, divider,
+ image; marks: bold, italic,
+ underline, strike, highlight, code,
+ link. Markdown input rules (# , - , 1. ,
+ > , [] ) and hotkeys (Mod-b/i/u,
+ Mod-z, …) are included.
+
+ Status: v0, work in progress. + Core logic is covered by unit + convergence tests; the contenteditable / Playwright suite + runs locally. The collaboration layer has a few documented, deferred limitations. +
+Jump into the pieces you'll reach for first:
+EditorRoot and EditorContent — the mount
+ surface and the single contenteditable.
+ createDefaultRegistrydefineBlockdefineMarktoggleMarksetBlockTypebindCrdtcreateNativeProvider+ The full API reference for every export is listed right below. +
++ A collection of unstyled, accessible UI primitives for Vue 3 — the headless + building blocks for design systems and component libraries. +
+
+ Most component libraries bundle behavior and styling together, so the moment
+ your design diverges you end up fighting the framework. @robonen/primitives
+ ships the hard part — state, focus management, keyboard interaction, ARIA wiring,
+ portalling and positioning — and leaves the markup and styling entirely to you.
+ Every primitive is composed from small, controllable parts (a Root,
+ a Trigger, a Content, and so on) following the same
+ conventions, so once you learn one you know them all.
+
+ No CSS shipped. Primitives render the DOM you ask for and expose state via + data attributes, so you bring your own styles — Tailwind, vanilla CSS, anything. +
+
+ Focus scopes, roving tabindex, visually-hidden labels and correct ARIA roles
+ are handled for you. The suite is tested against
+ axe-core in a real browser.
+
+ Bind state with v-model when you need control, or set a
+ defaultValue / defaultOpen and let the primitive
+ manage itself.
+
+ Every part takes an as prop, or use as="template"
+ to merge behavior onto your own element. Floating UI powers positioning for
+ popovers, tooltips and menus.
+
+ Primitives are assembled from named parts. Here is a complete dialog — open + state is uncontrolled, focus is trapped, body scroll is locked, and the + content is portalled out of the DOM flow: +
++ Need full control over open state? Bind it directly — the same primitive works + either way: +
+
+ At the core of every part is Primitive, a polymorphic functional
+ component. Pass as to choose the element, or as="template"
+ to forward behavior onto a child of your own.
+
+ The full primitive index is listed below. A few good starting points: +
++ A collection of 213+ tree-shakeable, SSR-safe composables for Vue 3 — + reactive primitives for state, sensors, the DOM, browser APIs, animation, forms and more. +
+
+ Every Vue app ends up re-implementing the same building blocks: a toggle, a debounced ref,
+ an event listener that cleans itself up, a media query, local-storage state. @robonen/vue
+ ships those building blocks as small, composable functions with a consistent API. Each one
+ is independently tree-shakeable, written in TypeScript with full inference, and safe to call
+ during server-side rendering — guards for window, document and
+ navigator are built in, so the same code runs on the server and hydrates cleanly
+ on the client.
+
+ Import only what you use. Each composable lives on its own and pulls in nothing it + doesn't need — your bundle stays exactly as small as your usage. +
+
+ Browser-only access is guarded behind lifecycle hooks and configurable
+ window/document targets, so Nuxt and SSR setups just work.
+
+ Written in TypeScript with precise return types and generics. MaybeRefOrGetter
+ arguments mean you can pass plain values, refs or getters interchangeably.
+
+ From state and reactivity to sensors, elements, storage, math and form handling — + one cohesive toolkit spanning the whole surface of a Vue app. +
+
+ Import the composables you need and use them inside <script setup>.
+ Here's a counter clamped to a range, with auto-cleaning keyboard shortcuts:
+
The same useCounter running live:
+ The full API reference is listed right below. A few good starting points: +
+localStorage / sessionStorage.
+