feat(storage): enhance useStorageAsync with cross-instance sync and event handling

This commit is contained in:
2026-06-10 15:09:46 +07:00
parent 07937e26db
commit a82f5f2dfd
25 changed files with 3725 additions and 199 deletions
+108 -44
View File
@@ -9,51 +9,54 @@
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
--font-sans: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace;
--font-display: 'Bricolage Grotesque', 'IBM Plex Sans', system-ui, sans-serif;
--radius-card: 0.75rem;
--radius-card: 0.5rem;
}
/* ── Semantic design tokens — Geist-minimal ──────────────────────────────── */
/* ── Semantic design tokens — ink on warm paper, signal-orange instruments ──
The site reads like a tool-maker's field manual: warm neutral surfaces,
hairline rules, international-orange accents, code-comment labels. */
:root {
--bg: #ffffff;
--bg-subtle: #fafafa;
--bg-elevated: #ffffff;
--bg-inset: #f4f4f5;
--border: #ececec;
--border-strong: #d8d8dc;
--fg: #18181b;
--fg-muted: #52525b;
--fg-subtle: #a1a1aa;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-fg: #ffffff;
--accent-subtle: #eef3ff;
--accent-text: #2563eb;
--header-bg: rgba(255, 255, 255, 0.72);
--ring: rgba(37, 99, 235, 0.35);
--shadow-card: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
--bg: #faf8f3;
--bg-subtle: #f4f1e8;
--bg-elevated: #fffdf8;
--bg-inset: #eeeadf;
--border: #e5dfd0;
--border-strong: #cfc6b1;
--fg: #211e18;
--fg-muted: #5d574b;
--fg-subtle: #93897a;
--accent: #d9480f;
--accent-hover: #bf3f0d;
--accent-fg: #fffdf8;
--accent-subtle: #f7e7d8;
--accent-text: #c2410c;
--header-bg: rgba(250, 248, 243, 0.82);
--ring: rgba(217, 72, 15, 0.35);
--shadow-card: 0 1px 2px rgba(56, 44, 28, 0.05), 0 1px 3px rgba(56, 44, 28, 0.07);
color-scheme: light;
}
.dark {
--bg: #0a0a0a;
--bg-subtle: #0f0f10;
--bg-elevated: #141416;
--bg-inset: #1b1b1e;
--border: #232327;
--border-strong: #34343a;
--fg: #ededed;
--fg-muted: #a1a1aa;
--fg-subtle: #6c6c75;
--accent: #3b82f6;
--accent-hover: #60a5fa;
--accent-fg: #ffffff;
--accent-subtle: #14203a;
--accent-text: #74a8ff;
--header-bg: rgba(10, 10, 10, 0.72);
--ring: rgba(59, 130, 246, 0.4);
--bg: #161310;
--bg-subtle: #1b1813;
--bg-elevated: #211d17;
--bg-inset: #2a251c;
--border: #322c22;
--border-strong: #4a4231;
--fg: #ece7db;
--fg-muted: #b2a995;
--fg-subtle: #7d7363;
--accent: #ff7d33;
--accent-hover: #ff9a59;
--accent-fg: #1d0e04;
--accent-subtle: #3a2415;
--accent-text: #ff9c63;
--header-bg: rgba(22, 19, 16, 0.82);
--ring: rgba(255, 125, 51, 0.4);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.5);
color-scheme: dark;
}
@@ -90,6 +93,55 @@ code, pre, kbd {
*::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 9999px; }
*::-webkit-scrollbar-track { background: transparent; }
/* ── Identity helpers ─────────────────────────────────────────────────────── */
/* Section labels styled as code comments: `// sensors` */
.comment-label {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.07em;
text-transform: lowercase;
color: var(--fg-subtle);
}
.comment-label::before {
content: '// ';
color: var(--accent-text);
opacity: 0.75;
}
/* Engineering-grid backdrop for heroes, faded out radially */
.blueprint {
background-image:
linear-gradient(var(--border) 1px, transparent 1px),
linear-gradient(90deg, var(--border) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: radial-gradient(ellipse 75% 90% at 24% 8%, black 25%, transparent 72%);
-webkit-mask-image: radial-gradient(ellipse 75% 90% at 24% 8%, black 25%, transparent 72%);
}
/* Staggered rise-in for card grids (landing / package index) */
@keyframes rise-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.stagger > * {
animation: rise-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.stagger > *:nth-child(1) { animation-delay: 0.03s; }
.stagger > *:nth-child(2) { animation-delay: 0.07s; }
.stagger > *:nth-child(3) { animation-delay: 0.11s; }
.stagger > *:nth-child(4) { animation-delay: 0.15s; }
.stagger > *:nth-child(5) { animation-delay: 0.19s; }
.stagger > *:nth-child(6) { animation-delay: 0.23s; }
.stagger > *:nth-child(7) { animation-delay: 0.27s; }
.stagger > *:nth-child(8) { animation-delay: 0.31s; }
.stagger > *:nth-child(n+9) { animation-delay: 0.35s; }
@media (prefers-reduced-motion: reduce) {
.stagger > * { animation: none; }
}
/* Shiki dual-theme: switch to dark colors under .dark */
.dark .shiki,
.dark .shiki span {
@@ -100,6 +152,9 @@ code, pre, kbd {
text-decoration: var(--shiki-dark-text-decoration) !important;
}
/* Code blocks sit on the warm surface, not on the theme's own background */
.shiki { background-color: transparent !important; }
/* ── Markdown (guide) typography ──────────────────────────────────────────── */
.prose-docs {
color: var(--fg-muted);
@@ -109,6 +164,7 @@ code, pre, kbd {
.prose-docs > :first-child { margin-top: 0; }
.prose-docs h1 {
color: var(--fg);
font-family: var(--font-display);
font-size: 1.875rem;
font-weight: 700;
letter-spacing: -0.02em;
@@ -116,6 +172,7 @@ code, pre, kbd {
}
.prose-docs h2 {
color: var(--fg);
font-family: var(--font-display);
font-size: 1.375rem;
font-weight: 650;
letter-spacing: -0.01em;
@@ -136,27 +193,28 @@ code, pre, kbd {
color: var(--accent-text);
text-decoration: none;
font-weight: 500;
border-bottom: 1px dotted var(--border-strong);
}
.prose-docs a:hover { text-decoration: underline; }
.prose-docs a:hover { border-bottom-color: var(--accent-text); }
.prose-docs strong { color: var(--fg); font-weight: 600; }
.prose-docs ul, .prose-docs ol { margin: 1rem 0; padding-left: 1.5rem; }
.prose-docs ul { list-style: disc; }
.prose-docs ol { list-style: decimal; }
.prose-docs li { margin: 0.375rem 0; }
.prose-docs li::marker { color: var(--fg-subtle); }
.prose-docs li::marker { color: var(--accent-text); }
.prose-docs blockquote {
border-left: 3px solid var(--border-strong);
border-left: 3px solid var(--accent);
padding-left: 1rem;
margin: 1.25rem 0;
color: var(--fg-muted);
}
.prose-docs hr { border: 0; border-top: 1px solid var(--border); margin: 2rem 0; }
.prose-docs hr { border: 0; border-top: 1px dashed var(--border-strong); margin: 2rem 0; }
/* inline code */
.prose-docs :not(pre) > code {
font-size: 0.85em;
background-color: var(--bg-inset);
border: 1px solid var(--border);
border-radius: 0.375rem;
border-radius: 0.25rem;
padding: 0.1rem 0.35rem;
color: var(--fg);
}
@@ -164,7 +222,7 @@ code, pre, kbd {
.prose-docs pre {
background-color: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: 0.625rem;
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
margin: 1.25rem 0;
@@ -183,7 +241,13 @@ code, pre, kbd {
padding: 0.5rem 0.75rem;
text-align: left;
}
.prose-docs th { background-color: var(--bg-subtle); color: var(--fg); font-weight: 600; }
.prose-docs th {
background-color: var(--bg-subtle);
color: var(--fg);
font-weight: 600;
font-family: var(--font-mono);
font-size: 0.8125rem;
}
/* Page-enter fade for route transitions */
.page-enter-active, .page-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; }
+6 -13
View File
@@ -3,17 +3,8 @@
size?: 'sm' | 'md';
}>();
const kindColors: Record<string, string> = {
function: 'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300',
class: 'bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300',
interface: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300',
type: 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300',
enum: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300',
variable: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-500/15 dark:text-zinc-300',
component: 'bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-500/15 dark:text-fuchsia-300',
guide: 'bg-teal-100 text-teal-700 dark:bg-teal-500/15 dark:text-teal-300',
};
// Monochrome instrument badges: the kind reads from the glyph, not a color.
// Components are the one structural exception and carry the accent.
const kindLabels: Record<string, string> = {
function: 'fn',
class: 'C',
@@ -29,8 +20,10 @@ const kindLabels: Record<string, string> = {
<template>
<span
:class="[
'inline-flex items-center justify-center rounded-md font-mono font-semibold shrink-0',
kindColors[kind] ?? kindColors.variable,
'inline-flex items-center justify-center rounded font-mono font-medium shrink-0 border',
kind === 'component'
? 'border-(--accent-subtle) bg-(--accent-subtle) text-(--accent-text)'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)',
size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs',
]"
:title="kind"
+5 -7
View File
@@ -71,7 +71,7 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg>
<span class="hidden sm:inline flex-1 text-left">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>
</button>
@@ -84,17 +84,15 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm" @click="close" />
<div class="fixed inset-x-0 top-[12vh] mx-auto max-w-xl px-4">
<div class="bg-(--bg-elevated) rounded-2xl 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)">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-(--fg-subtle) shrink-0">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg>
<span class="font-mono text-base text-(--accent-text) select-none shrink-0"></span>
<input
v-model="query"
data-search-input
type="text"
placeholder="Search across all packages…"
class="w-full py-3.5 px-3 bg-transparent text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none text-[15px]"
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]"
>
<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>
+4 -4
View File
@@ -6,16 +6,16 @@
const variantClasses: Record<string, string> = {
since: 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)',
neutral: 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)',
test: 'bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-500/20',
demo: 'bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-500/10 dark:text-blue-300 dark:border-blue-500/20',
wip: 'bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-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)',
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>
<template>
<span
:class="[
'inline-flex items-center px-2 py-0.5 text-[11px] font-medium rounded-full leading-none h-5',
'inline-flex items-center px-1.5 py-0.5 font-mono text-[10px] font-medium rounded leading-none h-4.5 lowercase',
variantClasses[variant ?? 'since'],
]"
>
+2 -2
View File
@@ -46,8 +46,8 @@ function go(id: string) {
<template>
<nav v-if="items.length > 0" class="text-sm">
<div class="text-[11px] font-semibold uppercase tracking-wider text-(--fg-subtle) mb-3">
On this page
<div class="comment-label mb-3">
on this page
</div>
<ul class="space-y-1 border-l border-(--border)">
<li v-for="item in items" :key="item.id">
+118 -24
View File
@@ -17,6 +17,60 @@ function isActive(pkgSlug: string, slug: string) {
return route.path === `/${pkgSlug}/${slug}`;
}
// ── Category tree: collapsed by default, filterable ───────────────────────
const navQuery = ref('');
const openCategories = ref(new Set<string>());
function toggleCategory(slug: string) {
if (openCategories.value.has(slug))
openCategories.value.delete(slug);
else
openCategories.value.add(slug);
openCategories.value = new Set(openCategories.value);
}
// Auto-open the category that contains the current page
watch([currentPackage, () => route.path], () => {
const pkg = currentPackage.value;
if (!pkg || pkg.kind !== 'api') return;
for (const category of pkg.categories) {
if (category.items.some(item => isActive(pkg.slug, item.slug)))
openCategories.value.add(category.slug);
}
openCategories.value = new Set(openCategories.value);
}, { immediate: true });
// Reset the filter when navigating to another package
watch(currentPackageSlug, () => {
navQuery.value = '';
});
const visibleCategories = computed(() => {
const pkg = currentPackage.value;
if (!pkg || pkg.kind !== 'api') return [];
const query = navQuery.value.trim().toLowerCase();
if (!query) return pkg.categories;
return pkg.categories
.map(category => ({
...category,
items: category.items.filter(item => item.name.toLowerCase().includes(query)),
}))
.filter(category => category.items.length > 0);
});
function isCategoryOpen(slug: string) {
// Filtering expands every matching category
return navQuery.value.trim() !== '' || openCategories.value.has(slug);
}
watch(() => route.path, () => {
isSidebarOpen.value = false;
});
@@ -38,10 +92,13 @@ watch(() => route.path, () => {
</svg>
</button>
<NuxtLink to="/" class="flex items-center gap-2 font-semibold text-[15px] mr-auto">
<span class="inline-flex items-center justify-center w-7 h-7 rounded-lg bg-(--fg) text-(--bg) text-xs font-bold">R</span>
<span class="hidden sm:flex items-center">
<span class="text-(--accent-text)">@robonen</span><span class="text-(--fg-subtle)">/</span><span class="text-(--fg)">tools</span>
<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>
<span class="hidden sm:flex items-baseline font-mono text-[13.5px] tracking-tight">
<span class="text-(--fg-subtle)">~/</span><span class="text-(--fg) font-medium">robonen</span><span class="text-(--fg-subtle)">/</span><span class="text-(--accent-text) font-medium">tools</span>
<span class="ml-1 inline-block w-1.75 h-3.75 translate-y-0.5 bg-(--accent) opacity-0 group-hover:opacity-80 group-hover:animate-pulse" />
</span>
</NuxtLink>
@@ -71,29 +128,38 @@ watch(() => route.path, () => {
>
<nav class="h-full overflow-y-auto py-8 px-4 lg:pr-6 lg:pl-0 overscroll-contain">
<div v-for="grp in groups" :key="grp.group" class="mb-7">
<div class="text-[11px] font-semibold uppercase tracking-wider text-(--fg-subtle) mb-2 px-2">
{{ grp.label }}
</div>
<div class="comment-label mb-2 px-2">{{ grp.label.toLowerCase() }}</div>
<ul class="space-y-0.5">
<li v-for="pkg in grp.packages" :key="pkg.slug">
<NuxtLink
:to="`/${pkg.slug}`"
:class="[
'flex items-center justify-between py-1.5 px-2 rounded-lg text-sm transition-colors',
'flex items-center justify-between py-1.5 px-2 rounded-md text-sm transition-colors',
currentPackageSlug === pkg.slug
? 'text-(--fg) font-medium bg-(--bg-inset)'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
]"
>
<span>{{ pkg.name.replace('@robonen/', '') }}</span>
<span class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">{{ pkg.kind === 'api' ? 'api' : pkg.kind === 'components' ? 'ui' : 'guide' }}</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>
</NuxtLink>
<!-- Expanded tree for the current package -->
<div v-if="currentPackageSlug === pkg.slug && currentPackage" class="mt-1 mb-2 ml-2 pl-3 border-l border-(--border)">
<div v-if="currentPackageSlug === pkg.slug && currentPackage" class="mt-1.5 mb-3 ml-2.5 pl-2.5 border-l border-(--border)">
<!-- Quick filter the tree below collapses to matches -->
<div v-if="currentPackage.kind === 'api'" class="relative mb-2 mt-1">
<span class="absolute left-2 top-1/2 -translate-y-1/2 font-mono text-[11px] text-(--accent-text) select-none"></span>
<input
v-model="navQuery"
type="text"
placeholder="filter…"
class="w-full h-7 pl-6 pr-2 font-mono text-[12px] rounded-md bg-(--bg-subtle) border border-(--border) text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none focus:border-(--border-strong) transition-colors"
>
</div>
<!-- Hand-authored guide sections (intro + prose pages) -->
<div v-if="currentPackage.docs.length" class="mb-2">
<div class="text-[11px] font-medium text-(--fg-subtle) py-1 px-1">Guide</div>
<div v-if="currentPackage.docs.length && !navQuery" class="mb-2">
<div class="comment-label py-1 px-1">guide</div>
<ul>
<li v-if="getIntro(currentPackage)">
<NuxtLink
@@ -101,7 +167,7 @@ watch(() => route.path, () => {
:class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
route.path === `/${pkg.slug}`
? 'text-(--accent-text) bg-(--accent-subtle) font-medium'
? 'text-(--accent-text) font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
]"
>
@@ -114,7 +180,7 @@ watch(() => route.path, () => {
:class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, s.slug)
? 'text-(--accent-text) bg-(--accent-subtle) font-medium'
? 'text-(--accent-text) font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
]"
>
@@ -124,22 +190,50 @@ watch(() => route.path, () => {
</ul>
</div>
<!-- api -->
<!-- api: collapsible categories -->
<template v-if="currentPackage.kind === 'api'">
<div v-for="cat in currentPackage.categories" :key="cat.slug" class="mb-2">
<div class="text-[11px] font-medium text-(--fg-subtle) py-1 px-1">{{ cat.name }}</div>
<ul>
<div v-if="navQuery && visibleCategories.length === 0" class="py-2 px-1 font-mono text-[11px] text-(--fg-subtle)">
no matches
</div>
<div v-for="cat in visibleCategories" :key="cat.slug" class="mb-0.5">
<button
type="button"
class="w-full flex items-center gap-1.5 py-1 px-1 rounded-md cursor-pointer group/cat"
@click="toggleCategory(cat.slug)"
>
<svg
xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
:class="[
'shrink-0 text-(--fg-subtle) transition-transform duration-150',
isCategoryOpen(cat.slug) ? 'rotate-90' : '',
]"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="comment-label group-hover/cat:text-(--fg-muted) transition-colors">{{ cat.name.toLowerCase() }}</span>
<span class="ml-auto font-mono text-[10px] text-(--fg-subtle) tabular-nums">{{ cat.items.length }}</span>
</button>
<ul v-if="isCategoryOpen(cat.slug)" class="mb-1.5">
<li v-for="item in cat.items" :key="item.slug">
<NuxtLink
:to="`/${pkg.slug}/${item.slug}`"
:class="[
'block py-1 px-2 text-[13px] rounded-md font-mono transition-colors truncate',
'flex items-center gap-1.5 py-0.75 px-2 text-[13px] rounded-md font-mono transition-colors',
isActive(pkg.slug, item.slug)
? 'text-(--accent-text) bg-(--accent-subtle) font-medium'
? 'text-(--accent-text) font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
]"
>
{{ item.name }}
<span
:class="[
'shrink-0 text-[10px] select-none transition-opacity',
isActive(pkg.slug, item.slug) ? 'opacity-100 text-(--accent-text)' : 'opacity-0',
]"
></span>
<span class="truncate">{{ item.name }}</span>
</NuxtLink>
</li>
</ul>
@@ -154,7 +248,7 @@ watch(() => route.path, () => {
:class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, c.slug)
? 'text-(--accent-text) bg-(--accent-subtle) font-medium'
? 'text-(--accent-text) font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
]"
>
@@ -171,7 +265,7 @@ watch(() => route.path, () => {
:class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, s.slug)
? 'text-(--accent-text) bg-(--accent-subtle) font-medium'
? 'text-(--accent-text) font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
]"
>
+4 -4
View File
@@ -98,14 +98,14 @@ const toc = computed(() => {
return items;
});
const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-3';
const sectionTitle = 'comment-label mb-3';
</script>
<template>
<div v-if="entry" class="xl:grid xl:grid-cols-[minmax(0,1fr)_14rem] xl:gap-12">
<article class="min-w-0 max-w-3xl">
<!-- Breadcrumb -->
<nav class="flex items-center gap-1.5 text-sm 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>
<span>/</span>
<span class="text-(--fg)">{{ title }}</span>
@@ -116,7 +116,7 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
<header class="mb-8">
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
<DocsBadge :kind="entry.item.kind" size="md" />
<h1 class="min-w-0 break-words text-2xl font-bold font-mono tracking-tight text-(--fg)">{{ entry.item.name }}</h1>
<h1 class="min-w-0 break-words text-[1.6rem] font-semibold font-mono tracking-tight text-(--fg)">{{ entry.item.name }}</h1>
<DocsTag v-if="entry.item.since" :label="`v${entry.item.since}`" variant="neutral" />
<DocsTag
v-if="entry.item.hasTests"
@@ -218,7 +218,7 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
<header class="mb-8">
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
<DocsBadge kind="component" size="md" />
<h1 class="text-2xl font-bold tracking-tight text-(--fg)">{{ entry.component.name }}</h1>
<h1 class="font-display text-[1.7rem] font-bold tracking-tight text-(--fg)">{{ entry.component.name }}</h1>
<DocsTag :label="`${entry.component.parts.length} parts`" variant="neutral" />
</div>
<p v-if="entry.component.description" class="text-(--fg-muted) text-[15px] leading-relaxed">
+87 -37
View File
@@ -22,6 +22,35 @@ const kindLabel = computed(() => ({
guide: 'Guide',
}[pkg.value!.kind]));
// ── API reference: filterable, chip-navigable categories ──────────────────
const query = ref('');
const filteredCategories = computed(() => {
if (pkg.value?.kind !== 'api') return [];
const needle = query.value.trim().toLowerCase();
if (!needle) return pkg.value.categories;
return pkg.value.categories
.map(category => ({
...category,
items: category.items.filter(item =>
item.name.toLowerCase().includes(needle)
|| item.description?.toLowerCase().includes(needle),
),
}))
.filter(category => category.items.length > 0);
});
const filteredCount = computed(() =>
filteredCategories.value.reduce((total, category) => total + category.items.length, 0),
);
function scrollToCategory(catSlug: string) {
document.getElementById(`cat-${catSlug}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// For guide packages, surface the overview section inline.
const overview = computed(() =>
pkg.value?.kind === 'guide' ? pkg.value.sections.find(s => s.slug === 'overview') : undefined,
@@ -40,52 +69,75 @@ const otherSections = computed(() =>
<!-- Auto header (shown only when there's no hand-authored intro) -->
<header v-else class="mb-8 pb-8 border-b border-(--border)">
<div class="comment-label mb-3">{{ kindLabel.toLowerCase() }} · {{ countEntries(pkg) }} entries</div>
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
<h1 class="font-mono text-2xl font-bold tracking-tight text-(--fg)">{{ pkg.name }}</h1>
<h1 class="font-display text-3xl font-bold tracking-tight text-(--fg)">{{ pkg.name }}</h1>
<DocsTag :label="`v${pkg.version}`" variant="neutral" />
</div>
<p class="text-(--fg-muted) text-[15px] leading-relaxed">{{ pkg.description }}</p>
<div class="mt-4 flex items-center gap-3 text-xs text-(--fg-subtle)">
<span>{{ kindLabel }}</span>
<span>·</span>
<span>{{ countEntries(pkg) }} entries</span>
</div>
<div class="mt-5">
<DocsCode :code="`pnpm add ${pkg.name}`" lang="bash" />
</div>
</header>
<!-- When an intro replaces the header, label the auto-generated reference -->
<h2 v-if="introComponent && pkg.kind === 'api'" class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4 pt-2">
API Reference
</h2>
<!-- API: categories of items -->
<!-- API: filter + category chips + dense reference grid -->
<template v-if="pkg.kind === 'api'">
<section v-for="category in pkg.categories" :key="category.slug" class="mb-10">
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">
{{ category.name }}
<span class="ml-1 text-(--fg-subtle) normal-case font-normal">· {{ category.items.length }}</span>
<div class="sticky top-14 z-20 -mx-2 px-2 py-3 backdrop-blur-md" style="background-color: var(--header-bg)">
<div class="relative mb-2.5">
<span class="absolute left-3 top-1/2 -translate-y-1/2 font-mono text-sm text-(--accent-text) select-none"></span>
<input
v-model="query"
type="text"
:placeholder="`filter ${countEntries(pkg)} entries…`"
class="w-full h-10 pl-8 pr-16 font-mono text-sm rounded-md bg-(--bg-elevated) border border-(--border) text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none focus:border-(--accent) transition-colors"
>
<span v-if="query" class="absolute right-3 top-1/2 -translate-y-1/2 font-mono text-[11px] text-(--fg-subtle) tabular-nums">
{{ filteredCount }} hits
</span>
</div>
<div class="flex gap-1.5 overflow-x-auto pb-1 -mb-1">
<button
v-for="category in filteredCategories"
:key="category.slug"
type="button"
class="shrink-0 inline-flex items-center gap-1.5 h-6.5 px-2.5 font-mono text-[11px] rounded-full border border-(--border) bg-(--bg-elevated) text-(--fg-muted) hover:border-(--accent) hover:text-(--accent-text) transition-colors cursor-pointer"
@click="scrollToCategory(category.slug)"
>
{{ category.name.toLowerCase() }}
<span class="text-(--fg-subtle) tabular-nums">{{ category.items.length }}</span>
</button>
</div>
</div>
<div v-if="query && filteredCategories.length === 0" class="py-16 text-center">
<div class="font-mono text-sm text-(--fg-subtle)">// no matches for "{{ query }}"</div>
</div>
<section
v-for="category in filteredCategories"
:id="`cat-${category.slug}`"
:key="category.slug"
class="mb-10 scroll-mt-40 pt-4"
>
<h2 class="comment-label mb-3">
{{ category.name.toLowerCase() }} · {{ category.items.length }}
</h2>
<div class="grid grid-cols-1 gap-2">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<NuxtLink
v-for="item in category.items"
:key="item.slug"
:to="`/${pkg.slug}/${item.slug}`"
class="group flex items-center gap-3 p-3 rounded-xl border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:bg-(--bg-subtle) 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" />
<DocsBadge :kind="item.kind" size="sm" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-sm font-medium text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ item.name }}</span>
<DocsTag v-if="item.hasTests" label="tested" variant="test" />
<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>
<DocsTag v-if="item.hasDemo" label="demo" variant="demo" />
</div>
<p v-if="item.description" class="text-sm text-(--fg-subtle) mt-0.5 truncate">{{ 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>
<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="text-(--fg-subtle) group-hover:text-(--accent-text) transition-colors shrink-0">
<polyline points="9 18 15 12 9 6" />
</svg>
</NuxtLink>
</div>
</section>
@@ -94,19 +146,19 @@ const otherSections = computed(() =>
<!-- Components: gallery -->
<template v-else-if="pkg.kind === 'components'">
<section>
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">
All components <span class="normal-case font-normal">· {{ pkg.components.length }}</span>
<h2 class="comment-label mb-4">
all components · {{ pkg.components.length }}
</h2>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="stagger grid grid-cols-1 gap-3 sm:grid-cols-2">
<NuxtLink
v-for="c in pkg.components"
:key="c.slug"
:to="`/${pkg.slug}/${c.slug}`"
class="group block p-4 rounded-xl border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all"
class="group block p-4 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all"
>
<div class="flex items-center justify-between gap-2 mb-1.5">
<span class="font-semibold text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ c.name }}</span>
<span class="text-[11px] text-(--fg-subtle)">{{ c.parts.length }} parts</span>
<span class="font-mono text-[11px] text-(--fg-subtle) tabular-nums">{{ c.parts.length }} parts</span>
</div>
<p v-if="c.description" class="text-sm text-(--fg-subtle) line-clamp-2">{{ c.description }}</p>
<div class="mt-3 flex flex-wrap gap-1">
@@ -117,7 +169,7 @@ const otherSections = computed(() =>
>
{{ part.role }}
</span>
<span v-if="c.parts.length > 4" class="text-[10px] text-(--fg-subtle) px-1">+{{ c.parts.length - 4 }}</span>
<span v-if="c.parts.length > 4" class="text-[10px] font-mono text-(--fg-subtle) px-1">+{{ c.parts.length - 4 }}</span>
</div>
</NuxtLink>
</div>
@@ -128,18 +180,16 @@ const otherSections = computed(() =>
<template v-else>
<DocsMarkdown v-if="overview" :source="overview.markdown" />
<section v-if="otherSections.length > 0" class="mt-10 pt-8 border-t border-(--border)">
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">Sections</h2>
<h2 class="comment-label mb-4">sections</h2>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<NuxtLink
v-for="s in otherSections"
:key="s.slug"
:to="`/${pkg.slug}/${s.slug}`"
class="group flex items-center justify-between gap-3 p-3.5 rounded-xl 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>
<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="text-(--fg-subtle) group-hover:text-(--accent-text) transition-colors shrink-0">
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="font-mono text-[11px] text-(--fg-subtle) group-hover:text-(--accent-text) transition-colors"></span>
</NuxtLink>
</div>
</section>
+37 -28
View File
@@ -3,10 +3,10 @@ const groups = getGroupedPackages();
const packages = getPackages();
const totalItems = getTotalItems();
const kindMeta: Record<string, { label: string; cls: string }> = {
api: { label: 'API', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300' },
components: { label: 'Components', cls: 'bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-500/15 dark:text-fuchsia-300' },
guide: { label: 'Guide', cls: 'bg-teal-100 text-teal-700 dark:bg-teal-500/15 dark:text-teal-300' },
const kindLabels: Record<string, string> = {
api: 'api',
components: 'ui',
guide: 'guide',
};
useHead({ title: '@robonen/tools — Documentation' });
@@ -15,52 +15,61 @@ useHead({ title: '@robonen/tools — Documentation' });
<template>
<div class="max-w-4xl">
<!-- Hero -->
<section class="mb-14">
<div class="inline-flex items-center gap-2 mb-5 px-3 py-1 rounded-full border border-(--border) bg-(--bg-subtle) text-xs text-(--fg-muted)">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500" />
Auto-generated from source &amp; JSDoc
</div>
<h1 class="text-4xl sm:text-5xl font-bold tracking-tight text-(--fg) mb-4">
@robonen/tools
<section class="relative mb-16 pt-4">
<div class="blueprint absolute -inset-x-10 -top-14 bottom-0 -z-10" aria-hidden="true" />
<div class="comment-label mb-5">field manual · generated from source &amp; jsdoc</div>
<h1 class="font-display text-5xl sm:text-6xl font-bold tracking-tight text-(--fg) mb-5 text-balance">
Tools, documented<span class="text-(--accent)">.</span>
</h1>
<p class="text-lg text-(--fg-muted) leading-relaxed max-w-2xl">
A monorepo of TypeScript utilities, Vue composables, headless UI primitives
and shared tooling documented, typed and tested.
and shared tooling typed, tested and demoed in place.
</p>
<div class="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-(--fg-subtle)">
<span><span class="text-(--fg) font-semibold">{{ packages.length }}</span> packages</span>
<span><span class="text-(--fg) font-semibold">{{ totalItems }}</span> documented items</span>
<span><span class="text-(--fg) font-semibold">{{ groups.length }}</span> groups</span>
<div class="mt-7 inline-flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[13px] text-(--fg-subtle) border border-(--border) rounded-md bg-(--bg-elevated) px-3 py-2">
<span class="text-(--accent-text)"></span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ packages.length }}</span> packages</span>
<span class="text-(--border-strong)">·</span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ totalItems }}</span> documented items</span>
<span class="text-(--border-strong)">·</span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ groups.length }}</span> groups</span>
</div>
</section>
<!-- Package groups -->
<section v-for="grp in groups" :key="grp.group" class="mb-10">
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">
{{ grp.label }}
</h2>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<section v-for="grp in groups" :key="grp.group" class="mb-12">
<h2 class="comment-label mb-4">{{ grp.label.toLowerCase() }}</h2>
<div class="stagger grid grid-cols-1 gap-3 sm:grid-cols-2">
<NuxtLink
v-for="pkg in grp.packages"
:key="pkg.slug"
:to="`/${pkg.slug}`"
class="group relative block p-5 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all"
class="group relative block p-5 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all overflow-hidden"
>
<!-- Corner notch fills in on hover like an indicator lamp -->
<span
class="absolute right-0 top-0 w-2 h-2 bg-(--accent) opacity-0 group-hover:opacity-100 transition-opacity"
style="clip-path: polygon(100% 0, 0 0, 100% 100%)"
aria-hidden="true"
/>
<div class="flex items-start justify-between gap-3 mb-2">
<h3 class="font-mono text-sm font-semibold text-(--fg) group-hover:text-(--accent-text) transition-colors">
{{ pkg.name }}
</h3>
<span :class="['text-[10px] px-2 py-0.5 rounded-full font-medium leading-none shrink-0', kindMeta[pkg.kind]?.cls]">
{{ kindMeta[pkg.kind]?.label }}
<span class="font-mono text-[10px] px-1.5 py-0.5 rounded border border-(--border) bg-(--bg-subtle) text-(--fg-subtle) leading-none shrink-0">
{{ kindLabels[pkg.kind] }}
</span>
</div>
<p class="text-sm text-(--fg-muted) leading-relaxed line-clamp-2">
{{ pkg.description }}
</p>
<div class="mt-4 flex items-center gap-3 text-xs text-(--fg-subtle)">
<span class="font-mono">v{{ pkg.version }}</span>
<span>·</span>
<span>{{ countEntries(pkg) }} {{ pkg.kind === 'components' ? 'components' : pkg.kind === 'guide' ? 'sections' : 'items' }}</span>
<div class="mt-4 flex items-center gap-2 font-mono text-[11px] text-(--fg-subtle)">
<span>v{{ pkg.version }}</span>
<span class="text-(--border-strong)">·</span>
<span class="tabular-nums">{{ countEntries(pkg) }} {{ pkg.kind === 'components' ? 'components' : pkg.kind === 'guide' ? 'sections' : 'items' }}</span>
</div>
</NuxtLink>
</div>
+24 -3
View File
@@ -85,9 +85,30 @@ export default defineNuxtModule({
filename: 'docs-metadata.ts',
write: true,
getContents: () => {
// No indentation (smaller module) and no `as const` — a multi-MB literal
// type is pathological for tsc, and consumers cast to DocsMetadata anyway.
return `export default ${JSON.stringify(metadata)};`;
// Base64-encode the payload (same trick as the Nitro virtual below):
// build-time text replacements rewrite tokens like `import.meta.client`
// → `true` even inside string literals, because esbuild re-emits
// strings with escapes normalized before the replacement plugins run —
// so code snippets in examples/demo sources can only reach the page
// verbatim if the module text never contains them. Decoded once at
// module init; works in the browser, Vue SSR, and prerender.
const encoded = Buffer.from(JSON.stringify(metadata), 'utf8').toString('base64');
return `
function decodePayload(encoded: string): string {
const globalBuffer = (globalThis as { Buffer?: { from: (input: string, encoding: string) => { toString: (encoding: string) => string } } }).Buffer;
if (globalBuffer)
return globalBuffer.from(encoded, 'base64').toString('utf8');
const binary = atob(encoded);
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
export default JSON.parse(decodePayload(${JSON.stringify(encoded)}));
`;
},
});
+5 -4
View File
@@ -50,8 +50,9 @@ export default defineNuxtConfig({
fonts: {
families: [
{ name: 'Inter', provider: 'google', weights: [400, 500, 600, 700] },
{ name: 'JetBrains Mono', provider: 'google', weights: [400, 500] },
{ name: 'IBM Plex Sans', provider: 'google', weights: [400, 500, 600, 700] },
{ name: 'IBM Plex Mono', provider: 'google', weights: [400, 500, 600] },
{ name: 'Bricolage Grotesque', provider: 'google', weights: [600, 700, 800] },
],
},
@@ -62,8 +63,8 @@ export default defineNuxtConfig({
meta: [
{ name: 'description', content: 'Auto-generated documentation for the @robonen/tools monorepo' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'theme-color', content: '#ffffff', media: '(prefers-color-scheme: light)' },
{ name: 'theme-color', content: '#0a0a0a', media: '(prefers-color-scheme: dark)' },
{ name: 'theme-color', content: '#faf8f3', media: '(prefers-color-scheme: light)' },
{ name: 'theme-color', content: '#161310', media: '(prefers-color-scheme: dark)' },
],
htmlAttrs: {
lang: 'en',