17 Commits

Author SHA1 Message Date
robonen 8ea85c4aab Merge pull request 'chore(deps): update actions/checkout action to v7' (#7) from renovate/actions-checkout-7.x into master
Publish to NPM / Check version changes and publish (push) Successful in 10m28s
Reviewed-on: #7
2026-06-18 19:44:24 +00:00
Renovate Bot 151e106aa7 chore(deps): update actions/checkout action to v7
CI / @robonen/crdt (pull_request) Successful in 1m40s
CI / @robonen/docs (pull_request) Successful in 5m30s
CI / @robonen/encoding (pull_request) Successful in 1m42s
CI / @robonen/eslint (pull_request) Successful in 1m6s
CI / @robonen/fetch (pull_request) Successful in 1m14s
CI / @robonen/platform (pull_request) Successful in 1m21s
CI / @robonen/primitives (pull_request) Successful in 5m26s
CI / @robonen/primitives-playground (pull_request) Successful in 1m54s
CI / @robonen/renovate (pull_request) Successful in 54s
CI / @robonen/stdlib (pull_request) Successful in 1m36s
CI / @robonen/stories (pull_request) Successful in 2m10s
CI / @robonen/tsconfig (pull_request) Successful in 52s
CI / @robonen/tsdown (pull_request) Successful in 59s
CI / @robonen/vue (pull_request) Successful in 3m31s
CI / @robonen/writekit (pull_request) Successful in 4m35s
CI / @robonen/writekit-playground (pull_request) Successful in 1m56s
CI / CI (pull_request) Successful in 5s
2026-06-18 16:04:37 +00:00
robonen 655f30a658 test(scroll-area): improve glimpse type test for pointer interactions
Publish to NPM / Check version changes and publish (push) Successful in 11m30s
2026-06-18 03:17:25 +07:00
robonen ab6d8f6ce0 build: bump new versions
Publish to NPM / Check version changes and publish (push) Failing after 10m34s
2026-06-18 02:57:03 +07:00
robonen e73e8d2cdd chore: update dependencies and configurations across multiple packages
Publish to NPM / Check version changes and publish (push) Failing after 10m44s
2026-06-18 02:02:58 +07:00
robonen 6e70d4edd1 refactor(ci): replace dynamic package discovery with static package list
Publish to NPM / Check version changes and publish (push) Failing after 10m51s
2026-06-16 06:01:38 +07: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
503 changed files with 8573 additions and 7850 deletions
@@ -13,47 +13,40 @@ env:
NODE_VERSION: 24.x NODE_VERSION: 24.x
jobs: jobs:
# Enumerate the workspace packages so the matrix below fans out one job per
# package (kept dynamic so new packages are picked up automatically).
discover:
name: Discover packages
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.list.outputs.packages }}
steps:
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: List workspace packages
id: list
run: echo "packages=$(pnpm -r ls --depth -1 --json | jq -c '[.[] | select(.name != "tools") | .name]')" >> "$GITHUB_OUTPUT"
# One job per package — build (with its workspace deps), lint and test run in # One job per package — build (with its workspace deps), lint and test run in
# parallel across packages. fail-fast: false so every package is reported. # parallel across packages. fail-fast: false so every package is reported.
#
# The list is static: Gitea's act_runner does not expand a dynamic matrix
# built from a previous job's outputs (the strategy is evaluated before the
# producing job runs), so `matrix.package` came out empty. When you add a
# workspace package, add a line here.
check: check:
name: ${{ matrix.package }} name: ${{ matrix.package }}
needs: discover
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
package: ${{ fromJSON(needs.discover.outputs.packages) }} package:
- "@robonen/eslint"
- "@robonen/tsconfig"
- "@robonen/tsdown"
- "@robonen/crdt"
- "@robonen/encoding"
- "@robonen/fetch"
- "@robonen/platform"
- "@robonen/stdlib"
- "@robonen/docs"
- "@robonen/renovate"
- "@robonen/primitives"
- "@robonen/primitives-playground"
- "@robonen/stories"
- "@robonen/vue"
- "@robonen/writekit"
- "@robonen/writekit-playground"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
@@ -77,7 +70,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 +80,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
@@ -13,7 +13,7 @@ jobs:
name: Check version changes and publish name: Check version changes and publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -43,31 +43,31 @@ jobs:
run: | run: |
# Find all package.json files (excluding node_modules) # Find all package.json files (excluding node_modules)
PACKAGE_FILES=$(find . -path "*/package.json" -not -path "*/node_modules/*") PACKAGE_FILES=$(find . -path "*/package.json" -not -path "*/node_modules/*")
for file in $PACKAGE_FILES; do for file in $PACKAGE_FILES; do
PACKAGE_DIR=$(dirname $file) PACKAGE_DIR=$(dirname $file)
echo "Checking $PACKAGE_DIR for version changes..." echo "Checking $PACKAGE_DIR for version changes..."
# Get package details # Get package details
PACKAGE_NAME=$(node -p "require('$file').name") PACKAGE_NAME=$(node -p "require('$file').name")
CURRENT_VERSION=$(node -p "require('$file').version") CURRENT_VERSION=$(node -p "require('$file').version")
IS_PRIVATE=$(node -p "require('$file').private || false") IS_PRIVATE=$(node -p "require('$file').private || false")
# Skip private packages # Skip private packages
if [ "$IS_PRIVATE" == "true" ]; then if [ "$IS_PRIVATE" == "true" ]; then
echo "Skipping private package $PACKAGE_NAME" echo "Skipping private package $PACKAGE_NAME"
continue continue
fi fi
# Skip root package # Skip root package
if [ "$PACKAGE_DIR" == "." ]; then if [ "$PACKAGE_DIR" == "." ]; then
echo "Skipping root package" echo "Skipping root package"
continue continue
fi fi
# Check if package exists on npm # Check if package exists on npm
NPM_VERSION=$(npm view $PACKAGE_NAME version 2>/dev/null || echo "0.0.0") NPM_VERSION=$(npm view $PACKAGE_NAME version 2>/dev/null || echo "0.0.0")
# Compare versions # Compare versions
if [ "$CURRENT_VERSION" != "$NPM_VERSION" ]; then if [ "$CURRENT_VERSION" != "$NPM_VERSION" ]; then
echo "Version changed for $PACKAGE_NAME: $NPM_VERSION → $CURRENT_VERSION" echo "Version changed for $PACKAGE_NAME: $NPM_VERSION → $CURRENT_VERSION"
+5 -4
View File
@@ -17,11 +17,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "configs/eslint" "directory": "configs/eslint"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.7.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
@@ -47,15 +48,15 @@
"dependencies": { "dependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "catalog:", "@stylistic/eslint-plugin": "catalog:",
"@vitest/eslint-plugin": "^1.6.19", "@vitest/eslint-plugin": "^1.6.20",
"eslint-plugin-import-x": "^4.16.2", "eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-n": "^18.1.0", "eslint-plugin-n": "^18.1.0",
"eslint-plugin-regexp": "^3.1.0", "eslint-plugin-regexp": "^3.1.0",
"eslint-plugin-unicorn": "^65.0.1", "eslint-plugin-unicorn": "^67.0.0",
"eslint-plugin-vue": "^10.9.2", "eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0", "globals": "^17.6.0",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"typescript-eslint": "^8.61.0", "typescript-eslint": "^8.61.1",
"vue-eslint-parser": "^10.4.1" "vue-eslint-parser": "^10.4.1"
}, },
"devDependencies": { "devDependencies": {
+1 -1
View File
@@ -15,7 +15,7 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "packages/tsconfig" "directory": "packages/tsconfig"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.7.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
+1 -1
View File
@@ -15,7 +15,7 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "configs/tsdown" "directory": "configs/tsdown"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.7.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
+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);
+2 -1
View File
@@ -17,11 +17,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "core/crdt" "directory": "core/crdt"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.7.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
+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);
+2 -1
View File
@@ -13,11 +13,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "core/encoding" "directory": "core/encoding"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.7.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
+1 -1
View File
@@ -15,7 +15,7 @@ const ASCII_ZERO = 0x30;
* luhn('4111 1111 1111 1111'); // true * luhn('4111 1111 1111 1111'); // true
* luhn('4111 1111 1111 1112'); // false * luhn('4111 1111 1111 1112'); // false
* *
* @since 0.0.2 * @since 0.0.1
*/ */
export function luhn(value: string): boolean { export function luhn(value: string): boolean {
const digits = value.replaceAll(NON_DIGIT, ''); const digits = value.replaceAll(NON_DIGIT, '');
+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);
+2 -1
View File
@@ -15,11 +15,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "core/fetch" "directory": "core/fetch"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.7.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
+3 -3
View File
@@ -128,7 +128,7 @@ import type { FetchExecuteMiddleware, FetchHook, FetchHooks, FetchOptions, Fetch
* }); * });
* await billing('/invoices', { method: 'POST', body: { amount: 100 } }); * await billing('/invoices', { method: 'POST', body: { amount: 100 } });
* *
* @since 0.1.0 * @since 0.0.1
*/ */
export function definePlugin< export function definePlugin<
const Name extends string, const Name extends string,
@@ -228,7 +228,7 @@ function applyDefaults(
* Ordering: plugin defaults (in declaration order) → user defaults (user wins). * Ordering: plugin defaults (in declaration order) → user defaults (user wins).
* Headers are merged independently through a single Headers instance. * Headers are merged independently through a single Headers instance.
* *
* @since 0.1.0 * @since 0.0.1
*/ */
export function composePlugins( export function composePlugins(
plugins: readonly FetchPlugin[] | undefined, plugins: readonly FetchPlugin[] | undefined,
@@ -331,7 +331,7 @@ function composeExecute(middlewares: readonly FetchExecuteMiddleware[]): FetchEx
* @description Runs all instance-level (plugin) hooks for a single phase, then the * @description Runs all instance-level (plugin) hooks for a single phase, then the
* optional user per-request hook(s). Avoids allocating an intermediate array per call. * optional user per-request hook(s). Avoids allocating an intermediate array per call.
* *
* @since 0.1.0 * @since 0.0.1
*/ */
export async function runHookPhase<C>( export async function runHookPhase<C>(
instance: ReadonlyArray<FetchHook<C>> | undefined, instance: ReadonlyArray<FetchHook<C>> | undefined,
+1 -1
View File
@@ -44,7 +44,7 @@ function shouldRetryStatus(options: ResolvedFetchOptions, status: number): boole
* *
* Auto-registered by `createFetch`; disable per-request via `retry: false`. * Auto-registered by `createFetch`; disable per-request via `retry: false`.
* *
* @since 0.1.0 * @since 0.0.1
*/ */
export function retryPlugin() { export function retryPlugin() {
return definePlugin({ return definePlugin({
+1 -1
View File
@@ -20,7 +20,7 @@ const baseSignals = new WeakMap<object, AbortSignal | undefined>();
* *
* Auto-registered by `createFetch`; no-op when `timeout` is unset. * Auto-registered by `createFetch`; no-op when `timeout` is unset.
* *
* @since 0.1.0 * @since 0.0.1
*/ */
export function timeoutPlugin() { export function timeoutPlugin() {
return definePlugin({ return definePlugin({
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/platform", "name": "@robonen/platform",
"version": "0.0.4", "version": "0.0.5",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Platform dependent utilities for javascript development", "description": "Platform dependent utilities for javascript development",
"keywords": [ "keywords": [
@@ -18,11 +18,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "packages/platform" "directory": "packages/platform"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.7.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
+2 -2
View File
@@ -5,7 +5,7 @@
* @category Multi * @category Multi
* @description Global object that works in any environment * @description Global object that works in any environment
* *
* @since 0.0.1 * @since 0.0.2
*/ */
export const _global export const _global
= typeof globalThis !== 'undefined' = typeof globalThis !== 'undefined'
@@ -23,6 +23,6 @@ export const _global
* @category Multi * @category Multi
* @description Check if the current environment is the client * @description Check if the current environment is the client
* *
* @since 0.0.1 * @since 0.0.2
*/ */
export const isClient = typeof window !== 'undefined' && typeof document !== 'undefined'; export const isClient = typeof window !== 'undefined' && typeof document !== 'undefined';
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/stdlib", "name": "@robonen/stdlib",
"version": "0.0.9", "version": "0.0.10",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "A collection of tools, utilities, and helpers for TypeScript", "description": "A collection of tools, utilities, and helpers for TypeScript",
"keywords": [ "keywords": [
@@ -18,11 +18,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "packages/stdlib" "directory": "packages/stdlib"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.7.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
+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,
], ],
}, },
+5 -4
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"
}, },
@@ -25,11 +26,11 @@
"@nuxt/fonts": "^0.14.0", "@nuxt/fonts": "^0.14.0",
"@nuxt/kit": "^4.4.8", "@nuxt/kit": "^4.4.8",
"@robonen/eslint": "workspace:*", "@robonen/eslint": "workspace:*",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.1",
"eslint": "catalog:", "eslint": "catalog:",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"nuxt": "catalog:", "nuxt": "catalog:",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.1",
"ts-morph": "^28.0.0", "ts-morph": "^28.0.0",
"vue": "catalog:", "vue": "catalog:",
"vue-router": "^5.1.0" "vue-router": "^5.1.0"
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Disallow:
+2 -2
View File
@@ -16,7 +16,7 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "packages/renovate" "directory": "packages/renovate"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.7.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
@@ -27,6 +27,6 @@
"test": "renovate-config-validator ./default.json" "test": "renovate-config-validator ./default.json"
}, },
"devDependencies": { "devDependencies": {
"renovate": "^43.216.1" "renovate": "^43.228.0"
} }
} }
+2 -2
View File
@@ -15,13 +15,13 @@
"type": "git", "type": "git",
"url": "git+https://github.com/robonen/tools.git" "url": "git+https://github.com/robonen/tools.git"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.7.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.2", "@types/node": "^25.9.3",
"@vitest/coverage-v8": "catalog:", "@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:", "@vitest/ui": "catalog:",
"citty": "^0.2.2", "citty": "^0.2.2",
+2836 -3603
View File
File diff suppressed because it is too large Load Diff
+22 -8
View File
@@ -6,20 +6,34 @@ packages:
- vue/*/playground - vue/*/playground
- docs - docs
allowBuilds:
'@parcel/watcher': true
core-js-pure: true
dtrace-provider: true
esbuild: true
re2: true
unrs-resolver: true
catalog: catalog:
'@stylistic/eslint-plugin': ^5.10.0 '@stylistic/eslint-plugin': ^5.10.0
'@vitest/browser': ^4.1.8 '@vitest/browser': ^4.1.9
'@vitest/coverage-v8': ^4.1.8 '@vitest/coverage-v8': ^4.1.9
'@vitest/ui': ^4.1.8 '@vitest/ui': ^4.1.9
'@vue/shared': ^3.5.35 '@vue/shared': ^3.5.38
'@vue/test-utils': ^2.4.11 '@vue/test-utils': ^2.4.11
eslint: ^10.4.1 eslint: ^10.5.0
jsdom: ^29.1.1 jsdom: ^29.1.1
nuxt: ^4.4.8 nuxt: ^4.4.8
tsdown: ^0.22.2 tsdown: ^0.22.3
vitest: ^4.1.8 vitest: ^4.1.9
vue: ^3.5.35 vue: ^3.5.38
ignoredBuiltDependencies: ignoredBuiltDependencies:
- '@parcel/watcher' - '@parcel/watcher'
- esbuild - esbuild
minimumReleaseAgeExclude:
- ast-kit@3.0.0
- renovate@43.228.0
- rolldown-plugin-dts@0.26.0
- tsdown@0.22.3
+12 -8
View File
@@ -15,11 +15,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "vue/primitives" "directory": "vue/primitives"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.7.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
@@ -58,23 +59,26 @@
"@robonen/tsconfig": "workspace:*", "@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*", "@robonen/tsdown": "workspace:*",
"@vitest/browser": "catalog:", "@vitest/browser": "catalog:",
"@vitest/browser-playwright": "^4.1.8", "@vitest/browser-playwright": "^4.1.9",
"@vue/test-utils": "catalog:", "@vue/test-utils": "catalog:",
"axe-core": "^4.12.0", "axe-core": "^4.12.1",
"eslint": "catalog:", "eslint": "catalog:",
"playwright": "^1.60.0", "playwright": "^1.61.0",
"tsdown": "catalog:", "tsdown": "catalog:",
"unplugin-vue": "^7.2.0", "unplugin-vue": "^7.2.0",
"vitest-browser-vue": "^2.1.0", "vitest-browser-vue": "^2.1.0",
"vue-tsc": "^3.3.4" "vue": "catalog:",
"vue-tsc": "^3.3.5"
}, },
"dependencies": { "dependencies": {
"@floating-ui/vue": "^1.1.11", "@floating-ui/vue": "^2.0.0",
"@robonen/encoding": "workspace:*", "@robonen/encoding": "workspace:*",
"@robonen/platform": "workspace:*", "@robonen/platform": "workspace:*",
"@robonen/stdlib": "workspace:*", "@robonen/stdlib": "workspace:*",
"@robonen/vue": "workspace:*", "@robonen/vue": "workspace:*",
"@vue/shared": "catalog:", "@vue/shared": "catalog:"
"vue": "catalog:" },
"peerDependencies": {
"vue": "^3.5"
} }
} }
+3 -3
View File
@@ -17,10 +17,10 @@
}, },
"devDependencies": { "devDependencies": {
"@robonen/tsconfig": "workspace:*", "@robonen/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.1",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.7",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.1",
"vite": "^8.0.16", "vite": "^8.0.16",
"vue-tsc": "^3.3.4" "vue-tsc": "^3.3.5"
} }
} }
@@ -12,8 +12,14 @@ import {
CalendarHeadCell, CalendarHeadCell,
CalendarRoot, CalendarRoot,
} from '../index'; } from '../index';
import { nativeDateAdapter } from '../../../utilities/config-provider';
import { findFirstFocusableDate, getLocaleWeekStartsOn, toIsoDate } from '../utils'; import { findFirstFocusableDate, getLocaleWeekStartsOn, toIsoDate } from '../utils';
// A date adapter whose "today" sits far outside any month exercised below, so
// the roving-tabindex fallback never anchors on the real system date (which
// would otherwise make date-sensitive expectations flaky).
const fixedTodayAdapter = { ...nativeDateAdapter, now: () => new Date(2020, 0, 1) };
function mountCalendar( function mountCalendar(
props: Record<string, unknown> = {}, props: Record<string, unknown> = {},
options: Record<string, unknown> = {}, options: Record<string, unknown> = {},
@@ -203,6 +209,7 @@ describe('Calendar — roving fallback tabindex', () => {
const w = mountCalendar({ const w = mountCalendar({
defaultPlaceholder: new Date(2026, 5, 1), defaultPlaceholder: new Date(2026, 5, 1),
isDateDisabled: (d: Date) => d.getMonth() === 5 && d.getDate() < 16, isDateDisabled: (d: Date) => d.getMonth() === 5 && d.getDate() < 16,
dateAdapter: fixedTodayAdapter,
}); });
const focusable = w.findAll('[data-primitives-calendar-cell-trigger][tabindex="0"]'); const focusable = w.findAll('[data-primitives-calendar-cell-trigger][tabindex="0"]');
expect(focusable).toHaveLength(1); expect(focusable).toHaveLength(1);
@@ -149,19 +149,27 @@ describe('scroll-area — ref forwarding', () => {
describe('scroll-area — glimpse type', () => { describe('scroll-area — glimpse type', () => {
it('accepts type="glimpse" and reveals scrollbars on pointer enter', async () => { it('accepts type="glimpse" and reveals scrollbars on pointer enter', async () => {
track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body })); const w = track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body }));
await waitFrames(); await waitFrames();
const root = document.querySelector('[dir]') as HTMLElement; const root = w.element as HTMLElement;
root.dispatchEvent(new PointerEvent('pointerenter')); root.dispatchEvent(new PointerEvent('pointerenter'));
await waitFrames(); await waitFrames();
expect(document.querySelectorAll('[data-state="visible"]').length).toBeGreaterThan(0); // Scope to this component's root: browser-mode suites share one document,
// so a global query can also count scrollbars mounted by other suites.
expect(root.querySelectorAll('[data-state="visible"]').length).toBeGreaterThan(0);
}); });
it('glimpse stays hidden before any interaction', async () => { it('glimpse stays hidden when the pointer is away', async () => {
track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body })); const w = track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body }));
const root = w.element as HTMLElement;
// Browser mode uses a real cursor: the area mounts at the top-left, so a
// leftover pointer from a previous suite can land on it and fire a stray
// `pointerenter` (revealing the glimpse). Let that settle, then assert the
// deterministic "pointer not over the area" state via `pointerleave`.
await waitFrames(); await waitFrames();
// No pointer enter / scroll => no visible scrollbar yet. root.dispatchEvent(new PointerEvent('pointerleave'));
expect(document.querySelectorAll('[data-state="visible"]').length).toBe(0); await waitFrames();
expect(root.querySelectorAll('[data-state="visible"]').length).toBe(0);
}); });
}); });
@@ -31,7 +31,7 @@ function isInClosedPopover(el: Element): boolean {
* *
* @param {MaybeComputedElementRef} target Element whose siblings should be aria-hidden * @param {MaybeComputedElementRef} target Element whose siblings should be aria-hidden
* *
* @since 0.0.14 * @since 0.0.1
*/ */
export function useHideOthers(target: MaybeComputedElementRef): void { export function useHideOthers(target: MaybeComputedElementRef): void {
if (!defaultWindow) return; if (!defaultWindow) return;
+4 -1
View File
@@ -11,7 +11,10 @@ export default defineConfig({
dts: { vue: true }, dts: { vue: true },
deps: { deps: {
neverBundle: ['vue'], neverBundle: ['vue'],
alwaysBundle: [/^@robonen\//, '@vue/shared'], // `@robonen/*` stay external (deduped by the package manager); only the
// stateless `@vue/shared` helpers are inlined (a Vue internal consumers
// don't install directly, so it can't be externalized reliably).
alwaysBundle: ['@vue/shared'],
}, },
inputOptions: { inputOptions: {
resolve: { resolve: {
+3
View File
@@ -13,6 +13,9 @@ export default defineConfig({
'@': resolve(__dirname, './src'), '@': resolve(__dirname, './src'),
}, },
}, },
optimizeDeps: {
include: ['@robonen/vue'],
},
test: { test: {
browser: { browser: {
enabled: true, enabled: true,
+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);
+4 -4
View File
@@ -19,12 +19,12 @@
"devDependencies": { "devDependencies": {
"@robonen/eslint": "workspace:*", "@robonen/eslint": "workspace:*",
"@robonen/tsconfig": "workspace:*", "@robonen/tsconfig": "workspace:*",
"@storybook/addon-a11y": "^10.4.2", "@storybook/addon-a11y": "^10.4.6",
"@storybook/addon-docs": "^10.4.2", "@storybook/addon-docs": "^10.4.6",
"@storybook/vue3-vite": "^10.4.2", "@storybook/vue3-vite": "^10.4.6",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.7",
"eslint": "catalog:", "eslint": "catalog:",
"storybook": "^10.4.2", "storybook": "^10.4.6",
"vite": "^8.0.16" "vite": "^8.0.16"
} }
} }
+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);
+9 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/vue", "name": "@robonen/vue",
"version": "0.0.13", "version": "0.0.14",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Collection of powerful tools for Vue", "description": "Collection of powerful tools for Vue",
"keywords": [ "keywords": [
@@ -16,11 +16,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "vue/toolkit" "directory": "vue/toolkit"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.7.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
@@ -49,11 +50,14 @@
"@robonen/tsdown": "workspace:*", "@robonen/tsdown": "workspace:*",
"@vue/test-utils": "catalog:", "@vue/test-utils": "catalog:",
"eslint": "catalog:", "eslint": "catalog:",
"tsdown": "catalog:" "tsdown": "catalog:",
"vue": "catalog:"
}, },
"dependencies": { "dependencies": {
"@robonen/platform": "workspace:*", "@robonen/platform": "workspace:*",
"@robonen/stdlib": "workspace:*", "@robonen/stdlib": "workspace:*"
"vue": "catalog:" },
"peerDependencies": {
"vue": "^3.5"
} }
} }
@@ -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 }}×
@@ -167,7 +167,7 @@ const RESERVED_KEYS = [
* // Shorthand: third argument is the duration in milliseconds * // Shorthand: third argument is the duration in milliseconds
* useAnimate(el, { opacity: [0, 1] }, 500); * useAnimate(el, { opacity: [0, 1] }, 500);
* *
* @since 0.0.15 * @since 0.0.14
*/ */
export function useAnimate( export function useAnimate(
target: MaybeComputedElementRef, target: MaybeComputedElementRef,
@@ -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
@@ -81,7 +81,7 @@ export interface UseCountdownReturn extends ResumableActions {
* onComplete: () => console.log('done'), * onComplete: () => console.log('done'),
* }); * });
* *
* @since 0.0.15 * @since 0.0.14
*/ */
export function useCountdown( export function useCountdown(
initialCountdown: MaybeRefOrGetter<number>, initialCountdown: MaybeRefOrGetter<number>,
@@ -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 }}
@@ -44,14 +44,15 @@ export type UseDateFormatReturn = ComputedRef<string>;
// Matches a token, or a `[literal]` escape that is emitted verbatim. // Matches a token, or a `[literal]` escape that is emitted verbatim.
const REGEX_FORMAT const REGEX_FORMAT
= /* #__PURE__ */ /[YMDHhms]o|\[([^\]]+)\]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a{1,2}|A{1,2}|m{1,2}|s{1,2}|z{1,4}|SSS/g; = /[YMDHhms]o|\[([^\]]+)\]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a{1,2}|A{1,2}|m{1,2}|s{1,2}|z{1,4}|SSS/g;
// Loose ISO-ish parser used for date strings without a trailing `Z`. The optional // Loose ISO-ish parser used for date strings without a trailing `Z`. The optional
// separators make adjacent digit groups technically "misleading" to the linter, // separators make adjacent digit groups technically "misleading" to the linter,
// but this is the deliberate lenient dayjs parser (accepts `2024-01-01` and // but this is the deliberate lenient dayjs parser (accepts `2024-01-01` and
// `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 = /^(\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 = /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;
@@ -206,7 +207,7 @@ export function formatDate(
* customMeridiem: (h) => (h < 12 ? 'morning' : 'evening'), * customMeridiem: (h) => (h < 12 ? 'morning' : 'evening'),
* }); * });
* *
* @since 0.0.15 * @since 0.0.14
*/ */
export function useDateFormat( export function useDateFormat(
date: MaybeRefOrGetter<DateLike>, date: MaybeRefOrGetter<DateLike>,
@@ -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"
> >
@@ -59,7 +59,7 @@ export type UseIntervalReturn = Readonly<ShallowRef<number>> | UseIntervalContro
* @example * @example
* const { counter, isActive, pause, resume, reset } = useInterval(1000, { controls: true }); * const { counter, isActive, pause, resume, reset } = useInterval(1000, { controls: true });
* *
* @since 0.0.15 * @since 0.0.14
*/ */
export function useInterval(interval?: MaybeRefOrGetter<number>, options?: UseIntervalOptions<false>): Readonly<ShallowRef<number>>; export function useInterval(interval?: MaybeRefOrGetter<number>, options?: UseIntervalOptions<false>): Readonly<ShallowRef<number>>;
export function useInterval(interval: MaybeRefOrGetter<number>, options: UseIntervalOptions<true>): UseIntervalControls; export function useInterval(interval: MaybeRefOrGetter<number>, options: UseIntervalOptions<true>): UseIntervalControls;
@@ -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
@@ -70,7 +70,7 @@ export type UseNowReturn<Controls extends boolean>
* // Run a callback on every update * // Run a callback on every update
* useNow({ interval: 1000, callback: date => console.log(date.toISOString()) }); * useNow({ interval: 1000, callback: date => console.log(date.toISOString()) });
* *
* @since 0.0.15 * @since 0.0.14
*/ */
export function useNow(options?: UseNowOptions<false>): Ref<Date>; export function useNow(options?: UseNowOptions<false>): Ref<Date>;
export function useNow(options: UseNowOptions<true>): UseNowControls; export function useNow(options: UseNowOptions<true>): UseNowControls;
@@ -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 = /\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' : ''}`,
@@ -195,7 +197,7 @@ function defaultFullDateFormatter(date: Date): string {
* @example * @example
* formatTimeAgo(new Date(Date.now() - 3 * 60_000)); // '3 minutes ago' * formatTimeAgo(new Date(Date.now() - 3 * 60_000)); // '3 minutes ago'
* *
* @since 0.0.15 * @since 0.0.14
*/ */
export function formatTimeAgo<UnitNames extends string = UseTimeAgoUnitName>( export function formatTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
from: Date, from: Date,
@@ -301,7 +303,7 @@ export function formatTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
* fullDateFormatter: d => d.toLocaleDateString('fr-FR'), * fullDateFormatter: d => d.toLocaleDateString('fr-FR'),
* }); * });
* *
* @since 0.0.15 * @since 0.0.14
*/ */
export function useTimeAgo<UnitNames extends string = UseTimeAgoUnitName>( export function useTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
time: MaybeRefOrGetter<Date | number | string>, time: MaybeRefOrGetter<Date | number | string>,
@@ -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
@@ -61,7 +61,7 @@ export type UseTimeoutReturn
* // Run a callback when the timeout elapses * // Run a callback when the timeout elapses
* useTimeout(5000, { callback: refresh }); * useTimeout(5000, { callback: refresh });
* *
* @since 0.0.15 * @since 0.0.14
*/ */
export function useTimeout(interval?: MaybeRefOrGetter<number>, options?: UseTimeoutOptions<false>): ComputedRef<boolean>; export function useTimeout(interval?: MaybeRefOrGetter<number>, options?: UseTimeoutOptions<false>): ComputedRef<boolean>;
export function useTimeout(interval: MaybeRefOrGetter<number>, options: UseTimeoutOptions<true>): UseTimeoutControls; export function useTimeout(interval: MaybeRefOrGetter<number>, options: UseTimeoutOptions<true>): UseTimeoutControls;
@@ -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
*/ */
@@ -58,7 +58,7 @@ export interface UseTimeoutFnReturn<Args extends any[]> {
* // Fire once now and again after the delay * // Fire once now and again after the delay
* useTimeoutFn(refresh, 5000, { immediateCallback: true }); * useTimeoutFn(refresh, 5000, { immediateCallback: true });
* *
* @since 0.0.15 * @since 0.0.14
*/ */
export function useTimeoutFn<T extends AnyFunction>( export function useTimeoutFn<T extends AnyFunction>(
cb: T, cb: T,
@@ -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"
> >
@@ -82,7 +82,7 @@ export type UseTimestampReturn<Controls extends boolean> = Controls extends true
* const offset = ref(0); * const offset = ref(0);
* const now = useTimestamp({ offset }); * const now = useTimestamp({ offset });
* *
* @since 0.0.15 * @since 0.0.14
*/ */
export function useTimestamp(options?: UseTimestampOptions<false>): Ref<number>; export function useTimestamp(options?: UseTimestampOptions<false>): Ref<number>;
export function useTimestamp(options: UseTimestampOptions<true>): UseTimestampControls; export function useTimestamp(options: UseTimestampOptions<true>): UseTimestampControls;
@@ -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
@@ -217,7 +218,7 @@ function valuesEqual(a: TransitionValue, b: TransitionValue): boolean {
* const color = ref([0, 0, 0]); * const color = ref([0, 0, 0]);
* const animated = useTransition(color, { duration: 1000 }); * const animated = useTransition(color, { duration: 1000 });
* *
* @since 0.0.15 * @since 0.0.14
*/ */
export function useTransition<T extends TransitionValue>( export function useTransition<T extends TransitionValue>(
source: MaybeRefOrGetter<T>, source: MaybeRefOrGetter<T>,
@@ -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> {
@@ -58,7 +58,7 @@ function isArrayDifferenceOptions<T>(value: unknown): value is UseArrayDifferenc
* const b = ref([2, 3, 4]); * const b = ref([2, 3, 4]);
* const symmetric = useArrayDifference(a, b, { symmetric: true }); // [1, 4] * const symmetric = useArrayDifference(a, b, { symmetric: true }); // [1, 4]
* *
* @since 0.0.15 * @since 0.0.14
*/ */
export function useArrayDifference<T>( export function useArrayDifference<T>(
list: MaybeRefOrGetter<T[]>, list: MaybeRefOrGetter<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

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