docs: add package introductions and the @robonen/crdt guide

An intro.vue landing for all 12 packages, plus a multi-section crdt guide (Concepts, Primitives, Replication & Sync, and an interactive convergence Playground).
This commit is contained in:
2026-06-08 15:52:03 +07:00
parent 09433415b6
commit 53f2d7ceef
16 changed files with 3438 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
const usageExample = `import { compose, base, typescript, vue, vitest, imports } from '@robonen/eslint';
// eslint.config.ts
export default compose(base, typescript, vue, vitest, imports);`;
const overrideExample = `import { compose, base, typescript } from '@robonen/eslint';
export default compose(base, typescript, {
// later entries override earlier ones
rules: { 'no-console': 'off' },
});`;
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>@robonen/eslint</h1>
<p class="text-lg text-(--fg-muted)">
Composable ESLint flat-config presets assemble a linting setup from
small, focused building blocks instead of one monolithic config.
</p>
</div>
<div class="prose-docs">
<p>
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. <code>@robonen/eslint</code> ships a curated set of presets
<code>base</code>, <code>typescript</code>, <code>vue</code>,
<code>vitest</code>, <code>imports</code>, <code>node</code>,
<code>regexp</code>, and <code>stylistic</code> and a single
<code>compose()</code> 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.
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Composable presets</h3>
<p class="text-sm text-(--fg-muted) m-0">
Mix and match focused presets per language and tool. Each preset is a
plain flat-config array no magic, no hidden state.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Override-friendly</h3>
<p class="text-sm text-(--fg-muted) m-0">
Append inline config objects after presets. Later entries win, exactly
as ESLint flat-config semantics intend.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Conditional by design</h3>
<p class="text-sm text-(--fg-muted) m-0">
<code>compose()</code> skips <code>false</code>/<code>null</code>/<code>undefined</code>
entries, so feature flags and conditional spreads just work.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Typed flat config</h3>
<p class="text-sm text-(--fg-muted) m-0">
Exported <code>FlatConfig</code> and <code>Rules</code> types give you
editor autocomplete and type-checked overrides in
<code>eslint.config.ts</code>.
</p>
</div>
</div>
<div class="prose-docs">
<h2>Install</h2>
<p>
Install the package alongside ESLint and <code>jiti</code> (so ESLint can
load a TypeScript <code>eslint.config.ts</code>).
</p>
</div>
<DocsCode :code="`pnpm add -D @robonen/eslint eslint jiti`" lang="bash" />
<div class="prose-docs">
<h2>Usage</h2>
<p>
Create <code>eslint.config.ts</code> in your project root and compose the
presets you want:
</p>
</div>
<DocsCode :code="usageExample" lang="ts" />
<div class="prose-docs">
<p>
Add inline config objects after the presets to tweak rules later
entries override earlier ones:
</p>
</div>
<DocsCode :code="overrideExample" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="font-medium text-(--fg) mb-2">Where to next</h3>
<ul class="text-sm text-(--fg-muted) space-y-1.5 list-disc pl-5 m-0">
<li>
<NuxtLink to="/eslint/overview" class="text-(--accent-text) hover:underline">compose</NuxtLink>
flatten presets and overrides into one config array.
</li>
<li>
<NuxtLink to="/eslint/base" class="text-(--accent-text) hover:underline">base</NuxtLink>
core ESLint, unicorn, regexp rules and global ignores.
</li>
<li>
<NuxtLink to="/eslint/typescript" class="text-(--accent-text) hover:underline">typescript</NuxtLink>
and
<NuxtLink to="/eslint/vue" class="text-(--accent-text) hover:underline">vue</NuxtLink>
language presets for TS and Vue 3 SFCs.
</li>
<li>
Read the guide sections below for the full preset table and migration
notes from <code>@robonen/oxlint</code>.
</li>
</ul>
</div>
</div>
</template>
+154
View File
@@ -0,0 +1,154 @@
<script setup lang="ts">
const nodeExample = `// Node / isomorphic library
{ "extends": "@robonen/tsconfig/tsconfig.base.json" }`;
const vueExample = `// Vue package, with path aliases
{
"extends": "@robonen/tsconfig/tsconfig.vue.json",
"compilerOptions": {
"paths": { "@/*": ["./src/*"] }
}
}`;
const splitExample = `// tsconfig.json — solution root
{
"files": [],
"references": [
{ "path": "./tsconfig.src.json" }, // extends tsconfig.dom.json
{ "path": "./tsconfig.node.json" } // *.config.ts, types: ["node"]
]
}`;
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>@robonen/tsconfig</h1>
<p class="text-lg text-(--fg-muted)">
Shared, strict TypeScript configurations a small set of layered
presets you extend instead of copying compiler options between packages.
</p>
</div>
<div class="prose-docs">
<p>
Every package in a monorepo wants the same modern, strict TypeScript
baseline, but keeping a dozen <code>tsconfig.json</code> files in sync by
hand drifts almost immediately. <code>@robonen/tsconfig</code> ships one
carefully tuned <code>base</code> config and three environment layers
<code>dom</code>, <code>vue</code>, and <code>node</code> that extend
it. Point your package's <code>extends</code> at the right preset and you
inherit a consistent, bundler-first, type-check-only setup with no local
compiler options to maintain.
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Layered presets</h3>
<p class="text-sm text-(--fg-muted) m-0">
<code>base</code> &rarr; <code>dom</code> &rarr; <code>vue</code>, plus
a sibling <code>node</code> layer. Extend the one that matches the
environment; everything else is inherited.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Strict by default</h3>
<p class="text-sm text-(--fg-muted) m-0">
<code>strict</code> plus <code>noUncheckedIndexedAccess</code>,
<code>noImplicitOverride</code>, <code>noImplicitReturns</code> and
<code>noFallthroughCasesInSwitch</code> are on out of the box.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Bundler-first</h3>
<p class="text-sm text-(--fg-muted) m-0">
<code>module: Preserve</code> with <code>Bundler</code> resolution,
<code>verbatimModuleSyntax</code> and <code>isolatedModules</code>.
Emit is <code>noEmit</code> — declarations come from
<code>tsdown</code>.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Env isolation</h3>
<p class="text-sm text-(--fg-muted) m-0">
Browser <code>src</code> (DOM, no Node globals) and tooling files
(<code>node</code> types, no DOM) split into separate projects wired
with project references.
</p>
</div>
</div>
<div class="prose-docs">
<h2>Install</h2>
<p>
Add the package as a dev dependency. It ships only JSON presets — no
runtime code.
</p>
</div>
<DocsCode :code="`pnpm add -D @robonen/tsconfig`" lang="bash" />
<div class="prose-docs">
<h2>Usage</h2>
<p>
Pick the preset that matches the package and extend it from your
<code>tsconfig.json</code>:
</p>
</div>
<DocsCode :code="nodeExample" lang="json" />
<div class="prose-docs">
<p>
Vue SFC packages extend the <code>vue</code> layer (adds
<code>jsx: preserve</code> and strict
<code>vueCompilerOptions</code>) and can declare path aliases inline:
</p>
</div>
<DocsCode :code="vueExample" lang="json" />
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-4">
<p class="text-sm text-(--fg-muted) m-0">
<strong class="text-(--fg)">Note:</strong> path aliases resolve relative
to the <code class="text-(--fg)">tsconfig.json</code> location —
<code class="text-(--fg)">baseUrl</code> is intentionally omitted
(deprecated, removed in TypeScript 7.0).
</p>
</div>
<div class="prose-docs">
<h2>DOM + Node split</h2>
<p>
Most packages mix browser <code>src</code> with Node tooling files
(<code>vite.config.ts</code>, <code>vitest.config.ts</code>,
<code>tsdown.config.ts</code>). Split them into two projects wired with
references so <code>src</code> never sees Node globals and config files
never see <code>DOM</code>, then type-check the whole package with
<code>tsc -b</code> / <code>vue-tsc -b</code>:
</p>
</div>
<DocsCode :code="splitExample" lang="json" />
<div class="rounded-lg border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="font-medium text-(--fg) mb-2">Where to next</h3>
<ul class="text-sm text-(--fg-muted) space-y-1.5 list-disc pl-5 m-0">
<li>
<strong class="text-(--fg)">Presets</strong> — the full table of
<code>base</code>, <code>dom</code>, <code>vue</code> and
<code>node</code>, with what each layer adds.
</li>
<li>
<strong class="text-(--fg)">Project references</strong> — the complete
DOM + Node split with <code>composite</code> and
<code>tsBuildInfoFile</code> wiring.
</li>
<li>
<strong class="text-(--fg)">What's included</strong> the exact
compiler options the <code>base</code> preset turns on.
</li>
<li>
See the guide sections in the sidebar for each of the above.
</li>
</ul>
</div>
</div>
</template>
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
const usageExample = `import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
tsconfig: './tsconfig.src.json',
entry: ['src/index.ts'],
});`;
const overrideExample = `import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
import Vue from 'unplugin-vue/rolldown';
export default defineConfig({
...sharedConfig,
entry: ['src/index.ts', 'src/*/index.ts'],
plugins: [Vue({ isProduction: true })],
// layer on top of the shared defaults
dts: { vue: true },
});`;
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>@robonen/tsdown</h1>
<p class="text-lg text-(--fg-muted)">
Shared tsdown build configuration for every <code>@robonen</code>
package one source of truth for output formats, declarations, and
bundle hygiene.
</p>
</div>
<div class="prose-docs">
<p>
Every library in this monorepo ships dual ESM/CJS builds with type
declarations, a clean <code>dist</code>, and a consistent license
banner. Re-declaring that in each package's
<code>tsdown.config.ts</code> is repetitive and drifts over time.
<code>@robonen/tsdown</code> exports a single
<code>sharedConfig</code> object you spread into
<code>defineConfig</code>, then add only what is package-specific
usually just <code>entry</code> and <code>tsconfig</code>.
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Dual ESM + CJS</h3>
<p class="text-sm text-(--fg-muted) m-0">
Emits both <code>esm</code> and <code>cjs</code> formats so packages
work in modern bundlers and legacy <code>require</code> setups alike.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Types included</h3>
<p class="text-sm text-(--fg-muted) m-0">
<code>dts: true</code> generates <code>.d.ts</code> declarations on
every build no separate type pipeline to maintain.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Clean, stable output</h3>
<p class="text-sm text-(--fg-muted) m-0">
<code>clean: true</code> wipes <code>dist</code> first and
<code>hash: false</code> keeps file names deterministic for
predictable publishing.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Spread &amp; override</h3>
<p class="text-sm text-(--fg-muted) m-0">
It is a plain object typed as <code>InlineConfig</code> spread it,
override any field, and let editor autocomplete guide you.
</p>
</div>
</div>
<div class="prose-docs">
<h2>Install</h2>
<p>
Add the config package and <code>tsdown</code> itself as dev
dependencies:
</p>
</div>
<DocsCode :code="`pnpm add -D @robonen/tsdown tsdown`" lang="bash" />
<div class="prose-docs">
<h2>Usage</h2>
<p>
Create <code>tsdown.config.ts</code> in your package, spread
<code>sharedConfig</code>, and supply your entry points:
</p>
</div>
<DocsCode :code="usageExample" lang="ts" />
<div class="prose-docs">
<p>
Because <code>sharedConfig</code> is a normal object, you can override
or extend any field after spreading add plugins, extra entries, or
tweak the declaration options:
</p>
</div>
<DocsCode :code="overrideExample" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="font-medium text-(--fg) mb-2">Where to next</h3>
<ul class="text-sm text-(--fg-muted) space-y-1.5 list-disc pl-5 m-0">
<li>
<NuxtLink to="/tsdown/overview" class="text-(--accent-text) hover:underline">sharedConfig</NuxtLink>
the full list of defaults and their exact values.
</li>
<li>
Read the guide sections below for the conventions baked into the
shared build and tips for package-specific overrides.
</li>
</ul>
</div>
</div>
</template>
+304
View File
@@ -0,0 +1,304 @@
<!-- title: Concepts -->
<!-- order: 1 -->
<script setup lang="ts">
const opIdSrc = `import { opId, opIdEq, opIdToString, createSiteId } from '@robonen/crdt';
// An OpId is just { site, clock } — a per-site Lamport counter
// tagged with the site that produced it.
const id = opId('alice', 3); // { site: 'alice', clock: 3 }
opIdToString(id); // 'alice@3'
opIdEq(id, opId('alice', 3)); // true
// A site id is a per-replica handle. Generate one when a session starts.
const site = createSiteId(); // e.g. 'k3f9a2d1xz'`;
const lamportSrc = `import { LamportClock } from '@robonen/crdt';
const clock = new LamportClock('alice');
clock.tick(); // { site: 'alice', clock: 1 }
clock.tick(); // { site: 'alice', clock: 2 }
// We hear about a remote op from 'bob' at clock 5.
clock.observe({ site: 'bob', clock: 5 });
// Our next local id jumps past it, so it's causally *after* what we've seen.
clock.tick(); // { site: 'alice', clock: 6 }`;
const compareSrc = `import { compareOpId, opId } from '@robonen/crdt';
// Higher clock wins.
compareOpId(opId('alice', 1), opId('alice', 2)); // < 0 (2 is greater)
// Equal clocks → site id breaks the tie, deterministically.
compareOpId(opId('alice', 2), opId('bob', 2)); // < 0 ('alice' < 'bob')
compareOpId(opId('bob', 2), opId('alice', 2)); // > 0
// Identical ids compare equal.
compareOpId(opId('alice', 2), opId('alice', 2)); // 0`;
const vvSrc = `import { VersionVector, opId } from '@robonen/crdt';
const vv = new VersionVector();
vv.observe(opId('alice', 3));
vv.observe(opId('bob', 1));
// "Have I already seen this op?" — the basis for dedup.
vv.has(opId('alice', 2)); // true (we've seen alice up to 3)
vv.has(opId('alice', 3)); // true
vv.has(opId('alice', 4)); // false (not yet)
vv.has(opId('carol', 1)); // false (never heard from carol)
// Highest dense clock per site (0 if a site is unknown).
vv.get('alice'); // 3
vv.get('carol'); // 0`;
const vvWireSrc = `import { VersionVector, opId } from '@robonen/crdt';
const local = new VersionVector();
local.observe(opId('alice', 5));
local.observe(opId('bob', 2));
// Snapshot for transport: a plain { site: clock } object.
const snapshot = local.toJSON(); // { alice: 5, bob: 2 }
// The other side reconstructs it and compares against its own log
// to compute exactly which ops you're missing.
const remoteKnows = VersionVector.fromJSON(snapshot);
remoteKnows.has(opId('alice', 4)); // true → skip it
remoteKnows.has(opId('alice', 6)); // false → send it`;
const propsSrc = `// Commutative — order of application doesn't matter:
// apply(apply(s, x), y) === apply(apply(s, y), x)
//
// Idempotent — re-applying a seen op is a no-op:
// apply(s, x) === apply(apply(s, x), x)
//
// Convergent — same op SET ⇒ same state, regardless of how it got there.
//
// These three together mean a network that reorders, duplicates, and
// delays messages can never push two replicas to different states.`;
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>Concepts</h1>
<p>
Every primitive in <code>@robonen/crdt</code> rests on one small idea: if all replicas agree on a
<strong>deterministic total order</strong> 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.
</p>
</div>
<div class="prose-docs">
<h2>Replicas and sites</h2>
<p>
A <strong>replica</strong> is one copy of the shared state a browser tab, a mobile app, a server
process. Each replica is owned by exactly one <strong>site</strong>, identified by a
<code>SiteId</code> (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 <code>createSiteId</code> to mint
one when a session begins; it trades on randomness for uniqueness, not secrecy, so there's no crypto
dependency.
</p>
<p>
Replicas never share mutable memory. They evolve independently and communicate only by exchanging
<strong>operations</strong> — 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.
</p>
</div>
<div class="prose-docs">
<h2>Op ids: naming every operation</h2>
<p>
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 <code>OpId</code>: a per-site counter (its Lamport <code>clock</code>) tagged with the
<code>site</code> that produced it.
</p>
</div>
<DocsCode :code="opIdSrc" lang="ts" />
<div class="prose-docs">
<p>
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. <code>opIdToString</code> gives the canonical
<code>site@clock</code> form, handy as a map key or for logging.
</p>
</div>
<div class="prose-docs">
<h2>Lamport clocks: encoding causality</h2>
<p>
A bare per-site counter is unique, but it isn't enough to compare two operations from different
sites in a meaningful way. <code>LamportClock</code> fixes that. It hands out monotonically
increasing ids via <code>tick()</code>, and — crucially — it <code>observe()</code>s the clocks of
remote operations it learns about, jumping its own counter ahead so that anything it produces next is
numbered <em>after</em> what it has already seen.
</p>
</div>
<DocsCode :code="lamportSrc" lang="ts" />
<div class="prose-docs">
<p>
This is the Lamport <em>happens-before</em> rule in miniature: if operation
<strong>A</strong> causally precedes <strong>B</strong> (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 <strong>concurrent</strong>, 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.
</p>
</div>
<div class="prose-docs">
<h2>compareOpId: the one tie-break</h2>
<p>
Lamport clocks give a <em>partial</em> order they leave concurrent operations incomparable. But to
converge, every replica must agree on a single <strong>total</strong> order so that any two
operations can be ranked the same way everywhere. <code>compareOpId</code> is that total order, and it
is the only conflict-resolution rule in the entire library:
</p>
<ul>
<li><strong>Higher clock wins.</strong> A later operation supersedes an earlier one.</li>
<li>
<strong>Site id breaks ties.</strong> When two ops share a clock (they were concurrent), the
string comparison of their site ids picks a winner arbitrary, but identical on every replica.
</li>
</ul>
</div>
<DocsCode :code="compareSrc" lang="ts" />
<div class="prose-docs">
<p>
That second rule is the quiet hero of the whole design. The choice of winner doesn't matter; what
matters is that <em>every replica makes the same choice</em>. 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.
</p>
<div class="my-4 rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
<p class="m-0 text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-(--fg)">Why one rule for everything?</strong>
<code class="text-(--accent-text)">LwwRegister</code> uses
<code class="text-(--accent-text)">compareOpId</code> to pick the surviving value;
<code class="text-(--accent-text)">Rga</code> uses it to break ties between concurrent inserts at
the same position; <code class="text-(--accent-text)">MarkStore</code> 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.
</p>
</div>
</div>
<div class="prose-docs">
<h2>Version vectors: who has seen what</h2>
<p>
Op ids order operations; a <code>VersionVector</code> summarizes <em>which</em> 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 <strong>dense</strong> — a site emits <code>1, 2, 3, …</code> 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.
</p>
</div>
<DocsCode :code="vvSrc" lang="ts" />
<div class="prose-docs">
<h3>Deduplication</h3>
<p>
Networks redeliver. Because operations are idempotent (more on that below), re-applying one is
harmless — but <code>vv.has(id)</code> 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.
</p>
<h3>Deltas</h3>
<p>
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 <em>only</em> the operations it's missing never the whole
document. A site with clock <code>4</code> in their vector but <code>9</code> in yours means ops
<code>5</code> through <code>9</code> are the delta. Version vectors are tiny and serialize to a plain
<code>{ site: clock }</code> object, so they're cheap to ship as the "here's what I have" handshake.
</p>
</div>
<DocsCode :code="vvWireSrc" lang="ts" />
<div class="prose-docs">
<div class="my-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
<p class="m-0 text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-amber-700 dark:text-amber-400">Density matters.</strong>
<code class="text-(--accent-text)">VersionVector</code> only works because clocks arrive without
gaps. If you generate ids with a raw <code class="text-(--accent-text)">LamportClock</code>, deliver
them in order per site (the <code class="text-(--accent-text)">Replica</code>'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.
</p>
</div>
</div>
<div class="prose-docs">
<h2>The three properties</h2>
<p>
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.
</p>
</div>
<DocsCode :code="propsSrc" lang="ts" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Commutative</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Order of application doesn't change the result. A replica can integrate operations as they arrive,
in whatever sequence the network delivers them.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Idempotent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Applying the same operation twice is the same as applying it once. Redelivery and retries are safe;
version vectors make them free.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Convergent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Same set of operations, same final state — full stop. Two replicas that have seen the same ops are
byte-for-byte identical.
</p>
</div>
</div>
<div class="prose-docs">
<p>
Commutativity and idempotency are <em>local</em> properties of how a single replica integrates an
operation. Convergence is the <em>global</em> consequence: if integration is both order-independent
and duplicate-safe, then the state of a replica is a pure function of the <em>set</em> 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.
</p>
</div>
<div class="prose-docs">
<h2>Putting it together</h2>
<p>
With the model in hand, the rest of the library reads as direct applications of it. The same
<code>OpId</code> that names an operation is the value <code>compareOpId</code> 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:
</p>
<ul>
<li>
<NuxtLink to="/crdt/primitives">Primitives</NuxtLink> — see the order in action across
<NuxtLink to="/crdt/rga">Rga</NuxtLink>, <NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink>,
and fractional indexing with <NuxtLink to="/crdt/key-between">keyBetween</NuxtLink>.
</li>
<li>
<NuxtLink to="/crdt/replication">Replication &amp; Sync</NuxtLink> — how
<NuxtLink to="/crdt/replica">Replica</NuxtLink> wires a clock, op log, and causal buffer into
version-vector deltas.
</li>
<li>
<NuxtLink to="/crdt/playground">Playground</NuxtLink> watch two replicas diverge and reconcile,
live in the browser.
</li>
</ul>
</div>
</div>
</template>
+435
View File
@@ -0,0 +1,435 @@
<!-- title: Primitives -->
<!-- order: 2 -->
<script setup lang="ts">
const lwwRegister = `import { LwwRegister, opId } from '@robonen/crdt';
// Two replicas hold the same register, start from the same value.
const a = new LwwRegister('draft');
const b = new LwwRegister('draft');
// They write concurrently — A at clock 4, B at clock 5.
a.set('A wins?', opId('a', 4));
b.set('B wins!', opId('b', 5));
// Exchange the writes (order and duplicates don't matter):
a.set('B wins!', opId('b', 5)); // 5 > 4 → accepted, returns true
b.set('A wins?', opId('a', 4)); // 4 < 5 → rejected, returns false
a.get(); // 'B wins!'
a.get() === b.get(); // true — converged on the higher op id`;
const lwwMap = `import { LwwMap, opId } from '@robonen/crdt';
const a = new LwwMap<string, string>();
const b = new LwwMap<string, string>();
// Concurrent edits to the same key, plus a concurrent delete.
a.set('color', 'red', opId('a', 7));
b.set('color', 'blue', opId('b', 7)); // same clock — site id breaks the tie
a.delete('color', opId('a', 7)); // tie too: delete vs set at the same id
// After both replicas see all three ops…
b.set('color', 'red', opId('a', 7)); // already covered by b's clock-7 write
a.set('color', 'blue', opId('b', 7)); // 'b' > 'a' at clock 7 → blue wins
a.get('color'); // 'blue'
a.has('color'); // true
a.toEntries(); // [['color', 'blue']]`;
const fractionalBetween = `import { keyBetween } from '@robonen/crdt';
// Open bounds (null) ask for "before everything" / "after everything".
const first = keyBetween(null, null); // e.g. 'V'
const second = keyBetween(first, null); // a key after 'first'
const zeroth = keyBetween(null, first); // a key before 'first'
// Insert strictly between two existing neighbors — no renumbering, ever.
const mid = keyBetween(zeroth, first);
zeroth < mid && mid < first; // true
// Sorting items by key reproduces their order:
const items = [
{ text: 'b', key: first },
{ text: 'a', key: zeroth },
{ text: 'ab', key: mid },
];
items.sort((x, y) => (x.key < y.key ? -1 : x.key > y.key ? 1 : 0));
items.map(i => i.text); // ['a', 'ab', 'b']`;
const fractionalBatch = `import { keysBetween } from '@robonen/crdt';
// Pre-allocate N keys at once — ascending, all strictly between the bounds.
const keys = keysBetween(null, null, 5);
// each keys[i] < keys[i + 1]
// Moving an item is a single-field write: give it a new key between its
// new neighbors and re-sort. Nothing else in the list changes.
function move(list, fromKeyLeft, fromKeyRight) {
return keyBetween(fromKeyLeft, fromKeyRight);
}`;
const rgaBasic = `import { Rga, opId } from '@robonen/crdt';
const rga = new Rga<string>();
// integrateInsert(id, value, originLeft) — originLeft = null means "at the start".
rga.integrateInsert(opId('a', 1), 'H', null);
rga.integrateInsert(opId('a', 2), 'i', opId('a', 1)); // after 'H'
rga.toArray().join(''); // 'Hi'
// Delete = tombstone. The node stays as an anchor; it just stops being visible.
rga.integrateDelete(opId('a', 2));
rga.toArray().join(''); // 'H'
rga.length; // 1 (visible count, tombstones excluded)`;
const rgaConverge = `import { Rga, opId } from '@robonen/crdt';
// Two replicas both start from "AC" and concurrently insert after 'A'.
function seed() {
const rga = new Rga<string>();
rga.integrateInsert(opId('seed', 1), 'A', null);
rga.integrateInsert(opId('seed', 2), 'C', opId('seed', 1));
return rga;
}
const left = seed();
const right = seed();
// left inserts 'x' after 'A'; right inserts 'y' after 'A' — same origin.
const xOp = { id: opId('left', 5), value: 'x', origin: opId('seed', 1) };
const yOp = { id: opId('right', 5), value: 'y', origin: opId('seed', 1) };
// Apply locally, then exchange. Order and duplicates don't matter.
left.integrateInsert(xOp.id, xOp.value, xOp.origin);
left.integrateInsert(yOp.id, yOp.value, yOp.origin);
right.integrateInsert(yOp.id, yOp.value, yOp.origin);
right.integrateInsert(xOp.id, xOp.value, xOp.origin);
// Tie-break: higher op id first. opId('right', 5) > opId('left', 5)
// (same clock, 'right' > 'left'), so 'y' lands before 'x'.
left.toArray().join(''); // 'AyxC'
right.toArray().join(''); // 'AyxC' — converged`;
const rgaBuffer = `import { Rga, opId } from '@robonen/crdt';
const rga = new Rga<string>();
rga.integrateInsert(opId('a', 1), 'H', null);
// An op arrives BEFORE its origin (causal violation). integrateInsert
// returns false instead of corrupting order — the caller buffers it.
const pending = { id: opId('a', 3), value: '!', origin: opId('a', 2) };
const ok = rga.integrateInsert(pending.id, pending.value, pending.origin);
// ok === false — origin opId('a', 2) isn't present yet
// Once the missing origin lands, retry the buffered op:
rga.integrateInsert(opId('a', 2), 'i', opId('a', 1));
rga.integrateInsert(pending.id, pending.value, pending.origin); // now true
rga.toArray().join(''); // 'Hi!'`;
const marks = `import { Rga, MarkStore, opId } from '@robonen/crdt';
// Build a sequence and grab the op ids of its characters.
const rga = new Rga<string>();
const ids: ReturnType<typeof opId>[] = [];
let left: ReturnType<typeof opId> | null = null;
for (let i = 0; i < 'bold'.length; i++) {
const id = opId('a', i + 1);
rga.integrateInsert(id, 'bold'[i]!, left);
ids.push(id);
left = id;
}
const marks = new MarkStore();
// A span anchors to the FIRST and LAST character op ids (inclusive), not to
// integer offsets — so it survives concurrent inserts/deletes around it.
marks.add({
id: opId('a', 10),
type: 'strong',
value: true,
start: ids[0]!, // 'b'
end: ids[3]!, // 'd'
});
// resolve() returns one active type→value map per character, in document order.
const active = marks.resolve(rga.visible().map(n => n.id));
active.map(m => m.get('strong')); // [true, true, true, true]`;
const marksConflict = `import { MarkStore, opId } from '@robonen/crdt';
const store = new MarkStore();
const start = opId('a', 1);
const end = opId('a', 4);
// Concurrent formatting on the same range: B turns it bold, A clears it.
store.add({ id: opId('a', 9), type: 'strong', value: false, start, end });
store.add({ id: opId('b', 9), type: 'strong', value: true, start, end });
// Highest op id wins per (character, type). opId('b', 9) > opId('a', 9),
// so 'strong' resolves to true — a null/false value would have cleared it.
const order = [opId('a', 1), opId('a', 2), opId('a', 3), opId('a', 4)];
store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
</script>
<template>
<div class="docs-section">
<!-- Intro -->
<div class="prose-docs">
<h1>Primitives</h1>
<p>
<code>@robonen/crdt</code> 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.
</p>
<p>
Every primitive leans on one shared idea:
<NuxtLink to="/crdt/compare-op-id">compareOpId</NuxtLink> a deterministic total order over
operation ids (higher Lamport clock wins; site id breaks ties). Because all primitives resolve
conflicts the same way, two replicas that have seen the same operations always agree, no matter
the order or duplicates in which those operations arrived. If op ids are new to you, start with
<NuxtLink to="/crdt/concepts">Concepts</NuxtLink>.
</p>
</div>
<!-- Map of the package -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Registers</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">LwwRegister</code> and
<code class="text-(--accent-text)">LwwMap</code> single values and keyed maps where the
write with the highest op id wins.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Ordering</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">keyBetween</code> /
<code class="text-(--accent-text)">keysBetween</code> fractional indexing to place or move
an item with a single string key.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Sequence</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">Rga</code> a replicated growable array: an ordered
sequence CRDT with tombstones and a deterministic insert tie-break.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Marks</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">MarkStore</code> lightweight Peritext formatting spans
anchored to character op ids, resolved per character by highest op id.
</p>
</div>
</div>
<!-- Registers -->
<div class="prose-docs">
<h2>LWW registers</h2>
<p>
A <NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink> is the smallest CRDT: a single
value with a timestamp. Every write carries an <code>OpId</code>, and a write only takes effect
if its id is strictly later than the current one by <code>compareOpId</code>. That single rule
gives you the three convergence properties for free applying writes is
<strong>commutative</strong> (a later write always beats an earlier one regardless of arrival
order), <strong>idempotent</strong> (re-applying a write is a no-op), and
<strong>convergent</strong> (every replica ends on the same winning write).
</p>
<p>
<code>set(value, id)</code> returns <code>true</code> when the write won and
<code>false</code> when it was superseded, which is handy for skipping downstream work.
</p>
</div>
<DocsCode :code="lwwRegister" lang="ts" />
<div class="prose-docs">
<h3>LwwMap</h3>
<p>
<NuxtLink to="/crdt/lww-map">LwwMap</NuxtLink> is a register per key. Each entry tracks its own
timestamp and a tombstone flag, so concurrent <code>set</code> and <code>delete</code> on the
same key converge to whichever has the higher op id deleting is just another timestamped
write that happens to hide the value. <code>get</code>, <code>has</code>, <code>keys</code>,
and <code>toEntries</code> all skip tombstoned entries, so the map reads like a plain map even
though deletions are retained internally for convergence.
</p>
</div>
<DocsCode :code="lwwMap" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-(--fg)">Why keep tombstones?</strong> If a delete simply dropped the entry,
a concurrent <code class="text-(--accent-text)">set</code> arriving afterward would resurrect
the key the two replicas would disagree on whether it exists. Retaining the delete as a
timestamped tombstone lets <code class="text-(--accent-text)">compareOpId</code> decide the
winner deterministically, the same way it does for live values.
</p>
</div>
<!-- Ordering -->
<div class="prose-docs">
<h2>Fractional indexing</h2>
<p>
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.
<NuxtLink to="/crdt/key-between">keyBetween</NuxtLink> sidesteps this by giving each item a
<em>string key</em> that lives strictly between its neighbors. Order is recovered by sorting
keys with plain string comparison — the digit alphabet is ASCII-ascending, so lexical order
matches digit order.
</p>
<p>
Pass <code>null</code> for an open bound: <code>keyBetween(null, x)</code> is "before
<code>x</code>", <code>keyBetween(x, null)</code> is "after <code>x</code>", and
<code>keyBetween(null, null)</code> 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.
</p>
</div>
<DocsCode :code="fractionalBetween" lang="ts" />
<div class="prose-docs">
<h3>Batches and moves</h3>
<p>
<NuxtLink to="/crdt/keys-between">keysBetween</NuxtLink> generates <code>n</code> 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, <strong>moving</strong>
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
<NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink> writes on each item's key).
</p>
</div>
<DocsCode :code="fractionalBatch" lang="ts" />
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-amber-700 dark:text-amber-400">Heads up:</strong>
<code class="text-(--accent-text)">keyBetween</code> requires <code>lower &lt; upper</code>
and throws otherwise. Two replicas independently generating a key between the
<em>same</em> neighbors can produce identical keys; pair the key with the item's op id as a
secondary sort to keep ordering deterministic, or let
<NuxtLink to="/crdt/rga">Rga</NuxtLink> handle character-level ordering for you.
</p>
</div>
<!-- Sequence -->
<div class="prose-docs">
<h2>The RGA sequence</h2>
<p>
<NuxtLink to="/crdt/rga">Rga</NuxtLink> (Replicated Growable Array) is the heart of the
package — the CRDT behind collaborative text. Each element is a node with a unique
<code>OpId</code>, a value, and an <code>originLeft</code>: the id of the element it was
inserted <em>after</em> (<code>null</code> means the start of the sequence). Deletion never
removes a node; it sets a <strong>tombstone</strong> flag, so the node lives on as a stable
anchor that later inserts and marks can still reference.
</p>
<p>
<code>integrateInsert(id, value, originLeft)</code> and <code>integrateDelete(id)</code> are
both idempotent — re-integrating an op you've already seen is a no-op that safely returns
<code>true</code>. Read the visible state with <code>toArray()</code>; use
<code>visible()</code> to get the surviving nodes (and their ids) for cursor anchoring, and
<code>length</code> for the visible count.
</p>
</div>
<DocsCode :code="rgaBasic" lang="ts" />
<div class="prose-docs">
<h3>Concurrent inserts and the tie-break</h3>
<p>
The interesting case is two replicas inserting at the <em>same</em> 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
<strong>higher op id</strong> is placed first (<code>compareOpId &gt; 0</code> scans past it).
Because every replica applies the identical comparison, they all settle on the same order
without any coordination.
</p>
</div>
<DocsCode :code="rgaConverge" lang="ts" />
<div class="prose-docs">
<h3>Causal buffering</h3>
<p>
RGA requires inserts to be integrated <strong>in causal order</strong>: an element's
<code>originLeft</code> must already be present, or there's no anchor to insert after. Rather
than guess, <code>integrateInsert</code> returns <code>false</code> when the origin is missing
and <code>integrateDelete</code> returns <code>false</code> for an unknown target the signal
to <em>buffer</em> the op and retry once its dependency lands. (At a higher level,
<NuxtLink to="/crdt/replica">Replica</NuxtLink> does this bookkeeping for you, holding and
replaying ops automatically.)
</p>
</div>
<DocsCode :code="rgaBuffer" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-(--fg)">Garbage collection.</strong> Tombstones accumulate. When every
replica has fully synced and nothing is in flight, <code class="text-(--accent-text)">gc(stable, keep?)</code>
drops deleted nodes whose insert is covered by a stable
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink>, returning how many it removed.
Run it only at quiescence a late op that uses a dropped node as its origin could no longer
integrate and pass <code class="text-(--accent-text)">keep</code> to protect ids still
referenced elsewhere, such as mark span endpoints.
</p>
</div>
<!-- Marks -->
<div class="prose-docs">
<h2>Marks (lightweight Peritext)</h2>
<p>
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.
<NuxtLink to="/crdt/mark-store">MarkStore</NuxtLink> follows the
<a href="https://www.inkandswitch.com/peritext/" target="_blank" rel="noopener">Peritext</a>
model: a <code>MarkSpan</code> anchors to the <code>OpId</code> 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.
</p>
<p>
A span's <code>value</code> is a JSON-serializable <code>MarkValue</code> pass
<code>true</code> (or attributes like a color string) to apply the mark, and
<code>null</code> or <code>false</code> to clear it.
</p>
</div>
<DocsCode :code="marks" lang="ts" />
<div class="prose-docs">
<h3>Resolving and converging</h3>
<p>
<code>add(span)</code> just records a span (idempotent by span id). The real work is
<code>resolve(order)</code>: given the character op ids in document order typically
<code>rga.visible().map(n =&gt; n.id)</code> it returns one <code>Map&lt;type, value&gt;</code>
of active marks per character. For each character and mark type, the covering span with the
<strong>highest op id wins</strong>, so concurrent formatting converges by the same
<code>compareOpId</code> rule as everything else; a winning <code>null</code>/<code>false</code>
span clears the mark.
</p>
</div>
<DocsCode :code="marksConflict" lang="ts" />
<!-- Where next -->
<div class="prose-docs">
<h2>Where to next</h2>
<ul>
<li>
<NuxtLink to="/crdt/concepts">Concepts</NuxtLink> op ids, Lamport clocks, version vectors,
and why convergence holds across all of these primitives.
</li>
<li>
<NuxtLink to="/crdt/replication">Replication &amp; Sync</NuxtLink> wire the primitives to a
<NuxtLink to="/crdt/replica">Replica</NuxtLink> for delta sync and automatic causal
buffering.
</li>
<li>
<NuxtLink to="/crdt/playground">Playground</NuxtLink> watch two replicas diverge and
reconcile, live in the browser.
</li>
</ul>
</div>
</div>
</template>
+378
View File
@@ -0,0 +1,378 @@
<!-- title: Replication & Sync -->
<!-- order: 3 -->
<script setup lang="ts">
const opLogShape = `import { OpLog } from '@robonen/crdt';
// The op log only ever reads \`id\` — the rest of the op is your domain payload.
interface CharOp {
id: { site: string; clock: number };
originLeft: { site: string; clock: number } | null;
value: string;
}
const log = new OpLog<CharOp>();
log.append(op); // true if new, false if already seen (dedup by id)
log.has(op.id); // version vector lookup, not a linear scan
log.version; // VersionVector — the highest clock seen per site
log.all(); // every op, in append order
log.delta(remoteVector); // ops the remote (described by its vector) lacks`;
const deltaExample = `// A asks B: "here's everything I've seen" (a state vector).
const aWants = a.replica.version;
// B answers with exactly the ops A is missing — nothing more.
const patch = b.replica.delta(aWants); // OpLog.delta filters by the vector
// A integrates them; ids it already has are silently dropped.
a.replica.receive(patch);`;
const roundTrip = `import { Replica, Rga } from '@robonen/crdt';
import type { OpId } from '@robonen/crdt';
interface CharOp {
id: OpId;
originLeft: OpId | null;
value: string;
}
// Each site owns an RGA (the sequence state) behind a Replica
// (clock + op log + causal buffer + delta sync).
function makeReplica(site: string) {
const rga = new Rga<string>();
const replica = new Replica<CharOp>(
{ integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) },
site,
);
return { rga, replica };
}
function type(peer: ReturnType<typeof makeReplica>, text: string): void {
let left: OpId | null = null;
for (const ch of text) {
const id = peer.replica.nextId(); // tick the Lamport clock
peer.replica.commitLocal({ id, originLeft: left, value: ch });
left = id;
}
}
const a = makeReplica('a');
const b = makeReplica('b');
// Concurrent, independent edits — neither has seen the other.
type(a, 'Hi');
type(b, 'Yo');
// Exchange ONLY the delta each side is missing, in both directions.
b.replica.receive(a.replica.delta(b.replica.version));
a.replica.receive(b.replica.delta(a.replica.version));
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged
a.rga.length; // 4`;
const bufferingExample = `const a = makeReplica('a');
type(a, 'ab'); // two ops; the 2nd inserts after (depends on) the 1st
const b = makeReplica('b');
const [op1, op2] = a.replica.delta(b.replica.version);
// Deliver the DEPENDENT op first. Its origin (op1) isn't present,
// so integrate() returns false and the replica buffers it.
b.replica.receive([op2]);
b.rga.toArray().join(''); // '' — nothing applied yet
// Now deliver the dependency. op1 integrates, which unblocks op2;
// drain() loops until no further progress is possible.
b.replica.receive([op1]);
b.rga.toArray().join(''); // 'ab'`;
const wireExample = `import {
encodeStateVector, decodeStateVector,
encodeOps, decodeOps,
} from '@robonen/crdt';
// --- Peer A: announce what I have ---
const myVector: Uint8Array = encodeStateVector(a.replica.version);
socket.send(myVector); // send over WebSocket, HTTP, BroadcastChannel, …
// --- Peer B: answer with the delta A is missing ---
const remoteVector = decodeStateVector(received);
const patch: Uint8Array = encodeOps(b.replica.delta(remoteVector));
socket.send(patch);
// --- Peer A: apply the patch ---
const ops = decodeOps<CharOp>(receivedPatch);
a.replica.receive(ops);`;
</script>
<template>
<div class="docs-section">
<!-- Intro -->
<div class="prose-docs">
<h1>Replication &amp; Sync</h1>
<p>
A CRDT primitive on its own guarantees that the <em>same</em> 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.
</p>
<p>
The pieces fit together in one direction. <code>OpLog</code> stores ops and tracks a
<code>VersionVector</code>; <code>Replica</code> wraps a log plus a Lamport clock and a
causal buffer, integrating local and remote ops into your domain state; and the
<code>sync</code> helpers turn version vectors and op batches into bytes for any transport.
</p>
</div>
<!-- Mental model -->
<div class="prose-docs">
<h2>The convergence model</h2>
<p>
Every operation carries a globally-unique <code>OpId</code> a per-site
<a href="https://en.wikipedia.org/wiki/Lamport_timestamp">Lamport</a> clock value tagged
with the site that produced it (<code>{ site, clock }</code>). Two facts make replication
work, and both flow from that id:
</p>
<ul>
<li>
<strong>Identity idempotence.</strong> Because an op's id is stable, a replica can tell
whether it has already seen an op and apply it at most once. Delivering the same op twice
is a no-op, so duplicate or replayed messages are harmless.
</li>
<li>
<strong>Determinism ⇒ commutativity.</strong> Concurrent ops are resolved by one shared
tie-break — <NuxtLink to="/crdt/compare-op-id">compareOpId</NuxtLink> (higher clock wins,
site id breaks ties). Since every replica agrees on it, the order ops arrive in doesn't
change the final state.
</li>
</ul>
<p>
Replication therefore reduces to a set-reconciliation problem: <em>get both replicas to the
same set of ops.</em> Convergence of the resulting state is the primitive's job; getting the
ops there efficiently is this layer's.
</p>
</div>
<!-- Version vectors -->
<div class="prose-docs">
<h3>Version vectors: "what have you seen?"</h3>
<p>
A <NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink> is the compact summary of
everything a replica has observed a map from site id to the highest clock seen for that
site. It relies on one assumption: each site emits <strong>dense</strong> clocks
(1, 2, 3, ), with no gaps. That's what lets a single number per site stand in for a whole
set: if a replica has seen <code>a@5</code>, it has necessarily seen <code>a@1…a@4</code>
too. So <code>has(id)</code> is just <code>get(id.site) &gt;= id.clock</code> — an O(1)
check, no per-op bookkeeping.
</p>
<p>
Two operations follow directly. <strong>Dedup:</strong> an incoming op whose id the vector
already covers can be ignored. <strong>Delta:</strong> given a remote vector, the set of ops
the remote lacks is exactly those whose id the vector does <em>not</em> cover.
</p>
</div>
<!-- OpLog -->
<div class="prose-docs">
<h2>The op log</h2>
<p>
<NuxtLink to="/crdt/op-log">OpLog</NuxtLink> is an append-only list of operations paired
with a version vector. It is deliberately domain-agnostic: the only field it reads is
<code>id</code> (the <code>HasOpId</code> constraint), so the same log stores RGA inserts,
LWW writes, mark spans, or anything else you give it.
</p>
</div>
<DocsCode :code="opLogShape" lang="ts" />
<div class="prose-docs">
<p>
<code>append</code> consults the vector first and returns <code>false</code> if the op is a
duplicate, so the log never stores the same id twice. <code>delta(remote)</code> walks the
log once and keeps every op the remote vector hasn't covered this is the heart of
"exchange only the delta".
</p>
</div>
<DocsCode :code="deltaExample" lang="ts" />
<!-- Replica -->
<div class="prose-docs">
<h2>The replica</h2>
<p>
<NuxtLink to="/crdt/replica">Replica</NuxtLink> ties everything together. It owns a
<code>LamportClock</code>, an <code>OpLog</code>, and a pending buffer, and you give it a
single handler <code>integrate(op)</code> that applies an op to your domain state and
returns <code>false</code> when the op's causal dependencies aren't present yet.
</p>
<h3>Producing local ops</h3>
<p>
Call <code>nextId()</code> to tick the clock and mint a fresh, causally-later
<code>OpId</code>, build your op around it, then hand it to <code>commitLocal(op)</code>.
That logs it, integrates it into local state, and notifies <code>onUpdate</code> listeners
with origin <code>'local'</code>. Because <code>nextId</code> advances a Lamport clock that
also tracks observed remote ops, locally-generated ids are always ordered after everything
the replica has seen.
</p>
<h3>Receiving remote ops</h3>
<p>
<code>receive(ops)</code> is the inbound path. For each op it advances the clock past the
remote id (<code>clock.observe</code>), 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 <code>'remote'</code>.
</p>
<h3>Computing a delta</h3>
<p>
<code>delta(remoteVector)</code> forwards to the log: the ops this replica holds that the
remote, described by its <code>version</code>, has not seen. The whole round-trip is two
deltas one per direction.
</p>
</div>
<!-- Round trip -->
<div class="prose-docs">
<h2>The canonical round-trip</h2>
<p>
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.
</p>
</div>
<DocsCode :code="roundTrip" lang="ts" />
<div class="prose-docs">
<p>
Note the asymmetry that makes this efficient: <code>a.replica.delta(b.replica.version)</code>
is computed against <em>B's</em> 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.
</p>
</div>
<!-- Why order does not matter -->
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Why the order of the two deltas is irrelevant</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
You could swap the two <code class="text-(--accent-text)">receive</code> 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 <code class="text-(--accent-text)">compareOpId</code> places
each op in its deterministic position regardless of arrival order. That is convergence,
and the property tests assert it across randomized schedules.
</p>
</div>
<!-- Causal buffering -->
<div class="prose-docs">
<h2>Causal buffering</h2>
<p>
Some ops can't be applied the instant they arrive. An RGA insert references an
<code>originLeft</code> — 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.
</p>
<p>
The handler signals this by returning <code>false</code> from <code>integrate</code>:
<NuxtLink to="/crdt/rga">Rga</NuxtLink>'s <code>integrateInsert</code> returns
<code>false</code> when its origin is absent, and <code>integrateDelete</code> returns
<code>false</code> when its target is unknown. <code>Replica.receive</code> treats a
<code>false</code> 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.
</p>
</div>
<DocsCode :code="bufferingExample" lang="ts" />
<div class="prose-docs">
<p>
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 <code>receive</code> of a batch delivered in any order still settles to the right
state — the buffer absorbs the disorder.
</p>
</div>
<!-- Wire encoding -->
<div class="prose-docs">
<h2>Transport-agnostic wire encoding</h2>
<p>
The <code>sync</code> 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:
</p>
<ul>
<li>
<NuxtLink to="/crdt/encode-state-vector">encodeStateVector</NuxtLink> /
<code>decodeStateVector</code> — a <code>VersionVector</code> ⇄ <code>Uint8Array</code>.
</li>
<li>
<NuxtLink to="/crdt/encode-ops">encodeOps</NuxtLink> / <code>decodeOps</code> — an op
batch (the delta or a full snapshot) ⇄ <code>Uint8Array</code>.
</li>
<li>
<code>encodeJson</code> / <code>decodeJson</code> — the lower-level pair the others build on.
</li>
</ul>
<p>
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 <code>Uint8Array</code>, the transport
is entirely up to you: WebSocket, HTTP, <code>BroadcastChannel</code>, a file on disk.
</p>
</div>
<DocsCode :code="wireExample" lang="ts" />
<!-- A typical sync protocol -->
<div class="prose-docs">
<h3>A minimal two-way protocol</h3>
<p>
Put the pieces together and a full reconciliation between two peers is four messages:
</p>
<ol>
<li>Each peer sends its <code>encodeStateVector(replica.version)</code>.</li>
<li>
On receiving the other's vector, each peer replies with
<code>encodeOps(replica.delta(theirVector))</code>.
</li>
<li>Each peer <code>receive()</code>s the decoded delta.</li>
<li>Both replicas now hold the same op set and the same converged state.</li>
</ol>
<p>
This generalizes cleanly. For live collaboration, also forward each locally-committed op as
it happens (subscribe with <code>onUpdate</code>, 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.
</p>
</div>
<!-- Caveat callout -->
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-5">
<h3 class="mb-1.5 text-sm font-semibold text-amber-700 dark:text-amber-400">Dense clocks are a precondition</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Version vectors assume each site's clocks are dense (1, 2, 3, ). That holds automatically
when ids come from <code class="text-(--accent-text)">Replica.nextId()</code>. If you mint
ids yourself, never skip a value for a site a gap would make
<code class="text-(--accent-text)">delta</code> believe a missing op was already delivered.
</p>
</div>
<!-- Where next -->
<div class="prose-docs">
<h2>Where to next</h2>
<ul>
<li>
<NuxtLink to="/crdt/replica">Replica</NuxtLink> the full API reference for
<code>commitLocal</code>, <code>receive</code>, <code>delta</code>, and
<code>onUpdate</code>.
</li>
<li>
<NuxtLink to="/crdt/op-log">OpLog</NuxtLink> and
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink> the storage and causality
primitives underneath.
</li>
<li>
<NuxtLink to="/crdt/playground">Playground</NuxtLink> watch two replicas diverge and
reconcile live in the browser.
</li>
</ul>
</div>
</div>
</template>
+528
View File
@@ -0,0 +1,528 @@
<!-- title: Playground -->
<!-- order: 4 -->
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import type { OpId } from '../src';
import { Replica, Rga } from '../src';
// ---------------------------------------------------------------------------
// The op shape exchanged between replicas.
//
// This is a REAL @robonen/crdt setup, not a simulation: each side owns an `Rga`
// for its sequence state, wrapped by a `Replica` that owns the Lamport clock,
// op log, causal buffer and delta computation. The only thing the demo adds is
// a tiny op union so we can both insert and delete characters.
// ---------------------------------------------------------------------------
type CharOp =
| { kind: 'insert'; id: OpId; value: string; originLeft: OpId | null }
| { kind: 'delete'; id: OpId; target: OpId };
interface Side {
rga: Rga<string>;
replica: Replica<CharOp>;
}
function makeSide(site: string): Side {
const rga = new Rga<string>();
const replica = new Replica<CharOp>(
{
// Return `false` when a dependency is missing — the Replica buffers the op
// and retries it automatically once the dependency arrives.
integrate: (op) => {
if (op.kind === 'insert')
return rga.integrateInsert(op.id, op.value, op.originLeft);
return rga.integrateDelete(op.target);
},
},
site,
);
return { rga, replica };
}
// Reactive view-model. The CRDT classes are plain (non-reactive) objects, so we
// keep a small reactive snapshot and refresh it after every mutation.
interface View {
text: string;
ops: number;
clock: number;
pending: number;
}
const snapshot = reactive<{ a: View; b: View }>({
a: { text: '', ops: 0, clock: 0, pending: 0 },
b: { text: '', ops: 0, clock: 0, pending: 0 },
});
const drafts = reactive({ a: '', b: '' });
let a: Side | null = null;
let b: Side | null = null;
function refresh(): void {
if (!a || !b)
return;
snapshot.a = {
text: a.rga.toArray().join(''),
ops: a.replica.version.get(a.replica.site),
clock: a.replica.version.get(a.replica.site),
pending: 0,
};
snapshot.b = {
text: b.rga.toArray().join(''),
ops: b.replica.version.get(b.replica.site),
clock: b.replica.version.get(b.replica.site),
pending: 0,
};
}
function init(): void {
a = makeSide('A');
b = makeSide('B');
drafts.a = '';
drafts.b = '';
refresh();
}
// The drafts are deliberately decoupled from the CRDT value until "Apply":
// that lets the user stage CONCURRENT edits on both sides before any sync, the
// scenario where convergence actually matters.
/**
* Diff the side's current CRDT string against the textarea draft and emit the
* minimal insert/delete ops to make the RGA match the draft, committing each
* locally. A real editor derives these ops from input events the same way.
*/
function apply(which: 'a' | 'b'): void {
const side = which === 'a' ? a : b;
if (!side)
return;
const current = side.rga.toArray();
const next = [...(which === 'a' ? drafts.a : drafts.b)];
// Longest common prefix / suffix → splice region (a tiny, dependency-free diff).
let start = 0;
while (start < current.length && start < next.length && current[start] === next[start])
start += 1;
let endCur = current.length;
let endNext = next.length;
while (endCur > start && endNext > start && current[endCur - 1] === next[endNext - 1]) {
endCur -= 1;
endNext -= 1;
}
// Delete the removed characters (right-to-left keeps live indices stable).
for (let i = endCur - 1; i >= start; i--) {
const target = side.rga.idAt(i);
if (target) {
const op: CharOp = { kind: 'delete', id: side.replica.nextId(), target };
side.replica.commitLocal(op);
}
}
// Insert the new characters after the surviving left neighbour.
let left = start > 0 ? side.rga.idAt(start - 1) : null;
for (let i = start; i < endNext; i++) {
const op: CharOp = {
kind: 'insert',
id: side.replica.nextId(),
value: next[i]!,
originLeft: left,
};
side.replica.commitLocal(op);
left = op.id;
}
// Re-read drafts from the authoritative CRDT value.
drafts.a = a!.rga.toArray().join('');
drafts.b = b!.rga.toArray().join('');
refresh();
}
/**
* One full sync round: each side hands the other only the ops it is missing
* (computed from the peer's version vector), and `receive` integrates them with
* dedup + causal buffering. After this both RGAs hold the identical sequence.
*/
function sync(): void {
if (!a || !b)
return;
// Snapshot versions BEFORE exchanging so each delta reflects pre-sync state.
const va = a.replica.version.clone();
const vb = b.replica.version.clone();
b.replica.receive(a.replica.delta(vb));
a.replica.receive(b.replica.delta(va));
drafts.a = a.rga.toArray().join('');
drafts.b = b.rga.toArray().join('');
refresh();
}
const ready = ref(false);
function start(): void {
if (ready.value)
return;
init();
ready.value = true;
}
const converged = computed(() =>
snapshot.a.text === snapshot.b.text && (snapshot.a.text.length > 0 || snapshot.a.ops > 0));
// --- static code samples ---------------------------------------------------
const setupCode = `import { Replica, Rga } from '@robonen/crdt';
import type { OpId } from '@robonen/crdt';
// Inserts and deletes travel as ops. Every op carries an \`id\`; that's all
// Replica's op log needs to dedup and compute deltas.
type CharOp =
| { kind: 'insert'; id: OpId; value: string; originLeft: OpId | null }
| { kind: 'delete'; id: OpId; target: OpId };
function makeSide(site: string) {
const rga = new Rga<string>();
const replica = new Replica<CharOp>(
{
// Return false when a causal dependency is missing — the Replica buffers
// the op and retries it automatically once the dependency lands.
integrate: (op) =>
op.kind === 'insert'
? rga.integrateInsert(op.id, op.value, op.originLeft)
: rga.integrateDelete(op.target),
},
site,
);
return { rga, replica };
}
const a = makeSide('A');
const b = makeSide('B');`;
const localEditCode = `// A types "cat" at the start. Each character is an insert anchored to the
// previous one via originLeft; nextId() advances A's Lamport clock.
let left: OpId | null = null;
for (const ch of 'cat') {
const op = { kind: 'insert', id: a.replica.nextId(), value: ch, originLeft: left } as const;
a.replica.commitLocal(op); // integrate locally + append to the log
left = op.id;
}
// Concurrently, B types "dog" — it has NOT seen A's ops yet.
left = null;
for (const ch of 'dog') {
const op = { kind: 'insert', id: b.replica.nextId(), value: ch, originLeft: left } as const;
b.replica.commitLocal(op);
left = op.id;
}
a.rga.toArray().join(''); // 'cat'
b.rga.toArray().join(''); // 'dog' — the replicas have DIVERGED`;
const syncCode = `// Send each side only what it's missing, computed from the peer's version.
// Snapshot versions first so both deltas describe the pre-sync state.
const va = a.replica.version.clone();
const vb = b.replica.version.clone();
b.replica.receive(a.replica.delta(vb)); // B integrates A's 3 inserts
a.replica.receive(b.replica.delta(va)); // A integrates B's 3 inserts
// Both RGAs now hold the same six characters in the same order. The order is
// decided by compareOpId (higher clock wins; site id breaks the tie) — NOT by
// who synced first — so the result is identical on every replica.
a.rga.toArray().join(''); // e.g. 'dogcat'
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
</script>
<template>
<div class="docs-section">
<!-- Intro -->
<div class="prose-docs">
<h1>Playground</h1>
<p>
Reading about convergence only gets you so far the intuition lands when you
<em>watch two replicas disagree and then reconcile</em>. Below is a live, two-replica
editor backed by the real <NuxtLink to="/crdt/rga">Rga</NuxtLink> and
<NuxtLink to="/crdt/replica">Replica</NuxtLink> classes from this package. Edit each side
independently, then press <strong>Sync</strong> and see them land on the exact same string.
</p>
</div>
<!-- Live demo -->
<div class="prose-docs">
<h2>Live: two replicas, one string</h2>
<p>
Replica <strong>A</strong> and replica <strong>B</strong> each own a private copy of a
shared document. Type something different into each, click <strong>Apply</strong> to commit
those edits locally (they diverge), then <strong>Sync</strong> 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.
</p>
</div>
<ClientOnly>
<template #fallback>
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-8 text-center text-sm text-(--fg-subtle)">
Loading interactive demo
</div>
</template>
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4 sm:p-5">
<div v-if="!ready" class="flex flex-col items-center gap-3 py-8 text-center">
<p class="text-sm text-(--fg-muted)">Spin up two fresh replicas to start editing.</p>
<button
type="button"
class="rounded-md bg-(--accent) px-4 py-2 text-sm font-medium text-(--accent-fg) hover:bg-(--accent-hover) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="start()"
>
Start demo
</button>
</div>
<div v-else class="flex flex-col gap-4">
<!-- Two replica panes -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Replica A -->
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) p-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider text-(--fg-muted)">Replica A</span>
<span class="rounded bg-(--bg-inset) px-1.5 py-0.5 font-mono text-[11px] text-(--fg-subtle)">site: A</span>
</div>
<textarea
v-model="drafts.a"
rows="3"
spellcheck="false"
class="resize-none rounded-md border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) focus:border-(--border-strong) focus:outline-none focus:ring-2 focus:ring-(--ring)"
placeholder="Type on A…"
/>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-xs font-medium text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="apply('a')"
>
Apply edits
</button>
<div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-(--fg-subtle)">
<span>ops {{ snapshot.a.ops }}</span>
<span>clock {{ snapshot.a.clock }}</span>
</div>
</div>
<div class="rounded-md bg-(--bg-inset) px-3 py-2 font-mono text-sm text-(--fg) break-all min-h-9">
<span v-if="snapshot.a.text">{{ snapshot.a.text }}</span>
<span v-else class="text-(--fg-subtle)">(empty)</span>
</div>
</div>
<!-- Replica B -->
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) p-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider text-(--fg-muted)">Replica B</span>
<span class="rounded bg-(--bg-inset) px-1.5 py-0.5 font-mono text-[11px] text-(--fg-subtle)">site: B</span>
</div>
<textarea
v-model="drafts.b"
rows="3"
spellcheck="false"
class="resize-none rounded-md border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) focus:border-(--border-strong) focus:outline-none focus:ring-2 focus:ring-(--ring)"
placeholder="Type on B…"
/>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-xs font-medium text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="apply('b')"
>
Apply edits
</button>
<div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-(--fg-subtle)">
<span>ops {{ snapshot.b.ops }}</span>
<span>clock {{ snapshot.b.clock }}</span>
</div>
</div>
<div class="rounded-md bg-(--bg-inset) px-3 py-2 font-mono text-sm text-(--fg) break-all min-h-9">
<span v-if="snapshot.b.text">{{ snapshot.b.text }}</span>
<span v-else class="text-(--fg-subtle)">(empty)</span>
</div>
</div>
</div>
<!-- Sync bar -->
<div class="flex flex-wrap items-center gap-3 border-t border-(--border) pt-3">
<button
type="button"
class="rounded-md bg-(--accent) px-4 py-2 text-sm font-medium text-(--accent-fg) hover:bg-(--accent-hover) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="sync()"
>
Sync
</button>
<button
type="button"
class="rounded-md px-3 py-2 text-sm text-(--fg-muted) hover:bg-(--bg-inset) hover:text-(--fg) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="init()"
>
Reset
</button>
<span
v-if="converged"
class="ml-auto inline-flex items-center gap-1.5 rounded-md bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400"
>
Converged both sides equal
</span>
<span
v-else
class="ml-auto inline-flex items-center gap-1.5 rounded-md bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-600 dark:text-amber-400"
>
Diverged sync to reconcile
</span>
</div>
</div>
</div>
</ClientOnly>
<div class="prose-docs">
<p>
Try the canonical experiment: type <code>cat</code> on A and <code>dog</code> 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.
</p>
</div>
<!-- How it works -->
<div class="prose-docs">
<h2>How the demo is wired</h2>
<p>
There's no mock here. Each side is a real <code>Rga&lt;string&gt;</code> wrapped in a
<code>Replica&lt;CharOp&gt;</code>. The <code>Replica</code> owns the Lamport clock, the
append-only op log, the causal buffer, and delta computation; the <code>Rga</code> holds the
actual character sequence with tombstones. We pass one handler <code>integrate</code>
that applies an op to the RGA.
</p>
</div>
<DocsCode :code="setupCode" lang="ts" />
<div class="prose-docs">
<h3>Making concurrent edits</h3>
<p>
A local edit is just an op: call <code>replica.nextId()</code> to mint a fresh op id (which
ticks that site's Lamport clock), build the insert or delete, and pass it to
<code>commitLocal</code>. 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.
</p>
</div>
<DocsCode :code="localEditCode" lang="ts" />
<div class="prose-docs">
<h3>Syncing the deltas</h3>
<p>
Sync is a delta exchange driven by version vectors. Each replica's
<code>version</code> records the highest clock it has seen per site;
<code>delta(remoteVersion)</code> returns exactly the ops the remote is missing.
<code>receive</code> then dedups, integrates, and crucially <em>buffers</em> any op
whose causal dependency hasn't arrived yet, retrying it automatically once that dependency
lands.
</p>
</div>
<DocsCode :code="syncCode" lang="ts" />
<!-- Why it converges -->
<div class="prose-docs">
<h2>Why it always converges</h2>
<p>
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.
</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Commutative</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
A-then-B and B-then-A produce the same sequence. Concurrent inserts at the same origin are
ordered by <code class="text-(--accent-text)">compareOpId</code>, so order of arrival
doesn't matter.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Idempotent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Receiving the same op twice is a no-op. The op log's version vector dedups on
<code class="text-(--accent-text)">id</code>, and <code class="text-(--accent-text)">integrateInsert</code>
short-circuits if the id is already present.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Causal</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
An insert can't integrate before its <code class="text-(--accent-text)">originLeft</code>,
nor a delete before its target. <code class="text-(--accent-text)">receive</code> buffers
such ops and retries them, so out-of-order delivery still converges.
</p>
</div>
</div>
<div class="prose-docs">
<h3>The single source of truth: op id order</h3>
<p>
Everything hinges on one comparison. When two replicas insert characters at the same
position concurrently, <code>Rga.integrateInsert</code> walks past any existing siblings
whose op id sorts <em>higher</em> and splices the new node in so the final order is fully
determined by <code>compareOpId</code>: 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.
</p>
<p>
That's also why deletes are tombstones rather than removals: a delete only flips a node's
<code>deleted</code> flag, so a concurrent insert that anchored to that node still has a
valid origin. The character disappears from <code>toArray()</code>, but the structure stays
intact for convergence. Tombstones are reclaimed later via
<NuxtLink to="/crdt/rga"><code>Rga.gc</code></NuxtLink>, but only at quiescence.
</p>
</div>
<!-- Things to try -->
<div class="prose-docs">
<h2>Experiments to try</h2>
<ul>
<li>
<strong>Repeat sync.</strong> Press <strong>Sync</strong> twice in a row the second pass
applies nothing, because each side's delta is now empty. Idempotence in action.
</li>
<li>
<strong>Concurrent deletes.</strong> Sync to a shared value, then delete different
characters on each side and sync again. Both deletions survive; neither clobbers the other.
</li>
<li>
<strong>Edit after sync.</strong> Keep editing on one side and syncing repeatedly — only
the new ops travel each time, because <code>delta</code> filters by the peer's version
vector.
</li>
<li>
<strong>Tie-break.</strong> Type a single different character at the very start of each
side, then sync. The one whose op id sorts higher lands first deterministically.
</li>
</ul>
</div>
<!-- Where next -->
<div class="prose-docs">
<h2>Where to next</h2>
<ul>
<li>
<NuxtLink to="/crdt/rga">Rga</NuxtLink> the full sequence API: tombstones, cursor
anchoring via op ids, and garbage collection.
</li>
<li>
<NuxtLink to="/crdt/replica">Replica</NuxtLink> clock, op log, causal buffer, deltas,
and the <code>onUpdate</code> subscription used to drive UI.
</li>
<li>
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink> and
<NuxtLink to="/crdt/compare-op-id">compareOpId</NuxtLink> the causality and tie-break
machinery behind every primitive.
</li>
</ul>
</div>
</div>
</template>
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
const replicaExample = `import { Replica, Rga, opId } from '@robonen/crdt';
// Each editing site owns an RGA (the sequence state) wrapped by a Replica
// (clock + op log + causal buffering + delta sync).
type Op = {
id: ReturnType<typeof opId>;
value: string;
originLeft: ReturnType<typeof opId> | null;
};
function makeReplica(site: string) {
const rga = new Rga<string>();
const replica = new Replica<Op>(
{ integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) },
site,
);
return { rga, replica };
}
const a = makeReplica('a');
const b = makeReplica('b');
// A types "hi" locally.
let left: Op['originLeft'] = null;
for (const ch of 'hi') {
const op: Op = { id: a.replica.nextId(), value: ch, originLeft: left };
a.replica.commitLocal(op);
left = op.id;
}
// Sync: send B only the ops it is missing, then send A only what it lacks.
b.replica.receive(a.replica.delta(b.replica.version));
a.replica.receive(b.replica.delta(a.replica.version));
a.rga.toArray().join(''); // 'hi'
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged`;
</script>
<template>
<div class="docs-section">
<!-- Hero -->
<div class="prose-docs">
<h1>@robonen/crdt</h1>
<p>
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.
</p>
</div>
<div class="prose-docs">
<p>
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 <strong>commutative, idempotent, and convergent</strong>, 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 <code>@robonen/editor</code>, but stays
fully domain-agnostic, ships zero runtime dependencies, and runs in both Node and the browser.
</p>
</div>
<!-- Feature cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Convergent by construction</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
One deterministic tie-break — <code class="text-(--accent-text)">compareOpId</code> (higher
Lamport clock wins; site id breaks ties) — is shared by every primitive, so LWW and RGA agree
on the same final state.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Causal buffering built in</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">Replica.receive</code> dedups, holds ops whose dependencies
haven't arrived yet (an insert before its origin), and retries them automatically as they land.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Delta sync, not full state</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Version vectors let each side request exactly the ops it's missing via
<code class="text-(--accent-text)">delta(version)</code>, with a transport-agnostic wire format.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Zero dependencies, pure TS</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
No runtime deps, no framework lock-in. Compose the primitives yourself, or lean on
<code class="text-(--accent-text)">Replica</code> to tie a clock, op log, and buffer together.
</p>
</div>
</div>
<!-- Install -->
<div class="prose-docs">
<h2>Install</h2>
<p>Add the package with your preferred package manager.</p>
</div>
<DocsCode :code="`pnpm add @robonen/crdt`" lang="bash" />
<!-- Usage -->
<div class="prose-docs">
<h2>Quick start</h2>
<p>
Two replicas edit a string independently, then exchange only the operations each is missing
and converge to the same result.
</p>
</div>
<DocsCode :code="replicaExample" lang="ts" />
<!-- Where next -->
<div class="prose-docs">
<h2>Where to next</h2>
<p>New to CRDTs? Work through the guide and finish in the live playground.</p>
<ul>
<li>
<NuxtLink to="/crdt/concepts">Concepts</NuxtLink> op ids, Lamport clocks, version vectors,
and why convergence holds.
</li>
<li>
<NuxtLink to="/crdt/primitives">Primitives</NuxtLink> a tour of
<NuxtLink to="/crdt/rga">Rga</NuxtLink>,
<NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink>, and fractional indexing with
<NuxtLink to="/crdt/key-between">keyBetween</NuxtLink>.
</li>
<li>
<NuxtLink to="/crdt/replication">Replication &amp; Sync</NuxtLink> wiring up
<NuxtLink to="/crdt/replica">Replica</NuxtLink>, deltas, and the wire encoding.
</li>
<li>
<NuxtLink to="/crdt/playground">Playground</NuxtLink> watch two replicas diverge and
reconcile, live in the browser.
</li>
</ul>
</div>
</div>
</template>
+147
View File
@@ -0,0 +1,147 @@
<script setup lang="ts">
// Landing hero for @robonen/encoding. Static content only — no live demo,
// so nothing here touches the DOM or runs at setup top-level.
</script>
<template>
<div class="docs-section">
<!-- Hero -->
<div class="prose-docs">
<h1>@robonen/encoding</h1>
<p>
Encoding utilities for TypeScript a dependency-free QR Code generator
and the Reed-Solomon error-correction primitives that power it.
</p>
</div>
<div class="prose-docs">
<p>
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. <code>@robonen/encoding</code> 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.
</p>
</div>
<!-- Feature cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">High-level QR in one call</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
<code>encodeText</code> and <code>encodeBinary</code> pick the smallest
version and optimal segment modes for you, then hand back an immutable
<code>QrCode</code> grid.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">Render-agnostic output</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
A <code>QrCode</code> is just a square of modules. Read each one with
<code>getModule(x, y)</code> and draw to SVG, canvas, or anything else
no rendering opinions baked in.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">Standalone Reed-Solomon</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
The GF(2^8) error-correction core <code>multiply</code>,
<code>computeDivisor</code>, <code>computeRemainder</code> is exported
on its own, reusable beyond QR.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">Zero dependencies, fully typed</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
Tree-shakeable ESM and CJS builds with no third-party runtime deps, hot
loops backed by typed arrays, and end-to-end TypeScript types.
</p>
</div>
</div>
<!-- Install -->
<div class="prose-docs">
<h2>Install</h2>
</div>
<DocsCode :code="`pnpm add @robonen/encoding`" lang="bash" />
<!-- Usage -->
<div class="prose-docs">
<h2>Quick start</h2>
<p>
Encode a string into a <code>QrCode</code> and walk its modules to render
it. <code>EccMap.M</code> selects the medium (~15% recovery) error-correction
level; <code>EccMap.L</code>, <code>.Q</code>, and <code>.H</code> are also
available.
</p>
</div>
<DocsCode lang="ts" :code="quickStart" />
<div class="prose-docs">
<p>
Need raw error-correction codewords without the QR machinery? The
Reed-Solomon primitives stand on their own:
</p>
</div>
<DocsCode lang="ts" :code="reedSolomon" />
<!-- Where to next -->
<div class="prose-docs">
<h2>Where to next</h2>
<ul>
<li>
<NuxtLink to="/encoding/encode-text">encodeText</NuxtLink> and
<NuxtLink to="/encoding/encode-binary">encodeBinary</NuxtLink> the
high-level entry points for text and binary payloads.
</li>
<li>
<NuxtLink to="/encoding/encode-segments">encodeSegments</NuxtLink> the
mid-level API for mixed-mode payloads and version/mask control.
</li>
<li>
<NuxtLink to="/encoding/qr-code">QrCode</NuxtLink> the immutable grid,
with <code>size</code>, <code>getModule</code>, and <code>getType</code>.
</li>
<li>
<NuxtLink to="/encoding/compute-remainder">computeRemainder</NuxtLink> and
<NuxtLink to="/encoding/compute-divisor">computeDivisor</NuxtLink> the
standalone Reed-Solomon core.
</li>
</ul>
</div>
</div>
</template>
<script lang="ts">
const quickStart = `import { encodeText, EccMap } from '@robonen/encoding';
// Smallest version + optimal segment modes are chosen automatically.
const qr = encodeText('https://robonen.dev', EccMap.M);
console.log(qr.size); // module count per side (21..177)
// Render however you like — here, plain text:
let out = '';
for (let y = 0; y < qr.size; y++) {
for (let x = 0; x < qr.size; x++)
out += qr.getModule(x, y) ? '##' : ' ';
out += '\\n';
}
console.log(out);`
const reedSolomon = `import { computeDivisor, computeRemainder } from '@robonen/encoding';
// Build a degree-10 generator polynomial over GF(2^8/0x11D)...
const divisor = computeDivisor(10);
// ...then derive the 10 error-correction codewords for your data block.
const data = [0x40, 0xd2, 0x75, 0x47, 0x76, 0x17, 0x32, 0x06, 0x27, 0x26];
const ecc = computeRemainder(data, divisor); // Uint8Array(10)`
</script>
+153
View File
@@ -0,0 +1,153 @@
<script setup lang="ts">
// Landing hero for @robonen/fetch — static, SSR-safe content.
const quickStart = `import { $fetch } from '@robonen/fetch';
interface Todo {
id: number;
title: string;
done: boolean;
}
// GET + automatic JSON parse — the body is typed for you
const todo = await $fetch<Todo>('https://api.example.com/todos/1');
// POST a plain object — serialized to JSON, content-type set automatically
const created = await $fetch<Todo>('https://api.example.com/todos', {
method: 'POST',
body: { title: 'Ship it', done: false },
});
// Method shortcuts
await $fetch.get<Todo>('https://api.example.com/todos/1');
await $fetch.delete('https://api.example.com/todos/1');`;
const instances = `import { $fetch } from '@robonen/fetch';
// Derive a pre-configured instance — defaults & plugins are inherited
const api = $fetch.create({
baseURL: 'https://api.example.com/v1',
headers: { 'x-app': 'web' },
retry: 2,
});
await api('/users'); // → /v1/users, retry:2, x-app header
await api('/search', { query: { q: 'vue', page: 2 } });
// 'extend' layers on more defaults / plugins; child wins on conflicts
const billing = api.extend({ baseURL: 'https://billing.example.com' });`;
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>@robonen/fetch</h1>
<p>
A lightweight, type-safe <code>fetch</code> wrapper with interceptors, retry,
timeout, and a composable plugin system V8-optimized internals, zero runtime
dependencies beyond the standard library.
</p>
</div>
<div class="prose-docs">
<p>
<code>globalThis.fetch</code> is great primitive plumbing, but every app
re-implements the same layer on top of it: JSON parsing, throwing on
<code>4xx</code>/<code>5xx</code>, base URLs, query strings, retries, timeouts,
auth headers. <strong>@robonen/fetch</strong> is that layer small, fully typed,
and built so attaching features costs nothing on the hot path.
</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">Type-safe end to end</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
Response data, request options, and plugin-contributed fields are all inferred
the parsed body comes back typed, no casting required.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">Smart bodies &amp; parsing</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
Plain objects are JSON-serialized; <code>FormData</code>/<code>Blob</code>/streams
pass through untouched. Responses are decoded from <code>Content-Type</code> or
forced via <code>responseType</code>.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">Retry, timeout &amp; errors</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
Built-in retry and per-attempt timeout with sensible defaults, and non-2xx
responses reject with a rich <code>FetchError</code> carrying status, request,
and parsed body.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">Hooks &amp; plugins</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
Lifecycle hooks plus a typed, composable plugin system with onion-style
<code>execute</code> middleware composed once, with zero per-request overhead
beyond the hooks themselves.
</p>
</div>
</div>
<div class="prose-docs">
<h2>Install</h2>
</div>
<DocsCode :code="`pnpm add @robonen/fetch`" lang="bash" />
<div class="prose-docs">
<h2>Quick start</h2>
<p>
Import the default <code>$fetch</code> instance it is backed by
<code>globalThis.fetch</code> and ready to use. The first type parameter types the
parsed body.
</p>
</div>
<DocsCode :code="quickStart" lang="ts" />
<div class="prose-docs">
<h2>Configured instances</h2>
<p>
Use <code>create</code> (or its alias <code>extend</code>) to derive instances with
a <code>baseURL</code>, default headers, retry policy, and plugins. Configuration is
merged down the chain; the child wins on conflicts.
</p>
</div>
<DocsCode :code="instances" lang="ts" />
<div class="prose-docs">
<h2>Where to next</h2>
<p>
The full API reference is listed below. A few good places to start:
</p>
<ul>
<li>
<NuxtLink to="/fetch/create-fetch"><code>createFetch</code></NuxtLink> build a
fully configured instance with defaults and plugins.
</li>
<li>
<NuxtLink to="/fetch/define-plugin"><code>definePlugin</code></NuxtLink> bundle
defaults, typed options, hooks, and <code>execute</code> middleware into a
reusable plugin.
</li>
<li>
<NuxtLink to="/fetch/fetch-error"><code>FetchError</code></NuxtLink> the rich
error thrown on non-2xx responses.
</li>
<li>
<NuxtLink to="/fetch/build-url"><code>buildURL</code></NuxtLink> and
<NuxtLink to="/fetch/detect-response-type"><code>detectResponseType</code></NuxtLink>
the URL and response-type helpers used internally, exported for reuse.
</li>
</ul>
</div>
</div>
</template>
+171
View File
@@ -0,0 +1,171 @@
<script setup lang="ts">
// Landing hero for @robonen/platform. Static content only — no runtime APIs are
// touched at setup top-level, so this prerenders and hydrates safely.
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>@robonen/platform</h1>
<p>
Platform-dependent utilities for browser and multi-runtime JavaScript focus management,
ARIA isolation, animation lifecycle tracking, and environment-safe globals.
</p>
</div>
<div class="prose-docs">
<p>
Most utility libraries stop at the platform boundary: the moment you need to reach for the
DOM, shadow roots, <code>aria-hidden</code>, or <code>globalThis</code>, you are on your own.
<strong>@robonen/platform</strong> 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
<NuxtLink to="/primitives">@robonen/primitives</NuxtLink> and the editor.
</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">
Focus, done right
</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
Shadow-DOM-aware active-element lookup, scroll-free focusing, and first/last tabbable-edge
detection via a fast <code class="text-(--accent-text)">TreeWalker</code> the bones of any focus trap.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">
Accessible isolation
</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
<code class="text-(--accent-text)">hideOthers</code> marks every sibling
<code class="text-(--accent-text)">aria-hidden</code>, ref-counted across layers, preserving
<code class="text-(--accent-text)">aria-live</code> regions. A dependency-free port of <code>aria-hidden</code>.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">
Animation lifecycle
</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
Detect running animations and transitions, then settle exit animations cleanly with
fill-mode flash prevention so unmounts wait for the CSS to finish.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">
Multi-runtime safe
</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
A resolved <code class="text-(--accent-text)">_global</code> and an
<code class="text-(--accent-text)">isClient</code> flag that work across Node, Bun, Deno, and the
browser guards baked in so SSR never throws.
</p>
</div>
</div>
<div class="prose-docs">
<h2>Install</h2>
</div>
<DocsCode :code="`pnpm add @robonen/platform`" lang="bash" />
<div class="prose-docs">
<h2>Subpath exports</h2>
<p>
The package splits along the platform boundary. Browser-only helpers live under
<code>/browsers</code>; runtime-agnostic helpers live under <code>/multi</code>.
</p>
<table>
<thead>
<tr>
<th>Entry</th>
<th>Scope</th>
<th>What you get</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>@robonen/platform/browsers</code></td>
<td>DOM</td>
<td>Focus, tabbable edges, <code>hideOthers</code>, animation lifecycle</td>
</tr>
<tr>
<td><code>@robonen/platform/multi</code></td>
<td>Any runtime</td>
<td><code>_global</code>, <code>isClient</code></td>
</tr>
</tbody>
</table>
</div>
<div class="prose-docs">
<h2>Usage</h2>
<p>
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.
</p>
</div>
<DocsCode lang="ts" :code="`import {
getActiveElement,
getTabbableEdges,
focus,
hideOthers,
} from '@robonen/platform/browsers';
function openDialog(dialog: HTMLElement) {
// Remember where focus was, so we can restore it on close.
const previouslyFocused = getActiveElement();
// Hide everything outside the dialog from screen readers (ref-counted).
const undoHide = hideOthers(dialog);
// Move focus to the first tabbable element inside the dialog.
const { first } = getTabbableEdges(dialog);
focus(first, { select: true });
return function close() {
undoHide();
focus(previouslyFocused);
};
}`" />
<div class="prose-docs">
<p>
On the cross-runtime side, reach for a safe global and a reliable client check without
sprinkling <code>typeof window</code> guards through your code:
</p>
</div>
<DocsCode lang="ts" :code="`import { _global, isClient } from '@robonen/platform/multi';
// Works in Node, Bun, Deno and the browser — never throws in SSR.
if (isClient) {
_global.addEventListener('resize', onResize);
}`" />
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4 text-sm text-(--fg-muted)">
<strong class="text-(--fg)">SSR note:</strong> browser helpers touch the DOM, so call them
inside event handlers or after mount.
<code class="text-(--accent-text)">hideOthers</code> already no-ops when <code>document</code> is
undefined, and <code>/multi</code> is import-safe everywhere.
</div>
<div class="prose-docs">
<h2>Where to next</h2>
<p>Browse the full API reference below, or jump straight to the building blocks:</p>
<ul>
<li><NuxtLink to="/platform/focus-guard">focusGuard</NuxtLink> add boundary guards for predictable focus wrapping</li>
<li><NuxtLink to="/platform/get-tabbable-edges">getTabbableEdges</NuxtLink> find the first and last focusable elements in a container</li>
<li><NuxtLink to="/platform/hide-others">hideOthers</NuxtLink> isolate a subtree for assistive technology</li>
<li><NuxtLink to="/platform/on-animation-settle">onAnimationSettle</NuxtLink> run a callback once an animation or transition finishes</li>
</ul>
</div>
</div>
</template>
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
const installCode = `pnpm add @robonen/stdlib`;
const usageCode = `import { groupBy, unique, clamp, tryIt } from '@robonen/stdlib';
// Arrays — group records by a derived key
const byType = groupBy(
[{ type: 'a', v: 1 }, { type: 'b', v: 2 }, { type: 'a', v: 3 }],
item => item.type,
);
// => { a: [{ type: 'a', v: 1 }, { type: 'a', v: 3 }], b: [{ type: 'b', v: 2 }] }
// Arrays — dedupe in a single pass
unique([1, 1, 2, 3, 3]); // => [1, 2, 3]
// Math — keep a value inside a range
clamp(value, 0, 100);
// Async — error handling without forking control flow
const { error, data } = await tryIt(fetch)('/api/todos/1');
if (error) {
// handle the failure
} else {
// use data
}`;
</script>
<template>
<div class="docs-section">
<!-- Hero -->
<div class="prose-docs">
<h1>@robonen/stdlib</h1>
<p class="text-lg text-(--fg-muted)">
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.
</p>
</div>
<div class="prose-docs">
<p>
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. <code>@robonen/stdlib</code>
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.
</p>
</div>
<!-- Feature highlights -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Fully typed</h3>
<p class="text-sm text-(--fg-muted) leading-relaxed">
Generic signatures preserve element and key types end to end, so inference keeps
working through <code>groupBy</code>, <code>partition</code>, <code>zip</code>, and friends.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Zero dependencies</h3>
<p class="text-sm text-(--fg-muted) leading-relaxed">
No transitive dependencies and no platform assumptions. The same code runs in Node,
the browser, Deno, Bun, and edge runtimes.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Tree-shakeable</h3>
<p class="text-sm text-(--fg-muted) leading-relaxed">
Each utility is a standalone export with no shared side effects. Import a single
function and ship only that function.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Batteries included</h3>
<p class="text-sm text-(--fg-muted) leading-relaxed">
Beyond array and math helpers you get data structures
(<code>Deque</code>, <code>BinaryHeap</code>, <code>LinkedList</code>) and patterns
(<code>StateMachine</code>, <code>PubSub</code>, <code>Command</code>).
</p>
</div>
</div>
<!-- Install -->
<div class="prose-docs">
<h2>Install</h2>
</div>
<DocsCode :code="installCode" lang="bash" />
<!-- Usage -->
<div class="prose-docs">
<h2>Quick start</h2>
<p>
Import named utilities directly from the package root. Each one is small, predictable,
and focused on a single job.
</p>
</div>
<DocsCode :code="usageCode" lang="ts" />
<!-- Modules overview -->
<div class="prose-docs">
<h2>What's inside</h2>
<p>
The library is organized into focused modules. A few of the most-used building blocks:
</p>
<ul>
<li>
<strong>Arrays</strong> — <code>groupBy</code>, <code>partition</code>,
<code>unique</code>, <code>zip</code>, <code>cluster</code>, <code>range</code>.
</li>
<li>
<strong>Async</strong> — <code>tryIt</code>, <code>retry</code>, <code>pool</code>,
<code>sleep</code> for control flow that doesn't fight you.
</li>
<li>
<strong>Math</strong> <code>clamp</code>, <code>lerp</code>, <code>remap</code>,
each with a BigInt variant.
</li>
<li>
<strong>Functions</strong> <code>debounce</code>, <code>throttle</code>,
<code>memoize</code>, <code>once</code>, <code>compose</code>, <code>pipe</code>.
</li>
<li>
<strong>Structs &amp; patterns</strong> <code>Deque</code>,
<code>PriorityQueue</code>, <code>StateMachine</code>, <code>PubSub</code> and more.
</li>
</ul>
</div>
<!-- Where to next -->
<div class="rounded-lg border border-(--border) bg-(--bg-elevated) p-5">
<h2 class="text-base font-semibold text-(--fg) mb-2">Where to next</h2>
<p class="text-sm text-(--fg-muted) mb-3">
Browse the full API reference below, or jump straight to a popular utility:
</p>
<ul class="flex flex-wrap gap-2 m-0 p-0 list-none">
<li>
<NuxtLink
to="/stdlib/group-by"
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-subtle) px-3 py-1.5 text-sm text-(--accent-text) hover:bg-(--bg-inset) focus:ring-(--ring)"
>
groupBy
</NuxtLink>
</li>
<li>
<NuxtLink
to="/stdlib/try-it"
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-subtle) px-3 py-1.5 text-sm text-(--accent-text) hover:bg-(--bg-inset) focus:ring-(--ring)"
>
tryIt
</NuxtLink>
</li>
<li>
<NuxtLink
to="/stdlib/retry"
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-subtle) px-3 py-1.5 text-sm text-(--accent-text) hover:bg-(--bg-inset) focus:ring-(--ring)"
>
retry
</NuxtLink>
</li>
<li>
<NuxtLink
to="/stdlib/clamp"
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-subtle) px-3 py-1.5 text-sm text-(--accent-text) hover:bg-(--bg-inset) focus:ring-(--ring)"
>
clamp
</NuxtLink>
</li>
<li>
<NuxtLink
to="/stdlib/debounce"
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-subtle) px-3 py-1.5 text-sm text-(--accent-text) hover:bg-(--bg-inset) focus:ring-(--ring)"
>
debounce
</NuxtLink>
</li>
</ul>
</div>
</div>
</template>
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
// Landing hero for @robonen/renovate. Static content only — no runtime APIs are
// touched at setup top-level, so this prerenders and hydrates safely.
const renovateJson = `{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>robonen/tools//infra/renovate/default.json"]
}`;
const overrideExample = `{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>robonen/tools//infra/renovate/default.json"],
"packageRules": [
{
"matchUpdateTypes": ["major"],
"automerge": false
}
]
}`;
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>@robonen/renovate</h1>
<p class="text-lg text-(--fg-muted)">
A shared Renovate configuration preset one line in your
<code>renovate.json</code> and dependency updates run on autopilot.
</p>
</div>
<div class="prose-docs">
<p>
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. <code>@robonen/renovate</code> captures
those decisions once as a single <code>default.json</code> preset that any
repo can extend, so the policy lives in one place instead of being copied
and slowly drifting across projects.
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">One-line adoption</h3>
<p class="text-sm text-(--fg-muted) m-0">
Extend it via
<code>github&gt;robonen/tools//infra/renovate/default.json</code>
no copy-pasted config, and updates to the policy roll out to every
consumer.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Quiet by default</h3>
<p class="text-sm text-(--fg-muted) m-0">
Non-major updates are grouped via <code>group:allNonMajor</code>, so you
review one consolidated PR instead of a wall of individual bumps.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Hands-off minor &amp; patch</h3>
<p class="text-sm text-(--fg-muted) m-0">
Minor, patch, pin, and digest updates auto-approve and auto-merge on a
13 AM schedule safe upgrades land overnight, majors still wait for you.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="font-medium text-(--fg) mb-1.5">Conventional commits</h3>
<p class="text-sm text-(--fg-muted) m-0">
Every update commits as <code>chore</code> with a
<code>bump</code> range strategy, and reviews are assigned straight from
<code>CODEOWNERS</code>.
</p>
</div>
</div>
<div class="prose-docs">
<h2>Install</h2>
<p>
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
<code>renovate-config-validator</code> available locally:
</p>
</div>
<DocsCode :code="`pnpm add @robonen/renovate`" lang="bash" />
<div class="prose-docs">
<h2>Usage</h2>
<p>
Create (or edit) <code>renovate.json</code> in your repository root and
extend the preset:
</p>
</div>
<DocsCode :code="renovateJson" lang="json" />
<div class="prose-docs">
<p>
That's the whole setup. To diverge from the shared policy, append your own
<code>packageRules</code> after the extend for example, opt out of
auto-merging major upgrades:
</p>
</div>
<DocsCode :code="overrideExample" lang="json" />
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4 text-sm text-(--fg-muted)">
<strong class="text-(--fg)">Tip:</strong> validate any config that extends
this preset with
<code class="text-(--accent-text)">renovate-config-validator</code> before
committing the package ships it as a <code>test</code> script against
<code>default.json</code>.
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="font-medium text-(--fg) mb-2">Where to next</h3>
<ul class="text-sm text-(--fg-muted) space-y-1.5 list-disc pl-5 m-0">
<li>
Read the guide sections below for the full list of what the preset
enables extended configs, schedules, and package rules.
</li>
<li>
See the upstream
<a href="https://docs.renovatebot.com/" class="text-(--accent-text) hover:underline" target="_blank" rel="noreferrer">Renovate docs</a>
for the complete configuration schema you can layer on top.
</li>
</ul>
</div>
</div>
</template>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
const quickStart = `<script setup lang="ts">
import { createDefaultRegistry, createEditor, createEditorState, EditorRoot } from '@robonen/editor';
const registry = createDefaultRegistry();
const editor = createEditor({ state: createEditorState({ registry }) });
<\/script>
<template>
<EditorRoot :editor="editor" autofocus class="editor" />
<\/template>`;
const composeSlots = `<EditorRoot :editor="editor" autofocus>
<EditorContent />
<EditorBubbleMenu /> <!-- formatting toolbar on selection -->
<EditorSlashMenu /> <!-- type \`/\` to insert blocks -->
</EditorRoot>`;
const commands = `import { setBlockType, toggleMark } from '@robonen/editor';
editor.command(toggleMark('bold'));
editor.command(setBlockType('heading', { level: 2 }));
// Called without a dispatch they run dry — perfect for
// computing disabled / active toolbar state.
const canBold = editor.command(toggleMark('bold'));`;
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>@robonen/editor</h1>
<p>
A <strong>headless, block-based rich-text editor for Vue 3</strong> in the spirit of
Tiptap / ProseMirror / Editor.js, but with a registry-driven schema and a
<strong>hand-built CRDT</strong> for collaboration (no Yjs / Loro / Automerge).
</p>
</div>
<div class="prose-docs">
<p>
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. <code>@robonen/editor</code> takes the ProseMirror route a single
<code>contenteditable</code> 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.
</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Headless by design</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Ships behavior and DOM structure (<code class="text-(--accent-text)">data-block-*</code>
hooks), never styling. Bring your own CSS and own the look completely.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Registry-driven schema</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">defineBlock</code> /
<code class="text-(--accent-text)">defineMark</code> register into an immutable schema
add a custom block or mark with no core changes.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Step-based transactions</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
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.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Own CRDT, pluggable</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
RGA text, fractional-indexed blocks, Peritext-style marks and presence behind a
<code class="text-(--accent-text)">CrdtProvider</code> over any transport.
</p>
</div>
</div>
<div class="prose-docs">
<h2>Install</h2>
<p>
The editor depends on <code>@robonen/crdt</code> for the built-in collaboration provider, and
on <code>vue</code> as a peer.
</p>
</div>
<DocsCode :code="`pnpm add @robonen/editor @robonen/crdt vue`" lang="bash" />
<div class="prose-docs">
<h2>Quick start</h2>
<p>
Create a registry, build an editor around its state, and mount <code>EditorRoot</code>. Its
default slot renders <code>EditorContent</code> (the single <code>contenteditable</code>), so
this is a fully working editor with all built-in blocks and marks.
</p>
</div>
<DocsCode :code="quickStart" lang="vue" />
<div class="prose-docs">
<p>
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 <code>/</code> at the start of a line.
</p>
</div>
<DocsCode :code="composeSlots" lang="vue" />
<div class="prose-docs">
<h2>Commands</h2>
<p>
Commands are <code>(state, dispatch?, view?) =&gt; boolean</code> functions that power the
keymap, the UI, and programmatic edits. Run one with <code>editor.command(...)</code>; omit
the dispatch to dry-run it for active/disabled state.
</p>
</div>
<DocsCode :code="commands" lang="ts" />
<div class="prose-docs">
<h2>Built-in blocks &amp; marks</h2>
<p>
<code>createDefaultRegistry()</code> wires up a full set out of the box
<strong>blocks:</strong> <code>paragraph</code>, <code>heading</code> (16),
<code>bulleted-list</code> / <code>numbered-list</code> / <code>todo-list</code>,
<code>blockquote</code>, <code>code-block</code>, <code>callout</code>, <code>divider</code>,
<code>image</code>; <strong>marks:</strong> <code>bold</code>, <code>italic</code>,
<code>underline</code>, <code>strike</code>, <code>highlight</code>, <code>code</code>,
<code>link</code>. Markdown input rules (<code># </code>, <code>- </code>, <code>1. </code>,
<code>&gt; </code>, <code>[] </code>) and hotkeys (<code>Mod-b/i/u</code>,
<code>Mod-z</code>, ) are included.
</p>
</div>
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-amber-700 dark:text-amber-400">Status: v0, work in progress.</strong>
Core logic is covered by unit + convergence tests; the contenteditable / Playwright suite
runs locally. The collaboration layer has a few documented, deferred limitations.
</p>
</div>
<div class="prose-docs">
<h2>Where to next</h2>
<p>Jump into the pieces you'll reach for first:</p>
<ul>
<li>
<code>EditorRoot</code> and <code>EditorContent</code> the mount
surface and the single contenteditable.
</li>
<li>
<NuxtLink to="/editor/create-default-registry"><code>createDefaultRegistry</code></NuxtLink>,
<NuxtLink to="/editor/define-block"><code>defineBlock</code></NuxtLink> and
<NuxtLink to="/editor/define-mark"><code>defineMark</code></NuxtLink> extend the schema.
</li>
<li>
<NuxtLink to="/editor/toggle-mark"><code>toggleMark</code></NuxtLink> /
<NuxtLink to="/editor/set-block-type"><code>setBlockType</code></NuxtLink> the commands
API for programmatic and toolbar edits.
</li>
<li>
<NuxtLink to="/editor/bind-crdt"><code>bindCrdt</code></NuxtLink> and
<NuxtLink to="/editor/create-native-provider"><code>createNativeProvider</code></NuxtLink>
wire up real-time collaboration with the built-in CRDT.
</li>
</ul>
<p>
The full API reference for every export is listed right below.
</p>
</div>
</div>
</template>
+145
View File
@@ -0,0 +1,145 @@
<script setup lang="ts">
// Landing hero for @robonen/primitives. Static content only — no runtime
// logic at setup top-level, so it prerenders and hydrates cleanly.
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>@robonen/primitives</h1>
<p>
A collection of unstyled, accessible UI primitives for Vue 3 the headless
building blocks for design systems and component libraries.
</p>
</div>
<p class="prose-docs">
Most component libraries bundle behavior and styling together, so the moment
your design diverges you end up fighting the framework. <code>@robonen/primitives</code>
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 <code>Root</code>,
a <code>Trigger</code>, a <code>Content</code>, and so on) following the same
conventions, so once you learn one you know them all.
</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="m-0 text-sm font-semibold text-(--fg)">Unstyled by design</h3>
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
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.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="m-0 text-sm font-semibold text-(--fg)">Accessible out of the box</h3>
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
Focus scopes, roving tabindex, visually-hidden labels and correct ARIA roles
are handled for you. The suite is tested against
<code>axe-core</code> in a real browser.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="m-0 text-sm font-semibold text-(--fg)">Controlled or uncontrolled</h3>
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
Bind state with <code>v-model</code> when you need control, or set a
<code>defaultValue</code> / <code>defaultOpen</code> and let the primitive
manage itself.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="m-0 text-sm font-semibold text-(--fg)">Composable & polymorphic</h3>
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
Every part takes an <code>as</code> prop, or use <code>as="template"</code>
to merge behavior onto your own element. Floating UI powers positioning for
popovers, tooltips and menus.
</p>
</div>
</div>
<div class="prose-docs">
<h2>Install</h2>
</div>
<DocsCode :code="`pnpm add @robonen/primitives`" lang="bash" />
<div class="prose-docs">
<h2>Usage</h2>
<p>
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:
</p>
</div>
<DocsCode lang="vue" :code="`<script setup lang=&quot;ts&quot;>
import {
DialogRoot,
DialogTrigger,
DialogPortal,
DialogOverlay,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
} from '@robonen/primitives';
</scr&shy;ipt>
<template>
<DialogRoot>
<DialogTrigger class=&quot;btn&quot;>Open</DialogTrigger>
<DialogPortal>
<DialogOverlay class=&quot;overlay&quot; />
<DialogContent class=&quot;dialog&quot;>
<DialogTitle>Delete project</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
<DialogClose class=&quot;btn&quot;>Cancel</DialogClose>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>`" />
<div class="prose-docs">
<p>
Need full control over open state? Bind it directly the same primitive works
either way:
</p>
</div>
<DocsCode lang="vue" :code="`<DialogRoot v-model:open=&quot;isOpen&quot;>
<!-- ... -->
</DialogRoot>`" />
<div class="prose-docs">
<h2>The Primitive component</h2>
<p>
At the core of every part is <code>Primitive</code>, a polymorphic functional
component. Pass <code>as</code> to choose the element, or <code>as="template"</code>
to forward behavior onto a child of your own.
</p>
</div>
<DocsCode lang="ts" :code="`import { Primitive, Slot } from '@robonen/primitives';
// <Primitive as=&quot;button&quot; /> renders a <button>
// <Primitive as=&quot;template&quot;> merges props onto the slotted child`" />
<div class="prose-docs">
<h2>Where to next</h2>
<p>
The full primitive index is listed below. A few good starting points:
</p>
<ul>
<li><NuxtLink to="/primitives/dialog">Dialog</NuxtLink> and <NuxtLink to="/primitives/alert-dialog">Alert Dialog</NuxtLink> modal layers with focus trapping.</li>
<li><NuxtLink to="/primitives/popover">Popover</NuxtLink>, <NuxtLink to="/primitives/tooltip">Tooltip</NuxtLink> and <NuxtLink to="/primitives/hover-card">Hover Card</NuxtLink> Floating UI positioned surfaces.</li>
<li><NuxtLink to="/primitives/select">Select</NuxtLink>, <NuxtLink to="/primitives/combobox">Combobox</NuxtLink> and <NuxtLink to="/primitives/listbox">Listbox</NuxtLink> keyboard-driven option pickers.</li>
<li><NuxtLink to="/primitives/switch">Switch</NuxtLink>, <NuxtLink to="/primitives/checkbox">Checkbox</NuxtLink> and <NuxtLink to="/primitives/slider">Slider</NuxtLink> form controls that integrate with native inputs.</li>
<li><NuxtLink to="/primitives/focus-scope">Focus Scope</NuxtLink> and <NuxtLink to="/primitives/presence">Presence</NuxtLink> the shared foundations every part builds on.</li>
</ul>
</div>
</div>
</template>
+162
View File
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { useCounter } from '../src';
const { count, increment, decrement, reset } = useCounter(0, { min: 0, max: 10 });
</script>
<template>
<div class="docs-section">
<!-- Hero -->
<div class="prose-docs">
<h1>@robonen/vue</h1>
<p>
A collection of <strong>213+ tree-shakeable, SSR-safe composables</strong> for Vue 3
reactive primitives for state, sensors, the DOM, browser APIs, animation, forms and more.
</p>
</div>
<div class="prose-docs">
<p>
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 <code>window</code>, <code>document</code> and
<code>navigator</code> are built in, so the same code runs on the server and hydrates cleanly
on the client.
</p>
</div>
<!-- Feature highlights -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Tree-shakeable by design</h3>
<p class="text-sm text-(--fg-muted) leading-relaxed">
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.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">SSR-safe out of the box</h3>
<p class="text-sm text-(--fg-muted) leading-relaxed">
Browser-only access is guarded behind lifecycle hooks and configurable
<code>window</code>/<code>document</code> targets, so Nuxt and SSR setups just work.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Fully typed</h3>
<p class="text-sm text-(--fg-muted) leading-relaxed">
Written in TypeScript with precise return types and generics. <code>MaybeRefOrGetter</code>
arguments mean you can pass plain values, refs or getters interchangeably.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Broad coverage</h3>
<p class="text-sm text-(--fg-muted) leading-relaxed">
From state and reactivity to sensors, elements, storage, math and form handling —
one cohesive toolkit spanning the whole surface of a Vue app.
</p>
</div>
</div>
<!-- Install -->
<div class="prose-docs">
<h2>Install</h2>
</div>
<DocsCode :code="`pnpm add @robonen/vue`" lang="bash" />
<!-- Usage -->
<div class="prose-docs">
<h2>Quick start</h2>
<p>
Import the composables you need and use them inside <code>&lt;script setup&gt;</code>.
Here's a counter clamped to a range, with auto-cleaning keyboard shortcuts:
</p>
</div>
<DocsCode
:code="`import { useCounter, useEventListener, useToggle } from '@robonen/vue';
// Clamped, reactive counter
const { count, increment, decrement, reset } = useCounter(0, { min: 0, max: 10 });
// A boolean toggle with custom truthy/falsy values
const { value: theme, toggle } = useToggle('light', {
truthyValue: 'dark',
falsyValue: 'light',
});
// Listener is removed automatically on unmount
useEventListener('keydown', (e) => {
if (e.key === 'ArrowUp') increment();
if (e.key === 'ArrowDown') decrement();
});`"
lang="ts"
/>
<!-- Live demo -->
<div class="prose-docs">
<p>The same <code>useCounter</code> running live:</p>
</div>
<ClientOnly>
<div class="flex items-center gap-3 rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
<button
type="button"
class="size-9 rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring) disabled:opacity-40"
:disabled="count <= 0"
@click="decrement()"
>
</button>
<span class="min-w-12 text-center text-lg font-medium tabular-nums text-(--fg)">{{ count }}</span>
<button
type="button"
class="size-9 rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring) disabled:opacity-40"
:disabled="count >= 10"
@click="increment()"
>
+
</button>
<button
type="button"
class="ml-auto rounded-md px-3 py-1.5 text-sm text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="reset()"
>
Reset
</button>
</div>
</ClientOnly>
<!-- Where to next -->
<div class="prose-docs">
<h2>Where to next</h2>
<p>
The full API reference is listed right below. A few good starting points:
</p>
<ul>
<li>
<NuxtLink to="/vue/use-counter">useCounter</NuxtLink> a clamped, reactive counter
with increment / decrement / set / reset.
</li>
<li>
<NuxtLink to="/vue/use-toggle">useToggle</NuxtLink> a boolean toggle with
customizable truthy / falsy values.
</li>
<li>
<NuxtLink to="/vue/use-event-listener">useEventListener</NuxtLink> declarative
event listeners that clean up on unmount.
</li>
<li>
<NuxtLink to="/vue/use-storage">useStorage</NuxtLink> reactive state synced to
<code>localStorage</code> / <code>sessionStorage</code>.
</li>
<li>
<NuxtLink to="/vue/use-magic-keys">useMagicKeys</NuxtLink> reactive keyboard
state for building shortcuts.
</li>
</ul>
</div>
</div>
</template>