Merge pull request #146 from robonen/docs

Docs
This commit is contained in:
2026-06-15 17:16:22 +07:00
committed by GitHub
1561 changed files with 79386 additions and 15061 deletions
@@ -77,7 +77,7 @@ jobs:
# browser. playwright is a direct devDep of these packages, so run its CLI
# in the package context (--filter) — it isn't resolvable from the root.
- name: Install Playwright Chromium
if: matrix.package == '@robonen/primitives' || matrix.package == '@robonen/editor'
if: matrix.package == '@robonen/primitives' || matrix.package == '@robonen/writekit'
run: pnpm --filter "${{ matrix.package }}" exec playwright install --with-deps chromium
- name: Lint
@@ -87,7 +87,7 @@ jobs:
run: pnpm --filter "${{ matrix.package }}" --if-present run test
# Sentinel job — aggregates all matrix results into a single status check.
# Add "CI" as the required check in branch protection rules.
# Add "CI" as the required status check in the branch protection rules.
ci:
name: CI
needs: check
@@ -43,31 +43,31 @@ jobs:
run: |
# Find all package.json files (excluding node_modules)
PACKAGE_FILES=$(find . -path "*/package.json" -not -path "*/node_modules/*")
for file in $PACKAGE_FILES; do
PACKAGE_DIR=$(dirname $file)
echo "Checking $PACKAGE_DIR for version changes..."
# Get package details
PACKAGE_NAME=$(node -p "require('$file').name")
CURRENT_VERSION=$(node -p "require('$file').version")
IS_PRIVATE=$(node -p "require('$file').private || false")
# Skip private packages
if [ "$IS_PRIVATE" == "true" ]; then
echo "Skipping private package $PACKAGE_NAME"
continue
fi
# Skip root package
if [ "$PACKAGE_DIR" == "." ]; then
echo "Skipping root package"
continue
fi
# Check if package exists on npm
NPM_VERSION=$(npm view $PACKAGE_NAME version 2>/dev/null || echo "0.0.0")
# Compare versions
if [ "$CURRENT_VERSION" != "$NPM_VERSION" ]; then
echo "Version changed for $PACKAGE_NAME: $NPM_VERSION → $CURRENT_VERSION"
+1 -1
View File
@@ -2,7 +2,7 @@
export { compose } from './compose';
/* Presets */
export { base, ignores, typescript, vue, vitest, imports, node, stylistic, regexp } from './presets';
export { base, ignores, typescript, vue, vitest, tests, imports, node, stylistic, regexp } from './presets';
/* Types */
export type {
+4 -1
View File
@@ -62,7 +62,10 @@ export const base: FlatConfigArray = [
rules: {
/* ── eslint core ──────────────────────────────────────── */
eqeqeq: 'error',
'no-console': 'warn',
/* Allow intentional `console.warn`/`console.error` — used for library dev
diagnostics (a11y/validation warnings, often `__DEV__`-guarded). Stray
`console.log`/`debug`/`info` are still flagged. */
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-debugger': 'error',
'no-eval': 'error',
'no-var': 'error',
+1
View File
@@ -2,6 +2,7 @@ export { base, ignores } from './base';
export { typescript } from './typescript';
export { vue } from './vue';
export { vitest } from './vitest';
export { tests } from './tests';
export { imports } from './imports';
export { node } from './node';
export { regexp } from './regexp';
+39
View File
@@ -0,0 +1,39 @@
import type { FlatConfigArray } from '../types';
/**
* Relaxations for test, spec and benchmark files — the type-boundary carve-outs
* that test scaffolding legitimately needs, applied uniformly across every
* package.
*
* Tests stub globals (`(globalThis as any).x`), cast `vi.fn()` mocks, build
* throwaway fixtures and keep deliberate sink variables; benchmarks pre-size
* arrays with `new Array(n)`. The `vitest` preset already grants these for its
* own (`it`/`expect`) ruleset, but most packages don't adopt that preset (their
* tests use string `describe` titles), so this small overlay carries just the
* relaxations — no vitest-specific style rules — and is meant to be composed
* LAST so it wins over the `typescript`/`stylistic` presets for these files.
*
* Source `any` is unaffected: it stays at `warn` everywhere else.
*/
export const tests: FlatConfigArray = [
{
name: 'robonen/tests',
files: [
'**/*.{test,spec,bench}.{ts,tsx,cts,mts,js,jsx,cjs,mjs}',
'**/test/**/*.{ts,tsx,js,jsx}',
'**/__test__/**/*.{ts,tsx,js,jsx}',
'**/__tests__/**/*.{ts,tsx,js,jsx}',
],
rules: {
/* Test scaffolding inspects/stubs untyped boundaries; `any` is idiomatic here. */
'@typescript-eslint/no-explicit-any': 'off',
/* Sink variables, partially-used fixtures and `_`-less throwaways are fine in tests. */
'@typescript-eslint/no-unused-vars': 'off',
'no-unused-vars': 'off',
/* Benchmarks legitimately pre-size arrays (`new Array(n)`) for fixtures. */
'unicorn/no-new-array': 'off',
/* Empty mock/fixture classes (e.g. stubbing `class DeviceOrientationEvent {}`). */
'@typescript-eslint/no-extraneous-class': 'off',
},
},
];
+2 -2
View File
@@ -1,6 +1,6 @@
# @robonen/crdt
Framework-agnostic CRDT primitives — the convergence engine behind `@robonen/editor`, usable on their own. Zero runtime dependencies; pure TypeScript; runs in Node and the browser.
Framework-agnostic CRDT primitives — the convergence engine behind `@robonen/writekit`, usable on their own. Zero runtime dependencies; pure TypeScript; runs in Node and the browser.
Every primitive is built so that **applying the same set of operations in any order, with duplicates, yields the same state** (commutative, idempotent, convergent), verified by property tests.
@@ -50,7 +50,7 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged
- `compareOpId` is the single deterministic tie-break (higher clock wins; site id breaks ties) every primitive agrees on — that's what makes LWW and RGA converge.
- `VersionVector` assumes **dense** per-site clocks (1, 2, 3, …).
- The v1 wire format is JSON encoded to bytes — simple and debuggable; a compact varint format is a later optimization with no API change.
- An editor-specific composition of these primitives (blocks + text + marks ↔ editor steps) lives in `@robonen/editor` under `crdt/native/`, not here — this package stays domain-agnostic.
- A writekit-specific composition of these primitives (blocks + text + marks ↔ writekit steps) lives in `@robonen/writekit` under `crdt/native/`, not here — this package stays domain-agnostic.
## Development
+20 -20
View File
@@ -179,13 +179,13 @@ const propsSrc = `// Commutative — order of application doesn't matter:
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
<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>
@@ -223,11 +223,11 @@ const propsSrc = `// Commutative — order of application doesn't matter:
<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)">
<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
<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>
@@ -242,23 +242,23 @@ const propsSrc = `// Commutative — order of application doesn't matter:
</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)">
<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)">
<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)">
<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>
+30 -30
View File
@@ -198,33 +198,33 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
<!-- 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
<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
<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
<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
<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>
@@ -262,12 +262,12 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
</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
<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
timestamped tombstone lets <code class="text-accent-text">compareOpId</code> decide the
winner deterministically, the same way it does for live values.
</p>
</div>
@@ -308,9 +308,9 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
<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)">
<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>
<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
@@ -366,14 +366,14 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
</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>
<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
integrate and pass <code class="text-accent-text">keep</code> to protect ids still
referenced elsewhere, such as mark span endpoints.
</p>
</div>
+8 -8
View File
@@ -249,12 +249,12 @@ a.replica.receive(ops);`;
</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
<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
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>
@@ -346,11 +346,11 @@ a.replica.receive(ops);`;
<!-- 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)">
<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
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.
<code class="text-accent-text">delta</code> believe a missing op was already delivered.
</p>
</div>
+36 -36
View File
@@ -260,17 +260,17 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
<ClientOnly>
<template #fallback>
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-8 text-center text-sm text-(--fg-subtle)">
<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 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>
<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)"
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
@@ -281,82 +281,82 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
<!-- 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 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>
<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)"
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)"
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)">
<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">
<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>
<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 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>
<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)"
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)"
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)">
<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">
<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>
<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">
<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)"
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)"
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
@@ -436,27 +436,27 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
</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)">
<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
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)">
<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>
<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
<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>
+17 -17
View File
@@ -55,40 +55,40 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged`;
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
property tests. It's the convergence engine behind <code>@robonen/writekit</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
<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
<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)">
<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.
<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)">
<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.
<code class="text-accent-text">Replica</code> to tie a clock, op log, and buffer together.
</p>
</div>
</div>
+2 -2
View File
@@ -1,3 +1,3 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
import { base, compose, imports, stylistic, tests, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic);
export default compose(base, typescript, imports, stylistic, tests);
+12 -12
View File
@@ -27,36 +27,36 @@
<!-- 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)">
<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)">
<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)">
<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)">
<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>
+2 -2
View File
@@ -1,4 +1,4 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
import { base, compose, imports, stylistic, tests, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic, {
name: 'encoding/overrides',
@@ -10,4 +10,4 @@ export default compose(base, typescript, imports, stylistic, {
oldest register's seed/last write is intentionally dead — keep symmetry. */
'no-useless-assignment': 'off',
},
});
}, tests);
+12 -12
View File
@@ -59,35 +59,35 @@ const billing = api.extend({ baseURL: 'https://billing.example.com' });`;
</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)">
<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)">
<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)">
<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)">
<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.
+2 -2
View File
@@ -1,3 +1,3 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
import { base, compose, imports, stylistic, tests, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic);
export default compose(base, typescript, imports, stylistic, tests);
+22 -22
View File
@@ -21,49 +21,49 @@
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.
<NuxtLink to="/primitives">@robonen/primitives</NuxtLink> and Writekit.
</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)">
<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)">
<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.
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)">
<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 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)">
<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)">
<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)">
<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
<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>
@@ -150,10 +150,10 @@ 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
<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
<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>
+2 -2
View File
@@ -1,4 +1,4 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
import { base, compose, imports, stylistic, tests, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic, {
name: 'platform/overrides',
@@ -6,4 +6,4 @@ export default compose(base, typescript, imports, stylistic, {
rules: {
'unicorn/prefer-global-this': 'off',
},
});
}, tests);
+7 -2
View File
@@ -61,6 +61,11 @@ const ALLOWED_VALUE_ESCAPES = /%(?:2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g;
// encodeURIComponent escapes: %23 # | %24 $ | %26 & | %2B + | %5E ^ | %60 ` | %7C |
const ALLOWED_NAME_ESCAPES = /%(?:2[346B]|5E|60|7C)/g;
// Runs of percent-escapes to decode in a cookie value, and the `()` pair a
// cookie name must escape. Global, but `replaceAll` resets lastIndex per call.
const PERCENT_ESCAPE_RE = /(?:%[\dA-F]{2})+/gi;
const PAREN_RE = /[()]/g;
/**
* @name encodeCookieValue
* @category Browsers
@@ -104,7 +109,7 @@ export function decodeCookieValue(value: string): string {
value = value.slice(1, -1);
try {
return value.replaceAll(/(?:%[\dA-F]{2})+/gi, decodeURIComponent);
return value.replaceAll(PERCENT_ESCAPE_RE, decodeURIComponent);
}
catch {
return value;
@@ -129,7 +134,7 @@ export function decodeCookieValue(value: string): string {
export function encodeCookieName(name: string): string {
return encodeURIComponent(name)
.replaceAll(ALLOWED_NAME_ESCAPES, decodeURIComponent)
.replaceAll(/[()]/g, c => c === '(' ? '%28' : '%29');
.replaceAll(PAREN_RE, c => c === '(' ? '%28' : '%29');
}
/**
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { isEventTarget } from './index';
describe('isEventTarget', () => {
it('is true for objects exposing addEventListener', () => {
expect(isEventTarget(globalThis)).toBe(true);
expect(isEventTarget(document)).toBe(true);
expect(isEventTarget(document.createElement('div'))).toBe(true);
});
it('is false for non-targets', () => {
expect(isEventTarget(null)).toBe(false);
expect(isEventTarget(undefined)).toBe(false);
expect(isEventTarget({})).toBe(false);
expect(isEventTarget('window')).toBe(false);
expect(isEventTarget(42)).toBe(false);
expect(isEventTarget(() => {})).toBe(false);
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* @name isEventTarget
* @category Browsers
* @description Type guard for a value that is itself an {@link EventTarget}
* (e.g. `window`, `document`, or an element) — i.e. it can be attached to
* directly rather than unwrapped from a ref/getter first.
*
* @param {unknown} value The value to test
* @returns {boolean} `true` when `value` is a non-null object exposing `addEventListener`
*
* @example
* if (isEventTarget(target))
* target.addEventListener('click', onClick);
*
* @since 0.0.5
*/
export function isEventTarget(value: unknown): value is EventTarget {
return typeof value === 'object' && value !== null && 'addEventListener' in value;
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { assignStyle, getTranslate, isInView, resetStyle, setStyle } from './index';
import { assignStyle, getTranslate, isInView, pxValue, resetStyle, setStyle } from './index';
function makeEl(): HTMLElement {
const el = document.createElement('div');
@@ -103,3 +103,19 @@ describe('isInView', () => {
Object.defineProperty(globalThis, 'visualViewport', { value: original, configurable: true });
});
});
describe('pxValue', () => {
it('parses raw px / unitless numbers', () => {
expect(pxValue('1024px')).toBe(1024);
expect(pxValue('768')).toBe(768);
});
it('treats em/rem as 16px', () => {
expect(pxValue('30rem')).toBe(480);
expect(pxValue('1.5em')).toBe(24);
});
it('returns NaN for non-numeric input', () => {
expect(pxValue('auto')).toBeNaN();
});
});
+36 -2
View File
@@ -10,6 +10,12 @@ export type StylePatch = Record<string, string>;
*/
export type TranslateAxis = 'x' | 'y';
// Parsing patterns hoisted to module scope (compiled once, reused). All are
// stateless (no `g`/`y` flag), so sharing across calls is behavior-safe.
const MATRIX3D_RE = /^matrix3d\((.+)\)$/;
const MATRIX_RE = /^matrix\((.+)\)$/;
const EM_REM_RE = /(?:em|rem)\s*$/i;
// Remembers the styles that {@link setStyle} overwrote, keyed by element, so
// {@link resetStyle} can put them back. A WeakMap lets the entry be collected
// once the element is gone.
@@ -114,7 +120,7 @@ export function getTranslate(element: HTMLElement, axis: TranslateAxis): number
// @ts-expect-error — vendor-prefixed transforms only exist in some browsers
= style.transform || style.webkitTransform || style.mozTransform;
let match = transform.match(/^matrix3d\((.+)\)$/);
let match = transform.match(MATRIX3D_RE);
if (match) {
// matrix3d: the translate components live at indices 12 (x) and 13 (y).
// https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix3d
@@ -123,7 +129,7 @@ export function getTranslate(element: HTMLElement, axis: TranslateAxis): number
// matrix: the translate components live at indices 4 (x) and 5 (y).
// https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix
match = transform.match(/^matrix\((.+)\)$/);
match = transform.match(MATRIX_RE);
return match ? Number.parseFloat(match[1].split(', ')[axis === 'y' ? 5 : 4]) : null;
}
@@ -188,3 +194,31 @@ export function isInView(element: HTMLElement): boolean {
&& rect.right <= window.visualViewport.width
);
}
/**
* @name pxValue
* @category Browsers
* @description Parse a CSS length token (`"1024px"`, `"48em"`, `"30rem"`,
* `"50%"`) into a pixel number. `em`/`rem` use the conventional 16px root size.
* Returns `NaN` for non-numeric input.
*
* @param {string} value The CSS length token to parse
* @returns {number} The value in pixels, or `NaN` when not parseable
*
* @example
* pxValue('30rem'); // 480
* pxValue('1024px'); // 1024
*
* @since 0.0.5
*/
export function pxValue(value: string): number {
const number = Number.parseFloat(value);
if (Number.isNaN(number))
return Number.NaN;
if (EM_REM_RE.test(value))
return number * 16;
return number;
}
@@ -20,16 +20,18 @@
*/
export function focusGuard(namespace = 'focus-guard') {
const guardAttr = `data-${namespace}`;
// Build the attribute selector once per guard, not on every create/remove call.
const guardSelector = `[${guardAttr}]`;
const createGuard = () => {
const edges = document.querySelectorAll(`[${guardAttr}]`);
const edges = document.querySelectorAll(guardSelector);
document.body.insertAdjacentElement('afterbegin', edges[0] ?? createGuardAttrs(guardAttr));
document.body.insertAdjacentElement('beforeend', edges[1] ?? createGuardAttrs(guardAttr));
};
const removeGuard = () => {
document.querySelectorAll(`[${guardAttr}]`).forEach(element => element.remove());
document.querySelectorAll(guardSelector).forEach(element => element.remove());
};
return {
@@ -85,7 +85,7 @@ export function getTabbableCandidates(container: HTMLElement): HTMLElement[] {
acceptNode: (node: HTMLElement) => {
const isHiddenInput = node.tagName === 'INPUT' && (node as HTMLInputElement).type === 'hidden';
if ((node as any).disabled || node.hidden || isHiddenInput)
if ((node as HTMLElement & { disabled?: boolean }).disabled || node.hidden || isHiddenInput)
return NodeFilter.FILTER_SKIP;
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
+1
View File
@@ -1,5 +1,6 @@
export * from './animationLifecycle';
export * from './cookies';
export * from './dom';
export * from './domStyle';
export * from './focusGuard';
export * from './focusScope';
+17 -7
View File
@@ -19,6 +19,17 @@ export function testUserAgentPlatform(re: RegExp): boolean | undefined {
: undefined;
}
// Detection patterns hoisted to module scope so they're compiled once, not on
// every call. All are stateless (no `g`/`y` flag), so reuse is behavior-safe.
const MAC_RE = /^Mac/;
const IPHONE_RE = /^iPhone/;
const IPAD_RE = /^iPad/;
// eslint-disable-next-line regexp/no-unused-capturing-group
const SAFARI_RE = /^((?!chrome|android).)*safari/i;
const FIREFOX_RE = /Firefox/;
const MOBILE_RE = /Mobile/;
const FXIOS_RE = /FxiOS/;
/**
* @name isMac
* @category Browsers
@@ -30,7 +41,7 @@ export function testUserAgentPlatform(re: RegExp): boolean | undefined {
* @since 0.0.5
*/
export function isMac(): boolean | undefined {
return testUserAgentPlatform(/^Mac/);
return testUserAgentPlatform(MAC_RE);
}
/**
@@ -43,7 +54,7 @@ export function isMac(): boolean | undefined {
* @since 0.0.5
*/
export function isIPhone(): boolean | undefined {
return testUserAgentPlatform(/^iPhone/);
return testUserAgentPlatform(IPHONE_RE);
}
/**
@@ -58,7 +69,7 @@ export function isIPhone(): boolean | undefined {
*/
export function isIPad(): boolean | undefined {
return (
testUserAgentPlatform(/^iPad/)
testUserAgentPlatform(IPAD_RE)
// iPadOS 13+ lies and reports as a Mac; touch support gives it away.
|| (isMac() && navigator.maxTouchPoints > 1)
);
@@ -91,8 +102,7 @@ export function isSafari(): boolean {
if (typeof navigator === 'undefined')
return false;
// eslint-disable-next-line regexp/no-unused-capturing-group
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
return SAFARI_RE.test(navigator.userAgent);
}
/**
@@ -111,7 +121,7 @@ export function isMobileFirefox(): boolean {
const userAgent = navigator.userAgent;
return (
(/Firefox/.test(userAgent) && /Mobile/.test(userAgent)) // Android Firefox
|| /FxiOS/.test(userAgent) // iOS Firefox
(FIREFOX_RE.test(userAgent) && MOBILE_RE.test(userAgent)) // Android Firefox
|| FXIOS_RE.test(userAgent) // iOS Firefox
);
}
+21 -21
View File
@@ -30,7 +30,7 @@ if (error) {
<!-- Hero -->
<div class="prose-docs">
<h1>@robonen/stdlib</h1>
<p class="text-lg text-(--fg-muted)">
<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>
@@ -48,30 +48,30 @@ if (error) {
<!-- 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">
<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">
<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">
<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">
<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>).
@@ -126,16 +126,16 @@ if (error) {
</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">
<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)"
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>
@@ -143,7 +143,7 @@ if (error) {
<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)"
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>
@@ -151,7 +151,7 @@ if (error) {
<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)"
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>
@@ -159,7 +159,7 @@ if (error) {
<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)"
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>
@@ -167,7 +167,7 @@ if (error) {
<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)"
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>
+2 -2
View File
@@ -1,3 +1,3 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
import { base, compose, imports, stylistic, tests, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic);
export default compose(base, typescript, imports, stylistic, tests);
+1 -1
View File
@@ -22,7 +22,7 @@ export function zip<A, B>(a: A[], b: B[]): Array<[A, B]>;
export function zip<A, B, C>(a: A[], b: B[], c: C[]): Array<[A, B, C]>;
export function zip<A, B, C, D>(a: A[], b: B[], c: C[], d: D[]): Array<[A, B, C, D]>;
export function zip<A, B, C, D, E>(a: A[], b: B[], c: C[], d: D[], e: E[]): Array<[A, B, C, D, E]>;
export function zip(...arrays: any[][]): any[][] {
export function zip(...arrays: unknown[][]): unknown[][] {
if (arrays.length === 0)
return [];
+4 -2
View File
@@ -6,7 +6,7 @@ export interface AsyncPoolOptions {
signal?: AbortSignal;
}
interface PoolEntry<T = any> {
interface PoolEntry<T = unknown> {
task: (signal: AbortSignal) => Promise<T>;
resolve: (value: T) => void;
reject: (reason: unknown) => void;
@@ -115,7 +115,9 @@ export class AsyncPool {
reject(this.signal.reason);
return promise;
}
const entry = { task, resolve, reject } as PoolEntry<T>;
// Stored in a homogeneous queue: the per-call T is erased to PoolEntry<unknown>.
// run() resolves/rejects with unknown values, so this is sound at runtime.
const entry = { task, resolve, reject } as unknown as PoolEntry;
if (this.activeCount < this.limit) {
this.run(entry);
}
+4 -4
View File
@@ -11,13 +11,13 @@ export interface RetryOptions {
export type RetryFunction<Return> = (
args: {
count: number;
stop: (error: any) => void;
stop: (error: unknown) => void;
},
) => Promise<Return>;
class RetryEarlyExitError {
cause: any;
constructor(cause: any) {
cause: unknown;
constructor(cause: unknown) {
this.cause = cause;
}
}
@@ -62,7 +62,7 @@ export async function retry<Return>(
const delayFn = isFunction(delay) ? delay : null;
const delayMs = delayFn ? 0 : delay as number;
const stop = (error?: any): never => {
const stop = (error?: unknown): never => {
throw new RetryEarlyExitError(error);
};
+4 -4
View File
@@ -1,4 +1,4 @@
export type TryItReturn<Return> = Return extends PromiseLike<any>
export type TryItReturn<Return> = Return extends PromiseLike<unknown>
? Promise<{ error: Error; data: undefined } | { error: undefined; data: Awaited<Return> }>
: { error: Error; data: undefined } | { error: undefined; data: Return };
@@ -7,11 +7,11 @@ function isThenable(value: unknown): value is PromiseLike<unknown> {
&& typeof (value as PromiseLike<unknown>).then === 'function';
}
function onResolve(data: any) {
function onResolve(data: unknown) {
return { error: undefined, data };
}
function onReject(error: any) {
function onReject(error: unknown) {
return { error, data: undefined };
}
@@ -32,7 +32,7 @@ function onReject(error: any) {
*
* @since 0.0.3
*/
export function tryIt<Args extends any[], Return>(
export function tryIt<Args extends unknown[], Return>(
fn: (...args: Args) => Return,
) {
return (...args: Args): TryItReturn<Return> => {
+8 -5
View File
@@ -7,7 +7,10 @@ export type ExtractFromObject<O extends Record<PropertyKey, unknown>, K>
? NonNullable<O>[K]
: never;
export type ExtractFromArray<A extends readonly any[], K>
export type ExtractFromArray<A extends readonly unknown[], K>
// `any[]` (not `unknown[]`) is load-bearing: `any[] extends number[]` is true while
// `unknown[] extends number[]` is false. This distinguishes a general array (widen the
// element with `undefined`) from a fixed tuple (resolve the exact element at index K).
= any[] extends A
? A extends ReadonlyArray<infer T>
? T | undefined
@@ -22,7 +25,7 @@ export type ExtractFromCollection<O, K>
: K extends [infer Key, ...infer Rest]
? O extends Record<PropertyKey, unknown>
? ExtractFromCollection<ExtractFromObject<O, Key>, Rest>
: O extends readonly any[]
: O extends readonly unknown[]
? ExtractFromCollection<ExtractFromArray<O, Key>, Rest>
: never
: never;
@@ -46,7 +49,7 @@ export type Get<O, K> = ExtractFromCollection<O, Path<K>>;
* @since 0.0.4
*/
export function get<O extends Collection, K extends string>(obj: O, path: K): Get<O, K> | undefined {
let value: any = obj;
let value: unknown = obj;
let start = 0;
// Walk the path without allocating an intermediate array of segments.
@@ -58,9 +61,9 @@ export function get<O extends Collection, K extends string>(obj: O, path: K): Ge
if (value === null || value === undefined)
return undefined;
value = value[path.slice(start, i)];
value = (value as Record<string, unknown>)[path.slice(start, i)];
start = i + 1;
}
return value;
return value as Get<O, K> | undefined;
}
+2 -2
View File
@@ -27,7 +27,7 @@ export function set<O extends Collection>(obj: O, path: string, value: unknown):
const keys = path.split('.');
const lastKey = keys[keys.length - 1]!;
let current: any = obj;
let current = obj as Record<PropertyKey, unknown>;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]!;
@@ -38,7 +38,7 @@ export function set<O extends Collection>(obj: O, path: string, value: unknown):
if (next === null || typeof next !== 'object')
current[key] = NUMERIC_SEGMENT.test(keys[i + 1]!) ? [] : {};
current = current[key];
current = current[key] as Record<PropertyKey, unknown>;
}
current[lastKey] = value;
+2 -2
View File
@@ -1,4 +1,4 @@
function equals(a: any, b: any, seen: WeakMap<object, unknown>): boolean {
function equals(a: unknown, b: unknown, seen: WeakMap<object, unknown>): boolean {
if (a === b)
return true;
@@ -65,7 +65,7 @@ function equals(a: any, b: any, seen: WeakMap<object, unknown>): boolean {
return false;
for (const key of aKeys) {
if (!Object.prototype.hasOwnProperty.call(b, key) || !equals(a[key], b[key], seen))
if (!Object.prototype.hasOwnProperty.call(b, key) || !equals((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key], seen))
return false;
}
+8 -8
View File
@@ -14,15 +14,15 @@ import type { AnyFunction } from '../../types';
*
* @since 0.0.10
*/
export function compose<A extends any[], B>(ab: (...a: A) => B): (...a: A) => B;
export function compose<A extends any[], B, C>(bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => C;
export function compose<A extends any[], B, C, D>(cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => D;
export function compose<A extends any[], B, C, D, E>(de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => E;
export function compose<A extends any[], B, C, D, E, F>(ef: (e: E) => F, de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => F;
export function compose<A extends any[], B, C, D, E, F, G>(fg: (f: F) => G, ef: (e: E) => F, de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => G;
export function compose<A extends any[], B, C, D, E, F, G, H>(gh: (g: G) => H, fg: (f: F) => G, ef: (e: E) => F, de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => H;
export function compose<A extends unknown[], B>(ab: (...a: A) => B): (...a: A) => B;
export function compose<A extends unknown[], B, C>(bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => C;
export function compose<A extends unknown[], B, C, D>(cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => D;
export function compose<A extends unknown[], B, C, D, E>(de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => E;
export function compose<A extends unknown[], B, C, D, E, F>(ef: (e: E) => F, de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => F;
export function compose<A extends unknown[], B, C, D, E, F, G>(fg: (f: F) => G, ef: (e: E) => F, de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => G;
export function compose<A extends unknown[], B, C, D, E, F, G, H>(gh: (g: G) => H, fg: (f: F) => G, ef: (e: E) => F, de: (d: D) => E, cd: (c: C) => D, bc: (b: B) => C, ab: (...a: A) => B): (...a: A) => H;
export function compose(...fns: AnyFunction[]): AnyFunction {
return function (this: unknown, ...args: any[]) {
return function (this: unknown, ...args: unknown[]) {
const last = fns.length - 1;
if (last < 0)
+8 -8
View File
@@ -14,15 +14,15 @@ import type { AnyFunction } from '../../types';
*
* @since 0.0.10
*/
export function pipe<A extends any[], B>(ab: (...a: A) => B): (...a: A) => B;
export function pipe<A extends any[], B, C>(ab: (...a: A) => B, bc: (b: B) => C): (...a: A) => C;
export function pipe<A extends any[], B, C, D>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D): (...a: A) => D;
export function pipe<A extends any[], B, C, D, E>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E): (...a: A) => E;
export function pipe<A extends any[], B, C, D, E, F>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F): (...a: A) => F;
export function pipe<A extends any[], B, C, D, E, F, G>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F, fg: (f: F) => G): (...a: A) => G;
export function pipe<A extends any[], B, C, D, E, F, G, H>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F, fg: (f: F) => G, gh: (g: G) => H): (...a: A) => H;
export function pipe<A extends unknown[], B>(ab: (...a: A) => B): (...a: A) => B;
export function pipe<A extends unknown[], B, C>(ab: (...a: A) => B, bc: (b: B) => C): (...a: A) => C;
export function pipe<A extends unknown[], B, C, D>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D): (...a: A) => D;
export function pipe<A extends unknown[], B, C, D, E>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E): (...a: A) => E;
export function pipe<A extends unknown[], B, C, D, E, F>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F): (...a: A) => F;
export function pipe<A extends unknown[], B, C, D, E, F, G>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F, fg: (f: F) => G): (...a: A) => G;
export function pipe<A extends unknown[], B, C, D, E, F, G, H>(ab: (...a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E, ef: (e: E) => F, fg: (f: F) => G, gh: (g: G) => H): (...a: A) => H;
export function pipe(...fns: AnyFunction[]): AnyFunction {
return function (this: unknown, ...args: any[]) {
return function (this: unknown, ...args: unknown[]) {
if (fns.length === 0)
return args[0];
@@ -124,6 +124,9 @@ export function createAsyncMachine<
export function createAsyncMachine(config: {
initial: string;
context?: unknown;
// Overload-implementation signature: the typed overloads above expose the real
// per-context API; `any` here accepts every concrete `AsyncStateNodeConfig<C>`
// (contravariant in `C`, so `unknown` would reject them).
states: Record<string, AsyncStateNodeConfig<any>>;
}): AsyncStateMachine {
return new AsyncStateMachine(
@@ -125,6 +125,9 @@ export function createMachine<
export function createMachine(config: {
initial: string;
context?: unknown;
// Overload-implementation signature: the typed overloads above expose the real
// per-context API; `any` here accepts every concrete `SyncStateNodeConfig<C>`
// (contravariant in `C`, so `unknown` would reject them).
states: Record<string, SyncStateNodeConfig<any>>;
}): StateMachine {
return new StateMachine(
@@ -58,7 +58,7 @@ export type AsyncStateNodeConfig<Context> = StateNodeConfig<Context, MaybePromis
export type ExtractStates<T> = keyof T & string;
export type ExtractEvents<T> = {
[K in keyof T]: T[K] extends { readonly on?: Readonly<Record<infer E extends string, any>> }
[K in keyof T]: T[K] extends { readonly on?: Readonly<Record<infer E extends string, unknown>> }
? E
: never;
}[keyof T];
+3 -2
View File
@@ -14,7 +14,7 @@ export interface BinaryHeapOptions<T> {
* @param {number} b Second element
* @returns {number} Negative if a < b, positive if a > b, zero if equal
*/
const defaultComparator: Comparator<any> = (a: number, b: number) => a - b;
const defaultComparator: Comparator<number> = (a: number, b: number) => a - b;
/**
* @name BinaryHeap
@@ -49,7 +49,8 @@ export class BinaryHeap<T> implements BinaryHeapLike<T> {
* @param {BinaryHeapOptions<T>} [options] Heap configuration
*/
constructor(initialValues?: T[] | T, options?: BinaryHeapOptions<T>) {
this.comparator = options?.comparator ?? defaultComparator;
// Numeric default; cast bridges it to the caller's `T` when no comparator is given.
this.comparator = options?.comparator ?? (defaultComparator as Comparator<T>);
if (initialValues !== null && initialValues !== undefined) {
const items = isArray(initialValues) ? initialValues : [initialValues];
+2 -2
View File
@@ -3,9 +3,9 @@
* @category Types
* @description To string any value
*
* @param {any} value
* @param {unknown} value
* @returns {string}
*
* @since 0.0.2
*/
export const toString = (value: any): string => Object.prototype.toString.call(value);
export const toString = (value: unknown): string => Object.prototype.toString.call(value);
-1
View File
@@ -38,7 +38,6 @@ describe('complex', () => {
});
it('true for class instances and null-prototype objects', () => {
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- fixture for the instance check
class Foo {}
expect(isObject(new Foo())).toBe(true);
+25 -25
View File
@@ -5,117 +5,117 @@ import { toString } from './casts';
* @category Types
* @description Check if a value is an array
*
* @param {any} value
* @returns {value is any[]}
* @param {unknown} value
* @returns {value is T[]}
*
* @since 0.0.2
*/
export const isArray = (value: any): value is any[] => Array.isArray(value);
export const isArray = <T = unknown>(value: unknown): value is T[] => Array.isArray(value);
/**
* @name isObject
* @category Types
* @description Check if a value is an object
*
* @param {any} value
* @param {unknown} value
* @returns {value is object}
*
* @since 0.0.2
*/
export const isObject = (value: any): value is object => toString(value) === '[object Object]';
export const isObject = (value: unknown): value is object => toString(value) === '[object Object]';
/**
* @name isRegExp
* @category Types
* @description Check if a value is a regexp
*
* @param {any} value
* @param {unknown} value
* @returns {value is RegExp}
*
* @since 0.0.2
*/
export const isRegExp = (value: any): value is RegExp => toString(value) === '[object RegExp]';
export const isRegExp = (value: unknown): value is RegExp => toString(value) === '[object RegExp]';
/**
* @name isDate
* @category Types
* @description Check if a value is a date
*
* @param {any} value
* @param {unknown} value
* @returns {value is Date}
*
* @since 0.0.2
*/
export const isDate = (value: any): value is Date => toString(value) === '[object Date]';
export const isDate = (value: unknown): value is Date => toString(value) === '[object Date]';
/**
* @name isError
* @category Types
* @description Check if a value is an error
*
* @param {any} value
* @param {unknown} value
* @returns {value is Error}
*
* @since 0.0.2
*/
export const isError = (value: any): value is Error => toString(value) === '[object Error]';
export const isError = (value: unknown): value is Error => toString(value) === '[object Error]';
/**
* @name isPromise
* @category Types
* @description Check if a value is a promise
*
* @param {any} value
* @returns {value is Promise<any>}
* @param {unknown} value
* @returns {value is Promise<unknown>}
*
* @since 0.0.2
*/
export const isPromise = (value: any): value is Promise<any> => toString(value) === '[object Promise]';
export const isPromise = (value: unknown): value is Promise<unknown> => toString(value) === '[object Promise]';
/**
* @name isMap
* @category Types
* @description Check if a value is a map
*
* @param {any} value
* @returns {value is Map<any, any>}
* @param {unknown} value
* @returns {value is Map<unknown, unknown>}
*
* @since 0.0.2
*/
export const isMap = (value: any): value is Map<any, any> => toString(value) === '[object Map]';
export const isMap = (value: unknown): value is Map<unknown, unknown> => toString(value) === '[object Map]';
/**
* @name isSet
* @category Types
* @description Check if a value is a set
*
* @param {any} value
* @returns {value is Set<any>}
* @param {unknown} value
* @returns {value is Set<unknown>}
*
* @since 0.0.2
*/
export const isSet = (value: any): value is Set<any> => toString(value) === '[object Set]';
export const isSet = (value: unknown): value is Set<unknown> => toString(value) === '[object Set]';
/**
* @name isWeakMap
* @category Types
* @description Check if a value is a weakmap
*
* @param {any} value
* @returns {value is WeakMap<object, any>}
* @param {unknown} value
* @returns {value is WeakMap<object, unknown>}
*
* @since 0.0.2
*/
export const isWeakMap = (value: any): value is WeakMap<object, any> => toString(value) === '[object WeakMap]';
export const isWeakMap = (value: unknown): value is WeakMap<object, unknown> => toString(value) === '[object WeakMap]';
/**
* @name isWeakSet
* @category Types
* @description Check if a value is a weakset
*
* @param {any} value
* @param {unknown} value
* @returns {value is WeakSet<object>}
*
* @since 0.0.2
*/
export const isWeakSet = (value: any): value is WeakSet<object> => toString(value) === '[object WeakSet]';
export const isWeakSet = (value: unknown): value is WeakSet<object> => toString(value) === '[object WeakSet]';
+17 -16
View File
@@ -3,93 +3,94 @@
* @category Types
* @description Check if a value is a boolean
*
* @param {any} value
* @param {unknown} value
* @returns {value is boolean}
*
* @since 0.0.2
*/
export const isBoolean = (value: any): value is boolean => typeof value === 'boolean';
export const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean';
/**
* @name isFunction
* @category Types
* @description Check if a value is a function
*
* @param {any} value
* @param {unknown} value
* @returns {value is Function}
*
* @since 0.0.2
*/
export const isFunction = <T extends (...args: any[]) => any>(value: any): value is T => typeof value === 'function';
// `(...args: any[]) => any` is the idiomatic "any function" constraint here; `unknown` would reject legitimate function shapes at call sites.
export const isFunction = <T extends (...args: any[]) => any>(value: unknown): value is T => typeof value === 'function';
/**
* @name isNumber
* @category Types
* @description Check if a value is a number
*
* @param {any} value
* @param {unknown} value
* @returns {value is number}
*
* @since 0.0.2
*/
export const isNumber = (value: any): value is number => typeof value === 'number';
export const isNumber = (value: unknown): value is number => typeof value === 'number';
/**
* @name isBigInt
* @category Types
* @description Check if a value is a bigint
*
* @param {any} value
* @param {unknown} value
* @returns {value is bigint}
*
* @since 0.0.2
*/
export const isBigInt = (value: any): value is bigint => typeof value === 'bigint';
export const isBigInt = (value: unknown): value is bigint => typeof value === 'bigint';
/**
* @name isString
* @category Types
* @description Check if a value is a string
*
* @param {any} value
* @param {unknown} value
* @returns {value is string}
*
* @since 0.0.2
*/
export const isString = (value: any): value is string => typeof value === 'string';
export const isString = (value: unknown): value is string => typeof value === 'string';
/**
* @name isSymbol
* @category Types
* @description Check if a value is a symbol
*
* @param {any} value
* @param {unknown} value
* @returns {value is symbol}
*
* @since 0.0.2
*/
export const isSymbol = (value: any): value is symbol => typeof value === 'symbol';
export const isSymbol = (value: unknown): value is symbol => typeof value === 'symbol';
/**
* @name isUndefined
* @category Types
* @description Check if a value is a undefined
*
* @param {any} value
* @param {unknown} value
* @returns {value is undefined}
*
* @since 0.0.2
*/
export const isUndefined = (value: any): value is undefined => value === undefined;
export const isUndefined = (value: unknown): value is undefined => value === undefined;
/**
* @name isNull
* @category Types
* @description Check if a value is a null
*
* @param {any} value
* @param {unknown} value
* @returns {value is null}
*
* @since 0.0.2
*/
export const isNull = (value: any): value is null => value === null;
export const isNull = (value: unknown): value is null => value === null;
+4 -1
View File
@@ -1,7 +1,10 @@
/**
* A collection definition
*/
export type Collection = Record<PropertyKey, any> | any[];
// `any[]` is kept (not `unknown[]`): as the `O extends Collection` constraint in `get`/`set`,
// `unknown[]` would reject arrays whose elements sit in contravariant positions (e.g. the
// `CircularBuffer<PoolEntry>` used by `async/pool`), breaking compilation outside this file.
export type Collection = Record<PropertyKey, unknown> | any[];
/**
* Parse a collection path string into an array of keys
+2
View File
@@ -1,6 +1,8 @@
/**
* Any function
*/
// `(...args: any[]) => any` is the idiomatic "any function" constraint; `unknown`
// would reject legitimate function shapes when used as `T extends AnyFunction`.
export type AnyFunction = (...args: any[]) => any;
/**
+75
View File
@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
const { data: home } = await useAsyncData(() => queryCollection('renovate').first());
useSeoMeta({
title: home.value?.title,
description: home.value?.description,
});
</script>
<template>
<ContentRenderer v-if="home" :value="home" />
<div v-else>Home not found</div>
</template>
+99 -34
View File
@@ -16,48 +16,113 @@
--radius-card: 0.5rem;
}
/* ── Semantic colour utilities ─────────────────────────────────────────────
Register the runtime theme tokens as Tailwind colours so templates use clean
utilities (`bg-bg`, `text-fg`, `border-border`, `ring-ring`, `bg-accent`…)
instead of the `bg-(--bg)` arbitrary-value escape hatch. `inline` makes each
utility emit `var(--token)` directly, so it stays switchable by the `.dark`
override below AND gains opacity modifiers (`bg-bg/80` → color-mix). The raw
`--token`s remain the single source of truth (consumed directly via `var()`
in the prose/identity CSS); these are thin aliases over them. */
@theme inline {
--color-bg: var(--bg);
--color-bg-subtle: var(--bg-subtle);
--color-bg-elevated: var(--bg-elevated);
--color-bg-inset: var(--bg-inset);
--color-border: var(--border);
--color-border-strong: var(--border-strong);
--color-fg: var(--fg);
--color-fg-muted: var(--fg-muted);
--color-fg-subtle: var(--fg-subtle);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-fg: var(--accent-fg);
--color-accent-subtle: var(--accent-subtle);
--color-accent-text: var(--accent-text);
--color-header-bg: var(--header-bg);
--color-ring: var(--ring);
}
/* ── Demo design-system shortcuts ──────────────────────────────────────────
The package demo.vue files share a small visual vocabulary: a width-capped
vertical shell, a code-comment eyebrow label, button/badge chrome, inputs,
and card surfaces. These were copy-pasted as identical Tailwind strings
across ~240 demos. Collapsed here into semantic utilities so the look is
tuned in one place. Each is the common CORE of its pattern — per-demo extras
(max-width, padding, disabled states, w-full, sizes) stay on the element, so
the rendered result is unchanged. */
@utility demo-stack {
@apply flex w-full flex-col gap-4;
}
@utility demo-label {
@apply text-xs font-medium uppercase tracking-wide text-fg-subtle;
}
@utility demo-card {
@apply rounded-xl border border-border bg-bg-elevated;
}
@utility demo-btn {
@apply inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-border bg-bg-elevated px-3 py-1.5 text-sm font-medium text-fg transition hover:bg-bg-inset hover:border-border-strong active:scale-[0.98];
}
@utility demo-btn-primary {
@apply inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-transparent bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition hover:bg-accent-hover active:scale-[0.98];
}
@utility demo-badge {
@apply inline-flex items-center gap-1.5 rounded-md border border-border bg-bg-inset px-2 py-0.5 text-xs font-medium text-fg-muted;
}
@utility demo-input {
@apply w-full rounded-lg border border-border bg-bg px-3 py-2 text-sm text-fg transition placeholder:text-fg-subtle focus:border-accent focus:outline-none focus:ring-2 focus:ring-ring;
}
@utility demo-stat {
@apply font-mono font-bold tabular-nums text-fg;
}
/* ── Semantic design tokens — ink on warm paper, signal-orange instruments ──
The site reads like a tool-maker's field manual: warm neutral surfaces,
hairline rules, international-orange accents, code-comment labels. */
:root {
--bg: #faf8f3;
--bg-subtle: #f4f1e8;
--bg-elevated: #fffdf8;
--bg-inset: #eeeadf;
--border: #e5dfd0;
--border-strong: #cfc6b1;
--fg: #211e18;
--fg-muted: #5d574b;
--fg-subtle: #93897a;
--accent: #d9480f;
--accent-hover: #bf3f0d;
--accent-fg: #fffdf8;
--accent-subtle: #f7e7d8;
--accent-text: #c2410c;
--header-bg: rgba(250, 248, 243, 0.82);
--ring: rgba(217, 72, 15, 0.35);
--shadow-card: 0 1px 2px rgba(56, 44, 28, 0.05), 0 1px 3px rgba(56, 44, 28, 0.07);
/* Colours are OKLCH (perceptually uniform — even lightness steps, predictable
hue) and are exact equivalents of the original hand-tuned sRGB palette.
Translucent tokens derive from their base via color-mix(), so they track
theme + accent retuning automatically instead of duplicating a literal. */
--bg: oklch(0.9793 0.007 88.64);
--bg-subtle: oklch(0.958 0.0124 91.52);
--bg-elevated: oklch(0.9942 0.0069 88.64);
--bg-inset: oklch(0.9371 0.0153 90.24);
--border: oklch(0.9043 0.0211 88.73);
--border-strong: oklch(0.8282 0.0303 87.56);
--fg: oklch(0.2363 0.012 84.56);
--fg-muted: oklch(0.4588 0.0204 84.58);
--fg-subtle: oklch(0.6346 0.0249 78.12);
--accent: oklch(0.5999 0.1905 37.88);
--accent-hover: oklch(0.5461 0.1724 37.96);
--accent-fg: oklch(0.9942 0.0069 88.64);
--accent-subtle: oklch(0.9367 0.0266 65.68);
--accent-text: oklch(0.5534 0.1739 38.4);
--header-bg: color-mix(in oklch, var(--bg) 82%, transparent);
--ring: color-mix(in oklch, var(--accent) 35%, transparent);
--shadow-card: 0 1px 2px oklch(0.302 0.0319 74.11 / 0.05), 0 1px 3px oklch(0.302 0.0319 74.11 / 0.07);
color-scheme: light;
}
.dark {
--bg: #161310;
--bg-subtle: #1b1813;
--bg-elevated: #211d17;
--bg-inset: #2a251c;
--border: #322c22;
--border-strong: #4a4231;
--fg: #ece7db;
--fg-muted: #b2a995;
--fg-subtle: #7d7363;
--accent: #ff7d33;
--accent-hover: #ff9a59;
--accent-fg: #1d0e04;
--accent-subtle: #3a2415;
--accent-text: #ff9c63;
--header-bg: rgba(22, 19, 16, 0.82);
--ring: rgba(255, 125, 51, 0.4);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.5);
--bg: oklch(0.1892 0.0077 67.33);
--bg-subtle: oklch(0.2107 0.0106 80.56);
--bg-elevated: oklch(0.2332 0.0127 78);
--bg-inset: oklch(0.267 0.0176 82.2);
--border: oklch(0.2964 0.0194 80.44);
--border-strong: oklch(0.3822 0.0294 85.68);
--fg: oklch(0.9286 0.0169 88);
--fg-muted: oklch(0.7369 0.0298 86.66);
--fg-subtle: oklch(0.56 0.0269 79.61);
--accent: oklch(0.7294 0.1789 46.57);
--accent-hover: oklch(0.7788 0.1452 51.83);
--accent-fg: oklch(0.1825 0.0328 56.53);
--accent-subtle: oklch(0.284 0.042 54.49);
--accent-text: oklch(0.7835 0.139 49.63);
/* --header-bg is not re-declared: the :root color-mix tracks --bg, which we
override above. Only --ring needs a tweak (slightly stronger in dark). */
--ring: color-mix(in oklch, var(--accent) 40%, transparent);
--shadow-card: 0 1px 2px oklch(0 0 0 / 0.4), 0 1px 3px oklch(0 0 0 / 0.5);
color-scheme: dark;
}
+2 -2
View File
@@ -22,8 +22,8 @@ const kindLabels: Record<string, string> = {
:class="[
'inline-flex items-center justify-center rounded font-mono font-medium shrink-0 border',
kind === 'component'
? 'border-(--accent-subtle) bg-(--accent-subtle) text-(--accent-text)'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)',
? 'border-accent-subtle bg-accent-subtle text-accent-text'
: 'border-border bg-bg-inset text-fg-muted',
size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs',
]"
:title="kind"
+5 -5
View File
@@ -39,12 +39,12 @@ async function copy() {
</script>
<template>
<div class="group relative rounded-xl border border-(--border) bg-(--bg-subtle) overflow-hidden max-w-full">
<div v-if="!bare" class="flex items-center justify-between px-3 h-9 border-b border-(--border) bg-(--bg-subtle)">
<span class="text-[11px] font-mono uppercase tracking-wider text-(--fg-subtle)">{{ langLabel }}</span>
<div class="group relative rounded-xl border border-border bg-bg-subtle overflow-hidden max-w-full">
<div v-if="!bare" class="flex items-center justify-between px-3 h-9 border-b border-border bg-bg-subtle">
<span class="text-[11px] font-mono uppercase tracking-wider text-fg-subtle">{{ langLabel }}</span>
<button
type="button"
class="inline-flex items-center gap-1 text-[11px] font-medium text-(--fg-subtle) hover:text-(--fg) transition-colors cursor-pointer"
class="inline-flex items-center gap-1 text-[11px] font-medium text-fg-subtle hover:text-fg transition-colors cursor-pointer"
@click="copy"
>
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -59,7 +59,7 @@ async function copy() {
<button
v-else
type="button"
class="absolute right-2 top-2 z-10 inline-flex items-center justify-center w-7 h-7 rounded-md bg-(--bg-elevated) border border-(--border) text-(--fg-subtle) opacity-0 group-hover:opacity-100 hover:text-(--fg) transition-all cursor-pointer"
class="absolute right-2 top-2 z-10 inline-flex items-center justify-center w-7 h-7 rounded-md bg-bg-elevated border border-border text-fg-subtle opacity-0 group-hover:opacity-100 hover:text-fg transition-all cursor-pointer"
title="Copy"
@click="copy"
>
+8 -8
View File
@@ -43,10 +43,10 @@ const roleColor: Record<string, string> = {
<div class="space-y-10">
<!-- Anatomy snippet -->
<section>
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-3">
<h2 class="text-xs font-semibold uppercase tracking-wider text-fg-subtle mb-3">
Anatomy
</h2>
<p class="text-sm text-(--fg-muted) mb-3">
<p class="text-sm text-fg-muted mb-3">
Import the parts and compose them. Each part forwards attributes to its underlying element.
</p>
<DocsCode :code="anatomyCode" lang="vue" />
@@ -54,7 +54,7 @@ const roleColor: Record<string, string> = {
<!-- Parts -->
<section>
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">
<h2 class="text-xs font-semibold uppercase tracking-wider text-fg-subtle mb-4">
API Reference
</h2>
<div class="space-y-8">
@@ -65,18 +65,18 @@ const roleColor: Record<string, string> = {
class="scroll-mt-20"
>
<div class="flex items-center gap-2.5 mb-2">
<h3 class="font-mono text-base font-semibold text-(--fg)">{{ part.name }}</h3>
<h3 class="font-mono text-base font-semibold text-fg">{{ part.name }}</h3>
<span
:class="[
'text-[11px] px-2 py-0.5 rounded-full font-medium leading-none',
roleColor[part.role] ?? 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)',
roleColor[part.role] ?? 'bg-bg-inset text-fg-muted border border-border',
]"
>
{{ part.role }}
</span>
</div>
<p v-if="part.description" class="text-sm text-(--fg-muted) mb-3 max-w-2xl">
<p v-if="part.description" class="text-sm text-fg-muted mb-3 max-w-2xl">
{{ part.description }}
</p>
@@ -85,11 +85,11 @@ const roleColor: Record<string, string> = {
</div>
<div v-if="part.emits.length > 0" class="mb-3">
<div class="text-[11px] font-semibold uppercase tracking-wider text-(--fg-subtle) mb-2">Emits</div>
<div class="text-[11px] font-semibold uppercase tracking-wider text-fg-subtle mb-2">Emits</div>
<DocsEmitsTable :emits="part.emits" />
</div>
<p v-if="part.props.length === 0 && part.emits.length === 0" class="text-sm text-(--fg-subtle) italic">
<p v-if="part.props.length === 0 && part.emits.length === 0" class="text-sm text-fg-subtle italic">
No props or events renders its element and forwards attributes.
</p>
</div>
+6 -6
View File
@@ -24,14 +24,14 @@ watch(showSource, async (show) => {
</script>
<template>
<div class="rounded-xl border border-(--border) overflow-hidden">
<div class="rounded-xl border border-border overflow-hidden">
<!-- Live demo client-only: demos are interactive and use browser APIs,
so they must not be instantiated during SSR/prerender. -->
<div class="p-4 sm:p-8 bg-(--bg-subtle) flex items-center justify-center min-h-32">
<div class="p-4 sm:p-8 bg-bg-subtle flex items-center justify-center min-h-32">
<ClientOnly>
<component :is="component" />
<template #fallback>
<div class="flex items-center gap-2 text-sm text-(--fg-subtle)">
<div class="flex items-center gap-2 text-sm text-fg-subtle">
<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
@@ -42,10 +42,10 @@ watch(showSource, async (show) => {
</div>
<!-- Source toggle bar -->
<div class="flex items-center border-t border-(--border) bg-(--bg-elevated)">
<div class="flex items-center border-t border-border bg-bg-elevated">
<button
type="button"
class="flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium text-(--fg-muted) hover:text-(--fg) transition-colors cursor-pointer"
class="flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium text-fg-muted hover:text-fg transition-colors cursor-pointer"
@click="showSource = !showSource"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -62,7 +62,7 @@ watch(showSource, async (show) => {
</div>
<!-- Source code -->
<div v-if="showSource" class="border-t border-(--border) bg-(--bg-subtle)">
<div v-if="showSource" class="border-t border-border bg-bg-subtle">
<div class="overflow-x-auto text-[13px] [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!" v-html="highlighted" />
</div>
</div>
+7 -7
View File
@@ -6,21 +6,21 @@ defineProps<{
</script>
<template>
<div v-if="emits.length > 0" class="overflow-x-auto rounded-xl border border-(--border)">
<div v-if="emits.length > 0" class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="bg-(--bg-subtle) text-left">
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Event</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Payload</th>
<tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Event</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Payload</th>
</tr>
</thead>
<tbody>
<tr v-for="e in emits" :key="e.name" class="border-t border-(--border) align-top">
<tr v-for="e in emits" :key="e.name" class="border-t border-border align-top">
<td class="py-2.5 px-4 whitespace-nowrap">
<code class="text-(--accent-text) font-mono text-[13px] font-medium">{{ e.name }}</code>
<code class="text-accent-text font-mono text-[13px] font-medium">{{ e.name }}</code>
</td>
<td class="py-2.5 px-4">
<code class="text-xs font-mono text-(--fg-muted) bg-(--bg-inset) px-1.5 py-0.5 rounded border border-(--border) wrap-break-word">{{ e.payload }}</code>
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ e.payload }}</code>
</td>
</tr>
</tbody>
+1 -1
View File
@@ -28,7 +28,7 @@ async function highlightCodeBlocks() {
try {
const out = await highlight(text, resolved);
const wrapper = document.createElement('div');
wrapper.className = 'not-prose rounded-xl border border-(--border) bg-(--bg-subtle) overflow-x-auto text-[13px] my-5 [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!';
wrapper.className = 'not-prose rounded-xl border border-border bg-bg-subtle overflow-x-auto text-[13px] my-5 [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!';
wrapper.innerHTML = out;
pre.replaceWith(wrapper);
}
+7 -7
View File
@@ -10,19 +10,19 @@ defineProps<{
<div
v-for="method in methods"
:key="method.name"
class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4"
class="rounded-xl border border-border bg-bg-subtle p-4"
>
<div class="flex items-center gap-2 mb-2">
<code class="text-sm font-mono font-semibold text-(--fg)">{{ method.name }}</code>
<code class="text-sm font-mono font-semibold text-fg">{{ method.name }}</code>
<span
v-if="method.visibility !== 'public'"
class="text-[10px] uppercase px-1.5 py-0.5 rounded bg-(--bg-inset) border border-(--border) text-(--fg-subtle)"
class="text-[10px] uppercase px-1.5 py-0.5 rounded bg-bg-inset border border-border text-fg-subtle"
>
{{ method.visibility }}
</span>
</div>
<p v-if="method.description" class="text-sm text-(--fg-muted) mb-3">
<p v-if="method.description" class="text-sm text-fg-muted mb-3">
<DocsText :text="method.description" />
</p>
@@ -36,9 +36,9 @@ defineProps<{
<DocsParamsTable v-if="method.params.length > 0" :params="method.params" />
<div v-if="method.returns" class="mt-2 text-sm">
<span class="text-(--fg-subtle)">Returns</span>
<code class="ml-1.5 text-xs font-mono bg-(--bg-inset) border border-(--border) px-1.5 py-0.5 rounded">{{ method.returns.type }}</code>
<DocsText v-if="method.returns.description" :text="method.returns.description" class="ml-2 text-(--fg-muted)" />
<span class="text-fg-subtle">Returns</span>
<code class="ml-1.5 text-xs font-mono bg-bg-inset border border-border px-1.5 py-0.5 rounded">{{ method.returns.type }}</code>
<DocsText v-if="method.returns.description" :text="method.returns.description" class="ml-2 text-fg-muted" />
</div>
</div>
</div>
+12 -12
View File
@@ -6,33 +6,33 @@ defineProps<{
</script>
<template>
<div v-if="params.length > 0" class="overflow-x-auto rounded-xl border border-(--border)">
<div v-if="params.length > 0" class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="bg-(--bg-subtle) text-left">
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Parameter</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Type</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider hidden sm:table-cell">Default</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Description</th>
<tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Parameter</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Type</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider hidden sm:table-cell">Default</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr>
</thead>
<tbody>
<tr
v-for="param in params"
:key="param.name"
class="border-t border-(--border) align-top"
class="border-t border-border align-top"
>
<td class="py-2.5 px-4 whitespace-nowrap">
<code class="text-(--accent-text) font-mono text-[13px] font-medium">{{ param.name }}</code><span v-if="param.optional" class="text-(--fg-subtle) text-xs">?</span>
<code class="text-accent-text font-mono text-[13px] font-medium">{{ param.name }}</code><span v-if="param.optional" class="text-fg-subtle text-xs">?</span>
</td>
<td class="py-2.5 px-4">
<code class="text-xs font-mono text-(--fg-muted) bg-(--bg-inset) px-1.5 py-0.5 rounded border border-(--border) wrap-break-word">{{ param.type }}</code>
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ param.type }}</code>
</td>
<td class="py-2.5 px-4 hidden sm:table-cell">
<code v-if="param.defaultValue" class="text-xs font-mono text-(--fg-muted)">{{ param.defaultValue }}</code>
<span v-else class="text-(--fg-subtle)"></span>
<code v-if="param.defaultValue" class="text-xs font-mono text-fg-muted">{{ param.defaultValue }}</code>
<span v-else class="text-fg-subtle"></span>
</td>
<td class="py-2.5 px-4 text-(--fg-muted) min-w-48">
<td class="py-2.5 px-4 text-fg-muted min-w-48">
<DocsText v-if="param.description" :text="param.description" />
<span v-else></span>
</td>
+13 -13
View File
@@ -8,34 +8,34 @@ defineProps<{
</script>
<template>
<div v-if="properties.length > 0" class="overflow-x-auto rounded-xl border border-(--border)">
<div v-if="properties.length > 0" class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="bg-(--bg-subtle) text-left">
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">{{ label ?? 'Property' }}</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Type</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider hidden sm:table-cell">Default</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Description</th>
<tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">{{ label ?? 'Property' }}</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Type</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider hidden sm:table-cell">Default</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr>
</thead>
<tbody>
<tr
v-for="prop in properties"
:key="prop.name"
class="border-t border-(--border) align-top"
class="border-t border-border align-top"
>
<td class="py-2.5 px-4 whitespace-nowrap">
<code class="text-(--accent-text) font-mono text-[13px] font-medium">{{ prop.name }}</code><span v-if="prop.optional" class="text-(--fg-subtle) text-xs">?</span>
<span v-if="prop.readonly" class="block text-[10px] text-(--fg-subtle) uppercase tracking-wide mt-0.5">readonly</span>
<code class="text-accent-text font-mono text-[13px] font-medium">{{ prop.name }}</code><span v-if="prop.optional" class="text-fg-subtle text-xs">?</span>
<span v-if="prop.readonly" class="block text-[10px] text-fg-subtle uppercase tracking-wide mt-0.5">readonly</span>
</td>
<td class="py-2.5 px-4">
<code class="text-xs font-mono text-(--fg-muted) bg-(--bg-inset) px-1.5 py-0.5 rounded border border-(--border) wrap-break-word">{{ prop.type }}</code>
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ prop.type }}</code>
</td>
<td class="py-2.5 px-4 hidden sm:table-cell">
<code v-if="prop.defaultValue" class="text-xs font-mono text-(--fg-muted)">{{ prop.defaultValue }}</code>
<span v-else class="text-(--fg-subtle)"></span>
<code v-if="prop.defaultValue" class="text-xs font-mono text-fg-muted">{{ prop.defaultValue }}</code>
<span v-else class="text-fg-subtle"></span>
</td>
<td class="py-2.5 px-4 text-(--fg-muted) min-w-48">
<td class="py-2.5 px-4 text-fg-muted min-w-48">
<DocsText v-if="prop.description" :text="prop.description" />
<span v-else></span>
</td>
+12 -12
View File
@@ -65,14 +65,14 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
<div>
<button
type="button"
class="flex items-center gap-2 px-2.5 h-9 text-sm text-(--fg-subtle) bg-(--bg-subtle) border border-(--border) rounded-lg hover:border-(--border-strong) transition-colors w-9 sm:w-56 justify-center sm:justify-start cursor-pointer"
class="flex items-center gap-2 px-2.5 h-9 text-sm text-fg-subtle bg-bg-subtle border border-border rounded-lg hover:border-border-strong transition-colors w-9 sm:w-56 justify-center sm:justify-start cursor-pointer"
@click="open"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg>
<span class="hidden sm:inline flex-1 text-left font-mono text-[13px]">search</span>
<kbd class="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono bg-(--bg) border border-(--border) rounded text-(--fg-subtle)">K</kbd>
<kbd class="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono bg-bg border border-border rounded text-fg-subtle">K</kbd>
</button>
<Teleport to="body">
@@ -84,21 +84,21 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm" @click="close" />
<div class="fixed inset-x-0 top-[12vh] mx-auto max-w-xl px-4">
<div class="bg-(--bg-elevated) rounded-xl border border-(--border) shadow-2xl overflow-hidden">
<div class="flex items-center px-4 border-b border-(--border)">
<span class="font-mono text-base text-(--accent-text) select-none shrink-0"></span>
<div class="bg-bg-elevated rounded-xl border border-border shadow-2xl overflow-hidden">
<div class="flex items-center px-4 border-b border-border">
<span class="font-mono text-base text-accent-text select-none shrink-0"></span>
<input
v-model="query"
data-search-input
type="text"
placeholder="search across all packages…"
class="w-full py-3.5 px-3 bg-transparent text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none font-mono text-[14px]"
class="w-full py-3.5 px-3 bg-transparent text-fg placeholder:text-fg-subtle focus:outline-none font-mono text-[14px]"
>
<kbd class="hidden sm:inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono bg-(--bg-inset) border border-(--border) rounded text-(--fg-subtle)">ESC</kbd>
<kbd class="hidden sm:inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono bg-bg-inset border border-border rounded text-fg-subtle">ESC</kbd>
</div>
<div class="max-h-[60vh] overflow-y-auto p-2">
<div v-if="query && results.length === 0" class="py-12 text-center text-sm text-(--fg-subtle)">
<div v-if="query && results.length === 0" class="py-12 text-center text-sm text-fg-subtle">
No results for "{{ query }}"
</div>
<ul v-else-if="results.length > 0" class="space-y-0.5">
@@ -107,20 +107,20 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
:to="`/${r.pkg.slug}/${r.slug}`"
:class="[
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
i === activeIndex ? 'bg-(--accent-subtle)' : 'hover:bg-(--bg-inset)',
i === activeIndex ? 'bg-accent-subtle' : 'hover:bg-bg-inset',
]"
@click="close"
@mouseenter="activeIndex = i"
>
<DocsBadge :kind="r.badge" size="sm" />
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-(--fg) truncate">{{ r.name }}</div>
<div class="text-xs text-(--fg-subtle) truncate">{{ r.pkg.name }} · {{ r.description }}</div>
<div class="text-sm font-medium text-fg truncate">{{ r.name }}</div>
<div class="text-xs text-fg-subtle truncate">{{ r.pkg.name }} · {{ r.description }}</div>
</div>
</NuxtLink>
</li>
</ul>
<div v-else class="py-12 text-center text-sm text-(--fg-subtle)">
<div v-else class="py-12 text-center text-sm text-fg-subtle">
Type to search functions, components &amp; guides
</div>
</div>
+3 -3
View File
@@ -4,10 +4,10 @@
}>();
const variantClasses: Record<string, string> = {
since: 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)',
neutral: 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)',
since: 'bg-bg-inset text-fg-muted border border-border',
neutral: 'bg-bg-inset text-fg-muted border border-border',
test: 'bg-emerald-50 text-emerald-800 border border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-500/20',
demo: 'bg-(--accent-subtle) text-(--accent-text) border border-(--accent-subtle)',
demo: 'bg-accent-subtle text-accent-text border border-accent-subtle',
wip: 'bg-amber-50 text-amber-800 border border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/20',
};
</script>
+1 -1
View File
@@ -12,7 +12,7 @@ const label = computed(() => ({
type="button"
:title="`Theme: ${label} (click to change)`"
:aria-label="`Theme: ${label}`"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset) transition-colors cursor-pointer"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-fg-muted hover:text-fg hover:bg-bg-inset transition-colors cursor-pointer"
@click="cycle"
>
<ClientOnly>
+3 -3
View File
@@ -49,7 +49,7 @@ function go(id: string) {
<div class="comment-label mb-3">
on this page
</div>
<ul class="space-y-1 border-l border-(--border)">
<ul class="space-y-1 border-l border-border">
<li v-for="item in items" :key="item.id">
<a
:href="`#${item.id}`"
@@ -57,8 +57,8 @@ function go(id: string) {
'block py-1 -ml-px border-l-2 transition-colors',
item.depth === 3 ? 'pl-6' : 'pl-4',
activeId === item.id
? 'border-(--accent) text-(--accent-text) font-medium'
: 'border-transparent text-(--fg-muted) hover:text-(--fg)',
? 'border-accent text-accent-text font-medium'
: 'border-transparent text-fg-muted hover:text-fg',
]"
@click.prevent="go(item.id)"
>
+46
View File
@@ -35,6 +35,28 @@ const GROUP_LABELS: Record<PackageGroup, string> = {
const GROUP_ORDER: PackageGroup[] = ['core', 'vue', 'configs', 'infra'];
/** Display order for component categories (unlisted categories sort last, AZ). */
const COMPONENT_CATEGORY_ORDER: string[] = [
'Forms',
'Selection',
'Color',
'Overlays',
'Menus',
'Disclosure',
'Navigation',
'Display',
'Feedback',
'Canvas & editors',
'Utilities',
'Other',
];
/** A category bucket of components, for grouped rendering. */
export interface ComponentGroup {
name: string;
components: ComponentMeta[];
}
export function useDocs() {
const data = metadata as unknown as DocsMetadata;
@@ -74,6 +96,29 @@ export function useDocs() {
return pkg.docs.filter(s => !s.isIntro);
}
/**
* A `components`-kind package's components bucketed by `category`, ordered by
* {@link COMPONENT_CATEGORY_ORDER} (unlisted categories last, AZ), with the
* components inside each bucket kept in their incoming (alphabetical) order.
*/
function getComponentGroups(pkg: PackageMeta): ComponentGroup[] {
if (pkg.kind !== 'components') return [];
const buckets = new Map<string, ComponentMeta[]>();
for (const c of pkg.components) {
const cat = c.category || 'Other';
const list = buckets.get(cat);
if (list) list.push(c);
else buckets.set(cat, [c]);
}
const rank = (name: string) => {
const i = COMPONENT_CATEGORY_ORDER.indexOf(name);
return i === -1 ? COMPONENT_CATEGORY_ORDER.length : i;
};
return [...buckets.entries()]
.map(([name, components]) => ({ name, components }))
.sort((a, b) => rank(a.name) - rank(b.name) || a.name.localeCompare(b.name));
}
/** Resolve any `/:package/:slug` route to a normalised entry. */
function resolveEntry(packageSlug: string, slug: string): DocEntry | undefined {
const pkg = getPackage(packageSlug);
@@ -157,6 +202,7 @@ export function useDocs() {
firstEntrySlug,
getIntro,
getDocSections,
getComponentGroups,
search,
getTotalItems,
};
+48 -43
View File
@@ -1,4 +1,4 @@
<script setup lang="ts">const { getGroupedPackages, getPackage, getIntro, getDocSections } = useDocs();
<script setup lang="ts">const { getGroupedPackages, getPackage, getIntro, getDocSections, getComponentGroups } = useDocs();
const groups = getGroupedPackages();
const route = useRoute();
@@ -79,11 +79,11 @@ watch(() => route.path, () => {
<template>
<div class="min-h-screen">
<!-- Header -->
<header class="sticky top-0 z-50 border-b border-(--border) backdrop-blur-md" style="background-color: var(--header-bg)">
<header class="sticky top-0 z-50 border-b border-border backdrop-blur-md" style="background-color: var(--header-bg)">
<div class="mx-auto max-w-352 flex items-center gap-3 px-4 h-14 sm:px-6">
<button
type="button"
class="lg:hidden inline-flex items-center justify-center w-9 h-9 -ml-1.5 rounded-lg text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)"
class="lg:hidden inline-flex items-center justify-center w-9 h-9 -ml-1.5 rounded-lg text-fg-muted hover:text-fg hover:bg-bg-inset"
aria-label="Toggle navigation"
@click="isSidebarOpen = !isSidebarOpen"
>
@@ -93,12 +93,12 @@ watch(() => route.path, () => {
</button>
<NuxtLink to="/" class="group flex items-center gap-2.5 mr-auto">
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-(--accent) text-(--accent-fg) font-mono text-[13px] font-semibold leading-none select-none">
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-accent text-accent-fg font-mono text-[13px] font-semibold leading-none select-none">
</span>
<span class="hidden sm:flex items-baseline font-mono text-[13.5px] tracking-tight">
<span class="text-(--fg-subtle)">~/</span><span class="text-(--fg) font-medium">robonen</span><span class="text-(--fg-subtle)">/</span><span class="text-(--accent-text) font-medium">tools</span>
<span class="ml-1 inline-block w-1.75 h-3.75 translate-y-0.5 bg-(--accent) opacity-0 group-hover:opacity-80 group-hover:animate-pulse" />
<span class="text-fg-subtle">~/</span><span class="text-fg font-medium">robonen</span><span class="text-fg-subtle">/</span><span class="text-accent-text font-medium">tools</span>
<span class="ml-1 inline-block w-1.75 h-3.75 translate-y-0.5 bg-accent opacity-0 group-hover:opacity-80 group-hover:animate-pulse" />
</span>
</NuxtLink>
@@ -108,7 +108,7 @@ watch(() => route.path, () => {
href="https://github.com/robonen/tools"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset) transition-colors"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-fg-muted hover:text-fg hover:bg-bg-inset transition-colors"
aria-label="GitHub"
>
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="currentColor">
@@ -122,7 +122,7 @@ watch(() => route.path, () => {
<!-- Sidebar -->
<aside
:class="[
'fixed inset-y-0 left-0 z-40 w-72 bg-(--bg) border-r border-(--border) pt-14 transform transition-transform lg:sticky lg:top-14 lg:z-auto lg:h-[calc(100vh-3.5rem)] lg:w-64 lg:shrink-0 lg:translate-x-0 lg:pt-0 lg:border-r-0 lg:bg-transparent',
'fixed inset-y-0 left-0 z-40 w-72 bg-bg border-r border-border pt-14 transform transition-transform lg:sticky lg:top-14 lg:z-auto lg:h-[calc(100vh-3.5rem)] lg:w-64 lg:shrink-0 lg:translate-x-0 lg:pt-0 lg:border-r-0 lg:bg-transparent',
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
]"
>
@@ -136,24 +136,24 @@ watch(() => route.path, () => {
:class="[
'flex items-center justify-between py-1.5 px-2 rounded-md text-sm transition-colors',
currentPackageSlug === pkg.slug
? 'text-(--fg) font-medium bg-(--bg-inset)'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
? 'text-fg font-medium bg-bg-inset'
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]"
>
<span class="font-mono text-[13px]">{{ pkg.name.replace('@robonen/', '') }}</span>
<span class="text-[10px] font-mono text-(--fg-subtle)">{{ pkg.kind === 'api' ? 'api' : pkg.kind === 'components' ? 'ui' : 'guide' }}</span>
<span class="text-[10px] font-mono text-fg-subtle">{{ pkg.kind === 'api' ? 'api' : pkg.kind === 'components' ? 'ui' : 'guide' }}</span>
</NuxtLink>
<!-- Expanded tree for the current package -->
<div v-if="currentPackageSlug === pkg.slug && currentPackage" class="mt-1.5 mb-3 ml-2.5 pl-2.5 border-l border-(--border)">
<div v-if="currentPackageSlug === pkg.slug && currentPackage" class="mt-1.5 mb-3 ml-2.5 pl-2.5 border-l border-border">
<!-- Quick filter the tree below collapses to matches -->
<div v-if="currentPackage.kind === 'api'" class="relative mb-2 mt-1">
<span class="absolute left-2 top-1/2 -translate-y-1/2 font-mono text-[11px] text-(--accent-text) select-none"></span>
<span class="absolute left-2 top-1/2 -translate-y-1/2 font-mono text-[11px] text-accent-text select-none"></span>
<input
v-model="navQuery"
type="text"
placeholder="filter…"
class="w-full h-7 pl-6 pr-2 font-mono text-[12px] rounded-md bg-(--bg-subtle) border border-(--border) text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none focus:border-(--border-strong) transition-colors"
class="w-full h-7 pl-6 pr-2 font-mono text-[12px] rounded-md bg-bg-subtle border border-border text-fg placeholder:text-fg-subtle focus:outline-none focus:border-border-strong transition-colors"
>
</div>
@@ -167,8 +167,8 @@ watch(() => route.path, () => {
:class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
route.path === `/${pkg.slug}`
? 'text-(--accent-text) font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
? 'text-accent-text font-medium'
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]"
>
Introduction
@@ -180,8 +180,8 @@ watch(() => route.path, () => {
:class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, s.slug)
? 'text-(--accent-text) font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
? 'text-accent-text font-medium'
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]"
>
{{ s.title }}
@@ -192,7 +192,7 @@ watch(() => route.path, () => {
<!-- api: collapsible categories -->
<template v-if="currentPackage.kind === 'api'">
<div v-if="navQuery && visibleCategories.length === 0" class="py-2 px-1 font-mono text-[11px] text-(--fg-subtle)">
<div v-if="navQuery && visibleCategories.length === 0" class="py-2 px-1 font-mono text-[11px] text-fg-subtle">
no matches
</div>
@@ -206,14 +206,14 @@ watch(() => route.path, () => {
xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
:class="[
'shrink-0 text-(--fg-subtle) transition-transform duration-150',
'shrink-0 text-fg-subtle transition-transform duration-150',
isCategoryOpen(cat.slug) ? 'rotate-90' : '',
]"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="comment-label group-hover/cat:text-(--fg-muted) transition-colors">{{ cat.name.toLowerCase() }}</span>
<span class="ml-auto font-mono text-[10px] text-(--fg-subtle) tabular-nums">{{ cat.items.length }}</span>
<span class="comment-label group-hover/cat:text-fg-muted transition-colors">{{ cat.name.toLowerCase() }}</span>
<span class="ml-auto font-mono text-[10px] text-fg-subtle tabular-nums">{{ cat.items.length }}</span>
</button>
<ul v-if="isCategoryOpen(cat.slug)" class="mb-1.5">
@@ -223,14 +223,14 @@ watch(() => route.path, () => {
:class="[
'flex items-center gap-1.5 py-0.75 px-2 text-[13px] rounded-md font-mono transition-colors',
isActive(pkg.slug, item.slug)
? 'text-(--accent-text) font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
? 'text-accent-text font-medium'
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]"
>
<span
:class="[
'shrink-0 text-[10px] select-none transition-opacity',
isActive(pkg.slug, item.slug) ? 'opacity-100 text-(--accent-text)' : 'opacity-0',
isActive(pkg.slug, item.slug) ? 'opacity-100 text-accent-text' : 'opacity-0',
]"
></span>
<span class="truncate">{{ item.name }}</span>
@@ -240,22 +240,27 @@ watch(() => route.path, () => {
</div>
</template>
<!-- components -->
<ul v-else-if="currentPackage.kind === 'components'">
<li v-for="c in currentPackage.components" :key="c.slug">
<NuxtLink
:to="`/${pkg.slug}/${c.slug}`"
:class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, c.slug)
? 'text-(--accent-text) font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
]"
>
{{ c.name }}
</NuxtLink>
</li>
</ul>
<!-- components: grouped by functional category -->
<template v-else-if="currentPackage.kind === 'components'">
<div v-for="group in getComponentGroups(currentPackage)" :key="group.name" class="mb-2">
<div class="comment-label py-1 px-1">{{ group.name.toLowerCase() }}</div>
<ul>
<li v-for="c in group.components" :key="c.slug">
<NuxtLink
:to="`/${pkg.slug}/${c.slug}`"
:class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, c.slug)
? 'text-accent-text font-medium'
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]"
>
{{ c.name }}
</NuxtLink>
</li>
</ul>
</div>
</template>
<!-- guide -->
<ul v-else>
@@ -265,8 +270,8 @@ watch(() => route.path, () => {
:class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, s.slug)
? 'text-(--accent-text) font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
? 'text-accent-text font-medium'
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]"
>
{{ s.title }}
+18 -18
View File
@@ -105,10 +105,10 @@ const sectionTitle = 'comment-label mb-3';
<div v-if="entry" class="xl:grid xl:grid-cols-[minmax(0,1fr)_14rem] xl:gap-12">
<article class="min-w-0 max-w-3xl">
<!-- Breadcrumb -->
<nav class="flex items-center gap-1.5 font-mono text-[13px] text-(--fg-subtle) mb-6">
<NuxtLink :to="`/${pkg.slug}`" class="hover:text-(--fg) transition-colors">{{ pkg.name }}</NuxtLink>
<nav class="flex items-center gap-1.5 font-mono text-[13px] text-fg-subtle mb-6">
<NuxtLink :to="`/${pkg.slug}`" class="hover:text-fg transition-colors">{{ pkg.name }}</NuxtLink>
<span>/</span>
<span class="text-(--fg)">{{ title }}</span>
<span class="text-fg">{{ title }}</span>
</nav>
<!-- API ITEM -->
@@ -116,7 +116,7 @@ const sectionTitle = 'comment-label mb-3';
<header class="mb-8">
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
<DocsBadge :kind="entry.item.kind" size="md" />
<h1 class="min-w-0 break-words text-[1.6rem] font-semibold font-mono tracking-tight text-(--fg)">{{ entry.item.name }}</h1>
<h1 class="min-w-0 break-words text-[1.6rem] font-semibold font-mono tracking-tight text-fg">{{ entry.item.name }}</h1>
<DocsTag v-if="entry.item.since" :label="`v${entry.item.since}`" variant="neutral" />
<DocsTag
v-if="entry.item.hasTests"
@@ -126,15 +126,15 @@ const sectionTitle = 'comment-label mb-3';
/>
<DocsTag v-if="entry.item.hasDemo" label="demo" variant="demo" />
</div>
<p v-if="entry.item.description" class="text-(--fg-muted) text-[15px] leading-relaxed">
<p v-if="entry.item.description" class="text-fg-muted text-[15px] leading-relaxed">
<DocsText :text="entry.item.description" />
</p>
<div class="flex items-center gap-4 mt-4 text-sm">
<a :href="ghUrl(entry.item.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-(--fg-subtle) hover:text-(--fg) transition-colors">
<a :href="ghUrl(entry.item.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-fg-subtle hover:text-fg transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg>
Source
</a>
<a v-if="entry.item.hasTests" :href="ghUrl(entry.item.sourcePath).replace('index.ts', 'index.test.ts')" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-(--fg-subtle) hover:text-(--fg) transition-colors">
<a v-if="entry.item.hasTests" :href="ghUrl(entry.item.sourcePath).replace('index.ts', 'index.test.ts')" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-fg-subtle hover:text-fg transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" /><polyline points="14 2 14 8 20 8" /><path d="m9 15 2 2 4-4" /></svg>
Tests
</a>
@@ -164,9 +164,9 @@ const sectionTitle = 'comment-label mb-3';
<h2 :class="sectionTitle">Type Parameters</h2>
<div class="space-y-1.5">
<div v-for="tp in entry.item.typeParams" :key="tp.name" class="flex items-baseline gap-2 text-sm flex-wrap">
<code class="font-mono font-medium text-(--accent-text)">{{ tp.name }}</code>
<span v-if="tp.constraint" class="text-(--fg-subtle)">extends <code class="font-mono text-xs">{{ tp.constraint }}</code></span>
<span v-if="tp.default" class="text-(--fg-subtle)">= <code class="font-mono text-xs">{{ tp.default }}</code></span>
<code class="font-mono font-medium text-accent-text">{{ tp.name }}</code>
<span v-if="tp.constraint" class="text-fg-subtle">extends <code class="font-mono text-xs">{{ tp.constraint }}</code></span>
<span v-if="tp.default" class="text-fg-subtle">= <code class="font-mono text-xs">{{ tp.default }}</code></span>
</div>
</div>
</section>
@@ -179,8 +179,8 @@ const sectionTitle = 'comment-label mb-3';
<section v-if="entry.item.returns" id="returns" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Returns</h2>
<div class="flex items-baseline gap-2 text-sm flex-wrap" :class="entry.item.returns.properties?.length ? 'mb-3' : ''">
<code class="font-mono bg-(--bg-inset) border border-(--border) px-2 py-1 rounded text-xs wrap-break-word">{{ entry.item.returns.type }}</code>
<DocsText v-if="entry.item.returns.description" :text="entry.item.returns.description" class="text-(--fg-muted)" />
<code class="font-mono bg-bg-inset border border-border px-2 py-1 rounded text-xs wrap-break-word">{{ entry.item.returns.type }}</code>
<DocsText v-if="entry.item.returns.description" :text="entry.item.returns.description" class="text-fg-muted" />
</div>
<DocsPropsTable v-if="entry.item.returns.properties?.length" :properties="entry.item.returns.properties" />
</section>
@@ -198,12 +198,12 @@ const sectionTitle = 'comment-label mb-3';
<section v-if="entry.item.relatedTypes?.length" id="related-types" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Related Types</h2>
<div class="space-y-4">
<div v-for="rt in entry.item.relatedTypes" :key="rt.name" class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4">
<div v-for="rt in entry.item.relatedTypes" :key="rt.name" class="rounded-xl border border-border bg-bg-subtle p-4">
<div class="flex items-center gap-2 mb-2">
<DocsBadge :kind="rt.kind" size="sm" />
<h3 class="font-mono font-semibold text-sm text-(--fg)">{{ rt.name }}</h3>
<h3 class="font-mono font-semibold text-sm text-fg">{{ rt.name }}</h3>
</div>
<p v-if="rt.description" class="text-sm text-(--fg-muted) mb-3">
<p v-if="rt.description" class="text-sm text-fg-muted mb-3">
<DocsText :text="rt.description" />
</p>
<DocsCode v-if="rt.signatures.length" :code="rt.signatures[0]!" />
@@ -218,14 +218,14 @@ const sectionTitle = 'comment-label mb-3';
<header class="mb-8">
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
<DocsBadge kind="component" size="md" />
<h1 class="font-display text-[1.7rem] font-bold tracking-tight text-(--fg)">{{ entry.component.name }}</h1>
<h1 class="font-display text-[1.7rem] font-bold tracking-tight text-fg">{{ entry.component.name }}</h1>
<DocsTag :label="`${entry.component.parts.length} parts`" variant="neutral" />
</div>
<p v-if="entry.component.description" class="text-(--fg-muted) text-[15px] leading-relaxed">
<p v-if="entry.component.description" class="text-fg-muted text-[15px] leading-relaxed">
<DocsText :text="entry.component.description" />
</p>
<div class="flex items-center gap-4 mt-4 text-sm">
<a :href="ghUrl(entry.component.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-(--fg-subtle) hover:text-(--fg) transition-colors">
<a :href="ghUrl(entry.component.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-fg-subtle hover:text-fg transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg>
Source
</a>
+55 -27
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">import { sections } from '#docs/sections';
const route = useRoute();
const { getPackage, countEntries, getIntro } = useDocs();
const { getPackage, countEntries, getIntro, getComponentGroups } = useDocs();
const slug = computed(() => route.params.package as string);
const pkg = computed(() => getPackage(slug.value));
@@ -51,6 +51,15 @@ function scrollToCategory(catSlug: string) {
document.getElementById(`cat-${catSlug}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ── Components: bucketed by functional category ───────────────────────────
const componentGroups = computed(() =>
pkg.value?.kind === 'components' ? getComponentGroups(pkg.value) : [],
);
function scrollToComponentGroup(name: string) {
document.getElementById(`cgrp-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// For guide packages, surface the overview section inline.
const overview = computed(() =>
pkg.value?.kind === 'guide' ? pkg.value.sections.find(s => s.slug === 'overview') : undefined,
@@ -68,13 +77,13 @@ const otherSections = computed(() =>
</section>
<!-- Auto header (shown only when there's no hand-authored intro) -->
<header v-else class="mb-8 pb-8 border-b border-(--border)">
<header v-else class="mb-8 pb-8 border-b border-border">
<div class="comment-label mb-3">{{ kindLabel.toLowerCase() }} · {{ countEntries(pkg) }} entries</div>
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
<h1 class="font-display text-3xl font-bold tracking-tight text-(--fg)">{{ pkg.name }}</h1>
<h1 class="font-display text-3xl font-bold tracking-tight text-fg">{{ pkg.name }}</h1>
<DocsTag :label="`v${pkg.version}`" variant="neutral" />
</div>
<p class="text-(--fg-muted) text-[15px] leading-relaxed">{{ pkg.description }}</p>
<p class="text-fg-muted text-[15px] leading-relaxed">{{ pkg.description }}</p>
<div class="mt-5">
<DocsCode :code="`pnpm add ${pkg.name}`" lang="bash" />
</div>
@@ -84,14 +93,14 @@ const otherSections = computed(() =>
<template v-if="pkg.kind === 'api'">
<div class="sticky top-14 z-20 -mx-2 px-2 py-3 backdrop-blur-md" style="background-color: var(--header-bg)">
<div class="relative mb-2.5">
<span class="absolute left-3 top-1/2 -translate-y-1/2 font-mono text-sm text-(--accent-text) select-none"></span>
<span class="absolute left-3 top-1/2 -translate-y-1/2 font-mono text-sm text-accent-text select-none"></span>
<input
v-model="query"
type="text"
:placeholder="`filter ${countEntries(pkg)} entries…`"
class="w-full h-10 pl-8 pr-16 font-mono text-sm rounded-md bg-(--bg-elevated) border border-(--border) text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none focus:border-(--accent) transition-colors"
class="w-full h-10 pl-8 pr-16 font-mono text-sm rounded-md bg-bg-elevated border border-border text-fg placeholder:text-fg-subtle focus:outline-none focus:border-accent transition-colors"
>
<span v-if="query" class="absolute right-3 top-1/2 -translate-y-1/2 font-mono text-[11px] text-(--fg-subtle) tabular-nums">
<span v-if="query" class="absolute right-3 top-1/2 -translate-y-1/2 font-mono text-[11px] text-fg-subtle tabular-nums">
{{ filteredCount }} hits
</span>
</div>
@@ -101,17 +110,17 @@ const otherSections = computed(() =>
v-for="category in filteredCategories"
:key="category.slug"
type="button"
class="shrink-0 inline-flex items-center gap-1.5 h-6.5 px-2.5 font-mono text-[11px] rounded-full border border-(--border) bg-(--bg-elevated) text-(--fg-muted) hover:border-(--accent) hover:text-(--accent-text) transition-colors cursor-pointer"
class="shrink-0 inline-flex items-center gap-1.5 h-6.5 px-2.5 font-mono text-[11px] rounded-full border border-border bg-bg-elevated text-fg-muted hover:border-accent hover:text-accent-text transition-colors cursor-pointer"
@click="scrollToCategory(category.slug)"
>
{{ category.name.toLowerCase() }}
<span class="text-(--fg-subtle) tabular-nums">{{ category.items.length }}</span>
<span class="text-fg-subtle tabular-nums">{{ category.items.length }}</span>
</button>
</div>
</div>
<div v-if="query && filteredCategories.length === 0" class="py-16 text-center">
<div class="font-mono text-sm text-(--fg-subtle)">// no matches for "{{ query }}"</div>
<div class="font-mono text-sm text-fg-subtle">// no matches for "{{ query }}"</div>
</div>
<section
@@ -128,48 +137,67 @@ const otherSections = computed(() =>
v-for="item in category.items"
:key="item.slug"
:to="`/${pkg.slug}/${item.slug}`"
class="group flex items-start gap-2.5 p-3 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all"
class="group flex items-start gap-2.5 p-3 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:shadow-(--shadow-card) transition-all"
>
<DocsBadge :kind="item.kind" size="sm" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5 flex-wrap">
<span class="font-mono text-[13px] font-medium text-(--fg) group-hover:text-(--accent-text) transition-colors truncate">{{ item.name }}</span>
<span class="font-mono text-[13px] font-medium text-fg group-hover:text-accent-text transition-colors truncate">{{ item.name }}</span>
<DocsTag v-if="item.hasDemo" label="demo" variant="demo" />
</div>
<p v-if="item.description" class="text-[12.5px] text-(--fg-subtle) mt-0.5 line-clamp-1">{{ item.description }}</p>
<p v-if="item.description" class="text-[12.5px] text-fg-subtle mt-0.5 line-clamp-1">{{ item.description }}</p>
</div>
</NuxtLink>
</div>
</section>
</template>
<!-- Components: gallery -->
<!-- Components: gallery grouped by functional category -->
<template v-else-if="pkg.kind === 'components'">
<section>
<!-- Category chips -->
<div class="mb-7 flex flex-wrap gap-1.5">
<button
v-for="group in componentGroups"
:key="group.name"
type="button"
class="font-mono text-[11px] px-2 py-1 rounded-md bg-bg-inset border border-border text-fg-muted hover:text-fg hover:border-border-strong transition-colors"
@click="scrollToComponentGroup(group.name)"
>
{{ group.name.toLowerCase() }}
<span class="text-fg-subtle tabular-nums">{{ group.components.length }}</span>
</button>
</div>
<section
v-for="group in componentGroups"
:id="`cgrp-${group.name}`"
:key="group.name"
class="mb-10 scroll-mt-24"
>
<h2 class="comment-label mb-4">
all components · {{ pkg.components.length }}
{{ group.name.toLowerCase() }} · {{ group.components.length }}
</h2>
<div class="stagger grid grid-cols-1 gap-3 sm:grid-cols-2">
<NuxtLink
v-for="c in pkg.components"
v-for="c in group.components"
:key="c.slug"
:to="`/${pkg.slug}/${c.slug}`"
class="group block p-4 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all"
class="group block p-4 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:shadow-(--shadow-card) transition-all"
>
<div class="flex items-center justify-between gap-2 mb-1.5">
<span class="font-semibold text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ c.name }}</span>
<span class="font-mono text-[11px] text-(--fg-subtle) tabular-nums">{{ c.parts.length }} parts</span>
<span class="font-semibold text-fg group-hover:text-accent-text transition-colors">{{ c.name }}</span>
<span class="font-mono text-[11px] text-fg-subtle tabular-nums">{{ c.parts.length }} parts</span>
</div>
<p v-if="c.description" class="text-sm text-(--fg-subtle) line-clamp-2">{{ c.description }}</p>
<p v-if="c.description" class="text-sm text-fg-subtle line-clamp-2">{{ c.description }}</p>
<div class="mt-3 flex flex-wrap gap-1">
<span
v-for="part in c.parts.slice(0, 4)"
:key="part.name"
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-(--bg-inset) border border-(--border) text-(--fg-subtle)"
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-bg-inset border border-border text-fg-subtle"
>
{{ part.role }}
</span>
<span v-if="c.parts.length > 4" class="text-[10px] font-mono text-(--fg-subtle) px-1">+{{ c.parts.length - 4 }}</span>
<span v-if="c.parts.length > 4" class="text-[10px] font-mono text-fg-subtle px-1">+{{ c.parts.length - 4 }}</span>
</div>
</NuxtLink>
</div>
@@ -179,17 +207,17 @@ const otherSections = computed(() =>
<!-- Guide: overview markdown + section links -->
<template v-else>
<DocsMarkdown v-if="overview" :source="overview.markdown" />
<section v-if="otherSections.length > 0" class="mt-10 pt-8 border-t border-(--border)">
<section v-if="otherSections.length > 0" class="mt-10 pt-8 border-t border-border">
<h2 class="comment-label mb-4">sections</h2>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<NuxtLink
v-for="s in otherSections"
:key="s.slug"
:to="`/${pkg.slug}/${s.slug}`"
class="group flex items-center justify-between gap-3 p-3.5 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:bg-(--bg-subtle) transition-all"
class="group flex items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:bg-bg-subtle transition-all"
>
<span class="text-sm font-medium text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ s.title }}</span>
<span class="font-mono text-[11px] text-(--fg-subtle) group-hover:text-(--accent-text) transition-colors"></span>
<span class="text-sm font-medium text-fg group-hover:text-accent-text transition-colors">{{ s.title }}</span>
<span class="font-mono text-[11px] text-fg-subtle group-hover:text-accent-text transition-colors"></span>
</NuxtLink>
</div>
</section>
+17 -17
View File
@@ -20,21 +20,21 @@ useHead({ title: '@robonen/tools — Documentation' });
<div class="comment-label mb-5">field manual · generated from source &amp; jsdoc</div>
<h1 class="font-display text-5xl sm:text-6xl font-bold tracking-tight text-(--fg) mb-5 text-balance">
Tools, documented<span class="text-(--accent)">.</span>
<h1 class="font-display text-5xl sm:text-6xl font-bold tracking-tight text-fg mb-5 text-balance">
Tools, documented<span class="text-accent">.</span>
</h1>
<p class="text-lg text-(--fg-muted) leading-relaxed max-w-2xl">
<p class="text-lg text-fg-muted leading-relaxed max-w-2xl">
A monorepo of TypeScript utilities, Vue composables, headless UI primitives
and shared tooling typed, tested and demoed in place.
</p>
<div class="mt-7 inline-flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[13px] text-(--fg-subtle) border border-(--border) rounded-md bg-(--bg-elevated) px-3 py-2">
<span class="text-(--accent-text)"></span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ packages.length }}</span> packages</span>
<span class="text-(--border-strong)">·</span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ totalItems }}</span> documented items</span>
<span class="text-(--border-strong)">·</span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ groups.length }}</span> groups</span>
<div class="mt-7 inline-flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[13px] text-fg-subtle border border-border rounded-md bg-bg-elevated px-3 py-2">
<span class="text-accent-text"></span>
<span><span class="text-fg font-medium tabular-nums">{{ packages.length }}</span> packages</span>
<span class="text-border-strong">·</span>
<span><span class="text-fg font-medium tabular-nums">{{ totalItems }}</span> documented items</span>
<span class="text-border-strong">·</span>
<span><span class="text-fg font-medium tabular-nums">{{ groups.length }}</span> groups</span>
</div>
</section>
@@ -46,29 +46,29 @@ useHead({ title: '@robonen/tools — Documentation' });
v-for="pkg in grp.packages"
:key="pkg.slug"
:to="`/${pkg.slug}`"
class="group relative block p-5 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all overflow-hidden"
class="group relative block p-5 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:shadow-(--shadow-card) transition-all overflow-hidden"
>
<!-- Corner notch fills in on hover like an indicator lamp -->
<span
class="absolute right-0 top-0 w-2 h-2 bg-(--accent) opacity-0 group-hover:opacity-100 transition-opacity"
class="absolute right-0 top-0 w-2 h-2 bg-accent opacity-0 group-hover:opacity-100 transition-opacity"
style="clip-path: polygon(100% 0, 0 0, 100% 100%)"
aria-hidden="true"
/>
<div class="flex items-start justify-between gap-3 mb-2">
<h3 class="font-mono text-sm font-semibold text-(--fg) group-hover:text-(--accent-text) transition-colors">
<h3 class="font-mono text-sm font-semibold text-fg group-hover:text-accent-text transition-colors">
{{ pkg.name }}
</h3>
<span class="font-mono text-[10px] px-1.5 py-0.5 rounded border border-(--border) bg-(--bg-subtle) text-(--fg-subtle) leading-none shrink-0">
<span class="font-mono text-[10px] px-1.5 py-0.5 rounded border border-border bg-bg-subtle text-fg-subtle leading-none shrink-0">
{{ kindLabels[pkg.kind] }}
</span>
</div>
<p class="text-sm text-(--fg-muted) leading-relaxed line-clamp-2">
<p class="text-sm text-fg-muted leading-relaxed line-clamp-2">
{{ pkg.description }}
</p>
<div class="mt-4 flex items-center gap-2 font-mono text-[11px] text-(--fg-subtle)">
<div class="mt-4 flex items-center gap-2 font-mono text-[11px] text-fg-subtle">
<span>v{{ pkg.version }}</span>
<span class="text-(--border-strong)">·</span>
<span class="text-border-strong">·</span>
<span class="tabular-nums">{{ countEntries(pkg) }} {{ pkg.kind === 'components' ? 'components' : pkg.kind === 'guide' ? 'sections' : 'items' }}</span>
</div>
</NuxtLink>
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
import { defineCollection, defineContentConfig } from '@nuxt/content';
const repositories = [
'../configs/tsconfig',
'../core/stdlib',
'../core/platform',
'../infra/renovate',
'../web/vue',
];
export default defineContentConfig({
collections: repositories.reduce((acc, repo) => {
const name = repo.split('/').pop();
acc[name] = defineCollection({
source: {
include: `**/*.md`,
exclude: ['**/node_modules/**', '**/dist/**'],
cwd: repo,
},
type: 'page',
});
return acc;
}, {}),
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { base, compose, imports, stylistic, typescript, vue } from '@robonen/eslint';
import { base, compose, imports, stylistic, tests, typescript, vue } from '@robonen/eslint';
export default compose(base, typescript, vue, imports, stylistic, {
name: 'docs/build-scripts',
@@ -7,4 +7,4 @@ export default compose(base, typescript, vue, imports, stylistic, {
/* Build-time tooling (doc extractor) logs progress to the console. */
'no-console': 'off',
},
});
}, tests);
+111 -65
View File
@@ -88,7 +88,7 @@ const PACKAGES: PackageConfig[] = [
{ path: 'core/crdt', slug: 'crdt', kind: 'api', group: 'core' },
// ── vue ──
{ path: 'vue/toolkit', slug: 'vue', kind: 'api', group: 'vue' },
{ path: 'vue/editor', slug: 'editor', kind: 'api', group: 'vue' },
{ path: 'vue/writekit', slug: 'writekit', kind: 'api', group: 'vue' },
{ path: 'vue/primitives', slug: 'primitives', kind: 'components', group: 'vue' },
// ── configs ──
{ path: 'configs/eslint', slug: 'eslint', kind: 'guide', group: 'configs', guideSources: ['README.md', 'rules/*.md'] },
@@ -98,6 +98,27 @@ const PACKAGES: PackageConfig[] = [
{ path: 'infra/renovate', slug: 'renovate', kind: 'guide', group: 'infra', guideSources: ['README.md'] },
];
/**
* Display label for each category FOLDER under `src/`. Components now live at
* `src/<category>/<component>/`, so the folder is the source of truth for a
* component's category. Unlisted folders fall back to `toPascalCase(folder)`.
* The display order of categories lives in `useDocs` (`COMPONENT_CATEGORY_ORDER`).
*/
const CATEGORY_LABELS: Record<string, string> = {
forms: 'Forms',
selection: 'Selection',
color: 'Color',
overlays: 'Overlays',
menus: 'Menus',
disclosure: 'Disclosure',
navigation: 'Navigation',
display: 'Display',
feedback: 'Feedback',
canvas: 'Canvas & editors',
utilities: 'Utilities',
internal: 'Internal',
};
// ── Helpers ────────────────────────────────────────────────────────────────
function toKebabCase(str: string): string {
@@ -716,14 +737,14 @@ function inferCategoryFromItem(item: ItemMeta): string {
}
/** Resolve a package's export subpaths to source entry files. */
function resolveEntryPoints(pkgDir: string, exportsField: Record<string, any>): Array<{ subpath: string; filePath: string }> {
function resolveEntryPoints(pkgDir: string, exportsField: Record<string, unknown>): Array<{ subpath: string; filePath: string }> {
const entryPoints: Array<{ subpath: string; filePath: string }> = [];
for (const [subpath, value] of Object.entries(exportsField)) {
if (typeof value !== 'object' || value === null) continue;
let entry: any = (value as Record<string, any>).import ?? (value as Record<string, any>).types;
if (typeof entry === 'object' && entry !== null) entry = entry.types || entry.default;
let entry: unknown = (value as Record<string, unknown>).import ?? (value as Record<string, unknown>).types;
if (typeof entry === 'object' && entry !== null) entry = (entry as Record<string, unknown>).types || (entry as Record<string, unknown>).default;
if (!entry || typeof entry !== 'string') continue;
// Wildcard exports (e.g. "./*") can't be resolved to a single file here.
if (entry.includes('*')) continue;
@@ -942,75 +963,100 @@ function roleFromName(componentName: string, base: string): string {
return role || 'Root';
}
/**
* Build a single component group from its directory, or `null` when the dir is
* not a component group (no `.vue`). `category` is the display label; `entryPoint`
* is the package subpath (e.g. `./forms/checkbox`).
*/
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string): ComponentMeta | null {
// A component group is any dir that ships at least one .vue file.
const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
if (vueFiles.length === 0) return null;
const base = toPascalCase(slug);
// Anatomy = the PUBLIC parts exported from index.ts, in declared order. This
// excludes demo.vue and internal parts (*Impl, *Modal/NonModal, *Position, …)
// that aren't part of the public API. Fall back to all .vue (minus demo) only
// when the barrel exposes no parseable `export { default as X }`.
const order = readPartOrder(resolve(dir, 'index.ts'));
const publicFiles = order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f));
const candidates = publicFiles.length > 0
? publicFiles
: vueFiles.filter(f => f !== 'demo.vue');
// Drop internal implementation/variant parts users never compose directly
// (the public part is e.g. `Content`, not `ContentImpl`/`ContentModal`).
const INTERNAL_PART = /(?:Impl|ContentModal|ContentNonModal|RootContentModal|RootContentNonModal|Position)\.vue$/;
const orderedFiles = candidates.filter(f => !INTERNAL_PART.test(f));
const parts: ComponentPartMeta[] = [];
let groupDescription = '';
for (const file of orderedFiles) {
const sfc = readFileSync(resolve(dir, file), 'utf-8');
const plain = extractScriptBlock(sfc, false);
const setup = extractScriptBlock(sfc, true);
const { props, description } = extractPartProps(plain);
const name = file.replace(/\.vue$/, '');
const role = roleFromName(name, base);
if (role === 'Root' && description && !groupDescription) groupDescription = description;
// Merge in `defineModel` v-model props/emits (invisible to the interface/
// defineEmits parsers), de-duping against any explicitly-declared ones.
const models = extractModels(setup);
const emits = extractEmits(setup);
for (const mp of models.props)
if (!props.some(p => p.name === mp.name)) props.push(mp);
for (const me of models.emits)
if (!emits.some(e => e.name === me.name)) emits.push(me);
parts.push({ name, role, description, props, emits });
}
return {
name: base,
slug,
category,
description: groupDescription,
entryPoint,
parts,
hasDemo: existsSync(resolve(dir, 'demo.vue')),
demoSource: '', // loaded lazily client-side via #docs/demo-sources
sourcePath: relative(ROOT, dir),
};
}
function buildComponents(pkgDir: string): ComponentMeta[] {
const srcDir = resolve(pkgDir, 'src');
if (!existsSync(srcDir)) return [];
const components: ComponentMeta[] = [];
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = resolve(srcDir, entry.name);
// Components live one level deep, in category folders: src/<category>/<component>/.
// The category folder IS the source of truth for the component's category.
for (const catEntry of readdirSync(srcDir, { withFileTypes: true })) {
if (!catEntry.isDirectory()) continue;
const catDir = resolve(srcDir, catEntry.name);
const label = CATEGORY_LABELS[catEntry.name];
// A component group is any dir that ships at least one .vue file.
const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
if (vueFiles.length === 0) continue;
const slug = entry.name;
const base = toPascalCase(slug);
// Anatomy = the PUBLIC parts exported from index.ts, in declared order. This
// excludes demo.vue and internal parts (*Impl, *Modal/NonModal, *Position, …)
// that aren't part of the public API. Fall back to all .vue (minus demo) only
// when the barrel exposes no parseable `export { default as X }`.
const order = readPartOrder(resolve(dir, 'index.ts'));
const publicFiles = order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f));
const candidates = publicFiles.length > 0
? publicFiles
: vueFiles.filter(f => f !== 'demo.vue');
// Drop internal implementation/variant parts users never compose directly
// (the public part is e.g. `Content`, not `ContentImpl`/`ContentModal`).
const INTERNAL_PART = /(?:Impl|ContentModal|ContentNonModal|RootContentModal|RootContentNonModal|Position)\.vue$/;
const orderedFiles = candidates.filter(f => !INTERNAL_PART.test(f));
const parts: ComponentPartMeta[] = [];
let groupDescription = '';
for (const file of orderedFiles) {
const sfc = readFileSync(resolve(dir, file), 'utf-8');
const plain = extractScriptBlock(sfc, false);
const setup = extractScriptBlock(sfc, true);
const { props, description } = extractPartProps(plain);
const name = file.replace(/\.vue$/, '');
const role = roleFromName(name, base);
if (role === 'Root' && description && !groupDescription) groupDescription = description;
// Merge in `defineModel` v-model props/emits (invisible to the interface/
// defineEmits parsers), de-duping against any explicitly-declared ones.
const models = extractModels(setup);
const emits = extractEmits(setup);
for (const mp of models.props)
if (!props.some(p => p.name === mp.name)) props.push(mp);
for (const me of models.emits)
if (!emits.some(e => e.name === me.name)) emits.push(me);
parts.push({ name, role, description, props, emits });
if (label) {
// A known category folder — each child dir is a component group.
for (const compEntry of readdirSync(catDir, { withFileTypes: true })) {
if (!compEntry.isDirectory()) continue;
const c = buildComponentAt(
resolve(catDir, compEntry.name),
compEntry.name,
label,
`./${catEntry.name}/${compEntry.name}`,
);
if (c) components.push(c);
}
}
else {
// Backward-compat: a flat component dir directly under src.
const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`);
if (c) components.push(c);
}
const entryPoint = `./${slug}`;
const demoPath = resolve(dir, 'demo.vue');
const hasDemo = existsSync(demoPath);
components.push({
name: base,
slug,
description: groupDescription,
entryPoint,
parts,
hasDemo,
demoSource: '', // loaded lazily client-side via #docs/demo-sources
sourcePath: relative(ROOT, dir),
});
}
return components.sort((a, b) => a.name.localeCompare(b.name));
+8 -2
View File
@@ -44,7 +44,7 @@ export default defineNuxtModule({
'@robonen/fetch': 'core/fetch/src',
'@robonen/encoding': 'core/encoding/src',
'@robonen/crdt': 'core/crdt/src',
'@robonen/editor': 'vue/editor/src',
'@robonen/writekit': 'vue/writekit/src',
'@robonen/primitives': 'vue/primitives/src',
'@robonen/vue': vueSrc,
};
@@ -58,7 +58,13 @@ export default defineNuxtModule({
// Primitive `as="template"` / Slot path), silently blanking every demo
// that hits it. `import.meta.env.DEV` resolves correctly in dev & prod.
config.define ??= {};
(config.define as Record<string, unknown>).__DEV__ ??= 'import.meta.env.DEV';
// Inline a STATIC boolean, not `import.meta.env.DEV`: a define value is
// inserted verbatim and is NOT re-scanned for Vite's `import.meta.env`
// replacement, so in a prod build it shipped a literal `import.meta.env.DEV`
// into chunks where `import.meta.env` is undefined at runtime →
// "Cannot read properties of undefined (reading 'DEV')". A literal
// true/false has no runtime dependency and tree-shakes the dev branches.
(config.define as Record<string, unknown>).__DEV__ ??= JSON.stringify(nuxt.options.dev);
const existing = config.resolve?.alias;
const sourceAliases = [
+2
View File
@@ -115,6 +115,8 @@ export interface ComponentMeta {
name: string;
/** URL-friendly slug, e.g. "accordion" */
slug: string;
/** Functional category for grouping in the docs, e.g. "Forms", "Overlays". */
category: string;
/** Short description (from README heading or first JSDoc) */
description: string;
/** Subpath export, e.g. "./accordion" */
+7 -7
View File
@@ -159,15 +159,15 @@ describe('getPackage / resolveEntry', () => {
describe('slug uniqueness & collisions', () => {
// A function and a co-located type/interface whose names differ only in case
// both slugify to the same value — the real extractor produces these in
// @robonen/editor and @robonen/vue.
// @robonen/writekit and @robonen/vue.
const colliding: DocsMetadata = {
generatedAt: '2026-06-08T00:00:00.000Z',
packages: [
{
name: '@robonen/editor',
name: '@robonen/writekit',
version: '1.0.0',
description: 'Editor',
slug: 'editor',
description: 'Writekit',
slug: 'writekit',
kind: 'api',
group: 'vue',
entryPoints: ['.'],
@@ -197,12 +197,12 @@ describe('slug uniqueness & collisions', () => {
it('reaches both colliding symbols — function and interface — independently', () => {
const leaves = buildLeaves(colliding);
// Exact case-sensitive name disambiguates the function from the interface.
const fn = resolveEntry(leaves, 'editor', 'position');
const iface = resolveEntry(leaves, 'editor', 'Position');
const fn = resolveEntry(leaves, 'writekit', 'position');
const iface = resolveEntry(leaves, 'writekit', 'Position');
expect(fn?.kind === 'api' && fn.item.kind).toBe('function');
expect(iface?.kind === 'api' && iface.item.kind).toBe('interface');
// The disambiguated slug also resolves the interface directly.
const bySlug = resolveEntry(leaves, 'editor', 'position-interface');
const bySlug = resolveEntry(leaves, 'writekit', 'position-interface');
expect(bySlug?.kind === 'api' && bySlug.item.kind).toBe('interface');
});
+2
View File
@@ -20,6 +20,8 @@ export default defineNuxtConfig({
vite: {
plugins: [
// `as any`: @tailwindcss/vite and Nuxt resolve different `vite` versions, so
// their `Plugin` types are structurally identical but nominally incompatible.
tailwindcss() as any,
],
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Disallow:
+86 -83
View File
@@ -299,89 +299,6 @@ importers:
specifier: ^43.216.1
version: 43.216.1(typanion@3.14.0)
vue/editor:
dependencies:
'@floating-ui/vue':
specifier: ^1.1.11
version: 1.1.11(vue@3.5.35(typescript@6.0.3))
'@robonen/crdt':
specifier: workspace:*
version: link:../../core/crdt
'@robonen/platform':
specifier: workspace:*
version: link:../../core/platform
'@robonen/stdlib':
specifier: workspace:*
version: link:../../core/stdlib
'@vue/shared':
specifier: 'catalog:'
version: 3.5.35
vue:
specifier: 'catalog:'
version: 3.5.35(typescript@6.0.3)
devDependencies:
'@robonen/eslint':
specifier: workspace:*
version: link:../../configs/eslint
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../configs/tsconfig
'@robonen/tsdown':
specifier: workspace:*
version: link:../../configs/tsdown
'@vitest/browser':
specifier: 'catalog:'
version: 4.1.8(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/browser-playwright':
specifier: ^4.1.8
version: 4.1.8(playwright@1.60.0)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8)
'@vue/test-utils':
specifier: 'catalog:'
version: 2.4.11(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))
eslint:
specifier: 'catalog:'
version: 10.4.1(jiti@2.7.0)
jsdom:
specifier: 'catalog:'
version: 29.1.1
playwright:
specifier: ^1.60.0
version: 1.60.0
tsdown:
specifier: 'catalog:'
version: 0.22.2(oxc-resolver@11.20.0)(typescript@6.0.3)(unrun@0.2.33)(vue-tsc@3.3.4(typescript@6.0.3))
unplugin-vue:
specifier: ^7.2.0
version: 7.2.0(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(vue@3.5.35(typescript@6.0.3))(yaml@2.9.0)
vitest-browser-vue:
specifier: ^2.1.0
version: 2.1.0(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3)))(vitest@4.1.8)(vue@3.5.35(typescript@6.0.3))
vue-tsc:
specifier: ^3.3.4
version: 3.3.4(typescript@6.0.3)
vue/editor/playground:
dependencies:
'@robonen/editor':
specifier: workspace:*
version: link:..
vue:
specifier: 'catalog:'
version: 3.5.35(typescript@6.0.3)
devDependencies:
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../../configs/tsconfig
'@vitejs/plugin-vue':
specifier: ^6.0.7
version: 6.0.7(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vue@3.5.35(typescript@6.0.3))
vite:
specifier: ^8.0.16
version: 8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0)
vue-tsc:
specifier: ^3.3.4
version: 3.3.4(typescript@6.0.3)
vue/primitives:
dependencies:
'@floating-ui/vue':
@@ -548,6 +465,92 @@ importers:
specifier: 'catalog:'
version: 0.22.2(oxc-resolver@11.20.0)(typescript@6.0.3)(unrun@0.2.33)(vue-tsc@3.2.6(typescript@6.0.3))
vue/writekit:
dependencies:
'@robonen/crdt':
specifier: workspace:*
version: link:../../core/crdt
'@robonen/platform':
specifier: workspace:*
version: link:../../core/platform
'@robonen/primitives':
specifier: workspace:*
version: link:../primitives
'@robonen/stdlib':
specifier: workspace:*
version: link:../../core/stdlib
'@robonen/vue':
specifier: workspace:*
version: link:../toolkit
'@vue/shared':
specifier: 'catalog:'
version: 3.5.35
vue:
specifier: 'catalog:'
version: 3.5.35(typescript@6.0.3)
devDependencies:
'@robonen/eslint':
specifier: workspace:*
version: link:../../configs/eslint
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../configs/tsconfig
'@robonen/tsdown':
specifier: workspace:*
version: link:../../configs/tsdown
'@vitest/browser':
specifier: 'catalog:'
version: 4.1.8(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/browser-playwright':
specifier: ^4.1.8
version: 4.1.8(playwright@1.60.0)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8)
'@vue/test-utils':
specifier: 'catalog:'
version: 2.4.11(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))
eslint:
specifier: 'catalog:'
version: 10.4.1(jiti@2.7.0)
jsdom:
specifier: 'catalog:'
version: 29.1.1
playwright:
specifier: ^1.60.0
version: 1.60.0
tsdown:
specifier: 'catalog:'
version: 0.22.2(oxc-resolver@11.20.0)(typescript@6.0.3)(unrun@0.2.33)(vue-tsc@3.3.4(typescript@6.0.3))
unplugin-vue:
specifier: ^7.2.0
version: 7.2.0(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(vue@3.5.35(typescript@6.0.3))(yaml@2.9.0)
vitest-browser-vue:
specifier: ^2.1.0
version: 2.1.0(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3)))(vitest@4.1.8)(vue@3.5.35(typescript@6.0.3))
vue-tsc:
specifier: ^3.3.4
version: 3.3.4(typescript@6.0.3)
vue/writekit/playground:
dependencies:
'@robonen/writekit':
specifier: workspace:*
version: link:..
vue:
specifier: 'catalog:'
version: 3.5.35(typescript@6.0.3)
devDependencies:
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../../configs/tsconfig
'@vitejs/plugin-vue':
specifier: ^6.0.7
version: 6.0.7(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vue@3.5.35(typescript@6.0.3))
vite:
specifier: ^8.0.16
version: 8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0)
vue-tsc:
specifier: ^3.3.4
version: 3.3.4(typescript@6.0.3)
packages:
'@adobe/css-tools@4.4.4':
-443
View File
@@ -1,443 +0,0 @@
<!-- title: Playground -->
<!-- order: 1 -->
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { EditorDocument, Inline, InlineNode, Node, RemoteCursor } from '../src';
import {
bindCrdt,
createDefaultRegistry,
createDoc,
createEditor,
createEditorState,
createNativeProvider,
createNode,
EditorBubbleMenu,
EditorContent,
EditorRemoteCursors,
EditorRoot,
EditorSlashMenu,
isBlockActive,
isMarkActive,
setBlockType,
toggleBlockType,
toggleMark,
} from '../src';
// Content helpers
function t(text: string, ...markTypes: string[]): InlineNode {
return { text, marks: markTypes.map(type => ({ type })) };
}
function p(content: string | Inline = ''): Node {
const inline = typeof content === 'string' ? (content ? [t(content)] : []) : content;
return createNode('paragraph', { content: inline });
}
const heading = (level: number, text: string): Node => createNode('heading', { attrs: { level }, content: text ? [t(text)] : [] });
const quote = (text: string): Node => createNode('blockquote', { content: [t(text)] });
const codeBlock = (text: string): Node => createNode('code-block', { content: [t(text)] });
const callout = (variant: string, text: string): Node => createNode('callout', { attrs: { variant }, content: [t(text)] });
const bullet = (text: string): Node => createNode('bulleted-list', { attrs: { indent: 0 }, content: [t(text)] });
const numbered = (text: string): Node => createNode('numbered-list', { attrs: { indent: 0 }, content: [t(text)] });
const todo = (text: string, checked = false): Node => createNode('todo-list', { attrs: { checked, indent: 0 }, content: [t(text)] });
const divider = (): Node => createNode('divider');
/** Visible text of a document (for word count / convergence check). */
function docText(doc: EditorDocument): string {
return doc.content
.map((block) => {
const c = block.content as unknown;
return Array.isArray(c) ? c.map(run => (run && typeof run === 'object' && 'text' in run ? String((run as InlineNode).text) : '')).join('') : '';
})
.join('\n');
}
// Tabs
const tabs = [
{ id: 'editor', label: 'Rich text & blocks' },
{ id: 'collab', label: 'Multiplayer' },
] as const;
const tab = ref<'editor' | 'collab'>('editor');
const registry = createDefaultRegistry();
// Tab 1: the rich editor (drag-to-reorder + live output)
const editor = createEditor({
state: createEditorState({
registry,
doc: createDoc([
heading(1, 'Try the editor'),
p([
t('A headless, block-based rich-text editor for Vue. This line mixes '),
t('bold', 'bold'), t(', '), t('italic', 'italic'), t(', '), t('code', 'code'),
t(' and '), t('highlight', 'highlight'), t('.'),
]),
p('Hover a block and drag the ⠿ handle on its left to reorder. Select text for the bubble menu, or type “/” on an empty line for the block menu.'),
heading(2, 'Blocks'),
bullet('Bulleted lists'),
numbered('Numbered lists'),
todo('A checkable to-do item', false),
quote('Block quotes for asides and citations.'),
callout('info', 'Callouts highlight tips and notes.'),
codeBlock('const editor = createEditor(createEditorState({ registry, doc }))'),
divider(),
p(''),
]),
}),
});
const rev = ref(0);
const bump = (): void => void (rev.value += 1);
editor.on('transaction', bump);
onBeforeUnmount(() => editor.off('transaction', bump));
const boldActive = computed(() => (rev.value, isMarkActive(editor.state, 'bold')));
const italicActive = computed(() => (rev.value, isMarkActive(editor.state, 'italic')));
const codeActive = computed(() => (rev.value, isMarkActive(editor.state, 'code')));
const highlightActive = computed(() => (rev.value, isMarkActive(editor.state, 'highlight')));
const h1Active = computed(() => (rev.value, isBlockActive(editor.state, 'heading', { level: 1 })));
const h2Active = computed(() => (rev.value, isBlockActive(editor.state, 'heading', { level: 2 })));
const quoteActive = computed(() => (rev.value, isBlockActive(editor.state, 'blockquote')));
const canUndo = computed(() => (rev.value, editor.canUndo()));
const canRedo = computed(() => (rev.value, editor.canRedo()));
// Live output
const showJson = ref(false);
const blockCount = computed(() => (rev.value, editor.state.doc.content.length));
const wordCount = computed(() => (rev.value, docText(editor.state.doc).trim().split(/\s+/).filter(Boolean).length));
const sid = (id: string): string => id.slice(0, 4);
const selectionSummary = computed(() => {
void rev.value;
const s = editor.state.selection;
if (s.kind === 'text')
return `text · ${sid(s.anchor.blockId)}:${s.anchor.offset}${sid(s.focus.blockId)}:${s.focus.offset}`;
return `node · ${s.ids.length} block${s.ids.length === 1 ? '' : 's'}`;
});
const docJson = computed(() => (rev.value, JSON.stringify(editor.state.doc, null, 2)));
// Tab 2: two CRDT replicas, synced in memory (multiplayer)
const seed = createDoc([
heading(1, 'Shared document'),
p('Edit in either pane — each is its own @robonen/crdt replica. Concurrent edits converge and you see the other cursor.'),
p(''),
]);
const editorA = createEditor({ state: createEditorState({ registry, doc: seed }) });
const providerA = createNativeProvider({ schema: registry.schema, doc: editorA.state.doc, user: { name: 'Alice', color: '#2563eb' } });
const editorB = createEditor({ state: createEditorState({ registry }) });
const providerB = createNativeProvider({ schema: registry.schema, user: { name: 'Bob', color: '#db2777' } });
const bindingA = bindCrdt(editorA, providerA);
const bindingB = bindCrdt(editorB, providerB);
providerB.applyUpdate(providerA.encodeDelta());
// In-memory transport with a "Connected" switch: while offline, ops queue and
// the docs diverge; reconnecting flushes them and they converge.
const connected = ref(true);
let queueAB: Uint8Array[] = [];
let queueBA: Uint8Array[] = [];
const offOpsA = providerA.onLocalOps((bytes) => {
if (connected.value) providerB.applyUpdate(bytes);
else queueAB.push(bytes);
});
const offOpsB = providerB.onLocalOps((bytes) => {
if (connected.value) providerA.applyUpdate(bytes);
else queueBA.push(bytes);
});
watch(connected, (on) => {
if (!on) return;
for (const b of queueAB) providerB.applyUpdate(b);
for (const b of queueBA) providerA.applyUpdate(b);
queueAB = [];
queueBA = [];
});
const cursorsA = ref<RemoteCursor[]>([]);
const cursorsB = ref<RemoteCursor[]>([]);
const offCurA = providerA.onAwareness(c => (cursorsA.value = c));
const offCurB = providerB.onAwareness(c => (cursorsB.value = c));
const offAwA = providerA.onLocalAwareness(bytes => connected.value && providerB.applyAwareness(bytes));
const offAwB = providerB.onLocalAwareness(bytes => connected.value && providerA.applyAwareness(bytes));
const collabRev = ref(0);
const bumpCollab = (): void => void (collabRev.value += 1);
editorA.on('transaction', bumpCollab);
editorB.on('transaction', bumpCollab);
const inSync = computed(() => (collabRev.value, docText(editorA.state.doc) === docText(editorB.state.doc)));
onBeforeUnmount(() => {
for (const off of [offOpsA, offOpsB, offCurA, offCurB, offAwA, offAwB]) off();
editorA.off('transaction', bumpCollab);
editorB.off('transaction', bumpCollab);
bindingA.detach();
bindingB.detach();
});
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>Playground</h1>
<p>
Live <code>@robonen/editor</code> instances built with the default registry the real
headless controller, single-contenteditable view, and CRDT-backed model from the API
reference. Switch tabs to explore the capabilities.
</p>
</div>
<!-- Tabs -->
<div class="ed-tabs" role="tablist">
<button
v-for="tb in tabs"
:key="tb.id"
type="button"
role="tab"
:aria-selected="tab === tb.id"
:class="['ed-tab', { 'ed-tab-active': tab === tb.id }]"
@click="tab = tb.id"
>
{{ tb.label }}
</button>
</div>
<ClientOnly>
<!-- Rich text & blocks -->
<div v-show="tab === 'editor'" class="editor-demo">
<div class="editor-demo-toolbar">
<button type="button" title="Bold" :data-active="boldActive || undefined" @mousedown.prevent="editor.command(toggleMark('bold'))"><b>B</b></button>
<button type="button" title="Italic" :data-active="italicActive || undefined" @mousedown.prevent="editor.command(toggleMark('italic'))"><i>I</i></button>
<button type="button" title="Inline code" :data-active="codeActive || undefined" @mousedown.prevent="editor.command(toggleMark('code'))"><code>&lt;&gt;</code></button>
<button type="button" title="Highlight" :data-active="highlightActive || undefined" @mousedown.prevent="editor.command(toggleMark('highlight'))">H</button>
<span class="sep" />
<button type="button" title="Heading 1" :data-active="h1Active || undefined" @mousedown.prevent="editor.command(toggleBlockType('heading', { level: 1 }))">H1</button>
<button type="button" title="Heading 2" :data-active="h2Active || undefined" @mousedown.prevent="editor.command(toggleBlockType('heading', { level: 2 }))">H2</button>
<button type="button" title="Quote" :data-active="quoteActive || undefined" @mousedown.prevent="editor.command(toggleBlockType('blockquote'))"></button>
<button type="button" title="Paragraph" @mousedown.prevent="editor.command(setBlockType('paragraph'))"></button>
<span class="sep" />
<button type="button" title="Undo" :disabled="!canUndo" @mousedown.prevent="editor.undo()"></button>
<button type="button" title="Redo" :disabled="!canRedo" @mousedown.prevent="editor.redo()"></button>
</div>
<EditorRoot :editor="editor" draggable class="editor-demo-root">
<EditorContent />
<EditorBubbleMenu />
<EditorSlashMenu />
</EditorRoot>
<!-- Live output -->
<div class="ed-output">
<div class="ed-stats">
<span><b>{{ blockCount }}</b> blocks</span>
<span><b>{{ wordCount }}</b> words</span>
<span class="ed-sel">selection: <code>{{ selectionSummary }}</code></span>
<button type="button" class="ed-json-toggle" @click="showJson = !showJson">
{{ showJson ? 'Hide' : 'Show' }} document JSON
</button>
</div>
<pre v-if="showJson" class="ed-json">{{ docJson }}</pre>
</div>
</div>
<!-- Multiplayer -->
<div v-show="tab === 'collab'" class="editor-demo">
<div class="ed-collab-bar">
<span class="ed-peer"><span class="ed-dot" style="background:#2563eb" />Alice</span>
<span class="ed-peer"><span class="ed-dot" style="background:#db2777" />Bob</span>
<span class="ed-spacer" />
<span :class="['ed-sync', inSync ? 'ed-sync-ok' : 'ed-sync-pending']">
{{ inSync ? 'in sync' : 'diverged' }}
</span>
<button type="button" :class="['ed-conn', connected ? 'ed-conn-on' : 'ed-conn-off']" @click="connected = !connected">
{{ connected ? 'Connected' : 'Offline' }}
</button>
</div>
<div class="ed-collab-grid">
<EditorRoot :editor="editorA" draggable class="editor-demo-root collab">
<EditorContent />
<EditorRemoteCursors :cursors="cursorsA" />
<EditorBubbleMenu />
<EditorSlashMenu />
</EditorRoot>
<EditorRoot :editor="editorB" draggable class="editor-demo-root collab">
<EditorContent />
<EditorRemoteCursors :cursors="cursorsB" />
<EditorBubbleMenu />
<EditorSlashMenu />
</EditorRoot>
</div>
<p class="ed-hint">
Each pane is a separate CRDT replica synced over an in-memory channel. Toggle
<b>Offline</b>, edit both sides so they diverge, then reconnect the replicas
converge automatically (no Yjs).
</p>
</div>
<template #fallback>
<div class="flex min-h-72 items-center justify-center gap-2 rounded-xl border border-(--border) bg-(--bg-subtle) text-sm text-(--fg-subtle)">
<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Loading editor
</div>
</template>
</ClientOnly>
<div class="prose-docs">
<h2>How it's wired</h2>
<p>
The editor is created from a registry and a document, then rendered with a single
<code>EditorRoot</code>. Multiplayer is just two editors, each bound to its own CRDT
replica with <code>bindCrdt</code>, exchanging ops over any transport.
</p>
</div>
<DocsCode
lang="ts"
:code="`import {
EditorRoot, EditorContent, EditorRemoteCursors,
createDefaultRegistry, createDoc, createEditor, createEditorState,
createNativeProvider, bindCrdt,
} from '@robonen/editor';
const registry = createDefaultRegistry();
const editor = createEditor({ state: createEditorState({ registry, doc: createDoc(blocks) }) });
// Collaboration: bind the editor to a CRDT replica and pipe ops to peers.
const provider = createNativeProvider({ schema: registry.schema, user: { name: 'Alice' } });
bindCrdt(editor, provider);
provider.onLocalOps(bytes => socket.send(bytes)); // any transport
socket.onmessage = bytes => provider.applyUpdate(bytes);`"
/>
<div class="prose-docs">
<p>
See <NuxtLink to="/editor/create-editor">createEditor</NuxtLink>,
<NuxtLink to="/editor/bind-crdt">bindCrdt</NuxtLink> and
<NuxtLink to="/editor/toggle-mark">toggleMark</NuxtLink> in the API reference for the full surface.
</p>
</div>
</div>
</template>
<style>
/* Unscoped on purpose: the editor renders its own DOM (and teleports menus to
<body>), so scoped styles can't reach them. Selectors are namespaced under
`.editor-demo*`, `.ed-*` and the editor's own classes to avoid leaking. */
.editor-demo { counter-reset: editor-demo-ol; }
/* tabs */
.ed-tabs { display: flex; gap: 4px; margin-bottom: 0.75rem; }
.ed-tab { padding: 6px 12px; border: 1px solid var(--border); background: var(--bg-elevated); color: var(--fg-muted); border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500; transition: background 0.12s, color 0.12s, border-color 0.12s; }
.ed-tab:hover { background: var(--bg-inset); color: var(--fg); }
.ed-tab-active { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
/* toolbar */
.editor-demo-toolbar { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; padding: 6px; border: 1px solid var(--border); border-bottom: 0; border-radius: 12px 12px 0 0; background: var(--bg-subtle); }
.editor-demo-toolbar button { display: inline-flex; align-items: center; justify-content: center; min-width: 32px; height: 30px; padding: 0 9px; border: 1px solid var(--border); background: var(--bg-elevated); color: var(--fg); border-radius: 7px; cursor: pointer; font-size: 13px; line-height: 1; transition: background 0.12s, border-color 0.12s; }
.editor-demo-toolbar button code { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; }
.editor-demo-toolbar button:hover { border-color: var(--border-strong); background: var(--bg-inset); }
.editor-demo-toolbar button:disabled { opacity: 0.4; cursor: not-allowed; }
.editor-demo-toolbar button[data-active] { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
.editor-demo-toolbar .sep { width: 1px; height: 18px; background: var(--border); margin: 0 4px; }
/* editable surface */
.editor-demo-root { border: 1px solid var(--border); border-radius: 0 0 12px 12px; padding: 1rem 1.25rem 1rem 2rem; min-height: 280px; background: var(--bg); color: var(--fg); }
.editor-demo-root, .editor-demo-root [data-editor-content] { outline: none; }
.editor-demo-root:focus-within { border-color: var(--accent); }
.editor-demo-root [data-block-id] { position: relative; }
.editor-demo-root [data-block-content] { outline: none; margin: 0.45em 0; line-height: 1.7; }
.editor-demo-root h1[data-block-content], .editor-demo-root h2[data-block-content], .editor-demo-root h3[data-block-content] { margin: 0.7em 0 0.3em; line-height: 1.3; font-weight: 700; letter-spacing: -0.01em; }
.editor-demo-root h1[data-block-content] { font-size: 1.6rem; }
.editor-demo-root h2[data-block-content] { font-size: 1.3rem; }
.editor-demo-root h3[data-block-content] { font-size: 1.1rem; }
.editor-demo-root [data-block-content][data-empty]::before { content: attr(data-placeholder); color: var(--fg-subtle); pointer-events: none; }
/* inline marks */
.editor-demo-root [data-block-content] strong { font-weight: 700; }
.editor-demo-root [data-block-content] em { font-style: italic; }
.editor-demo-root [data-block-content] u { text-decoration: underline; }
.editor-demo-root [data-block-content] s, .editor-demo-root [data-block-content] del { text-decoration: line-through; }
.editor-demo-root [data-block-content] mark { background: rgba(245, 200, 66, 0.4); color: inherit; border-radius: 2px; padding: 0 0.1em; }
.editor-demo-root [data-block-content] code { background: var(--bg-inset); border: 1px solid var(--border); padding: 0.05em 0.35em; border-radius: 4px; font-family: var(--font-mono, ui-monospace, monospace); font-size: 0.9em; }
.editor-demo-root [data-block-content] a { color: var(--accent-text); text-decoration: underline; cursor: pointer; }
.editor-demo-root blockquote[data-block-content] { border-left: 3px solid var(--border-strong); padding-left: 1rem; color: var(--fg-muted); font-style: italic; }
.editor-demo-root pre[data-block-content] { background: var(--bg-inset); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; font-family: var(--font-mono, ui-monospace, monospace); font-size: 0.85rem; white-space: pre-wrap; }
/* callouts */
.editor-demo-root [data-callout] { position: relative; border-radius: 8px; margin: 0.5em 0; padding: 0.6rem 0.8rem 0.6rem 2.4rem; border: 1px solid var(--border); background: var(--bg-subtle); }
.editor-demo-root [data-callout]::before { position: absolute; left: 0.8rem; }
.editor-demo-root [data-callout='info']::before { content: '️'; }
.editor-demo-root [data-callout='warn']::before { content: '⚠️'; }
.editor-demo-root [data-callout='success']::before { content: '✅'; }
/* lists */
.editor-demo-root [data-list] { position: relative; padding-left: 1.6em; }
.editor-demo-root [data-list]::before { position: absolute; left: 0.35em; color: var(--fg-muted); }
.editor-demo-root [data-list='bullet']::before { content: '•'; }
.editor-demo-root [data-list='ordered'] { counter-increment: editor-demo-ol; }
.editor-demo-root [data-list='ordered']::before { content: counter(editor-demo-ol) '.'; }
.editor-demo-root [data-list='todo']::before { content: '☐'; }
.editor-demo-root [data-list='todo'][data-checked='true']::before { content: '☑'; }
.editor-demo-root [data-list='todo'][data-checked='true'] { color: var(--fg-subtle); text-decoration: line-through; }
.editor-demo-root [data-editor-divider] { border: 0; border-top: 2px solid var(--border); margin: 1em 0; }
/* selection */
.editor-demo-root ::selection { background: var(--accent-subtle); }
.editor-demo-root [data-block-content][data-selected], .editor-demo-root [data-block-id][data-selected] { background: var(--accent-subtle); border-radius: 4px; }
/* drag-to-reorder handle */
.editor-demo-root .editor-drag-handle { position: absolute; left: -1.4em; top: 0.2em; cursor: grab; color: var(--fg-subtle); user-select: none; opacity: 0; transition: opacity 0.1s; line-height: 1.4; }
.editor-demo-root [data-block-id]:hover > .editor-drag-handle { opacity: 1; }
.editor-demo-root .editor-drag-handle:hover { color: var(--fg-muted); }
.editor-demo-root .editor-drag-handle:active { cursor: grabbing; }
/* output panel */
.ed-output { margin-top: 0.75rem; }
.ed-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1rem; font-size: 13px; color: var(--fg-muted); }
.ed-stats b { color: var(--fg); font-variant-numeric: tabular-nums; }
.ed-stats code { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; color: var(--accent-text); }
.ed-sel { min-width: 0; }
.ed-json-toggle { margin-left: auto; border: 1px solid var(--border); background: var(--bg-elevated); color: var(--fg-muted); border-radius: 7px; padding: 4px 10px; font-size: 12px; cursor: pointer; transition: background 0.12s, color 0.12s; }
.ed-json-toggle:hover { background: var(--bg-inset); color: var(--fg); }
.ed-json { margin-top: 0.6rem; max-height: 320px; overflow: auto; background: var(--bg-inset); border: 1px solid var(--border); border-radius: 10px; padding: 0.9rem 1rem; font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; line-height: 1.6; color: var(--fg-muted); white-space: pre; }
/* multiplayer */
.ed-collab-bar { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.6rem; font-size: 13px; }
.ed-peer { display: inline-flex; align-items: center; gap: 6px; color: var(--fg-muted); font-weight: 500; }
.ed-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
.ed-spacer { flex: 1; }
.ed-sync { font-size: 12px; font-weight: 600; border-radius: 999px; padding: 2px 10px; }
.ed-sync-ok { color: var(--accent-text); background: var(--accent-subtle); }
.ed-sync-pending { color: #b45309; background: rgba(245, 158, 11, 0.15); }
.ed-conn { border: 1px solid var(--border); border-radius: 8px; padding: 4px 12px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background 0.12s, color 0.12s, border-color 0.12s; }
.ed-conn-on { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
.ed-conn-off { background: var(--bg-elevated); color: var(--fg-muted); }
.ed-collab-grid { display: grid; grid-template-columns: 1fr; gap: 0.75rem; }
@media (min-width: 720px) { .ed-collab-grid { grid-template-columns: 1fr 1fr; } }
.ed-collab-grid .editor-demo-root { border-radius: 12px; min-height: 200px; }
.editor-demo-root.collab { position: relative; }
.ed-hint { margin-top: 0.6rem; font-size: 13px; color: var(--fg-subtle); }
/* remote cursors (component sets --cursor-color per peer) */
.editor-remote-cursors { position: absolute; inset: 0; pointer-events: none; overflow: visible; z-index: 4; }
.editor-remote-selection { position: absolute; background: var(--cursor-color); opacity: 0.22; border-radius: 2px; }
.editor-remote-caret { position: absolute; width: 2px; background: var(--cursor-color); }
.editor-remote-caret-label { position: absolute; top: -1.05em; left: -1px; font-size: 10px; line-height: 1; white-space: nowrap; color: #fff; background: var(--cursor-color); padding: 1px 4px; border-radius: 3px 3px 3px 0; }
/* floating menus (teleported to <body>) */
.editor-bubble-menu { display: flex; gap: 2px; background: var(--bg-elevated); border: 1px solid var(--border-strong); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); z-index: 60; }
.editor-bubble-menu button { min-width: 30px; height: 28px; padding: 0 8px; border: 0; background: transparent; color: var(--fg-muted); border-radius: 5px; cursor: pointer; font-size: 13px; text-transform: capitalize; }
.editor-bubble-menu button:hover { background: var(--bg-inset); color: var(--fg); }
.editor-bubble-menu button[data-active] { background: var(--accent); color: var(--accent-fg); }
.editor-slash-menu { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 10px; padding: 4px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16); width: 240px; max-height: 300px; overflow: auto; z-index: 60; }
.editor-slash-menu button { display: flex; justify-content: space-between; align-items: baseline; width: 100%; text-align: left; border: 0; background: transparent; padding: 7px 10px; border-radius: 7px; cursor: pointer; font-size: 14px; color: var(--fg); }
.editor-slash-menu button[data-highlighted] { background: var(--bg-inset); }
.editor-slash-menu .slash-group { font-size: 11px; color: var(--fg-subtle); text-transform: capitalize; }
</style>
-156
View File
@@ -1,156 +0,0 @@
<script setup lang="ts">
import { shallowRef } from 'vue';
import CollabDemo from './demos/CollabDemo.vue';
import CommandsDemo from './demos/CommandsDemo.vue';
import ComplexBlocksDemo from './demos/ComplexBlocksDemo.vue';
import CustomKeymapDemo from './demos/CustomKeymapDemo.vue';
import ManyBlocksDemo from './demos/ManyBlocksDemo.vue';
import MarksDemo from './demos/MarksDemo.vue';
import MultiEditorDemo from './demos/MultiEditorDemo.vue';
import ReadOnlyDemo from './demos/ReadOnlyDemo.vue';
import RichTextDemo from './demos/RichTextDemo.vue';
const demos = [
{ id: 'rich', title: 'Rich text', component: RichTextDemo },
{ id: 'complex', title: 'Complex blocks', component: ComplexBlocksDemo },
{ id: 'collab', title: 'Collaboration', component: CollabDemo },
{ id: 'marks', title: 'Inline marks', component: MarksDemo },
{ id: 'many', title: 'Many blocks', component: ManyBlocksDemo },
{ id: 'multi', title: 'Multiple editors', component: MultiEditorDemo },
{ id: 'readonly', title: 'Read-only', component: ReadOnlyDemo },
{ id: 'commands', title: 'Commands API', component: CommandsDemo },
{ id: 'keymap', title: 'Custom keymap', component: CustomKeymapDemo },
];
const current = shallowRef(demos[0]!);
</script>
<template>
<div class="layout">
<nav class="sidebar">
<h1>@robonen/editor</h1>
<button
v-for="demo in demos"
:key="demo.id"
:class="{ active: demo.id === current.id }"
@click="current = demo"
>
{{ demo.title }}
</button>
</nav>
<main class="content">
<component :is="current.component" :key="current.id" />
</main>
</div>
</template>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; color: #1a1a1a; background: #fafafa; }
.layout { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
.sidebar { border-right: 1px solid #e5e5e5; padding: 1rem; background: #fff; position: sticky; top: 0; height: 100vh; }
.sidebar h1 { font-size: 14px; margin: 0 0 1rem; color: #666; }
.sidebar button { display: block; width: 100%; text-align: left; padding: 8px 10px; margin-bottom: 2px; border: 0; background: transparent; border-radius: 6px; cursor: pointer; font-size: 14px; color: #333; }
.sidebar button:hover { background: #f0f0f0; }
.sidebar button.active { background: #1a1a1a; color: #fff; }
.content { padding: 2rem; max-width: 880px; }
.content section > h2 { margin: 0 0 0.25rem; }
.hint { color: #888; font-size: 13px; margin: 0 0 1rem; }
.toolbar { display: flex; gap: 4px; align-items: center; margin-bottom: 0.75rem; }
.toolbar.wrap { flex-wrap: wrap; }
.toolbar button { min-width: 32px; height: 32px; padding: 0 8px; border: 1px solid #ddd; background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; }
.toolbar button:hover { border-color: #bbb; }
.toolbar button:disabled { opacity: 0.4; cursor: default; }
.toolbar button[data-active] { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
.toolbar .sep { width: 1px; height: 20px; background: #ddd; margin: 0 4px; }
.editor { border: 1px solid #e5e5e5; border-radius: 8px; padding: 1rem 1.25rem; min-height: 120px; background: #fff; }
.editor:focus-within { border-color: #999; }
.editor.scroll { max-height: 420px; overflow: auto; }
.editor [data-block-content] { outline: none; margin: 0.4em 0; line-height: 1.6; }
.editor [data-block-content]:is(h1, h2, h3, h4, h5, h6) { margin: 0.6em 0 0.3em; line-height: 1.3; }
.editor [data-block-type='heading'] [data-block-content] { font-weight: 700; }
.editor [data-block-content][data-empty]::before { content: attr(data-placeholder); color: #bbb; pointer-events: none; }
.editor [data-block-content] strong { font-weight: 700; }
.editor [data-block-content] em { font-style: italic; }
.editor ::selection { background: #b3d4fc; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
details { margin-top: 1rem; }
summary { cursor: pointer; color: #666; font-size: 13px; }
details pre { background: #f6f6f6; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 12px; max-height: 300px; }
/* inline marks */
.editor [data-block-content] mark { background: #fde68a; border-radius: 2px; }
.editor [data-block-content] code { background: #eef0f2; padding: 0.1em 0.35em; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
.editor [data-block-content] a { color: #2563eb; text-decoration: underline; cursor: pointer; }
/* blockquote */
.editor blockquote[data-block-content] { border-left: 3px solid #ddd; padding-left: 1rem; color: #555; font-style: italic; }
/* code block */
.editor pre[data-block-content] { background: #f6f8fa; border: 1px solid #eaecef; border-radius: 6px; padding: 0.75rem 1rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; white-space: pre-wrap; }
/* callout */
.editor [data-callout] { position: relative; border-radius: 8px; margin: 0.5em 0; padding: 0.6rem 0.8rem 0.6rem 2.4rem; }
.editor [data-callout]::before { position: absolute; left: 0.8rem; }
.editor [data-callout='info'] { background: #eef4ff; } .editor [data-callout='info']::before { content: '️'; }
.editor [data-callout='warn'] { background: #fff6e6; } .editor [data-callout='warn']::before { content: '⚠️'; }
.editor [data-callout='success'] { background: #ecfdf3; } .editor [data-callout='success']::before { content: '✅'; }
/* lists (flat-with-indent; marker in the gutter, indent via inline margin-left) */
.editor { counter-reset: editor-ol; }
.editor [data-list] { position: relative; }
.editor [data-list]::before { position: absolute; left: 0.1em; color: #555; }
.editor [data-list='bullet']::before { content: '•'; }
.editor [data-list='ordered'] { counter-increment: editor-ol; }
.editor [data-list='ordered']::before { content: counter(editor-ol) '.'; }
.editor [data-list='todo']::before { content: '☐'; }
.editor [data-list='todo'][data-checked='true']::before { content: '☑'; }
.editor [data-list='todo'][data-checked='true'] { color: #999; text-decoration: line-through; }
/* atoms: image + divider */
.editor [data-editor-image] { margin: 0.8em 0; text-align: center; }
.editor [data-editor-image] img { max-width: 100%; border-radius: 8px; }
.editor [data-editor-image] figcaption { color: #888; font-size: 13px; margin-top: 4px; }
.editor [data-editor-image] .image-placeholder { background: #f3f3f3; border: 1px dashed #ccc; border-radius: 8px; padding: 1.5rem; color: #999; }
.editor [data-editor-image] .image-fields { display: flex; flex-direction: column; gap: 4px; margin: 6px auto 0; max-width: 360px; }
.editor [data-editor-image] .image-fields input { padding: 4px 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
.editor [data-editor-divider] { border: 0; border-top: 2px solid #e5e5e5; margin: 1em 0; }
/* node selection highlight */
.editor [data-block-type='image'][data-selected], .editor [data-block-type='divider'][data-selected] { outline: 2px solid #2563eb; outline-offset: 2px; border-radius: 6px; }
.editor [data-block-content][data-selected], .editor [data-block-id][data-selected]:not([data-block-type='image']):not([data-block-type='divider']) { background: rgba(37, 99, 235, 0.08); border-radius: 4px; }
/* floating menus (teleported to body) */
.editor-bubble-menu { display: flex; gap: 2px; background: #1a1a1a; border-radius: 8px; padding: 4px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); z-index: 50; }
.editor-bubble-menu button { min-width: 30px; height: 28px; padding: 0 8px; border: 0; background: transparent; color: #fff; border-radius: 5px; cursor: pointer; font-size: 13px; text-transform: capitalize; }
.editor-bubble-menu button:hover { background: rgba(255, 255, 255, 0.15); }
.editor-bubble-menu button[data-active] { background: #fff; color: #1a1a1a; }
.editor-slash-menu { background: #fff; border: 1px solid #e5e5e5; border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); width: 230px; max-height: 280px; overflow: auto; z-index: 50; }
.editor-slash-menu button { display: flex; justify-content: space-between; align-items: baseline; width: 100%; text-align: left; border: 0; background: transparent; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 14px; color: #333; }
.editor-slash-menu button[data-highlighted] { background: #f0f0f0; }
.editor-slash-menu .slash-group { font-size: 11px; color: #aaa; text-transform: capitalize; }
kbd { background: #eee; border: 1px solid #ddd; border-radius: 4px; padding: 1px 5px; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
/* drag-to-reorder handle */
.editor [data-block-id] { position: relative; }
.editor-drag-handle { position: absolute; left: -1.2em; top: 0.25em; cursor: grab; color: #ccc; user-select: none; opacity: 0; transition: opacity 0.1s; line-height: 1.4; }
.editor [data-block-id]:hover > .editor-drag-handle { opacity: 1; }
.editor-drag-handle:hover { color: #888; }
.editor-drag-handle:active { cursor: grabbing; }
/* remote collaboration cursors */
.editor.collab { position: relative; }
.editor-remote-cursors { position: absolute; inset: 0; pointer-events: none; overflow: visible; z-index: 4; }
.editor-remote-selection { position: absolute; background: var(--cursor-color); opacity: 0.22; border-radius: 2px; }
.editor-remote-caret { position: absolute; width: 2px; background: var(--cursor-color); }
.editor-remote-caret-label { position: absolute; top: -1.05em; left: -1px; font-size: 10px; line-height: 1; white-space: nowrap; color: #fff; background: var(--cursor-color); padding: 1px 4px; border-radius: 3px 3px 3px 0; }
</style>
-34
View File
@@ -1,34 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import type { Editor } from '@editor';
import { isBlockActive, isMarkActive, setBlockType, toggleBlockType, toggleMark } from '@editor';
const { editor } = defineProps<{ editor: Editor }>();
// Re-evaluate active-states on every transaction.
const rev = ref(0);
const bump = (): void => void (rev.value += 1);
editor.on('transaction', bump);
onBeforeUnmount(() => editor.off('transaction', bump));
const boldActive = computed(() => (rev.value, isMarkActive(editor.state, 'bold')));
const italicActive = computed(() => (rev.value, isMarkActive(editor.state, 'italic')));
const h1Active = computed(() => (rev.value, isBlockActive(editor.state, 'heading', { level: 1 })));
const h2Active = computed(() => (rev.value, isBlockActive(editor.state, 'heading', { level: 2 })));
const canUndo = computed(() => (rev.value, editor.canUndo()));
const canRedo = computed(() => (rev.value, editor.canRedo()));
</script>
<template>
<div class="toolbar">
<button :data-active="boldActive || undefined" @mousedown.prevent="editor.command(toggleMark('bold'))"><b>B</b></button>
<button :data-active="italicActive || undefined" @mousedown.prevent="editor.command(toggleMark('italic'))"><i>I</i></button>
<span class="sep" />
<button :data-active="h1Active || undefined" @mousedown.prevent="editor.command(toggleBlockType('heading', { level: 1 }))">H1</button>
<button :data-active="h2Active || undefined" @mousedown.prevent="editor.command(toggleBlockType('heading', { level: 2 }))">H2</button>
<button @mousedown.prevent="editor.command(setBlockType('paragraph'))">P</button>
<span class="sep" />
<button :disabled="!canUndo" @mousedown.prevent="editor.undo()">Undo</button>
<button :disabled="!canRedo" @mousedown.prevent="editor.redo()">Redo</button>
</div>
</template>
@@ -1,62 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
EditorRoot,
createNode,
createTransaction,
moveBlockDown,
moveBlockUp,
removeBlock,
setBlockType,
toggleMark,
} from '@editor';
import { h, makeEditor, p } from '../lib';
const editor = makeEditor([
h(1, 'Commands API'),
p('Drive the editor programmatically with the buttons below. Put the caret in a block first.'),
p('Second block.'),
p('Third block.'),
]);
const rev = ref(0);
editor.on('transaction', () => (rev.value += 1));
const docJson = computed(() => (rev.value, JSON.stringify(editor.state.doc, null, 2)));
const canDelete = computed(() => (rev.value, editor.state.doc.content.length > 1));
function focusId(): string | undefined {
const sel = editor.state.selection;
return sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
}
function appendParagraph(): void {
const node = createNode('paragraph', { content: [{ text: 'Appended block', marks: [] }] });
editor.dispatch(createTransaction(editor.state).insertBlock(node, editor.state.doc.content.length));
}
function deleteFocused(): void {
const id = focusId();
if (id && editor.state.doc.content.length > 1)
editor.command(removeBlock(id));
}
</script>
<template>
<section>
<h2>Commands API</h2>
<p class="hint">Programmatic control every button is a command or transaction on the editor.</p>
<div class="toolbar wrap">
<button @mousedown.prevent="appendParagraph">Append paragraph</button>
<button @mousedown.prevent="editor.command(moveBlockUp)">Move block </button>
<button @mousedown.prevent="editor.command(moveBlockDown)">Move block </button>
<button @mousedown.prevent="editor.command(setBlockType('heading', { level: 1 }))"> H1</button>
<button @mousedown.prevent="editor.command(setBlockType('paragraph'))"> Paragraph</button>
<button @mousedown.prevent="editor.command(toggleMark('bold'))">Toggle bold</button>
<button :disabled="!canDelete" @mousedown.prevent="deleteFocused">Delete block</button>
</div>
<EditorRoot :editor="editor" class="editor" />
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section>
</template>
@@ -1,102 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { Node } from '@editor';
import {
EditorBubbleMenu,
EditorContent,
EditorRoot,
EditorSlashMenu,
addMark,
createTransaction,
nodeSelection,
setBlockType,
toggleChecked,
toggleMark,
} from '@editor';
import { bullet, callout, code, divider, h, image, makeEditor, numbered, p, quote, t, todo } from '../lib';
const editor = makeEditor([
h(1, 'Complex blocks'),
p([t('A document with '), t('many', 'bold'), t(' block types. Put the caret in a block and use the controls to convert it, or insert media.')]),
quote('“The block is the unit of composition.” — a registry-driven editor.'),
callout('info', 'Callouts carry a variant attribute. This one is "info".'),
callout('warn', 'And this is a "warn" callout.'),
code('function hello() {\n // Enter inserts a newline here, not a block split\n return \'code block\';\n}'),
h(2, 'Lists'),
bullet('Bulleted item one'),
bullet('Nested bullet (indent = 1) — Tab / Shift+Tab changes indent', 1),
bullet('Bulleted item two'),
numbered('Numbered item (counter via CSS)'),
numbered('Numbered item'),
todo('A finished task', true),
todo('A pending task', false),
h(2, 'Media (atoms)'),
image('https://picsum.photos/seed/robonen/520/240', 'A random sample image'),
divider(),
p('Click an image or divider to select it, then Backspace/Delete removes it. Selecting an image reveals its URL / alt / caption fields.'),
]);
const rev = ref(0);
editor.on('transaction', () => (rev.value += 1));
const docJson = computed(() => (rev.value, JSON.stringify(editor.state.doc, null, 2)));
function focusIndex(): number {
const sel = editor.state.selection;
const id = sel.kind === 'text' ? sel.focus.blockId : sel.ids[0];
const index = id ? editor.state.doc.content.findIndex(block => block.id === id) : -1;
return index === -1 ? editor.state.doc.content.length - 1 : index;
}
function insertAfterFocus(node: Node): void {
editor.dispatch(createTransaction(editor.state).insertBlock(node, focusIndex() + 1).setSelection(nodeSelection([node.id])));
}
function addLink(): void {
const href = globalThis.prompt('Link URL', 'https://');
if (href)
editor.command(addMark('link', { href }));
}
</script>
<template>
<section>
<h2>Complex blocks</h2>
<p class="hint">Quote, callout, code block, lists (bulleted / numbered / to-do), image &amp; divider atoms, plus the full mark set. Everything is registry-driven.</p>
<div class="toolbar wrap">
<button @mousedown.prevent="editor.command(toggleMark('bold'))"><b>B</b></button>
<button @mousedown.prevent="editor.command(toggleMark('italic'))"><i>I</i></button>
<button @mousedown.prevent="editor.command(toggleMark('underline'))"><u>U</u></button>
<button @mousedown.prevent="editor.command(toggleMark('strike'))"><s>S</s></button>
<button @mousedown.prevent="editor.command(toggleMark('code'))">&lt;/&gt;</button>
<button @mousedown.prevent="editor.command(toggleMark('highlight'))">HL</button>
<button @mousedown.prevent="addLink">Link</button>
<span class="sep" />
<button @mousedown.prevent="editor.command(setBlockType('paragraph'))">P</button>
<button @mousedown.prevent="editor.command(setBlockType('heading', { level: 1 }))">H1</button>
<button @mousedown.prevent="editor.command(setBlockType('heading', { level: 2 }))">H2</button>
<button @mousedown.prevent="editor.command(setBlockType('blockquote'))">Quote</button>
<button @mousedown.prevent="editor.command(setBlockType('code-block'))">Code</button>
<button @mousedown.prevent="editor.command(setBlockType('callout', { variant: 'info' }))">Callout</button>
<span class="sep" />
<button @mousedown.prevent="editor.command(setBlockType('bulleted-list'))"> List</button>
<button @mousedown.prevent="editor.command(setBlockType('numbered-list'))">1. List</button>
<button @mousedown.prevent="editor.command(setBlockType('todo-list', { checked: false, indent: 0 }))"> Todo</button>
<button @mousedown.prevent="editor.command(toggleChecked)">Toggle </button>
<span class="sep" />
<button @mousedown.prevent="insertAfterFocus(image('', ''))">+ Image</button>
<button @mousedown.prevent="insertAfterFocus(divider())">+ Divider</button>
<span class="sep" />
<button @mousedown.prevent="editor.undo()">Undo</button>
<button @mousedown.prevent="editor.redo()">Redo</button>
</div>
<p class="hint">Type <kbd>/</kbd> to insert a block; select text for the bubble toolbar; hover a block and drag the <span aria-hidden="true"></span> handle to reorder. Markdown shortcuts work too: <kbd># </kbd>, <kbd>- </kbd>, <kbd>&gt; </kbd>, <kbd>1. </kbd>, <kbd>[] </kbd>.</p>
<EditorRoot :editor="editor" autofocus draggable class="editor">
<EditorContent />
<EditorBubbleMenu />
<EditorSlashMenu />
</EditorRoot>
<details><summary>document JSON</summary><pre>{{ docJson }}</pre></details>
</section>
</template>
@@ -1,25 +0,0 @@
<script setup lang="ts">
import { EditorRoot } from '@editor';
import { h, makeEditor, p } from '../lib';
import Toolbar from '../Toolbar.vue';
const left = makeEditor([h(2, 'Editor A'), p('Type and select here.')]);
const right = makeEditor([h(2, 'Editor B'), p('This editor is fully independent.')]);
</script>
<template>
<section>
<h2>Multiple editors</h2>
<p class="hint">Two independent editors on one page selection and editing in one must never affect the other.</p>
<div class="cols">
<div>
<Toolbar :editor="left" />
<EditorRoot :editor="left" class="editor" />
</div>
<div>
<Toolbar :editor="right" />
<EditorRoot :editor="right" class="editor" />
</div>
</div>
</section>
</template>
@@ -1,54 +0,0 @@
import { describe, expect, it } from 'vitest';
import { caret, createDoc, createNode, nodeInline, nodeText, textSelection } from '../../model';
import { createDefaultRegistry } from '../../preset';
import { createEditor, createEditorState } from '../../state';
import { joinBackward, splitBlock, toggleMark } from '..';
function para(id: string, text: string) {
return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
}
function editorWith(blocks: Array<ReturnType<typeof para>>, selection?: ReturnType<typeof caret>) {
const registry = createDefaultRegistry();
return createEditor({ state: createEditorState({ registry, doc: createDoc(blocks), selection }) });
}
describe('commands', () => {
it('toggleMark applies then removes bold on a range', () => {
const registry = createDefaultRegistry();
const editor = createEditor({
state: createEditorState({
registry,
doc: createDoc([para('a', 'abc')]),
selection: textSelection({ blockId: 'a', offset: 0 }, { blockId: 'a', offset: 3 }),
}),
});
expect(editor.command(toggleMark('bold'))).toBe(true);
expect(nodeInline(editor.state.doc.content[0]!)).toEqual([{ text: 'abc', marks: [{ type: 'bold' }] }]);
editor.command(toggleMark('bold'));
expect(nodeInline(editor.state.doc.content[0]!)).toEqual([{ text: 'abc', marks: [] }]);
});
it('splitBlock splits at the caret', () => {
const editor = editorWith([para('a', 'hello')], caret('a', 2));
expect(editor.command(splitBlock)).toBe(true);
expect(editor.state.doc.content.map(block => nodeText(block))).toEqual(['he', 'llo']);
expect(editor.state.selection.kind).toBe('text');
});
it('joinBackward merges into the previous block', () => {
const editor = editorWith([para('a', 'foo'), para('b', 'bar')], caret('b', 0));
expect(editor.command(joinBackward)).toBe(true);
expect(editor.state.doc.content.map(block => nodeText(block))).toEqual(['foobar']);
});
it('undo restores the document after a split', () => {
const editor = editorWith([para('a', 'hello')], caret('a', 2));
editor.command(splitBlock);
expect(editor.state.doc.content.length).toBe(2);
expect(editor.undo()).toBe(true);
expect(editor.state.doc.content.map(block => nodeText(block))).toEqual(['hello']);
});
});
-2
View File
@@ -1,2 +0,0 @@
export { useContextFactory } from './useContextFactory';
export { useEventListener } from './useEventListener';
@@ -1,31 +0,0 @@
import type { App, InjectionKey } from 'vue';
import { inject as vueInject, provide as vueProvide } from 'vue';
/**
* Factory for a strongly-typed provide/inject pair keyed by a unique Symbol.
* Local copy of the `@robonen/vue` helper so the editor stays self-contained.
*/
export function useContextFactory<ContextValue>(name: string) {
const injectionKey: InjectionKey<ContextValue> = Symbol(name);
const inject = <Fallback extends ContextValue = ContextValue>(fallback?: Fallback) => {
const context = vueInject(injectionKey, fallback);
if (context !== undefined)
return context;
throw new Error(`useContextFactory: '${name}' context is not provided`);
};
const provide = (context: ContextValue) => {
vueProvide(injectionKey, context);
return context;
};
const appProvide = (app: App) => (context: ContextValue) => {
app.provide(injectionKey, context);
return context;
};
return { inject, provide, appProvide, key: injectionKey };
}
@@ -1,38 +0,0 @@
import type { MaybeRefOrGetter } from 'vue';
import { onScopeDispose, toValue, watch } from 'vue';
type ListenerTarget = Window | Document | HTMLElement | null | undefined;
/**
* Attach an event listener to a (possibly reactive) target, re-binding when the
* target changes and cleaning up on scope dispose. Minimal local replacement for
* the `@robonen/vue` composable.
*/
export function useEventListener(
target: MaybeRefOrGetter<ListenerTarget>,
event: string,
handler: (event: Event) => void,
options?: boolean | AddEventListenerOptions,
): () => void {
let detach = (): void => {};
const stopWatch = watch(
() => toValue(target),
(el) => {
detach();
if (!el)
return;
el.addEventListener(event, handler, options);
detach = () => el.removeEventListener(event, handler, options);
},
{ immediate: true, flush: 'post' },
);
const stop = (): void => {
detach();
stopWatch();
};
onScopeDispose(stop);
return stop;
}
@@ -1,30 +0,0 @@
import type { AllowedComponentProps, Component, IntrinsicElementAttributes, SetupContext, VNodeProps } from 'vue';
import { h } from 'vue';
import { renderSlotChild } from './Slot';
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
export interface PrimitiveProps {
as?: keyof IntrinsicElementAttributes | Component;
}
/**
* Polymorphic element renderer: renders `as` (a tag or component), or the single
* slotted child when `as === 'template'`. Local copy of the primitives helper.
*/
export function Primitive(props: PrimitiveProps & VNodeProps & AllowedComponentProps & Record<string, unknown>, ctx: FunctionalComponentContext) {
const as = props.as;
return as === 'template'
? renderSlotChild(ctx.slots, ctx.attrs)
: h(as!, ctx.attrs, ctx.slots);
}
Primitive.inheritAttrs = false;
Primitive.props = {
as: {
type: [String, Object],
default: 'div' as const,
},
};
-50
View File
@@ -1,50 +0,0 @@
import type { SetupContext, Slots, VNode } from 'vue';
import { Comment, Fragment, cloneVNode, warn } from 'vue';
import { getRawChildren } from './getRawChildren';
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
/**
* Renders a single child from the provided default slot, applying attrs to it.
* Shared between `<Slot>` and `<Primitive as="template">`.
*
* @param slots - Component slots
* @param attrs - Attrs to apply to the slotted child
* @returns Cloned VNode with merged attrs or null
*/
export function renderSlotChild(slots: Slots, attrs: Record<string, unknown>): VNode | null {
if (!slots.default) return null;
const raw = slots.default();
if (raw.length === 1) {
const only = raw[0] as VNode;
const t = only.type;
if (t !== Fragment && t !== Comment)
return cloneVNode(only, attrs, true);
}
const children = getRawChildren(raw);
if (!children.length) return null;
if (__DEV__ && children.length > 1) {
warn('<Slot> can only be used on a single element or component.');
}
return cloneVNode(children[0]!, attrs, true);
}
/**
* A component that renders a single child from its default slot, applying the
* provided attributes to it.
*
* @param _ - Props (unused)
* @param context - Setup context containing slots and attrs
* @returns Cloned VNode with merged attrs or null
*/
export function Slot(_: Record<string, unknown>, { slots, attrs }: FunctionalComponentContext) {
return renderSlotChild(slots, attrs);
}
Slot.inheritAttrs = false;
@@ -1,43 +0,0 @@
import type { VNode } from 'vue';
import { Comment, Fragment } from 'vue';
import { PatchFlags } from '@vue/shared';
/**
* Recursively extracts and flattens VNodes from potentially nested Fragments
* while filtering out Comment nodes. Local copy of the primitives helper to keep
* `@robonen/editor` self-contained.
*
* @param children - Array of VNodes to process
* @returns Flattened array of non-Comment VNodes
*/
export function getRawChildren(children: VNode[]): VNode[] {
const result: VNode[] = [];
flatten(children, result);
return result;
}
function flatten(children: VNode[], result: VNode[]): void {
let keyedFragmentCount = 0;
const startIdx = result.length;
for (let i = 0, len = children.length; i < len; i++) {
const child = children[i]!;
if (child.type === Fragment) {
if (child.patchFlag & PatchFlags.KEYED_FRAGMENT) {
keyedFragmentCount++;
}
flatten(child.children as VNode[], result);
}
else if (child.type !== Comment) {
result.push(child);
}
}
if (keyedFragmentCount > 1) {
for (let i = startIdx; i < result.length; i++) {
result[i]!.patchFlag = PatchFlags.BAIL;
}
}
}
-4
View File
@@ -1,4 +0,0 @@
export { Primitive } from './Primitive';
export type { PrimitiveProps } from './Primitive';
export { Slot, renderSlotChild } from './Slot';
export { getRawChildren } from './getRawChildren';
@@ -1,86 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue';
import { isCollapsed } from '../../model';
import { isMarkActive, toggleMark } from '../../commands';
import { useEditorContext } from '../context';
import { useEventListener } from '../composables';
export interface EditorBubbleMenuProps {
/** Marks shown in the default toolbar (ignored when the default slot is used). */
marks?: string[];
}
const { marks = ['bold', 'italic', 'underline', 'strike', 'code'] } = defineProps<EditorBubbleMenuProps>();
const ctx = useEditorContext();
const reference = ref<{ getBoundingClientRect: () => DOMRect } | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const open = ref(false);
const rev = ref(0);
const { floatingStyles, update } = useFloating(reference, floatingEl, {
placement: 'top',
middleware: [offset(8), flip(), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
});
function selectionRect(): DOMRect | null {
const selection = globalThis.window === undefined ? null : globalThis.getSelection();
if (!selection || selection.rangeCount === 0)
return null;
const rect = selection.getRangeAt(0).getBoundingClientRect();
return rect.width || rect.height ? rect : null;
}
function refresh(): void {
rev.value += 1;
const sel = ctx.editor.state.selection;
const rect = selectionRect();
open.value = sel.kind === 'text' && !isCollapsed(sel) && !ctx.composing.value && rect !== null;
if (open.value) {
reference.value = { getBoundingClientRect: () => selectionRect() ?? new DOMRect() };
void update();
}
}
ctx.editor.on('transaction', refresh);
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'selectionchange', refresh);
onBeforeUnmount(() => ctx.editor.off('transaction', refresh));
function active(type: string): boolean {
return Boolean(rev.value >= 0 && isMarkActive(ctx.editor.state, type));
}
function toggle(type: string): void {
ctx.editor.command(toggleMark(type));
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
ref="floatingEl"
:style="floatingStyles"
class="editor-bubble-menu"
role="toolbar"
data-editor-bubble-menu=""
>
<slot :active="active" :toggle="toggle" :editor="ctx.editor">
<button
v-for="mark in marks"
:key="mark"
type="button"
:data-mark="mark"
:data-active="active(mark) || undefined"
@mousedown.prevent="toggle(mark)"
>
{{ mark }}
</button>
</slot>
</div>
</Teleport>
</template>
-8
View File
@@ -1,8 +0,0 @@
export * from './slash-items';
export { default as EditorBubbleMenu } from './EditorBubbleMenu.vue';
export type { EditorBubbleMenuProps } from './EditorBubbleMenu.vue';
export { default as EditorSlashMenu } from './EditorSlashMenu.vue';
export type { EditorSlashMenuProps } from './EditorSlashMenu.vue';
export { default as EditorRemoteCursors } from './EditorRemoteCursors.vue';
export type { EditorRemoteCursorsProps } from './EditorRemoteCursors.vue';
+542
View File
@@ -0,0 +1,542 @@
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > screenToContent — over N points 1376ms
· 100 points 1,192,582.00 0.0000 0.8000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.78% 596291
· 1000 points 143,410.00 0.0000 0.2000 0.0070 0.0000 0.1000 0.1000 0.1000 ±2.68% 71705
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > contentToScreen — over N points 1375ms
· 100 points 1,182,360.00 0.0000 6.5000 0.0008 0.0000 0.0000 0.1000 0.1000 ±4.42% 591180
· 1000 points 146,178.76 0.0000 0.3000 0.0068 0.0000 0.1000 0.1000 0.1000 ±2.69% 73104
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > round-trip screen→content→screen — over N points 1365ms
· 100 points 1,179,437.99 0.0000 0.2000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.77% 589719
· 1000 points 141,443.71 0.0000 0.2000 0.0071 0.0000 0.1000 0.1000 0.1000 ±2.68% 70736
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > zoomAtPointer — over N anchor points 1373ms
· 100 points 1,095,020.00 0.0000 0.2000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.76% 547510
· 1000 points 128,084.38 0.0000 0.2000 0.0078 0.0000 0.1000 0.1000 0.1000 ±2.67% 64055
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > clampViewport — zoom-only (no extent) 1343ms
· 100 viewports 964,282.00 0.0000 0.3000 0.0010 0.0000 0.1000 0.1000 0.1000 ±2.77% 482141
· 1000 viewports 107,640.47 0.0000 0.2000 0.0093 0.0000 0.1000 0.1000 0.1000 ±2.65% 53831
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > clampViewport — with translate extent 1330ms
· 100 viewports 902,672.00 0.0000 0.2000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 451336
· 1000 viewports 97,734.45 0.0000 0.4000 0.0102 0.0000 0.1000 0.1000 0.1000 ±2.66% 48877
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > clampViewport — degenerate extent (centring branch) 1334ms
· 100 viewports 919,704.06 0.0000 0.3000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 459944
· 1000 viewports 105,346.00 0.0000 0.2000 0.0095 0.0000 0.1000 0.1000 0.1000 ±2.64% 52673
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > wheelToZoomFactor — over N wheel events 1232ms
· 100 events 174,063.19 0.0000 0.4000 0.0057 0.0000 0.1000 0.1000 0.1000 ±2.70% 87049
· 1000 events 16,542.00 0.0000 0.4000 0.0605 0.1000 0.2000 0.2000 0.2000 ±2.01% 8271
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > wheel-zoom pipeline (factor → clamp → zoomAtPointer) 1238ms
· 100 steps 205,246.00 0.0000 0.3000 0.0049 0.0000 0.1000 0.1000 0.1000 ±2.72% 102623
· 1000 steps 19,588.00 0.0000 0.4000 0.0511 0.1000 0.2000 0.2000 0.2000 ±2.11% 9794
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > drag-pan move (translate + clamp) 1316ms
· 100 moves 822,331.53 0.0000 0.2000 0.0012 0.0000 0.1000 0.1000 0.1000 ±2.76% 411248
· 1000 moves 85,706.86 0.0000 0.2000 0.0117 0.0000 0.1000 0.1000 0.1000 ±2.62% 42862
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > fitViewTransform 2304ms
· single fit 7,124,967.01 0.0000 4.5000 0.0001 0.0000 0.0000 0.0000 0.1000 ±3.29% 3563196
· 100 fits 1,157,900.00 0.0000 1.0000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.80% 578950
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > measureContentRect (real getBoundingClientRect) 616ms
· 100 measurements 17,414.52 0.0000 2.0000 0.0574 0.1000 0.2000 0.2000 0.2000 ±2.20% 8709
✓ |chromium| src/canvas/zoom-pan/__test__/ZoomPan.bench.ts > ViewportRoot — mount with N tiles 1236ms
· 50 tiles — mount + unmount 2,345.61 0.2000 14.2000 0.4263 0.4000 1.0000 6.5000 13.7000 ±10.02% 1204
· 500 tiles — mount + unmount 875.12 0.9000 8.1000 1.1427 1.1000 5.4000 6.8000 8.1000 ±5.54% 438
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > ruler ticks — timecode (per pan/zoom) 1933ms
· timecodeTicks — 100-clip span (~150s) 53,809.24 0.0000 0.3000 0.0186 0.0000 0.1000 0.1000 0.2000 ±2.52% 26910
· timecodeTicks — 1000-clip span (~1500s) 18,988.20 0.0000 0.3000 0.0527 0.1000 0.2000 0.2000 0.2000 ±2.11% 9496
· timecodeTicks — wide window, fixed viewport (1200px) 838,271.99 0.0000 0.3000 0.0012 0.0000 0.1000 0.1000 0.1000 ±2.77% 419136
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > ruler ticks — wall clock (per pan/zoom) 1233ms
· timeTicks — 100-clip span (~150s) 157,950.41 0.0000 0.4000 0.0063 0.0000 0.1000 0.1000 0.1000 ±2.71% 78991
· timeTicks — 1000-clip span (~1500s) 32,748.00 0.0000 0.6000 0.0305 0.1000 0.1000 0.2000 0.2000 ±2.39% 16374
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > scale projection (scaleLinear over clips) 1741ms
· scaleLinear — project 100 clip edges 3,262,253.56 0.0000 0.2000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1631453
· scaleLinear — project 1000 clip edges 466,074.79 0.0000 0.2000 0.0021 0.0000 0.1000 0.1000 0.1000 ±2.74% 233084
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > timecode formatting (per clip label) 1826ms
· timeToTimecode — 100 clip durations 113,117.38 0.0000 0.2000 0.0088 0.0000 0.1000 0.1000 0.1000 ±2.65% 56570
· timeToTimecode — 1000 clip durations 11,245.75 0.0000 0.4000 0.0889 0.1000 0.2000 0.2000 0.3000 ±1.71% 5624
· framesToTimecode — 1000 (raw, pre-converted) 14,099.18 0.0000 0.3000 0.0709 0.1000 0.2000 0.2000 0.2000 ±1.88% 7051
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > snapToFrame (nudge / grid granularity) 1537ms
· snapToFrame — 100 clip starts 2,369,298.14 0.0000 0.3000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1184886
· snapToFrame — 1000 clip starts 308,024.40 0.0000 0.1000 0.0032 0.0000 0.1000 0.1000 0.1000 ±2.73% 154043
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > marquee hit-test (clipIntersectsTime per pointer move) 1743ms
· clipIntersectsTime — 100 clips 3,878,759.99 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1939380
· clipIntersectsTime — 1000 clips 600,243.95 0.0000 0.1000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.75% 300182
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > clipsDuration (auto-duration recompute) 1897ms
· clipsDuration — 100 clips 4,189,313.97 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2094657
· clipsDuration — 1000 clips 639,822.00 0.0000 0.1000 0.0016 0.0000 0.1000 0.1000 0.1000 ±2.75% 319911
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > applyClipChanges (controlled reducer) 1828ms
· applyClipChanges — 100 clips / 100 changes 115,978.00 0.0000 0.3000 0.0086 0.0000 0.1000 0.1000 0.1000 ±2.67% 57989
· applyClipChanges — 1000 clips / 1000 changes 10,148.00 0.0000 0.5000 0.0985 0.1000 0.2000 0.2000 0.3000 ±1.65% 5074
· applyClipChanges — 1000 clips / single move 17,258.00 0.0000 0.4000 0.0579 0.1000 0.2000 0.2000 0.3000 ±2.05% 8629
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > applyTrackChanges (controlled reducer) 1236ms
· applyTrackChanges — 50 tracks / 50 patches 190,324.00 0.0000 0.3000 0.0053 0.0000 0.1000 0.1000 0.1000 ±2.71% 95162
· applyTrackChanges — 500 tracks / 500 patches 18,284.00 0.0000 0.4000 0.0547 0.1000 0.2000 0.2000 0.3000 ±2.09% 9142
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > TimelineRoot — mount (full tree) 1446ms
· mount — 4 tracks / 50 clips 196.51 3.7000 52.7000 5.0889 4.3000 52.7000 52.7000 52.7000 ±20.03% 99
· mount — 8 tracks / 500 clips 22.7790 34.9000 94.4000 43.9000 41.2000 94.4000 94.4000 94.4000 ±23.28% 12
✓ |chromium| src/canvas/timeline/__test__/Timeline.bench.ts > TimelineRoot — update after prop change 2151ms
· zoom change (pxPerSecond) — 8 tracks / 500 clips 14.4949 59.8000 138.30 68.9900 62.1000 138.30 138.30 138.30 ±25.29% 10
· clips-array swap — 8 tracks / 500 clips 13.0225 66.7000 150.90 76.7900 70.0000 150.90 150.90 150.90 ±24.30% 10
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > buildEvaluator — build cost 5205ms
· linear — 16 anchors 1,234,628.00 0.0000 0.3000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.77% 617314
· linear — 256 anchors 125,932.00 0.0000 0.3000 0.0079 0.0000 0.1000 0.1000 0.1000 ±2.67% 62966
· monotone — 16 anchors 383,598.00 0.0000 0.2000 0.0026 0.0000 0.1000 0.1000 0.1000 ±2.74% 191799
· monotone — 256 anchors 33,197.36 0.0000 0.2000 0.0301 0.1000 0.1000 0.1000 0.2000 ±2.37% 16602
· catmull-rom — 16 anchors 79,434.11 0.0000 1.5000 0.0126 0.0000 0.1000 0.1000 0.1000 ±2.79% 39725
· catmull-rom — 256 anchors 50,563.89 0.0000 1.5000 0.0198 0.0000 0.1000 0.1000 0.2000 ±2.70% 25287
· bezier — 16 anchors 720,043.99 0.0000 0.2000 0.0014 0.0000 0.1000 0.1000 0.1000 ±2.75% 360094
· bezier — 256 anchors 68,496.00 0.0000 0.2000 0.0146 0.0000 0.1000 0.1000 0.1000 ±2.58% 34248
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > evaluator sampling — 256 samples 2576ms
· linear 343,429.32 0.0000 0.2000 0.0029 0.0000 0.1000 0.1000 0.1000 ±2.73% 171749
· monotone 592,140.00 0.0000 0.1000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.75% 296070
· catmull-rom 248,890.00 0.0000 0.2000 0.0040 0.0000 0.1000 0.1000 0.1000 ±2.72% 124445
· bezier (Newton-Raphson per call) 161,676.00 0.0000 0.2000 0.0062 0.0000 0.1000 0.1000 0.1000 ±2.69% 80838
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > evaluator sampling — 1024 samples (stress) 1848ms
· monotone — 16 anchors 156,256.00 0.0000 0.2000 0.0064 0.0000 0.1000 0.1000 0.1000 ±2.68% 78128
· monotone — 256 anchors (deep binary search) 111,976.00 0.0000 0.2000 0.0089 0.0000 0.1000 0.1000 0.1000 ±2.65% 55988
· bezier — 16 anchors 48,394.00 0.0000 0.3000 0.0207 0.0000 0.1000 0.1000 0.2000 ±2.50% 24197
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > build + sample 256 (full per-edit, 16 anchors) 1864ms
· monotone 185,120.98 0.0000 0.5000 0.0054 0.0000 0.1000 0.1000 0.1000 ±2.71% 92579
· catmull-rom 58,420.32 0.0000 1.5000 0.0171 0.0000 0.1000 0.1000 0.2000 ±2.75% 29216
· bezier 120,559.89 0.0000 0.3000 0.0083 0.0000 0.1000 0.1000 0.1000 ±2.67% 60292
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > toLUT — spline lookup table 1841ms
· monotone — 256 entries 110,670.00 0.0000 0.3000 0.0090 0.0000 0.1000 0.1000 0.1000 ±2.65% 55335
· monotone — 1024 entries 27,216.56 0.0000 0.2000 0.0367 0.1000 0.1000 0.2000 0.2000 ±2.29% 13611
· bezier — 256 entries 82,832.00 0.0000 0.3000 0.0121 0.0000 0.1000 0.1000 0.1000 ±2.62% 41416
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > curve path `d` build — sampled polyline (256 samples) 1217ms
· monotone — sample + project + buildPolylinePath 40,060.00 0.0000 2.3000 0.0250 0.0000 0.1000 0.1000 0.2000 ±2.61% 20030
· catmull-rom — sample + project + buildPolylinePath 36,482.00 0.0000 1.9000 0.0274 0.1000 0.1000 0.1000 0.2000 ±2.53% 18241
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > curve path `d` build — bezier segment chain 1242ms
· 16 anchors (15 segments) 234,902.00 0.0000 1.6000 0.0043 0.0000 0.1000 0.1000 0.1000 ±2.87% 117451
· 256 anchors (255 segments) 11,619.68 0.0000 2.1000 0.0861 0.1000 0.2000 0.2000 0.5000 ±2.14% 5811
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > spline primitives — per-call baselines 5370ms
· linearInterpolate — 256-pt table lookup 7,088,362.35 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3544890
· catmullRom — 16-pt parametric eval 7,313,673.99 0.0000 0.7000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.79% 3656837
· evalCubicBezier — single cubic eval 6,966,088.85 0.0000 0.7000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.79% 3483741
· monotoneCubic — build closure (16 pts) 510,439.91 0.0000 0.3000 0.0020 0.0000 0.1000 0.1000 0.1000 ±2.75% 255271
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > anchor housekeeping 2732ms
· sortAnchors — 16 (unsorted) 1,003,727.99 0.0000 0.3000 0.0010 0.0000 0.0000 0.1000 0.1000 ±2.77% 501864
· sortAnchors — 256 (unsorted) 46,338.73 0.0000 0.4000 0.0216 0.0000 0.1000 0.1000 0.2000 ±2.49% 23174
· anchorsToPoints — 16 1,232,089.59 0.0000 0.6000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.78% 616168
· anchorsToPoints — 256 125,538.00 0.0000 0.3000 0.0080 0.0000 0.1000 0.1000 0.1000 ±2.67% 62769
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > pointer-move clamp math 7694ms
· clampAnchorX — interior anchor (neighbour clamp), 16 7,403,125.41 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3702303
· clampAnchorX — interior anchor (neighbour clamp), 256 7,373,969.27 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3687722
· clampAnchorY — domain clamp 7,443,999.99 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3722000
· simulated updateAnchor step — clamp + slice-replace, 16 6,432,109.99 0.0000 7.2000 0.0002 0.0000 0.0000 0.0000 0.1000 ±3.96% 3216055
· simulated updateAnchor step — clamp + slice-replace, 256 5,501,865.63 0.0000 0.3000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.78% 2751483
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > simulated drag stroke (60 frames, monotone, 16 anchors) 603ms
· clamp + rebuild + sample-256 per frame 3,084.00 0.2000 0.8000 0.3243 0.4000 0.4000 0.5000 0.7000 ±0.94% 1542
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > mount — Root + Curve + N Points 2026ms
· 50 points (monotone) 231.22 3.0000 52.5000 4.3248 3.4000 25.9000 52.5000 52.5000 ±21.15% 121
· 500 points (monotone, stress) 23.6220 32.1000 101.60 42.3333 38.5000 101.60 101.60 101.60 ±28.33% 12
· 50 points (bezier path) 239.62 2.9000 78.0000 4.1733 3.2000 9.8000 78.0000 78.0000 ±30.21% 120
✓ |chromium| src/canvas/curve-editor/__test__/CurveEditor.bench.ts > update after prop change (50 points) 1215ms
· switch interpolation monotone→bezier→monotone 165.90 5.1000 10.5000 6.0277 5.6000 10.5000 10.5000 10.5000 ±5.79% 83
· replace model array (commit an edit) 118.34 7.5000 12.3000 8.4500 8.1000 12.3000 12.3000 12.3000 ±4.39% 60
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > rotatePoint — kernel 2625ms
· rotatePoint × 100 4,186,828.67 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2093833
· rotatePoint × 1000 893,619.28 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 446899
· rotateVector (origin-free) × 1000 896,738.65 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 448459
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > pointer angle math 3950ms
· pointerAngle × 100 3,031,405.99 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1515703
· pointerAngle × 1000 889,550.00 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 444775
· shortestAngleDelta × 1000 900,573.89 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 450377
· normalizeRotation × 1000 1,020,559.89 0.0000 0.1000 0.0010 0.0000 0.0000 0.1000 0.1000 ±2.76% 510382
· snapRotation (15°) × 1000 1,030,022.00 0.0000 0.1000 0.0010 0.0000 0.0000 0.1000 0.1000 ±2.76% 515011
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > rotate drag — per-frame 1280ms
· rotationFromPointer × 100 frames 533,194.00 0.0000 0.1000 0.0019 0.0000 0.1000 0.1000 0.1000 ±2.75% 266597
· rotationFromPointer × 1000 frames 52,782.00 0.0000 0.2000 0.0189 0.0000 0.1000 0.1000 0.1000 ±2.51% 26391
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > resizeEdge — per-frame 3636ms
· resizeEdge corner (no options) × 100 108,148.00 0.0000 0.3000 0.0092 0.0000 0.1000 0.1000 0.1000 ±2.65% 54074
· resizeEdge corner (no options) × 1000 10,806.00 0.0000 0.5000 0.0925 0.1000 0.2000 0.2000 0.3000 ±1.67% 5403
· resizeEdge aspect-locked corner × 1000 8,762.25 0.0000 0.4000 0.1141 0.1000 0.2000 0.2000 0.3000 ±1.50% 4382
· resizeEdge symmetric (Alt) corner × 1000 9,848.00 0.0000 0.3000 0.1015 0.1000 0.2000 0.2000 0.3000 ±1.55% 4924
· resizeEdge edge handle × 1000 13,704.00 0.0000 0.3000 0.0730 0.1000 0.2000 0.2000 0.2000 ±1.85% 6852
· rotated scale frame (rotateVector → resizeEdge) × 1000 8,680.53 0.0000 0.4000 0.1152 0.2000 0.2000 0.2000 0.3000 ±1.55% 4342
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > aspect + axes helpers 1376ms
· applyAspectRatio × 1000 421,907.62 0.0000 0.1000 0.0024 0.0000 0.1000 0.1000 0.1000 ±2.74% 210996
· handleAxes × 8 positions × 125 (=1000) 967,260.00 0.0000 0.1000 0.0010 0.0000 0.1000 0.1000 0.1000 ±2.76% 483630
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > constrain + move 2025ms
· constrainRect × 1000 901,852.00 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 450926
· moveBox × 1000 61,595.68 0.0000 0.2000 0.0162 0.0000 0.1000 0.1000 0.2000 ±2.56% 30804
· resolvePivot (center) × 1000 898,150.37 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 449165
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > local ⇄ world 703ms
· localToWorld → worldToLocal round-trip × 1000 892,126.00 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 446063
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > decomposeTransform — corners 1230ms
· decomposeTransform × 100 175,760.00 0.0000 0.5000 0.0057 0.0000 0.1000 0.1000 0.1000 ±2.72% 87880
· decomposeTransform × 1000 15,972.00 0.0000 0.3000 0.0626 0.1000 0.2000 0.2000 0.2000 ±2.00% 7986
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > TransformBoxRoot — mount full part set 7944ms
· mount + unmount — 1 box (root + 8 handles + rotate + status) 1,188.34 0.5000 47.3000 0.8415 0.7000 6.2000 6.8000 47.3000 ±19.41% 595
· mount + unmount — 50 boxes 26.9024 28.2000 95.9000 37.1714 34.1000 95.9000 95.9000 95.9000 ±26.49% 14
· mount + unmount — 500 boxes (stress) 2.5223 338.90 636.70 396.47 373.10 636.70 636.70 636.70 ±19.60% 10
✓ |chromium| src/canvas/transform-box/__test__/TransformBox.bench.ts > TransformBoxRoot — update after transform change 1010ms
· mount → setProps(transform) → update — 50 boxes 30.7515 27.7000 36.6000 32.5188 34.6000 36.6000 36.6000 36.6000 ±5.11% 16
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > edge-paths — straight 1308ms
· 100 edges 664,128.00 0.0000 0.5000 0.0015 0.0000 0.1000 0.1000 0.1000 ±2.78% 332064
· 1000 edges 72,543.49 0.0000 0.5000 0.0138 0.0000 0.1000 0.1000 0.2000 ±2.61% 36279
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > edge-paths — bezier 1221ms
· 100 edges 77,522.00 0.0000 0.8000 0.0129 0.0000 0.1000 0.1000 0.2000 ±2.64% 38761
· 1000 edges 6,873.25 0.0000 0.6000 0.1455 0.2000 0.3000 0.3000 0.4000 ±1.43% 3438
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > edge-paths — smoothstep (corner builder) 1209ms
· 100 edges 13,001.40 0.0000 0.6000 0.0769 0.1000 0.2000 0.2000 0.4000 ±1.88% 6502
· 1000 edges 1,290.19 0.6000 1.3000 0.7751 0.8000 1.1000 1.3000 1.3000 ±0.87% 646
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > edge-paths — step (zero-radius smoothstep) 1208ms
· 100 edges 12,797.44 0.0000 0.5000 0.0781 0.1000 0.2000 0.2000 0.4000 ±1.89% 6400
· 1000 edges 1,268.48 0.6000 1.6000 0.7883 0.8000 1.3000 1.5000 1.6000 ±1.08% 635
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > pointer math — screenToFlow 1772ms
· 100 moves 3,327,878.00 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1663939
· 1000 moves 533,703.26 0.0000 0.1000 0.0019 0.0000 0.1000 0.1000 0.1000 ±2.75% 266905
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > pointer math — flowToScreen 1695ms
· 100 points 3,548,374.33 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1774542
· 1000 points 596,212.76 0.0000 0.1000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.75% 298166
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > pointer math — zoomAtPointer (wheel zoom) 1708ms
· 100 wheel steps 3,119,281.99 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1559641
· 1000 wheel steps 466,404.72 0.0000 0.1000 0.0021 0.0000 0.1000 0.1000 0.1000 ±2.74% 233249
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > pointer math — snapPoint (drag with snap-to-grid) 1399ms
· 100 moves 1,187,976.00 0.0000 0.1000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.76% 593988
· 1000 moves 137,674.00 0.0000 0.2000 0.0073 0.0000 0.1000 0.1000 0.1000 ±2.67% 68837
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > getNodesBounds 1430ms
· 100 nodes 1,637,925.99 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 818963
· 1000 nodes 167,484.00 0.0000 0.2000 0.0060 0.0000 0.1000 0.1000 0.1000 ±2.69% 83742
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > fitViewTransform (bounds + fit) 1421ms
· 100 nodes 1,622,870.00 0.0000 0.4000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.77% 811435
· 1000 nodes 168,224.36 0.0000 0.1000 0.0059 0.0000 0.1000 0.1000 0.1000 ±2.69% 84129
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > getNodePositionAbsolute — parent chain (depth 64) 1295ms
· single leaf walk 725,752.85 0.0000 0.1000 0.0014 0.0000 0.1000 0.1000 0.1000 ±2.75% 362949
· 64 nodes (all walked) 25,778.00 0.0000 0.2000 0.0388 0.1000 0.1000 0.2000 0.2000 ±2.25% 12889
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > visibleFlowRect + getVisibleNodeIds (node cull) 1309ms
· 100 nodes 800,992.00 0.0000 0.1000 0.0012 0.0000 0.1000 0.1000 0.1000 ±2.75% 400496
· 1000 nodes 80,856.00 0.0000 0.2000 0.0124 0.0000 0.1000 0.1000 0.1000 ±2.60% 40428
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > getVisibleEdgeIds (edge cull by visible node set) 1310ms
· 100 edges 796,650.67 0.0000 0.2000 0.0013 0.0000 0.1000 0.1000 0.1000 ±2.76% 398405
· 1000 edges 79,270.15 0.0000 0.3000 0.0126 0.0000 0.1000 0.1000 0.1000 ±2.60% 39643
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > getNodesInsideRect (marquee selection) 1429ms
· 100 nodes 1,742,606.00 0.0000 0.4000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.78% 871303
· 1000 nodes 159,956.01 0.0000 0.3000 0.0063 0.0000 0.1000 0.1000 0.1000 ±2.69% 79994
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > findClosestHandle (connect-drag snapping) 1216ms
· 100 nodes 3,915.22 0.1000 0.6000 0.2554 0.3000 0.4000 0.4000 0.6000 ±1.04% 1958
· 1000 nodes 360.41 2.7000 3.2000 2.7746 2.8000 3.2000 3.2000 3.2000 ±0.45% 181
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > applyNodeChanges (drag → position changes) 1223ms
· 100 position changes 115,198.00 0.0000 0.4000 0.0087 0.0000 0.1000 0.1000 0.1000 ±2.68% 57599
· 1000 position changes 10,344.00 0.0000 0.5000 0.0967 0.1000 0.2000 0.2000 0.4000 ±1.67% 5172
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > applyEdgeChanges (select changes) 1223ms
· 100 select changes 113,930.00 0.0000 0.4000 0.0088 0.0000 0.1000 0.1000 0.1000 ±2.68% 56965
· 1000 select changes 10,150.00 0.0000 0.5000 0.0985 0.1000 0.2000 0.2000 0.3000 ±1.63% 5075
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > addEdge (dedupe scan on connect) 1271ms
· append into 100 edges 422,387.52 0.0000 0.3000 0.0024 0.0000 0.1000 0.1000 0.1000 ±2.75% 211236
· append into 1000 edges 41,092.00 0.0000 0.3000 0.0243 0.0000 0.1000 0.1000 0.2000 ±2.47% 20546
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > FlowRoot — mount + unmount 1879ms
· 50 nodes / 50 edges 127.81 5.7000 43.9000 7.8242 6.8000 43.9000 43.9000 43.9000 ±16.28% 66
· 500 nodes / 500 edges 13.8007 64.9000 104.80 72.4600 72.8000 104.80 104.80 104.80 ±11.67% 10
✓ |chromium| src/canvas/flow/__test__/Flow.bench.ts > FlowRoot — re-render after prop change (viewport pan) 4494ms
· 50 nodes — viewport setProps 78.8022 9.3000 97.7000 12.6900 10.0000 97.7000 97.7000 97.7000 ±35.07% 40
· 500 nodes — nodes setProps (controlled replace) 3.8464 234.50 331.70 259.98 258.90 331.70 331.70 331.70 ±7.26% 10
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > tick generation — timeTicks (seconds mode) 1585ms
· realistic window (~15s @ 40px/s) 2,270,653.88 0.0000 0.5000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.80% 1135554
· stress window (1000s @ 4px/s) 628,932.00 0.0000 0.3000 0.0016 0.0000 0.1000 0.1000 0.1000 ±2.76% 314466
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > tick generation — timecodeTicks (timecode mode) 2025ms
· realistic window 659,680.07 0.0000 5.2000 0.0015 0.0000 0.1000 0.1000 0.1000 ±3.44% 329906
· stress window 212,063.59 0.0000 0.3000 0.0047 0.0000 0.1000 0.1000 0.1000 ±2.72% 106053
· realistic window — drop-frame labels 639,334.00 0.0000 0.3000 0.0016 0.0000 0.1000 0.1000 0.1000 ±2.76% 319667
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > tick generation — frameTicks (frames mode) 1226ms
· realistic window — timecode ticker w/ frame labels 13,785.24 0.0000 0.2000 0.0725 0.1000 0.2000 0.2000 0.2000 ±1.87% 6894
· stress window — integer-frame axis 3,373.33 0.2000 0.4000 0.2964 0.3000 0.4000 0.4000 0.4000 ±0.90% 1687
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > tick generation — niceTicks (generic axis) 1620ms
· realistic window 2,482,165.58 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.78% 1241331
· stress window 689,030.00 0.0000 0.3000 0.0015 0.0000 0.1000 0.1000 0.1000 ±2.76% 344515
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > projection math — scaleLinear (time → px) 2085ms
· 100 values 5,357,289.99 0.0000 4.6000 0.0002 0.0000 0.0000 0.0000 0.1000 ±3.29% 2678645
· 1000 values 954,867.03 0.0000 0.1000 0.0010 0.0000 0.1000 0.1000 0.1000 ±2.76% 477529
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > projection math — scaleLinear (px → time, invert) 2082ms
· 100 pixels 5,374,567.97 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2687284
· 1000 pixels 897,614.00 0.0000 0.1000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 448807
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > projection math — roundToStep (snap, pointer path) 1228ms
· 100 values 145,666.87 0.0000 1.2000 0.0069 0.0000 0.1000 0.1000 0.1000 ±2.72% 72848
· 1000 values 13,971.21 0.0000 0.4000 0.0716 0.1000 0.2000 0.2000 0.2000 ±1.90% 6987
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > useScale — projector closures 1260ms
· scale() × 1000 24,637.07 0.0000 0.3000 0.0406 0.1000 0.2000 0.2000 0.2000 ±2.24% 12321
· invert() × 100 (pointer sweep) 362,189.56 0.0000 5.2000 0.0028 0.0000 0.1000 0.1000 0.1000 ±3.41% 181131
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > label formatting — per mode 3045ms
· formatClock × 1000 (seconds) 58,456.00 0.0000 0.4000 0.0171 0.0000 0.1000 0.1000 0.2000 ±2.55% 29228
· formatTimecode × 1000 (timecode) 14,244.00 0.0000 0.4000 0.0702 0.1000 0.2000 0.2000 0.2000 ±1.92% 7122
· framesToTimecode × 1000 — drop-frame 11,798.00 0.0000 0.3000 0.0848 0.1000 0.2000 0.2000 0.2000 ±1.75% 5899
· formatFrames × 1000 (frames) 126.51 7.8000 8.0000 7.9047 7.9000 8.0000 8.0000 8.0000 ±0.20% 64
· formatTimeForMode × 1000 — dispatch (timecode) 14,360.00 0.0000 0.5000 0.0696 0.1000 0.2000 0.2000 0.2000 ±1.90% 7180
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > mode plumbing 3848ms
· modeToTickKind × 3 modes 7,138,575.98 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3569288
· tickFormatFor × 3 modes 7,160,973.99 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3580487
· secondsToFrames × 1000 900,848.00 0.0000 0.2000 0.0011 0.0000 0.1000 0.1000 0.1000 ±2.76% 450424
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > TimeRulerRoot — mount 1816ms
· mount — seconds mode 3,622.55 0.1000 7.5000 0.2760 0.3000 0.5000 5.6000 6.9000 ±7.69% 1812
· mount — timecode mode 3,641.08 0.1000 15.8000 0.2746 0.3000 0.5000 4.9000 10.0000 ±9.53% 1826
· mount — frames mode 3,690.00 0.1000 17.4000 0.2710 0.3000 0.4000 3.8000 5.9000 ±8.82% 1845
✓ |chromium| src/canvas/time-ruler/__test__/TimeRuler.bench.ts > TimeRulerRoot — re-render after prop change 1807ms
· zoom change (pan/zoom gesture stream) 2,864.85 0.2000 16.5000 0.3491 0.3000 0.6000 4.5000 5.0000 ±8.18% 1433
· offset change (pan stream) 2,864.85 0.2000 19.1000 0.3491 0.3000 0.6000 4.7000 18.3000 ±11.20% 1433
· mode change (timecode → frames, regenerate ladder) 2,636.42 0.2000 19.4000 0.3793 0.4000 0.7000 4.7000 5.4000 ±9.04% 1319
✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > resolveAxisLock — per-frame axis decision 4332ms
· static axis "x" — fast path (100 frames) 5,190,889.99 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2595445
· axis "both", no shift-lock (100 frames) 5,139,134.18 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2570081
· axis "both" + shift-lock dominant-axis pick (100 frames) 3,188,760.26 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1594699
· axis "both" + shift-lock dominant-axis pick (1000 frames) 536,272.00 0.0000 0.1000 0.0019 0.0000 0.1000 0.1000 0.1000 ±2.75% 268136
✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > computeFrame — single frame (feature on/off matrix) 4741ms
· free move, no snap/bounds/rect 7,265,881.98 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3632941
· axis-locked + scalar snap + bounds + rect (all features) 7,268,978.27 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3635216
· tuple snap + bounds (per-axis grid) 7,100,307.97 0.0000 0.3000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3550154
✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > computeFrame — full gesture stream 2307ms
· 100 frames — free move (no snap/bounds) 2,542,427.52 0.0000 0.1000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1271468
· 100 frames — snap + bounds + rect 1,352,931.41 0.0000 0.1000 0.0007 0.0000 0.0000 0.1000 0.1000 ±2.76% 676601
· 1000 frames — snap + bounds + rect (stress) 158,140.37 0.0000 0.2000 0.0063 0.0000 0.1000 0.1000 0.1000 ±2.68% 79086
✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > simulated flush() pipeline — resolveAxisLock + computeFrame 2103ms
· 100 moves — shift-lock, no snap/bounds 1,252,108.00 0.0000 0.2000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.76% 626054
· 100 moves — shift-lock + snap + bounds + rect 714,692.00 0.0000 0.2000 0.0014 0.0000 0.1000 0.1000 0.1000 ±2.76% 357346
· 1000 moves — shift-lock + snap + bounds + rect (stress) 71,891.62 0.0000 0.2000 0.0139 0.0000 0.1000 0.1000 0.1000 ±2.59% 35953
✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > usePointerDrag — mount N instances 1444ms
· mount 50 draggable handles 3,532.68 0.1000 16.5000 0.2831 0.3000 4.5000 5.5000 8.6000 ±10.78% 1778
· mount 500 draggable handles (stress) 375.97 2.0000 9.8000 2.6598 2.3000 8.8000 9.8000 9.8000 ±8.40% 189
✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > usePointerDrag — update after prop change 806ms
· 50 handles → re-render to 60 handles 1,159.21 0.2000 379.80 0.8627 0.4000 5.2000 5.8000 379.80 ±105.99% 814
✓ |chromium| src/internal/pointer-drag/__test__/PointerDrag.bench.ts > usePointerDrag — live event round-trip (rAF-coalesced) 2625ms
· mount + down + 20 moves + up 5.7202 173.00 176.30 174.82 176.10 176.30 176.30 176.30 ±0.48% 10
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > countBars 3183ms
· small body (300px) 7,352,141.59 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3676806
· large body (1800px) 7,120,649.88 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3561037
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > resamplePeaks — by source length (100 buckets) 2620ms
· 100 peaks 1,162,090.00 0.0000 0.4000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.82% 581045
· 1000 peaks 274,426.00 0.0000 0.3000 0.0036 0.0000 0.1000 0.1000 0.1000 ±2.74% 137213
· 10000 peaks 32,241.55 0.0000 0.3000 0.0310 0.1000 0.1000 0.2000 0.2000 ±2.39% 16124
· 10000 peaks (Float32Array) 28,240.00 0.0000 0.4000 0.0354 0.1000 0.2000 0.2000 0.2000 ±2.35% 14120
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > resamplePeaks — by bucket count (10000 peaks) 1815ms
· 100 buckets 30,416.00 0.0000 0.3000 0.0329 0.1000 0.1000 0.2000 0.2000 ±2.37% 15208
· 600 buckets 24,258.00 0.0000 0.4000 0.0412 0.1000 0.2000 0.2000 0.2000 ±2.27% 12129
· upsample → 2000 buckets 17,884.42 0.0000 0.4000 0.0559 0.1000 0.2000 0.2000 0.3000 ±2.12% 8944
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > resamplePeaks — windowed slice (zoom/scroll) 1219ms
· full window — 600 buckets over 10000 24,211.16 0.0000 0.4000 0.0413 0.1000 0.2000 0.2000 0.3000 ±2.26% 12108
· 25% zoom window — 600 buckets over slice 81,302.00 0.0000 0.5000 0.0123 0.0000 0.1000 0.1000 0.2000 ±2.65% 40651
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > buildBars — bars-mode geometry 1837ms
· 100 bars from 1000 peaks 192,124.00 0.0000 0.4000 0.0052 0.0000 0.1000 0.1000 0.1000 ±2.73% 96062
· 600 bars from 10000 peaks 20,179.96 0.0000 0.5000 0.0496 0.1000 0.2000 0.2000 0.3000 ±2.19% 10092
· 600 bars from 10000 peaks (Float32Array) 20,065.99 0.0000 0.5000 0.0498 0.1000 0.2000 0.2000 0.3000 ±2.19% 10035
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > buildBars — sliding window (simulated scrub/zoom recompute) 608ms
· 600 bars, window slides per iteration 48,556.00 0.0000 0.3000 0.0206 0.0000 0.1000 0.1000 0.2000 ±2.52% 24278
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > buildPathPoints — path-mode silhouette 1229ms
· 256 samples from 1000 peaks 143,684.00 0.0000 0.3000 0.0070 0.0000 0.1000 0.1000 0.1000 ±2.68% 71842
· 1024 samples from 10000 peaks 17,946.41 0.0000 0.4000 0.0557 0.1000 0.2000 0.2000 0.2000 ±2.08% 8975
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > buildSmoothPath — Catmull-Rom path string 1814ms
· 256 points, tension 0 20,822.00 0.0000 12.7000 0.0480 0.1000 0.2000 0.2000 0.3000 ±5.58% 10411
· 256 points, tension 0.5 21,690.00 0.0000 1.8000 0.0461 0.1000 0.2000 0.2000 0.3000 ±2.49% 10845
· 1024 points, tension 0 3,891.22 0.1000 1.9000 0.2570 0.3000 0.5000 0.6000 1.9000 ±1.71% 1946
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > WaveformRoot + WaveformBars — mount 1212ms
· mount with ~50-bar fixture 3,469.92 0.1000 25.3000 0.2882 0.3000 0.6000 4.6000 14.5000 ±12.57% 1736
· mount with ~500-bar fixture 2,211.16 0.3000 7.5000 0.4523 0.4000 2.1000 2.3000 3.4000 ±4.26% 1110
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > WaveformRoot — update after prop change 1209ms
· currentTime change → patch 3,270.69 0.1000 4.4000 0.3057 0.3000 0.6000 3.7000 4.3000 ±5.00% 1636
· peaks swap → re-resample + patch 3,008.00 0.1000 26.9000 0.3324 0.3000 0.4000 4.2000 18.1000 ±13.36% 1504
✓ |chromium| src/canvas/waveform/__test__/Waveform.bench.ts > WaveformRoot + WaveformPath — mount 1212ms
· path mode, 256 samples 3,722.51 0.1000 26.3000 0.2686 0.3000 0.4000 4.8000 21.6000 ±14.62% 1862
· path mode, 1024 samples 3,783.24 0.1000 24.8000 0.2643 0.2000 0.5000 4.8000 23.8000 ±14.72% 1892
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > sampleKeyframes — single sample by curve size 2895ms
· 100 keyframes — sample mid-range 6,003,987.24 0.0000 0.4000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 3002594
· 1000 keyframes — sample mid-range 6,322,849.43 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 3162057
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > sampleKeyframes — full curve sweep (per-frame readout) 1241ms
· 100 keyframes × 120 samples 131,984.00 0.0000 0.3000 0.0076 0.0000 0.1000 0.1000 0.1000 ±2.68% 65992
· 1000 keyframes × 120 samples 132,100.00 0.0000 0.4000 0.0076 0.0000 0.1000 0.1000 0.1000 ±2.68% 66050
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > solveBezierX — easing solve 1954ms
· identity (linear) × 64 4,689,626.08 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2345282
· ease-in-out (Newton-Raphson) × 64 684,475.11 0.0000 0.1000 0.0015 0.0000 0.1000 0.1000 0.1000 ±2.75% 342306
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > sortKeyframes — reconcile / commit 1289ms
· 100 keyframes (reverse-sorted input) 572,597.48 0.0000 0.3000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.77% 286356
· 1000 keyframes (reverse-sorted input) 58,944.00 0.0000 0.5000 0.0170 0.0000 0.1000 0.1000 0.2000 ±2.58% 29472
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > clampKeyframeTime — neighbour clamp (pointer drag) 1495ms
· 100 keyframes × 100 moves 1,118,014.00 0.0000 0.1000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.76% 559007
· 1000 keyframes × 100 moves 1,098,890.23 0.0000 0.1000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.76% 549555
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > snapTimeToFrame — frame-grid quantize 942ms
· 100 quantize ops @30fps 2,803,763.98 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1401882
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > defaultKeyframeValueText — aria-valuetext 653ms
· 100 value-text formats (with property) 374,868.00 0.0000 2.1000 0.0027 0.0000 0.1000 0.1000 0.1000 ±2.87% 187434
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > KeyframeTrackRoot — mount + unmount 3241ms
· mount 50 keyframes 124.68 6.5000 17.3000 8.0206 7.5000 17.3000 17.3000 17.3000 ±7.59% 63
· mount 500 keyframes 5.5850 167.40 205.80 179.05 182.80 205.80 205.80 205.80 ±4.79% 10
✓ |chromium| src/canvas/keyframe-track/__test__/KeyframeTrack.bench.ts > KeyframeTrackRoot — re-render after prop change 3593ms
· 50 keyframes — duration change + flush 106.93 8.1000 14.6000 9.3519 8.8000 14.6000 14.6000 14.6000 ±5.96% 54
· 500 keyframes — duration change + flush 5.0792 191.20 211.80 196.88 198.90 211.80 211.80 211.80 ±2.12% 10
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > evalCubicBezier — sweep t 2273ms
· 100 params 5,712,887.99 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2856444
· 1000 params 1,745,702.86 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 873026
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > cubicBezierTangent — sweep t 2221ms
· 100 params 5,713,947.23 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2857545
· 1000 params 1,732,195.56 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 866271
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > solveBezierX — ease (x→y) 1966ms
· 100 params 430,674.00 0.0000 0.3000 0.0023 0.0000 0.1000 0.1000 0.1000 ±2.75% 215337
· 1000 params 75,640.00 0.0000 0.2000 0.0132 0.0000 0.1000 0.1000 0.1000 ±2.59% 37820
· 1000 params — identity short-circuit 746,870.00 0.0000 0.1000 0.0013 0.0000 0.1000 0.1000 0.1000 ±2.75% 373435
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > cubicBezier1D — scalar Bernstein 799ms
· 1000 params 1,727,796.00 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 863898
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > catmullRom — sweep t 2559ms
· 50 knots × 100 params 573,049.39 0.0000 0.2000 0.0017 0.0000 0.1000 0.1000 0.1000 ±2.75% 286582
· 500 knots × 100 params 566,750.00 0.0000 0.2000 0.0018 0.0000 0.1000 0.1000 0.1000 ±2.75% 283375
· 500 knots × 1000 params 61,514.00 0.0000 0.2000 0.0163 0.0000 0.1000 0.1000 0.2000 ±2.56% 30757
· 500 knots × 1000 params — closed 25,026.00 0.0000 0.5000 0.0400 0.1000 0.2000 0.2000 0.2000 ±2.28% 12513
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > monotoneCubic — build 1223ms
· 100 knots 107,972.00 0.0000 0.4000 0.0093 0.0000 0.1000 0.1000 0.1000 ±2.65% 53986
· 1000 knots 11,616.00 0.0000 0.4000 0.0861 0.1000 0.2000 0.2000 0.3000 ±1.74% 5808
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > monotoneCubic — apply (pre-built fn) 1841ms
· 100 knots → 256-LUT 112,353.53 0.0000 0.2000 0.0089 0.0000 0.1000 0.1000 0.1000 ±2.65% 56188
· 1000 knots → 256-LUT 98,026.39 0.0000 0.3000 0.0102 0.0000 0.1000 0.1000 0.1000 ±2.64% 49023
· 1000 knots → 1024-LUT 22,792.00 0.0000 0.3000 0.0439 0.1000 0.2000 0.2000 0.2000 ±2.19% 11396
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > monotoneCubic — build + apply (knots changed) 1211ms
· 100 knots → build + 256-LUT 51,035.79 0.0000 0.3000 0.0196 0.0000 0.1000 0.1000 0.2000 ±2.51% 25523
· 1000 knots → build + 256-LUT 10,241.95 0.0000 0.4000 0.0976 0.1000 0.2000 0.2000 0.3000 ±1.64% 5122
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > linearInterpolate — query sweep 1218ms
· 100 knots × 1000 queries 58,300.00 0.0000 0.2000 0.0172 0.0000 0.1000 0.1000 0.2000 ±2.54% 29150
· 1000 knots × 1000 queries 45,376.00 0.0000 0.2000 0.0220 0.0000 0.1000 0.1000 0.2000 ±2.47% 22688
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > sampleToPolyline — bezier curve 1256ms
· 100 segments 302,246.00 0.0000 0.2000 0.0033 0.0000 0.1000 0.1000 0.1000 ±2.73% 151123
· 1000 segments 29,642.07 0.0000 0.2000 0.0337 0.1000 0.1000 0.2000 0.2000 ±2.33% 14824
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > sampleFnToPolyline — monotone curve 1244ms
· 100 segments 237,716.46 0.0000 0.2000 0.0042 0.0000 0.1000 0.1000 0.1000 ±2.72% 118882
· 1000 segments 24,476.00 0.0000 0.3000 0.0409 0.1000 0.2000 0.2000 0.2000 ±2.24% 12238
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > buildPolylinePath — string concat 1248ms
· 100 points 292,709.46 0.0000 5.2000 0.0034 0.0000 0.1000 0.1000 0.1000 ±3.40% 146384
· 1000 points 20,331.93 0.0000 4.1000 0.0492 0.1000 0.2000 0.2000 0.3000 ±2.83% 10168
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > buildSmoothPath — Catmull-Rom cubics 1836ms
· 50 points 164,496.00 0.0000 1.7000 0.0061 0.0000 0.1000 0.1000 0.1000 ±2.90% 82248
· 500 points 11,876.00 0.0000 2.8000 0.0842 0.1000 0.2000 0.3000 0.5000 ±2.30% 5938
· 500 points — tension 0.5 11,907.62 0.0000 1.5000 0.0840 0.1000 0.2000 0.2000 0.8000 ±2.10% 5955
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > buildBezierPath — single segment 1584ms
· 1 segment 7,229,343.95 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3614672
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > pointer-move — smooth path rebuild 622ms
· drag mutate + buildSmoothPath (64 points) 124,047.19 0.0000 3.3000 0.0081 0.0000 0.1000 0.1000 0.1000 ±3.04% 62036
✓ |chromium| src/internal/spline/__test__/Spline.bench.ts > pointer-move — curve recompute 609ms
· mutate knot + monotoneCubic + 256-LUT (100 knots) 50,884.00 0.0000 0.4000 0.0197 0.0000 0.1000 0.1000 0.2000 ±2.52% 25442
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: scaleLinear (pointer projection) 2187ms
· scaleLinear ×100 5,601,259.99 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2800630
· scaleLinear ×1000 1,711,881.63 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 856112
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: roundToStep (snap-to-step hot path) 1223ms
· roundToStep ×100 139,038.19 0.0000 0.4000 0.0072 0.0000 0.1000 0.1000 0.1000 ±2.69% 69533
· roundToStep ×1000 14,550.00 0.0000 0.3000 0.0687 0.1000 0.2000 0.2000 0.2000 ±1.93% 7275
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: getStepDecimals (per-step cache miss) 609ms
· getStepDecimals ×1000 (varied step) 57,814.44 0.0000 0.4000 0.0173 0.0000 0.1000 0.1000 0.2000 ±2.55% 28913
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: getClosestValueIndex (nearest-thumb pick) 1233ms
· 100 thumbs ×100 picks 185,872.83 0.0000 0.2000 0.0054 0.0000 0.1000 0.1000 0.1000 ±2.70% 92955
· 1000 thumbs ×100 picks 19,448.00 0.0000 0.2000 0.0514 0.1000 0.2000 0.2000 0.2000 ±2.09% 9724
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: hasMinStepsBetweenSortedValues (drag invariant) 2044ms
· 100 values 5,019,368.13 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2510186
· 1000 values 1,238,818.25 0.0000 0.1000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.76% 619533
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > math: niceNum (tick rounding primitive) 866ms
· niceNum ×1000 (varied magnitude) 1,927,046.59 0.0000 0.1000 0.0005 0.0000 0.0000 0.1000 0.1000 ±2.76% 963716
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > ticks: niceTicks (realistic vs stress) 2234ms
· realistic (600s axis) 2,542,638.00 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.78% 1271319
· stress (10h axis, dense range) 186,886.00 0.0000 0.3000 0.0054 0.0000 0.1000 0.1000 0.1000 ±2.72% 93443
· stress + custom format 164,223.16 0.0000 0.4000 0.0061 0.0000 0.1000 0.1000 0.1000 ±2.73% 82128
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > ticks: timeTicks (human time ladder) 1426ms
· realistic (600s axis) 1,647,574.49 0.0000 0.4000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.77% 823952
· stress (10h axis, dense range) 53,833.23 0.0000 0.3000 0.0186 0.0000 0.1000 0.1000 0.2000 ±2.53% 26922
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > ticks: timecodeTicks (frame-aligned, fps conversion) 1297ms
· realistic (600s @ 30fps) 548,354.33 0.0000 0.4000 0.0018 0.0000 0.1000 0.1000 0.1000 ±2.77% 274232
· stress (10h @ 29.97fps drop-frame labels) 57,158.00 0.0000 0.4000 0.0175 0.0000 0.1000 0.1000 0.2000 ±2.57% 28579
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > ticks: frameTicks (integer-frame axis) 1210ms
· realistic (18000-frame axis) 15,666.00 0.0000 0.2000 0.0638 0.1000 0.2000 0.2000 0.2000 ±1.96% 7833
· stress (1.08M-frame axis, dense range) 583.65 1.6000 2.2000 1.7134 1.7000 1.8000 1.8000 2.2000 ±0.45% 292
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > timecode: framesToTimecode label formatting 1843ms
· non-drop ×100 131,950.00 0.0000 0.5000 0.0076 0.0000 0.1000 0.1000 0.1000 ±2.69% 65975
· drop-frame 29.97 ×100 98,946.00 0.0000 0.4000 0.0101 0.0000 0.1000 0.1000 0.1000 ±2.65% 49473
· drop-frame 29.97 ×1000 11,649.67 0.0000 0.5000 0.0858 0.1000 0.2000 0.2000 0.2000 ±1.76% 5826
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > timecode: scalar label formatters 2705ms
· formatClock ×1000 38,464.00 0.0000 0.5000 0.0260 0.1000 0.1000 0.1000 0.2000 ±2.44% 19232
· formatTimecode ×1000 (@30fps) 14,127.17 0.0000 0.5000 0.0708 0.1000 0.2000 0.2000 0.3000 ±1.93% 7065
· formatFrames ×1000 125.00 7.8000 8.1000 8.0000 8.0000 8.1000 8.1000 8.1000 ±0.20% 63
· secondsToFrames ×1000 1,958,522.30 0.0000 0.1000 0.0005 0.0000 0.0000 0.1000 0.1000 ±2.76% 979457
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > useScale: composable construction 2233ms
· build (plain options) 3,913,615.30 0.0000 0.2000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1957199
· build (clamp + step + ticks) 3,798,454.33 0.0000 5.6000 0.0003 0.0000 0.0000 0.0000 0.1000 ±3.54% 1899607
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > useScale: pointer-move loop (scale/invert/roundValue) 1825ms
· invert+round ×100 events 75,092.00 0.0000 0.3000 0.0133 0.0000 0.1000 0.1000 0.1000 ±2.59% 37546
· invert+round ×1000 events 7,250.55 0.0000 0.5000 0.1379 0.2000 0.3000 0.3000 0.4000 ±1.43% 3626
· scale ×1000 events 32,496.00 0.0000 0.2000 0.0308 0.1000 0.1000 0.2000 0.2000 ±2.36% 16248
✓ |chromium| src/internal/scale/__test__/Scale.bench.ts > useScale: reactive tick recompute on domain change (zoom/pan) 664ms
· zoom step → recompute ticks/major/minor 425,508.00 0.0000 11.8000 0.0024 0.0000 0.1000 0.1000 0.1000 ±6.84% 212754
✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > pointer → saturation/value math 2226ms
· pointerToSV — 100 moves (ltr) 2,405,101.98 0.0000 0.1000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1202551
· pointerToSV — 1000 moves (ltr) 333,625.28 0.0000 0.1000 0.0030 0.0000 0.1000 0.1000 0.1000 ±2.73% 166846
· pointerToSV — 1000 moves (rtl flip) 336,208.76 0.0000 0.1000 0.0030 0.0000 0.1000 0.1000 0.1000 ±2.73% 168138
✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > clampChannel — channel clamp 681ms
· clampChannel — 1000 calls 675,342.93 0.0000 0.2000 0.0015 0.0000 0.1000 0.1000 0.1000 ±2.75% 337739
✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > hsvToRgb — hue background recompute 1839ms
· hsvToRgb — 100 colors 187,550.00 0.0000 0.4000 0.0053 0.0000 0.1000 0.1000 0.1000 ±2.71% 93775
· hsvToRgb — 1000 colors 21,726.00 0.0000 0.3000 0.0460 0.1000 0.2000 0.2000 0.2000 ±2.19% 10863
· hsvaToCss — 1000 colors (full hsva) 15,998.80 0.0000 0.3000 0.0625 0.1000 0.2000 0.2000 0.2000 ±1.96% 8001
✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > preserve-hue setters — drag/key commit 1228ms
· setSaturationValue — 1000 commits (sweep incl. grey) 477.43 1.7000 14.6000 2.0946 1.9000 9.7000 9.8000 14.6000 ±8.93% 239
· setSaturation + setValue — 1000 key nudges 215.27 3.7000 20.6000 4.6454 4.0000 14.5000 20.6000 20.6000 ±11.18% 108
✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > mount — ColorAreaRoot + N thumbs 1574ms
· mount + unmount — 50 thumbs 481.33 1.6000 8.4000 2.0776 1.8000 8.1000 8.3000 8.4000 ±7.89% 241
· mount + unmount — 500 thumbs 48.6003 16.5000 31.7000 20.5760 24.1000 31.7000 31.7000 31.7000 ±8.52% 25
✓ |chromium| src/color/color-area/__test__/ColorArea.bench.ts > update — re-render after HSVA change 603ms
· 1 thumb — mount then patch new HSVA 8,746.25 0.0000 14.5000 0.1143 0.1000 0.2000 0.3000 6.4000 ±9.94% 4374
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > histogramMax — peak scan 4098ms
· 100 bins 4,995,467.98 0.0000 0.1000 0.0002 0.0000 0.0000 0.0000 0.1000 ±2.77% 2497734
· 256 bins 3,199,458.11 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1600049
· 1000 bins 1,277,438.00 0.0000 0.1000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.76% 638719
· 256 bins — all zero (guard path) 3,267,112.59 0.0000 0.1000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.77% 1633883
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > projectBars — linear (peak scan + normalise + alloc) 1886ms
· 100 bins 340,717.86 0.0000 1.0000 0.0029 0.0000 0.1000 0.1000 0.1000 ±2.76% 170393
· 256 bins 141,573.69 0.0000 0.5000 0.0071 0.0000 0.1000 0.1000 0.1000 ±2.70% 70801
· 1000 bins 35,842.00 0.0000 0.6000 0.0279 0.1000 0.1000 0.1000 0.2000 ±2.44% 17921
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > projectBars — log (log1p per bin + alloc) 1865ms
· 100 bins 269,120.18 0.0000 0.6000 0.0037 0.0000 0.1000 0.1000 0.1000 ±2.73% 134587
· 256 bins 107,862.00 0.0000 0.6000 0.0093 0.0000 0.1000 0.1000 0.1000 ±2.68% 53931
· 1000 bins 24,446.00 0.0000 0.9000 0.0409 0.1000 0.2000 0.2000 0.2000 ±2.29% 12223
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > projectBars — all-zero guard (no NaN, no divide) 1244ms
· 256 bins — linear 144,297.14 0.0000 0.6000 0.0069 0.0000 0.1000 0.1000 0.1000 ±2.72% 72163
· 256 bins — log 142,830.00 0.0000 0.5000 0.0070 0.0000 0.1000 0.1000 0.1000 ±2.71% 71415
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > projectBarHeight — per-bin scalar (1000x loop) 1613ms
· linear x1000 1,734,069.99 0.0000 0.2000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 867035
· log x1000 1,715,284.95 0.0000 0.1000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.76% 857814
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > per-channel projection (RGB composite, record data) 1209ms
· 4 channels x 100 bins x 2 scales 33,410.00 0.0000 1.1000 0.0299 0.1000 0.1000 0.1000 0.2000 ±2.45% 16705
· 4 channels x 1000 bins x 2 scales 3,681.26 0.1000 0.9000 0.2716 0.3000 0.4000 0.4000 0.9000 ±1.11% 1841
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > HistogramRoot + HistogramBars — mount 1843ms
· 50 bars (linear) 1,078.00 0.6000 31.0000 0.9276 0.8000 5.1000 5.2000 31.0000 ±13.21% 539
· 500 bars (linear) 137.50 5.5000 40.9000 7.2725 6.3000 40.9000 40.9000 40.9000 ±14.68% 69
· 500 bars (log) 135.59 5.6000 49.4000 7.3750 6.3000 49.4000 49.4000 49.4000 ±17.87% 68
✓ |chromium| src/canvas/histogram/__test__/Histogram.bench.ts > HistogramRoot + HistogramBars — update after prop change 1266ms
· 500 bars — scaleType linear → log 96.9721 8.8000 13.6000 10.3122 12.2000 13.6000 13.6000 13.6000 ±4.41% 49
· record data — channel l → rgb (expand to 3 primaries) 166.20 4.6000 47.2000 6.0167 5.0000 47.2000 47.2000 47.2000 ±17.36% 84
✓ |chromium| src/internal/utils/__test__/getRawChildren.bench.ts > getRawChildren 4228ms
· flat elements 6,294,584.19 0.0000 4.6000 0.0002 0.0000 0.0000 0.0000 0.1000 ±3.32% 3148551
· mixed elements and comments 1,001,372.00 0.0000 0.3000 0.0010 0.0000 0.0000 0.1000 0.1000 ±2.77% 500686
· single fragment with children 1,208,230.35 0.0000 0.3000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.77% 604236
· nested fragments (depth 5) 499,336.13 0.0000 0.3000 0.0020 0.0000 0.1000 0.1000 0.1000 ±2.75% 249718
· wide fragment (50 children) 86,290.74 0.0000 0.4000 0.0116 0.0000 0.1000 0.1000 0.1000 ±2.63% 43154
✓ |chromium| src/internal/utils/__test__/getRawChildren.bench.ts > getRawChildren — BAIL path 2383ms
· 1 keyed fragment (no BAIL) 1,520,578.00 0.0000 0.3000 0.0007 0.0000 0.0000 0.1000 0.1000 ±2.78% 760289
· 2 keyed fragments (BAIL triggered) 1,533,169.38 0.0000 0.4000 0.0007 0.0000 0.0000 0.1000 0.1000 ±2.79% 766738
· 3 keyed fragments (BAIL triggered) 1,175,990.00 0.0000 0.4000 0.0009 0.0000 0.0000 0.1000 0.1000 ±2.77% 587995
✓ |chromium| src/internal/utils/__test__/getRawChildren.bench.ts > patch — optimized vs BAIL patchFlag 2535ms
· patch with TEXT patchFlag 188,428.83 0.0000 3.6000 0.0053 0.0000 0.1000 0.1000 0.1000 ±5.49% 94384
· patch with BAIL patchFlag 186,314.00 0.0000 3.8000 0.0054 0.0000 0.1000 0.1000 0.1000 ±5.34% 93157
· patch with CLASS patchFlag 186,798.64 0.0000 5.4000 0.0054 0.0000 0.1000 0.1000 0.1000 ±5.87% 93418
· patch with CLASS→BAIL patchFlag 189,736.00 0.0000 3.9000 0.0053 0.0000 0.1000 0.1000 0.1000 ±5.56% 94868
✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > baseline: raw h() 2736ms
· h() — 1 attr 2,030,339.94 0.0000 0.5000 0.0005 0.0000 0.0000 0.0000 0.1000 ±2.79% 1015373
· h() — 5 attrs 2,006,400.00 0.0000 4.1000 0.0005 0.0000 0.0000 0.0000 0.1000 ±3.20% 1003200
· h() — 15 attrs 1,971,350.00 0.0000 0.5000 0.0005 0.0000 0.0000 0.1000 0.1000 ±2.79% 985675
✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > baseline: raw cloneVNode() 3321ms
· cloneVNode — 1 attr 4,866,745.99 0.0000 4.4000 0.0002 0.0000 0.0000 0.0000 0.1000 ±3.26% 2433373
· cloneVNode — 5 attrs 3,803,831.25 0.0000 0.4000 0.0003 0.0000 0.0000 0.0000 0.1000 ±2.78% 1902296
· cloneVNode — 15 attrs 2,318,270.00 0.0000 0.3000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1159135
✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Primitive vs h() 2658ms
· h("div") — baseline 1,979,428.12 0.0000 5.8000 0.0005 0.0000 0.0000 0.0000 0.1000 ±3.58% 989912
· Primitive({ as: "div" }) 2,033,420.00 0.0000 0.4000 0.0005 0.0000 0.0000 0.0000 0.1000 ±2.78% 1016710
· Primitive({ as: "template" }) — Slot mode 2,260,389.99 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.78% 1130195
✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Slot — scaling by attrs 2697ms
· 1 attr 2,604,471.11 0.0000 4.7000 0.0004 0.0000 0.0000 0.0000 0.1000 ±3.32% 1302496
· 5 attrs 2,245,925.99 0.0000 0.3000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.77% 1122963
· 15 attrs (mixed types) 1,642,142.00 0.0000 0.3000 0.0006 0.0000 0.0000 0.1000 0.1000 ±2.77% 821071
✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Slot — edge cases 2331ms
· child with comments to skip 1,212,457.51 0.0000 0.3000 0.0008 0.0000 0.0000 0.1000 0.1000 ±2.77% 606350
· no default slot 7,219,866.03 0.0000 0.1000 0.0001 0.0000 0.0000 0.0000 0.1000 ±2.77% 3610655
✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Slot — fresh attrs per call 1768ms
· 5 attrs (stable ref) 2,232,887.43 0.0000 0.3000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.78% 1116667
· 5 attrs (new object) 2,244,653.07 0.0000 0.4000 0.0004 0.0000 0.0000 0.0000 0.1000 ±2.79% 1122551
✓ |chromium| src/internal/primitive/__test__/Primitive.bench.ts > Primitive — mount + update via render() 1852ms
· h("div") — mount + update 135,494.00 0.0000 6.2000 0.0074 0.0000 0.1000 0.1000 0.1000 ±6.78% 67747
· Primitive({ as: "div" }) — mount + update 39,210.00 0.0000 6.5000 0.0255 0.0000 0.1000 0.1000 0.2000 ±6.71% 19605
· Primitive({ as: "template" }) — mount + update 39,744.05 0.0000 19.9000 0.0252 0.0000 0.1000 0.1000 0.2000 ±10.03% 19876
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -24,35 +24,35 @@
</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)">
<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)">
<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)">
<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)">
<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.

Some files were not shown because too many files have changed in this diff Show More