12 Commits

Author SHA1 Message Date
Renovate Bot 6538f1cf9e chore(deps): update eslint monorepo to v10
CI / Discover packages (pull_request) Successful in 1m14s
CI / ${{ matrix.package }} (pull_request) Failing after 38s
CI / CI (pull_request) Failing after 5s
2026-06-15 22:06:15 +00:00
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
331 changed files with 5461 additions and 4016 deletions
@@ -77,7 +77,7 @@ jobs:
# browser. playwright is a direct devDep of these packages, so run its CLI # 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. # in the package context (--filter) — it isn't resolvable from the root.
- name: Install Playwright Chromium - 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 run: pnpm --filter "${{ matrix.package }}" exec playwright install --with-deps chromium
- name: Lint - name: Lint
@@ -87,7 +87,7 @@ jobs:
run: pnpm --filter "${{ matrix.package }}" --if-present run test run: pnpm --filter "${{ matrix.package }}" --if-present run test
# Sentinel job — aggregates all matrix results into a single status check. # 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: ci:
name: CI name: CI
needs: check needs: check
+1 -1
View File
@@ -66,7 +66,7 @@
"tsdown": "catalog:" "tsdown": "catalog:"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": ">=9.39.4" "eslint": ">=10.5.0"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
+2 -2
View File
@@ -1,6 +1,6 @@
# @robonen/crdt # @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. 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. - `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, …). - `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. - 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 ## 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 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. CRDT, built by different code, nonetheless agree on the final document.
</p> </p>
<div class="my-4 rounded-lg border border-(--border) bg-(--bg-subtle) p-4"> <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)"> <p class="m-0 text-sm leading-relaxed text-fg-muted">
<strong class="text-(--fg)">Why one rule for everything?</strong> <strong class="text-fg">Why one rule for everything?</strong>
<code class="text-(--accent-text)">LwwRegister</code> uses <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">compareOpId</code> to pick the surviving value;
<code class="text-(--accent-text)">Rga</code> uses it to break ties between concurrent inserts at <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 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 formatting wins per character. One total order, applied consistently, is what turns a pile of
independent primitives into a coherent, converging system. independent primitives into a coherent, converging system.
</p> </p>
@@ -223,11 +223,11 @@ const propsSrc = `// Commutative — order of application doesn't matter:
<DocsCode :code="vvWireSrc" lang="ts" /> <DocsCode :code="vvWireSrc" lang="ts" />
<div class="prose-docs"> <div class="prose-docs">
<div class="my-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-4"> <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> <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 <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 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 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. this for you) so a single high-water mark per site can stand in for the full set of seen ops.
</p> </p>
</div> </div>
@@ -242,23 +242,23 @@ const propsSrc = `// Commutative — order of application doesn't matter:
</div> </div>
<DocsCode :code="propsSrc" lang="ts" /> <DocsCode :code="propsSrc" lang="ts" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <h3 class="mb-1.5 text-sm font-semibold text-fg">Commutative</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <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, Order of application doesn't change the result. A replica can integrate operations as they arrive,
in whatever sequence the network delivers them. in whatever sequence the network delivers them.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <h3 class="mb-1.5 text-sm font-semibold text-fg">Idempotent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <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; Applying the same operation twice is the same as applying it once. Redelivery and retries are safe;
version vectors make them free. version vectors make them free.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <h3 class="mb-1.5 text-sm font-semibold text-fg">Convergent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <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 Same set of operations, same final state — full stop. Two replicas that have seen the same ops are
byte-for-byte identical. byte-for-byte identical.
</p> </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 --> <!-- Map of the package -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <h3 class="mb-1.5 text-sm font-semibold text-fg">Registers</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<code class="text-(--accent-text)">LwwRegister</code> and <code class="text-accent-text">LwwRegister</code> and
<code class="text-(--accent-text)">LwwMap</code> single values and keyed maps where the <code class="text-accent-text">LwwMap</code> single values and keyed maps where the
write with the highest op id wins. write with the highest op id wins.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <h3 class="mb-1.5 text-sm font-semibold text-fg">Ordering</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<code class="text-(--accent-text)">keyBetween</code> / <code class="text-accent-text">keyBetween</code> /
<code class="text-(--accent-text)">keysBetween</code> fractional indexing to place or move <code class="text-accent-text">keysBetween</code> fractional indexing to place or move
an item with a single string key. an item with a single string key.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <h3 class="mb-1.5 text-sm font-semibold text-fg">Sequence</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<code class="text-(--accent-text)">Rga</code> a replicated growable array: an ordered <code class="text-accent-text">Rga</code> a replicated growable array: an ordered
sequence CRDT with tombstones and a deterministic insert tie-break. sequence CRDT with tombstones and a deterministic insert tie-break.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <h3 class="mb-1.5 text-sm font-semibold text-fg">Marks</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<code class="text-(--accent-text)">MarkStore</code> lightweight Peritext formatting spans <code class="text-accent-text">MarkStore</code> lightweight Peritext formatting spans
anchored to character op ids, resolved per character by highest op id. anchored to character op ids, resolved per character by highest op id.
</p> </p>
</div> </div>
@@ -262,12 +262,12 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
</div> </div>
<DocsCode :code="lwwMap" lang="ts" /> <DocsCode :code="lwwMap" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4"> <div class="rounded-lg border border-border bg-bg-subtle p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<strong class="text-(--fg)">Why keep tombstones?</strong> If a delete simply dropped the entry, <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 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 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. winner deterministically, the same way it does for live values.
</p> </p>
</div> </div>
@@ -308,9 +308,9 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
<DocsCode :code="fractionalBatch" lang="ts" /> <DocsCode :code="fractionalBatch" lang="ts" />
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4"> <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> <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 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 <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 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> </div>
<DocsCode :code="rgaBuffer" lang="ts" /> <DocsCode :code="rgaBuffer" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4"> <div class="rounded-lg border border-border bg-bg-subtle p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<strong class="text-(--fg)">Garbage collection.</strong> Tombstones accumulate. When every <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> 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 drops deleted nodes whose insert is covered by a stable
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink>, returning how many it removed. <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 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. referenced elsewhere, such as mark span endpoints.
</p> </p>
</div> </div>
+8 -8
View File
@@ -249,12 +249,12 @@ a.replica.receive(ops);`;
</div> </div>
<!-- Why order does not matter --> <!-- Why order does not matter -->
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <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)"> <p class="text-sm leading-relaxed text-fg-muted">
You could swap the two <code class="text-(--accent-text)">receive</code> lines, run them 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 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, each op in its deterministic position regardless of arrival order. That is convergence,
and the property tests assert it across randomized schedules. and the property tests assert it across randomized schedules.
</p> </p>
@@ -346,11 +346,11 @@ a.replica.receive(ops);`;
<!-- Caveat callout --> <!-- Caveat callout -->
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-5"> <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> <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 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 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> </p>
</div> </div>
+36 -36
View File
@@ -260,17 +260,17 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
<ClientOnly> <ClientOnly>
<template #fallback> <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 Loading interactive demo
</div> </div>
</template> </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"> <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 <button
type="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()" @click="start()"
> >
Start demo Start demo
@@ -281,82 +281,82 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
<!-- Two replica panes --> <!-- Two replica panes -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Replica A --> <!-- 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"> <div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider text-(--fg-muted)">Replica 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> <span class="rounded bg-bg-inset px-1.5 py-0.5 font-mono text-[11px] text-fg-subtle">site: A</span>
</div> </div>
<textarea <textarea
v-model="drafts.a" v-model="drafts.a"
rows="3" rows="3"
spellcheck="false" 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…" placeholder="Type on A…"
/> />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
type="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')" @click="apply('a')"
> >
Apply edits Apply edits
</button> </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>ops {{ snapshot.a.ops }}</span>
<span>clock {{ snapshot.a.clock }}</span> <span>clock {{ snapshot.a.clock }}</span>
</div> </div>
</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-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>
</div> </div>
<!-- Replica B --> <!-- 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"> <div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider text-(--fg-muted)">Replica 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> <span class="rounded bg-bg-inset px-1.5 py-0.5 font-mono text-[11px] text-fg-subtle">site: B</span>
</div> </div>
<textarea <textarea
v-model="drafts.b" v-model="drafts.b"
rows="3" rows="3"
spellcheck="false" 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…" placeholder="Type on B…"
/> />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
type="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')" @click="apply('b')"
> >
Apply edits Apply edits
</button> </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>ops {{ snapshot.b.ops }}</span>
<span>clock {{ snapshot.b.clock }}</span> <span>clock {{ snapshot.b.clock }}</span>
</div> </div>
</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-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> </div>
</div> </div>
<!-- Sync bar --> <!-- 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 <button
type="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()" @click="sync()"
> >
Sync Sync
</button> </button>
<button <button
type="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()" @click="init()"
> >
Reset Reset
@@ -436,27 +436,27 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
</div> </div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <h3 class="mb-1.5 text-sm font-semibold text-fg">Commutative</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <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 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. doesn't matter.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <h3 class="mb-1.5 text-sm font-semibold text-fg">Idempotent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <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 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. short-circuits if the id is already present.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <h3 class="mb-1.5 text-sm font-semibold text-fg">Causal</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
An insert can't integrate before its <code class="text-(--accent-text)">originLeft</code>, 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 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. such ops and retries them, so out-of-order delivery still converges.
</p> </p>
</div> </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: 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 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 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. fully domain-agnostic, ships zero runtime dependencies, and runs in both Node and the browser.
</p> </p>
</div> </div>
<!-- Feature cards --> <!-- Feature cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <h3 class="mb-1.5 text-sm font-semibold text-fg">Convergent by construction</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
One deterministic tie-break — <code class="text-(--accent-text)">compareOpId</code> (higher 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 Lamport clock wins; site id breaks ties) — is shared by every primitive, so LWW and RGA agree
on the same final state. on the same final state.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <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)"> <p class="text-sm leading-relaxed text-fg-muted">
<code class="text-(--accent-text)">Replica.receive</code> dedups, holds ops whose dependencies <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. haven't arrived yet (an insert before its origin), and retries them automatically as they land.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <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)"> <p class="text-sm leading-relaxed text-fg-muted">
Version vectors let each side request exactly the ops it's missing via 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> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <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)"> <p class="text-sm leading-relaxed text-fg-muted">
No runtime deps, no framework lock-in. Compose the primitives yourself, or lean on 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> </p>
</div> </div>
</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 --> <!-- Feature cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <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> <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)"> <p class="mt-1.5 text-sm text-fg-muted">
<code>encodeText</code> and <code>encodeBinary</code> pick the smallest <code>encodeText</code> and <code>encodeBinary</code> pick the smallest
version and optimal segment modes for you, then hand back an immutable version and optimal segment modes for you, then hand back an immutable
<code>QrCode</code> grid. <code>QrCode</code> grid.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-(--fg)">Render-agnostic output</h3> <h3 class="text-sm font-semibold text-fg">Render-agnostic output</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
A <code>QrCode</code> is just a square of modules. Read each one with 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 <code>getModule(x, y)</code> and draw to SVG, canvas, or anything else
no rendering opinions baked in. no rendering opinions baked in.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-(--fg)">Standalone Reed-Solomon</h3> <h3 class="text-sm font-semibold text-fg">Standalone Reed-Solomon</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
The GF(2^8) error-correction core <code>multiply</code>, The GF(2^8) error-correction core <code>multiply</code>,
<code>computeDivisor</code>, <code>computeRemainder</code> is exported <code>computeDivisor</code>, <code>computeRemainder</code> is exported
on its own, reusable beyond QR. on its own, reusable beyond QR.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <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> <h3 class="text-sm font-semibold text-fg">Zero dependencies, fully typed</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
Tree-shakeable ESM and CJS builds with no third-party runtime deps, hot Tree-shakeable ESM and CJS builds with no third-party runtime deps, hot
loops backed by typed arrays, and end-to-end TypeScript types. loops backed by typed arrays, and end-to-end TypeScript types.
</p> </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, { export default compose(base, typescript, imports, stylistic, {
name: 'encoding/overrides', 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. */ oldest register's seed/last write is intentionally dead — keep symmetry. */
'no-useless-assignment': 'off', 'no-useless-assignment': 'off',
}, },
}); }, tests);
+12 -12
View File
@@ -59,35 +59,35 @@ const billing = api.extend({ baseURL: 'https://billing.example.com' });`;
</div> </div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <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> <h3 class="text-sm font-semibold text-fg">Type-safe end to end</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
Response data, request options, and plugin-contributed fields are all inferred Response data, request options, and plugin-contributed fields are all inferred
the parsed body comes back typed, no casting required. the parsed body comes back typed, no casting required.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <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> <h3 class="text-sm font-semibold text-fg">Smart bodies &amp; parsing</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
Plain objects are JSON-serialized; <code>FormData</code>/<code>Blob</code>/streams Plain objects are JSON-serialized; <code>FormData</code>/<code>Blob</code>/streams
pass through untouched. Responses are decoded from <code>Content-Type</code> or pass through untouched. Responses are decoded from <code>Content-Type</code> or
forced via <code>responseType</code>. forced via <code>responseType</code>.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <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> <h3 class="text-sm font-semibold text-fg">Retry, timeout &amp; errors</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
Built-in retry and per-attempt timeout with sensible defaults, and non-2xx Built-in retry and per-attempt timeout with sensible defaults, and non-2xx
responses reject with a rich <code>FetchError</code> carrying status, request, responses reject with a rich <code>FetchError</code> carrying status, request,
and parsed body. and parsed body.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-(--fg)">Hooks &amp; plugins</h3> <h3 class="text-sm font-semibold text-fg">Hooks &amp; plugins</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
Lifecycle hooks plus a typed, composable plugin system with onion-style Lifecycle hooks plus a typed, composable plugin system with onion-style
<code>execute</code> middleware composed once, with zero per-request overhead <code>execute</code> middleware composed once, with zero per-request overhead
beyond the hooks themselves. 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; --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 ── /* ── Semantic design tokens — ink on warm paper, signal-orange instruments ──
The site reads like a tool-maker's field manual: warm neutral surfaces, The site reads like a tool-maker's field manual: warm neutral surfaces,
hairline rules, international-orange accents, code-comment labels. */ hairline rules, international-orange accents, code-comment labels. */
:root { :root {
--bg: #faf8f3; /* Colours are OKLCH (perceptually uniform — even lightness steps, predictable
--bg-subtle: #f4f1e8; hue) and are exact equivalents of the original hand-tuned sRGB palette.
--bg-elevated: #fffdf8; Translucent tokens derive from their base via color-mix(), so they track
--bg-inset: #eeeadf; theme + accent retuning automatically instead of duplicating a literal. */
--border: #e5dfd0; --bg: oklch(0.9793 0.007 88.64);
--border-strong: #cfc6b1; --bg-subtle: oklch(0.958 0.0124 91.52);
--fg: #211e18; --bg-elevated: oklch(0.9942 0.0069 88.64);
--fg-muted: #5d574b; --bg-inset: oklch(0.9371 0.0153 90.24);
--fg-subtle: #93897a; --border: oklch(0.9043 0.0211 88.73);
--accent: #d9480f; --border-strong: oklch(0.8282 0.0303 87.56);
--accent-hover: #bf3f0d; --fg: oklch(0.2363 0.012 84.56);
--accent-fg: #fffdf8; --fg-muted: oklch(0.4588 0.0204 84.58);
--accent-subtle: #f7e7d8; --fg-subtle: oklch(0.6346 0.0249 78.12);
--accent-text: #c2410c; --accent: oklch(0.5999 0.1905 37.88);
--header-bg: rgba(250, 248, 243, 0.82); --accent-hover: oklch(0.5461 0.1724 37.96);
--ring: rgba(217, 72, 15, 0.35); --accent-fg: oklch(0.9942 0.0069 88.64);
--shadow-card: 0 1px 2px rgba(56, 44, 28, 0.05), 0 1px 3px rgba(56, 44, 28, 0.07); --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; color-scheme: light;
} }
.dark { .dark {
--bg: #161310; --bg: oklch(0.1892 0.0077 67.33);
--bg-subtle: #1b1813; --bg-subtle: oklch(0.2107 0.0106 80.56);
--bg-elevated: #211d17; --bg-elevated: oklch(0.2332 0.0127 78);
--bg-inset: #2a251c; --bg-inset: oklch(0.267 0.0176 82.2);
--border: #322c22; --border: oklch(0.2964 0.0194 80.44);
--border-strong: #4a4231; --border-strong: oklch(0.3822 0.0294 85.68);
--fg: #ece7db; --fg: oklch(0.9286 0.0169 88);
--fg-muted: #b2a995; --fg-muted: oklch(0.7369 0.0298 86.66);
--fg-subtle: #7d7363; --fg-subtle: oklch(0.56 0.0269 79.61);
--accent: #ff7d33; --accent: oklch(0.7294 0.1789 46.57);
--accent-hover: #ff9a59; --accent-hover: oklch(0.7788 0.1452 51.83);
--accent-fg: #1d0e04; --accent-fg: oklch(0.1825 0.0328 56.53);
--accent-subtle: #3a2415; --accent-subtle: oklch(0.284 0.042 54.49);
--accent-text: #ff9c63; --accent-text: oklch(0.7835 0.139 49.63);
--header-bg: rgba(22, 19, 16, 0.82); /* --header-bg is not re-declared: the :root color-mix tracks --bg, which we
--ring: rgba(255, 125, 51, 0.4); override above. Only --ring needs a tweak (slightly stronger in dark). */
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.5); --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; color-scheme: dark;
} }
+2 -2
View File
@@ -22,8 +22,8 @@ const kindLabels: Record<string, string> = {
:class="[ :class="[
'inline-flex items-center justify-center rounded font-mono font-medium shrink-0 border', 'inline-flex items-center justify-center rounded font-mono font-medium shrink-0 border',
kind === 'component' kind === 'component'
? 'border-(--accent-subtle) bg-(--accent-subtle) text-(--accent-text)' ? 'border-accent-subtle bg-accent-subtle text-accent-text'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)', : 'border-border bg-bg-inset text-fg-muted',
size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs', size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs',
]" ]"
:title="kind" :title="kind"
+5 -5
View File
@@ -39,12 +39,12 @@ async function copy() {
</script> </script>
<template> <template>
<div class="group relative rounded-xl border border-(--border) bg-(--bg-subtle) overflow-hidden max-w-full"> <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)"> <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> <span class="text-[11px] font-mono uppercase tracking-wider text-fg-subtle">{{ langLabel }}</span>
<button <button
type="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" @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"> <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 <button
v-else v-else
type="button" 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" title="Copy"
@click="copy" @click="copy"
> >
+8 -8
View File
@@ -43,10 +43,10 @@ const roleColor: Record<string, string> = {
<div class="space-y-10"> <div class="space-y-10">
<!-- Anatomy snippet --> <!-- Anatomy snippet -->
<section> <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 Anatomy
</h2> </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. Import the parts and compose them. Each part forwards attributes to its underlying element.
</p> </p>
<DocsCode :code="anatomyCode" lang="vue" /> <DocsCode :code="anatomyCode" lang="vue" />
@@ -54,7 +54,7 @@ const roleColor: Record<string, string> = {
<!-- Parts --> <!-- Parts -->
<section> <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 API Reference
</h2> </h2>
<div class="space-y-8"> <div class="space-y-8">
@@ -65,18 +65,18 @@ const roleColor: Record<string, string> = {
class="scroll-mt-20" class="scroll-mt-20"
> >
<div class="flex items-center gap-2.5 mb-2"> <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 <span
:class="[ :class="[
'text-[11px] px-2 py-0.5 rounded-full font-medium leading-none', '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 }} {{ part.role }}
</span> </span>
</div> </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 }} {{ part.description }}
</p> </p>
@@ -85,11 +85,11 @@ const roleColor: Record<string, string> = {
</div> </div>
<div v-if="part.emits.length > 0" class="mb-3"> <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" /> <DocsEmitsTable :emits="part.emits" />
</div> </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. No props or events renders its element and forwards attributes.
</p> </p>
</div> </div>
+6 -6
View File
@@ -24,14 +24,14 @@ watch(showSource, async (show) => {
</script> </script>
<template> <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, <!-- Live demo client-only: demos are interactive and use browser APIs,
so they must not be instantiated during SSR/prerender. --> 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> <ClientOnly>
<component :is="component" /> <component :is="component" />
<template #fallback> <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"> <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" /> <path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg> </svg>
@@ -42,10 +42,10 @@ watch(showSource, async (show) => {
</div> </div>
<!-- Source toggle bar --> <!-- 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 <button
type="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" @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"> <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> </div>
<!-- Source code --> <!-- 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 class="overflow-x-auto text-[13px] [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!" v-html="highlighted" />
</div> </div>
</div> </div>
+7 -7
View File
@@ -6,21 +6,21 @@ defineProps<{
</script> </script>
<template> <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"> <table class="w-full text-sm border-collapse">
<thead> <thead>
<tr class="bg-(--bg-subtle) text-left"> <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">Event</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Payload</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Payload</th>
</tr> </tr>
</thead> </thead>
<tbody> <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"> <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>
<td class="py-2.5 px-4"> <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> </td>
</tr> </tr>
</tbody> </tbody>
+1 -1
View File
@@ -28,7 +28,7 @@ async function highlightCodeBlocks() {
try { try {
const out = await highlight(text, resolved); const out = await highlight(text, resolved);
const wrapper = document.createElement('div'); 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; wrapper.innerHTML = out;
pre.replaceWith(wrapper); pre.replaceWith(wrapper);
} }
+7 -7
View File
@@ -10,19 +10,19 @@ defineProps<{
<div <div
v-for="method in methods" v-for="method in methods"
:key="method.name" :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"> <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 <span
v-if="method.visibility !== 'public'" 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 }} {{ method.visibility }}
</span> </span>
</div> </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" /> <DocsText :text="method.description" />
</p> </p>
@@ -36,9 +36,9 @@ defineProps<{
<DocsParamsTable v-if="method.params.length > 0" :params="method.params" /> <DocsParamsTable v-if="method.params.length > 0" :params="method.params" />
<div v-if="method.returns" class="mt-2 text-sm"> <div v-if="method.returns" class="mt-2 text-sm">
<span class="text-(--fg-subtle)">Returns</span> <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> <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)" /> <DocsText v-if="method.returns.description" :text="method.returns.description" class="ml-2 text-fg-muted" />
</div> </div>
</div> </div>
</div> </div>
+12 -12
View File
@@ -6,33 +6,33 @@ defineProps<{
</script> </script>
<template> <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"> <table class="w-full text-sm border-collapse">
<thead> <thead>
<tr class="bg-(--bg-subtle) text-left"> <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">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">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 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> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="param in params" v-for="param in params"
:key="param.name" :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"> <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>
<td class="py-2.5 px-4"> <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>
<td class="py-2.5 px-4 hidden sm:table-cell"> <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> <code v-if="param.defaultValue" class="text-xs font-mono text-fg-muted">{{ param.defaultValue }}</code>
<span v-else class="text-(--fg-subtle)"></span> <span v-else class="text-fg-subtle"></span>
</td> </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" /> <DocsText v-if="param.description" :text="param.description" />
<span v-else></span> <span v-else></span>
</td> </td>
+13 -13
View File
@@ -8,34 +8,34 @@ defineProps<{
</script> </script>
<template> <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"> <table class="w-full text-sm border-collapse">
<thead> <thead>
<tr class="bg-(--bg-subtle) text-left"> <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">{{ 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">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 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> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="prop in properties" v-for="prop in properties"
:key="prop.name" :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"> <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> <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> <span v-if="prop.readonly" class="block text-[10px] text-fg-subtle uppercase tracking-wide mt-0.5">readonly</span>
</td> </td>
<td class="py-2.5 px-4"> <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>
<td class="py-2.5 px-4 hidden sm:table-cell"> <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> <code v-if="prop.defaultValue" class="text-xs font-mono text-fg-muted">{{ prop.defaultValue }}</code>
<span v-else class="text-(--fg-subtle)"></span> <span v-else class="text-fg-subtle"></span>
</td> </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" /> <DocsText v-if="prop.description" :text="prop.description" />
<span v-else></span> <span v-else></span>
</td> </td>
+12 -12
View File
@@ -65,14 +65,14 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
<div> <div>
<button <button
type="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" @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"> <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" /> <circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg> </svg>
<span class="hidden sm:inline flex-1 text-left font-mono text-[13px]">search</span> <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> </button>
<Teleport to="body"> <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-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="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="bg-bg-elevated rounded-xl border border-border shadow-2xl overflow-hidden">
<div class="flex items-center px-4 border-b border-(--border)"> <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> <span class="font-mono text-base text-accent-text select-none shrink-0"></span>
<input <input
v-model="query" v-model="query"
data-search-input data-search-input
type="text" type="text"
placeholder="search across all packages…" 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>
<div class="max-h-[60vh] overflow-y-auto p-2"> <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 }}" No results for "{{ query }}"
</div> </div>
<ul v-else-if="results.length > 0" class="space-y-0.5"> <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}`" :to="`/${r.pkg.slug}/${r.slug}`"
:class="[ :class="[
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors', '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" @click="close"
@mouseenter="activeIndex = i" @mouseenter="activeIndex = i"
> >
<DocsBadge :kind="r.badge" size="sm" /> <DocsBadge :kind="r.badge" size="sm" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="text-sm font-medium text-(--fg) truncate">{{ r.name }}</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 class="text-xs text-fg-subtle truncate">{{ r.pkg.name }} · {{ r.description }}</div>
</div> </div>
</NuxtLink> </NuxtLink>
</li> </li>
</ul> </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 Type to search functions, components &amp; guides
</div> </div>
</div> </div>
+3 -3
View File
@@ -4,10 +4,10 @@
}>(); }>();
const variantClasses: Record<string, string> = { const variantClasses: Record<string, string> = {
since: '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)', 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', 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', 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> </script>
+1 -1
View File
@@ -12,7 +12,7 @@ const label = computed(() => ({
type="button" type="button"
:title="`Theme: ${label} (click to change)`" :title="`Theme: ${label} (click to change)`"
:aria-label="`Theme: ${label}`" :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" @click="cycle"
> >
<ClientOnly> <ClientOnly>
+3 -3
View File
@@ -49,7 +49,7 @@ function go(id: string) {
<div class="comment-label mb-3"> <div class="comment-label mb-3">
on this page on this page
</div> </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"> <li v-for="item in items" :key="item.id">
<a <a
:href="`#${item.id}`" :href="`#${item.id}`"
@@ -57,8 +57,8 @@ function go(id: string) {
'block py-1 -ml-px border-l-2 transition-colors', 'block py-1 -ml-px border-l-2 transition-colors',
item.depth === 3 ? 'pl-6' : 'pl-4', item.depth === 3 ? 'pl-6' : 'pl-4',
activeId === item.id activeId === item.id
? 'border-(--accent) text-(--accent-text) font-medium' ? 'border-accent text-accent-text font-medium'
: 'border-transparent text-(--fg-muted) hover:text-(--fg)', : 'border-transparent text-fg-muted hover:text-fg',
]" ]"
@click.prevent="go(item.id)" @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']; 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() { export function useDocs() {
const data = metadata as unknown as DocsMetadata; const data = metadata as unknown as DocsMetadata;
@@ -74,6 +96,29 @@ export function useDocs() {
return pkg.docs.filter(s => !s.isIntro); 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. */ /** Resolve any `/:package/:slug` route to a normalised entry. */
function resolveEntry(packageSlug: string, slug: string): DocEntry | undefined { function resolveEntry(packageSlug: string, slug: string): DocEntry | undefined {
const pkg = getPackage(packageSlug); const pkg = getPackage(packageSlug);
@@ -157,6 +202,7 @@ export function useDocs() {
firstEntrySlug, firstEntrySlug,
getIntro, getIntro,
getDocSections, getDocSections,
getComponentGroups,
search, search,
getTotalItems, getTotalItems,
}; };
+48 -43
View File
@@ -1,4 +1,4 @@
<script setup lang="ts">const { getGroupedPackages, getPackage, getIntro, getDocSections } = useDocs(); <script setup lang="ts">const { getGroupedPackages, getPackage, getIntro, getDocSections, getComponentGroups } = useDocs();
const groups = getGroupedPackages(); const groups = getGroupedPackages();
const route = useRoute(); const route = useRoute();
@@ -79,11 +79,11 @@ watch(() => route.path, () => {
<template> <template>
<div class="min-h-screen"> <div class="min-h-screen">
<!-- Header --> <!-- 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"> <div class="mx-auto max-w-352 flex items-center gap-3 px-4 h-14 sm:px-6">
<button <button
type="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" aria-label="Toggle navigation"
@click="isSidebarOpen = !isSidebarOpen" @click="isSidebarOpen = !isSidebarOpen"
> >
@@ -93,12 +93,12 @@ watch(() => route.path, () => {
</button> </button>
<NuxtLink to="/" class="group flex items-center gap-2.5 mr-auto"> <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>
<span class="hidden sm:flex items-baseline font-mono text-[13.5px] tracking-tight"> <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="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="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> </span>
</NuxtLink> </NuxtLink>
@@ -108,7 +108,7 @@ watch(() => route.path, () => {
href="https://github.com/robonen/tools" href="https://github.com/robonen/tools"
target="_blank" target="_blank"
rel="noopener noreferrer" 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" aria-label="GitHub"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="currentColor"> <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 --> <!-- Sidebar -->
<aside <aside
:class="[ :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', isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
]" ]"
> >
@@ -136,24 +136,24 @@ watch(() => route.path, () => {
:class="[ :class="[
'flex items-center justify-between py-1.5 px-2 rounded-md text-sm transition-colors', 'flex items-center justify-between py-1.5 px-2 rounded-md text-sm transition-colors',
currentPackageSlug === pkg.slug currentPackageSlug === pkg.slug
? 'text-(--fg) font-medium bg-(--bg-inset)' ? 'text-fg font-medium bg-bg-inset'
: 'text-(--fg-muted) hover:text-(--fg) hover: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="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> </NuxtLink>
<!-- Expanded tree for the current package --> <!-- 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 --> <!-- Quick filter the tree below collapses to matches -->
<div v-if="currentPackage.kind === 'api'" class="relative mb-2 mt-1"> <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 <input
v-model="navQuery" v-model="navQuery"
type="text" type="text"
placeholder="filter…" 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> </div>
@@ -167,8 +167,8 @@ watch(() => route.path, () => {
:class="[ :class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate', 'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
route.path === `/${pkg.slug}` route.path === `/${pkg.slug}`
? 'text-(--accent-text) font-medium' ? 'text-accent-text font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]" ]"
> >
Introduction Introduction
@@ -180,8 +180,8 @@ watch(() => route.path, () => {
:class="[ :class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate', 'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, s.slug) isActive(pkg.slug, s.slug)
? 'text-(--accent-text) font-medium' ? 'text-accent-text font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]" ]"
> >
{{ s.title }} {{ s.title }}
@@ -192,7 +192,7 @@ watch(() => route.path, () => {
<!-- api: collapsible categories --> <!-- api: collapsible categories -->
<template v-if="currentPackage.kind === 'api'"> <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 no matches
</div> </div>
@@ -206,14 +206,14 @@ watch(() => route.path, () => {
xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 24 24" 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" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
:class="[ :class="[
'shrink-0 text-(--fg-subtle) transition-transform duration-150', 'shrink-0 text-fg-subtle transition-transform duration-150',
isCategoryOpen(cat.slug) ? 'rotate-90' : '', isCategoryOpen(cat.slug) ? 'rotate-90' : '',
]" ]"
> >
<polyline points="9 18 15 12 9 6" /> <polyline points="9 18 15 12 9 6" />
</svg> </svg>
<span class="comment-label group-hover/cat:text-(--fg-muted) transition-colors">{{ cat.name.toLowerCase() }}</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> <span class="ml-auto font-mono text-[10px] text-fg-subtle tabular-nums">{{ cat.items.length }}</span>
</button> </button>
<ul v-if="isCategoryOpen(cat.slug)" class="mb-1.5"> <ul v-if="isCategoryOpen(cat.slug)" class="mb-1.5">
@@ -223,14 +223,14 @@ watch(() => route.path, () => {
:class="[ :class="[
'flex items-center gap-1.5 py-0.75 px-2 text-[13px] rounded-md font-mono transition-colors', 'flex items-center gap-1.5 py-0.75 px-2 text-[13px] rounded-md font-mono transition-colors',
isActive(pkg.slug, item.slug) isActive(pkg.slug, item.slug)
? 'text-(--accent-text) font-medium' ? 'text-accent-text font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]" ]"
> >
<span <span
:class="[ :class="[
'shrink-0 text-[10px] select-none transition-opacity', '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>
<span class="truncate">{{ item.name }}</span> <span class="truncate">{{ item.name }}</span>
@@ -240,22 +240,27 @@ watch(() => route.path, () => {
</div> </div>
</template> </template>
<!-- components --> <!-- components: grouped by functional category -->
<ul v-else-if="currentPackage.kind === 'components'"> <template v-else-if="currentPackage.kind === 'components'">
<li v-for="c in currentPackage.components" :key="c.slug"> <div v-for="group in getComponentGroups(currentPackage)" :key="group.name" class="mb-2">
<NuxtLink <div class="comment-label py-1 px-1">{{ group.name.toLowerCase() }}</div>
:to="`/${pkg.slug}/${c.slug}`" <ul>
:class="[ <li v-for="c in group.components" :key="c.slug">
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate', <NuxtLink
isActive(pkg.slug, c.slug) :to="`/${pkg.slug}/${c.slug}`"
? 'text-(--accent-text) font-medium' :class="[
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', 'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
]" isActive(pkg.slug, c.slug)
> ? 'text-accent-text font-medium'
{{ c.name }} : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
</NuxtLink> ]"
</li> >
</ul> {{ c.name }}
</NuxtLink>
</li>
</ul>
</div>
</template>
<!-- guide --> <!-- guide -->
<ul v-else> <ul v-else>
@@ -265,8 +270,8 @@ watch(() => route.path, () => {
:class="[ :class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate', 'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, s.slug) isActive(pkg.slug, s.slug)
? 'text-(--accent-text) font-medium' ? 'text-accent-text font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]" ]"
> >
{{ s.title }} {{ 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"> <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"> <article class="min-w-0 max-w-3xl">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav class="flex items-center gap-1.5 font-mono text-[13px] text-(--fg-subtle) mb-6"> <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> <NuxtLink :to="`/${pkg.slug}`" class="hover:text-fg transition-colors">{{ pkg.name }}</NuxtLink>
<span>/</span> <span>/</span>
<span class="text-(--fg)">{{ title }}</span> <span class="text-fg">{{ title }}</span>
</nav> </nav>
<!-- API ITEM --> <!-- API ITEM -->
@@ -116,7 +116,7 @@ const sectionTitle = 'comment-label mb-3';
<header class="mb-8"> <header class="mb-8">
<div class="flex items-center gap-2.5 mb-2 flex-wrap"> <div class="flex items-center gap-2.5 mb-2 flex-wrap">
<DocsBadge :kind="entry.item.kind" size="md" /> <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.since" :label="`v${entry.item.since}`" variant="neutral" />
<DocsTag <DocsTag
v-if="entry.item.hasTests" 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" /> <DocsTag v-if="entry.item.hasDemo" label="demo" variant="demo" />
</div> </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" /> <DocsText :text="entry.item.description" />
</p> </p>
<div class="flex items-center gap-4 mt-4 text-sm"> <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> <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 Source
</a> </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> <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 Tests
</a> </a>
@@ -164,9 +164,9 @@ const sectionTitle = 'comment-label mb-3';
<h2 :class="sectionTitle">Type Parameters</h2> <h2 :class="sectionTitle">Type Parameters</h2>
<div class="space-y-1.5"> <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"> <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> <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.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> <span v-if="tp.default" class="text-fg-subtle">= <code class="font-mono text-xs">{{ tp.default }}</code></span>
</div> </div>
</div> </div>
</section> </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"> <section v-if="entry.item.returns" id="returns" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Returns</h2> <h2 :class="sectionTitle">Returns</h2>
<div class="flex items-baseline gap-2 text-sm flex-wrap" :class="entry.item.returns.properties?.length ? 'mb-3' : ''"> <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> <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)" /> <DocsText v-if="entry.item.returns.description" :text="entry.item.returns.description" class="text-fg-muted" />
</div> </div>
<DocsPropsTable v-if="entry.item.returns.properties?.length" :properties="entry.item.returns.properties" /> <DocsPropsTable v-if="entry.item.returns.properties?.length" :properties="entry.item.returns.properties" />
</section> </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"> <section v-if="entry.item.relatedTypes?.length" id="related-types" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Related Types</h2> <h2 :class="sectionTitle">Related Types</h2>
<div class="space-y-4"> <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"> <div class="flex items-center gap-2 mb-2">
<DocsBadge :kind="rt.kind" size="sm" /> <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> </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" /> <DocsText :text="rt.description" />
</p> </p>
<DocsCode v-if="rt.signatures.length" :code="rt.signatures[0]!" /> <DocsCode v-if="rt.signatures.length" :code="rt.signatures[0]!" />
@@ -218,14 +218,14 @@ const sectionTitle = 'comment-label mb-3';
<header class="mb-8"> <header class="mb-8">
<div class="flex items-center gap-2.5 mb-2 flex-wrap"> <div class="flex items-center gap-2.5 mb-2 flex-wrap">
<DocsBadge kind="component" size="md" /> <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" /> <DocsTag :label="`${entry.component.parts.length} parts`" variant="neutral" />
</div> </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" /> <DocsText :text="entry.component.description" />
</p> </p>
<div class="flex items-center gap-4 mt-4 text-sm"> <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> <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 Source
</a> </a>
+55 -27
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">import { sections } from '#docs/sections'; <script setup lang="ts">import { sections } from '#docs/sections';
const route = useRoute(); const route = useRoute();
const { getPackage, countEntries, getIntro } = useDocs(); const { getPackage, countEntries, getIntro, getComponentGroups } = useDocs();
const slug = computed(() => route.params.package as string); const slug = computed(() => route.params.package as string);
const pkg = computed(() => getPackage(slug.value)); const pkg = computed(() => getPackage(slug.value));
@@ -51,6 +51,15 @@ function scrollToCategory(catSlug: string) {
document.getElementById(`cat-${catSlug}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); 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. // For guide packages, surface the overview section inline.
const overview = computed(() => const overview = computed(() =>
pkg.value?.kind === 'guide' ? pkg.value.sections.find(s => s.slug === 'overview') : undefined, pkg.value?.kind === 'guide' ? pkg.value.sections.find(s => s.slug === 'overview') : undefined,
@@ -68,13 +77,13 @@ const otherSections = computed(() =>
</section> </section>
<!-- Auto header (shown only when there's no hand-authored intro) --> <!-- 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="comment-label mb-3">{{ kindLabel.toLowerCase() }} · {{ countEntries(pkg) }} entries</div>
<div class="flex items-center gap-2.5 mb-2 flex-wrap"> <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" /> <DocsTag :label="`v${pkg.version}`" variant="neutral" />
</div> </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"> <div class="mt-5">
<DocsCode :code="`pnpm add ${pkg.name}`" lang="bash" /> <DocsCode :code="`pnpm add ${pkg.name}`" lang="bash" />
</div> </div>
@@ -84,14 +93,14 @@ const otherSections = computed(() =>
<template v-if="pkg.kind === 'api'"> <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="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"> <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 <input
v-model="query" v-model="query"
type="text" type="text"
:placeholder="`filter ${countEntries(pkg)} entries…`" :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 {{ filteredCount }} hits
</span> </span>
</div> </div>
@@ -101,17 +110,17 @@ const otherSections = computed(() =>
v-for="category in filteredCategories" v-for="category in filteredCategories"
:key="category.slug" :key="category.slug"
type="button" 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)" @click="scrollToCategory(category.slug)"
> >
{{ category.name.toLowerCase() }} {{ 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> </button>
</div> </div>
</div> </div>
<div v-if="query && filteredCategories.length === 0" class="py-16 text-center"> <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> </div>
<section <section
@@ -128,48 +137,67 @@ const otherSections = computed(() =>
v-for="item in category.items" v-for="item in category.items"
:key="item.slug" :key="item.slug"
:to="`/${pkg.slug}/${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" /> <DocsBadge :kind="item.kind" size="sm" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5 flex-wrap"> <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" /> <DocsTag v-if="item.hasDemo" label="demo" variant="demo" />
</div> </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> </div>
</NuxtLink> </NuxtLink>
</div> </div>
</section> </section>
</template> </template>
<!-- Components: gallery --> <!-- Components: gallery grouped by functional category -->
<template v-else-if="pkg.kind === 'components'"> <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"> <h2 class="comment-label mb-4">
all components · {{ pkg.components.length }} {{ group.name.toLowerCase() }} · {{ group.components.length }}
</h2> </h2>
<div class="stagger grid grid-cols-1 gap-3 sm:grid-cols-2"> <div class="stagger grid grid-cols-1 gap-3 sm:grid-cols-2">
<NuxtLink <NuxtLink
v-for="c in pkg.components" v-for="c in group.components"
:key="c.slug" :key="c.slug"
:to="`/${pkg.slug}/${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"> <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-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-mono text-[11px] text-fg-subtle tabular-nums">{{ c.parts.length }} parts</span>
</div> </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"> <div class="mt-3 flex flex-wrap gap-1">
<span <span
v-for="part in c.parts.slice(0, 4)" v-for="part in c.parts.slice(0, 4)"
:key="part.name" :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 }} {{ part.role }}
</span> </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> </div>
</NuxtLink> </NuxtLink>
</div> </div>
@@ -179,17 +207,17 @@ const otherSections = computed(() =>
<!-- Guide: overview markdown + section links --> <!-- Guide: overview markdown + section links -->
<template v-else> <template v-else>
<DocsMarkdown v-if="overview" :source="overview.markdown" /> <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> <h2 class="comment-label mb-4">sections</h2>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<NuxtLink <NuxtLink
v-for="s in otherSections" v-for="s in otherSections"
:key="s.slug" :key="s.slug"
:to="`/${pkg.slug}/${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="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="font-mono text-[11px] text-fg-subtle group-hover:text-accent-text transition-colors"></span>
</NuxtLink> </NuxtLink>
</div> </div>
</section> </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> <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"> <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> Tools, documented<span class="text-accent">.</span>
</h1> </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 A monorepo of TypeScript utilities, Vue composables, headless UI primitives
and shared tooling typed, tested and demoed in place. and shared tooling typed, tested and demoed in place.
</p> </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"> <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 class="text-accent-text"></span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ packages.length }}</span> packages</span> <span><span class="text-fg font-medium tabular-nums">{{ packages.length }}</span> packages</span>
<span class="text-(--border-strong)">·</span> <span class="text-border-strong">·</span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ totalItems }}</span> documented items</span> <span><span class="text-fg font-medium tabular-nums">{{ totalItems }}</span> documented items</span>
<span class="text-(--border-strong)">·</span> <span class="text-border-strong">·</span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ groups.length }}</span> groups</span> <span><span class="text-fg font-medium tabular-nums">{{ groups.length }}</span> groups</span>
</div> </div>
</section> </section>
@@ -46,29 +46,29 @@ useHead({ title: '@robonen/tools — Documentation' });
v-for="pkg in grp.packages" v-for="pkg in grp.packages"
:key="pkg.slug" :key="pkg.slug"
:to="`/${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 --> <!-- Corner notch fills in on hover like an indicator lamp -->
<span <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%)" style="clip-path: polygon(100% 0, 0 0, 100% 100%)"
aria-hidden="true" aria-hidden="true"
/> />
<div class="flex items-start justify-between gap-3 mb-2"> <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 }} {{ pkg.name }}
</h3> </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] }} {{ kindLabels[pkg.kind] }}
</span> </span>
</div> </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 }} {{ pkg.description }}
</p> </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>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> <span class="tabular-nums">{{ countEntries(pkg) }} {{ pkg.kind === 'components' ? 'components' : pkg.kind === 'guide' ? 'sections' : 'items' }}</span>
</div> </div>
</NuxtLink> </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, { export default compose(base, typescript, vue, imports, stylistic, {
name: 'docs/build-scripts', 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. */ /* Build-time tooling (doc extractor) logs progress to the console. */
'no-console': 'off', 'no-console': 'off',
}, },
}); }, tests);
+111 -65
View File
@@ -88,7 +88,7 @@ const PACKAGES: PackageConfig[] = [
{ path: 'core/crdt', slug: 'crdt', kind: 'api', group: 'core' }, { path: 'core/crdt', slug: 'crdt', kind: 'api', group: 'core' },
// ── vue ── // ── vue ──
{ path: 'vue/toolkit', slug: 'vue', kind: 'api', group: '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' }, { path: 'vue/primitives', slug: 'primitives', kind: 'components', group: 'vue' },
// ── configs ── // ── configs ──
{ path: 'configs/eslint', slug: 'eslint', kind: 'guide', group: 'configs', guideSources: ['README.md', 'rules/*.md'] }, { 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'] }, { 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 ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
function toKebabCase(str: string): string { function toKebabCase(str: string): string {
@@ -716,14 +737,14 @@ function inferCategoryFromItem(item: ItemMeta): string {
} }
/** Resolve a package's export subpaths to source entry files. */ /** 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 }> = []; const entryPoints: Array<{ subpath: string; filePath: string }> = [];
for (const [subpath, value] of Object.entries(exportsField)) { for (const [subpath, value] of Object.entries(exportsField)) {
if (typeof value !== 'object' || value === null) continue; if (typeof value !== 'object' || value === null) continue;
let entry: any = (value as Record<string, any>).import ?? (value as Record<string, any>).types; let entry: unknown = (value as Record<string, unknown>).import ?? (value as Record<string, unknown>).types;
if (typeof entry === 'object' && entry !== null) entry = entry.types || entry.default; 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; if (!entry || typeof entry !== 'string') continue;
// Wildcard exports (e.g. "./*") can't be resolved to a single file here. // Wildcard exports (e.g. "./*") can't be resolved to a single file here.
if (entry.includes('*')) continue; if (entry.includes('*')) continue;
@@ -942,75 +963,100 @@ function roleFromName(componentName: string, base: string): string {
return role || 'Root'; return role || 'Root';
} }
/**
* Build a single component group from its directory, or `null` when the dir is
* not a component group (no `.vue`). `category` is the display label; `entryPoint`
* is the package subpath (e.g. `./forms/checkbox`).
*/
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string): ComponentMeta | null {
// A component group is any dir that ships at least one .vue file.
const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
if (vueFiles.length === 0) return null;
const base = toPascalCase(slug);
// Anatomy = the PUBLIC parts exported from index.ts, in declared order. This
// excludes demo.vue and internal parts (*Impl, *Modal/NonModal, *Position, …)
// that aren't part of the public API. Fall back to all .vue (minus demo) only
// when the barrel exposes no parseable `export { default as X }`.
const order = readPartOrder(resolve(dir, 'index.ts'));
const publicFiles = order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f));
const candidates = publicFiles.length > 0
? publicFiles
: vueFiles.filter(f => f !== 'demo.vue');
// Drop internal implementation/variant parts users never compose directly
// (the public part is e.g. `Content`, not `ContentImpl`/`ContentModal`).
const INTERNAL_PART = /(?:Impl|ContentModal|ContentNonModal|RootContentModal|RootContentNonModal|Position)\.vue$/;
const orderedFiles = candidates.filter(f => !INTERNAL_PART.test(f));
const parts: ComponentPartMeta[] = [];
let groupDescription = '';
for (const file of orderedFiles) {
const sfc = readFileSync(resolve(dir, file), 'utf-8');
const plain = extractScriptBlock(sfc, false);
const setup = extractScriptBlock(sfc, true);
const { props, description } = extractPartProps(plain);
const name = file.replace(/\.vue$/, '');
const role = roleFromName(name, base);
if (role === 'Root' && description && !groupDescription) groupDescription = description;
// Merge in `defineModel` v-model props/emits (invisible to the interface/
// defineEmits parsers), de-duping against any explicitly-declared ones.
const models = extractModels(setup);
const emits = extractEmits(setup);
for (const mp of models.props)
if (!props.some(p => p.name === mp.name)) props.push(mp);
for (const me of models.emits)
if (!emits.some(e => e.name === me.name)) emits.push(me);
parts.push({ name, role, description, props, emits });
}
return {
name: base,
slug,
category,
description: groupDescription,
entryPoint,
parts,
hasDemo: existsSync(resolve(dir, 'demo.vue')),
demoSource: '', // loaded lazily client-side via #docs/demo-sources
sourcePath: relative(ROOT, dir),
};
}
function buildComponents(pkgDir: string): ComponentMeta[] { function buildComponents(pkgDir: string): ComponentMeta[] {
const srcDir = resolve(pkgDir, 'src'); const srcDir = resolve(pkgDir, 'src');
if (!existsSync(srcDir)) return []; if (!existsSync(srcDir)) return [];
const components: ComponentMeta[] = []; const components: ComponentMeta[] = [];
for (const entry of readdirSync(srcDir, { withFileTypes: true })) { // Components live one level deep, in category folders: src/<category>/<component>/.
if (!entry.isDirectory()) continue; // The category folder IS the source of truth for the component's category.
const dir = resolve(srcDir, entry.name); for (const catEntry of readdirSync(srcDir, { withFileTypes: true })) {
if (!catEntry.isDirectory()) continue;
const catDir = resolve(srcDir, catEntry.name);
const label = CATEGORY_LABELS[catEntry.name];
// A component group is any dir that ships at least one .vue file. if (label) {
const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue')); // A known category folder — each child dir is a component group.
if (vueFiles.length === 0) continue; for (const compEntry of readdirSync(catDir, { withFileTypes: true })) {
if (!compEntry.isDirectory()) continue;
const slug = entry.name; const c = buildComponentAt(
const base = toPascalCase(slug); resolve(catDir, compEntry.name),
compEntry.name,
// Anatomy = the PUBLIC parts exported from index.ts, in declared order. This label,
// excludes demo.vue and internal parts (*Impl, *Modal/NonModal, *Position, …) `./${catEntry.name}/${compEntry.name}`,
// that aren't part of the public API. Fall back to all .vue (minus demo) only );
// when the barrel exposes no parseable `export { default as X }`. if (c) components.push(c);
const order = readPartOrder(resolve(dir, 'index.ts')); }
const publicFiles = order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f)); }
const candidates = publicFiles.length > 0 else {
? publicFiles // Backward-compat: a flat component dir directly under src.
: vueFiles.filter(f => f !== 'demo.vue'); const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`);
// Drop internal implementation/variant parts users never compose directly if (c) components.push(c);
// (the public part is e.g. `Content`, not `ContentImpl`/`ContentModal`).
const INTERNAL_PART = /(?:Impl|ContentModal|ContentNonModal|RootContentModal|RootContentNonModal|Position)\.vue$/;
const orderedFiles = candidates.filter(f => !INTERNAL_PART.test(f));
const parts: ComponentPartMeta[] = [];
let groupDescription = '';
for (const file of orderedFiles) {
const sfc = readFileSync(resolve(dir, file), 'utf-8');
const plain = extractScriptBlock(sfc, false);
const setup = extractScriptBlock(sfc, true);
const { props, description } = extractPartProps(plain);
const name = file.replace(/\.vue$/, '');
const role = roleFromName(name, base);
if (role === 'Root' && description && !groupDescription) groupDescription = description;
// Merge in `defineModel` v-model props/emits (invisible to the interface/
// defineEmits parsers), de-duping against any explicitly-declared ones.
const models = extractModels(setup);
const emits = extractEmits(setup);
for (const mp of models.props)
if (!props.some(p => p.name === mp.name)) props.push(mp);
for (const me of models.emits)
if (!emits.some(e => e.name === me.name)) emits.push(me);
parts.push({ name, role, description, props, emits });
} }
const entryPoint = `./${slug}`;
const demoPath = resolve(dir, 'demo.vue');
const hasDemo = existsSync(demoPath);
components.push({
name: base,
slug,
description: groupDescription,
entryPoint,
parts,
hasDemo,
demoSource: '', // loaded lazily client-side via #docs/demo-sources
sourcePath: relative(ROOT, dir),
});
} }
return components.sort((a, b) => a.name.localeCompare(b.name)); 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/fetch': 'core/fetch/src',
'@robonen/encoding': 'core/encoding/src', '@robonen/encoding': 'core/encoding/src',
'@robonen/crdt': 'core/crdt/src', '@robonen/crdt': 'core/crdt/src',
'@robonen/editor': 'vue/editor/src', '@robonen/writekit': 'vue/writekit/src',
'@robonen/primitives': 'vue/primitives/src', '@robonen/primitives': 'vue/primitives/src',
'@robonen/vue': vueSrc, '@robonen/vue': vueSrc,
}; };
@@ -58,7 +58,13 @@ export default defineNuxtModule({
// Primitive `as="template"` / Slot path), silently blanking every demo // Primitive `as="template"` / Slot path), silently blanking every demo
// that hits it. `import.meta.env.DEV` resolves correctly in dev & prod. // that hits it. `import.meta.env.DEV` resolves correctly in dev & prod.
config.define ??= {}; 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 existing = config.resolve?.alias;
const sourceAliases = [ const sourceAliases = [
+2
View File
@@ -115,6 +115,8 @@ export interface ComponentMeta {
name: string; name: string;
/** URL-friendly slug, e.g. "accordion" */ /** URL-friendly slug, e.g. "accordion" */
slug: string; slug: string;
/** Functional category for grouping in the docs, e.g. "Forms", "Overlays". */
category: string;
/** Short description (from README heading or first JSDoc) */ /** Short description (from README heading or first JSDoc) */
description: string; description: string;
/** Subpath export, e.g. "./accordion" */ /** Subpath export, e.g. "./accordion" */
+7 -7
View File
@@ -159,15 +159,15 @@ describe('getPackage / resolveEntry', () => {
describe('slug uniqueness & collisions', () => { describe('slug uniqueness & collisions', () => {
// A function and a co-located type/interface whose names differ only in case // 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 // 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 = { const colliding: DocsMetadata = {
generatedAt: '2026-06-08T00:00:00.000Z', generatedAt: '2026-06-08T00:00:00.000Z',
packages: [ packages: [
{ {
name: '@robonen/editor', name: '@robonen/writekit',
version: '1.0.0', version: '1.0.0',
description: 'Editor', description: 'Writekit',
slug: 'editor', slug: 'writekit',
kind: 'api', kind: 'api',
group: 'vue', group: 'vue',
entryPoints: ['.'], entryPoints: ['.'],
@@ -197,12 +197,12 @@ describe('slug uniqueness & collisions', () => {
it('reaches both colliding symbols — function and interface — independently', () => { it('reaches both colliding symbols — function and interface — independently', () => {
const leaves = buildLeaves(colliding); const leaves = buildLeaves(colliding);
// Exact case-sensitive name disambiguates the function from the interface. // Exact case-sensitive name disambiguates the function from the interface.
const fn = resolveEntry(leaves, 'editor', 'position'); const fn = resolveEntry(leaves, 'writekit', 'position');
const iface = resolveEntry(leaves, 'editor', 'Position'); const iface = resolveEntry(leaves, 'writekit', 'Position');
expect(fn?.kind === 'api' && fn.item.kind).toBe('function'); expect(fn?.kind === 'api' && fn.item.kind).toBe('function');
expect(iface?.kind === 'api' && iface.item.kind).toBe('interface'); expect(iface?.kind === 'api' && iface.item.kind).toBe('interface');
// The disambiguated slug also resolves the interface directly. // 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'); expect(bySlug?.kind === 'api' && bySlug.item.kind).toBe('interface');
}); });
+2
View File
@@ -20,6 +20,8 @@ export default defineNuxtConfig({
vite: { vite: {
plugins: [ plugins: [
// `as any`: @tailwindcss/vite and Nuxt resolve different `vite` versions, so
// their `Plugin` types are structurally identical but nominally incompatible.
tailwindcss() as any, tailwindcss() as any,
], ],
}, },
+3 -2
View File
@@ -10,8 +10,9 @@
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "vitest run", "test": "vitest run",
"dev": "nuxt dev", "dev": "nuxt dev",
"build": "nuxt build", "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",
"generate": "nuxt generate", "build": "pnpm run build:deps && nuxt build",
"generate": "pnpm run build:deps && nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"extract": "jiti ./modules/extractor/extract.ts" "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 specifier: ^43.216.1
version: 43.216.1(typanion@3.14.0) 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: vue/primitives:
dependencies: dependencies:
'@floating-ui/vue': '@floating-ui/vue':
@@ -548,6 +465,92 @@ importers:
specifier: 'catalog:' 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)) 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: packages:
'@adobe/css-tools@4.4.4': '@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, { export default compose(base, typescript, imports, stylistic, {
name: 'stories/overrides', name: 'stories/overrides',
@@ -6,4 +6,4 @@ export default compose(base, typescript, imports, stylistic, {
rules: { rules: {
'@stylistic/no-multiple-empty-lines': 'off', '@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 --> <!-- Feature highlights -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <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"> <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 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. doesn't need your bundle stays exactly as small as your usage.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <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"> <p class="text-sm text-fg-muted leading-relaxed">
Browser-only access is guarded behind lifecycle hooks and configurable Browser-only access is guarded behind lifecycle hooks and configurable
<code>window</code>/<code>document</code> targets, so Nuxt and SSR setups just work. <code>window</code>/<code>document</code> targets, so Nuxt and SSR setups just work.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <h3 class="text-sm font-semibold text-fg mb-1.5">Fully typed</h3>
<p class="text-sm text-(--fg-muted) leading-relaxed"> <p class="text-sm text-fg-muted leading-relaxed">
Written in TypeScript with precise return types and generics. <code>MaybeRefOrGetter</code> Written in TypeScript with precise return types and generics. <code>MaybeRefOrGetter</code>
arguments mean you can pass plain values, refs or getters interchangeably. arguments mean you can pass plain values, refs or getters interchangeably.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <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> <h3 class="text-sm font-semibold text-fg mb-1.5">Broad coverage</h3>
<p class="text-sm text-(--fg-muted) leading-relaxed"> <p class="text-sm text-fg-muted leading-relaxed">
From state and reactivity to sensors, elements, storage, math and form handling From state and reactivity to sensors, elements, storage, math and form handling
one cohesive toolkit spanning the whole surface of a Vue app. one cohesive toolkit spanning the whole surface of a Vue app.
</p> </p>
@@ -101,19 +101,19 @@ useEventListener('keydown', (e) => {
<p>The same <code>useCounter</code> running live:</p> <p>The same <code>useCounter</code> running live:</p>
</div> </div>
<ClientOnly> <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 <button
type="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" :disabled="count <= 0"
@click="decrement()" @click="decrement()"
> >
</button> </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 <button
type="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" :disabled="count >= 10"
@click="increment()" @click="increment()"
> >
@@ -121,7 +121,7 @@ useEventListener('keydown', (e) => {
</button> </button>
<button <button
type="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()" @click="reset()"
> >
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 'running': return 'bg-emerald-500';
case 'paused': return 'bg-amber-500'; case 'paused': return 'bg-amber-500';
case 'finished': return 'bg-sky-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> </script>
<template> <template>
<div class="flex w-full max-w-sm flex-col gap-4"> <div class="demo-stack max-w-sm">
<div <div
v-if="!isSupported" 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" 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> </div>
<template v-else> <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 <div
ref="target" ref="target"
class="size-12 bg-(--accent) shadow-lg" class="size-12 bg-accent shadow-lg"
/> />
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-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="demo-label">
State State
</div> </div>
<div class="mt-1 flex items-center gap-2"> <div class="mt-1 flex items-center gap-2">
<span class="inline-block size-2 rounded-full transition" :class="stateColor" /> <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> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-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="demo-label">
Current time Current time
</div> </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 }} {{ elapsed }}
</div> </div>
</div> </div>
@@ -86,31 +86,31 @@ const rates = [0.5, 1, 2] as const;
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-2">
<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" class="demo-btn-primary"
@click="play" @click="play"
> >
Play Play
</button> </button>
<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" @click="pause"
> >
Pause Pause
</button> </button>
<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" @click="reverse"
> >
Reverse Reverse
</button> </button>
<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" @click="finish"
> >
Finish Finish
</button> </button>
<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" @click="cancel"
> >
Cancel Cancel
@@ -118,7 +118,7 @@ const rates = [0.5, 1, 2] as const;
</div> </div>
<div class="flex flex-col gap-2"> <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 Playback rate
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -127,8 +127,8 @@ const rates = [0.5, 1, 2] as const;
:key="rate" :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="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 :class="playbackRate === rate
? 'border-transparent bg-(--accent) text-(--accent-fg)' ? 'border-transparent bg-accent text-accent-fg'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'" : 'border-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="playbackRate = rate" @click="playbackRate = rate"
> >
{{ rate }}× {{ rate }}×
@@ -37,9 +37,9 @@ function toggle() {
</script> </script>
<template> <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 border-(--border) bg-(--bg-elevated) p-5 text-center"> <div class="demo-card p-5 text-center">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"> <div class="demo-label">
Time remaining Time remaining
</div> </div>
<div <div
@@ -48,22 +48,22 @@ function toggle() {
? 'text-emerald-600 dark:text-emerald-400' ? 'text-emerald-600 dark:text-emerald-400'
: remaining <= 10 && remaining > 0 : remaining <= 10 && remaining > 0
? 'text-amber-600 dark:text-amber-400' ? 'text-amber-600 dark:text-amber-400'
: 'text-(--fg)'" : 'text-fg'"
> >
{{ minutes }}:{{ seconds }} {{ minutes }}:{{ seconds }}
</div> </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 <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}%` }" :style="{ width: `${progress * 100}%` }"
/> />
</div> </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 <span
class="inline-block size-2 rounded-full transition" 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' }} {{ justFinished ? 'Completed' : isActive ? 'Counting down' : 'Paused' }}
</div> </div>
@@ -73,7 +73,7 @@ function toggle() {
<button <button
v-for="preset in presets" v-for="preset in presets"
:key="preset" :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)" @click="setPreset(preset)"
> >
{{ preset < 60 ? `${preset}s` : `${preset / 60}m` }} {{ preset < 60 ? `${preset}s` : `${preset / 60}m` }}
@@ -82,20 +82,20 @@ function toggle() {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <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" :disabled="remaining === 0 && isActive"
@click="toggle" @click="toggle"
> >
{{ isActive ? 'Pause' : 'Resume' }} {{ isActive ? 'Pause' : 'Resume' }}
</button> </button>
<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()" @click="start()"
> >
Restart Restart
</button> </button>
<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" @click="stop"
> >
Stop Stop
@@ -27,46 +27,46 @@ const isValid = computed(() => formatted.value !== 'Invalid Date');
</script> </script>
<template> <template>
<div class="flex w-full max-w-md flex-col gap-4"> <div class="demo-stack max-w-md">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4"> <div class="demo-card p-4">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"> <div class="demo-label">
Formatted output Formatted output
</div> </div>
<div <div
class="mt-2 font-mono text-lg font-semibold tabular-nums" 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 }} {{ formatted }}
</div> </div>
</div> </div>
<div class="flex flex-col gap-1.5"> <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 Date input
</label> </label>
<input <input
v-model="date" v-model="date"
type="datetime-local" 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>
<div class="flex flex-col gap-1.5"> <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 Format token string
</label> </label>
<input <input
v-model="format" v-model="format"
type="text" type="text"
spellcheck="false" 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"> <div class="flex flex-wrap gap-1.5 pt-1">
<button <button
v-for="f in formats" v-for="f in formats"
:key="f" :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="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="{ 'border-accent text-accent-text': format === f }"
@click="format = f" @click="format = f"
> >
{{ f }} {{ f }}
@@ -75,7 +75,7 @@ const isValid = computed(() => formatted.value !== 'Invalid Date');
</div> </div>
<div class="flex flex-col gap-1.5"> <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 Locale
</label> </label>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@@ -84,8 +84,8 @@ const isValid = computed(() => formatted.value !== 'Invalid Date');
:key="loc.value" :key="loc.value"
class="rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer" class="rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="locale === loc.value :class="locale === loc.value
? 'border-transparent bg-(--accent) text-(--accent-fg)' ? 'border-transparent bg-accent text-accent-fg'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'" : 'border-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="locale = loc.value" @click="locale = loc.value"
> >
{{ loc.label }} {{ loc.label }}
@@ -52,6 +52,7 @@ const REGEX_FORMAT
// `20240101`); JS lacks possessive quantifiers to disambiguate it. // `20240101`); JS lacks possessive quantifiers to disambiguate it.
// eslint-disable-next-line regexp/no-misleading-capturing-group // 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_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; const ORDINAL_SUFFIXES = ['th', 'st', 'nd', 'rd'] as const;
@@ -82,7 +83,7 @@ function formatOrdinal(num: number): string {
export function normalizeDate(date: DateLike): Date { export function normalizeDate(date: DateLike): Date {
if (date === null || date === undefined) return new Date(); if (date === null || date === undefined) return new Date();
if (isDate(date)) return new Date(date.getTime()); 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); const d = REGEX_PARSE.exec(date);
if (d) { if (d) {
const month = d[2] ? Number(d[2]) - 1 : 0; const month = d[2] ? Number(d[2]) - 1 : 0;
@@ -27,12 +27,12 @@ function toggle() {
</script> </script>
<template> <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 border-(--border) bg-(--bg-elevated) p-5 text-center"> <div class="demo-card p-5 text-center">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"> <div class="demo-label">
Ticks elapsed Ticks elapsed
</div> </div>
<div class="mt-2 font-mono text-5xl font-bold tabular-nums text-(--fg)"> <div class="demo-stat mt-2 text-5xl">
{{ counter }} {{ counter }}
</div> </div>
@@ -41,21 +41,21 @@ function toggle() {
v-for="(on, i) in beats" v-for="(on, i) in beats"
:key="i" :key="i"
class="size-2.5 rounded-full transition-colors duration-200" 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>
<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 <span
class="inline-block size-2 rounded-full transition" 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' }} {{ isActive ? `Ticking every ${interval}ms` : 'Paused' }}
</div> </div>
</div> </div>
<div class="flex flex-col gap-1.5"> <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 Interval speed
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -64,8 +64,8 @@ function toggle() {
:key="speed.value" :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="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 :class="interval === speed.value
? 'border-transparent bg-(--accent) text-(--accent-fg)' ? 'border-transparent bg-accent text-accent-fg'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'" : 'border-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="interval = speed.value" @click="interval = speed.value"
> >
{{ speed.label }} {{ speed.label }}
@@ -75,13 +75,13 @@ function toggle() {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <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" @click="toggle"
> >
{{ isActive ? 'Pause' : 'Resume' }} {{ isActive ? 'Pause' : 'Resume' }}
</button> </button>
<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" :disabled="counter === 0"
@click="reset" @click="reset"
> >
@@ -31,22 +31,22 @@ function clear() {
</script> </script>
<template> <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 rounded-xl border border-(--border) bg-(--bg-elevated) p-4"> <div class="demo-card flex items-center justify-between p-4">
<div> <div>
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"> <div class="demo-label">
Interval callback Interval callback
</div> </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 <span
class="inline-block size-2 rounded-full transition" 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' }} {{ isActive ? `Firing every ${interval}ms` : 'Stopped' }}
</div> </div>
</div> </div>
<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" class="demo-btn-primary"
@click="toggle" @click="toggle"
> >
{{ isActive ? 'Pause' : 'Start' }} {{ isActive ? 'Pause' : 'Start' }}
@@ -54,7 +54,7 @@ function clear() {
</div> </div>
<div class="flex flex-col gap-1.5"> <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 Interval
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -63,24 +63,24 @@ function clear() {
:key="speed.value" :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="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 :class="interval === speed.value
? 'border-transparent bg-(--accent) text-(--accent-fg)' ? 'border-transparent bg-accent text-accent-fg'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'" : 'border-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="interval = speed.value" @click="interval = speed.value"
> >
{{ speed.label }} {{ speed.label }}
</button> </button>
</div> </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. Changing the interval while running restarts the timer with the new duration.
</p> </p>
</div> </div>
<div class="flex items-center justify-between"> <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 Tick log
</div> </div>
<button <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" :disabled="logs.length === 0"
@click="clear" @click="clear"
> >
@@ -88,17 +88,17 @@ function clear() {
</button> </button>
</div> </div>
<div class="min-h-32 rounded-lg border border-(--border) bg-(--bg-inset) p-3"> <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)"> <p v-if="logs.length === 0" class="py-6 text-center text-sm text-fg-subtle">
No ticks yet press Start. No ticks yet press Start.
</p> </p>
<ul v-else class="flex flex-col gap-1.5"> <ul v-else class="flex flex-col gap-1.5">
<li <li
v-for="log in logs" v-for="log in logs"
:key="log.id" :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 }} {{ log.time }}
</li> </li>
</ul> </ul>
@@ -106,14 +106,14 @@ function clear() {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<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" :disabled="isActive"
@click="resume" @click="resume"
> >
Resume Resume
</button> </button>
<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" :disabled="!isActive"
@click="pause" @click="pause"
> >
@@ -20,23 +20,23 @@ const secondAngle = computed(() => {
</script> </script>
<template> <template>
<div class="w-full max-w-sm flex flex-col gap-4"> <div class="demo-stack max-w-sm">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col items-center gap-3"> <div class="demo-card 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-label">Reactive now</div>
<div class="flex items-baseline gap-1"> <div class="flex items-baseline gap-1">
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ time }}</span> <span class="demo-stat text-3xl">{{ time }}</span>
<span class="font-mono text-lg font-semibold tabular-nums text-(--fg-subtle)">.{{ millis }}</span> <span class="font-mono text-lg font-semibold tabular-nums text-fg-subtle">.{{ millis }}</span>
</div> </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="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>
<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)` }" :style="{ transform: `translateX(-50%) rotate(${secondAngle}deg)` }"
/> />
</div> </div>
@@ -44,11 +44,11 @@ const secondAngle = computed(() => {
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<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 <span
class="size-1.5 rounded-full transition" 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' }} {{ isActive ? 'Ticking (RAF)' : 'Paused' }}
</span> </span>
@@ -56,7 +56,7 @@ const secondAngle = computed(() => {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
type="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" @click="toggle"
> >
{{ isActive ? 'Pause' : 'Resume' }} {{ isActive ? 'Pause' : 'Resume' }}
@@ -64,7 +64,7 @@ const secondAngle = computed(() => {
<button <button
type="button" type="button"
:disabled="isActive" :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" @click="resume"
> >
Resume Resume
@@ -72,7 +72,7 @@ const secondAngle = computed(() => {
<button <button
type="button" type="button"
:disabled="!isActive" :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" @click="pause"
> >
Pause Pause
@@ -35,46 +35,46 @@ const limitLabel = computed(() => (fpsLimit.value === 0 ? 'Unlimited' : `${fpsLi
</script> </script>
<template> <template>
<div class="w-full max-w-sm flex flex-col gap-4"> <div class="demo-stack max-w-sm">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col gap-4"> <div class="demo-card p-4 flex flex-col gap-4">
<div class="flex items-center justify-between"> <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 <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' }} {{ isActive ? 'Running' : 'Paused' }}
</span> </span>
</div> </div>
<!-- The animated track: marker position is updated every frame --> <!-- 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 <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}%` }" :style="{ left: `${position}%` }"
/> />
</div> </div>
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-2">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-2 text-center"> <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="demo-stat text-lg">{{ fps }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">fps</div> <div class="text-[10px] uppercase tracking-wide text-fg-subtle">fps</div>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-2 text-center"> <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="demo-stat text-lg">{{ delta.toFixed(1) }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">delta ms</div> <div class="text-[10px] uppercase tracking-wide text-fg-subtle">delta ms</div>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-2 text-center"> <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="demo-stat text-lg">{{ frames }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">frames</div> <div class="text-[10px] uppercase tracking-wide text-fg-subtle">frames</div>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex items-center justify-between"> <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> <label class="demo-label" for="fps-limit">FPS limit</label>
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ limitLabel }}</span> <span class="font-mono text-xs tabular-nums text-fg-muted">{{ limitLabel }}</span>
</div> </div>
<input <input
id="fps-limit" id="fps-limit"
@@ -83,14 +83,14 @@ const limitLabel = computed(() => (fpsLimit.value === 0 ? 'Unlimited' : `${fpsLi
min="0" min="0"
max="60" max="60"
step="5" 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> </div>
<button <button
type="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" @click="toggle"
> >
{{ isActive ? 'Pause loop' : 'Resume loop' }} {{ isActive ? 'Pause loop' : 'Resume loop' }}
@@ -42,15 +42,15 @@ const absolute = computed(() =>
</script> </script>
<template> <template>
<div class="w-full max-w-sm flex flex-col gap-4"> <div class="demo-stack max-w-sm">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col items-center gap-2"> <div class="demo-card 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="demo-label">Relative time</span>
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg) text-center">{{ timeAgo }}</span> <span class="demo-stat text-3xl text-center">{{ timeAgo }}</span>
<span class="text-xs text-(--fg-muted)">{{ absolute }}</span> <span class="text-xs text-fg-muted">{{ absolute }}</span>
</div> </div>
<div class="flex flex-col gap-2"> <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"> <div class="grid grid-cols-2 gap-2">
<button <button
v-for="preset in presets" v-for="preset in presets"
@@ -58,8 +58,8 @@ const absolute = computed(() =>
type="button" 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="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 :class="offset === preset.offset
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)' ? '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-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="offset = preset.offset" @click="offset = preset.offset"
> >
{{ preset.label }} {{ preset.label }}
@@ -69,14 +69,14 @@ const absolute = computed(() =>
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<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 ? 'Updating every 1s' : 'Updates paused' }} {{ isActive ? 'Updating every 1s' : 'Updates paused' }}
</span> </span>
<button <button
type="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" @click="toggle"
> >
{{ isActive ? 'Pause' : 'Resume' }} {{ isActive ? 'Pause' : 'Resume' }}
@@ -165,10 +165,12 @@ const DEFAULT_UNITS: Array<UseTimeAgoUnit<UseTimeAgoUnitName>> = [
{ max: Number.POSITIVE_INFINITY, value: 31536000000, name: 'year' }, { max: Number.POSITIVE_INFINITY, value: 31536000000, name: 'year' },
]; ];
const REGEX_DIGIT = /* #__PURE__ */ /\d/;
const DEFAULT_MESSAGES: UseTimeAgoMessages<UseTimeAgoUnitName> = { const DEFAULT_MESSAGES: UseTimeAgoMessages<UseTimeAgoUnitName> = {
justNow: 'just now', justNow: 'just now',
past: n => /\d/.test(n) ? `${n} ago` : n, past: n => REGEX_DIGIT.test(n) ? `${n} ago` : n,
future: n => /\d/.test(n) ? `in ${n}` : 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' : ''}`, 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' : ''}`, 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' : ''}`, day: (n, past) => n === 1 ? (past ? 'yesterday' : 'tomorrow') : `${n} day${n > 1 ? 's' : ''}`,
@@ -24,21 +24,21 @@ function cancel() {
</script> </script>
<template> <template>
<div class="w-full max-w-sm flex flex-col gap-4"> <div class="demo-stack max-w-sm">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col items-center gap-3"> <div class="demo-card p-4 flex flex-col items-center gap-3">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Status</span> <span class="demo-label">Status</span>
<div <div
class="flex size-20 items-center justify-center rounded-full border-2 transition" class="flex size-20 items-center justify-center rounded-full border-2 transition"
:class="ready :class="ready
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' ? '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> <span class="text-sm font-semibold">{{ ready ? 'Ready' : 'Pending' }}</span>
</div> </div>
<p class="text-center text-sm text-(--fg-muted)"> <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-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-if="ready">Idle start the timer below</template>
<template v-else>Counting down stays pending until the delay elapses</template> <template v-else>Counting down stays pending until the delay elapses</template>
</p> </p>
@@ -46,8 +46,8 @@ function cancel() {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="delay">Delay</label> <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> <span class="font-mono text-xs tabular-nums text-fg-muted">{{ (delay / 1000).toFixed(1) }}s</span>
</div> </div>
<input <input
id="delay" id="delay"
@@ -56,14 +56,14 @@ function cancel() {
min="500" min="500"
max="5000" max="5000"
step="500" step="500"
class="w-full accent-(--accent) cursor-pointer" class="w-full accent-accent cursor-pointer"
> >
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
type="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" @click="restart"
> >
{{ ready ? 'Start' : 'Restart' }} {{ ready ? 'Start' : 'Restart' }}
@@ -71,7 +71,7 @@ function cancel() {
<button <button
type="button" type="button"
:disabled="ready" :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" @click="cancel"
> >
Cancel Cancel
@@ -42,23 +42,23 @@ function undo() {
<template> <template>
<div class="w-full max-w-sm flex flex-col gap-3"> <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"> <ul v-if="inbox.length" class="flex flex-col gap-2">
<li <li
v-for="mail in inbox" v-for="mail in inbox"
:key="mail.id" :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 }" :class="{ 'opacity-40': pendingDelete?.id === mail.id }"
> >
<div class="min-w-0"> <div class="min-w-0">
<div class="truncate text-sm font-medium text-(--fg)">{{ mail.subject }}</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 class="truncate text-xs text-fg-muted">{{ mail.from }}</div>
</div> </div>
<button <button
type="button" type="button"
:disabled="isPending" :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)" @click="archive(mail)"
> >
Archive Archive
@@ -66,7 +66,7 @@ function undo() {
</li> </li>
</ul> </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. Inbox zero everything archived.
</div> </div>
@@ -21,7 +21,7 @@ export interface UseTimeoutFnOptions {
immediateCallback?: boolean; immediateCallback?: boolean;
} }
export interface UseTimeoutFnReturn<Args extends any[]> { export interface UseTimeoutFnReturn<Args extends unknown[]> {
/** /**
* Whether the timeout is currently pending * Whether the timeout is currently pending
*/ */
@@ -45,33 +45,33 @@ function resetOffset() {
</script> </script>
<template> <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 border-(--border) bg-(--bg-elevated) p-4 text-center"> <div class="demo-card p-4 text-center">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"> <div class="demo-label">
Reactive timestamp Reactive timestamp
</div> </div>
<div class="mt-2 font-mono text-3xl font-bold tabular-nums text-(--fg)"> <div class="demo-stat mt-2 text-3xl">
{{ clockTime }} {{ clockTime }}
</div> </div>
<div class="mt-1 text-sm text-(--fg-muted)"> <div class="mt-1 text-sm text-fg-muted">
{{ clockDate }} {{ clockDate }}
</div> </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 <span
class="inline-block size-2 rounded-full transition" 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' }} {{ isActive ? 'Updating every second' : 'Paused' }}
</div> </div>
</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 {{ Math.round(timestamp) }} ms
</div> </div>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<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" class="demo-btn-primary"
@click="toggle" @click="toggle"
> >
{{ isActive ? 'Pause' : 'Resume' }} {{ isActive ? 'Pause' : 'Resume' }}
@@ -79,13 +79,13 @@ function resetOffset() {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<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)" @click="shift(-3600_000)"
> >
-1h -1h
</button> </button>
<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)" @click="shift(3600_000)"
> >
+1h +1h
@@ -93,13 +93,13 @@ function resetOffset() {
</div> </div>
</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> <span>
Offset: 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> </span>
<button <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" :disabled="offset === 0"
@click="resetOffset" @click="resetOffset"
> >
@@ -40,19 +40,19 @@ function randomize() {
<template> <template>
<div class="flex w-full max-w-md flex-col gap-5"> <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"> <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 Eased value
</span> </span>
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)"> <span class="demo-stat text-3xl">
{{ value.toFixed(1) }} {{ value.toFixed(1) }}
</span> </span>
</div> </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 <div
class="h-full rounded-full bg-(--accent)" class="h-full rounded-full bg-accent"
:style="{ width: `${Math.max(0, Math.min(100, value))}%` }" :style="{ width: `${Math.max(0, Math.min(100, value))}%` }"
/> />
</div> </div>
@@ -63,10 +63,10 @@ function randomize() {
type="range" type="range"
min="0" min="0"
max="100" max="100"
class="h-1.5 flex-1 cursor-pointer accent-(--accent)" class="h-1.5 flex-1 cursor-pointer accent-accent"
> >
<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="randomize" @click="randomize"
> >
Random Random
@@ -75,21 +75,21 @@ function randomize() {
</div> </div>
<div class="flex flex-col gap-2"> <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 Easing preset
</label> </label>
<select <select
v-model="preset" 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"> <option v-for="name in presetNames" :key="name" :value="name">
{{ name }} {{ name }}
</option> </option>
</select> </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>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> </label>
<input <input
v-model.number="duration" v-model.number="duration"
@@ -97,21 +97,21 @@ function randomize() {
min="100" min="100"
max="2000" max="2000"
step="100" step="100"
class="h-1.5 w-full cursor-pointer accent-(--accent)" class="h-1.5 w-full cursor-pointer accent-accent"
> >
</div> </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="flex items-center gap-3">
<div <div
class="size-12 shrink-0 rounded-lg border border-(--border)" class="size-12 shrink-0 rounded-lg border border-border"
:style="{ backgroundColor: colorCss }" :style="{ backgroundColor: colorCss }"
/> />
<div class="min-w-0"> <div class="min-w-0">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"> <div class="demo-label">
Animated tuple Animated tuple
</div> </div>
<div class="font-mono text-sm text-(--fg) tabular-nums"> <div class="font-mono text-sm text-fg tabular-nums">
{{ colorCss }} {{ colorCss }}
</div> </div>
</div> </div>
@@ -121,7 +121,7 @@ function randomize() {
<button <button
v-for="[label, rgb] in swatches" v-for="[label, rgb] in swatches"
:key="label" :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]" @click="colorTarget = [...rgb]"
> >
<span class="size-2.5 rounded-full" :style="{ backgroundColor: `rgb(${rgb.join(',')})` }" /> <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 { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types'; import type { ConfigurableWindow } from '@/types';
import { useRafFn } from '@/composables/animation/useRafFn'; import { useRafFn } from '@/composables/animation/useRafFn';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
/** /**
* Cubic bezier control points `[x1, y1, x2, y2]` (the implied endpoints are * 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); return computed(() => outputRef.value);
} }
@@ -40,16 +40,16 @@ function toggle(track: Track) {
</script> </script>
<template> <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"> <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 Library tap to add / remove from playlist
</span> </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 <input
v-model="symmetric" v-model="symmetric"
type="checkbox" type="checkbox"
class="size-4 cursor-pointer accent-(--accent)" class="size-4 cursor-pointer accent-accent"
> >
Symmetric Symmetric
</label> </label>
@@ -61,8 +61,8 @@ function toggle(track: Track) {
:key="track.id" :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="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) :class="inPlaylist(track)
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)' ? '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-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="toggle(track)" @click="toggle(track)"
> >
<span class="truncate">{{ track.title }}</span> <span class="truncate">{{ track.title }}</span>
@@ -70,12 +70,12 @@ function toggle(track: Track) {
</button> </button>
</div> </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"> <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' }} {{ symmetric ? 'In exactly one (XOR)' : 'Not in playlist' }}
</span> </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 }} {{ diff.length }}
</span> </span>
</div> </div>
@@ -84,12 +84,12 @@ function toggle(track: Track) {
<li <li
v-for="track in diff" v-for="track in diff"
:key="track.id" :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 }} {{ track.title }}
</li> </li>
</ul> </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. No difference every track matches.
</p> </p>
</div> </div>
@@ -1,6 +1,6 @@
import { computed, toValue } from 'vue'; import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } 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. * Comparator deciding whether two array elements are considered equal.
@@ -24,7 +24,7 @@ export interface UseArrayDifferenceOptions<T> {
comparator?: UseArrayDifferenceComparatorFn<T> | keyof T; comparator?: UseArrayDifferenceComparatorFn<T> | keyof T;
} }
export type UseArrayDifferenceReturn<T = any> export type UseArrayDifferenceReturn<T = unknown>
= ComputedRef<T[]>; = ComputedRef<T[]>;
function isArrayDifferenceOptions<T>(value: unknown): value is UseArrayDifferenceOptions<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. // Resolve the comparator once instead of rebuilding it on every recompute.
let compare: UseArrayDifferenceComparatorFn<T>; 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; const key = resolved as keyof T;
compare = (value, othVal) => value[key] === othVal[key]; compare = (value, othVal) => value[key] === othVal[key];
} }
else if (typeof resolved === 'function') { else if (isFunction(resolved)) {
compare = resolved; compare = resolved;
} }
else { else {
@@ -30,22 +30,22 @@ function reset() {
</script> </script>
<template> <template>
<div class="flex w-full max-w-sm flex-col gap-4"> <div class="demo-stack max-w-sm">
<div <div
class="rounded-xl border p-4 transition" class="rounded-xl border p-4 transition"
:class="allDone :class="allDone
? 'border-emerald-500/30 bg-emerald-500/10' ? '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"> <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 Release readiness
</span> </span>
<span <span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium" class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="allDone :class="allDone
? 'border-emerald-500/30 text-emerald-600 dark:text-emerald-400' ? '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 <span
class="size-2 rounded-full" class="size-2 rounded-full"
@@ -54,7 +54,7 @@ function reset() {
{{ allDone ? 'Ready to ship' : 'Blocked' }} {{ allDone ? 'Ready to ship' : 'Blocked' }}
</span> </span>
</div> </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 {{ completed }} / {{ checklist.length }} complete
</div> </div>
</div> </div>
@@ -62,18 +62,18 @@ function reset() {
<ul class="flex flex-col gap-2"> <ul class="flex flex-col gap-2">
<li v-for="item in checklist" :key="item.id"> <li v-for="item in checklist" :key="item.id">
<button <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)" @click="toggle(item)"
> >
<span <span
class="flex size-5 shrink-0 items-center justify-center rounded-md border text-xs transition" class="flex size-5 shrink-0 items-center justify-center rounded-md border text-xs transition"
:class="item.done :class="item.done
? 'border-transparent bg-(--accent) text-(--accent-fg)' ? 'border-transparent bg-accent text-accent-fg'
: 'border-(--border-strong) text-transparent'" : 'border-border-strong text-transparent'"
> >
</span> </span>
<span :class="item.done ? 'line-through text-(--fg-subtle)' : ''"> <span :class="item.done ? 'line-through text-fg-subtle' : ''">
{{ item.label }} {{ item.label }}
</span> </span>
</button> </button>
@@ -81,7 +81,7 @@ function reset() {
</ul> </ul>
<button <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" @click="reset"
> >
Reset Reset
@@ -35,18 +35,18 @@ const formatted = computed(() =>
</script> </script>
<template> <template>
<div class="flex w-full max-w-md flex-col gap-4"> <div class="demo-stack max-w-md">
<input <input
v-model="query" v-model="query"
type="text" type="text"
placeholder="Search products…" 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"> <div class="demo-card flex flex-col gap-3 p-4">
<label class="flex items-center justify-between text-sm text-(--fg-muted)"> <label class="flex items-center justify-between text-sm text-fg-muted">
<span>Max price</span> <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> </label>
<input <input
v-model.number="maxPrice" v-model.number="maxPrice"
@@ -54,21 +54,21 @@ const formatted = computed(() =>
min="25" min="25"
max="400" max="400"
step="5" 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 <input
v-model="inStockOnly" v-model="inStockOnly"
type="checkbox" type="checkbox"
class="size-4 cursor-pointer accent-(--accent)" class="size-4 cursor-pointer accent-accent"
> >
In stock only In stock only
</label> </label>
</div> </div>
<div class="flex items-center justify-between text-xs"> <div class="flex items-center justify-between text-xs">
<span class="font-medium uppercase tracking-wide text-(--fg-subtle)">Results</span> <span class="font-medium uppercase tracking-wide text-fg-subtle">Results</span>
<span class="font-mono tabular-nums text-(--fg-muted)"> <span class="font-mono tabular-nums text-fg-muted">
{{ formatted.length }} / {{ products.length }} {{ formatted.length }} / {{ products.length }}
</span> </span>
</div> </div>
@@ -77,10 +77,10 @@ const formatted = computed(() =>
<li <li
v-for="product in formatted" v-for="product in formatted"
:key="product.name" :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"> <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 <span
v-if="!product.inStock" 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" 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 Out
</span> </span>
</div> </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> </li>
</ul> </ul>
<div <div
v-else 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. No products match your filters.
</div> </div>
@@ -35,13 +35,13 @@ const matchIndex = computed(() =>
</script> </script>
<template> <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="space-y-3">
<div class="flex items-center justify-between"> <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 Max price
</label> </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> </div>
<input <input
id="maxPrice" id="maxPrice"
@@ -50,28 +50,28 @@ const matchIndex = computed(() =>
min="20" min="20"
max="400" max="400"
step="5" 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)"> <label class="flex cursor-pointer items-center gap-2 text-sm text-fg-muted">
<input v-model="inStockOnly" type="checkbox" class="accent-(--accent)"> <input v-model="inStockOnly" type="checkbox" class="accent-accent">
In stock only In stock only
</label> </label>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3"> <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)"> <p class="demo-label mb-1">
First match First match
</p> </p>
<template v-if="firstMatch"> <template v-if="firstMatch">
<div class="flex items-baseline justify-between"> <div class="flex items-baseline justify-between">
<span class="text-sm font-medium text-(--fg)">{{ firstMatch.name }}</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> <span class="font-mono text-sm tabular-nums text-fg">${{ firstMatch.price }}</span>
</div> </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 }} index {{ matchIndex }} · id {{ firstMatch.id }}
</p> </p>
</template> </template>
<p v-else class="text-sm text-(--fg-subtle)"> <p v-else class="text-sm text-fg-subtle">
No product matches the filters No product matches the filters
</p> </p>
</div> </div>
@@ -82,8 +82,8 @@ const matchIndex = computed(() =>
:key="product.id" :key="product.id"
class="flex items-center justify-between rounded-lg border px-3 py-2 text-sm transition" class="flex items-center justify-between rounded-lg border px-3 py-2 text-sm transition"
:class="product.id === firstMatch?.id :class="product.id === firstMatch?.id
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)' ? 'border-accent bg-accent-subtle text-accent-text'
: 'border-(--border) bg-(--bg-elevated) text-(--fg-muted)'" : 'border-border bg-bg-elevated text-fg-muted'"
> >
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
{{ product.name }} {{ product.name }}
@@ -24,15 +24,15 @@ function toggle(index: number) {
</script> </script>
<template> <template>
<div class="flex w-full max-w-sm flex-col gap-4"> <div class="demo-stack max-w-sm">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3"> <div class="rounded-lg border border-border bg-bg-inset p-3">
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"> <p class="demo-label">
Next pending index Next pending index
</p> </p>
<p class="mt-1 font-mono text-3xl font-bold tabular-nums text-(--fg)"> <p class="demo-stat mt-1 text-3xl">
{{ nextIndex }} {{ nextIndex }}
</p> </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}` }} {{ nextIndex === -1 ? 'All steps complete' : `${steps[nextIndex]!.label}` }}
</p> </p>
</div> </div>
@@ -43,16 +43,16 @@ function toggle(index: number) {
:key="step.label" :key="step.label"
class="flex items-center justify-between rounded-lg border px-3 py-2 text-sm transition" class="flex items-center justify-between rounded-lg border px-3 py-2 text-sm transition"
:class="index === nextIndex :class="index === nextIndex
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)' ? 'border-accent bg-accent-subtle text-accent-text'
: 'border-(--border) bg-(--bg-elevated) text-(--fg-muted)'" : 'border-border bg-bg-elevated text-fg-muted'"
> >
<span class="flex items-center gap-2"> <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 :class="step.done ? 'line-through opacity-60' : ''">{{ step.label }}</span>
</span> </span>
<button <button
type="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)" @click="toggle(index)"
> >
{{ step.done ? 'Undo' : 'Done' }} {{ step.done ? 'Undo' : 'Done' }}
@@ -42,7 +42,7 @@ const tone: Record<LogEntry['level'], string> = {
</script> </script>
<template> <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"> <div class="flex gap-1.5">
<button <button
v-for="level in levels" v-for="level in levels"
@@ -50,38 +50,38 @@ const tone: Record<LogEntry['level'], string> = {
type="button" 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="flex-1 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="filter === level :class="filter === level
? 'border-transparent bg-(--accent) text-(--accent-fg)' ? 'border-transparent bg-accent text-accent-fg'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset)'" : 'border-border bg-bg-elevated text-fg hover:bg-bg-inset'"
@click="filter = level" @click="filter = level"
> >
{{ level }} {{ level }}
</button> </button>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3"> <div class="rounded-lg border border-border bg-bg-inset p-3">
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"> <p class="demo-label">
Latest {{ filter }} entry Latest {{ filter }} entry
</p> </p>
<template v-if="latest"> <template v-if="latest">
<p class="mt-1 font-mono text-sm text-(--fg)">{{ latest.message }}</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> <p class="mt-1 font-mono text-xs text-fg-subtle">#{{ latest.id }}</p>
</template> </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 No {{ filter }} entries yet
</p> </p>
</div> </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 <li
v-for="entry in log" v-for="entry in log"
:key="entry.id" :key="entry.id"
class="flex items-center gap-2 rounded-md px-2 py-1 font-mono text-xs transition" 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]"> <span class="w-10 shrink-0 font-semibold uppercase" :class="tone[entry.level]">
{{ entry.level }} {{ entry.level }}
</span> </span>
<span class="truncate text-(--fg-muted)">{{ entry.message }}</span> <span class="truncate text-fg-muted">{{ entry.message }}</span>
</li> </li>
</ul> </ul>
@@ -90,7 +90,7 @@ const tone: Record<LogEntry['level'], string> = {
v-for="level in levels" v-for="level in levels"
:key="level" :key="level"
type="button" 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)" @click="append(level)"
> >
+ {{ level }} + {{ level }}
@@ -26,9 +26,9 @@ const hasTag = useArrayIncludes(tags, query, { fromIndex: fromIndex.value });
</script> </script>
<template> <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 border-(--border) bg-(--bg-elevated) p-4"> <div class="demo-card p-4">
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"> <p class="demo-label mb-2">
Member by key Member by key
</p> </p>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
@@ -37,11 +37,11 @@ const hasTag = useArrayIncludes(tags, query, { fromIndex: fromIndex.value });
:key="user.id" :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="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition"
:class="user.id === searchId :class="user.id === searchId
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)' ? 'border-accent bg-accent-subtle text-accent-text'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'" : 'border-border bg-bg-inset text-fg-muted'"
> >
{{ user.name }} {{ user.name }}
<span class="font-mono text-(--fg-subtle)">#{{ user.id }}</span> <span class="font-mono text-fg-subtle">#{{ user.id }}</span>
</span> </span>
</div> </div>
@@ -49,21 +49,21 @@ const hasTag = useArrayIncludes(tags, query, { fromIndex: fromIndex.value });
<input <input
v-model.number="searchId" v-model.number="searchId"
type="number" 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 <span
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium" class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium"
:class="isMember :class="isMember
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' ? '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' }} {{ isMember ? 'includes id' : 'not found' }}
</span> </span>
</div> </div>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4"> <div class="demo-card p-4">
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"> <p class="demo-label mb-2">
Primitive search (fromIndex 2) Primitive search (fromIndex 2)
</p> </p>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
@@ -72,8 +72,8 @@ const hasTag = useArrayIncludes(tags, query, { fromIndex: fromIndex.value });
:key="i" :key="i"
class="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium" class="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="i < 2 :class="i < 2
? 'border-(--border) bg-(--bg-inset) text-(--fg-subtle) opacity-50 line-through' ? '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-muted'"
> >
{{ tag }} {{ tag }}
</span> </span>
@@ -83,13 +83,13 @@ const hasTag = useArrayIncludes(tags, query, { fromIndex: fromIndex.value });
v-model="query" v-model="query"
type="text" type="text"
placeholder="search a tag…" 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 Searching from index 2
<span <span
class="font-mono font-semibold" 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> >{{ hasTag }}</span>
</p> </p>
</div> </div>
@@ -1,6 +1,6 @@
import { computed, toValue } from 'vue'; import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } 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. * 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. // Resolve the comparator once instead of on every recompute.
let compare: UseArrayIncludesComparatorFn<T, V>; 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; const key = resolved as keyof T;
compare = (element, searched) => element[key] === (searched as unknown); compare = (element, searched) => element[key] === (searched as unknown);
} }
else if (typeof resolved === 'function') { else if (isFunction(resolved)) {
compare = resolved; compare = resolved;
} }
else { else {
@@ -30,19 +30,19 @@ function remove(index: number) {
</script> </script>
<template> <template>
<div class="flex w-full max-w-sm flex-col gap-4"> <div class="demo-stack max-w-sm">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3"> <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)"> <p class="demo-label mb-1">
Joined result Joined result
</p> </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-if="joined">{{ joined }}</span>
<span v-else class="text-(--fg-subtle)">empty</span> <span v-else class="text-fg-subtle">empty</span>
</p> </p>
</div> </div>
<div class="flex flex-col gap-2"> <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"> <div class="flex gap-1.5">
<button <button
v-for="sep in separators" v-for="sep in separators"
@@ -50,8 +50,8 @@ function remove(index: number) {
type="button" 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="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 :class="separator === sep.value
? 'border-transparent bg-(--accent) text-(--accent-fg)' ? 'border-transparent bg-accent text-accent-fg'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset)'" : 'border-border bg-bg-elevated text-fg hover:bg-bg-inset'"
@click="separator = sep.value" @click="separator = sep.value"
> >
{{ sep.label }} {{ sep.label }}
@@ -63,16 +63,16 @@ function remove(index: number) {
<li <li
v-for="(segment, index) in segments" v-for="(segment, index) in segments"
:key="index" :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="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 }} {{ segment }}
</span> </span>
<button <button
type="button" type="button"
aria-label="Remove segment" 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)" @click="remove(index)"
> >
@@ -85,11 +85,11 @@ function remove(index: number) {
v-model="draft" v-model="draft"
type="text" type="text"
placeholder="add a segment…" 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 <button
type="submit" 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()" :disabled="!draft.trim()"
> >
Add Add
@@ -1,5 +1,6 @@
import { computed, toValue } from 'vue'; import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue'; import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isFunction } from '@robonen/stdlib';
export type UseArrayJoinReturn = ComputedRef<string>; export type UseArrayJoinReturn = ComputedRef<string>;
@@ -30,7 +31,7 @@ export function useArrayJoin(
// reactive items first lets the computed track per-item ref dependencies. // reactive items first lets the computed track per-item ref dependencies.
let needsUnwrap = false; let needsUnwrap = false;
for (const item of resolved) { 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; needsUnwrap = true;
break; break;
} }
@@ -34,17 +34,17 @@ function bump(index: number, delta: number) {
</script> </script>
<template> <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"> <div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Cart</span> <span class="demo-label">Cart</span>
<label class="flex items-center gap-2 text-sm text-(--fg-muted)"> <label class="flex items-center gap-2 text-sm text-fg-muted">
Tax {{ taxRate }}% Tax {{ taxRate }}%
<input <input
v-model.number="taxRate" v-model.number="taxRate"
type="range" type="range"
min="0" min="0"
max="25" max="25"
class="accent-(--accent)" class="accent-accent"
> >
</label> </label>
</div> </div>
@@ -53,41 +53,41 @@ function bump(index: number, delta: number) {
<li <li
v-for="(item, index) in priced" v-for="(item, index) in priced"
:key="item.name" :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"> <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 }} {{ item.name }}
</p> </p>
<p class="text-xs text-(--fg-subtle)"> <p class="text-xs text-fg-subtle">
base {{ formatter.format(item.price) }} base {{ formatter.format(item.price) }}
</p> </p>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<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="Decrease price" aria-label="Decrease price"
@click="bump(index, -10)" @click="bump(index, -10)"
> >
&minus; &minus;
</button> </button>
<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" aria-label="Increase price"
@click="bump(index, 10)" @click="bump(index, 10)"
> >
+ +
</button> </button>
</div> </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) }} {{ formatter.format(item.gross) }}
</span> </span>
</li> </li>
</ul> </ul>
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) p-3"> <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="demo-label">Total with tax</span>
<span class="font-mono text-xl font-bold tabular-nums text-(--fg)"> <span class="demo-stat text-xl">
{{ formatter.format(total) }} {{ formatter.format(total) }}
</span> </span>
</div> </div>
@@ -36,14 +36,14 @@ function removeAt(index: number) {
</script> </script>
<template> <template>
<div class="w-full max-w-md flex flex-col gap-4"> <div class="demo-stack max-w-md">
<label class="flex items-center justify-between gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4"> <label class="demo-card flex items-center justify-between gap-3 p-4">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Starting budget</span> <span class="demo-label">Starting budget</span>
<input <input
v-model.number="startingBudget" v-model.number="startingBudget"
type="number" type="number"
step="50" 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> </label>
@@ -51,34 +51,34 @@ function removeAt(index: number) {
<li <li
v-for="(expense, index) in expenses" v-for="(expense, index) in expenses"
:key="index" :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"> <span class="font-mono text-sm tabular-nums text-rose-600 dark:text-rose-400">
&minus;{{ formatter.format(expense.amount) }} &minus;{{ formatter.format(expense.amount) }}
</span> </span>
<button <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" aria-label="Remove expense"
@click="removeAt(index)" @click="removeAt(index)"
> >
&times; &times;
</button> </button>
</li> </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. No expenses full budget remains.
</li> </li>
</ul> </ul>
<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="add" @click="add"
> >
+ Add charge + Add charge
</button> </button>
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) p-3"> <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> <span class="demo-label">Remaining</span>
<span <span
class="font-mono text-2xl font-bold tabular-nums" 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'" :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> </script>
<template> <template>
<div class="w-full max-w-md flex flex-col gap-4"> <div class="demo-stack max-w-md">
<div <div
class="flex items-center gap-3 rounded-xl border p-4 transition" class="flex items-center gap-3 rounded-xl border p-4 transition"
:class="hasOverloaded :class="hasOverloaded
@@ -43,7 +43,7 @@ function load(index: number, delta: number) {
</p> </p>
</div> </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>Alert threshold</span>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<input <input
@@ -51,9 +51,9 @@ function load(index: number, delta: number) {
type="range" type="range"
min="40" min="40"
max="100" 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> </span>
</label> </label>
@@ -61,31 +61,31 @@ function load(index: number, delta: number) {
<li <li
v-for="(server, index) in servers" v-for="(server, index) in servers"
:key="server.name" :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"> <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="flex items-center gap-2">
<span <span
class="font-mono text-sm tabular-nums" 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> >{{ server.cpu }}%</span>
<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="Decrease load" aria-label="Decrease load"
@click="load(index, -10)" @click="load(index, -10)"
>&minus;</button> >&minus;</button>
<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" aria-label="Increase load"
@click="load(index, 10)" @click="load(index, 10)"
>+</button> >+</button>
</span> </span>
</div> </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 <div
class="h-full rounded-full transition-all" 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}%` }" :style="{ width: `${server.cpu}%` }"
/> />
</div> </div>
@@ -35,40 +35,40 @@ function addTag() {
</script> </script>
<template> <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"> <form class="flex gap-2" @submit.prevent="addTag">
<input <input
v-model="draft" v-model="draft"
type="text" type="text"
placeholder="Add a tag, e.g. TypeScript" 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 <button
type="submit" 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 Add
</button> </button>
</form> </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> <span>Case-insensitive comparator</span>
<input <input
v-model="caseInsensitive" v-model="caseInsensitive"
type="checkbox" type="checkbox"
class="size-4 accent-(--accent) cursor-pointer" class="size-4 accent-accent cursor-pointer"
> >
</label> </label>
<div class="flex flex-col gap-1.5"> <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 }}) Source ({{ raw.length }})
</span> </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 <span
v-for="(tag, index) in raw" v-for="(tag, index) in raw"
:key="`${tag}-${index}`" :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 }} {{ tag }}
</span> </span>
@@ -76,18 +76,18 @@ function addTag() {
</div> </div>
<div class="flex flex-col gap-1.5"> <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 }}) Unique ({{ unique.length }})
</span> </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 <span
v-for="tag in unique" v-for="tag in unique"
:key="tag" :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 }} {{ tag }}
</span> </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> </div>
</div> </div>
@@ -1,6 +1,6 @@
import { computed, toValue } from 'vue'; import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } 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. * 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. // Resolve the comparison strategy once, not on every recompute.
// Key of T (string | number | symbol) -> O(n) first-seen-wins key de-dup. // 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; const key = comparator as keyof T;
return computed<T[]>(() => uniqueByKey(resolve(list), element => element[key] as PropertyKey)); 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²) // A unary key extractor stays O(n); a binary comparator falls back to O(n²)
// pairwise comparison (unavoidable for arbitrary equality). Branch on arity. // pairwise comparison (unavoidable for arbitrary equality). Branch on arity.
if (comparator.length <= 1) { if (comparator.length <= 1) {
@@ -38,23 +38,23 @@ const keys: { id: SortKey; label: string }[] = [
</script> </script>
<template> <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="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 <button
v-for="key in keys" v-for="key in keys"
:key="key.id" :key="key.id"
class="rounded-md px-3 py-1 text-sm font-medium transition cursor-pointer" class="rounded-md px-3 py-1 text-sm font-medium transition cursor-pointer"
:class="sortKey === key.id :class="sortKey === key.id
? 'bg-(--accent) text-(--accent-fg)' ? 'bg-accent text-accent-fg'
: 'text-(--fg-muted) hover:text-(--fg)'" : 'text-fg-muted hover:text-fg'"
@click="sortKey = key.id" @click="sortKey = key.id"
> >
{{ key.label }} {{ key.label }}
</button> </button>
</div> </div>
<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="descending = !descending" @click="descending = !descending"
> >
{{ descending ? 'Desc ↓' : 'Asc ↑' }} {{ descending ? 'Desc ↓' : 'Asc ↑' }}
@@ -65,22 +65,22 @@ const keys: { id: SortKey; label: string }[] = [
<li <li
v-for="(player, index) in sorted" v-for="(player, index) in sorted"
:key="player.name" :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 }} {{ index + 1 }}
</span> </span>
<span class="flex-1 text-sm font-medium text-(--fg)">{{ player.name }}</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="demo-badge">
Lv {{ player.level }} Lv {{ player.level }}
</span> </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() }} {{ player.score.toLocaleString() }}
</span> </span>
</li> </li>
</ol> </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. Stable sort players with an equal {{ sortKey }} keep their original order. The source array is left untouched.
</p> </p>
</div> </div>
@@ -2,13 +2,13 @@ import { computed, isRef, toValue, watchEffect } from 'vue';
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'; import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
import { isFunction } from '@robonen/stdlib'; import { isFunction } from '@robonen/stdlib';
export type UseSortedCompareFn<T = any> export type UseSortedCompareFn<T = unknown>
= (a: T, b: T) => number; = (a: T, b: T) => number;
export type UseSortedFn<T = any> export type UseSortedFn<T = unknown>
= (arr: T[], compareFn: UseSortedCompareFn<T>) => T[]; = (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 * The sort algorithm to apply. Receives a copy of the array (or the source
* itself in `dirty` mode) and the resolved compare function. * 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 * @since 0.0.15
*/ */
export function useSorted<T = any>(source: Ref<T[]>, compareFn?: UseSortedCompareFn<T>): Ref<T[]>; export function useSorted<T = unknown>(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 = unknown>(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 = unknown>(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 = unknown>(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 = unknown>(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 = unknown>(source: MaybeRefOrGetter<T[]>, compareFn?: UseSortedCompareFn<T>, options?: Omit<UseSortedOptions<T>, 'compareFn'>): ComputedRef<T[]>;
export function useSorted<T = any>( export function useSorted<T = unknown>(
source: MaybeRefOrGetter<T[]>, source: MaybeRefOrGetter<T[]>,
compareFnOrOptions?: UseSortedCompareFn<T> | UseSortedOptions<T>, compareFnOrOptions?: UseSortedCompareFn<T> | UseSortedOptions<T>,
maybeOptions?: Omit<UseSortedOptions<T>, 'compareFn'>, maybeOptions?: Omit<UseSortedOptions<T>, 'compareFn'>,
@@ -27,9 +27,9 @@ function setQuantity(delta: number): void {
</script> </script>
<template> <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"> <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 <span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium" class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="supported :class="supported
@@ -41,15 +41,15 @@ function setQuantity(delta: number): void {
</span> </span>
</div> </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"> <div class="flex flex-wrap gap-2">
<button <button
v-for="product in products" v-for="product in products"
:key="product" :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="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 :class="cart.item === product
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)' ? '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-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="pick(product)" @click="pick(product)"
> >
{{ product }} {{ product }}
@@ -57,18 +57,18 @@ function setQuantity(delta: number): void {
</div> </div>
<div class="mt-4 flex items-center justify-between"> <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"> <div class="flex items-center gap-2">
<button <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" :disabled="cart.quantity <= 1"
@click="setQuantity(-1)" @click="setQuantity(-1)"
> >
&minus; &minus;
</button> </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 <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)" @click="setQuantity(1)"
> >
+ +
@@ -77,21 +77,21 @@ function setQuantity(delta: number): void {
</div> </div>
</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"> <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> <span class="font-bold">${{ subtotal }}</span>
</div> </div>
</div> </div>
<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="theme = theme === 'light' ? 'dark' : 'light'" @click="theme = theme === 'light' ? 'dark' : 'light'"
> >
Toggle shared theme: <span class="font-mono">{{ theme }}</span> Toggle shared theme: <span class="font-mono">{{ theme }}</span>
</button> </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. Open this page in a second tab. Every change you make here is broadcast and mirrored instantly in the other tab.
</p> </p>
</div> </div>
@@ -21,42 +21,42 @@ const device = (): string => (isDesktop.value ? 'Desktop' : isMobile.value ? 'Mo
</script> </script>
<template> <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 border-(--border) bg-(--bg-elevated) p-4"> <div class="demo-card p-4">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Active breakpoint</span> <span class="demo-label">Active breakpoint</span>
<div class="mt-1 flex items-baseline gap-2"> <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="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> <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> </div>
<div class="flex flex-col gap-1.5"> <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 <div
v-for="row in rows" v-for="row in rows"
:key="row.key" :key="row.key"
class="flex items-center justify-between rounded-lg border px-3 py-2 text-sm transition" class="flex items-center justify-between rounded-lg border px-3 py-2 text-sm transition"
:class="bp[row.key].value :class="bp[row.key].value
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)' ? 'border-accent bg-accent-subtle text-accent-text'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'" : 'border-border bg-bg-inset text-fg-muted'"
> >
<span class="font-mono font-medium">{{ row.key }}</span> <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 <span
class="h-2 w-2 rounded-full transition" 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> </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"> <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> <span>[{{ current.length ? current.join(', ') : '—' }}]</span>
</div> </div>
</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. Resize your browser window the matched breakpoints update live.
</p> </p>
</div> </div>
@@ -5,6 +5,7 @@ import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types'; import type { ConfigurableWindow } from '@/types';
import { useMediaQuery } from '@/composables/browser/useMediaQuery'; import { useMediaQuery } from '@/composables/browser/useMediaQuery';
import type { UseMediaQueryOptions } 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; * 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 | ''>; 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. * Add `delta` to the numeric portion of a CSS length, preserving its unit.
* Used to build the strict (`> / <`) variants from inclusive media queries via * Used to build the strict (`> / <`) variants from inclusive media queries via
@@ -14,9 +14,9 @@ const snippets = [
</script> </script>
<template> <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"> <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 <span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium" class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isSupported :class="isSupported
@@ -32,11 +32,11 @@ const snippets = [
<input <input
v-model="draft" v-model="draft"
type="text" 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…" placeholder="Type something to copy…"
> >
<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" class="demo-btn-primary disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="copyPending || !draft" :disabled="copyPending || !draft"
@click="copy(draft)" @click="copy(draft)"
> >
@@ -45,21 +45,21 @@ const snippets = [
</div> </div>
<div class="flex flex-col gap-1.5"> <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 <button
v-for="snippet in snippets" v-for="snippet in snippets"
:key="snippet" :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)" @click="copy(snippet)"
> >
<span class="truncate font-mono text-xs text-(--fg-muted)">{{ snippet }}</span> <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="shrink-0 text-xs text-fg-subtle">Copy</span>
</button> </button>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3"> <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> <span class="demo-label">Last copied</span>
<p class="mt-1 break-all font-mono text-sm text-(--fg)">{{ text || '—' }}</p> <p class="mt-1 break-all font-mono text-sm text-fg">{{ text || '—' }}</p>
</div> </div>
</template> </template>
@@ -34,9 +34,9 @@ function typesOf(item: ClipboardItem): string {
</script> </script>
<template> <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"> <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 <span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium" class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isSupported :class="isSupported
@@ -48,42 +48,42 @@ function typesOf(item: ClipboardItem): string {
</div> </div>
<template v-if="isSupported"> <template v-if="isSupported">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4"> <div class="demo-card p-4">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Rich payload</span> <span class="demo-label">Rich payload</span>
<p class="mt-1 text-sm text-(--fg)" v-html="html" /> <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> <p class="mt-1 font-mono text-xs text-fg-subtle">text/plain &middot; text/html</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <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" :disabled="copyPending"
@click="copyRich" @click="copyRich"
> >
{{ copyPending ? 'Copying…' : copied ? 'Copied!' : 'Copy rich content' }} {{ copyPending ? 'Copying…' : copied ? 'Copied!' : 'Copy rich content' }}
</button> </button>
<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" @click="readClipboard"
> >
Read clipboard Read clipboard
</button> </button>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3"> <div class="rounded-lg border border-border bg-bg-inset p-3">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"> <span class="demo-label">
content ({{ content.length }} {{ content.length === 1 ? 'item' : 'items' }}) content ({{ content.length }} {{ content.length === 1 ? 'item' : 'items' }})
</span> </span>
<ul v-if="content.length" class="mt-2 flex flex-col gap-1"> <ul v-if="content.length" class="mt-2 flex flex-col gap-1">
<li <li
v-for="(item, i) in content" v-for="(item, i) in content"
:key="i" :key="i"
class="font-mono text-xs text-(--fg)" class="font-mono text-xs text-fg"
> >
#{{ i + 1 }}: {{ typesOf(item) }} #{{ i + 1 }}: {{ typesOf(item) }}
</li> </li>
</ul> </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>
<div <div
@@ -20,9 +20,9 @@ onMounted(() => {
</script> </script>
<template> <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"> <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 <span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium" class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isSupported :class="isSupported
@@ -34,7 +34,7 @@ onMounted(() => {
</div> </div>
<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" class="demo-btn-primary disabled:cursor-not-allowed disabled:opacity-40"
:disabled="open" :disabled="open"
@click="open = true" @click="open = true"
> >
@@ -47,19 +47,19 @@ onMounted(() => {
leave-active-class="transition duration-100 ease-in" leave-active-class="transition duration-100 ease-in"
leave-to-class="opacity-0 translate-y-1" 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 class="flex items-start justify-between gap-3">
<div> <div>
<p class="text-sm font-semibold text-(--fg)">Unsaved changes</p> <p class="text-sm font-semibold text-fg">Unsaved changes</p>
<p class="mt-1 text-sm text-(--fg-muted)"> <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> 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. (or the Android back gesture) to dismiss.
</p> </p>
</div> </div>
</div> </div>
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<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="close()" @click="close()"
> >
Dismiss via close() Dismiss via close()
@@ -68,18 +68,18 @@ onMounted(() => {
</div> </div>
</Transition> </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"> <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> <span class="font-bold">{{ closeCount }}</span>
</div> </div>
<div class="mt-1 flex justify-between"> <div class="mt-1 flex justify-between">
<span class="text-(--fg-muted)">last</span> <span class="text-fg-muted">last</span>
<span>{{ lastClosedAt ?? '—' }}</span> <span>{{ lastClosedAt ?? '—' }}</span>
</div> </div>
</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. Open the dialog, then dismiss it with Esc, the system back gesture, or the programmatic <code class="font-mono">close()</code> call.
</p> </p>
</div> </div>
@@ -30,11 +30,11 @@ const options = [
<template> <template>
<div <div
ref="target" 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"> <div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Color mode</span> <span class="demo-label">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-badge">
system: {{ mode.system.value }} system: {{ mode.system.value }}
</span> </span>
</div> </div>
@@ -46,8 +46,8 @@ const options = [
type="button" 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="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 :class="mode === opt.value
? 'border-transparent bg-(--accent) text-(--accent-fg)' ? 'border-transparent bg-accent text-accent-fg'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'" : 'border-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="mode = opt.value" @click="mode = opt.value"
> >
<span class="text-base leading-none">{{ opt.icon }}</span> <span class="text-base leading-none">{{ opt.icon }}</span>
@@ -55,26 +55,26 @@ const options = [
</button> </button>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4"> <div class="demo-card p-4">
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Reactive state</p> <p class="demo-label mb-3">Reactive state</p>
<dl class="space-y-2 text-sm"> <dl class="space-y-2 text-sm">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<dt class="text-(--fg-muted)">selected (emitAuto)</dt> <dt class="text-fg-muted">selected (emitAuto)</dt>
<dd class="font-mono tabular-nums text-(--fg)">{{ mode }}</dd> <dd class="font-mono tabular-nums text-fg">{{ mode }}</dd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<dt class="text-(--fg-muted)">resolved state</dt> <dt class="text-fg-muted">resolved state</dt>
<dd class="font-mono tabular-nums text-(--fg)">{{ mode.state.value }}</dd> <dd class="font-mono tabular-nums text-fg">{{ mode.state.value }}</dd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<dt class="text-(--fg-muted)">store</dt> <dt class="text-fg-muted">store</dt>
<dd class="font-mono tabular-nums text-(--fg)">{{ mode.store.value }}</dd> <dd class="font-mono tabular-nums text-fg">{{ mode.store.value }}</dd>
</div> </div>
</dl> </dl>
</div> </div>
<p class="text-xs text-(--fg-subtle)"> <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. 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. Pick "Auto" to follow your OS preference.
</p> </p>
</div> </div>
@@ -15,10 +15,10 @@ const accent = computed(() => `hsl(${hue.value} 80% 55%)`);
</script> </script>
<template> <template>
<div class="flex w-full max-w-sm flex-col gap-4"> <div class="demo-stack max-w-sm">
<div <div
ref="target" 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 <div
class="shadow-lg transition-all duration-300 ease-out" class="shadow-lg transition-all duration-300 ease-out"
@@ -31,11 +31,11 @@ const accent = computed(() => `hsl(${hue.value} 80% 55%)`);
/> />
</div> </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 flex-col gap-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="hue">Hue</label> <label class="demo-label" for="hue">Hue</label>
<span class="font-mono text-sm tabular-nums text-(--fg)">--demo-hue: {{ hue }}</span> <span class="font-mono text-sm tabular-nums text-fg">--demo-hue: {{ hue }}</span>
</div> </div>
<input <input
id="hue" id="hue"
@@ -43,14 +43,14 @@ const accent = computed(() => `hsl(${hue.value} 80% 55%)`);
type="range" type="range"
min="0" min="0"
max="360" max="360"
class="w-full accent-(--accent) cursor-pointer" class="w-full accent-accent cursor-pointer"
> >
<div class="flex gap-1.5"> <div class="flex gap-1.5">
<button <button
v-for="s in swatches" v-for="s in swatches"
:key="s" :key="s"
type="button" 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%)` }" :style="{ background: `hsl(${s} 80% 55%)` }"
:aria-label="`Set hue ${s}`" :aria-label="`Set hue ${s}`"
@click="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 flex-col gap-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="radius">Radius</label> <label class="demo-label" for="radius">Radius</label>
<span class="font-mono text-sm tabular-nums text-(--fg)">--demo-radius: {{ radius }}</span> <span class="font-mono text-sm tabular-nums text-fg">--demo-radius: {{ radius }}</span>
</div> </div>
<input <input
id="radius" id="radius"
@@ -69,14 +69,14 @@ const accent = computed(() => `hsl(${hue.value} 80% 55%)`);
type="range" type="range"
min="0" min="0"
max="48" max="48"
class="w-full accent-(--accent) cursor-pointer" class="w-full accent-accent cursor-pointer"
> >
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="size">Size</label> <label class="demo-label" for="size">Size</label>
<span class="font-mono text-sm tabular-nums text-(--fg)">--demo-size: {{ size }}</span> <span class="font-mono text-sm tabular-nums text-fg">--demo-size: {{ size }}</span>
</div> </div>
<input <input
id="size" id="size"
@@ -84,12 +84,12 @@ const accent = computed(() => `hsl(${hue.value} 80% 55%)`);
type="range" type="range"
min="48" min="48"
max="140" max="140"
class="w-full accent-(--accent) cursor-pointer" class="w-full accent-accent cursor-pointer"
> >
</div> </div>
</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 }}; background: {{ accent }};
</div> </div>
</div> </div>
@@ -23,9 +23,9 @@ function toggle() {
<div <div
ref="target" ref="target"
data-demo-mode 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"> <div class="flex items-center gap-3">
<span <span
class="flex h-10 w-10 items-center justify-center rounded-lg text-lg transition-colors" class="flex h-10 w-10 items-center justify-center rounded-lg text-lg transition-colors"
@@ -36,8 +36,8 @@ function toggle() {
{{ isDark ? '☾' : '☀' }} {{ isDark ? '☾' : '☀' }}
</span> </span>
<div> <div>
<p class="text-sm font-medium text-(--fg)">{{ isDark ? 'Dark mode' : 'Light mode' }}</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> <p class="text-xs text-fg-subtle">isDark = {{ isDark }}</p>
</div> </div>
</div> </div>
@@ -45,30 +45,30 @@ function toggle() {
type="button" type="button"
role="switch" role="switch"
:aria-checked="isDark" :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="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="isDark ? 'bg-accent' : 'bg-bg-inset'"
@click="toggle" @click="toggle"
> >
<span <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'" :class="isDark ? 'translate-x-6' : 'translate-x-1'"
/> />
</button> </button>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3"> <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> <p class="demo-label mb-2">Preview surface</p>
<div class="flex items-center gap-2"> <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' }}" data-demo-mode = "{{ isDark ? 'dark' : 'light' }}"
</span> </span>
</div> </div>
</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 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> 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. falls back to <code class="font-mono text-fg-muted">auto</code> to keep tracking it.
</p> </p>
</div> </div>
</template> </template>
@@ -51,7 +51,7 @@ watch(isOpen, (openNow) => {
</script> </script>
<template> <template>
<div class="flex w-full max-w-sm flex-col gap-4"> <div class="demo-stack max-w-sm">
<div <div
v-if="!isSupported" 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" 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> <template v-else>
<div <div
ref="host" 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 <div
ref="player" 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="demo-label">Live timer</span>
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)"> <span class="demo-stat text-3xl">
{{ String(Math.floor(elapsed / 60)).padStart(2, '0') }}:{{ String(elapsed % 60).padStart(2, '0') }} {{ String(Math.floor(elapsed / 60)).padStart(2, '0') }}:{{ String(elapsed % 60).padStart(2, '0') }}
</span> </span>
</div> </div>
@@ -78,7 +78,7 @@ watch(isOpen, (openNow) => {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
type="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" :disabled="isOpen"
@click="popOut" @click="popOut"
> >
@@ -86,7 +86,7 @@ watch(isOpen, (openNow) => {
</button> </button>
<button <button
type="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" :disabled="!isOpen"
@click="close" @click="close"
> >
@@ -94,17 +94,17 @@ watch(isOpen, (openNow) => {
</button> </button>
</div> </div>
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-sm"> <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="text-fg-muted">isOpen</span>
<span <span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium" class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="isOpen :class="isOpen
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' ? '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 <span
class="h-1.5 w-1.5 rounded-full" 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' }} {{ isOpen ? 'floating' : 'docked' }}
</span> </span>
@@ -118,7 +118,7 @@ watch(isOpen, (openNow) => {
</p> </p>
<p <p
v-else 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. "Pop out" moves the live timer into an always-on-top window. Closing it returns the element to the page.
</p> </p>
@@ -46,33 +46,33 @@ function toggleListening() {
</script> </script>
<template> <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"> <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 <div
ref="pad" 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 <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'" :class="inside ? 'opacity-100' : 'opacity-0'"
:style="{ left: `${pos.x}px`, top: `${pos.y}px` }" :style="{ left: `${pos.x}px`, top: `${pos.y}px` }"
/> />
<div class="pointer-events-none absolute inset-0 flex items-center justify-center"> <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>
<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 }} x: {{ pos.x }} · y: {{ pos.y }}
</div> </div>
</div> </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"> <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 <button
type="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" @click="toggleListening"
> >
{{ listening ? 'Stop listening' : 'Start listening' }} {{ listening ? 'Stop listening' : 'Start listening' }}
@@ -81,30 +81,30 @@ function toggleListening() {
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<kbd <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 || '—' }} {{ lastKey || '—' }}
</kbd> </kbd>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-xs text-(--fg-subtle)">presses captured</span> <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="demo-stat text-lg">{{ keyCount }}</span>
</div> </div>
<span <span
class="ml-auto inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium" class="ml-auto inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
:class="listening :class="listening
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' ? '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 <span
class="h-1.5 w-1.5 rounded-full" 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' }} {{ listening ? 'active' : 'stopped' }}
</span> </span>
</div> </div>
</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. Listeners auto-detach on unmount. The returned stop function lets you detach early press any key, then toggle listening.
</p> </p>
</div> </div>
@@ -6,8 +6,8 @@ import type { MaybeRefOrGetter } from 'vue';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
interface InferEventTarget<Events> { interface InferEventTarget<Events> {
addEventListener: (event: Events, listener?: any, options?: any) => any; addEventListener: (event: Events, listener?: GeneralEventListener, options?: boolean | AddEventListenerOptions) => void;
removeEventListener: (event: Events, listener?: any, options?: any) => any; removeEventListener: (event: Events, listener?: GeneralEventListener, options?: boolean | EventListenerOptions) => void;
} }
export type GeneralEventListener<E = Event> = (evt: E) => void; export type GeneralEventListener<E = Event> = (evt: E) => void;
@@ -27,7 +27,7 @@ type ListenerOptions = boolean | AddEventListenerOptions;
*/ */
export function useEventListener<E extends WindowEventName>( export function useEventListener<E extends WindowEventName>(
event: Arrayable<E>, event: Arrayable<E>,
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>, listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => void>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>, options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction; ): VoidFunction;
@@ -41,7 +41,7 @@ export function useEventListener<E extends WindowEventName>(
export function useEventListener<E extends WindowEventName>( export function useEventListener<E extends WindowEventName>(
target: Window, target: Window,
event: Arrayable<E>, event: Arrayable<E>,
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>, listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => void>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>, options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction; ): VoidFunction;
@@ -55,7 +55,7 @@ export function useEventListener<E extends WindowEventName>(
export function useEventListener<E extends DocumentEventName>( export function useEventListener<E extends DocumentEventName>(
target: Document, target: Document,
event: Arrayable<E>, event: Arrayable<E>,
listener: Arrayable<(this: Document, ev: DocumentEventMap[E]) => any>, listener: Arrayable<(this: Document, ev: DocumentEventMap[E]) => void>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>, options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction; ): VoidFunction;
@@ -69,7 +69,7 @@ export function useEventListener<E extends DocumentEventName>(
export function useEventListener<E extends ElementEventName>( export function useEventListener<E extends ElementEventName>(
target: MaybeRefOrGetter<HTMLElement | null | undefined>, target: MaybeRefOrGetter<HTMLElement | null | undefined>,
event: Arrayable<E>, event: Arrayable<E>,
listener: Arrayable<(this: HTMLElement, ev: HTMLElementEventMap[E]) => any>, listener: Arrayable<(this: HTMLElement, ev: HTMLElementEventMap[E]) => void>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>, options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction; ): VoidFunction;
@@ -101,6 +101,7 @@ export function useEventListener<EventType = Event>(
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>, options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>,
): VoidFunction; ): VoidFunction;
// Variadic implementation signature behind the typed overloads above; args are narrowed at runtime.
export function useEventListener(...args: any[]) { export function useEventListener(...args: any[]) {
let target: MaybeRefOrGetter<EventTarget> | undefined = defaultWindow; let target: MaybeRefOrGetter<EventTarget> | undefined = defaultWindow;
let _events: Arrayable<string>; let _events: Arrayable<string>;
@@ -34,7 +34,7 @@ async function pick() {
</script> </script>
<template> <template>
<div class="flex w-full max-w-sm flex-col gap-4"> <div class="demo-stack max-w-sm">
<div <div
v-if="!isSupported" 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" 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> <template v-else>
<div <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 }" :style="{ backgroundColor: hex, color: readableText }"
> >
<span class="font-mono text-2xl font-bold tabular-nums">{{ hex }}</span> <span class="font-mono text-2xl font-bold tabular-nums">{{ hex }}</span>
</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" @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"> <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" /> <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> </svg>
Pick a color from screen Pick a color from screen
</button> </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 }} {{ error }}
</p> </p>
<div v-if="history.length" class="flex flex-col gap-2"> <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"> <div class="flex flex-wrap gap-2">
<button <button
v-for="color in history" v-for="color in history"
:key="color" :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" @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 }} {{ color }}
</button> </button>
</div> </div>
@@ -31,34 +31,34 @@ function select(preset: Preset) {
</script> </script>
<template> <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 border-(--border) bg-(--bg-elevated) p-4"> <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 items-center gap-2 rounded-lg border border-border bg-bg-inset px-3 py-2">
<div class="flex gap-1.5"> <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-red-500/70" />
<span class="size-2.5 rounded-full bg-amber-500/70" /> <span class="size-2.5 rounded-full bg-amber-500/70" />
<span class="size-2.5 rounded-full bg-emerald-500/70" /> <span class="size-2.5 rounded-full bg-emerald-500/70" />
</div> </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="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>
</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. Look at the real browser tab its favicon updates live.
</p> </p>
</div> </div>
<div class="flex flex-col gap-2"> <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"> <div class="grid grid-cols-2 gap-2">
<button <button
v-for="preset in presets" v-for="preset in presets"
:key="preset.label" :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="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 :class="active === preset.label
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)' ? '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-border bg-bg-elevated text-fg hover:bg-bg-inset hover:border-border-strong'"
@click="select(preset)" @click="select(preset)"
> >
<span class="text-base leading-none">{{ preset.emoji }}</span> <span class="text-base leading-none">{{ preset.emoji }}</span>
@@ -67,7 +67,7 @@ function select(preset: Preset) {
</div> </div>
</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" favicon.value = "{{ presets.find(p => p.label === active)?.emoji }} svg"
</div> </div>
</div> </div>
@@ -35,26 +35,26 @@ function pick() {
</script> </script>
<template> <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"> <div class="flex items-center justify-between gap-3">
<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="multiple" type="checkbox" class="size-4 rounded border-(--border) accent-(--accent)"> <input v-model="multiple" type="checkbox" class="size-4 rounded border-border accent-accent">
Allow multiple Allow multiple
</label> </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 }} {{ status }}
</span> </span>
</div> </div>
<div class="flex gap-2"> <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"> <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" /> <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> </svg>
Choose images Choose images
</button> </button>
<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" :disabled="!selected.length"
@click="reset" @click="reset"
> >
@@ -64,25 +64,25 @@ function pick() {
<div <div
v-if="!selected.length" 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-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-xs text-fg-subtle">Click Choose images to open the native dialog</span>
</div> </div>
<div v-else class="flex flex-col gap-2"> <div v-else class="flex flex-col gap-2">
<div class="flex items-center justify-between"> <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="demo-label">{{ selected.length }} file(s)</span>
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ formatBytes(totalBytes) }} total</span> <span class="font-mono text-xs tabular-nums text-fg-muted">{{ formatBytes(totalBytes) }} total</span>
</div> </div>
<ul class="flex max-h-44 flex-col gap-1.5 overflow-auto"> <ul class="flex max-h-44 flex-col gap-1.5 overflow-auto">
<li <li
v-for="file in selected" v-for="file in selected"
:key="file.name + file.lastModified" :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="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="shrink-0 font-mono text-xs tabular-nums text-fg-subtle">{{ formatBytes(file.size) }}</span>
</li> </li>
</ul> </ul>
</div> </div>
@@ -47,7 +47,7 @@ async function newFile() {
</script> </script>
<template> <template>
<div class="flex w-full max-w-md flex-col gap-4"> <div class="demo-stack max-w-md">
<div <div
v-if="!isSupported" 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" 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> <template v-else>
<div class="flex flex-wrap gap-2"> <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 Open
</button> </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 New
</button> </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 Save
</button> </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 Save As
</button> </button>
</div> </div>
<div v-if="fileName" class="flex flex-wrap items-center gap-2"> <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 }} {{ fileName }}
</span> </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' }} {{ fileMIME || 'text/plain' }}
</span> </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) }} {{ formatBytes(fileSize) }}
</span> </span>
</div> </div>
@@ -88,19 +88,19 @@ async function newFile() {
v-model="text" v-model="text"
rows="6" rows="6"
spellcheck="false" 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…" placeholder="File contents…"
/> />
<div <div
v-else 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-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-xs text-fg-subtle">Open an existing file or create a new one, edit it, then save back to disk.</span>
</div> </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 }} {{ lastError }}
</p> </p>
</template> </template>

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