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
+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>