11 Commits

Author SHA1 Message Date
robonen c8c9676d1e Merge pull request #148 from robonen/docs
Publish to NPM / Check version changes and publish (push) Failing after 2m26s
refactor(docs): remove unused broadcastedRef composable
2026-06-15 17:50:26 +07:00
robonen 91fa464d95 refactor(docs): remove unused broadcastedRef composable 2026-06-15 17:49:59 +07:00
robonen b24291ac3a Merge pull request #147 from robonen/docs
fix(docs): build workspace libs before the Nuxt build
2026-06-15 17:48:32 +07:00
robonen 98b76f46cf fix(docs): build workspace libs before the Nuxt build
The SFC type resolver resolves `@robonen/*` package imports (e.g. PrimitiveProps,
used in `defineProps<X extends PrimitiveProps>()`) via the package `exports` →
`dist/*.d.ts`, independently of the Vite source-alias. On a fresh checkout (CI /
Vercel) those dist files don't exist yet, so the compiler throws 'Failed to
resolve extends base type'. `build`/`generate` now run `build:deps` first.
2026-06-15 17:41:34 +07:00
robonen 0679160cb0 Merge pull request #146 from robonen/docs
Docs
2026-06-15 17:16:22 +07:00
robonen 4c8c3a396e chore(ci): migrate workflows from GitHub to Gitea; update lockfile 2026-06-15 16:55:23 +07:00
robonen 8adc2522c6 docs: site WIP, extractor type cleanup, tests preset; add broadcastedRef
Type the docs extractor's package.json parsing as unknown; comment the Vite
plugin version-skew cast; wire the tests preset; site/architecture WIP.
2026-06-15 16:55:22 +07:00
robonen be667df3d8 chore(stories): wire tests preset into lint config 2026-06-15 16:55:07 +07:00
robonen a147ec0730 docs(core): update crdt/encoding/fetch docs and lint config 2026-06-15 16:55:07 +07:00
robonen aa2938cb34 refactor(toolkit): type source any with proper types
Genuinely type composable any usages (useStepper/useStorage/useForm/
createEventHook/useSorted/etc.) as proper generics/unknown; keep idiomatic
any-function and overload-impl signatures with comments; skipped test -> .todo.
2026-06-15 16:55:07 +07:00
robonen 858cd8f8e0 Merge pull request #143 from robonen/docs
feat(navigation-menu): enhance context handling and lifecycle management
2026-06-10 16:36:24 +07:00
330 changed files with 5460 additions and 4015 deletions
@@ -77,7 +77,7 @@ jobs:
# browser. playwright is a direct devDep of these packages, so run its CLI
# in the package context (--filter) — it isn't resolvable from the root.
- name: Install Playwright Chromium
if: matrix.package == '@robonen/primitives' || matrix.package == '@robonen/editor'
if: matrix.package == '@robonen/primitives' || matrix.package == '@robonen/writekit'
run: pnpm --filter "${{ matrix.package }}" exec playwright install --with-deps chromium
- name: Lint
@@ -87,7 +87,7 @@ jobs:
run: pnpm --filter "${{ matrix.package }}" --if-present run test
# Sentinel job — aggregates all matrix results into a single status check.
# Add "CI" as the required check in branch protection rules.
# Add "CI" as the required status check in the branch protection rules.
ci:
name: CI
needs: check
+2 -2
View File
@@ -1,6 +1,6 @@
# @robonen/crdt
Framework-agnostic CRDT primitives — the convergence engine behind `@robonen/editor`, usable on their own. Zero runtime dependencies; pure TypeScript; runs in Node and the browser.
Framework-agnostic CRDT primitives — the convergence engine behind `@robonen/writekit`, usable on their own. Zero runtime dependencies; pure TypeScript; runs in Node and the browser.
Every primitive is built so that **applying the same set of operations in any order, with duplicates, yields the same state** (commutative, idempotent, convergent), verified by property tests.
@@ -50,7 +50,7 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged
- `compareOpId` is the single deterministic tie-break (higher clock wins; site id breaks ties) every primitive agrees on — that's what makes LWW and RGA converge.
- `VersionVector` assumes **dense** per-site clocks (1, 2, 3, …).
- The v1 wire format is JSON encoded to bytes — simple and debuggable; a compact varint format is a later optimization with no API change.
- An editor-specific composition of these primitives (blocks + text + marks ↔ editor steps) lives in `@robonen/editor` under `crdt/native/`, not here — this package stays domain-agnostic.
- A writekit-specific composition of these primitives (blocks + text + marks ↔ writekit steps) lives in `@robonen/writekit` under `crdt/native/`, not here — this package stays domain-agnostic.
## Development
+20 -20
View File
@@ -179,13 +179,13 @@ const propsSrc = `// Commutative — order of application doesn't matter:
same survivor. That single shared decision is what lets a last-writer-wins register and a sequence
CRDT, built by different code, nonetheless agree on the final document.
</p>
<div class="my-4 rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
<p class="m-0 text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-(--fg)">Why one rule for everything?</strong>
<code class="text-(--accent-text)">LwwRegister</code> uses
<code class="text-(--accent-text)">compareOpId</code> to pick the surviving value;
<code class="text-(--accent-text)">Rga</code> uses it to break ties between concurrent inserts at
the same position; <code class="text-(--accent-text)">MarkStore</code> uses it to decide which
<div class="my-4 rounded-lg border border-border bg-bg-subtle p-4">
<p class="m-0 text-sm leading-relaxed text-fg-muted">
<strong class="text-fg">Why one rule for everything?</strong>
<code class="text-accent-text">LwwRegister</code> uses
<code class="text-accent-text">compareOpId</code> to pick the surviving value;
<code class="text-accent-text">Rga</code> uses it to break ties between concurrent inserts at
the same position; <code class="text-accent-text">MarkStore</code> uses it to decide which
formatting wins per character. One total order, applied consistently, is what turns a pile of
independent primitives into a coherent, converging system.
</p>
@@ -223,11 +223,11 @@ const propsSrc = `// Commutative — order of application doesn't matter:
<DocsCode :code="vvWireSrc" lang="ts" />
<div class="prose-docs">
<div class="my-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
<p class="m-0 text-sm leading-relaxed text-(--fg-muted)">
<p class="m-0 text-sm leading-relaxed text-fg-muted">
<strong class="text-amber-700 dark:text-amber-400">Density matters.</strong>
<code class="text-(--accent-text)">VersionVector</code> only works because clocks arrive without
gaps. If you generate ids with a raw <code class="text-(--accent-text)">LamportClock</code>, deliver
them in order per site (the <code class="text-(--accent-text)">Replica</code>'s causal buffer does
<code class="text-accent-text">VersionVector</code> only works because clocks arrive without
gaps. If you generate ids with a raw <code class="text-accent-text">LamportClock</code>, deliver
them in order per site (the <code class="text-accent-text">Replica</code>'s causal buffer does
this for you) so a single high-water mark per site can stand in for the full set of seen ops.
</p>
</div>
@@ -242,23 +242,23 @@ const propsSrc = `// Commutative — order of application doesn't matter:
</div>
<DocsCode :code="propsSrc" lang="ts" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Commutative</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Commutative</h3>
<p class="text-sm leading-relaxed text-fg-muted">
Order of application doesn't change the result. A replica can integrate operations as they arrive,
in whatever sequence the network delivers them.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Idempotent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Idempotent</h3>
<p class="text-sm leading-relaxed text-fg-muted">
Applying the same operation twice is the same as applying it once. Redelivery and retries are safe;
version vectors make them free.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Convergent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Convergent</h3>
<p class="text-sm leading-relaxed text-fg-muted">
Same set of operations, same final state — full stop. Two replicas that have seen the same ops are
byte-for-byte identical.
</p>
+30 -30
View File
@@ -198,33 +198,33 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
<!-- Map of the package -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Registers</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">LwwRegister</code> and
<code class="text-(--accent-text)">LwwMap</code> single values and keyed maps where the
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Registers</h3>
<p class="text-sm leading-relaxed text-fg-muted">
<code class="text-accent-text">LwwRegister</code> and
<code class="text-accent-text">LwwMap</code> single values and keyed maps where the
write with the highest op id wins.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Ordering</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">keyBetween</code> /
<code class="text-(--accent-text)">keysBetween</code> fractional indexing to place or move
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Ordering</h3>
<p class="text-sm leading-relaxed text-fg-muted">
<code class="text-accent-text">keyBetween</code> /
<code class="text-accent-text">keysBetween</code> fractional indexing to place or move
an item with a single string key.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Sequence</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">Rga</code> a replicated growable array: an ordered
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Sequence</h3>
<p class="text-sm leading-relaxed text-fg-muted">
<code class="text-accent-text">Rga</code> a replicated growable array: an ordered
sequence CRDT with tombstones and a deterministic insert tie-break.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Marks</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">MarkStore</code> lightweight Peritext formatting spans
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Marks</h3>
<p class="text-sm leading-relaxed text-fg-muted">
<code class="text-accent-text">MarkStore</code> lightweight Peritext formatting spans
anchored to character op ids, resolved per character by highest op id.
</p>
</div>
@@ -262,12 +262,12 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
</div>
<DocsCode :code="lwwMap" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-(--fg)">Why keep tombstones?</strong> If a delete simply dropped the entry,
a concurrent <code class="text-(--accent-text)">set</code> arriving afterward would resurrect
<div class="rounded-lg border border-border bg-bg-subtle p-4">
<p class="text-sm leading-relaxed text-fg-muted">
<strong class="text-fg">Why keep tombstones?</strong> If a delete simply dropped the entry,
a concurrent <code class="text-accent-text">set</code> arriving afterward would resurrect
the key the two replicas would disagree on whether it exists. Retaining the delete as a
timestamped tombstone lets <code class="text-(--accent-text)">compareOpId</code> decide the
timestamped tombstone lets <code class="text-accent-text">compareOpId</code> decide the
winner deterministically, the same way it does for live values.
</p>
</div>
@@ -308,9 +308,9 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
<DocsCode :code="fractionalBatch" lang="ts" />
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)">
<p class="text-sm leading-relaxed text-fg-muted">
<strong class="text-amber-700 dark:text-amber-400">Heads up:</strong>
<code class="text-(--accent-text)">keyBetween</code> requires <code>lower &lt; upper</code>
<code class="text-accent-text">keyBetween</code> requires <code>lower &lt; upper</code>
and throws otherwise. Two replicas independently generating a key between the
<em>same</em> neighbors can produce identical keys; pair the key with the item's op id as a
secondary sort to keep ordering deterministic, or let
@@ -366,14 +366,14 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
</div>
<DocsCode :code="rgaBuffer" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-(--fg)">Garbage collection.</strong> Tombstones accumulate. When every
replica has fully synced and nothing is in flight, <code class="text-(--accent-text)">gc(stable, keep?)</code>
<div class="rounded-lg border border-border bg-bg-subtle p-4">
<p class="text-sm leading-relaxed text-fg-muted">
<strong class="text-fg">Garbage collection.</strong> Tombstones accumulate. When every
replica has fully synced and nothing is in flight, <code class="text-accent-text">gc(stable, keep?)</code>
drops deleted nodes whose insert is covered by a stable
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink>, returning how many it removed.
Run it only at quiescence a late op that uses a dropped node as its origin could no longer
integrate and pass <code class="text-(--accent-text)">keep</code> to protect ids still
integrate and pass <code class="text-accent-text">keep</code> to protect ids still
referenced elsewhere, such as mark span endpoints.
</p>
</div>
+8 -8
View File
@@ -249,12 +249,12 @@ a.replica.receive(ops);`;
</div>
<!-- Why order does not matter -->
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Why the order of the two deltas is irrelevant</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
You could swap the two <code class="text-(--accent-text)">receive</code> lines, run them
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Why the order of the two deltas is irrelevant</h3>
<p class="text-sm leading-relaxed text-fg-muted">
You could swap the two <code class="text-accent-text">receive</code> lines, run them
repeatedly, or interleave them with more edits — the result is the same. Each side only ever
adds ops it hasn't seen, and <code class="text-(--accent-text)">compareOpId</code> places
adds ops it hasn't seen, and <code class="text-accent-text">compareOpId</code> places
each op in its deterministic position regardless of arrival order. That is convergence,
and the property tests assert it across randomized schedules.
</p>
@@ -346,11 +346,11 @@ a.replica.receive(ops);`;
<!-- Caveat callout -->
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-5">
<h3 class="mb-1.5 text-sm font-semibold text-amber-700 dark:text-amber-400">Dense clocks are a precondition</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<p class="text-sm leading-relaxed text-fg-muted">
Version vectors assume each site's clocks are dense (1, 2, 3, ). That holds automatically
when ids come from <code class="text-(--accent-text)">Replica.nextId()</code>. If you mint
when ids come from <code class="text-accent-text">Replica.nextId()</code>. If you mint
ids yourself, never skip a value for a site a gap would make
<code class="text-(--accent-text)">delta</code> believe a missing op was already delivered.
<code class="text-accent-text">delta</code> believe a missing op was already delivered.
</p>
</div>
+36 -36
View File
@@ -260,17 +260,17 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
<ClientOnly>
<template #fallback>
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-8 text-center text-sm text-(--fg-subtle)">
<div class="rounded-xl border border-border bg-bg-subtle p-8 text-center text-sm text-fg-subtle">
Loading interactive demo
</div>
</template>
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4 sm:p-5">
<div class="rounded-xl border border-border bg-bg-subtle p-4 sm:p-5">
<div v-if="!ready" class="flex flex-col items-center gap-3 py-8 text-center">
<p class="text-sm text-(--fg-muted)">Spin up two fresh replicas to start editing.</p>
<p class="text-sm text-fg-muted">Spin up two fresh replicas to start editing.</p>
<button
type="button"
class="rounded-md bg-(--accent) px-4 py-2 text-sm font-medium text-(--accent-fg) hover:bg-(--accent-hover) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:bg-accent-hover focus:outline-none focus:ring-2 focus:ring-ring"
@click="start()"
>
Start demo
@@ -281,82 +281,82 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
<!-- Two replica panes -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Replica A -->
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) p-3">
<div class="flex flex-col gap-2 rounded-lg border border-border bg-bg-elevated p-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider text-(--fg-muted)">Replica A</span>
<span class="rounded bg-(--bg-inset) px-1.5 py-0.5 font-mono text-[11px] text-(--fg-subtle)">site: A</span>
<span class="text-xs font-semibold uppercase tracking-wider text-fg-muted">Replica A</span>
<span class="rounded bg-bg-inset px-1.5 py-0.5 font-mono text-[11px] text-fg-subtle">site: A</span>
</div>
<textarea
v-model="drafts.a"
rows="3"
spellcheck="false"
class="resize-none rounded-md border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) focus:border-(--border-strong) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="resize-none rounded-md border border-border bg-bg px-3 py-2 font-mono text-sm text-fg focus:border-border-strong focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Type on A…"
/>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-xs font-medium text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="rounded-md border border-border bg-bg-elevated px-3 py-1.5 text-xs font-medium text-fg hover:bg-bg-inset focus:outline-none focus:ring-2 focus:ring-ring"
@click="apply('a')"
>
Apply edits
</button>
<div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-(--fg-subtle)">
<div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-fg-subtle">
<span>ops {{ snapshot.a.ops }}</span>
<span>clock {{ snapshot.a.clock }}</span>
</div>
</div>
<div class="rounded-md bg-(--bg-inset) px-3 py-2 font-mono text-sm text-(--fg) break-all min-h-9">
<div class="rounded-md bg-bg-inset px-3 py-2 font-mono text-sm text-fg break-all min-h-9">
<span v-if="snapshot.a.text">{{ snapshot.a.text }}</span>
<span v-else class="text-(--fg-subtle)">(empty)</span>
<span v-else class="text-fg-subtle">(empty)</span>
</div>
</div>
<!-- Replica B -->
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) p-3">
<div class="flex flex-col gap-2 rounded-lg border border-border bg-bg-elevated p-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider text-(--fg-muted)">Replica B</span>
<span class="rounded bg-(--bg-inset) px-1.5 py-0.5 font-mono text-[11px] text-(--fg-subtle)">site: B</span>
<span class="text-xs font-semibold uppercase tracking-wider text-fg-muted">Replica B</span>
<span class="rounded bg-bg-inset px-1.5 py-0.5 font-mono text-[11px] text-fg-subtle">site: B</span>
</div>
<textarea
v-model="drafts.b"
rows="3"
spellcheck="false"
class="resize-none rounded-md border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) focus:border-(--border-strong) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="resize-none rounded-md border border-border bg-bg px-3 py-2 font-mono text-sm text-fg focus:border-border-strong focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Type on B…"
/>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-xs font-medium text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="rounded-md border border-border bg-bg-elevated px-3 py-1.5 text-xs font-medium text-fg hover:bg-bg-inset focus:outline-none focus:ring-2 focus:ring-ring"
@click="apply('b')"
>
Apply edits
</button>
<div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-(--fg-subtle)">
<div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-fg-subtle">
<span>ops {{ snapshot.b.ops }}</span>
<span>clock {{ snapshot.b.clock }}</span>
</div>
</div>
<div class="rounded-md bg-(--bg-inset) px-3 py-2 font-mono text-sm text-(--fg) break-all min-h-9">
<div class="rounded-md bg-bg-inset px-3 py-2 font-mono text-sm text-fg break-all min-h-9">
<span v-if="snapshot.b.text">{{ snapshot.b.text }}</span>
<span v-else class="text-(--fg-subtle)">(empty)</span>
<span v-else class="text-fg-subtle">(empty)</span>
</div>
</div>
</div>
<!-- Sync bar -->
<div class="flex flex-wrap items-center gap-3 border-t border-(--border) pt-3">
<div class="flex flex-wrap items-center gap-3 border-t border-border pt-3">
<button
type="button"
class="rounded-md bg-(--accent) px-4 py-2 text-sm font-medium text-(--accent-fg) hover:bg-(--accent-hover) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:bg-accent-hover focus:outline-none focus:ring-2 focus:ring-ring"
@click="sync()"
>
Sync
</button>
<button
type="button"
class="rounded-md px-3 py-2 text-sm text-(--fg-muted) hover:bg-(--bg-inset) hover:text-(--fg) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="rounded-md px-3 py-2 text-sm text-fg-muted hover:bg-bg-inset hover:text-fg focus:outline-none focus:ring-2 focus:ring-ring"
@click="init()"
>
Reset
@@ -436,27 +436,27 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Commutative</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Commutative</h3>
<p class="text-sm leading-relaxed text-fg-muted">
A-then-B and B-then-A produce the same sequence. Concurrent inserts at the same origin are
ordered by <code class="text-(--accent-text)">compareOpId</code>, so order of arrival
ordered by <code class="text-accent-text">compareOpId</code>, so order of arrival
doesn't matter.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Idempotent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Idempotent</h3>
<p class="text-sm leading-relaxed text-fg-muted">
Receiving the same op twice is a no-op. The op log's version vector dedups on
<code class="text-(--accent-text)">id</code>, and <code class="text-(--accent-text)">integrateInsert</code>
<code class="text-accent-text">id</code>, and <code class="text-accent-text">integrateInsert</code>
short-circuits if the id is already present.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Causal</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
An insert can't integrate before its <code class="text-(--accent-text)">originLeft</code>,
nor a delete before its target. <code class="text-(--accent-text)">receive</code> buffers
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Causal</h3>
<p class="text-sm leading-relaxed text-fg-muted">
An insert can't integrate before its <code class="text-accent-text">originLeft</code>,
nor a delete before its target. <code class="text-accent-text">receive</code> buffers
such ops and retries them, so out-of-order delivery still converges.
</p>
</div>
+17 -17
View File
@@ -55,40 +55,40 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged`;
offline, with messages that arrive out of order or twice. A CRDT solves this by construction:
every primitive here is <strong>commutative, idempotent, and convergent</strong>, so applying
the same set of operations in any order yields the same state a property verified by
property tests. It's the convergence engine behind <code>@robonen/editor</code>, but stays
property tests. It's the convergence engine behind <code>@robonen/writekit</code>, but stays
fully domain-agnostic, ships zero runtime dependencies, and runs in both Node and the browser.
</p>
</div>
<!-- Feature cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Convergent by construction</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
One deterministic tie-break — <code class="text-(--accent-text)">compareOpId</code> (higher
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Convergent by construction</h3>
<p class="text-sm leading-relaxed text-fg-muted">
One deterministic tie-break — <code class="text-accent-text">compareOpId</code> (higher
Lamport clock wins; site id breaks ties) — is shared by every primitive, so LWW and RGA agree
on the same final state.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Causal buffering built in</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">Replica.receive</code> dedups, holds ops whose dependencies
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Causal buffering built in</h3>
<p class="text-sm leading-relaxed text-fg-muted">
<code class="text-accent-text">Replica.receive</code> dedups, holds ops whose dependencies
haven't arrived yet (an insert before its origin), and retries them automatically as they land.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Delta sync, not full state</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Delta sync, not full state</h3>
<p class="text-sm leading-relaxed text-fg-muted">
Version vectors let each side request exactly the ops it's missing via
<code class="text-(--accent-text)">delta(version)</code>, with a transport-agnostic wire format.
<code class="text-accent-text">delta(version)</code>, with a transport-agnostic wire format.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Zero dependencies, pure TS</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-fg">Zero dependencies, pure TS</h3>
<p class="text-sm leading-relaxed text-fg-muted">
No runtime deps, no framework lock-in. Compose the primitives yourself, or lean on
<code class="text-(--accent-text)">Replica</code> to tie a clock, op log, and buffer together.
<code class="text-accent-text">Replica</code> to tie a clock, op log, and buffer together.
</p>
</div>
</div>
+2 -2
View File
@@ -1,3 +1,3 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
import { base, compose, imports, stylistic, tests, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic);
export default compose(base, typescript, imports, stylistic, tests);
+12 -12
View File
@@ -27,36 +27,36 @@
<!-- Feature cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">High-level QR in one call</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
<div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-fg">High-level QR in one call</h3>
<p class="mt-1.5 text-sm text-fg-muted">
<code>encodeText</code> and <code>encodeBinary</code> pick the smallest
version and optimal segment modes for you, then hand back an immutable
<code>QrCode</code> grid.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">Render-agnostic output</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
<div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-fg">Render-agnostic output</h3>
<p class="mt-1.5 text-sm text-fg-muted">
A <code>QrCode</code> is just a square of modules. Read each one with
<code>getModule(x, y)</code> and draw to SVG, canvas, or anything else
no rendering opinions baked in.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">Standalone Reed-Solomon</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
<div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-fg">Standalone Reed-Solomon</h3>
<p class="mt-1.5 text-sm text-fg-muted">
The GF(2^8) error-correction core <code>multiply</code>,
<code>computeDivisor</code>, <code>computeRemainder</code> is exported
on its own, reusable beyond QR.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">Zero dependencies, fully typed</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
<div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-fg">Zero dependencies, fully typed</h3>
<p class="mt-1.5 text-sm text-fg-muted">
Tree-shakeable ESM and CJS builds with no third-party runtime deps, hot
loops backed by typed arrays, and end-to-end TypeScript types.
</p>
+2 -2
View File
@@ -1,4 +1,4 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
import { base, compose, imports, stylistic, tests, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic, {
name: 'encoding/overrides',
@@ -10,4 +10,4 @@ export default compose(base, typescript, imports, stylistic, {
oldest register's seed/last write is intentionally dead — keep symmetry. */
'no-useless-assignment': 'off',
},
});
}, tests);
+12 -12
View File
@@ -59,35 +59,35 @@ const billing = api.extend({ baseURL: 'https://billing.example.com' });`;
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">Type-safe end to end</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
<div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-fg">Type-safe end to end</h3>
<p class="mt-1.5 text-sm text-fg-muted">
Response data, request options, and plugin-contributed fields are all inferred
the parsed body comes back typed, no casting required.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">Smart bodies &amp; parsing</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
<div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-fg">Smart bodies &amp; parsing</h3>
<p class="mt-1.5 text-sm text-fg-muted">
Plain objects are JSON-serialized; <code>FormData</code>/<code>Blob</code>/streams
pass through untouched. Responses are decoded from <code>Content-Type</code> or
forced via <code>responseType</code>.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">Retry, timeout &amp; errors</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
<div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-fg">Retry, timeout &amp; errors</h3>
<p class="mt-1.5 text-sm text-fg-muted">
Built-in retry and per-attempt timeout with sensible defaults, and non-2xx
responses reject with a rich <code>FetchError</code> carrying status, request,
and parsed body.
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
<h3 class="text-sm font-semibold text-(--fg)">Hooks &amp; plugins</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)">
<div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-fg">Hooks &amp; plugins</h3>
<p class="mt-1.5 text-sm text-fg-muted">
Lifecycle hooks plus a typed, composable plugin system with onion-style
<code>execute</code> middleware composed once, with zero per-request overhead
beyond the hooks themselves.
+2 -2
View File
@@ -1,3 +1,3 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
import { base, compose, imports, stylistic, tests, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic);
export default compose(base, typescript, imports, stylistic, tests);
+75
View File
@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
const { data: home } = await useAsyncData(() => queryCollection('renovate').first());
useSeoMeta({
title: home.value?.title,
description: home.value?.description,
});
</script>
<template>
<ContentRenderer v-if="home" :value="home" />
<div v-else>Home not found</div>
</template>
+99 -34
View File
@@ -16,48 +16,113 @@
--radius-card: 0.5rem;
}
/* ── Semantic colour utilities ─────────────────────────────────────────────
Register the runtime theme tokens as Tailwind colours so templates use clean
utilities (`bg-bg`, `text-fg`, `border-border`, `ring-ring`, `bg-accent`…)
instead of the `bg-(--bg)` arbitrary-value escape hatch. `inline` makes each
utility emit `var(--token)` directly, so it stays switchable by the `.dark`
override below AND gains opacity modifiers (`bg-bg/80` → color-mix). The raw
`--token`s remain the single source of truth (consumed directly via `var()`
in the prose/identity CSS); these are thin aliases over them. */
@theme inline {
--color-bg: var(--bg);
--color-bg-subtle: var(--bg-subtle);
--color-bg-elevated: var(--bg-elevated);
--color-bg-inset: var(--bg-inset);
--color-border: var(--border);
--color-border-strong: var(--border-strong);
--color-fg: var(--fg);
--color-fg-muted: var(--fg-muted);
--color-fg-subtle: var(--fg-subtle);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-fg: var(--accent-fg);
--color-accent-subtle: var(--accent-subtle);
--color-accent-text: var(--accent-text);
--color-header-bg: var(--header-bg);
--color-ring: var(--ring);
}
/* ── Demo design-system shortcuts ──────────────────────────────────────────
The package demo.vue files share a small visual vocabulary: a width-capped
vertical shell, a code-comment eyebrow label, button/badge chrome, inputs,
and card surfaces. These were copy-pasted as identical Tailwind strings
across ~240 demos. Collapsed here into semantic utilities so the look is
tuned in one place. Each is the common CORE of its pattern — per-demo extras
(max-width, padding, disabled states, w-full, sizes) stay on the element, so
the rendered result is unchanged. */
@utility demo-stack {
@apply flex w-full flex-col gap-4;
}
@utility demo-label {
@apply text-xs font-medium uppercase tracking-wide text-fg-subtle;
}
@utility demo-card {
@apply rounded-xl border border-border bg-bg-elevated;
}
@utility demo-btn {
@apply inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-border bg-bg-elevated px-3 py-1.5 text-sm font-medium text-fg transition hover:bg-bg-inset hover:border-border-strong active:scale-[0.98];
}
@utility demo-btn-primary {
@apply inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-transparent bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition hover:bg-accent-hover active:scale-[0.98];
}
@utility demo-badge {
@apply inline-flex items-center gap-1.5 rounded-md border border-border bg-bg-inset px-2 py-0.5 text-xs font-medium text-fg-muted;
}
@utility demo-input {
@apply w-full rounded-lg border border-border bg-bg px-3 py-2 text-sm text-fg transition placeholder:text-fg-subtle focus:border-accent focus:outline-none focus:ring-2 focus:ring-ring;
}
@utility demo-stat {
@apply font-mono font-bold tabular-nums text-fg;
}
/* ── Semantic design tokens — ink on warm paper, signal-orange instruments ──
The site reads like a tool-maker's field manual: warm neutral surfaces,
hairline rules, international-orange accents, code-comment labels. */
:root {
--bg: #faf8f3;
--bg-subtle: #f4f1e8;
--bg-elevated: #fffdf8;
--bg-inset: #eeeadf;
--border: #e5dfd0;
--border-strong: #cfc6b1;
--fg: #211e18;
--fg-muted: #5d574b;
--fg-subtle: #93897a;
--accent: #d9480f;
--accent-hover: #bf3f0d;
--accent-fg: #fffdf8;
--accent-subtle: #f7e7d8;
--accent-text: #c2410c;
--header-bg: rgba(250, 248, 243, 0.82);
--ring: rgba(217, 72, 15, 0.35);
--shadow-card: 0 1px 2px rgba(56, 44, 28, 0.05), 0 1px 3px rgba(56, 44, 28, 0.07);
/* Colours are OKLCH (perceptually uniform — even lightness steps, predictable
hue) and are exact equivalents of the original hand-tuned sRGB palette.
Translucent tokens derive from their base via color-mix(), so they track
theme + accent retuning automatically instead of duplicating a literal. */
--bg: oklch(0.9793 0.007 88.64);
--bg-subtle: oklch(0.958 0.0124 91.52);
--bg-elevated: oklch(0.9942 0.0069 88.64);
--bg-inset: oklch(0.9371 0.0153 90.24);
--border: oklch(0.9043 0.0211 88.73);
--border-strong: oklch(0.8282 0.0303 87.56);
--fg: oklch(0.2363 0.012 84.56);
--fg-muted: oklch(0.4588 0.0204 84.58);
--fg-subtle: oklch(0.6346 0.0249 78.12);
--accent: oklch(0.5999 0.1905 37.88);
--accent-hover: oklch(0.5461 0.1724 37.96);
--accent-fg: oklch(0.9942 0.0069 88.64);
--accent-subtle: oklch(0.9367 0.0266 65.68);
--accent-text: oklch(0.5534 0.1739 38.4);
--header-bg: color-mix(in oklch, var(--bg) 82%, transparent);
--ring: color-mix(in oklch, var(--accent) 35%, transparent);
--shadow-card: 0 1px 2px oklch(0.302 0.0319 74.11 / 0.05), 0 1px 3px oklch(0.302 0.0319 74.11 / 0.07);
color-scheme: light;
}
.dark {
--bg: #161310;
--bg-subtle: #1b1813;
--bg-elevated: #211d17;
--bg-inset: #2a251c;
--border: #322c22;
--border-strong: #4a4231;
--fg: #ece7db;
--fg-muted: #b2a995;
--fg-subtle: #7d7363;
--accent: #ff7d33;
--accent-hover: #ff9a59;
--accent-fg: #1d0e04;
--accent-subtle: #3a2415;
--accent-text: #ff9c63;
--header-bg: rgba(22, 19, 16, 0.82);
--ring: rgba(255, 125, 51, 0.4);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.5);
--bg: oklch(0.1892 0.0077 67.33);
--bg-subtle: oklch(0.2107 0.0106 80.56);
--bg-elevated: oklch(0.2332 0.0127 78);
--bg-inset: oklch(0.267 0.0176 82.2);
--border: oklch(0.2964 0.0194 80.44);
--border-strong: oklch(0.3822 0.0294 85.68);
--fg: oklch(0.9286 0.0169 88);
--fg-muted: oklch(0.7369 0.0298 86.66);
--fg-subtle: oklch(0.56 0.0269 79.61);
--accent: oklch(0.7294 0.1789 46.57);
--accent-hover: oklch(0.7788 0.1452 51.83);
--accent-fg: oklch(0.1825 0.0328 56.53);
--accent-subtle: oklch(0.284 0.042 54.49);
--accent-text: oklch(0.7835 0.139 49.63);
/* --header-bg is not re-declared: the :root color-mix tracks --bg, which we
override above. Only --ring needs a tweak (slightly stronger in dark). */
--ring: color-mix(in oklch, var(--accent) 40%, transparent);
--shadow-card: 0 1px 2px oklch(0 0 0 / 0.4), 0 1px 3px oklch(0 0 0 / 0.5);
color-scheme: dark;
}
+2 -2
View File
@@ -22,8 +22,8 @@ const kindLabels: Record<string, string> = {
:class="[
'inline-flex items-center justify-center rounded font-mono font-medium shrink-0 border',
kind === 'component'
? 'border-(--accent-subtle) bg-(--accent-subtle) text-(--accent-text)'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)',
? 'border-accent-subtle bg-accent-subtle text-accent-text'
: 'border-border bg-bg-inset text-fg-muted',
size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs',
]"
:title="kind"
+5 -5
View File
@@ -39,12 +39,12 @@ async function copy() {
</script>
<template>
<div class="group relative rounded-xl border border-(--border) bg-(--bg-subtle) overflow-hidden max-w-full">
<div v-if="!bare" class="flex items-center justify-between px-3 h-9 border-b border-(--border) bg-(--bg-subtle)">
<span class="text-[11px] font-mono uppercase tracking-wider text-(--fg-subtle)">{{ langLabel }}</span>
<div class="group relative rounded-xl border border-border bg-bg-subtle overflow-hidden max-w-full">
<div v-if="!bare" class="flex items-center justify-between px-3 h-9 border-b border-border bg-bg-subtle">
<span class="text-[11px] font-mono uppercase tracking-wider text-fg-subtle">{{ langLabel }}</span>
<button
type="button"
class="inline-flex items-center gap-1 text-[11px] font-medium text-(--fg-subtle) hover:text-(--fg) transition-colors cursor-pointer"
class="inline-flex items-center gap-1 text-[11px] font-medium text-fg-subtle hover:text-fg transition-colors cursor-pointer"
@click="copy"
>
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -59,7 +59,7 @@ async function copy() {
<button
v-else
type="button"
class="absolute right-2 top-2 z-10 inline-flex items-center justify-center w-7 h-7 rounded-md bg-(--bg-elevated) border border-(--border) text-(--fg-subtle) opacity-0 group-hover:opacity-100 hover:text-(--fg) transition-all cursor-pointer"
class="absolute right-2 top-2 z-10 inline-flex items-center justify-center w-7 h-7 rounded-md bg-bg-elevated border border-border text-fg-subtle opacity-0 group-hover:opacity-100 hover:text-fg transition-all cursor-pointer"
title="Copy"
@click="copy"
>
+8 -8
View File
@@ -43,10 +43,10 @@ const roleColor: Record<string, string> = {
<div class="space-y-10">
<!-- Anatomy snippet -->
<section>
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-3">
<h2 class="text-xs font-semibold uppercase tracking-wider text-fg-subtle mb-3">
Anatomy
</h2>
<p class="text-sm text-(--fg-muted) mb-3">
<p class="text-sm text-fg-muted mb-3">
Import the parts and compose them. Each part forwards attributes to its underlying element.
</p>
<DocsCode :code="anatomyCode" lang="vue" />
@@ -54,7 +54,7 @@ const roleColor: Record<string, string> = {
<!-- Parts -->
<section>
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">
<h2 class="text-xs font-semibold uppercase tracking-wider text-fg-subtle mb-4">
API Reference
</h2>
<div class="space-y-8">
@@ -65,18 +65,18 @@ const roleColor: Record<string, string> = {
class="scroll-mt-20"
>
<div class="flex items-center gap-2.5 mb-2">
<h3 class="font-mono text-base font-semibold text-(--fg)">{{ part.name }}</h3>
<h3 class="font-mono text-base font-semibold text-fg">{{ part.name }}</h3>
<span
:class="[
'text-[11px] px-2 py-0.5 rounded-full font-medium leading-none',
roleColor[part.role] ?? 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)',
roleColor[part.role] ?? 'bg-bg-inset text-fg-muted border border-border',
]"
>
{{ part.role }}
</span>
</div>
<p v-if="part.description" class="text-sm text-(--fg-muted) mb-3 max-w-2xl">
<p v-if="part.description" class="text-sm text-fg-muted mb-3 max-w-2xl">
{{ part.description }}
</p>
@@ -85,11 +85,11 @@ const roleColor: Record<string, string> = {
</div>
<div v-if="part.emits.length > 0" class="mb-3">
<div class="text-[11px] font-semibold uppercase tracking-wider text-(--fg-subtle) mb-2">Emits</div>
<div class="text-[11px] font-semibold uppercase tracking-wider text-fg-subtle mb-2">Emits</div>
<DocsEmitsTable :emits="part.emits" />
</div>
<p v-if="part.props.length === 0 && part.emits.length === 0" class="text-sm text-(--fg-subtle) italic">
<p v-if="part.props.length === 0 && part.emits.length === 0" class="text-sm text-fg-subtle italic">
No props or events renders its element and forwards attributes.
</p>
</div>
+6 -6
View File
@@ -24,14 +24,14 @@ watch(showSource, async (show) => {
</script>
<template>
<div class="rounded-xl border border-(--border) overflow-hidden">
<div class="rounded-xl border border-border overflow-hidden">
<!-- Live demo client-only: demos are interactive and use browser APIs,
so they must not be instantiated during SSR/prerender. -->
<div class="p-4 sm:p-8 bg-(--bg-subtle) flex items-center justify-center min-h-32">
<div class="p-4 sm:p-8 bg-bg-subtle flex items-center justify-center min-h-32">
<ClientOnly>
<component :is="component" />
<template #fallback>
<div class="flex items-center gap-2 text-sm text-(--fg-subtle)">
<div class="flex items-center gap-2 text-sm text-fg-subtle">
<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
@@ -42,10 +42,10 @@ watch(showSource, async (show) => {
</div>
<!-- Source toggle bar -->
<div class="flex items-center border-t border-(--border) bg-(--bg-elevated)">
<div class="flex items-center border-t border-border bg-bg-elevated">
<button
type="button"
class="flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium text-(--fg-muted) hover:text-(--fg) transition-colors cursor-pointer"
class="flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium text-fg-muted hover:text-fg transition-colors cursor-pointer"
@click="showSource = !showSource"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -62,7 +62,7 @@ watch(showSource, async (show) => {
</div>
<!-- Source code -->
<div v-if="showSource" class="border-t border-(--border) bg-(--bg-subtle)">
<div v-if="showSource" class="border-t border-border bg-bg-subtle">
<div class="overflow-x-auto text-[13px] [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!" v-html="highlighted" />
</div>
</div>
+7 -7
View File
@@ -6,21 +6,21 @@ defineProps<{
</script>
<template>
<div v-if="emits.length > 0" class="overflow-x-auto rounded-xl border border-(--border)">
<div v-if="emits.length > 0" class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="bg-(--bg-subtle) text-left">
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Event</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Payload</th>
<tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Event</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Payload</th>
</tr>
</thead>
<tbody>
<tr v-for="e in emits" :key="e.name" class="border-t border-(--border) align-top">
<tr v-for="e in emits" :key="e.name" class="border-t border-border align-top">
<td class="py-2.5 px-4 whitespace-nowrap">
<code class="text-(--accent-text) font-mono text-[13px] font-medium">{{ e.name }}</code>
<code class="text-accent-text font-mono text-[13px] font-medium">{{ e.name }}</code>
</td>
<td class="py-2.5 px-4">
<code class="text-xs font-mono text-(--fg-muted) bg-(--bg-inset) px-1.5 py-0.5 rounded border border-(--border) wrap-break-word">{{ e.payload }}</code>
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ e.payload }}</code>
</td>
</tr>
</tbody>
+1 -1
View File
@@ -28,7 +28,7 @@ async function highlightCodeBlocks() {
try {
const out = await highlight(text, resolved);
const wrapper = document.createElement('div');
wrapper.className = 'not-prose rounded-xl border border-(--border) bg-(--bg-subtle) overflow-x-auto text-[13px] my-5 [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!';
wrapper.className = 'not-prose rounded-xl border border-border bg-bg-subtle overflow-x-auto text-[13px] my-5 [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!';
wrapper.innerHTML = out;
pre.replaceWith(wrapper);
}
+7 -7
View File
@@ -10,19 +10,19 @@ defineProps<{
<div
v-for="method in methods"
:key="method.name"
class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4"
class="rounded-xl border border-border bg-bg-subtle p-4"
>
<div class="flex items-center gap-2 mb-2">
<code class="text-sm font-mono font-semibold text-(--fg)">{{ method.name }}</code>
<code class="text-sm font-mono font-semibold text-fg">{{ method.name }}</code>
<span
v-if="method.visibility !== 'public'"
class="text-[10px] uppercase px-1.5 py-0.5 rounded bg-(--bg-inset) border border-(--border) text-(--fg-subtle)"
class="text-[10px] uppercase px-1.5 py-0.5 rounded bg-bg-inset border border-border text-fg-subtle"
>
{{ method.visibility }}
</span>
</div>
<p v-if="method.description" class="text-sm text-(--fg-muted) mb-3">
<p v-if="method.description" class="text-sm text-fg-muted mb-3">
<DocsText :text="method.description" />
</p>
@@ -36,9 +36,9 @@ defineProps<{
<DocsParamsTable v-if="method.params.length > 0" :params="method.params" />
<div v-if="method.returns" class="mt-2 text-sm">
<span class="text-(--fg-subtle)">Returns</span>
<code class="ml-1.5 text-xs font-mono bg-(--bg-inset) border border-(--border) px-1.5 py-0.5 rounded">{{ method.returns.type }}</code>
<DocsText v-if="method.returns.description" :text="method.returns.description" class="ml-2 text-(--fg-muted)" />
<span class="text-fg-subtle">Returns</span>
<code class="ml-1.5 text-xs font-mono bg-bg-inset border border-border px-1.5 py-0.5 rounded">{{ method.returns.type }}</code>
<DocsText v-if="method.returns.description" :text="method.returns.description" class="ml-2 text-fg-muted" />
</div>
</div>
</div>
+12 -12
View File
@@ -6,33 +6,33 @@ defineProps<{
</script>
<template>
<div v-if="params.length > 0" class="overflow-x-auto rounded-xl border border-(--border)">
<div v-if="params.length > 0" class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="bg-(--bg-subtle) text-left">
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Parameter</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Type</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider hidden sm:table-cell">Default</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Description</th>
<tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Parameter</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Type</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider hidden sm:table-cell">Default</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr>
</thead>
<tbody>
<tr
v-for="param in params"
:key="param.name"
class="border-t border-(--border) align-top"
class="border-t border-border align-top"
>
<td class="py-2.5 px-4 whitespace-nowrap">
<code class="text-(--accent-text) font-mono text-[13px] font-medium">{{ param.name }}</code><span v-if="param.optional" class="text-(--fg-subtle) text-xs">?</span>
<code class="text-accent-text font-mono text-[13px] font-medium">{{ param.name }}</code><span v-if="param.optional" class="text-fg-subtle text-xs">?</span>
</td>
<td class="py-2.5 px-4">
<code class="text-xs font-mono text-(--fg-muted) bg-(--bg-inset) px-1.5 py-0.5 rounded border border-(--border) wrap-break-word">{{ param.type }}</code>
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ param.type }}</code>
</td>
<td class="py-2.5 px-4 hidden sm:table-cell">
<code v-if="param.defaultValue" class="text-xs font-mono text-(--fg-muted)">{{ param.defaultValue }}</code>
<span v-else class="text-(--fg-subtle)"></span>
<code v-if="param.defaultValue" class="text-xs font-mono text-fg-muted">{{ param.defaultValue }}</code>
<span v-else class="text-fg-subtle"></span>
</td>
<td class="py-2.5 px-4 text-(--fg-muted) min-w-48">
<td class="py-2.5 px-4 text-fg-muted min-w-48">
<DocsText v-if="param.description" :text="param.description" />
<span v-else></span>
</td>
+13 -13
View File
@@ -8,34 +8,34 @@ defineProps<{
</script>
<template>
<div v-if="properties.length > 0" class="overflow-x-auto rounded-xl border border-(--border)">
<div v-if="properties.length > 0" class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="bg-(--bg-subtle) text-left">
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">{{ label ?? 'Property' }}</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Type</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider hidden sm:table-cell">Default</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Description</th>
<tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">{{ label ?? 'Property' }}</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Type</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider hidden sm:table-cell">Default</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr>
</thead>
<tbody>
<tr
v-for="prop in properties"
:key="prop.name"
class="border-t border-(--border) align-top"
class="border-t border-border align-top"
>
<td class="py-2.5 px-4 whitespace-nowrap">
<code class="text-(--accent-text) font-mono text-[13px] font-medium">{{ prop.name }}</code><span v-if="prop.optional" class="text-(--fg-subtle) text-xs">?</span>
<span v-if="prop.readonly" class="block text-[10px] text-(--fg-subtle) uppercase tracking-wide mt-0.5">readonly</span>
<code class="text-accent-text font-mono text-[13px] font-medium">{{ prop.name }}</code><span v-if="prop.optional" class="text-fg-subtle text-xs">?</span>
<span v-if="prop.readonly" class="block text-[10px] text-fg-subtle uppercase tracking-wide mt-0.5">readonly</span>
</td>
<td class="py-2.5 px-4">
<code class="text-xs font-mono text-(--fg-muted) bg-(--bg-inset) px-1.5 py-0.5 rounded border border-(--border) wrap-break-word">{{ prop.type }}</code>
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ prop.type }}</code>
</td>
<td class="py-2.5 px-4 hidden sm:table-cell">
<code v-if="prop.defaultValue" class="text-xs font-mono text-(--fg-muted)">{{ prop.defaultValue }}</code>
<span v-else class="text-(--fg-subtle)"></span>
<code v-if="prop.defaultValue" class="text-xs font-mono text-fg-muted">{{ prop.defaultValue }}</code>
<span v-else class="text-fg-subtle"></span>
</td>
<td class="py-2.5 px-4 text-(--fg-muted) min-w-48">
<td class="py-2.5 px-4 text-fg-muted min-w-48">
<DocsText v-if="prop.description" :text="prop.description" />
<span v-else></span>
</td>
+12 -12
View File
@@ -65,14 +65,14 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
<div>
<button
type="button"
class="flex items-center gap-2 px-2.5 h-9 text-sm text-(--fg-subtle) bg-(--bg-subtle) border border-(--border) rounded-lg hover:border-(--border-strong) transition-colors w-9 sm:w-56 justify-center sm:justify-start cursor-pointer"
class="flex items-center gap-2 px-2.5 h-9 text-sm text-fg-subtle bg-bg-subtle border border-border rounded-lg hover:border-border-strong transition-colors w-9 sm:w-56 justify-center sm:justify-start cursor-pointer"
@click="open"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg>
<span class="hidden sm:inline flex-1 text-left font-mono text-[13px]">search</span>
<kbd class="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono bg-(--bg) border border-(--border) rounded text-(--fg-subtle)">K</kbd>
<kbd class="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono bg-bg border border-border rounded text-fg-subtle">K</kbd>
</button>
<Teleport to="body">
@@ -84,21 +84,21 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm" @click="close" />
<div class="fixed inset-x-0 top-[12vh] mx-auto max-w-xl px-4">
<div class="bg-(--bg-elevated) rounded-xl border border-(--border) shadow-2xl overflow-hidden">
<div class="flex items-center px-4 border-b border-(--border)">
<span class="font-mono text-base text-(--accent-text) select-none shrink-0"></span>
<div class="bg-bg-elevated rounded-xl border border-border shadow-2xl overflow-hidden">
<div class="flex items-center px-4 border-b border-border">
<span class="font-mono text-base text-accent-text select-none shrink-0"></span>
<input
v-model="query"
data-search-input
type="text"
placeholder="search across all packages…"
class="w-full py-3.5 px-3 bg-transparent text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none font-mono text-[14px]"
class="w-full py-3.5 px-3 bg-transparent text-fg placeholder:text-fg-subtle focus:outline-none font-mono text-[14px]"
>
<kbd class="hidden sm:inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono bg-(--bg-inset) border border-(--border) rounded text-(--fg-subtle)">ESC</kbd>
<kbd class="hidden sm:inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono bg-bg-inset border border-border rounded text-fg-subtle">ESC</kbd>
</div>
<div class="max-h-[60vh] overflow-y-auto p-2">
<div v-if="query && results.length === 0" class="py-12 text-center text-sm text-(--fg-subtle)">
<div v-if="query && results.length === 0" class="py-12 text-center text-sm text-fg-subtle">
No results for "{{ query }}"
</div>
<ul v-else-if="results.length > 0" class="space-y-0.5">
@@ -107,20 +107,20 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
:to="`/${r.pkg.slug}/${r.slug}`"
:class="[
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
i === activeIndex ? 'bg-(--accent-subtle)' : 'hover:bg-(--bg-inset)',
i === activeIndex ? 'bg-accent-subtle' : 'hover:bg-bg-inset',
]"
@click="close"
@mouseenter="activeIndex = i"
>
<DocsBadge :kind="r.badge" size="sm" />
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-(--fg) truncate">{{ r.name }}</div>
<div class="text-xs text-(--fg-subtle) truncate">{{ r.pkg.name }} · {{ r.description }}</div>
<div class="text-sm font-medium text-fg truncate">{{ r.name }}</div>
<div class="text-xs text-fg-subtle truncate">{{ r.pkg.name }} · {{ r.description }}</div>
</div>
</NuxtLink>
</li>
</ul>
<div v-else class="py-12 text-center text-sm text-(--fg-subtle)">
<div v-else class="py-12 text-center text-sm text-fg-subtle">
Type to search functions, components &amp; guides
</div>
</div>
+3 -3
View File
@@ -4,10 +4,10 @@
}>();
const variantClasses: Record<string, string> = {
since: 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)',
neutral: 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)',
since: 'bg-bg-inset text-fg-muted border border-border',
neutral: 'bg-bg-inset text-fg-muted border border-border',
test: 'bg-emerald-50 text-emerald-800 border border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-500/20',
demo: 'bg-(--accent-subtle) text-(--accent-text) border border-(--accent-subtle)',
demo: 'bg-accent-subtle text-accent-text border border-accent-subtle',
wip: 'bg-amber-50 text-amber-800 border border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/20',
};
</script>
+1 -1
View File
@@ -12,7 +12,7 @@ const label = computed(() => ({
type="button"
:title="`Theme: ${label} (click to change)`"
:aria-label="`Theme: ${label}`"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset) transition-colors cursor-pointer"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-fg-muted hover:text-fg hover:bg-bg-inset transition-colors cursor-pointer"
@click="cycle"
>
<ClientOnly>
+3 -3
View File
@@ -49,7 +49,7 @@ function go(id: string) {
<div class="comment-label mb-3">
on this page
</div>
<ul class="space-y-1 border-l border-(--border)">
<ul class="space-y-1 border-l border-border">
<li v-for="item in items" :key="item.id">
<a
:href="`#${item.id}`"
@@ -57,8 +57,8 @@ function go(id: string) {
'block py-1 -ml-px border-l-2 transition-colors',
item.depth === 3 ? 'pl-6' : 'pl-4',
activeId === item.id
? 'border-(--accent) text-(--accent-text) font-medium'
: 'border-transparent text-(--fg-muted) hover:text-(--fg)',
? 'border-accent text-accent-text font-medium'
: 'border-transparent text-fg-muted hover:text-fg',
]"
@click.prevent="go(item.id)"
>
+46
View File
@@ -35,6 +35,28 @@ const GROUP_LABELS: Record<PackageGroup, string> = {
const GROUP_ORDER: PackageGroup[] = ['core', 'vue', 'configs', 'infra'];
/** Display order for component categories (unlisted categories sort last, AZ). */
const COMPONENT_CATEGORY_ORDER: string[] = [
'Forms',
'Selection',
'Color',
'Overlays',
'Menus',
'Disclosure',
'Navigation',
'Display',
'Feedback',
'Canvas & editors',
'Utilities',
'Other',
];
/** A category bucket of components, for grouped rendering. */
export interface ComponentGroup {
name: string;
components: ComponentMeta[];
}
export function useDocs() {
const data = metadata as unknown as DocsMetadata;
@@ -74,6 +96,29 @@ export function useDocs() {
return pkg.docs.filter(s => !s.isIntro);
}
/**
* A `components`-kind package's components bucketed by `category`, ordered by
* {@link COMPONENT_CATEGORY_ORDER} (unlisted categories last, AZ), with the
* components inside each bucket kept in their incoming (alphabetical) order.
*/
function getComponentGroups(pkg: PackageMeta): ComponentGroup[] {
if (pkg.kind !== 'components') return [];
const buckets = new Map<string, ComponentMeta[]>();
for (const c of pkg.components) {
const cat = c.category || 'Other';
const list = buckets.get(cat);
if (list) list.push(c);
else buckets.set(cat, [c]);
}
const rank = (name: string) => {
const i = COMPONENT_CATEGORY_ORDER.indexOf(name);
return i === -1 ? COMPONENT_CATEGORY_ORDER.length : i;
};
return [...buckets.entries()]
.map(([name, components]) => ({ name, components }))
.sort((a, b) => rank(a.name) - rank(b.name) || a.name.localeCompare(b.name));
}
/** Resolve any `/:package/:slug` route to a normalised entry. */
function resolveEntry(packageSlug: string, slug: string): DocEntry | undefined {
const pkg = getPackage(packageSlug);
@@ -157,6 +202,7 @@ export function useDocs() {
firstEntrySlug,
getIntro,
getDocSections,
getComponentGroups,
search,
getTotalItems,
};
+37 -32
View File
@@ -1,4 +1,4 @@
<script setup lang="ts">const { getGroupedPackages, getPackage, getIntro, getDocSections } = useDocs();
<script setup lang="ts">const { getGroupedPackages, getPackage, getIntro, getDocSections, getComponentGroups } = useDocs();
const groups = getGroupedPackages();
const route = useRoute();
@@ -79,11 +79,11 @@ watch(() => route.path, () => {
<template>
<div class="min-h-screen">
<!-- Header -->
<header class="sticky top-0 z-50 border-b border-(--border) backdrop-blur-md" style="background-color: var(--header-bg)">
<header class="sticky top-0 z-50 border-b border-border backdrop-blur-md" style="background-color: var(--header-bg)">
<div class="mx-auto max-w-352 flex items-center gap-3 px-4 h-14 sm:px-6">
<button
type="button"
class="lg:hidden inline-flex items-center justify-center w-9 h-9 -ml-1.5 rounded-lg text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)"
class="lg:hidden inline-flex items-center justify-center w-9 h-9 -ml-1.5 rounded-lg text-fg-muted hover:text-fg hover:bg-bg-inset"
aria-label="Toggle navigation"
@click="isSidebarOpen = !isSidebarOpen"
>
@@ -93,12 +93,12 @@ watch(() => route.path, () => {
</button>
<NuxtLink to="/" class="group flex items-center gap-2.5 mr-auto">
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-(--accent) text-(--accent-fg) font-mono text-[13px] font-semibold leading-none select-none">
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-accent text-accent-fg font-mono text-[13px] font-semibold leading-none select-none">
</span>
<span class="hidden sm:flex items-baseline font-mono text-[13.5px] tracking-tight">
<span class="text-(--fg-subtle)">~/</span><span class="text-(--fg) font-medium">robonen</span><span class="text-(--fg-subtle)">/</span><span class="text-(--accent-text) font-medium">tools</span>
<span class="ml-1 inline-block w-1.75 h-3.75 translate-y-0.5 bg-(--accent) opacity-0 group-hover:opacity-80 group-hover:animate-pulse" />
<span class="text-fg-subtle">~/</span><span class="text-fg font-medium">robonen</span><span class="text-fg-subtle">/</span><span class="text-accent-text font-medium">tools</span>
<span class="ml-1 inline-block w-1.75 h-3.75 translate-y-0.5 bg-accent opacity-0 group-hover:opacity-80 group-hover:animate-pulse" />
</span>
</NuxtLink>
@@ -108,7 +108,7 @@ watch(() => route.path, () => {
href="https://github.com/robonen/tools"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset) transition-colors"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-fg-muted hover:text-fg hover:bg-bg-inset transition-colors"
aria-label="GitHub"
>
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="currentColor">
@@ -122,7 +122,7 @@ watch(() => route.path, () => {
<!-- Sidebar -->
<aside
:class="[
'fixed inset-y-0 left-0 z-40 w-72 bg-(--bg) border-r border-(--border) pt-14 transform transition-transform lg:sticky lg:top-14 lg:z-auto lg:h-[calc(100vh-3.5rem)] lg:w-64 lg:shrink-0 lg:translate-x-0 lg:pt-0 lg:border-r-0 lg:bg-transparent',
'fixed inset-y-0 left-0 z-40 w-72 bg-bg border-r border-border pt-14 transform transition-transform lg:sticky lg:top-14 lg:z-auto lg:h-[calc(100vh-3.5rem)] lg:w-64 lg:shrink-0 lg:translate-x-0 lg:pt-0 lg:border-r-0 lg:bg-transparent',
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
]"
>
@@ -136,24 +136,24 @@ watch(() => route.path, () => {
:class="[
'flex items-center justify-between py-1.5 px-2 rounded-md text-sm transition-colors',
currentPackageSlug === pkg.slug
? 'text-(--fg) font-medium bg-(--bg-inset)'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
? 'text-fg font-medium bg-bg-inset'
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]"
>
<span class="font-mono text-[13px]">{{ pkg.name.replace('@robonen/', '') }}</span>
<span class="text-[10px] font-mono text-(--fg-subtle)">{{ pkg.kind === 'api' ? 'api' : pkg.kind === 'components' ? 'ui' : 'guide' }}</span>
<span class="text-[10px] font-mono text-fg-subtle">{{ pkg.kind === 'api' ? 'api' : pkg.kind === 'components' ? 'ui' : 'guide' }}</span>
</NuxtLink>
<!-- Expanded tree for the current package -->
<div v-if="currentPackageSlug === pkg.slug && currentPackage" class="mt-1.5 mb-3 ml-2.5 pl-2.5 border-l border-(--border)">
<div v-if="currentPackageSlug === pkg.slug && currentPackage" class="mt-1.5 mb-3 ml-2.5 pl-2.5 border-l border-border">
<!-- Quick filter the tree below collapses to matches -->
<div v-if="currentPackage.kind === 'api'" class="relative mb-2 mt-1">
<span class="absolute left-2 top-1/2 -translate-y-1/2 font-mono text-[11px] text-(--accent-text) select-none"></span>
<span class="absolute left-2 top-1/2 -translate-y-1/2 font-mono text-[11px] text-accent-text select-none"></span>
<input
v-model="navQuery"
type="text"
placeholder="filter…"
class="w-full h-7 pl-6 pr-2 font-mono text-[12px] rounded-md bg-(--bg-subtle) border border-(--border) text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none focus:border-(--border-strong) transition-colors"
class="w-full h-7 pl-6 pr-2 font-mono text-[12px] rounded-md bg-bg-subtle border border-border text-fg placeholder:text-fg-subtle focus:outline-none focus:border-border-strong transition-colors"
>
</div>
@@ -167,8 +167,8 @@ watch(() => route.path, () => {
:class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
route.path === `/${pkg.slug}`
? 'text-(--accent-text) font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
? 'text-accent-text font-medium'
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]"
>
Introduction
@@ -180,8 +180,8 @@ watch(() => route.path, () => {
:class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, s.slug)
? 'text-(--accent-text) font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
? 'text-accent-text font-medium'
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]"
>
{{ s.title }}
@@ -192,7 +192,7 @@ watch(() => route.path, () => {
<!-- api: collapsible categories -->
<template v-if="currentPackage.kind === 'api'">
<div v-if="navQuery && visibleCategories.length === 0" class="py-2 px-1 font-mono text-[11px] text-(--fg-subtle)">
<div v-if="navQuery && visibleCategories.length === 0" class="py-2 px-1 font-mono text-[11px] text-fg-subtle">
no matches
</div>
@@ -206,14 +206,14 @@ watch(() => route.path, () => {
xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
:class="[
'shrink-0 text-(--fg-subtle) transition-transform duration-150',
'shrink-0 text-fg-subtle transition-transform duration-150',
isCategoryOpen(cat.slug) ? 'rotate-90' : '',
]"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="comment-label group-hover/cat:text-(--fg-muted) transition-colors">{{ cat.name.toLowerCase() }}</span>
<span class="ml-auto font-mono text-[10px] text-(--fg-subtle) tabular-nums">{{ cat.items.length }}</span>
<span class="comment-label group-hover/cat:text-fg-muted transition-colors">{{ cat.name.toLowerCase() }}</span>
<span class="ml-auto font-mono text-[10px] text-fg-subtle tabular-nums">{{ cat.items.length }}</span>
</button>
<ul v-if="isCategoryOpen(cat.slug)" class="mb-1.5">
@@ -223,14 +223,14 @@ watch(() => route.path, () => {
:class="[
'flex items-center gap-1.5 py-0.75 px-2 text-[13px] rounded-md font-mono transition-colors',
isActive(pkg.slug, item.slug)
? 'text-(--accent-text) font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
? 'text-accent-text font-medium'
: 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]"
>
<span
:class="[
'shrink-0 text-[10px] select-none transition-opacity',
isActive(pkg.slug, item.slug) ? 'opacity-100 text-(--accent-text)' : 'opacity-0',
isActive(pkg.slug, item.slug) ? 'opacity-100 text-accent-text' : 'opacity-0',
]"
></span>
<span class="truncate">{{ item.name }}</span>
@@ -240,22 +240,27 @@ watch(() => route.path, () => {
</div>
</template>
<!-- components -->
<ul v-else-if="currentPackage.kind === 'components'">
<li v-for="c in currentPackage.components" :key="c.slug">
<!-- 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 }}
+18 -18
View File
@@ -105,10 +105,10 @@ const sectionTitle = 'comment-label mb-3';
<div v-if="entry" class="xl:grid xl:grid-cols-[minmax(0,1fr)_14rem] xl:gap-12">
<article class="min-w-0 max-w-3xl">
<!-- Breadcrumb -->
<nav class="flex items-center gap-1.5 font-mono text-[13px] text-(--fg-subtle) mb-6">
<NuxtLink :to="`/${pkg.slug}`" class="hover:text-(--fg) transition-colors">{{ pkg.name }}</NuxtLink>
<nav class="flex items-center gap-1.5 font-mono text-[13px] text-fg-subtle mb-6">
<NuxtLink :to="`/${pkg.slug}`" class="hover:text-fg transition-colors">{{ pkg.name }}</NuxtLink>
<span>/</span>
<span class="text-(--fg)">{{ title }}</span>
<span class="text-fg">{{ title }}</span>
</nav>
<!-- API ITEM -->
@@ -116,7 +116,7 @@ const sectionTitle = 'comment-label mb-3';
<header class="mb-8">
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
<DocsBadge :kind="entry.item.kind" size="md" />
<h1 class="min-w-0 break-words text-[1.6rem] font-semibold font-mono tracking-tight text-(--fg)">{{ entry.item.name }}</h1>
<h1 class="min-w-0 break-words text-[1.6rem] font-semibold font-mono tracking-tight text-fg">{{ entry.item.name }}</h1>
<DocsTag v-if="entry.item.since" :label="`v${entry.item.since}`" variant="neutral" />
<DocsTag
v-if="entry.item.hasTests"
@@ -126,15 +126,15 @@ const sectionTitle = 'comment-label mb-3';
/>
<DocsTag v-if="entry.item.hasDemo" label="demo" variant="demo" />
</div>
<p v-if="entry.item.description" class="text-(--fg-muted) text-[15px] leading-relaxed">
<p v-if="entry.item.description" class="text-fg-muted text-[15px] leading-relaxed">
<DocsText :text="entry.item.description" />
</p>
<div class="flex items-center gap-4 mt-4 text-sm">
<a :href="ghUrl(entry.item.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-(--fg-subtle) hover:text-(--fg) transition-colors">
<a :href="ghUrl(entry.item.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-fg-subtle hover:text-fg transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg>
Source
</a>
<a v-if="entry.item.hasTests" :href="ghUrl(entry.item.sourcePath).replace('index.ts', 'index.test.ts')" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-(--fg-subtle) hover:text-(--fg) transition-colors">
<a v-if="entry.item.hasTests" :href="ghUrl(entry.item.sourcePath).replace('index.ts', 'index.test.ts')" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-fg-subtle hover:text-fg transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" /><polyline points="14 2 14 8 20 8" /><path d="m9 15 2 2 4-4" /></svg>
Tests
</a>
@@ -164,9 +164,9 @@ const sectionTitle = 'comment-label mb-3';
<h2 :class="sectionTitle">Type Parameters</h2>
<div class="space-y-1.5">
<div v-for="tp in entry.item.typeParams" :key="tp.name" class="flex items-baseline gap-2 text-sm flex-wrap">
<code class="font-mono font-medium text-(--accent-text)">{{ tp.name }}</code>
<span v-if="tp.constraint" class="text-(--fg-subtle)">extends <code class="font-mono text-xs">{{ tp.constraint }}</code></span>
<span v-if="tp.default" class="text-(--fg-subtle)">= <code class="font-mono text-xs">{{ tp.default }}</code></span>
<code class="font-mono font-medium text-accent-text">{{ tp.name }}</code>
<span v-if="tp.constraint" class="text-fg-subtle">extends <code class="font-mono text-xs">{{ tp.constraint }}</code></span>
<span v-if="tp.default" class="text-fg-subtle">= <code class="font-mono text-xs">{{ tp.default }}</code></span>
</div>
</div>
</section>
@@ -179,8 +179,8 @@ const sectionTitle = 'comment-label mb-3';
<section v-if="entry.item.returns" id="returns" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Returns</h2>
<div class="flex items-baseline gap-2 text-sm flex-wrap" :class="entry.item.returns.properties?.length ? 'mb-3' : ''">
<code class="font-mono bg-(--bg-inset) border border-(--border) px-2 py-1 rounded text-xs wrap-break-word">{{ entry.item.returns.type }}</code>
<DocsText v-if="entry.item.returns.description" :text="entry.item.returns.description" class="text-(--fg-muted)" />
<code class="font-mono bg-bg-inset border border-border px-2 py-1 rounded text-xs wrap-break-word">{{ entry.item.returns.type }}</code>
<DocsText v-if="entry.item.returns.description" :text="entry.item.returns.description" class="text-fg-muted" />
</div>
<DocsPropsTable v-if="entry.item.returns.properties?.length" :properties="entry.item.returns.properties" />
</section>
@@ -198,12 +198,12 @@ const sectionTitle = 'comment-label mb-3';
<section v-if="entry.item.relatedTypes?.length" id="related-types" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Related Types</h2>
<div class="space-y-4">
<div v-for="rt in entry.item.relatedTypes" :key="rt.name" class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4">
<div v-for="rt in entry.item.relatedTypes" :key="rt.name" class="rounded-xl border border-border bg-bg-subtle p-4">
<div class="flex items-center gap-2 mb-2">
<DocsBadge :kind="rt.kind" size="sm" />
<h3 class="font-mono font-semibold text-sm text-(--fg)">{{ rt.name }}</h3>
<h3 class="font-mono font-semibold text-sm text-fg">{{ rt.name }}</h3>
</div>
<p v-if="rt.description" class="text-sm text-(--fg-muted) mb-3">
<p v-if="rt.description" class="text-sm text-fg-muted mb-3">
<DocsText :text="rt.description" />
</p>
<DocsCode v-if="rt.signatures.length" :code="rt.signatures[0]!" />
@@ -218,14 +218,14 @@ const sectionTitle = 'comment-label mb-3';
<header class="mb-8">
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
<DocsBadge kind="component" size="md" />
<h1 class="font-display text-[1.7rem] font-bold tracking-tight text-(--fg)">{{ entry.component.name }}</h1>
<h1 class="font-display text-[1.7rem] font-bold tracking-tight text-fg">{{ entry.component.name }}</h1>
<DocsTag :label="`${entry.component.parts.length} parts`" variant="neutral" />
</div>
<p v-if="entry.component.description" class="text-(--fg-muted) text-[15px] leading-relaxed">
<p v-if="entry.component.description" class="text-fg-muted text-[15px] leading-relaxed">
<DocsText :text="entry.component.description" />
</p>
<div class="flex items-center gap-4 mt-4 text-sm">
<a :href="ghUrl(entry.component.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-(--fg-subtle) hover:text-(--fg) transition-colors">
<a :href="ghUrl(entry.component.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-fg-subtle hover:text-fg transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg>
Source
</a>
+55 -27
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">import { sections } from '#docs/sections';
const route = useRoute();
const { getPackage, countEntries, getIntro } = useDocs();
const { getPackage, countEntries, getIntro, getComponentGroups } = useDocs();
const slug = computed(() => route.params.package as string);
const pkg = computed(() => getPackage(slug.value));
@@ -51,6 +51,15 @@ function scrollToCategory(catSlug: string) {
document.getElementById(`cat-${catSlug}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ── Components: bucketed by functional category ───────────────────────────
const componentGroups = computed(() =>
pkg.value?.kind === 'components' ? getComponentGroups(pkg.value) : [],
);
function scrollToComponentGroup(name: string) {
document.getElementById(`cgrp-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// For guide packages, surface the overview section inline.
const overview = computed(() =>
pkg.value?.kind === 'guide' ? pkg.value.sections.find(s => s.slug === 'overview') : undefined,
@@ -68,13 +77,13 @@ const otherSections = computed(() =>
</section>
<!-- Auto header (shown only when there's no hand-authored intro) -->
<header v-else class="mb-8 pb-8 border-b border-(--border)">
<header v-else class="mb-8 pb-8 border-b border-border">
<div class="comment-label mb-3">{{ kindLabel.toLowerCase() }} · {{ countEntries(pkg) }} entries</div>
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
<h1 class="font-display text-3xl font-bold tracking-tight text-(--fg)">{{ pkg.name }}</h1>
<h1 class="font-display text-3xl font-bold tracking-tight text-fg">{{ pkg.name }}</h1>
<DocsTag :label="`v${pkg.version}`" variant="neutral" />
</div>
<p class="text-(--fg-muted) text-[15px] leading-relaxed">{{ pkg.description }}</p>
<p class="text-fg-muted text-[15px] leading-relaxed">{{ pkg.description }}</p>
<div class="mt-5">
<DocsCode :code="`pnpm add ${pkg.name}`" lang="bash" />
</div>
@@ -84,14 +93,14 @@ const otherSections = computed(() =>
<template v-if="pkg.kind === 'api'">
<div class="sticky top-14 z-20 -mx-2 px-2 py-3 backdrop-blur-md" style="background-color: var(--header-bg)">
<div class="relative mb-2.5">
<span class="absolute left-3 top-1/2 -translate-y-1/2 font-mono text-sm text-(--accent-text) select-none"></span>
<span class="absolute left-3 top-1/2 -translate-y-1/2 font-mono text-sm text-accent-text select-none"></span>
<input
v-model="query"
type="text"
:placeholder="`filter ${countEntries(pkg)} entries…`"
class="w-full h-10 pl-8 pr-16 font-mono text-sm rounded-md bg-(--bg-elevated) border border-(--border) text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none focus:border-(--accent) transition-colors"
class="w-full h-10 pl-8 pr-16 font-mono text-sm rounded-md bg-bg-elevated border border-border text-fg placeholder:text-fg-subtle focus:outline-none focus:border-accent transition-colors"
>
<span v-if="query" class="absolute right-3 top-1/2 -translate-y-1/2 font-mono text-[11px] text-(--fg-subtle) tabular-nums">
<span v-if="query" class="absolute right-3 top-1/2 -translate-y-1/2 font-mono text-[11px] text-fg-subtle tabular-nums">
{{ filteredCount }} hits
</span>
</div>
@@ -101,17 +110,17 @@ const otherSections = computed(() =>
v-for="category in filteredCategories"
:key="category.slug"
type="button"
class="shrink-0 inline-flex items-center gap-1.5 h-6.5 px-2.5 font-mono text-[11px] rounded-full border border-(--border) bg-(--bg-elevated) text-(--fg-muted) hover:border-(--accent) hover:text-(--accent-text) transition-colors cursor-pointer"
class="shrink-0 inline-flex items-center gap-1.5 h-6.5 px-2.5 font-mono text-[11px] rounded-full border border-border bg-bg-elevated text-fg-muted hover:border-accent hover:text-accent-text transition-colors cursor-pointer"
@click="scrollToCategory(category.slug)"
>
{{ category.name.toLowerCase() }}
<span class="text-(--fg-subtle) tabular-nums">{{ category.items.length }}</span>
<span class="text-fg-subtle tabular-nums">{{ category.items.length }}</span>
</button>
</div>
</div>
<div v-if="query && filteredCategories.length === 0" class="py-16 text-center">
<div class="font-mono text-sm text-(--fg-subtle)">// no matches for "{{ query }}"</div>
<div class="font-mono text-sm text-fg-subtle">// no matches for "{{ query }}"</div>
</div>
<section
@@ -128,48 +137,67 @@ const otherSections = computed(() =>
v-for="item in category.items"
:key="item.slug"
:to="`/${pkg.slug}/${item.slug}`"
class="group flex items-start gap-2.5 p-3 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all"
class="group flex items-start gap-2.5 p-3 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:shadow-(--shadow-card) transition-all"
>
<DocsBadge :kind="item.kind" size="sm" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5 flex-wrap">
<span class="font-mono text-[13px] font-medium text-(--fg) group-hover:text-(--accent-text) transition-colors truncate">{{ item.name }}</span>
<span class="font-mono text-[13px] font-medium text-fg group-hover:text-accent-text transition-colors truncate">{{ item.name }}</span>
<DocsTag v-if="item.hasDemo" label="demo" variant="demo" />
</div>
<p v-if="item.description" class="text-[12.5px] text-(--fg-subtle) mt-0.5 line-clamp-1">{{ item.description }}</p>
<p v-if="item.description" class="text-[12.5px] text-fg-subtle mt-0.5 line-clamp-1">{{ item.description }}</p>
</div>
</NuxtLink>
</div>
</section>
</template>
<!-- Components: gallery -->
<!-- Components: gallery grouped by functional category -->
<template v-else-if="pkg.kind === 'components'">
<section>
<!-- Category chips -->
<div class="mb-7 flex flex-wrap gap-1.5">
<button
v-for="group in componentGroups"
:key="group.name"
type="button"
class="font-mono text-[11px] px-2 py-1 rounded-md bg-bg-inset border border-border text-fg-muted hover:text-fg hover:border-border-strong transition-colors"
@click="scrollToComponentGroup(group.name)"
>
{{ group.name.toLowerCase() }}
<span class="text-fg-subtle tabular-nums">{{ group.components.length }}</span>
</button>
</div>
<section
v-for="group in componentGroups"
:id="`cgrp-${group.name}`"
:key="group.name"
class="mb-10 scroll-mt-24"
>
<h2 class="comment-label mb-4">
all components · {{ pkg.components.length }}
{{ group.name.toLowerCase() }} · {{ group.components.length }}
</h2>
<div class="stagger grid grid-cols-1 gap-3 sm:grid-cols-2">
<NuxtLink
v-for="c in pkg.components"
v-for="c in group.components"
:key="c.slug"
:to="`/${pkg.slug}/${c.slug}`"
class="group block p-4 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all"
class="group block p-4 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:shadow-(--shadow-card) transition-all"
>
<div class="flex items-center justify-between gap-2 mb-1.5">
<span class="font-semibold text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ c.name }}</span>
<span class="font-mono text-[11px] text-(--fg-subtle) tabular-nums">{{ c.parts.length }} parts</span>
<span class="font-semibold text-fg group-hover:text-accent-text transition-colors">{{ c.name }}</span>
<span class="font-mono text-[11px] text-fg-subtle tabular-nums">{{ c.parts.length }} parts</span>
</div>
<p v-if="c.description" class="text-sm text-(--fg-subtle) line-clamp-2">{{ c.description }}</p>
<p v-if="c.description" class="text-sm text-fg-subtle line-clamp-2">{{ c.description }}</p>
<div class="mt-3 flex flex-wrap gap-1">
<span
v-for="part in c.parts.slice(0, 4)"
:key="part.name"
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-(--bg-inset) border border-(--border) text-(--fg-subtle)"
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-bg-inset border border-border text-fg-subtle"
>
{{ part.role }}
</span>
<span v-if="c.parts.length > 4" class="text-[10px] font-mono text-(--fg-subtle) px-1">+{{ c.parts.length - 4 }}</span>
<span v-if="c.parts.length > 4" class="text-[10px] font-mono text-fg-subtle px-1">+{{ c.parts.length - 4 }}</span>
</div>
</NuxtLink>
</div>
@@ -179,17 +207,17 @@ const otherSections = computed(() =>
<!-- Guide: overview markdown + section links -->
<template v-else>
<DocsMarkdown v-if="overview" :source="overview.markdown" />
<section v-if="otherSections.length > 0" class="mt-10 pt-8 border-t border-(--border)">
<section v-if="otherSections.length > 0" class="mt-10 pt-8 border-t border-border">
<h2 class="comment-label mb-4">sections</h2>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<NuxtLink
v-for="s in otherSections"
:key="s.slug"
:to="`/${pkg.slug}/${s.slug}`"
class="group flex items-center justify-between gap-3 p-3.5 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:bg-(--bg-subtle) transition-all"
class="group flex items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:bg-bg-subtle transition-all"
>
<span class="text-sm font-medium text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ s.title }}</span>
<span class="font-mono text-[11px] text-(--fg-subtle) group-hover:text-(--accent-text) transition-colors"></span>
<span class="text-sm font-medium text-fg group-hover:text-accent-text transition-colors">{{ s.title }}</span>
<span class="font-mono text-[11px] text-fg-subtle group-hover:text-accent-text transition-colors"></span>
</NuxtLink>
</div>
</section>
+17 -17
View File
@@ -20,21 +20,21 @@ useHead({ title: '@robonen/tools — Documentation' });
<div class="comment-label mb-5">field manual · generated from source &amp; jsdoc</div>
<h1 class="font-display text-5xl sm:text-6xl font-bold tracking-tight text-(--fg) mb-5 text-balance">
Tools, documented<span class="text-(--accent)">.</span>
<h1 class="font-display text-5xl sm:text-6xl font-bold tracking-tight text-fg mb-5 text-balance">
Tools, documented<span class="text-accent">.</span>
</h1>
<p class="text-lg text-(--fg-muted) leading-relaxed max-w-2xl">
<p class="text-lg text-fg-muted leading-relaxed max-w-2xl">
A monorepo of TypeScript utilities, Vue composables, headless UI primitives
and shared tooling typed, tested and demoed in place.
</p>
<div class="mt-7 inline-flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[13px] text-(--fg-subtle) border border-(--border) rounded-md bg-(--bg-elevated) px-3 py-2">
<span class="text-(--accent-text)"></span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ packages.length }}</span> packages</span>
<span class="text-(--border-strong)">·</span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ totalItems }}</span> documented items</span>
<span class="text-(--border-strong)">·</span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ groups.length }}</span> groups</span>
<div class="mt-7 inline-flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[13px] text-fg-subtle border border-border rounded-md bg-bg-elevated px-3 py-2">
<span class="text-accent-text"></span>
<span><span class="text-fg font-medium tabular-nums">{{ packages.length }}</span> packages</span>
<span class="text-border-strong">·</span>
<span><span class="text-fg font-medium tabular-nums">{{ totalItems }}</span> documented items</span>
<span class="text-border-strong">·</span>
<span><span class="text-fg font-medium tabular-nums">{{ groups.length }}</span> groups</span>
</div>
</section>
@@ -46,29 +46,29 @@ useHead({ title: '@robonen/tools — Documentation' });
v-for="pkg in grp.packages"
:key="pkg.slug"
:to="`/${pkg.slug}`"
class="group relative block p-5 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all overflow-hidden"
class="group relative block p-5 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:shadow-(--shadow-card) transition-all overflow-hidden"
>
<!-- Corner notch fills in on hover like an indicator lamp -->
<span
class="absolute right-0 top-0 w-2 h-2 bg-(--accent) opacity-0 group-hover:opacity-100 transition-opacity"
class="absolute right-0 top-0 w-2 h-2 bg-accent opacity-0 group-hover:opacity-100 transition-opacity"
style="clip-path: polygon(100% 0, 0 0, 100% 100%)"
aria-hidden="true"
/>
<div class="flex items-start justify-between gap-3 mb-2">
<h3 class="font-mono text-sm font-semibold text-(--fg) group-hover:text-(--accent-text) transition-colors">
<h3 class="font-mono text-sm font-semibold text-fg group-hover:text-accent-text transition-colors">
{{ pkg.name }}
</h3>
<span class="font-mono text-[10px] px-1.5 py-0.5 rounded border border-(--border) bg-(--bg-subtle) text-(--fg-subtle) leading-none shrink-0">
<span class="font-mono text-[10px] px-1.5 py-0.5 rounded border border-border bg-bg-subtle text-fg-subtle leading-none shrink-0">
{{ kindLabels[pkg.kind] }}
</span>
</div>
<p class="text-sm text-(--fg-muted) leading-relaxed line-clamp-2">
<p class="text-sm text-fg-muted leading-relaxed line-clamp-2">
{{ pkg.description }}
</p>
<div class="mt-4 flex items-center gap-2 font-mono text-[11px] text-(--fg-subtle)">
<div class="mt-4 flex items-center gap-2 font-mono text-[11px] text-fg-subtle">
<span>v{{ pkg.version }}</span>
<span class="text-(--border-strong)">·</span>
<span class="text-border-strong">·</span>
<span class="tabular-nums">{{ countEntries(pkg) }} {{ pkg.kind === 'components' ? 'components' : pkg.kind === 'guide' ? 'sections' : 'items' }}</span>
</div>
</NuxtLink>
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
import { defineCollection, defineContentConfig } from '@nuxt/content';
const repositories = [
'../configs/tsconfig',
'../core/stdlib',
'../core/platform',
'../infra/renovate',
'../web/vue',
];
export default defineContentConfig({
collections: repositories.reduce((acc, repo) => {
const name = repo.split('/').pop();
acc[name] = defineCollection({
source: {
include: `**/*.md`,
exclude: ['**/node_modules/**', '**/dist/**'],
cwd: repo,
},
type: 'page',
});
return acc;
}, {}),
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { base, compose, imports, stylistic, typescript, vue } from '@robonen/eslint';
import { base, compose, imports, stylistic, tests, typescript, vue } from '@robonen/eslint';
export default compose(base, typescript, vue, imports, stylistic, {
name: 'docs/build-scripts',
@@ -7,4 +7,4 @@ export default compose(base, typescript, vue, imports, stylistic, {
/* Build-time tooling (doc extractor) logs progress to the console. */
'no-console': 'off',
},
});
}, tests);
+69 -23
View File
@@ -88,7 +88,7 @@ const PACKAGES: PackageConfig[] = [
{ path: 'core/crdt', slug: 'crdt', kind: 'api', group: 'core' },
// ── vue ──
{ path: 'vue/toolkit', slug: 'vue', kind: 'api', group: 'vue' },
{ path: 'vue/editor', slug: 'editor', kind: 'api', group: 'vue' },
{ path: 'vue/writekit', slug: 'writekit', kind: 'api', group: 'vue' },
{ path: 'vue/primitives', slug: 'primitives', kind: 'components', group: 'vue' },
// ── configs ──
{ path: 'configs/eslint', slug: 'eslint', kind: 'guide', group: 'configs', guideSources: ['README.md', 'rules/*.md'] },
@@ -98,6 +98,27 @@ const PACKAGES: PackageConfig[] = [
{ path: 'infra/renovate', slug: 'renovate', kind: 'guide', group: 'infra', guideSources: ['README.md'] },
];
/**
* Display label for each category FOLDER under `src/`. Components now live at
* `src/<category>/<component>/`, so the folder is the source of truth for a
* component's category. Unlisted folders fall back to `toPascalCase(folder)`.
* The display order of categories lives in `useDocs` (`COMPONENT_CATEGORY_ORDER`).
*/
const CATEGORY_LABELS: Record<string, string> = {
forms: 'Forms',
selection: 'Selection',
color: 'Color',
overlays: 'Overlays',
menus: 'Menus',
disclosure: 'Disclosure',
navigation: 'Navigation',
display: 'Display',
feedback: 'Feedback',
canvas: 'Canvas & editors',
utilities: 'Utilities',
internal: 'Internal',
};
// ── Helpers ────────────────────────────────────────────────────────────────
function toKebabCase(str: string): string {
@@ -716,14 +737,14 @@ function inferCategoryFromItem(item: ItemMeta): string {
}
/** Resolve a package's export subpaths to source entry files. */
function resolveEntryPoints(pkgDir: string, exportsField: Record<string, any>): Array<{ subpath: string; filePath: string }> {
function resolveEntryPoints(pkgDir: string, exportsField: Record<string, unknown>): Array<{ subpath: string; filePath: string }> {
const entryPoints: Array<{ subpath: string; filePath: string }> = [];
for (const [subpath, value] of Object.entries(exportsField)) {
if (typeof value !== 'object' || value === null) continue;
let entry: any = (value as Record<string, any>).import ?? (value as Record<string, any>).types;
if (typeof entry === 'object' && entry !== null) entry = entry.types || entry.default;
let entry: unknown = (value as Record<string, unknown>).import ?? (value as Record<string, unknown>).types;
if (typeof entry === 'object' && entry !== null) entry = (entry as Record<string, unknown>).types || (entry as Record<string, unknown>).default;
if (!entry || typeof entry !== 'string') continue;
// Wildcard exports (e.g. "./*") can't be resolved to a single file here.
if (entry.includes('*')) continue;
@@ -942,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));
+8 -2
View File
@@ -44,7 +44,7 @@ export default defineNuxtModule({
'@robonen/fetch': 'core/fetch/src',
'@robonen/encoding': 'core/encoding/src',
'@robonen/crdt': 'core/crdt/src',
'@robonen/editor': 'vue/editor/src',
'@robonen/writekit': 'vue/writekit/src',
'@robonen/primitives': 'vue/primitives/src',
'@robonen/vue': vueSrc,
};
@@ -58,7 +58,13 @@ export default defineNuxtModule({
// Primitive `as="template"` / Slot path), silently blanking every demo
// that hits it. `import.meta.env.DEV` resolves correctly in dev & prod.
config.define ??= {};
(config.define as Record<string, unknown>).__DEV__ ??= 'import.meta.env.DEV';
// Inline a STATIC boolean, not `import.meta.env.DEV`: a define value is
// inserted verbatim and is NOT re-scanned for Vite's `import.meta.env`
// replacement, so in a prod build it shipped a literal `import.meta.env.DEV`
// into chunks where `import.meta.env` is undefined at runtime →
// "Cannot read properties of undefined (reading 'DEV')". A literal
// true/false has no runtime dependency and tree-shakes the dev branches.
(config.define as Record<string, unknown>).__DEV__ ??= JSON.stringify(nuxt.options.dev);
const existing = config.resolve?.alias;
const sourceAliases = [
+2
View File
@@ -115,6 +115,8 @@ export interface ComponentMeta {
name: string;
/** URL-friendly slug, e.g. "accordion" */
slug: string;
/** Functional category for grouping in the docs, e.g. "Forms", "Overlays". */
category: string;
/** Short description (from README heading or first JSDoc) */
description: string;
/** Subpath export, e.g. "./accordion" */
+7 -7
View File
@@ -159,15 +159,15 @@ describe('getPackage / resolveEntry', () => {
describe('slug uniqueness & collisions', () => {
// A function and a co-located type/interface whose names differ only in case
// both slugify to the same value — the real extractor produces these in
// @robonen/editor and @robonen/vue.
// @robonen/writekit and @robonen/vue.
const colliding: DocsMetadata = {
generatedAt: '2026-06-08T00:00:00.000Z',
packages: [
{
name: '@robonen/editor',
name: '@robonen/writekit',
version: '1.0.0',
description: 'Editor',
slug: 'editor',
description: 'Writekit',
slug: 'writekit',
kind: 'api',
group: 'vue',
entryPoints: ['.'],
@@ -197,12 +197,12 @@ describe('slug uniqueness & collisions', () => {
it('reaches both colliding symbols — function and interface — independently', () => {
const leaves = buildLeaves(colliding);
// Exact case-sensitive name disambiguates the function from the interface.
const fn = resolveEntry(leaves, 'editor', 'position');
const iface = resolveEntry(leaves, 'editor', 'Position');
const fn = resolveEntry(leaves, 'writekit', 'position');
const iface = resolveEntry(leaves, 'writekit', 'Position');
expect(fn?.kind === 'api' && fn.item.kind).toBe('function');
expect(iface?.kind === 'api' && iface.item.kind).toBe('interface');
// The disambiguated slug also resolves the interface directly.
const bySlug = resolveEntry(leaves, 'editor', 'position-interface');
const bySlug = resolveEntry(leaves, 'writekit', 'position-interface');
expect(bySlug?.kind === 'api' && bySlug.item.kind).toBe('interface');
});
+2
View File
@@ -20,6 +20,8 @@ export default defineNuxtConfig({
vite: {
plugins: [
// `as any`: @tailwindcss/vite and Nuxt resolve different `vite` versions, so
// their `Plugin` types are structurally identical but nominally incompatible.
tailwindcss() as any,
],
},
+3 -2
View File
@@ -10,8 +10,9 @@
"lint:fix": "eslint . --fix",
"test": "vitest run",
"dev": "nuxt dev",
"build": "nuxt build",
"generate": "nuxt generate",
"build:deps": "pnpm --filter @robonen/stdlib --filter @robonen/platform --filter @robonen/fetch --filter @robonen/encoding --filter @robonen/crdt --filter @robonen/vue --filter @robonen/primitives --filter @robonen/writekit build",
"build": "pnpm run build:deps && nuxt build",
"generate": "pnpm run build:deps && nuxt generate",
"preview": "nuxt preview",
"extract": "jiti ./modules/extractor/extract.ts"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Disallow:
+86 -83
View File
@@ -299,89 +299,6 @@ importers:
specifier: ^43.216.1
version: 43.216.1(typanion@3.14.0)
vue/editor:
dependencies:
'@floating-ui/vue':
specifier: ^1.1.11
version: 1.1.11(vue@3.5.35(typescript@6.0.3))
'@robonen/crdt':
specifier: workspace:*
version: link:../../core/crdt
'@robonen/platform':
specifier: workspace:*
version: link:../../core/platform
'@robonen/stdlib':
specifier: workspace:*
version: link:../../core/stdlib
'@vue/shared':
specifier: 'catalog:'
version: 3.5.35
vue:
specifier: 'catalog:'
version: 3.5.35(typescript@6.0.3)
devDependencies:
'@robonen/eslint':
specifier: workspace:*
version: link:../../configs/eslint
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../configs/tsconfig
'@robonen/tsdown':
specifier: workspace:*
version: link:../../configs/tsdown
'@vitest/browser':
specifier: 'catalog:'
version: 4.1.8(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/browser-playwright':
specifier: ^4.1.8
version: 4.1.8(playwright@1.60.0)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8)
'@vue/test-utils':
specifier: 'catalog:'
version: 2.4.11(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))
eslint:
specifier: 'catalog:'
version: 10.4.1(jiti@2.7.0)
jsdom:
specifier: 'catalog:'
version: 29.1.1
playwright:
specifier: ^1.60.0
version: 1.60.0
tsdown:
specifier: 'catalog:'
version: 0.22.2(oxc-resolver@11.20.0)(typescript@6.0.3)(unrun@0.2.33)(vue-tsc@3.3.4(typescript@6.0.3))
unplugin-vue:
specifier: ^7.2.0
version: 7.2.0(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(vue@3.5.35(typescript@6.0.3))(yaml@2.9.0)
vitest-browser-vue:
specifier: ^2.1.0
version: 2.1.0(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3)))(vitest@4.1.8)(vue@3.5.35(typescript@6.0.3))
vue-tsc:
specifier: ^3.3.4
version: 3.3.4(typescript@6.0.3)
vue/editor/playground:
dependencies:
'@robonen/editor':
specifier: workspace:*
version: link:..
vue:
specifier: 'catalog:'
version: 3.5.35(typescript@6.0.3)
devDependencies:
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../../configs/tsconfig
'@vitejs/plugin-vue':
specifier: ^6.0.7
version: 6.0.7(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vue@3.5.35(typescript@6.0.3))
vite:
specifier: ^8.0.16
version: 8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0)
vue-tsc:
specifier: ^3.3.4
version: 3.3.4(typescript@6.0.3)
vue/primitives:
dependencies:
'@floating-ui/vue':
@@ -548,6 +465,92 @@ importers:
specifier: 'catalog:'
version: 0.22.2(oxc-resolver@11.20.0)(typescript@6.0.3)(unrun@0.2.33)(vue-tsc@3.2.6(typescript@6.0.3))
vue/writekit:
dependencies:
'@robonen/crdt':
specifier: workspace:*
version: link:../../core/crdt
'@robonen/platform':
specifier: workspace:*
version: link:../../core/platform
'@robonen/primitives':
specifier: workspace:*
version: link:../primitives
'@robonen/stdlib':
specifier: workspace:*
version: link:../../core/stdlib
'@robonen/vue':
specifier: workspace:*
version: link:../toolkit
'@vue/shared':
specifier: 'catalog:'
version: 3.5.35
vue:
specifier: 'catalog:'
version: 3.5.35(typescript@6.0.3)
devDependencies:
'@robonen/eslint':
specifier: workspace:*
version: link:../../configs/eslint
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../configs/tsconfig
'@robonen/tsdown':
specifier: workspace:*
version: link:../../configs/tsdown
'@vitest/browser':
specifier: 'catalog:'
version: 4.1.8(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/browser-playwright':
specifier: ^4.1.8
version: 4.1.8(playwright@1.60.0)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8)
'@vue/test-utils':
specifier: 'catalog:'
version: 2.4.11(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))
eslint:
specifier: 'catalog:'
version: 10.4.1(jiti@2.7.0)
jsdom:
specifier: 'catalog:'
version: 29.1.1
playwright:
specifier: ^1.60.0
version: 1.60.0
tsdown:
specifier: 'catalog:'
version: 0.22.2(oxc-resolver@11.20.0)(typescript@6.0.3)(unrun@0.2.33)(vue-tsc@3.3.4(typescript@6.0.3))
unplugin-vue:
specifier: ^7.2.0
version: 7.2.0(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(vue@3.5.35(typescript@6.0.3))(yaml@2.9.0)
vitest-browser-vue:
specifier: ^2.1.0
version: 2.1.0(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3)))(vitest@4.1.8)(vue@3.5.35(typescript@6.0.3))
vue-tsc:
specifier: ^3.3.4
version: 3.3.4(typescript@6.0.3)
vue/writekit/playground:
dependencies:
'@robonen/writekit':
specifier: workspace:*
version: link:..
vue:
specifier: 'catalog:'
version: 3.5.35(typescript@6.0.3)
devDependencies:
'@robonen/tsconfig':
specifier: workspace:*
version: link:../../../configs/tsconfig
'@vitejs/plugin-vue':
specifier: ^6.0.7
version: 6.0.7(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0))(vue@3.5.35(typescript@6.0.3))
vite:
specifier: ^8.0.16
version: 8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.44.0)(yaml@2.9.0)
vue-tsc:
specifier: ^3.3.4
version: 3.3.4(typescript@6.0.3)
packages:
'@adobe/css-tools@4.4.4':
+2 -2
View File
@@ -1,4 +1,4 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
import { base, compose, imports, stylistic, tests, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic, {
name: 'stories/overrides',
@@ -6,4 +6,4 @@ export default compose(base, typescript, imports, stylistic, {
rules: {
'@stylistic/no-multiple-empty-lines': 'off',
},
});
}, tests);
+17 -17
View File
@@ -29,33 +29,33 @@ const { count, increment, decrement, reset } = useCounter(0, { min: 0, max: 10 }
<!-- Feature highlights -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Tree-shakeable by design</h3>
<p class="text-sm text-(--fg-muted) leading-relaxed">
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="text-sm font-semibold text-fg mb-1.5">Tree-shakeable by design</h3>
<p class="text-sm text-fg-muted leading-relaxed">
Import only what you use. Each composable lives on its own and pulls in nothing it
doesn't need — your bundle stays exactly as small as your usage.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">SSR-safe out of the box</h3>
<p class="text-sm text-(--fg-muted) leading-relaxed">
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="text-sm font-semibold text-fg mb-1.5">SSR-safe out of the box</h3>
<p class="text-sm text-fg-muted leading-relaxed">
Browser-only access is guarded behind lifecycle hooks and configurable
<code>window</code>/<code>document</code> targets, so Nuxt and SSR setups just work.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Fully typed</h3>
<p class="text-sm text-(--fg-muted) leading-relaxed">
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="text-sm font-semibold text-fg mb-1.5">Fully typed</h3>
<p class="text-sm text-fg-muted leading-relaxed">
Written in TypeScript with precise return types and generics. <code>MaybeRefOrGetter</code>
arguments mean you can pass plain values, refs or getters interchangeably.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Broad coverage</h3>
<p class="text-sm text-(--fg-muted) leading-relaxed">
<div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="text-sm font-semibold text-fg mb-1.5">Broad coverage</h3>
<p class="text-sm text-fg-muted leading-relaxed">
From state and reactivity to sensors, elements, storage, math and form handling —
one cohesive toolkit spanning the whole surface of a Vue app.
</p>
@@ -101,19 +101,19 @@ useEventListener('keydown', (e) => {
<p>The same <code>useCounter</code> running live:</p>
</div>
<ClientOnly>
<div class="flex items-center gap-3 rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
<div class="flex items-center gap-3 rounded-lg border border-border bg-bg-subtle p-4">
<button
type="button"
class="size-9 rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring) disabled:opacity-40"
class="size-9 rounded-md border border-border bg-bg-elevated text-fg hover:bg-bg-inset focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-40"
:disabled="count <= 0"
@click="decrement()"
>
</button>
<span class="min-w-12 text-center text-lg font-medium tabular-nums text-(--fg)">{{ count }}</span>
<span class="min-w-12 text-center text-lg font-medium tabular-nums text-fg">{{ count }}</span>
<button
type="button"
class="size-9 rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring) disabled:opacity-40"
class="size-9 rounded-md border border-border bg-bg-elevated text-fg hover:bg-bg-inset focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-40"
:disabled="count >= 10"
@click="increment()"
>
@@ -121,7 +121,7 @@ useEventListener('keydown', (e) => {
</button>
<button
type="button"
class="ml-auto rounded-md px-3 py-1.5 text-sm text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="ml-auto rounded-md px-3 py-1.5 text-sm text-fg-muted hover:text-fg hover:bg-bg-inset focus:outline-none focus:ring-2 focus:ring-ring"
@click="reset()"
>
Reset
+2 -2
View File
@@ -1,3 +1,3 @@
import { base, compose, imports, stylistic, typescript, vitest, vue } from '@robonen/eslint';
import { base, compose, imports, stylistic, tests, typescript, vitest, vue } from '@robonen/eslint';
export default compose(base, typescript, vue, vitest, imports, stylistic);
export default compose(base, typescript, vue, vitest, imports, stylistic, tests);
@@ -40,7 +40,7 @@ const stateColor = computed(() => {
case 'running': return 'bg-emerald-500';
case 'paused': return 'bg-amber-500';
case 'finished': return 'bg-sky-500';
default: return 'bg-(--border-strong)';
default: return 'bg-border-strong';
}
});
@@ -48,7 +48,7 @@ const rates = [0.5, 1, 2] as const;
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="demo-stack max-w-sm">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-600 dark:text-amber-400"
@@ -57,28 +57,28 @@ const rates = [0.5, 1, 2] as const;
</div>
<template v-else>
<div class="flex h-28 items-center justify-center overflow-hidden rounded-xl border border-(--border) bg-(--bg-inset)">
<div class="flex h-28 items-center justify-center overflow-hidden rounded-xl border border-border bg-bg-inset">
<div
ref="target"
class="size-12 bg-(--accent) shadow-lg"
class="size-12 bg-accent shadow-lg"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="rounded-lg border border-border bg-bg-inset p-3">
<div class="demo-label">
State
</div>
<div class="mt-1 flex items-center gap-2">
<span class="inline-block size-2 rounded-full transition" :class="stateColor" />
<span class="font-mono text-sm text-(--fg)">{{ playState }}</span>
<span class="font-mono text-sm text-fg">{{ playState }}</span>
</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="rounded-lg border border-border bg-bg-inset p-3">
<div class="demo-label">
Current time
</div>
<div class="mt-1 font-mono text-sm tabular-nums text-(--fg)">
<div class="mt-1 font-mono text-sm tabular-nums text-fg">
{{ elapsed }}
</div>
</div>
@@ -86,31 +86,31 @@ const rates = [0.5, 1, 2] as const;
<div class="grid grid-cols-3 gap-2">
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn-primary"
@click="play"
>
Play
</button>
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="pause"
>
Pause
</button>
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="reverse"
>
Reverse
</button>
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="finish"
>
Finish
</button>
<button
class="col-span-2 inline-flex 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] cursor-pointer"
class="demo-btn col-span-2"
@click="cancel"
>
Cancel
@@ -118,7 +118,7 @@ const rates = [0.5, 1, 2] as const;
</div>
<div class="flex flex-col gap-2">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="demo-label">
Playback rate
</div>
<div class="flex gap-2">
@@ -127,8 +127,8 @@ const rates = [0.5, 1, 2] as const;
:key="rate"
class="flex-1 rounded-lg border px-3 py-1.5 text-sm font-medium tabular-nums transition active:scale-[0.98] cursor-pointer"
:class="playbackRate === rate
? 'border-transparent bg-(--accent) text-(--accent-fg)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
? 'border-transparent bg-accent text-accent-fg'
: 'border-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="playbackRate = rate"
>
{{ rate }}×
@@ -37,9 +37,9 @@ function toggle() {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5 text-center">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="demo-stack max-w-sm">
<div class="demo-card p-5 text-center">
<div class="demo-label">
Time remaining
</div>
<div
@@ -48,22 +48,22 @@ function toggle() {
? 'text-emerald-600 dark:text-emerald-400'
: remaining <= 10 && remaining > 0
? 'text-amber-600 dark:text-amber-400'
: 'text-(--fg)'"
: 'text-fg'"
>
{{ minutes }}:{{ seconds }}
</div>
<div class="mt-4 h-1.5 w-full overflow-hidden rounded-full bg-(--bg-inset)">
<div class="mt-4 h-1.5 w-full overflow-hidden rounded-full bg-bg-inset">
<div
class="h-full rounded-full bg-(--accent) transition-[width] duration-300 ease-linear"
class="h-full rounded-full bg-accent transition-[width] duration-300 ease-linear"
:style="{ width: `${progress * 100}%` }"
/>
</div>
<div class="mt-3 flex items-center justify-center gap-2 text-xs text-(--fg-subtle)">
<div class="mt-3 flex items-center justify-center gap-2 text-xs text-fg-subtle">
<span
class="inline-block size-2 rounded-full transition"
:class="isActive ? 'bg-emerald-500' : justFinished ? 'bg-sky-500' : 'bg-(--border-strong)'"
:class="isActive ? 'bg-emerald-500' : justFinished ? 'bg-sky-500' : 'bg-border-strong'"
/>
{{ justFinished ? 'Completed' : isActive ? 'Counting down' : 'Paused' }}
</div>
@@ -73,7 +73,7 @@ function toggle() {
<button
v-for="preset in presets"
:key="preset"
class="rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium tabular-nums text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
class="rounded-lg border border-border bg-bg-elevated px-3 py-1.5 text-sm font-medium tabular-nums text-fg transition hover:bg-bg-inset hover:border-border-strong active:scale-[0.98] cursor-pointer"
@click="setPreset(preset)"
>
{{ preset < 60 ? `${preset}s` : `${preset / 60}m` }}
@@ -82,20 +82,20 @@ function toggle() {
<div class="flex items-center gap-2">
<button
class="flex-1 inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn-primary flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="remaining === 0 && isActive"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Resume' }}
</button>
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="start()"
>
Restart
</button>
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="stop"
>
Stop
@@ -27,46 +27,46 @@ const isValid = computed(() => formatted.value !== 'Invalid Date');
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="demo-stack max-w-md">
<div class="demo-card p-4">
<div class="demo-label">
Formatted output
</div>
<div
class="mt-2 font-mono text-lg font-semibold tabular-nums"
:class="isValid ? 'text-(--fg)' : 'text-red-600 dark:text-red-400'"
:class="isValid ? 'text-fg' : 'text-red-600 dark:text-red-400'"
>
{{ formatted }}
</div>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<label class="demo-label">
Date input
</label>
<input
v-model="date"
type="datetime-local"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="demo-input"
>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<label class="demo-label">
Format token string
</label>
<input
v-model="format"
type="text"
spellcheck="false"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="demo-input font-mono"
>
<div class="flex flex-wrap gap-1.5 pt-1">
<button
v-for="f in formats"
:key="f"
class="rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 font-mono text-xs text-(--fg-muted) transition hover:bg-(--bg-elevated) hover:text-(--fg) active:scale-[0.98] cursor-pointer"
:class="{ 'border-(--accent) text-(--accent-text)': format === f }"
class="rounded-md border border-border bg-bg-inset px-2 py-0.5 font-mono text-xs text-fg-muted transition hover:bg-bg-elevated hover:text-fg active:scale-[0.98] cursor-pointer"
:class="{ 'border-accent text-accent-text': format === f }"
@click="format = f"
>
{{ f }}
@@ -75,7 +75,7 @@ const isValid = computed(() => formatted.value !== 'Invalid Date');
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<label class="demo-label">
Locale
</label>
<div class="flex flex-wrap gap-2">
@@ -84,8 +84,8 @@ const isValid = computed(() => formatted.value !== 'Invalid Date');
:key="loc.value"
class="rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="locale === loc.value
? 'border-transparent bg-(--accent) text-(--accent-fg)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
? 'border-transparent bg-accent text-accent-fg'
: 'border-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="locale = loc.value"
>
{{ loc.label }}
@@ -52,6 +52,7 @@ const REGEX_FORMAT
// `20240101`); JS lacks possessive quantifiers to disambiguate it.
// eslint-disable-next-line regexp/no-misleading-capturing-group
const REGEX_PARSE = /* #__PURE__ */ /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[T\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/i;
const REGEX_ISO_SUFFIX = /* #__PURE__ */ /z$/i;
const ORDINAL_SUFFIXES = ['th', 'st', 'nd', 'rd'] as const;
@@ -82,7 +83,7 @@ function formatOrdinal(num: number): string {
export function normalizeDate(date: DateLike): Date {
if (date === null || date === undefined) return new Date();
if (isDate(date)) return new Date(date.getTime());
if (isString(date) && !/z$/i.test(date)) {
if (isString(date) && !REGEX_ISO_SUFFIX.test(date)) {
const d = REGEX_PARSE.exec(date);
if (d) {
const month = d[2] ? Number(d[2]) - 1 : 0;
@@ -27,12 +27,12 @@ function toggle() {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5 text-center">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="demo-stack max-w-sm">
<div class="demo-card p-5 text-center">
<div class="demo-label">
Ticks elapsed
</div>
<div class="mt-2 font-mono text-5xl font-bold tabular-nums text-(--fg)">
<div class="demo-stat mt-2 text-5xl">
{{ counter }}
</div>
@@ -41,21 +41,21 @@ function toggle() {
v-for="(on, i) in beats"
:key="i"
class="size-2.5 rounded-full transition-colors duration-200"
:class="on ? 'bg-(--accent)' : 'bg-(--bg-inset)'"
:class="on ? 'bg-accent' : 'bg-bg-inset'"
/>
</div>
<div class="mt-4 flex items-center justify-center gap-2 text-xs text-(--fg-subtle)">
<div class="mt-4 flex items-center justify-center gap-2 text-xs text-fg-subtle">
<span
class="inline-block size-2 rounded-full transition"
:class="isActive ? 'bg-emerald-500' : 'bg-(--border-strong)'"
:class="isActive ? 'bg-emerald-500' : 'bg-border-strong'"
/>
{{ isActive ? `Ticking every ${interval}ms` : 'Paused' }}
</div>
</div>
<div class="flex flex-col gap-1.5">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="demo-label">
Interval speed
</div>
<div class="flex gap-2">
@@ -64,8 +64,8 @@ function toggle() {
:key="speed.value"
class="flex-1 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="interval === speed.value
? 'border-transparent bg-(--accent) text-(--accent-fg)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
? 'border-transparent bg-accent text-accent-fg'
: 'border-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="interval = speed.value"
>
{{ speed.label }}
@@ -75,13 +75,13 @@ function toggle() {
<div class="flex items-center gap-2">
<button
class="flex-1 inline-flex 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] cursor-pointer"
class="demo-btn-primary flex-1"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Resume' }}
</button>
<button
class="inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="counter === 0"
@click="reset"
>
@@ -31,22 +31,22 @@ function clear() {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex items-center justify-between rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="demo-stack max-w-sm">
<div class="demo-card flex items-center justify-between p-4">
<div>
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="demo-label">
Interval callback
</div>
<div class="mt-1 flex items-center gap-2 text-sm text-(--fg-muted)">
<div class="mt-1 flex items-center gap-2 text-sm text-fg-muted">
<span
class="inline-block size-2 rounded-full transition"
:class="isActive ? 'bg-emerald-500' : 'bg-(--border-strong)'"
:class="isActive ? 'bg-emerald-500' : 'bg-border-strong'"
/>
{{ isActive ? `Firing every ${interval}ms` : 'Stopped' }}
</div>
</div>
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn-primary"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Start' }}
@@ -54,7 +54,7 @@ function clear() {
</div>
<div class="flex flex-col gap-1.5">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="demo-label">
Interval
</div>
<div class="flex gap-2">
@@ -63,24 +63,24 @@ function clear() {
:key="speed.value"
class="flex-1 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="interval === speed.value
? 'border-transparent bg-(--accent) text-(--accent-fg)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
? 'border-transparent bg-accent text-accent-fg'
: 'border-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="interval = speed.value"
>
{{ speed.label }}
</button>
</div>
<p class="text-xs text-(--fg-subtle)">
<p class="text-xs text-fg-subtle">
Changing the interval while running restarts the timer with the new duration.
</p>
</div>
<div class="flex items-center justify-between">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="demo-label">
Tick log
</div>
<button
class="text-xs text-(--accent-text) transition hover:underline disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"
class="text-xs text-accent-text transition hover:underline disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"
:disabled="logs.length === 0"
@click="clear"
>
@@ -88,17 +88,17 @@ function clear() {
</button>
</div>
<div class="min-h-32 rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<p v-if="logs.length === 0" class="py-6 text-center text-sm text-(--fg-subtle)">
<div class="min-h-32 rounded-lg border border-border bg-bg-inset p-3">
<p v-if="logs.length === 0" class="py-6 text-center text-sm text-fg-subtle">
No ticks yet press Start.
</p>
<ul v-else class="flex flex-col gap-1.5">
<li
v-for="log in logs"
:key="log.id"
class="flex items-center gap-2 font-mono text-sm tabular-nums text-(--fg)"
class="flex items-center gap-2 font-mono text-sm tabular-nums text-fg"
>
<span class="inline-block size-1.5 rounded-full bg-(--accent)" />
<span class="inline-block size-1.5 rounded-full bg-accent" />
{{ log.time }}
</li>
</ul>
@@ -106,14 +106,14 @@ function clear() {
<div class="flex items-center gap-2">
<button
class="flex-1 inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="isActive"
@click="resume"
>
Resume
</button>
<button
class="flex-1 inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!isActive"
@click="pause"
>
@@ -20,23 +20,23 @@ const secondAngle = computed(() => {
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col items-center gap-3">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Reactive now</div>
<div class="demo-stack max-w-sm">
<div class="demo-card p-4 flex flex-col items-center gap-3">
<div class="demo-label">Reactive now</div>
<div class="flex items-baseline gap-1">
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ time }}</span>
<span class="font-mono text-lg font-semibold tabular-nums text-(--fg-subtle)">.{{ millis }}</span>
<span class="demo-stat text-3xl">{{ time }}</span>
<span class="font-mono text-lg font-semibold tabular-nums text-fg-subtle">.{{ millis }}</span>
</div>
<div class="text-sm text-(--fg-muted)">{{ date }}</div>
<div class="text-sm text-fg-muted">{{ date }}</div>
<div class="relative mt-1 size-24 rounded-full border-2 border-(--border-strong) bg-(--bg-inset)">
<div class="relative mt-1 size-24 rounded-full border-2 border-border-strong bg-bg-inset">
<div class="absolute inset-0 flex items-center justify-center">
<div class="size-1.5 rounded-full bg-(--accent)" />
<div class="size-1.5 rounded-full bg-accent" />
</div>
<div
class="absolute bottom-1/2 left-1/2 h-9 w-0.5 origin-bottom rounded-full bg-(--accent)"
class="absolute bottom-1/2 left-1/2 h-9 w-0.5 origin-bottom rounded-full bg-accent"
:style="{ transform: `translateX(-50%) rotate(${secondAngle}deg)` }"
/>
</div>
@@ -44,11 +44,11 @@ const secondAngle = computed(() => {
<div class="flex items-center justify-between gap-3">
<span
class="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)"
class="demo-badge"
>
<span
class="size-1.5 rounded-full transition"
:class="isActive ? 'bg-emerald-500' : 'bg-(--fg-subtle)'"
:class="isActive ? 'bg-emerald-500' : 'bg-fg-subtle'"
/>
{{ isActive ? 'Ticking (RAF)' : 'Paused' }}
</span>
@@ -56,7 +56,7 @@ const secondAngle = computed(() => {
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Resume' }}
@@ -64,7 +64,7 @@ const secondAngle = computed(() => {
<button
type="button"
:disabled="isActive"
class="inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn-primary disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="resume"
>
Resume
@@ -72,7 +72,7 @@ const secondAngle = computed(() => {
<button
type="button"
:disabled="!isActive"
class="inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="pause"
>
Pause
@@ -35,46 +35,46 @@ const limitLabel = computed(() => (fpsLimit.value === 0 ? 'Unlimited' : `${fpsLi
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col gap-4">
<div class="demo-stack max-w-sm">
<div class="demo-card p-4 flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">requestAnimationFrame</span>
<span class="demo-label">requestAnimationFrame</span>
<span
class="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)"
class="demo-badge"
>
<span class="size-1.5 rounded-full transition" :class="isActive ? 'bg-emerald-500' : 'bg-(--fg-subtle)'" />
<span class="size-1.5 rounded-full transition" :class="isActive ? 'bg-emerald-500' : 'bg-fg-subtle'" />
{{ isActive ? 'Running' : 'Paused' }}
</span>
</div>
<!-- The animated track: marker position is updated every frame -->
<div class="relative mx-2.5 h-8 rounded-lg border border-(--border) bg-(--bg-inset)">
<div class="relative mx-2.5 h-8 rounded-lg border border-border bg-bg-inset">
<div
class="absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-(--accent) shadow"
class="absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent shadow"
:style="{ left: `${position}%` }"
/>
</div>
<div class="grid grid-cols-3 gap-2">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-2 text-center">
<div class="font-mono text-lg font-bold tabular-nums text-(--fg)">{{ fps }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">fps</div>
<div class="rounded-lg border border-border bg-bg-inset p-2 text-center">
<div class="demo-stat text-lg">{{ fps }}</div>
<div class="text-[10px] uppercase tracking-wide text-fg-subtle">fps</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-2 text-center">
<div class="font-mono text-lg font-bold tabular-nums text-(--fg)">{{ delta.toFixed(1) }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">delta ms</div>
<div class="rounded-lg border border-border bg-bg-inset p-2 text-center">
<div class="demo-stat text-lg">{{ delta.toFixed(1) }}</div>
<div class="text-[10px] uppercase tracking-wide text-fg-subtle">delta ms</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-2 text-center">
<div class="font-mono text-lg font-bold tabular-nums text-(--fg)">{{ frames }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">frames</div>
<div class="rounded-lg border border-border bg-bg-inset p-2 text-center">
<div class="demo-stat text-lg">{{ frames }}</div>
<div class="text-[10px] uppercase tracking-wide text-fg-subtle">frames</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="fps-limit">FPS limit</label>
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ limitLabel }}</span>
<label class="demo-label" for="fps-limit">FPS limit</label>
<span class="font-mono text-xs tabular-nums text-fg-muted">{{ limitLabel }}</span>
</div>
<input
id="fps-limit"
@@ -83,14 +83,14 @@ const limitLabel = computed(() => (fpsLimit.value === 0 ? 'Unlimited' : `${fpsLi
min="0"
max="60"
step="5"
class="w-full accent-(--accent) cursor-pointer"
class="w-full accent-accent cursor-pointer"
>
<p class="text-xs text-(--fg-subtle)">Changing the limit takes effect on the next mount; toggle below to see it live.</p>
<p class="text-xs text-fg-subtle">Changing the limit takes effect on the next mount; toggle below to see it live.</p>
</div>
<button
type="button"
class="inline-flex 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] cursor-pointer"
class="demo-btn-primary"
@click="toggle"
>
{{ isActive ? 'Pause loop' : 'Resume loop' }}
@@ -42,15 +42,15 @@ const absolute = computed(() =>
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col items-center gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Relative time</span>
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg) text-center">{{ timeAgo }}</span>
<span class="text-xs text-(--fg-muted)">{{ absolute }}</span>
<div class="demo-stack max-w-sm">
<div class="demo-card p-4 flex flex-col items-center gap-2">
<span class="demo-label">Relative time</span>
<span class="demo-stat text-3xl text-center">{{ timeAgo }}</span>
<span class="text-xs text-fg-muted">{{ absolute }}</span>
</div>
<div class="flex flex-col gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Pick an instant</span>
<span class="demo-label">Pick an instant</span>
<div class="grid grid-cols-2 gap-2">
<button
v-for="preset in presets"
@@ -58,8 +58,8 @@ const absolute = computed(() =>
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="offset === preset.offset
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
? 'border-transparent bg-accent text-accent-fg hover:bg-accent-hover'
: 'border-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="offset = preset.offset"
>
{{ preset.label }}
@@ -69,14 +69,14 @@ const absolute = computed(() =>
<div class="flex items-center justify-between gap-3">
<span
class="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)"
class="demo-badge"
>
<span class="size-1.5 rounded-full transition" :class="isActive ? 'bg-emerald-500' : 'bg-(--fg-subtle)'" />
<span class="size-1.5 rounded-full transition" :class="isActive ? 'bg-emerald-500' : 'bg-fg-subtle'" />
{{ isActive ? 'Updating every 1s' : 'Updates paused' }}
</span>
<button
type="button"
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Resume' }}
@@ -165,10 +165,12 @@ const DEFAULT_UNITS: Array<UseTimeAgoUnit<UseTimeAgoUnitName>> = [
{ max: Number.POSITIVE_INFINITY, value: 31536000000, name: 'year' },
];
const REGEX_DIGIT = /* #__PURE__ */ /\d/;
const DEFAULT_MESSAGES: UseTimeAgoMessages<UseTimeAgoUnitName> = {
justNow: 'just now',
past: n => /\d/.test(n) ? `${n} ago` : n,
future: n => /\d/.test(n) ? `in ${n}` : n,
past: n => REGEX_DIGIT.test(n) ? `${n} ago` : n,
future: n => REGEX_DIGIT.test(n) ? `in ${n}` : n,
month: (n, past) => n === 1 ? (past ? 'last month' : 'next month') : `${n} month${n > 1 ? 's' : ''}`,
year: (n, past) => n === 1 ? (past ? 'last year' : 'next year') : `${n} year${n > 1 ? 's' : ''}`,
day: (n, past) => n === 1 ? (past ? 'yesterday' : 'tomorrow') : `${n} day${n > 1 ? 's' : ''}`,
@@ -24,21 +24,21 @@ function cancel() {
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col items-center gap-3">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Status</span>
<div class="demo-stack max-w-sm">
<div class="demo-card p-4 flex flex-col items-center gap-3">
<span class="demo-label">Status</span>
<div
class="flex size-20 items-center justify-center rounded-full border-2 transition"
:class="ready
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'"
: 'border-accent bg-accent-subtle text-accent-text'"
>
<span class="text-sm font-semibold">{{ ready ? 'Ready' : 'Pending' }}</span>
</div>
<p class="text-center text-sm text-(--fg-muted)">
<template v-if="ready && firedAt">Fired at <span class="font-mono tabular-nums text-(--fg)">{{ firedAt }}</span></template>
<p class="text-center text-sm text-fg-muted">
<template v-if="ready && firedAt">Fired at <span class="font-mono tabular-nums text-fg">{{ firedAt }}</span></template>
<template v-else-if="ready">Idle start the timer below</template>
<template v-else>Counting down stays pending until the delay elapses</template>
</p>
@@ -46,8 +46,8 @@ function cancel() {
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="delay">Delay</label>
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ (delay / 1000).toFixed(1) }}s</span>
<label class="demo-label" for="delay">Delay</label>
<span class="font-mono text-xs tabular-nums text-fg-muted">{{ (delay / 1000).toFixed(1) }}s</span>
</div>
<input
id="delay"
@@ -56,14 +56,14 @@ function cancel() {
min="500"
max="5000"
step="500"
class="w-full accent-(--accent) cursor-pointer"
class="w-full accent-accent cursor-pointer"
>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="flex-1 inline-flex 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] cursor-pointer"
class="demo-btn-primary flex-1"
@click="restart"
>
{{ ready ? 'Start' : 'Restart' }}
@@ -71,7 +71,7 @@ function cancel() {
<button
type="button"
:disabled="ready"
class="flex-1 inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="cancel"
>
Cancel
@@ -42,23 +42,23 @@ function undo() {
<template>
<div class="w-full max-w-sm flex flex-col gap-3">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Inbox · undo with grace period</span>
<span class="demo-label">Inbox · undo with grace period</span>
<ul v-if="inbox.length" class="flex flex-col gap-2">
<li
v-for="mail in inbox"
:key="mail.id"
class="flex items-center justify-between gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-3 transition"
class="demo-card flex items-center justify-between gap-3 p-3 transition"
:class="{ 'opacity-40': pendingDelete?.id === mail.id }"
>
<div class="min-w-0">
<div class="truncate text-sm font-medium text-(--fg)">{{ mail.subject }}</div>
<div class="truncate text-xs text-(--fg-muted)">{{ mail.from }}</div>
<div class="truncate text-sm font-medium text-fg">{{ mail.subject }}</div>
<div class="truncate text-xs text-fg-muted">{{ mail.from }}</div>
</div>
<button
type="button"
:disabled="isPending"
class="shrink-0 inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn shrink-0 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
@click="archive(mail)"
>
Archive
@@ -66,7 +66,7 @@ function undo() {
</li>
</ul>
<div v-else class="rounded-xl border border-dashed border-(--border) bg-(--bg-inset) p-6 text-center text-sm text-(--fg-subtle)">
<div v-else class="rounded-xl border border-dashed border-border bg-bg-inset p-6 text-center text-sm text-fg-subtle">
Inbox zero everything archived.
</div>
@@ -21,7 +21,7 @@ export interface UseTimeoutFnOptions {
immediateCallback?: boolean;
}
export interface UseTimeoutFnReturn<Args extends any[]> {
export interface UseTimeoutFnReturn<Args extends unknown[]> {
/**
* Whether the timeout is currently pending
*/
@@ -45,33 +45,33 @@ function resetOffset() {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 text-center">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="demo-stack max-w-sm">
<div class="demo-card p-4 text-center">
<div class="demo-label">
Reactive timestamp
</div>
<div class="mt-2 font-mono text-3xl font-bold tabular-nums text-(--fg)">
<div class="demo-stat mt-2 text-3xl">
{{ clockTime }}
</div>
<div class="mt-1 text-sm text-(--fg-muted)">
<div class="mt-1 text-sm text-fg-muted">
{{ clockDate }}
</div>
<div class="mt-3 flex items-center justify-center gap-2 text-xs text-(--fg-subtle)">
<div class="mt-3 flex items-center justify-center gap-2 text-xs text-fg-subtle">
<span
class="inline-block size-2 rounded-full transition"
:class="isActive ? 'bg-emerald-500' : 'bg-(--border-strong)'"
:class="isActive ? 'bg-emerald-500' : 'bg-border-strong'"
/>
{{ isActive ? 'Updating every second' : 'Paused' }}
</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
<div class="rounded-lg border border-border bg-bg-inset p-3 font-mono text-sm text-fg tabular-nums">
{{ Math.round(timestamp) }} ms
</div>
<div class="flex items-center justify-between gap-2">
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn-primary"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Resume' }}
@@ -79,13 +79,13 @@ function resetOffset() {
<div class="flex items-center gap-2">
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="shift(-3600_000)"
>
-1h
</button>
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="shift(3600_000)"
>
+1h
@@ -93,13 +93,13 @@ function resetOffset() {
</div>
</div>
<div class="flex items-center justify-between text-sm text-(--fg-muted)">
<div class="flex items-center justify-between text-sm text-fg-muted">
<span>
Offset:
<span class="font-mono text-(--fg) tabular-nums">{{ (offset / 3600_000).toFixed(0) }}h</span>
<span class="font-mono text-fg tabular-nums">{{ (offset / 3600_000).toFixed(0) }}h</span>
</span>
<button
class="text-(--accent-text) transition hover:underline disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"
class="text-accent-text transition hover:underline disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"
:disabled="offset === 0"
@click="resetOffset"
>
@@ -40,19 +40,19 @@ function randomize() {
<template>
<div class="flex w-full max-w-md flex-col gap-5">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="demo-card p-4">
<div class="flex items-baseline justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<span class="demo-label">
Eased value
</span>
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">
<span class="demo-stat text-3xl">
{{ value.toFixed(1) }}
</span>
</div>
<div class="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-(--bg-inset)">
<div class="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-bg-inset">
<div
class="h-full rounded-full bg-(--accent)"
class="h-full rounded-full bg-accent"
:style="{ width: `${Math.max(0, Math.min(100, value))}%` }"
/>
</div>
@@ -63,10 +63,10 @@ function randomize() {
type="range"
min="0"
max="100"
class="h-1.5 flex-1 cursor-pointer accent-(--accent)"
class="h-1.5 flex-1 cursor-pointer accent-accent"
>
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="randomize"
>
Random
@@ -75,21 +75,21 @@ function randomize() {
</div>
<div class="flex flex-col gap-2">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<label class="demo-label">
Easing preset
</label>
<select
v-model="preset"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="w-full rounded-lg border border-border bg-bg px-3 py-2 text-sm text-fg transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-ring"
>
<option v-for="name in presetNames" :key="name" :value="name">
{{ name }}
</option>
</select>
<label class="mt-1 flex items-center justify-between text-sm text-(--fg-muted)">
<label class="mt-1 flex items-center justify-between text-sm text-fg-muted">
<span>Duration</span>
<span class="font-mono text-(--fg) tabular-nums">{{ duration }}ms</span>
<span class="font-mono text-fg tabular-nums">{{ duration }}ms</span>
</label>
<input
v-model.number="duration"
@@ -97,21 +97,21 @@ function randomize() {
min="100"
max="2000"
step="100"
class="h-1.5 w-full cursor-pointer accent-(--accent)"
class="h-1.5 w-full cursor-pointer accent-accent"
>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="demo-card p-4">
<div class="flex items-center gap-3">
<div
class="size-12 shrink-0 rounded-lg border border-(--border)"
class="size-12 shrink-0 rounded-lg border border-border"
:style="{ backgroundColor: colorCss }"
/>
<div class="min-w-0">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="demo-label">
Animated tuple
</div>
<div class="font-mono text-sm text-(--fg) tabular-nums">
<div class="font-mono text-sm text-fg tabular-nums">
{{ colorCss }}
</div>
</div>
@@ -121,7 +121,7 @@ function randomize() {
<button
v-for="[label, rgb] in swatches"
:key="label"
class="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) transition hover:border-(--border-strong) cursor-pointer"
class="demo-badge transition hover:border-border-strong cursor-pointer"
@click="colorTarget = [...rgb]"
>
<span class="size-2.5 rounded-full" :style="{ backgroundColor: `rgb(${rgb.join(',')})` }" />
@@ -4,6 +4,7 @@ import { clamp, isFunction, isNumber, lerp, noop } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useRafFn } from '@/composables/animation/useRafFn';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
/**
* Cubic bezier control points `[x1, y1, x2, y2]` (the implied endpoints are
@@ -356,5 +357,10 @@ export function useTransition<T extends TransitionValue>(
},
);
// The RAF loop is torn down by useRafFn on scope dispose, but a pending start
// delay (window.setTimeout) is not — clear it so the timer can't fire into a
// disposed scope.
tryOnScopeDispose(clearDelay);
return computed(() => outputRef.value);
}
@@ -40,16 +40,16 @@ function toggle(track: Track) {
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<div class="demo-stack max-w-md">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<span class="demo-label">
Library tap to add / remove from playlist
</span>
<label class="inline-flex cursor-pointer items-center gap-2 text-sm text-(--fg-muted)">
<label class="inline-flex cursor-pointer items-center gap-2 text-sm text-fg-muted">
<input
v-model="symmetric"
type="checkbox"
class="size-4 cursor-pointer accent-(--accent)"
class="size-4 cursor-pointer accent-accent"
>
Symmetric
</label>
@@ -61,8 +61,8 @@ function toggle(track: Track) {
:key="track.id"
class="inline-flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="inPlaylist(track)
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
? 'border-transparent bg-accent text-accent-fg hover:bg-accent-hover'
: 'border-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="toggle(track)"
>
<span class="truncate">{{ track.title }}</span>
@@ -70,12 +70,12 @@ function toggle(track: Track) {
</button>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="demo-card p-4">
<div class="flex items-baseline justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<span class="demo-label">
{{ symmetric ? 'In exactly one (XOR)' : 'Not in playlist' }}
</span>
<span class="font-mono text-sm tabular-nums text-(--fg-muted)">
<span class="font-mono text-sm tabular-nums text-fg-muted">
{{ diff.length }}
</span>
</div>
@@ -84,12 +84,12 @@ function toggle(track: Track) {
<li
v-for="track in diff"
:key="track.id"
class="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)"
class="demo-badge"
>
{{ track.title }}
</li>
</ul>
<p v-else class="mt-3 text-sm text-(--fg-subtle)">
<p v-else class="mt-3 text-sm text-fg-subtle">
No difference every track matches.
</p>
</div>
@@ -1,6 +1,6 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isObject, isString } from '@robonen/stdlib';
import { isFunction, isNumber, isObject, isString, isSymbol } from '@robonen/stdlib';
/**
* Comparator deciding whether two array elements are considered equal.
@@ -24,7 +24,7 @@ export interface UseArrayDifferenceOptions<T> {
comparator?: UseArrayDifferenceComparatorFn<T> | keyof T;
}
export type UseArrayDifferenceReturn<T = any>
export type UseArrayDifferenceReturn<T = unknown>
= ComputedRef<T[]>;
function isArrayDifferenceOptions<T>(value: unknown): value is UseArrayDifferenceOptions<T> {
@@ -101,11 +101,11 @@ export function useArrayDifference<T>(
// Resolve the comparator once instead of rebuilding it on every recompute.
let compare: UseArrayDifferenceComparatorFn<T>;
if (isString(resolved) || typeof resolved === 'symbol' || typeof resolved === 'number') {
if (isString(resolved) || isSymbol(resolved) || isNumber(resolved)) {
const key = resolved as keyof T;
compare = (value, othVal) => value[key] === othVal[key];
}
else if (typeof resolved === 'function') {
else if (isFunction(resolved)) {
compare = resolved;
}
else {
@@ -30,22 +30,22 @@ function reset() {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="demo-stack max-w-sm">
<div
class="rounded-xl border p-4 transition"
:class="allDone
? 'border-emerald-500/30 bg-emerald-500/10'
: 'border-(--border) bg-(--bg-elevated)'"
: 'border-border bg-bg-elevated'"
>
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<span class="demo-label">
Release readiness
</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="allDone
? 'border-emerald-500/30 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
: 'border-border bg-bg-inset text-fg-muted'"
>
<span
class="size-2 rounded-full"
@@ -54,7 +54,7 @@ function reset() {
{{ allDone ? 'Ready to ship' : 'Blocked' }}
</span>
</div>
<div class="mt-2 font-mono text-sm tabular-nums text-(--fg-muted)">
<div class="mt-2 font-mono text-sm tabular-nums text-fg-muted">
{{ completed }} / {{ checklist.length }} complete
</div>
</div>
@@ -62,18 +62,18 @@ function reset() {
<ul class="flex flex-col gap-2">
<li v-for="item in checklist" :key="item.id">
<button
class="flex w-full items-center gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2 text-left text-sm text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.99] cursor-pointer"
class="flex w-full items-center gap-3 rounded-lg border border-border bg-bg-elevated px-3 py-2 text-left text-sm text-fg transition hover:bg-bg-inset hover:border-border-strong active:scale-[0.99] cursor-pointer"
@click="toggle(item)"
>
<span
class="flex size-5 shrink-0 items-center justify-center rounded-md border text-xs transition"
:class="item.done
? 'border-transparent bg-(--accent) text-(--accent-fg)'
: 'border-(--border-strong) text-transparent'"
? 'border-transparent bg-accent text-accent-fg'
: 'border-border-strong text-transparent'"
>
</span>
<span :class="item.done ? 'line-through text-(--fg-subtle)' : ''">
<span :class="item.done ? 'line-through text-fg-subtle' : ''">
{{ item.label }}
</span>
</button>
@@ -81,7 +81,7 @@ function reset() {
</ul>
<button
class="inline-flex items-center justify-center gap-1.5 self-start 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] cursor-pointer"
class="demo-btn self-start"
@click="reset"
>
Reset
@@ -35,18 +35,18 @@ const formatted = computed(() =>
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<div class="demo-stack max-w-md">
<input
v-model="query"
type="text"
placeholder="Search products…"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="demo-input"
>
<div class="flex flex-col gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<label class="flex items-center justify-between text-sm text-(--fg-muted)">
<div class="demo-card flex flex-col gap-3 p-4">
<label class="flex items-center justify-between text-sm text-fg-muted">
<span>Max price</span>
<span class="font-mono text-(--fg) tabular-nums">${{ maxPrice }}</span>
<span class="font-mono text-fg tabular-nums">${{ maxPrice }}</span>
</label>
<input
v-model.number="maxPrice"
@@ -54,21 +54,21 @@ const formatted = computed(() =>
min="25"
max="400"
step="5"
class="h-1.5 w-full cursor-pointer accent-(--accent)"
class="h-1.5 w-full cursor-pointer accent-accent"
>
<label class="inline-flex cursor-pointer items-center gap-2 text-sm text-(--fg-muted)">
<label class="inline-flex cursor-pointer items-center gap-2 text-sm text-fg-muted">
<input
v-model="inStockOnly"
type="checkbox"
class="size-4 cursor-pointer accent-(--accent)"
class="size-4 cursor-pointer accent-accent"
>
In stock only
</label>
</div>
<div class="flex items-center justify-between text-xs">
<span class="font-medium uppercase tracking-wide text-(--fg-subtle)">Results</span>
<span class="font-mono tabular-nums text-(--fg-muted)">
<span class="font-medium uppercase tracking-wide text-fg-subtle">Results</span>
<span class="font-mono tabular-nums text-fg-muted">
{{ formatted.length }} / {{ products.length }}
</span>
</div>
@@ -77,10 +77,10 @@ const formatted = computed(() =>
<li
v-for="product in formatted"
:key="product.name"
class="flex items-center justify-between gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2"
class="flex items-center justify-between gap-3 rounded-lg border border-border bg-bg-elevated px-3 py-2"
>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-(--fg)">{{ product.name }}</span>
<span class="text-sm font-medium text-fg">{{ product.name }}</span>
<span
v-if="!product.inStock"
class="inline-flex items-center rounded-md border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400"
@@ -88,12 +88,12 @@ const formatted = computed(() =>
Out
</span>
</div>
<span class="font-mono text-sm tabular-nums text-(--fg-muted)">{{ product.priceLabel }}</span>
<span class="font-mono text-sm tabular-nums text-fg-muted">{{ product.priceLabel }}</span>
</li>
</ul>
<div
v-else
class="rounded-lg border border-dashed border-(--border) bg-(--bg-inset) px-3 py-6 text-center text-sm text-(--fg-subtle)"
class="rounded-lg border border-dashed border-border bg-bg-inset px-3 py-6 text-center text-sm text-fg-subtle"
>
No products match your filters.
</div>
@@ -35,13 +35,13 @@ const matchIndex = computed(() =>
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="demo-stack max-w-sm">
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="maxPrice">
<label class="demo-label" for="maxPrice">
Max price
</label>
<span class="font-mono text-sm tabular-nums text-(--fg)">${{ maxPrice }}</span>
<span class="font-mono text-sm tabular-nums text-fg">${{ maxPrice }}</span>
</div>
<input
id="maxPrice"
@@ -50,28 +50,28 @@ const matchIndex = computed(() =>
min="20"
max="400"
step="5"
class="w-full accent-(--accent)"
class="w-full accent-accent"
>
<label class="flex cursor-pointer items-center gap-2 text-sm text-(--fg-muted)">
<input v-model="inStockOnly" type="checkbox" class="accent-(--accent)">
<label class="flex cursor-pointer items-center gap-2 text-sm text-fg-muted">
<input v-model="inStockOnly" type="checkbox" class="accent-accent">
In stock only
</label>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<p class="mb-1 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="rounded-lg border border-border bg-bg-inset p-3">
<p class="demo-label mb-1">
First match
</p>
<template v-if="firstMatch">
<div class="flex items-baseline justify-between">
<span class="text-sm font-medium text-(--fg)">{{ firstMatch.name }}</span>
<span class="font-mono text-sm tabular-nums text-(--fg)">${{ firstMatch.price }}</span>
<span class="text-sm font-medium text-fg">{{ firstMatch.name }}</span>
<span class="font-mono text-sm tabular-nums text-fg">${{ firstMatch.price }}</span>
</div>
<p class="mt-1 font-mono text-xs text-(--fg-subtle)">
<p class="mt-1 font-mono text-xs text-fg-subtle">
index {{ matchIndex }} · id {{ firstMatch.id }}
</p>
</template>
<p v-else class="text-sm text-(--fg-subtle)">
<p v-else class="text-sm text-fg-subtle">
No product matches the filters
</p>
</div>
@@ -82,8 +82,8 @@ const matchIndex = computed(() =>
:key="product.id"
class="flex items-center justify-between rounded-lg border px-3 py-2 text-sm transition"
:class="product.id === firstMatch?.id
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg-muted)'"
? 'border-accent bg-accent-subtle text-accent-text'
: 'border-border bg-bg-elevated text-fg-muted'"
>
<span class="flex items-center gap-2">
{{ product.name }}
@@ -24,15 +24,15 @@ function toggle(index: number) {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="demo-stack max-w-sm">
<div class="rounded-lg border border-border bg-bg-inset p-3">
<p class="demo-label">
Next pending index
</p>
<p class="mt-1 font-mono text-3xl font-bold tabular-nums text-(--fg)">
<p class="demo-stat mt-1 text-3xl">
{{ nextIndex }}
</p>
<p class="mt-1 text-sm text-(--fg-subtle)">
<p class="mt-1 text-sm text-fg-subtle">
{{ nextIndex === -1 ? 'All steps complete' : `${steps[nextIndex]!.label}` }}
</p>
</div>
@@ -43,16 +43,16 @@ function toggle(index: number) {
:key="step.label"
class="flex items-center justify-between rounded-lg border px-3 py-2 text-sm transition"
:class="index === nextIndex
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg-muted)'"
? 'border-accent bg-accent-subtle text-accent-text'
: 'border-border bg-bg-elevated text-fg-muted'"
>
<span class="flex items-center gap-2">
<span class="font-mono text-xs tabular-nums text-(--fg-subtle)">{{ index }}</span>
<span class="font-mono text-xs tabular-nums text-fg-subtle">{{ index }}</span>
<span :class="step.done ? 'line-through opacity-60' : ''">{{ step.label }}</span>
</span>
<button
type="button"
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="toggle(index)"
>
{{ step.done ? 'Undo' : 'Done' }}
@@ -42,7 +42,7 @@ const tone: Record<LogEntry['level'], string> = {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="demo-stack max-w-sm">
<div class="flex gap-1.5">
<button
v-for="level in levels"
@@ -50,38 +50,38 @@ const tone: Record<LogEntry['level'], string> = {
type="button"
class="flex-1 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="filter === level
? 'border-transparent bg-(--accent) text-(--accent-fg)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset)'"
? 'border-transparent bg-accent text-accent-fg'
: 'border-border bg-bg-elevated text-fg hover:bg-bg-inset'"
@click="filter = level"
>
{{ level }}
</button>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="rounded-lg border border-border bg-bg-inset p-3">
<p class="demo-label">
Latest {{ filter }} entry
</p>
<template v-if="latest">
<p class="mt-1 font-mono text-sm text-(--fg)">{{ latest.message }}</p>
<p class="mt-1 font-mono text-xs text-(--fg-subtle)">#{{ latest.id }}</p>
<p class="mt-1 font-mono text-sm text-fg">{{ latest.message }}</p>
<p class="mt-1 font-mono text-xs text-fg-subtle">#{{ latest.id }}</p>
</template>
<p v-else class="mt-1 text-sm text-(--fg-subtle)">
<p v-else class="mt-1 text-sm text-fg-subtle">
No {{ filter }} entries yet
</p>
</div>
<ul class="flex max-h-44 flex-col gap-1 overflow-y-auto rounded-lg border border-(--border) bg-(--bg-elevated) p-2">
<ul class="flex max-h-44 flex-col gap-1 overflow-y-auto rounded-lg border border-border bg-bg-elevated p-2">
<li
v-for="entry in log"
:key="entry.id"
class="flex items-center gap-2 rounded-md px-2 py-1 font-mono text-xs transition"
:class="entry.id === latest?.id ? 'bg-(--accent-subtle)' : ''"
:class="entry.id === latest?.id ? 'bg-accent-subtle' : ''"
>
<span class="w-10 shrink-0 font-semibold uppercase" :class="tone[entry.level]">
{{ entry.level }}
</span>
<span class="truncate text-(--fg-muted)">{{ entry.message }}</span>
<span class="truncate text-fg-muted">{{ entry.message }}</span>
</li>
</ul>
@@ -90,7 +90,7 @@ const tone: Record<LogEntry['level'], string> = {
v-for="level in levels"
:key="level"
type="button"
class="flex-1 inline-flex 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] cursor-pointer"
class="demo-btn flex-1"
@click="append(level)"
>
+ {{ level }}
@@ -26,9 +26,9 @@ const hasTag = useArrayIncludes(tags, query, { fromIndex: fromIndex.value });
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="demo-stack max-w-sm">
<div class="demo-card p-4">
<p class="demo-label mb-2">
Member by key
</p>
<div class="flex flex-wrap gap-1.5">
@@ -37,11 +37,11 @@ const hasTag = useArrayIncludes(tags, query, { fromIndex: fromIndex.value });
:key="user.id"
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition"
:class="user.id === searchId
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
? 'border-accent bg-accent-subtle text-accent-text'
: 'border-border bg-bg-inset text-fg-muted'"
>
{{ user.name }}
<span class="font-mono text-(--fg-subtle)">#{{ user.id }}</span>
<span class="font-mono text-fg-subtle">#{{ user.id }}</span>
</span>
</div>
@@ -49,21 +49,21 @@ const hasTag = useArrayIncludes(tags, query, { fromIndex: fromIndex.value });
<input
v-model.number="searchId"
type="number"
class="w-24 rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="w-24 rounded-lg border border-border bg-bg px-3 py-2 text-sm text-fg transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-ring"
>
<span
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium"
:class="isMember
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'bg-(--bg-inset) text-(--fg-subtle)'"
: 'bg-bg-inset text-fg-subtle'"
>
{{ isMember ? 'includes id' : 'not found' }}
</span>
</div>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="demo-card p-4">
<p class="demo-label mb-2">
Primitive search (fromIndex 2)
</p>
<div class="flex flex-wrap gap-1.5">
@@ -72,8 +72,8 @@ const hasTag = useArrayIncludes(tags, query, { fromIndex: fromIndex.value });
:key="i"
class="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="i < 2
? 'border-(--border) bg-(--bg-inset) text-(--fg-subtle) opacity-50 line-through'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
? 'border-border bg-bg-inset text-fg-subtle opacity-50 line-through'
: 'border-border bg-bg-inset text-fg-muted'"
>
{{ tag }}
</span>
@@ -83,13 +83,13 @@ const hasTag = useArrayIncludes(tags, query, { fromIndex: fromIndex.value });
v-model="query"
type="text"
placeholder="search a tag…"
class="mt-3 w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="demo-input mt-3"
>
<p class="mt-2 text-sm text-(--fg-muted)">
<p class="mt-2 text-sm text-fg-muted">
Searching from index 2
<span
class="font-mono font-semibold"
:class="hasTag ? 'text-emerald-600 dark:text-emerald-400' : 'text-(--fg-subtle)'"
:class="hasTag ? 'text-emerald-600 dark:text-emerald-400' : 'text-fg-subtle'"
>{{ hasTag }}</span>
</p>
</div>
@@ -1,6 +1,6 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isObject, isString } from '@robonen/stdlib';
import { isFunction, isNumber, isObject, isString, isSymbol } from '@robonen/stdlib';
/**
* Comparator deciding whether an array element equals the searched value.
@@ -83,11 +83,11 @@ export function useArrayIncludes<T, V = T>(
// Resolve the comparator once instead of on every recompute.
let compare: UseArrayIncludesComparatorFn<T, V>;
if (isString(resolved) || typeof resolved === 'symbol' || typeof resolved === 'number') {
if (isString(resolved) || isSymbol(resolved) || isNumber(resolved)) {
const key = resolved as keyof T;
compare = (element, searched) => element[key] === (searched as unknown);
}
else if (typeof resolved === 'function') {
else if (isFunction(resolved)) {
compare = resolved;
}
else {
@@ -30,19 +30,19 @@ function remove(index: number) {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<p class="mb-1 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="demo-stack max-w-sm">
<div class="rounded-lg border border-border bg-bg-inset p-3">
<p class="demo-label mb-1">
Joined result
</p>
<p class="break-all font-mono text-sm text-(--fg) tabular-nums">
<p class="break-all font-mono text-sm text-fg tabular-nums">
<span v-if="joined">{{ joined }}</span>
<span v-else class="text-(--fg-subtle)">empty</span>
<span v-else class="text-fg-subtle">empty</span>
</p>
</div>
<div class="flex flex-col gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Separator</span>
<span class="demo-label">Separator</span>
<div class="flex gap-1.5">
<button
v-for="sep in separators"
@@ -50,8 +50,8 @@ function remove(index: number) {
type="button"
class="flex-1 rounded-lg border px-2 py-1.5 text-xs font-medium transition active:scale-[0.98] cursor-pointer"
:class="separator === sep.value
? 'border-transparent bg-(--accent) text-(--accent-fg)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset)'"
? 'border-transparent bg-accent text-accent-fg'
: 'border-border bg-bg-elevated text-fg hover:bg-bg-inset'"
@click="separator = sep.value"
>
{{ sep.label }}
@@ -63,16 +63,16 @@ function remove(index: number) {
<li
v-for="(segment, index) in segments"
:key="index"
class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm text-(--fg)"
class="flex items-center justify-between rounded-lg border border-border bg-bg-elevated px-3 py-1.5 text-sm text-fg"
>
<span class="flex items-center gap-2">
<span class="font-mono text-xs text-(--fg-subtle)">{{ index }}</span>
<span class="font-mono text-xs text-fg-subtle">{{ index }}</span>
{{ segment }}
</span>
<button
type="button"
aria-label="Remove segment"
class="rounded-md px-2 py-0.5 text-xs font-medium text-(--fg-subtle) transition hover:bg-(--bg-inset) hover:text-(--fg) cursor-pointer"
class="rounded-md px-2 py-0.5 text-xs font-medium text-fg-subtle transition hover:bg-bg-inset hover:text-fg cursor-pointer"
@click="remove(index)"
>
@@ -85,11 +85,11 @@ function remove(index: number) {
v-model="draft"
type="text"
placeholder="add a segment…"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="demo-input"
>
<button
type="submit"
class="inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
class="demo-btn-primary disabled:cursor-not-allowed disabled:opacity-40"
:disabled="!draft.trim()"
>
Add
@@ -1,5 +1,6 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isFunction } from '@robonen/stdlib';
export type UseArrayJoinReturn = ComputedRef<string>;
@@ -30,7 +31,7 @@ export function useArrayJoin(
// reactive items first lets the computed track per-item ref dependencies.
let needsUnwrap = false;
for (const item of resolved) {
if (typeof item === 'function' || (typeof item === 'object' && item !== null && 'value' in item)) {
if (isFunction(item) || (typeof item === 'object' && item !== null && 'value' in item)) {
needsUnwrap = true;
break;
}
@@ -34,17 +34,17 @@ function bump(index: number, delta: number) {
</script>
<template>
<div class="w-full max-w-md flex flex-col gap-4">
<div class="demo-stack max-w-md">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Cart</span>
<label class="flex items-center gap-2 text-sm text-(--fg-muted)">
<span class="demo-label">Cart</span>
<label class="flex items-center gap-2 text-sm text-fg-muted">
Tax {{ taxRate }}%
<input
v-model.number="taxRate"
type="range"
min="0"
max="25"
class="accent-(--accent)"
class="accent-accent"
>
</label>
</div>
@@ -53,41 +53,41 @@ function bump(index: number, delta: number) {
<li
v-for="(item, index) in priced"
:key="item.name"
class="flex items-center justify-between gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2"
class="flex items-center justify-between gap-3 rounded-lg border border-border bg-bg-elevated px-3 py-2"
>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-(--fg)">
<p class="truncate text-sm font-medium text-fg">
{{ item.name }}
</p>
<p class="text-xs text-(--fg-subtle)">
<p class="text-xs text-fg-subtle">
base {{ formatter.format(item.price) }}
</p>
</div>
<div class="flex items-center gap-1.5">
<button
class="inline-flex size-7 items-center justify-center rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg) transition hover:bg-(--bg-inset) active:scale-[0.98] cursor-pointer"
class="inline-flex size-7 items-center justify-center rounded-md border border-border bg-bg-elevated text-fg transition hover:bg-bg-inset active:scale-[0.98] cursor-pointer"
aria-label="Decrease price"
@click="bump(index, -10)"
>
&minus;
</button>
<button
class="inline-flex size-7 items-center justify-center rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg) transition hover:bg-(--bg-inset) active:scale-[0.98] cursor-pointer"
class="inline-flex size-7 items-center justify-center rounded-md border border-border bg-bg-elevated text-fg transition hover:bg-bg-inset active:scale-[0.98] cursor-pointer"
aria-label="Increase price"
@click="bump(index, 10)"
>
+
</button>
</div>
<span class="w-20 text-right font-mono text-sm tabular-nums text-(--fg)">
<span class="w-20 text-right font-mono text-sm tabular-nums text-fg">
{{ formatter.format(item.gross) }}
</span>
</li>
</ul>
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Total with tax</span>
<span class="font-mono text-xl font-bold tabular-nums text-(--fg)">
<div class="flex items-center justify-between rounded-lg border border-border bg-bg-inset p-3">
<span class="demo-label">Total with tax</span>
<span class="demo-stat text-xl">
{{ formatter.format(total) }}
</span>
</div>
@@ -36,14 +36,14 @@ function removeAt(index: number) {
</script>
<template>
<div class="w-full max-w-md flex flex-col gap-4">
<label class="flex items-center justify-between gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Starting budget</span>
<div class="demo-stack max-w-md">
<label class="demo-card flex items-center justify-between gap-3 p-4">
<span class="demo-label">Starting budget</span>
<input
v-model.number="startingBudget"
type="number"
step="50"
class="w-28 rounded-lg border border-(--border) bg-(--bg) px-3 py-1.5 text-right font-mono text-sm tabular-nums text-(--fg) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="w-28 rounded-lg border border-border bg-bg px-3 py-1.5 text-right font-mono text-sm tabular-nums text-fg transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-ring"
>
</label>
@@ -51,34 +51,34 @@ function removeAt(index: number) {
<li
v-for="(expense, index) in expenses"
:key="index"
class="flex items-center justify-between gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2"
class="flex items-center justify-between gap-3 rounded-lg border border-border bg-bg-elevated px-3 py-2"
>
<span class="flex-1 truncate text-sm text-(--fg)">{{ expense.label }}</span>
<span class="flex-1 truncate text-sm text-fg">{{ expense.label }}</span>
<span class="font-mono text-sm tabular-nums text-rose-600 dark:text-rose-400">
&minus;{{ formatter.format(expense.amount) }}
</span>
<button
class="inline-flex size-6 items-center justify-center rounded-md text-(--fg-subtle) transition hover:bg-(--bg-inset) hover:text-(--fg) active:scale-[0.98] cursor-pointer"
class="inline-flex size-6 items-center justify-center rounded-md text-fg-subtle transition hover:bg-bg-inset hover:text-fg active:scale-[0.98] cursor-pointer"
aria-label="Remove expense"
@click="removeAt(index)"
>
&times;
</button>
</li>
<li v-if="expenses.length === 0" class="rounded-lg border border-dashed border-(--border) px-3 py-4 text-center text-sm text-(--fg-subtle)">
<li v-if="expenses.length === 0" class="rounded-lg border border-dashed border-border px-3 py-4 text-center text-sm text-fg-subtle">
No expenses full budget remains.
</li>
</ul>
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="add"
>
+ Add charge
</button>
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Remaining</span>
<div class="flex items-center justify-between rounded-lg border border-border bg-bg-inset p-3">
<span class="demo-label">Remaining</span>
<span
class="font-mono text-2xl font-bold tabular-nums"
:class="remaining < 0 ? 'text-rose-600 dark:text-rose-400' : 'text-emerald-600 dark:text-emerald-400'"
@@ -27,7 +27,7 @@ function load(index: number, delta: number) {
</script>
<template>
<div class="w-full max-w-md flex flex-col gap-4">
<div class="demo-stack max-w-md">
<div
class="flex items-center gap-3 rounded-xl border p-4 transition"
:class="hasOverloaded
@@ -43,7 +43,7 @@ function load(index: number, delta: number) {
</p>
</div>
<label class="flex items-center justify-between gap-3 text-sm text-(--fg-muted)">
<label class="flex items-center justify-between gap-3 text-sm text-fg-muted">
<span>Alert threshold</span>
<span class="flex items-center gap-2">
<input
@@ -51,9 +51,9 @@ function load(index: number, delta: number) {
type="range"
min="40"
max="100"
class="accent-(--accent)"
class="accent-accent"
>
<span class="w-10 text-right font-mono tabular-nums text-(--fg)">{{ threshold }}%</span>
<span class="w-10 text-right font-mono tabular-nums text-fg">{{ threshold }}%</span>
</span>
</label>
@@ -61,31 +61,31 @@ function load(index: number, delta: number) {
<li
v-for="(server, index) in servers"
:key="server.name"
class="rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2.5"
class="rounded-lg border border-border bg-bg-elevated px-3 py-2.5"
>
<div class="mb-1.5 flex items-center justify-between">
<span class="font-mono text-sm text-(--fg)">{{ server.name }}</span>
<span class="font-mono text-sm text-fg">{{ server.name }}</span>
<span class="flex items-center gap-2">
<span
class="font-mono text-sm tabular-nums"
:class="server.cpu > threshold ? 'text-amber-600 dark:text-amber-400' : 'text-(--fg-muted)'"
:class="server.cpu > threshold ? 'text-amber-600 dark:text-amber-400' : 'text-fg-muted'"
>{{ server.cpu }}%</span>
<button
class="inline-flex size-6 items-center justify-center rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg) transition hover:bg-(--bg-inset) active:scale-[0.98] cursor-pointer"
class="inline-flex size-6 items-center justify-center rounded-md border border-border bg-bg-elevated text-fg transition hover:bg-bg-inset active:scale-[0.98] cursor-pointer"
aria-label="Decrease load"
@click="load(index, -10)"
>&minus;</button>
<button
class="inline-flex size-6 items-center justify-center rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg) transition hover:bg-(--bg-inset) active:scale-[0.98] cursor-pointer"
class="inline-flex size-6 items-center justify-center rounded-md border border-border bg-bg-elevated text-fg transition hover:bg-bg-inset active:scale-[0.98] cursor-pointer"
aria-label="Increase load"
@click="load(index, 10)"
>+</button>
</span>
</div>
<div class="h-1.5 w-full overflow-hidden rounded-full bg-(--bg-inset)">
<div class="h-1.5 w-full overflow-hidden rounded-full bg-bg-inset">
<div
class="h-full rounded-full transition-all"
:class="server.cpu > threshold ? 'bg-amber-500' : 'bg-(--accent)'"
:class="server.cpu > threshold ? 'bg-amber-500' : 'bg-accent'"
:style="{ width: `${server.cpu}%` }"
/>
</div>
@@ -35,40 +35,40 @@ function addTag() {
</script>
<template>
<div class="w-full max-w-md flex flex-col gap-4">
<div class="demo-stack max-w-md">
<form class="flex gap-2" @submit.prevent="addTag">
<input
v-model="draft"
type="text"
placeholder="Add a tag, e.g. TypeScript"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="demo-input"
>
<button
type="submit"
class="inline-flex 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] cursor-pointer"
class="demo-btn-primary"
>
Add
</button>
</form>
<label class="flex items-center justify-between gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2.5 text-sm text-(--fg)">
<label class="flex items-center justify-between gap-3 rounded-lg border border-border bg-bg-elevated px-3 py-2.5 text-sm text-fg">
<span>Case-insensitive comparator</span>
<input
v-model="caseInsensitive"
type="checkbox"
class="size-4 accent-(--accent) cursor-pointer"
class="size-4 accent-accent cursor-pointer"
>
</label>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<span class="demo-label">
Source ({{ raw.length }})
</span>
<div class="flex flex-wrap gap-1.5 rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<div class="flex flex-wrap gap-1.5 rounded-lg border border-border bg-bg-inset p-3">
<span
v-for="(tag, index) in raw"
:key="`${tag}-${index}`"
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-elevated) px-2 py-0.5 text-xs font-medium text-(--fg-muted)"
class="inline-flex items-center rounded-md border border-border bg-bg-elevated px-2 py-0.5 text-xs font-medium text-fg-muted"
>
{{ tag }}
</span>
@@ -76,18 +76,18 @@ function addTag() {
</div>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<span class="demo-label">
Unique ({{ unique.length }})
</span>
<div class="flex flex-wrap gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) p-3">
<div class="flex flex-wrap gap-1.5 rounded-lg border border-border bg-bg-elevated p-3">
<span
v-for="tag in unique"
:key="tag"
class="inline-flex items-center gap-1.5 rounded-md border border-(--accent) bg-(--accent-subtle) px-2 py-0.5 text-xs font-medium text-(--accent-text)"
class="inline-flex items-center gap-1.5 rounded-md border border-accent bg-accent-subtle px-2 py-0.5 text-xs font-medium text-accent-text"
>
{{ tag }}
</span>
<span v-if="unique.length === 0" class="text-xs text-(--fg-subtle)">No tags yet.</span>
<span v-if="unique.length === 0" class="text-xs text-fg-subtle">No tags yet.</span>
</div>
</div>
</div>
@@ -1,6 +1,6 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isString, unique } from '@robonen/stdlib';
import { isFunction, isNumber, isString, isSymbol, unique } from '@robonen/stdlib';
/**
* Equality comparator deciding whether two array elements are duplicates.
@@ -66,12 +66,12 @@ export function useArrayUnique<T>(
// Resolve the comparison strategy once, not on every recompute.
// Key of T (string | number | symbol) -> O(n) first-seen-wins key de-dup.
if (isString(comparator) || typeof comparator === 'symbol' || typeof comparator === 'number') {
if (isString(comparator) || isSymbol(comparator) || isNumber(comparator)) {
const key = comparator as keyof T;
return computed<T[]>(() => uniqueByKey(resolve(list), element => element[key] as PropertyKey));
}
if (typeof comparator === 'function') {
if (isFunction(comparator)) {
// A unary key extractor stays O(n); a binary comparator falls back to O(n²)
// pairwise comparison (unavoidable for arbitrary equality). Branch on arity.
if (comparator.length <= 1) {
@@ -38,23 +38,23 @@ const keys: { id: SortKey; label: string }[] = [
</script>
<template>
<div class="w-full max-w-md flex flex-col gap-4">
<div class="demo-stack max-w-md">
<div class="flex items-center justify-between gap-2">
<div class="inline-flex rounded-lg border border-(--border) bg-(--bg-elevated) p-0.5">
<div class="inline-flex rounded-lg border border-border bg-bg-elevated p-0.5">
<button
v-for="key in keys"
:key="key.id"
class="rounded-md px-3 py-1 text-sm font-medium transition cursor-pointer"
:class="sortKey === key.id
? 'bg-(--accent) text-(--accent-fg)'
: 'text-(--fg-muted) hover:text-(--fg)'"
? 'bg-accent text-accent-fg'
: 'text-fg-muted hover:text-fg'"
@click="sortKey = key.id"
>
{{ key.label }}
</button>
</div>
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="descending = !descending"
>
{{ descending ? 'Desc ↓' : 'Asc ↑' }}
@@ -65,22 +65,22 @@ const keys: { id: SortKey; label: string }[] = [
<li
v-for="(player, index) in sorted"
:key="player.name"
class="flex items-center gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2.5"
class="flex items-center gap-3 rounded-lg border border-border bg-bg-elevated px-3 py-2.5"
>
<span class="w-6 text-center font-mono text-sm tabular-nums text-(--fg-subtle)">
<span class="w-6 text-center font-mono text-sm tabular-nums text-fg-subtle">
{{ index + 1 }}
</span>
<span class="flex-1 text-sm font-medium text-(--fg)">{{ player.name }}</span>
<span class="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)">
<span class="flex-1 text-sm font-medium text-fg">{{ player.name }}</span>
<span class="demo-badge">
Lv {{ player.level }}
</span>
<span class="w-16 text-right font-mono text-sm font-semibold tabular-nums text-(--fg)">
<span class="w-16 text-right font-mono text-sm font-semibold tabular-nums text-fg">
{{ player.score.toLocaleString() }}
</span>
</li>
</ol>
<p class="text-xs text-(--fg-subtle)">
<p class="text-xs text-fg-subtle">
Stable sort players with an equal {{ sortKey }} keep their original order. The source array is left untouched.
</p>
</div>
@@ -2,13 +2,13 @@ import { computed, isRef, toValue, watchEffect } from 'vue';
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
import { isFunction } from '@robonen/stdlib';
export type UseSortedCompareFn<T = any>
export type UseSortedCompareFn<T = unknown>
= (a: T, b: T) => number;
export type UseSortedFn<T = any>
export type UseSortedFn<T = unknown>
= (arr: T[], compareFn: UseSortedCompareFn<T>) => T[];
export interface UseSortedOptions<T = any> {
export interface UseSortedOptions<T = unknown> {
/**
* The sort algorithm to apply. Receives a copy of the array (or the source
* itself in `dirty` mode) and the resolved compare function.
@@ -101,13 +101,13 @@ const defaultSortFn: UseSortedFn = <T>(source: T[], compareFn: UseSortedCompareF
*
* @since 0.0.15
*/
export function useSorted<T = any>(source: Ref<T[]>, compareFn?: UseSortedCompareFn<T>): Ref<T[]>;
export function useSorted<T = any>(source: MaybeRefOrGetter<T[]>, compareFn?: UseSortedCompareFn<T>): ComputedRef<T[]>;
export function useSorted<T = any>(source: Ref<T[]>, options?: UseSortedOptions<T>): Ref<T[]>;
export function useSorted<T = any>(source: MaybeRefOrGetter<T[]>, options?: UseSortedOptions<T>): ComputedRef<T[]>;
export function useSorted<T = any>(source: Ref<T[]>, compareFn?: UseSortedCompareFn<T>, options?: Omit<UseSortedOptions<T>, 'compareFn'>): Ref<T[]>;
export function useSorted<T = any>(source: MaybeRefOrGetter<T[]>, compareFn?: UseSortedCompareFn<T>, options?: Omit<UseSortedOptions<T>, 'compareFn'>): ComputedRef<T[]>;
export function useSorted<T = any>(
export function useSorted<T = unknown>(source: Ref<T[]>, compareFn?: UseSortedCompareFn<T>): Ref<T[]>;
export function useSorted<T = unknown>(source: MaybeRefOrGetter<T[]>, compareFn?: UseSortedCompareFn<T>): ComputedRef<T[]>;
export function useSorted<T = unknown>(source: Ref<T[]>, options?: UseSortedOptions<T>): Ref<T[]>;
export function useSorted<T = unknown>(source: MaybeRefOrGetter<T[]>, options?: UseSortedOptions<T>): ComputedRef<T[]>;
export function useSorted<T = unknown>(source: Ref<T[]>, compareFn?: UseSortedCompareFn<T>, options?: Omit<UseSortedOptions<T>, 'compareFn'>): Ref<T[]>;
export function useSorted<T = unknown>(source: MaybeRefOrGetter<T[]>, compareFn?: UseSortedCompareFn<T>, options?: Omit<UseSortedOptions<T>, 'compareFn'>): ComputedRef<T[]>;
export function useSorted<T = unknown>(
source: MaybeRefOrGetter<T[]>,
compareFnOrOptions?: UseSortedCompareFn<T> | UseSortedOptions<T>,
maybeOptions?: Omit<UseSortedOptions<T>, 'compareFn'>,
@@ -27,9 +27,9 @@ function setQuantity(delta: number): void {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="demo-stack max-w-sm">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Shared cart</span>
<span class="demo-label">Shared cart</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="supported
@@ -41,15 +41,15 @@ function setQuantity(delta: number): void {
</span>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="demo-card p-4">
<div class="flex flex-wrap gap-2">
<button
v-for="product in products"
:key="product"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="cart.item === product
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
? 'border-transparent bg-accent text-accent-fg hover:bg-accent-hover'
: 'border-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="pick(product)"
>
{{ product }}
@@ -57,18 +57,18 @@ function setQuantity(delta: number): void {
</div>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-(--fg-muted)">Quantity</span>
<span class="text-sm text-fg-muted">Quantity</span>
<div class="flex items-center gap-2">
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--border) bg-(--bg-elevated) text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border bg-bg-elevated text-fg transition hover:bg-bg-inset hover:border-border-strong active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
:disabled="cart.quantity <= 1"
@click="setQuantity(-1)"
>
&minus;
</button>
<span class="w-8 text-center font-mono text-lg font-bold tabular-nums text-(--fg)">{{ cart.quantity }}</span>
<span class="demo-stat w-8 text-center text-lg">{{ cart.quantity }}</span>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--border) bg-(--bg-elevated) text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border bg-bg-elevated text-fg transition hover:bg-bg-inset hover:border-border-strong active:scale-[0.98] cursor-pointer"
@click="setQuantity(1)"
>
+
@@ -77,21 +77,21 @@ function setQuantity(delta: number): void {
</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
<div class="rounded-lg border border-border bg-bg-inset p-3 font-mono text-sm text-fg tabular-nums">
<div class="flex justify-between">
<span class="text-(--fg-muted)">{{ cart.item }} &times; {{ cart.quantity }}</span>
<span class="text-fg-muted">{{ cart.item }} &times; {{ cart.quantity }}</span>
<span class="font-bold">${{ subtotal }}</span>
</div>
</div>
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="theme = theme === 'light' ? 'dark' : 'light'"
>
Toggle shared theme: <span class="font-mono">{{ theme }}</span>
</button>
<p class="text-xs text-(--fg-subtle)">
<p class="text-xs text-fg-subtle">
Open this page in a second tab. Every change you make here is broadcast and mirrored instantly in the other tab.
</p>
</div>
@@ -21,42 +21,42 @@ const device = (): string => (isDesktop.value ? 'Desktop' : isMobile.value ? 'Mo
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Active breakpoint</span>
<div class="demo-stack max-w-sm">
<div class="demo-card p-4">
<span class="demo-label">Active breakpoint</span>
<div class="mt-1 flex items-baseline gap-2">
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ active || 'none' }}</span>
<span class="rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">{{ device() }}</span>
<span class="demo-stat text-3xl">{{ active || 'none' }}</span>
<span class="rounded-md border border-border bg-bg-inset px-2 py-0.5 text-xs font-medium text-fg-muted">{{ device() }}</span>
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Tailwind breakpoints</span>
<span class="demo-label">Tailwind breakpoints</span>
<div
v-for="row in rows"
:key="row.key"
class="flex items-center justify-between rounded-lg border px-3 py-2 text-sm transition"
:class="bp[row.key].value
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
? 'border-accent bg-accent-subtle text-accent-text'
: 'border-border bg-bg-inset text-fg-muted'"
>
<span class="font-mono font-medium">{{ row.key }}</span>
<span class="font-mono tabular-nums text-(--fg-subtle)">&ge; {{ row.width }}</span>
<span class="font-mono tabular-nums text-fg-subtle">&ge; {{ row.width }}</span>
<span
class="h-2 w-2 rounded-full transition"
:class="bp[row.key].value ? 'bg-(--accent)' : 'bg-(--border-strong)'"
:class="bp[row.key].value ? 'bg-accent' : 'bg-border-strong'"
/>
</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
<div class="rounded-lg border border-border bg-bg-inset p-3 font-mono text-sm text-fg tabular-nums">
<div class="flex justify-between">
<span class="text-(--fg-muted)">current()</span>
<span class="text-fg-muted">current()</span>
<span>[{{ current.length ? current.join(', ') : '—' }}]</span>
</div>
</div>
<p class="text-xs text-(--fg-subtle)">
<p class="text-xs text-fg-subtle">
Resize your browser window the matched breakpoints update live.
</p>
</div>
@@ -5,6 +5,7 @@ import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery';
import { pxValue } from '@robonen/platform/browsers';
/**
* A breakpoints map: name viewport width. Numbers are treated as pixels;
@@ -61,22 +62,6 @@ export type UseBreakpointsReturn<K extends string = string>
active: () => ComputedRef<K | ''>;
};
/**
* Parse a CSS length token (`"1024px"`, `"48em"`, `"30rem"`, `"50%"`) into a
* pixel number. `em`/`rem` use the conventional 16px root size.
*/
function pxValue(value: string): number {
const number = Number.parseFloat(value);
if (Number.isNaN(number))
return Number.NaN;
if (/(?:em|rem)\s*$/i.test(value))
return number * 16;
return number;
}
/**
* Add `delta` to the numeric portion of a CSS length, preserving its unit.
* Used to build the strict (`> / <`) variants from inclusive media queries via
@@ -14,9 +14,9 @@ const snippets = [
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="demo-stack max-w-sm">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Clipboard API</span>
<span class="demo-label">Clipboard API</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isSupported
@@ -32,11 +32,11 @@ const snippets = [
<input
v-model="draft"
type="text"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="demo-input"
placeholder="Type something to copy…"
>
<button
class="inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn-primary disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="copyPending || !draft"
@click="copy(draft)"
>
@@ -45,21 +45,21 @@ const snippets = [
</div>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Quick copy</span>
<span class="demo-label">Quick copy</span>
<button
v-for="snippet in snippets"
:key="snippet"
class="inline-flex items-center justify-between gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2 text-left text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.99] cursor-pointer"
class="inline-flex items-center justify-between gap-2 rounded-lg border border-border bg-bg-elevated px-3 py-2 text-left text-sm font-medium text-fg transition hover:bg-bg-inset hover:border-border-strong active:scale-[0.99] cursor-pointer"
@click="copy(snippet)"
>
<span class="truncate font-mono text-xs text-(--fg-muted)">{{ snippet }}</span>
<span class="shrink-0 text-xs text-(--fg-subtle)">Copy</span>
<span class="truncate font-mono text-xs text-fg-muted">{{ snippet }}</span>
<span class="shrink-0 text-xs text-fg-subtle">Copy</span>
</button>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Last copied</span>
<p class="mt-1 break-all font-mono text-sm text-(--fg)">{{ text || '—' }}</p>
<div class="rounded-lg border border-border bg-bg-inset p-3">
<span class="demo-label">Last copied</span>
<p class="mt-1 break-all font-mono text-sm text-fg">{{ text || '—' }}</p>
</div>
</template>
@@ -34,9 +34,9 @@ function typesOf(item: ClipboardItem): string {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="demo-stack max-w-sm">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">ClipboardItem API</span>
<span class="demo-label">ClipboardItem API</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isSupported
@@ -48,42 +48,42 @@ function typesOf(item: ClipboardItem): string {
</div>
<template v-if="isSupported">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Rich payload</span>
<p class="mt-1 text-sm text-(--fg)" v-html="html" />
<p class="mt-1 font-mono text-xs text-(--fg-subtle)">text/plain &middot; text/html</p>
<div class="demo-card p-4">
<span class="demo-label">Rich payload</span>
<p class="mt-1 text-sm text-fg" v-html="html" />
<p class="mt-1 font-mono text-xs text-fg-subtle">text/plain &middot; text/html</p>
</div>
<div class="flex gap-2">
<button
class="inline-flex flex-1 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn-primary flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="copyPending"
@click="copyRich"
>
{{ copyPending ? 'Copying…' : copied ? 'Copied!' : 'Copy rich content' }}
</button>
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="readClipboard"
>
Read clipboard
</button>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
<div class="rounded-lg border border-border bg-bg-inset p-3">
<span class="demo-label">
content ({{ content.length }} {{ content.length === 1 ? 'item' : 'items' }})
</span>
<ul v-if="content.length" class="mt-2 flex flex-col gap-1">
<li
v-for="(item, i) in content"
:key="i"
class="font-mono text-xs text-(--fg)"
class="font-mono text-xs text-fg"
>
#{{ i + 1 }}: {{ typesOf(item) }}
</li>
</ul>
<p v-else class="mt-2 font-mono text-xs text-(--fg-subtle)">No items read yet</p>
<p v-else class="mt-2 font-mono text-xs text-fg-subtle">No items read yet</p>
</div>
<div
@@ -20,9 +20,9 @@ onMounted(() => {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="demo-stack max-w-sm">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">CloseWatcher API</span>
<span class="demo-label">CloseWatcher API</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isSupported
@@ -34,7 +34,7 @@ onMounted(() => {
</div>
<button
class="inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
class="demo-btn-primary disabled:cursor-not-allowed disabled:opacity-40"
:disabled="open"
@click="open = true"
>
@@ -47,19 +47,19 @@ onMounted(() => {
leave-active-class="transition duration-100 ease-in"
leave-to-class="opacity-0 translate-y-1"
>
<div v-if="open" class="rounded-xl border border-(--border-strong) bg-(--bg-elevated) p-4 shadow-lg">
<div v-if="open" class="rounded-xl border border-border-strong bg-bg-elevated p-4 shadow-lg">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-(--fg)">Unsaved changes</p>
<p class="mt-1 text-sm text-(--fg-muted)">
Press <kbd class="rounded border border-(--border) bg-(--bg-inset) px-1.5 py-0.5 font-mono text-xs text-(--fg)">Esc</kbd>
<p class="text-sm font-semibold text-fg">Unsaved changes</p>
<p class="mt-1 text-sm text-fg-muted">
Press <kbd class="rounded border border-border bg-bg-inset px-1.5 py-0.5 font-mono text-xs text-fg">Esc</kbd>
(or the Android back gesture) to dismiss.
</p>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
class="inline-flex 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] cursor-pointer"
class="demo-btn"
@click="close()"
>
Dismiss via close()
@@ -68,18 +68,18 @@ onMounted(() => {
</div>
</Transition>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
<div class="rounded-lg border border-border bg-bg-inset p-3 font-mono text-sm text-fg tabular-nums">
<div class="flex justify-between">
<span class="text-(--fg-muted)">closes</span>
<span class="text-fg-muted">closes</span>
<span class="font-bold">{{ closeCount }}</span>
</div>
<div class="mt-1 flex justify-between">
<span class="text-(--fg-muted)">last</span>
<span class="text-fg-muted">last</span>
<span>{{ lastClosedAt ?? '—' }}</span>
</div>
</div>
<p class="text-xs text-(--fg-subtle)">
<p class="text-xs text-fg-subtle">
Open the dialog, then dismiss it with Esc, the system back gesture, or the programmatic <code class="font-mono">close()</code> call.
</p>
</div>
@@ -30,11 +30,11 @@ const options = [
<template>
<div
ref="target"
class="flex w-full max-w-sm flex-col gap-4"
class="demo-stack max-w-sm"
>
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Color mode</span>
<span class="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)">
<span class="demo-label">Color mode</span>
<span class="demo-badge">
system: {{ mode.system.value }}
</span>
</div>
@@ -46,8 +46,8 @@ const options = [
type="button"
class="inline-flex flex-col items-center justify-center gap-1 rounded-lg border px-2 py-3 text-xs font-medium transition active:scale-[0.98] cursor-pointer"
:class="mode === opt.value
? 'border-transparent bg-(--accent) text-(--accent-fg)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
? 'border-transparent bg-accent text-accent-fg'
: 'border-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="mode = opt.value"
>
<span class="text-base leading-none">{{ opt.icon }}</span>
@@ -55,26 +55,26 @@ const options = [
</button>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Reactive state</p>
<div class="demo-card p-4">
<p class="demo-label mb-3">Reactive state</p>
<dl class="space-y-2 text-sm">
<div class="flex items-center justify-between">
<dt class="text-(--fg-muted)">selected (emitAuto)</dt>
<dd class="font-mono tabular-nums text-(--fg)">{{ mode }}</dd>
<dt class="text-fg-muted">selected (emitAuto)</dt>
<dd class="font-mono tabular-nums text-fg">{{ mode }}</dd>
</div>
<div class="flex items-center justify-between">
<dt class="text-(--fg-muted)">resolved state</dt>
<dd class="font-mono tabular-nums text-(--fg)">{{ mode.state.value }}</dd>
<dt class="text-fg-muted">resolved state</dt>
<dd class="font-mono tabular-nums text-fg">{{ mode.state.value }}</dd>
</div>
<div class="flex items-center justify-between">
<dt class="text-(--fg-muted)">store</dt>
<dd class="font-mono tabular-nums text-(--fg)">{{ mode.store.value }}</dd>
<dt class="text-fg-muted">store</dt>
<dd class="font-mono tabular-nums text-fg">{{ mode.store.value }}</dd>
</div>
</dl>
</div>
<p class="text-xs text-(--fg-subtle)">
The chosen mode is applied as <code class="font-mono text-(--fg-muted)">data-demo-theme</code> on this card.
<p class="text-xs text-fg-subtle">
The chosen mode is applied as <code class="font-mono text-fg-muted">data-demo-theme</code> on this card.
Pick "Auto" to follow your OS preference.
</p>
</div>
@@ -15,10 +15,10 @@ const accent = computed(() => `hsl(${hue.value} 80% 55%)`);
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="demo-stack max-w-sm">
<div
ref="target"
class="flex items-center justify-center rounded-xl border border-(--border) bg-(--bg-inset) p-6"
class="flex items-center justify-center rounded-xl border border-border bg-bg-inset p-6"
>
<div
class="shadow-lg transition-all duration-300 ease-out"
@@ -31,11 +31,11 @@ const accent = computed(() => `hsl(${hue.value} 80% 55%)`);
/>
</div>
<div class="flex flex-col gap-4 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="demo-card flex flex-col gap-4 p-4">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="hue">Hue</label>
<span class="font-mono text-sm tabular-nums text-(--fg)">--demo-hue: {{ hue }}</span>
<label class="demo-label" for="hue">Hue</label>
<span class="font-mono text-sm tabular-nums text-fg">--demo-hue: {{ hue }}</span>
</div>
<input
id="hue"
@@ -43,14 +43,14 @@ const accent = computed(() => `hsl(${hue.value} 80% 55%)`);
type="range"
min="0"
max="360"
class="w-full accent-(--accent) cursor-pointer"
class="w-full accent-accent cursor-pointer"
>
<div class="flex gap-1.5">
<button
v-for="s in swatches"
:key="s"
type="button"
class="h-6 w-6 rounded-md border border-(--border) transition hover:scale-110 active:scale-95 cursor-pointer"
class="h-6 w-6 rounded-md border border-border transition hover:scale-110 active:scale-95 cursor-pointer"
:style="{ background: `hsl(${s} 80% 55%)` }"
:aria-label="`Set hue ${s}`"
@click="hue = s"
@@ -60,8 +60,8 @@ const accent = computed(() => `hsl(${hue.value} 80% 55%)`);
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="radius">Radius</label>
<span class="font-mono text-sm tabular-nums text-(--fg)">--demo-radius: {{ radius }}</span>
<label class="demo-label" for="radius">Radius</label>
<span class="font-mono text-sm tabular-nums text-fg">--demo-radius: {{ radius }}</span>
</div>
<input
id="radius"
@@ -69,14 +69,14 @@ const accent = computed(() => `hsl(${hue.value} 80% 55%)`);
type="range"
min="0"
max="48"
class="w-full accent-(--accent) cursor-pointer"
class="w-full accent-accent cursor-pointer"
>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="size">Size</label>
<span class="font-mono text-sm tabular-nums text-(--fg)">--demo-size: {{ size }}</span>
<label class="demo-label" for="size">Size</label>
<span class="font-mono text-sm tabular-nums text-fg">--demo-size: {{ size }}</span>
</div>
<input
id="size"
@@ -84,12 +84,12 @@ const accent = computed(() => `hsl(${hue.value} 80% 55%)`);
type="range"
min="48"
max="140"
class="w-full accent-(--accent) cursor-pointer"
class="w-full accent-accent cursor-pointer"
>
</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
<div class="rounded-lg border border-border bg-bg-inset p-3 font-mono text-sm text-fg tabular-nums">
background: {{ accent }};
</div>
</div>
@@ -23,9 +23,9 @@ function toggle() {
<div
ref="target"
data-demo-mode
class="flex w-full max-w-sm flex-col gap-4"
class="demo-stack max-w-sm"
>
<div class="flex items-center justify-between rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="demo-card flex items-center justify-between p-4">
<div class="flex items-center gap-3">
<span
class="flex h-10 w-10 items-center justify-center rounded-lg text-lg transition-colors"
@@ -36,8 +36,8 @@ function toggle() {
{{ isDark ? '☾' : '☀' }}
</span>
<div>
<p class="text-sm font-medium text-(--fg)">{{ isDark ? 'Dark mode' : 'Light mode' }}</p>
<p class="text-xs text-(--fg-subtle)">isDark = {{ isDark }}</p>
<p class="text-sm font-medium text-fg">{{ isDark ? 'Dark mode' : 'Light mode' }}</p>
<p class="text-xs text-fg-subtle">isDark = {{ isDark }}</p>
</div>
</div>
@@ -45,30 +45,30 @@ function toggle() {
type="button"
role="switch"
:aria-checked="isDark"
class="relative inline-flex h-7 w-12 items-center rounded-full border border-(--border) transition focus:outline-none focus:ring-2 focus:ring-(--ring) cursor-pointer"
:class="isDark ? 'bg-(--accent)' : 'bg-(--bg-inset)'"
class="relative inline-flex h-7 w-12 items-center rounded-full border border-border transition focus:outline-none focus:ring-2 focus:ring-ring cursor-pointer"
:class="isDark ? 'bg-accent' : 'bg-bg-inset'"
@click="toggle"
>
<span
class="inline-block h-5 w-5 transform rounded-full bg-(--bg) shadow transition-transform"
class="inline-block h-5 w-5 transform rounded-full bg-bg shadow transition-transform"
:class="isDark ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Preview surface</p>
<div class="rounded-lg border border-border bg-bg-inset p-3">
<p class="demo-label mb-2">Preview surface</p>
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
<span class="inline-flex items-center gap-1.5 rounded-md border border-border bg-bg px-2 py-0.5 text-xs font-medium text-fg-muted">
data-demo-mode = "{{ isDark ? 'dark' : 'light' }}"
</span>
</div>
</div>
<p class="text-xs text-(--fg-subtle)">
<p class="text-xs text-fg-subtle">
Writing the boolean toggles the attribute on this card. When the requested state
matches your OS preference, <code class="font-mono text-(--fg-muted)">useDark</code>
falls back to <code class="font-mono text-(--fg-muted)">auto</code> to keep tracking it.
matches your OS preference, <code class="font-mono text-fg-muted">useDark</code>
falls back to <code class="font-mono text-fg-muted">auto</code> to keep tracking it.
</p>
</div>
</template>
@@ -51,7 +51,7 @@ watch(isOpen, (openNow) => {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="demo-stack max-w-sm">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400"
@@ -62,14 +62,14 @@ watch(isOpen, (openNow) => {
<template v-else>
<div
ref="host"
class="min-h-[7rem] rounded-xl border border-(--border) bg-(--bg-inset) p-1"
class="min-h-[7rem] rounded-xl border border-border bg-bg-inset p-1"
>
<div
ref="player"
class="flex h-full flex-col items-center justify-center gap-1 rounded-lg bg-(--bg-elevated) p-6"
class="flex h-full flex-col items-center justify-center gap-1 rounded-lg bg-bg-elevated p-6"
>
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Live timer</span>
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">
<span class="demo-label">Live timer</span>
<span class="demo-stat text-3xl">
{{ String(Math.floor(elapsed / 60)).padStart(2, '0') }}:{{ String(elapsed % 60).padStart(2, '0') }}
</span>
</div>
@@ -78,7 +78,7 @@ watch(isOpen, (openNow) => {
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex flex-1 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn-primary flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="isOpen"
@click="popOut"
>
@@ -86,7 +86,7 @@ watch(isOpen, (openNow) => {
</button>
<button
type="button"
class="inline-flex flex-1 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!isOpen"
@click="close"
>
@@ -94,17 +94,17 @@ watch(isOpen, (openNow) => {
</button>
</div>
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-sm">
<span class="text-(--fg-muted)">isOpen</span>
<div class="flex items-center justify-between rounded-lg border border-border bg-bg-inset p-3 text-sm">
<span class="text-fg-muted">isOpen</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isOpen
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg) text-(--fg-muted)'"
: 'border-border bg-bg text-fg-muted'"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="isOpen ? 'bg-emerald-500' : 'bg-(--fg-subtle)'"
:class="isOpen ? 'bg-emerald-500' : 'bg-fg-subtle'"
/>
{{ isOpen ? 'floating' : 'docked' }}
</span>
@@ -118,7 +118,7 @@ watch(isOpen, (openNow) => {
</p>
<p
v-else
class="text-xs text-(--fg-subtle)"
class="text-xs text-fg-subtle"
>
"Pop out" moves the live timer into an always-on-top window. Closing it returns the element to the page.
</p>
@@ -46,33 +46,33 @@ function toggleListening() {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="demo-stack max-w-sm">
<div class="flex flex-col gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">pointermove on element</span>
<span class="demo-label">pointermove on element</span>
<div
ref="pad"
class="relative h-32 overflow-hidden rounded-xl border border-(--border) bg-(--bg-inset) touch-none"
class="relative h-32 overflow-hidden rounded-xl border border-border bg-bg-inset touch-none"
>
<div
class="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-(--accent) transition-opacity"
class="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent transition-opacity"
:class="inside ? 'opacity-100' : 'opacity-0'"
:style="{ left: `${pos.x}px`, top: `${pos.y}px` }"
/>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span class="text-xs text-(--fg-subtle)">{{ inside ? '' : 'Hover here' }}</span>
<span class="text-xs text-fg-subtle">{{ inside ? '' : 'Hover here' }}</span>
</div>
<div class="pointer-events-none absolute bottom-2 left-2 rounded-md border border-(--border) bg-(--bg) px-2 py-0.5 font-mono text-xs tabular-nums text-(--fg-muted)">
<div class="pointer-events-none absolute bottom-2 left-2 rounded-md border border-border bg-bg px-2 py-0.5 font-mono text-xs tabular-nums text-fg-muted">
x: {{ pos.x }} · y: {{ pos.y }}
</div>
</div>
</div>
<div class="flex flex-col gap-2 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="demo-card flex flex-col gap-2 p-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">keydown on window</span>
<span class="demo-label">keydown on window</span>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-xs font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border bg-bg-elevated px-3 py-1.5 text-xs font-medium text-fg transition hover:bg-bg-inset hover:border-border-strong active:scale-[0.98] cursor-pointer"
@click="toggleListening"
>
{{ listening ? 'Stop listening' : 'Start listening' }}
@@ -81,30 +81,30 @@ function toggleListening() {
<div class="flex items-center gap-3">
<kbd
class="flex min-w-[3.5rem] items-center justify-center rounded-lg border border-(--border-strong) bg-(--bg-inset) px-3 py-2 font-mono text-sm font-medium text-(--fg)"
class="flex min-w-[3.5rem] items-center justify-center rounded-lg border border-border-strong bg-bg-inset px-3 py-2 font-mono text-sm font-medium text-fg"
>
{{ lastKey || '—' }}
</kbd>
<div class="flex flex-col">
<span class="text-xs text-(--fg-subtle)">presses captured</span>
<span class="font-mono text-lg font-bold tabular-nums text-(--fg)">{{ keyCount }}</span>
<span class="text-xs text-fg-subtle">presses captured</span>
<span class="demo-stat text-lg">{{ keyCount }}</span>
</div>
<span
class="ml-auto inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="listening
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
: 'border-border bg-bg-inset text-fg-muted'"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="listening ? 'bg-emerald-500 animate-pulse' : 'bg-(--fg-subtle)'"
:class="listening ? 'bg-emerald-500 animate-pulse' : 'bg-fg-subtle'"
/>
{{ listening ? 'active' : 'stopped' }}
</span>
</div>
</div>
<p class="text-xs text-(--fg-subtle)">
<p class="text-xs text-fg-subtle">
Listeners auto-detach on unmount. The returned stop function lets you detach early press any key, then toggle listening.
</p>
</div>
@@ -6,8 +6,8 @@ import type { MaybeRefOrGetter } from 'vue';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
interface InferEventTarget<Events> {
addEventListener: (event: Events, listener?: any, options?: any) => any;
removeEventListener: (event: Events, listener?: any, options?: any) => any;
addEventListener: (event: Events, listener?: GeneralEventListener, options?: boolean | AddEventListenerOptions) => void;
removeEventListener: (event: Events, listener?: GeneralEventListener, options?: boolean | EventListenerOptions) => void;
}
export type GeneralEventListener<E = Event> = (evt: E) => void;
@@ -27,7 +27,7 @@ type ListenerOptions = boolean | AddEventListenerOptions;
*/
export function useEventListener<E extends WindowEventName>(
event: Arrayable<E>,
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>,
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => void>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction;
@@ -41,7 +41,7 @@ export function useEventListener<E extends WindowEventName>(
export function useEventListener<E extends WindowEventName>(
target: Window,
event: Arrayable<E>,
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>,
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => void>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction;
@@ -55,7 +55,7 @@ export function useEventListener<E extends WindowEventName>(
export function useEventListener<E extends DocumentEventName>(
target: Document,
event: Arrayable<E>,
listener: Arrayable<(this: Document, ev: DocumentEventMap[E]) => any>,
listener: Arrayable<(this: Document, ev: DocumentEventMap[E]) => void>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction;
@@ -69,7 +69,7 @@ export function useEventListener<E extends DocumentEventName>(
export function useEventListener<E extends ElementEventName>(
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
event: Arrayable<E>,
listener: Arrayable<(this: HTMLElement, ev: HTMLElementEventMap[E]) => any>,
listener: Arrayable<(this: HTMLElement, ev: HTMLElementEventMap[E]) => void>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction;
@@ -101,6 +101,7 @@ export function useEventListener<EventType = Event>(
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction;
// Variadic implementation signature behind the typed overloads above; args are narrowed at runtime.
export function useEventListener(...args: any[]) {
let target: MaybeRefOrGetter<EventTarget> | undefined = defaultWindow;
let _events: Arrayable<string>;
@@ -34,7 +34,7 @@ async function pick() {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="demo-stack max-w-sm">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-center text-sm text-amber-600 dark:text-amber-400"
@@ -44,33 +44,33 @@ async function pick() {
<template v-else>
<div
class="flex h-32 items-center justify-center rounded-xl border border-(--border) transition-colors duration-300"
class="flex h-32 items-center justify-center rounded-xl border border-border transition-colors duration-300"
:style="{ backgroundColor: hex, color: readableText }"
>
<span class="font-mono text-2xl font-bold tabular-nums">{{ hex }}</span>
</div>
<button class="inline-flex 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] cursor-pointer" @click="pick">
<button class="demo-btn-primary" @click="pick">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m2 22 1-1h3l9-9" /><path d="M3 21v-3l9-9" /><path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L21 6l3 3-3 3-3-3-9 9" />
</svg>
Pick a color from screen
</button>
<p v-if="error" class="text-center text-xs text-(--fg-subtle)">
<p v-if="error" class="text-center text-xs text-fg-subtle">
{{ error }}
</p>
<div v-if="history.length" class="flex flex-col gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Recent</span>
<span class="demo-label">Recent</span>
<div class="flex flex-wrap gap-2">
<button
v-for="color in history"
:key="color"
class="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) transition hover:border-(--border-strong) cursor-pointer"
class="demo-badge transition hover:border-border-strong cursor-pointer"
@click="sRGBHex = color"
>
<span class="size-3 rounded-full border border-(--border)" :style="{ backgroundColor: color }" />
<span class="size-3 rounded-full border border-border" :style="{ backgroundColor: color }" />
{{ color }}
</button>
</div>
@@ -31,34 +31,34 @@ function select(preset: Preset) {
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="flex items-center gap-2 rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-2">
<div class="demo-stack max-w-sm">
<div class="demo-card p-4">
<div class="flex items-center gap-2 rounded-lg border border-border bg-bg-inset px-3 py-2">
<div class="flex gap-1.5">
<span class="size-2.5 rounded-full bg-red-500/70" />
<span class="size-2.5 rounded-full bg-amber-500/70" />
<span class="size-2.5 rounded-full bg-emerald-500/70" />
</div>
<div class="ml-2 flex flex-1 items-center gap-2 rounded-md bg-(--bg) px-2 py-1">
<div class="ml-2 flex flex-1 items-center gap-2 rounded-md bg-bg px-2 py-1">
<span class="text-base leading-none">{{ presets.find(p => p.label === active)?.emoji }}</span>
<span class="truncate text-xs text-(--fg-muted)">My Awesome App</span>
<span class="truncate text-xs text-fg-muted">My Awesome App</span>
</div>
</div>
<p class="mt-2 text-center text-xs text-(--fg-subtle)">
<p class="mt-2 text-center text-xs text-fg-subtle">
Look at the real browser tab its favicon updates live.
</p>
</div>
<div class="flex flex-col gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Choose a favicon</span>
<span class="demo-label">Choose a favicon</span>
<div class="grid grid-cols-2 gap-2">
<button
v-for="preset in presets"
:key="preset.label"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="active === preset.label
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
? 'border-transparent bg-accent text-accent-fg hover:bg-accent-hover'
: 'border-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="select(preset)"
>
<span class="text-base leading-none">{{ preset.emoji }}</span>
@@ -67,7 +67,7 @@ function select(preset: Preset) {
</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-xs text-(--fg) break-all">
<div class="rounded-lg border border-border bg-bg-inset p-3 font-mono text-xs text-fg break-all">
favicon.value = "{{ presets.find(p => p.label === active)?.emoji }} svg"
</div>
</div>
@@ -35,26 +35,26 @@ function pick() {
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<div class="demo-stack max-w-md">
<div class="flex items-center justify-between gap-3">
<label class="inline-flex cursor-pointer items-center gap-2 text-sm text-(--fg-muted)">
<input v-model="multiple" type="checkbox" class="size-4 rounded border-(--border) accent-(--accent)">
<label class="inline-flex cursor-pointer items-center gap-2 text-sm text-fg-muted">
<input v-model="multiple" type="checkbox" class="size-4 rounded border-border accent-accent">
Allow multiple
</label>
<span class="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)">
<span class="demo-badge">
{{ status }}
</span>
</div>
<div class="flex gap-2">
<button class="inline-flex flex-1 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] cursor-pointer" @click="pick">
<button class="demo-btn-primary flex-1" @click="pick">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
</svg>
Choose images
</button>
<button
class="inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!selected.length"
@click="reset"
>
@@ -64,25 +64,25 @@ function pick() {
<div
v-if="!selected.length"
class="flex flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-(--border) bg-(--bg-inset) p-6 text-center"
class="flex flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-border bg-bg-inset p-6 text-center"
>
<span class="text-sm text-(--fg-muted)">No files selected</span>
<span class="text-xs text-(--fg-subtle)">Click Choose images to open the native dialog</span>
<span class="text-sm text-fg-muted">No files selected</span>
<span class="text-xs text-fg-subtle">Click Choose images to open the native dialog</span>
</div>
<div v-else class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">{{ selected.length }} file(s)</span>
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ formatBytes(totalBytes) }} total</span>
<span class="demo-label">{{ selected.length }} file(s)</span>
<span class="font-mono text-xs tabular-nums text-fg-muted">{{ formatBytes(totalBytes) }} total</span>
</div>
<ul class="flex max-h-44 flex-col gap-1.5 overflow-auto">
<li
v-for="file in selected"
:key="file.name + file.lastModified"
class="flex items-center justify-between gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2"
class="flex items-center justify-between gap-3 rounded-lg border border-border bg-bg-elevated px-3 py-2"
>
<span class="truncate text-sm text-(--fg)">{{ file.name }}</span>
<span class="shrink-0 font-mono text-xs tabular-nums text-(--fg-subtle)">{{ formatBytes(file.size) }}</span>
<span class="truncate text-sm text-fg">{{ file.name }}</span>
<span class="shrink-0 font-mono text-xs tabular-nums text-fg-subtle">{{ formatBytes(file.size) }}</span>
</li>
</ul>
</div>
@@ -47,7 +47,7 @@ async function newFile() {
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<div class="demo-stack max-w-md">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-center text-sm text-amber-600 dark:text-amber-400"
@@ -57,28 +57,28 @@ async function newFile() {
<template v-else>
<div class="flex flex-wrap gap-2">
<button class="inline-flex 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] cursor-pointer" @click="open()">
<button class="demo-btn" @click="open()">
Open
</button>
<button class="inline-flex 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] cursor-pointer" @click="newFile">
<button class="demo-btn" @click="newFile">
New
</button>
<button class="inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100" :disabled="data === undefined" @click="save()">
<button class="demo-btn-primary disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100" :disabled="data === undefined" @click="save()">
Save
</button>
<button class="inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100" :disabled="data === undefined" @click="saveAs()">
<button class="demo-btn disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100" :disabled="data === undefined" @click="saveAs()">
Save As
</button>
</div>
<div v-if="fileName" class="flex flex-wrap items-center gap-2">
<span class="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)">
<span class="demo-badge">
{{ fileName }}
</span>
<span class="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)">
<span class="demo-badge">
{{ fileMIME || 'text/plain' }}
</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-mono tabular-nums text-(--fg-muted)">
<span class="inline-flex items-center gap-1.5 rounded-md border border-border bg-bg-inset px-2 py-0.5 text-xs font-mono tabular-nums text-fg-muted">
{{ formatBytes(fileSize) }}
</span>
</div>
@@ -88,19 +88,19 @@ async function newFile() {
v-model="text"
rows="6"
spellcheck="false"
class="w-full resize-none rounded-lg border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
class="demo-input resize-none font-mono"
placeholder="File contents…"
/>
<div
v-else
class="flex flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-(--border) bg-(--bg-inset) p-6 text-center"
class="flex flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-border bg-bg-inset p-6 text-center"
>
<span class="text-sm text-(--fg-muted)">No file open</span>
<span class="text-xs text-(--fg-subtle)">Open an existing file or create a new one, edit it, then save back to disk.</span>
<span class="text-sm text-fg-muted">No file open</span>
<span class="text-xs text-fg-subtle">Open an existing file or create a new one, edit it, then save back to disk.</span>
</div>
<p v-if="lastError" class="text-center text-xs text-(--fg-subtle)">
<p v-if="lastError" class="text-center text-xs text-fg-subtle">
{{ lastError }}
</p>
</template>
@@ -7,7 +7,7 @@ const { isSupported, isFullscreen, enter, exit, toggle } = useFullscreen(target)
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="demo-stack max-w-sm">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-center text-sm text-amber-600 dark:text-amber-400"
@@ -17,27 +17,27 @@ const { isSupported, isFullscreen, enter, exit, toggle } = useFullscreen(target)
<div
ref="target"
class="relative flex h-44 flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-(--border) bg-(--bg-elevated) transition-colors"
:class="isFullscreen && 'bg-(--bg-inset)'"
class="demo-card relative flex h-44 flex-col items-center justify-center gap-3 overflow-hidden transition-colors"
:class="isFullscreen && 'bg-bg-inset'"
>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isFullscreen
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
: 'border-border bg-bg-inset text-fg-muted'"
>
<span class="size-1.5 rounded-full" :class="isFullscreen ? 'bg-emerald-500' : 'bg-(--fg-subtle)'" />
<span class="size-1.5 rounded-full" :class="isFullscreen ? 'bg-emerald-500' : 'bg-fg-subtle'" />
{{ isFullscreen ? 'Fullscreen' : 'Windowed' }}
</span>
<p class="px-6 text-center text-sm text-(--fg-muted)">
<p class="px-6 text-center text-sm text-fg-muted">
This panel becomes the fullscreen target. Press
<kbd class="rounded border border-(--border) bg-(--bg) px-1.5 py-0.5 font-mono text-xs text-(--fg)">Esc</kbd>
<kbd class="rounded border border-border bg-bg px-1.5 py-0.5 font-mono text-xs text-fg">Esc</kbd>
to leave.
</p>
<button
class="inline-flex 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn-primary disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!isSupported"
@click="toggle"
>
@@ -55,14 +55,14 @@ const { isSupported, isFullscreen, enter, exit, toggle } = useFullscreen(target)
<div class="flex gap-2">
<button
class="inline-flex flex-1 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!isSupported || isFullscreen"
@click="enter"
>
Enter
</button>
<button
class="inline-flex flex-1 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] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
class="demo-btn flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!isSupported || !isFullscreen"
@click="exit"
>

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