@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 < upper</code>
|
||||
<code class="text-accent-text">keyBetween</code> requires <code>lower < 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 & 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 & 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 & 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 & 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 & 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 & 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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
export * from './animationLifecycle';
|
||||
export * from './cookies';
|
||||
export * from './dom';
|
||||
export * from './domStyle';
|
||||
export * from './focusGuard';
|
||||
export * from './focusScope';
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,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 [];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & guides…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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, A–Z). */
|
||||
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, A–Z), 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,
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
<!-- 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)',
|
||||
? '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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -20,21 +20,21 @@ useHead({ title: '@robonen/tools — Documentation' });
|
||||
|
||||
<div class="comment-label mb-5">field manual · generated from source & 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
@@ -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;
|
||||
}, {}),
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,21 +963,16 @@ function roleFromName(componentName: string, base: string): string {
|
||||
return role || 'Root';
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
/**
|
||||
* 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) continue;
|
||||
if (vueFiles.length === 0) return null;
|
||||
|
||||
const slug = entry.name;
|
||||
const base = toPascalCase(slug);
|
||||
|
||||
// Anatomy = the PUBLIC parts exported from index.ts, in declared order. This
|
||||
@@ -997,20 +1013,50 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
||||
parts.push({ name, role, description, props, emits });
|
||||
}
|
||||
|
||||
const entryPoint = `./${slug}`;
|
||||
const demoPath = resolve(dir, 'demo.vue');
|
||||
const hasDemo = existsSync(demoPath);
|
||||
|
||||
components.push({
|
||||
return {
|
||||
name: base,
|
||||
slug,
|
||||
category,
|
||||
description: groupDescription,
|
||||
entryPoint,
|
||||
parts,
|
||||
hasDemo,
|
||||
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[] = [];
|
||||
|
||||
// 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];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return components.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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" */
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 |
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
Generated
+86
-83
@@ -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':
|
||||
|
||||
@@ -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><></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>
|
||||
@@ -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>
|
||||
@@ -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 & 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'))"></></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>> </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']);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user