|
| 1 | +<script setup lang="ts"> |
| 2 | +/** |
| 3 | + * Interactive "what should I read" wizard for the Getting Started guide. |
| 4 | + * |
| 5 | + * Every question is a grid of selectable cards (multiple answers allowed per |
| 6 | + * question, since a real devtool usually spans more than one answer — e.g. |
| 7 | + * it reads from both the node side and the user's web app). Selections |
| 8 | + * persist to `localStorage` so a reader can leave the page and pick up where |
| 9 | + * they left off; the recommended reading list at the bottom recomputes from |
| 10 | + * whatever is currently checked. |
| 11 | + */ |
| 12 | +
|
| 13 | +interface WizardItem { |
| 14 | + value: string |
| 15 | + label: string |
| 16 | + icon: string |
| 17 | + /** Accent color key from `ITEM_COLORS`, applied once the item is selected. */ |
| 18 | + color: keyof typeof ITEM_COLORS |
| 19 | + description?: string |
| 20 | +} |
| 21 | +
|
| 22 | +/** |
| 23 | + * Per-item accent colors, applied only to a *selected* card (its badge, plus |
| 24 | + * the card's border/background/ring); unselected cards stay neutral gray. |
| 25 | + * Spelled out as full class strings (not interpolated) so Tailwind's scanner |
| 26 | + * keeps them in the build. |
| 27 | + */ |
| 28 | +const ITEM_COLORS = { |
| 29 | + sky: { badge: 'bg-sky-500/10 text-sky-500', card: 'border-sky-500/60 bg-sky-500/5 ring-1 ring-sky-500/20' }, |
| 30 | + indigo: { badge: 'bg-indigo-500/10 text-indigo-500', card: 'border-indigo-500/60 bg-indigo-500/5 ring-1 ring-indigo-500/20' }, |
| 31 | + violet: { badge: 'bg-violet-500/10 text-violet-500', card: 'border-violet-500/60 bg-violet-500/5 ring-1 ring-violet-500/20' }, |
| 32 | + purple: { badge: 'bg-purple-500/10 text-purple-500', card: 'border-purple-500/60 bg-purple-500/5 ring-1 ring-purple-500/20' }, |
| 33 | + fuchsia: { badge: 'bg-fuchsia-500/10 text-fuchsia-500', card: 'border-fuchsia-500/60 bg-fuchsia-500/5 ring-1 ring-fuchsia-500/20' }, |
| 34 | + pink: { badge: 'bg-pink-500/10 text-pink-500', card: 'border-pink-500/60 bg-pink-500/5 ring-1 ring-pink-500/20' }, |
| 35 | + rose: { badge: 'bg-rose-500/10 text-rose-500', card: 'border-rose-500/60 bg-rose-500/5 ring-1 ring-rose-500/20' }, |
| 36 | + amber: { badge: 'bg-amber-500/10 text-amber-500', card: 'border-amber-500/60 bg-amber-500/5 ring-1 ring-amber-500/20' }, |
| 37 | + orange: { badge: 'bg-orange-500/10 text-orange-500', card: 'border-orange-500/60 bg-orange-500/5 ring-1 ring-orange-500/20' }, |
| 38 | + emerald: { badge: 'bg-emerald-500/10 text-emerald-500', card: 'border-emerald-500/60 bg-emerald-500/5 ring-1 ring-emerald-500/20' }, |
| 39 | + teal: { badge: 'bg-teal-500/10 text-teal-500', card: 'border-teal-500/60 bg-teal-500/5 ring-1 ring-teal-500/20' }, |
| 40 | + cyan: { badge: 'bg-cyan-500/10 text-cyan-500', card: 'border-cyan-500/60 bg-cyan-500/5 ring-1 ring-cyan-500/20' }, |
| 41 | + blue: { badge: 'bg-blue-500/10 text-blue-500', card: 'border-blue-500/60 bg-blue-500/5 ring-1 ring-blue-500/20' }, |
| 42 | + green: { badge: 'bg-green-500/10 text-green-500', card: 'border-green-500/60 bg-green-500/5 ring-1 ring-green-500/20' }, |
| 43 | +} as const |
| 44 | +
|
| 45 | +interface WizardSection { |
| 46 | + key: string |
| 47 | + title: string |
| 48 | + hint: string |
| 49 | + icon: string |
| 50 | + items: WizardItem[] |
| 51 | +} |
| 52 | +
|
| 53 | +interface DocEntry { |
| 54 | + title: string |
| 55 | + description: string |
| 56 | + icon: string |
| 57 | +} |
| 58 | +
|
| 59 | +const STORAGE_KEY = 'devframe-docs:getting-started' |
| 60 | +
|
| 61 | +const sections: WizardSection[] = [ |
| 62 | + { |
| 63 | + key: 'environments', |
| 64 | + title: 'Target environments', |
| 65 | + hint: 'What do you expect your tool to work with?', |
| 66 | + icon: 'i-lucide-target', |
| 67 | + items: [ |
| 68 | + { value: 'standalone', label: 'Standalone', icon: 'i-lucide-terminal', color: 'sky', description: 'A CLI or dev server with no host framework' }, |
| 69 | + { value: 'framework', label: 'Specific framework', icon: 'i-lucide-shapes', color: 'violet', description: 'Specifically for frameworks like Vite, Next.js, Nuxt, etc.' }, |
| 70 | + { value: 'all', label: 'All frameworks', icon: 'i-lucide-infinity', color: 'emerald', description: 'I want to support as many frameworks as possible' }, |
| 71 | + ], |
| 72 | + }, |
| 73 | + { |
| 74 | + key: 'dataSource', |
| 75 | + title: 'Data source', |
| 76 | + hint: 'Where do you want to visualize data from?', |
| 77 | + icon: 'i-lucide-database', |
| 78 | + items: [ |
| 79 | + { value: 'node', label: 'The node side', icon: 'i-lucide-server', color: 'indigo', description: 'Server state, build output, the filesystem, child processes' }, |
| 80 | + { value: 'browser', label: 'The user\'s web app', icon: 'i-lucide-app-window', color: 'cyan', description: 'State living in the page you\'re developing' }, |
| 81 | + ], |
| 82 | + }, |
| 83 | + { |
| 84 | + key: 'availability', |
| 85 | + title: 'Data availability', |
| 86 | + hint: 'When is the data available?', |
| 87 | + icon: 'i-lucide-clock', |
| 88 | + items: [ |
| 89 | + { value: 'dev', label: 'Development time', icon: 'i-lucide-code', color: 'blue', description: 'Live, over a running dev server' }, |
| 90 | + { value: 'build', label: 'Production build time', icon: 'i-lucide-hammer', color: 'amber', description: 'Data from the production build' }, |
| 91 | + { value: 'static', label: 'Statically available', icon: 'i-lucide-hard-drive', color: 'teal', description: 'Local filesystem, uploaded files, etc.' }, |
| 92 | + { value: 'remote', label: 'Remotely', icon: 'i-lucide-cloud', color: 'sky', description: 'Over the web' }, |
| 93 | + ], |
| 94 | + }, |
| 95 | + { |
| 96 | + key: 'frontend', |
| 97 | + title: 'Frontend approach', |
| 98 | + hint: 'How do you want to build the frontend view?', |
| 99 | + icon: 'i-lucide-palette', |
| 100 | + items: [ |
| 101 | + { value: 'framework', label: 'A preferred framework', icon: 'i-lucide-component', color: 'green', description: 'Vue, React, Svelte, Solid...' }, |
| 102 | + { value: 'webcomponents', label: 'Web Components', icon: 'i-lucide-box', color: 'orange', description: 'Use Web Components for renderering' }, |
| 103 | + { value: 'nodeside', label: 'Build it on the node side', icon: 'i-lucide-braces', color: 'purple', description: 'Describe the UI as data instead of shipping a bundle' }, |
| 104 | + ], |
| 105 | + }, |
| 106 | + { |
| 107 | + key: 'requirements', |
| 108 | + title: 'Other requirements', |
| 109 | + hint: 'Any specific requirements?', |
| 110 | + icon: 'i-lucide-list-checks', |
| 111 | + items: [ |
| 112 | + { value: 'agent', label: 'Exposed to coding agents', icon: 'i-lucide-bot', color: 'pink', description: 'Some functionality should be available to coding agents.' }, |
| 113 | + { value: 'terminal', label: 'Sub process access', icon: 'i-lucide-square-terminal', color: 'amber', description: 'Need to spawn other child process from the node side' }, |
| 114 | + { value: 'streaming', label: 'Streaming data', icon: 'i-lucide-radio', color: 'rose', description: 'Push chunk-style data from the node side to the browser side' }, |
| 115 | + { value: 'deep-linking', label: 'Deep linking', icon: 'i-lucide-link', color: 'cyan', description: 'Shareable URLs into a specific view' }, |
| 116 | + { value: 'overlay', label: 'User app overlay', icon: 'i-lucide-layers', color: 'fuchsia', description: 'I want to overlay a UI on top of the user\'s web app' }, |
| 117 | + ], |
| 118 | + }, |
| 119 | +] |
| 120 | +
|
| 121 | +/** Every doc a recommendation can point at, keyed by its route. */ |
| 122 | +const DOC_CATALOG: Record<string, DocEntry> = { |
| 123 | + '/guide': { title: 'Introduction', description: 'What devframe is and who it\'s for.', icon: 'i-lucide-book-open' }, |
| 124 | + '/guide/devframe-definition': { title: 'Devframe Definition', description: 'One defineDevframe() call returns a portable definition every adapter consumes.', icon: 'i-lucide-package' }, |
| 125 | + '/guide/tutorial-server-data-inspector': { title: 'Tutorial: Build a Server Data Inspector', description: 'Build a devtool end to end, then ship it as a hub dock, a static build, a dev server, and a CLI.', icon: 'i-lucide-graduation-cap' }, |
| 126 | + '/guide/rpc': { title: 'RPC', description: 'Type-safe, bidirectional calls between the node side and the browser side.', icon: 'i-lucide-cable' }, |
| 127 | + '/guide/shared-state': { title: 'Shared State', description: 'Observable state synced between the node side and every RPC client.', icon: 'i-lucide-refresh-cw' }, |
| 128 | + '/guide/streaming': { title: 'Streaming', description: 'Push chunk-style data from the node side to the browser side.', icon: 'i-lucide-radio' }, |
| 129 | + '/guide/client-assets': { title: 'Client Assets', description: 'Where a devframe\'s built SPA lives — a local directory or an npm package.', icon: 'i-lucide-folder-tree' }, |
| 130 | + '/guide/client': { title: 'Client', description: 'Connects any surface to a devframe\'s node side with RPC and shared state.', icon: 'i-lucide-plug' }, |
| 131 | + '/guide/transports': { title: 'Transports', description: 'Live RPC over WebSocket or SSE, transparent to your RPC code.', icon: 'i-lucide-waypoints' }, |
| 132 | + '/guide/security': { title: 'Security', description: 'Localhost binding and a trust handshake before a browser can call RPC.', icon: 'i-lucide-shield-check' }, |
| 133 | + '/guide/agent-native': { title: 'Agent-Native Devframe', description: 'Expose RPC functions, resources, and shared state to coding agents over MCP.', icon: 'i-lucide-bot' }, |
| 134 | + '/guide/hub': { title: 'Hub', description: 'Orchestrate many devtools sharing one UI — docks, terminals, messages, commands.', icon: 'i-lucide-layout-dashboard' }, |
| 135 | + '/guide/client-context': { title: 'Client Scripts & Client Context', description: 'How a dock client script runs a devframe\'s code inside the host page.', icon: 'i-lucide-code' }, |
| 136 | + '/guide/hub-initiate': { title: 'Serve a Hub Anywhere', description: 'initHub() serves a whole multi-devframe install from one handler.', icon: 'i-lucide-server-cog' }, |
| 137 | + '/guide/services': { title: 'Cross-Devframe Services', description: 'Expose a typed, namespaced capability to every devframe in a hub.', icon: 'i-lucide-share-2' }, |
| 138 | + '/guide/deep-linking': { title: 'Deep Linking', description: 'Send a user to a specific view inside a devframe from a URL or an agent.', icon: 'i-lucide-link' }, |
| 139 | + '/guide/json-render': { title: 'JSON-Render', description: 'Describe a UI as data — a serializable component spec any frontend renders.', icon: 'i-lucide-braces' }, |
| 140 | + '/guide/build-your-own-json-render-frontend': { title: 'Build Your Own JSON-Render Frontend', description: 'Implement the renderer contract in your own framework instead of the reference one.', icon: 'i-lucide-component' }, |
| 141 | + '/guide/build-your-own-hub-ui': { title: 'Build Your Own Hub UI', description: 'The two contracts a hub UI provider implements — node side and browser side.', icon: 'i-lucide-layout-panel-left' }, |
| 142 | + '/guide/standalone-cli': { title: 'Standalone CLI with Devframe', description: 'npx my-tool starts a dev server serving your SPA over type-safe RPC.', icon: 'i-lucide-terminal' }, |
| 143 | + '/helpers/interactive-auth': { title: 'Interactive Auth', description: 'An OTP auth layer over devframe\'s node-side primitives.', icon: 'i-lucide-key-round' }, |
| 144 | + '/helpers/utilities': { title: 'Utilities', description: 'Small, stable helpers bundled into devframe — no npm install.', icon: 'i-lucide-wrench' }, |
| 145 | + '/adapters': { title: 'Adapters', description: 'Every path from a DevframeDefinition to a running devframe.', icon: 'i-lucide-shuffle' }, |
| 146 | + '/adapters/initiate': { title: 'The Standard Handler', description: 'initDevframe() turns a definition into a Web Standard Request → Response handler.', icon: 'i-lucide-server' }, |
| 147 | + '/adapters/cac': { title: 'CLI (cac)', description: 'A cac CLI around a DevframeDefinition with dev, build, and mcp commands.', icon: 'i-lucide-square-terminal' }, |
| 148 | + '/adapters/build': { title: 'Build', description: 'Produces a static deploy of a devframe.', icon: 'i-lucide-hammer' }, |
| 149 | + '/adapters/vite': { title: 'Vite (adapter)', description: 'Wraps a definition so Vite DevTools\' plugin-scan picks it up.', icon: 'i-lucide-zap' }, |
| 150 | + '/adapters/embedded': { title: 'Embedded', description: 'Register a devframe into an already-running context at runtime.', icon: 'i-lucide-plug-zap' }, |
| 151 | + '/adapters/mcp': { title: 'MCP', description: 'Exposes a devframe\'s agent-facing API as a Model Context Protocol server.', icon: 'i-lucide-bot' }, |
| 152 | + '/frameworks': { title: 'Frameworks', description: 'Framework kits that integrate devframe with a meta-framework\'s dev server.', icon: 'i-lucide-blocks' }, |
| 153 | + '/frameworks/vite': { title: 'Vite', description: 'Author one devframe\'s SPA, or mount a whole hub, from a Vite plugin.', icon: 'i-simple-icons-vite' }, |
| 154 | + '/frameworks/next': { title: 'Next', description: 'Host devframes from a Next.js App Router app via a route handler.', icon: 'i-simple-icons-nextdotjs' }, |
| 155 | + '/frameworks/nuxt': { title: 'Nuxt', description: 'A Nuxt module split into authoring one devframe or mounting a hub.', icon: 'i-simple-icons-nuxtdotjs' }, |
| 156 | + '/plugins/a11y': { title: 'Accessibility Inspector', description: 'Runs axe-core against the user app and highlights violations in the page.', icon: 'i-lucide-accessibility' }, |
| 157 | + '/plugins/terminals': { title: 'Terminals', description: 'A terminal panel built on xterm.js.', icon: 'i-lucide-square-terminal' }, |
| 158 | +} |
| 159 | +
|
| 160 | +/** Always worth reading, regardless of what's checked above. */ |
| 161 | +const BASE_DOCS = ['/guide', '/guide/devframe-definition', '/guide/tutorial-server-data-inspector'] |
| 162 | +
|
| 163 | +/** `${section.key}:${item.value}` -> doc routes that answer is worth reading. */ |
| 164 | +const RECOMMENDATIONS: Record<string, string[]> = { |
| 165 | + 'dataSource:node': ['/guide/rpc', '/guide/shared-state', '/helpers/utilities'], |
| 166 | + 'dataSource:browser': ['/guide/client-context', '/guide/deep-linking', '/plugins/a11y'], |
| 167 | +
|
| 168 | + 'environments:standalone': ['/guide/standalone-cli', '/adapters/cac', '/adapters/build'], |
| 169 | + 'environments:framework': ['/adapters'], |
| 170 | + 'environments:all': ['/adapters/initiate', '/adapters', '/guide/devframe-definition'], |
| 171 | +
|
| 172 | + 'availability:dev': ['/guide/rpc', '/guide/transports'], |
| 173 | + 'availability:build': ['/adapters/build', '/guide/client-assets'], |
| 174 | + 'availability:static': ['/adapters/build', '/helpers/utilities'], |
| 175 | + 'availability:remote': ['/guide/transports', '/guide/security'], |
| 176 | +
|
| 177 | + 'frontend:framework': ['/guide/client-assets', '/guide/client'], |
| 178 | + 'frontend:webcomponents': ['/guide/hub', '/guide/build-your-own-hub-ui'], |
| 179 | + 'frontend:nodeside': ['/guide/json-render', '/guide/build-your-own-json-render-frontend'], |
| 180 | +
|
| 181 | + 'requirements:agent': ['/guide/agent-native', '/adapters/mcp'], |
| 182 | + 'requirements:terminal': ['/plugins/terminals'], |
| 183 | + 'requirements:streaming': ['/guide/streaming'], |
| 184 | + 'requirements:deep-linking': ['/guide/deep-linking'], |
| 185 | + 'requirements:overlay': ['/guide/client-context', '/plugins/a11y'], |
| 186 | +} |
| 187 | +
|
| 188 | +const selections = reactive<Record<string, string[]>>( |
| 189 | + Object.fromEntries(sections.map(section => [section.key, [] as string[]])), |
| 190 | +) |
| 191 | +
|
| 192 | +onMounted(() => { |
| 193 | + if (!import.meta.client) |
| 194 | + return |
| 195 | + try { |
| 196 | + const raw = localStorage.getItem(STORAGE_KEY) |
| 197 | + if (!raw) |
| 198 | + return |
| 199 | + const saved = JSON.parse(raw) as Record<string, unknown> |
| 200 | + for (const section of sections) { |
| 201 | + const values = saved[section.key] |
| 202 | + if (!Array.isArray(values)) |
| 203 | + continue |
| 204 | + const known = new Set(section.items.map(item => item.value)) |
| 205 | + selections[section.key] = values.filter((value): value is string => typeof value === 'string' && known.has(value)) |
| 206 | + } |
| 207 | + } |
| 208 | + catch { |
| 209 | + // Corrupt or inaccessible storage - fall back to a clean slate. |
| 210 | + } |
| 211 | +}) |
| 212 | +
|
| 213 | +watch(selections, (value) => { |
| 214 | + if (!import.meta.client) |
| 215 | + return |
| 216 | + localStorage.setItem(STORAGE_KEY, JSON.stringify(value)) |
| 217 | +}, { deep: true }) |
| 218 | +
|
| 219 | +function isChecked(sectionKey: string, value: string): boolean { |
| 220 | + return selections[sectionKey]!.includes(value) |
| 221 | +} |
| 222 | +
|
| 223 | +function toggle(sectionKey: string, value: string): void { |
| 224 | + const current = selections[sectionKey]! |
| 225 | + const index = current.indexOf(value) |
| 226 | + if (index === -1) |
| 227 | + current.push(value) |
| 228 | + else |
| 229 | + current.splice(index, 1) |
| 230 | +} |
| 231 | +
|
| 232 | +const hasSelections = computed(() => sections.some(section => selections[section.key]!.length > 0)) |
| 233 | +
|
| 234 | +const recommendedDocs = computed(() => { |
| 235 | + const paths = new Set(BASE_DOCS) |
| 236 | + for (const section of sections) { |
| 237 | + for (const value of selections[section.key]!) { |
| 238 | + for (const path of RECOMMENDATIONS[`${section.key}:${value}`] ?? []) |
| 239 | + paths.add(path) |
| 240 | + } |
| 241 | + } |
| 242 | + return [...paths] |
| 243 | + .filter(path => path in DOC_CATALOG) |
| 244 | + .map(path => ({ path, ...DOC_CATALOG[path]! })) |
| 245 | +}) |
| 246 | +
|
| 247 | +function reset(): void { |
| 248 | + for (const section of sections) selections[section.key] = [] |
| 249 | +} |
| 250 | +</script> |
| 251 | + |
| 252 | +<template> |
| 253 | + <div class="not-prose rounded-xl border border-default divide-y divide-default overflow-hidden"> |
| 254 | + <div class="flex items-center justify-between gap-4 px-5 py-3 sm:px-6 bg-muted"> |
| 255 | + <div> |
| 256 | + <p class="font-medium text-highlighted"> |
| 257 | + What kind of devtool do you want to build? |
| 258 | + </p> |
| 259 | + <p class="text-sm text-muted mt-0.5"> |
| 260 | + Multi-select, check whatever applies (selection persists in localStorage) |
| 261 | + </p> |
| 262 | + </div> |
| 263 | + <UButton |
| 264 | + label="Reset" |
| 265 | + icon="i-lucide-rotate-ccw" |
| 266 | + color="neutral" |
| 267 | + variant="ghost" |
| 268 | + size="xs" |
| 269 | + :disabled="!hasSelections" |
| 270 | + class="cursor-pointer shrink-0" |
| 271 | + @click="reset" |
| 272 | + /> |
| 273 | + </div> |
| 274 | + |
| 275 | + <div |
| 276 | + v-for="section in sections" |
| 277 | + :key="section.key" |
| 278 | + class="px-5 py-4 sm:px-6" |
| 279 | + > |
| 280 | + <div class="flex flex-wrap items-baseline gap-x-2 mb-3"> |
| 281 | + <p class="flex items-center gap-2 font-medium text-highlighted"> |
| 282 | + <UIcon :name="section.icon" class="size-4 text-muted" /> |
| 283 | + {{ section.hint }} |
| 284 | + </p> |
| 285 | + </div> |
| 286 | + |
| 287 | + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2.5"> |
| 288 | + <button |
| 289 | + v-for="item in section.items" |
| 290 | + :key="item.value" |
| 291 | + type="button" |
| 292 | + role="checkbox" |
| 293 | + :aria-checked="isChecked(section.key, item.value)" |
| 294 | + class="relative flex items-center gap-3 rounded-lg border p-3 text-left transition-colors cursor-pointer" |
| 295 | + :class="isChecked(section.key, item.value) |
| 296 | + ? ITEM_COLORS[item.color].card |
| 297 | + : 'border-default hover:border-accented hover:bg-elevated/50'" |
| 298 | + @click="toggle(section.key, item.value)" |
| 299 | + > |
| 300 | + <span |
| 301 | + class="inline-flex items-center justify-center size-8 shrink-0 rounded-full transition-colors" |
| 302 | + :class="isChecked(section.key, item.value) ? ITEM_COLORS[item.color].badge : 'bg-elevated text-muted'" |
| 303 | + > |
| 304 | + <UIcon :name="item.icon" class="size-4" /> |
| 305 | + </span> |
| 306 | + <span class="min-w-0 flex-1"> |
| 307 | + <span class="block text-sm font-medium text-highlighted">{{ item.label }}</span> |
| 308 | + <span |
| 309 | + v-if="item.description" |
| 310 | + class="block text-xs text-muted mt-0.5" |
| 311 | + >{{ item.description }}</span> |
| 312 | + </span> |
| 313 | + </button> |
| 314 | + </div> |
| 315 | + </div> |
| 316 | + |
| 317 | + <div class="px-5 py-4 sm:px-6 bg-muted"> |
| 318 | + <p class="font-medium text-highlighted mb-3"> |
| 319 | + {{ hasSelections ? 'Recommended docs, based on your answers' : 'Start here' }} |
| 320 | + </p> |
| 321 | + <div class="flex flex-col divide-y divide-default rounded-lg border border-default overflow-hidden bg-default"> |
| 322 | + <NuxtLink |
| 323 | + v-for="doc in recommendedDocs" |
| 324 | + :key="doc.path" |
| 325 | + :to="doc.path" |
| 326 | + class="group flex items-center gap-3 p-3 transition-colors hover:bg-elevated/50" |
| 327 | + > |
| 328 | + <span class="inline-flex items-center justify-center size-8 shrink-0 rounded-full bg-elevated text-muted"> |
| 329 | + <UIcon :name="doc.icon" class="size-4" /> |
| 330 | + </span> |
| 331 | + <span class="min-w-0 flex-1"> |
| 332 | + <span class="block text-sm font-medium text-highlighted">{{ doc.title }}</span> |
| 333 | + <span class="block text-xs text-muted mt-0.5">{{ doc.description }}</span> |
| 334 | + </span> |
| 335 | + <UIcon |
| 336 | + name="i-lucide-arrow-right" |
| 337 | + class="size-4 shrink-0 text-dimmed transition-transform group-hover:translate-x-0.5 group-hover:text-muted" |
| 338 | + /> |
| 339 | + </NuxtLink> |
| 340 | + </div> |
| 341 | + </div> |
| 342 | + </div> |
| 343 | +</template> |
0 commit comments