@brumaombra/ui-vintage 0.9.3 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -33,6 +33,7 @@ Only build something new when no entry point fits, and compose it from library p
33
33
  - Icons are Hugeicons definitions: import each one from its own module (`import Rocket01Icon from '@hugeicons/core-free-icons/Rocket01Icon'`, never the `@hugeicons/core-free-icons` barrel, which makes the build parse the whole icon set) and pass them to `:icon` props, or render them with `HugeiconsIcon` from `@hugeicons/vue`. Both packages are peer dependencies, so the app must list them in its own `package.json`.
34
34
  - Form controls use `v-model`. Mark invalid fields with `aria-invalid` and show messages with `FieldError`.
35
35
  - Library strings are translated through vue-i18n (`uiVintage.*` keys). App copy belongs in the app's own locale files.
36
+ - Pass links as plain paths (`to: '/settings'`, `app-link-to="/"`), never `localePath()` results: the components render `NuxtLinkLocale`, which adds the language prefix and navigates client-side. External URLs work in the same props. For link buttons, give the `Button` a `to` (it renders the localized link itself), never a link wrapped around a button.
36
37
 
37
38
  ## 4. Check your work
38
39
 
package/COMPONENTS.md CHANGED
@@ -31,7 +31,7 @@ Buttons, segmented controls, menus, and keyboard hints.
31
31
 
32
32
  ### `@brumaombra/ui-vintage/button`
33
33
 
34
- Button with primary, secondary, ghost, link, and tone variants. Spring press feedback and a `loading` state that keeps the width stable. Use `as-child` to render a link.
34
+ Button with primary, secondary, ghost, link, and tone variants. Spring press feedback and a `loading` state that keeps the width stable. Give it a `to` to make it a navigation button (a localized link, external URLs work too).
35
35
 
36
36
  ```ts
37
37
  import { Button, buttonVariants } from '@brumaombra/ui-vintage/button';
@@ -44,8 +44,8 @@ import type { ButtonVariants } from '@brumaombra/ui-vintage/button';
44
44
  Save changes
45
45
  </Button>
46
46
 
47
- <Button as-child>
48
- <NuxtLink to="/pricing">Pricing</NuxtLink>
47
+ <Button to="/pricing" variant="secondary">
48
+ Pricing
49
49
  </Button>
50
50
  ```
51
51
 
@@ -57,6 +57,7 @@ import type { ButtonVariants } from '@brumaombra/ui-vintage/button';
57
57
  | `size` | `ButtonVariants['size']` | |
58
58
  | `loading` | `boolean` | `false` |
59
59
  | `disabled` | `boolean` | `false` |
60
+ | `to` | `string` | |
60
61
  | `class` | `HTMLAttributes['class']` | |
61
62
 
62
63
  - **Also accepts** all props of Reka `PrimitiveProps`.
@@ -255,7 +256,7 @@ import { LoadMoreButton } from '@brumaombra/ui-vintage/load-more-button';
255
256
 
256
257
  ### `@brumaombra/ui-vintage/text-link`
257
258
 
258
- Inline text link that renders an anchor or a router link component (pass `NuxtLink` as `link-component`).
259
+ Inline text link for an internal path (localized with `NuxtLinkLocale`) or an external URL.
259
260
 
260
261
  ```ts
261
262
  import { TextLink } from '@brumaombra/ui-vintage/text-link';
@@ -267,7 +268,6 @@ import { TextLink } from '@brumaombra/ui-vintage/text-link';
267
268
  | --- | --- | --- |
268
269
  | `to` *(required)* | `string` | |
269
270
  | `text` *(required)* | `string` | |
270
- | `linkComponent` | `Component \| string` | `'a'` |
271
271
 
272
272
  - **Emits:** `click` (event: MouseEvent).
273
273
 
@@ -3057,14 +3057,14 @@ import { BackgroundGrid } from '@brumaombra/ui-vintage/background-grid';
3057
3057
 
3058
3058
  ### `@brumaombra/ui-vintage/dashboard-shell`
3059
3059
 
3060
- Complete dashboard layout: collapsible sidebar built from `sidebar-sections`, mobile sheet, sticky topbar, and grid background. Pass `NuxtLink` as `sidebar-link-component` for client-side navigation.
3060
+ Complete dashboard layout: collapsible sidebar built from `sidebar-sections`, mobile sheet, sticky topbar, and grid background. Item `to` paths are localized and navigate client-side (`NuxtLinkLocale`).
3061
3061
 
3062
3062
  ```ts
3063
3063
  import { DashboardShell } from '@brumaombra/ui-vintage/dashboard-shell';
3064
3064
  ```
3065
3065
 
3066
3066
  ```vue
3067
- <DashboardShell title="Projects" :sidebar-sections="sections" :sidebar-link-component="NuxtLink">
3067
+ <DashboardShell title="Projects" :sidebar-sections="sections">
3068
3068
  <template #topbar-trailing>
3069
3069
  <ThemeSelector />
3070
3070
  </template>
@@ -3084,8 +3084,7 @@ import { DashboardShell } from '@brumaombra/ui-vintage/dashboard-shell';
3084
3084
  | `description` | `string` | `''` |
3085
3085
  | `collapsible` | `'offcanvas' \| 'icon' \| 'none'` | `'offcanvas'` |
3086
3086
  | `compact` | `boolean` | `false` |
3087
- | `sidebarSections` | `Array<{ id: string; label?: string; items: Array<{ id: string; label: string; description?: string; icon?: HugeiconsIconDefinition \| null; href?: string; to?: unknown; active?: boolean; newTab?: boolean }> }>` | `[]` |
3088
- | `sidebarLinkComponent` | `Component \| string` | `'a'` |
3087
+ | `sidebarSections` | `Array<{ id: string; label?: string; items: Array<{ id: string; label: string; description?: string; icon?: HugeiconsIconDefinition \| null; to: string; active?: boolean; newTab?: boolean }> }>` | `[]` |
3089
3088
  | `showBackground` | `boolean` | `true` |
3090
3089
  | `insetClass` | `HTMLAttributes['class']` | |
3091
3090
  | `topbarClass` | `HTMLAttributes['class']` | |
@@ -3178,7 +3177,6 @@ import type { LandingFooterLink, LandingFooterSection } from '@brumaombra/ui-vin
3178
3177
  | `logoClass` | `HTMLAttributes['class']` | |
3179
3178
  | `appDescription` | `string` | `''` |
3180
3179
  | `sections` | `LandingFooterSection[]` | `[]` |
3181
- | `linkComponent` | `Component \| string` | `'a'` |
3182
3180
  | `authorName` | `string` | `''` |
3183
3181
  | `authorLink` | `string` | `''` |
3184
3182
  | `year` | `number` | `new Date().getFullYear()` |
@@ -3196,8 +3194,7 @@ import type { LandingFooterLink, LandingFooterSection } from '@brumaombra/ui-vin
3196
3194
  interface LandingFooterLink {
3197
3195
  id: string;
3198
3196
  label: string;
3199
- href?: string;
3200
- to?: unknown;
3197
+ to: string;
3201
3198
  newTab?: boolean;
3202
3199
  }
3203
3200
  interface LandingFooterSection {
@@ -3244,7 +3241,6 @@ import type { LandingFooterLink, LandingFooterSection } from '@brumaombra/ui-vin
3244
3241
  | `logoClass` | `HTMLAttributes['class']` | |
3245
3242
  | `appDescription` | `string` | `''` |
3246
3243
  | `sections` | `LandingFooterSection[]` | `[]` |
3247
- | `linkComponent` | `Component \| string` | `'a'` |
3248
3244
  | `authorName` | `string` | `''` |
3249
3245
  | `authorLink` | `string` | `''` |
3250
3246
  | `year` | `number` | `new Date().getFullYear()` |
@@ -3262,8 +3258,7 @@ import type { LandingFooterLink, LandingFooterSection } from '@brumaombra/ui-vin
3262
3258
  interface LandingFooterLink {
3263
3259
  id: string;
3264
3260
  label: string;
3265
- href?: string;
3266
- to?: unknown;
3261
+ to: string;
3267
3262
  newTab?: boolean;
3268
3263
  }
3269
3264
  interface LandingFooterSection {
@@ -3684,7 +3679,7 @@ import type { BlogPostData, BlogCategoryData, BlogTagData } from '@brumaombra/ui
3684
3679
  ```ts
3685
3680
  function slugify(value: unknown)
3686
3681
  function normalizeContentPath(path: string)
3687
- function buildCategoriesFromPosts(posts: BlogPostData[], getPath: (slug: string) => string): BlogCategoryData[]
3682
+ function buildCategoriesFromPosts(posts: BlogPostData[], getPath: (slug: string) => string = slug => `/blog/categories/${slug}`): BlogCategoryData[]
3688
3683
  function buildTagsFromPosts(posts: BlogPostData[]): BlogTagData[]
3689
3684
  function sortByStableSeed(items: T[], seed: string, getKey: (item: T) => string)
3690
3685
  interface BlogPostData {
package/README.md CHANGED
@@ -140,7 +140,7 @@ if (confirmed) {
140
140
 
141
141
  ### Build a layout
142
142
 
143
- Shells own the layout and responsive behavior; your app owns the navigation data and the content:
143
+ Shells own the layout and responsive behavior; your app owns the navigation data and the content. Links take plain paths (`to: '/settings'`): the components render `NuxtLinkLocale`, which adds the current language prefix and navigates client-side, and leaves external URLs as they are:
144
144
 
145
145
  ```vue
146
146
  <script setup>
@@ -151,8 +151,8 @@ const sidebarSections = [{
151
151
  id: 'workspace',
152
152
  label: 'Workspace',
153
153
  items: [
154
- { id: 'overview', label: 'Overview', href: '/', active: true },
155
- { id: 'settings', label: 'Settings', href: '/settings' }
154
+ { id: 'overview', label: 'Overview', to: '/', active: true },
155
+ { id: 'settings', label: 'Settings', to: '/settings' }
156
156
  ]
157
157
  }];
158
158
  </script>
@@ -402,7 +402,7 @@ New entry points need an export in `package.json` and a description and category
402
402
  - Nuxt 4 and Vue 3.5 or newer.
403
403
  - Tailwind CSS v4 with `@tailwindcss/vite` registered in `vite.plugins`.
404
404
  - `@hugeicons/core-free-icons` 4.3 or newer and `@hugeicons/vue` 1 (the icons you pass to `:icon` props, and the ones the components render). Import each icon from its own module, as in the example above: the package barrel makes the build parse the whole icon set.
405
- - `vue-i18n` 9, 10, or 11 with an i18n setup, such as `@nuxtjs/i18n` (the components call `useI18n()`).
405
+ - `@nuxtjs/i18n` 10 (the components call `useI18n()`, and every link renders `NuxtLinkLocale`).
406
406
  - `@nuxt/image` 2 for image components (the module installs it when needed).
407
407
  - `@nuxt/content` 3, only for the `blog` and `content` components.
408
408
 
@@ -411,7 +411,7 @@ New entry points need an export in `package.json` and a description and category
411
411
  - **Components can't resolve Nuxt imports:** check that the app runs Nuxt 4 and that `@brumaombra/ui-vintage` is registered in `nuxt.config`.
412
412
  - **Styles are missing:** register the module once, add `tailwindcss()` from `@tailwindcss/vite` to `vite.plugins`, and restart the dev server after changing `nuxt.config`.
413
413
  - **`NuxtImg` is unavailable:** install `@nuxt/image`, or let the module install it during setup.
414
- - **`useI18n` errors or missing translations:** register `@nuxtjs/i18n` (or install Vue I18n yourself); the components need an i18n composer, and the library messages are merged into it.
414
+ - **`useI18n` errors, missing translations, or `NuxtLinkLocale` failing to resolve:** register `@nuxtjs/i18n`; the components need its composer and link component, and the library messages are merged into it.
415
415
  - **An import fails:** use the public subpath from `package.json`, such as `@brumaombra/ui-vintage/button`, never an internal `src/` path.
416
416
 
417
417
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brumaombra/ui-vintage",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "Source-published Nuxt UI runtime with shared theme tokens and built-in Nuxt integrations.",
5
5
  "private": false,
6
6
  "author": "Mauro Brambilla",
@@ -81,7 +81,7 @@ const underlineDelay = computed(() => `${beforeWords.value.length * 55 + 600}ms`
81
81
  <template>
82
82
  <header :class="cn('relative flex flex-col gap-6 pt-6 md:pt-12', alignClass.header)">
83
83
  <!-- Announcement (links to something new, such as the latest post) -->
84
- <NuxtLink v-if="props.announcement" :to="props.announcement.to" class="group/announce max-w-full animate-uv-fade-up rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
84
+ <NuxtLinkLocale v-if="props.announcement" :to="props.announcement.to" class="group/announce max-w-full animate-uv-fade-up rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
85
85
  <span class="inline-flex max-w-full items-center gap-2 rounded border border-warning/35 bg-warning/10 px-3 py-1 text-xs text-foreground transition-colors duration-150 group-hover/announce:border-warning/70">
86
86
  <span aria-hidden="true" class="relative flex size-1.5 shrink-0 text-warning">
87
87
  <span class="absolute inline-flex size-full animate-uv-ping-soft rounded-full bg-current opacity-60" />
@@ -91,7 +91,7 @@ const underlineDelay = computed(() => `${beforeWords.value.length * 55 + 600}ms`
91
91
  <span class="truncate opacity-80">{{ props.announcement.text }}</span>
92
92
  <HugeiconsIcon :icon="ArrowRight01Icon" class="size-3.5 shrink-0 transition-transform duration-300 ease-spring group-hover/announce:translate-x-0.5" />
93
93
  </span>
94
- </NuxtLink>
94
+ </NuxtLinkLocale>
95
95
 
96
96
  <!-- Badges -->
97
97
  <div v-if="props.badges.length > 0" :class="cn('flex flex-wrap gap-2', alignClass.row)">
@@ -13,14 +13,14 @@ import BlogViewAllLink from './BlogViewAllLink.vue';
13
13
  import CategoriesSection from './CategoriesSection.vue';
14
14
  import HeaderCarousel from './HeaderCarousel.vue';
15
15
 
16
- // Post shown in the carousel and in the list (paths already localized)
16
+ // Post shown in the carousel and in the list (the links localize the paths)
17
17
  type BlogListPost = {
18
18
  path: string;
19
19
  title: string;
20
20
  description?: string;
21
21
  image?: string;
22
22
  categoryText?: string;
23
- categoryPath?: string;
23
+ categorySlug?: string;
24
24
  };
25
25
 
26
26
  // Props
@@ -57,6 +57,12 @@ const emit = defineEmits<{
57
57
 
58
58
  const { t } = useI18n();
59
59
 
60
+ // Featured posts with the link to their category page
61
+ const carouselPosts = computed(() => props.featuredPosts.map(post => ({
62
+ ...post,
63
+ categoryPath: post.categorySlug ? `/blog/categories/${post.categorySlug}` : undefined
64
+ })));
65
+
60
66
  // Announce the latest post in the header
61
67
  const headerAnnouncement = computed(() => props.featuredPosts[0] ? { text: props.featuredPosts[0].title, to: props.featuredPosts[0].path } : null);
62
68
 
@@ -69,7 +75,7 @@ const headerStats = computed(() => [
69
75
  </script>
70
76
 
71
77
  <template>
72
- <div class="space-y-16 md:space-y-24">
78
+ <div class="space-y-14 md:space-y-18">
73
79
  <!-- Header section -->
74
80
  <BlogHeaderSection :announcement="headerAnnouncement"
75
81
  :title="props.title"
@@ -80,7 +86,7 @@ const headerStats = computed(() => [
80
86
  <!-- Header carousel -->
81
87
  <section>
82
88
  <BlogSectionTitle :title="t('uiVintage.blog.latestPosts')" :description="t('uiVintage.blog.latestPostsDescription')" />
83
- <HeaderCarousel :featured-posts="props.featuredPosts" />
89
+ <HeaderCarousel :featured-posts="carouselPosts" />
84
90
  </section>
85
91
 
86
92
  <!-- Categories section -->
@@ -38,17 +38,17 @@ const showModifiedDate = computed(() => Boolean(props.dateModified) && formatted
38
38
  <!-- Author -->
39
39
  <div :class="['flex gap-3 px-5 py-4', props.authorBio ? 'items-start' : 'items-center']">
40
40
  <!-- Author image -->
41
- <NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="shrink-0 rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
41
+ <NuxtLinkLocale :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="shrink-0 rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
42
42
  <NuxtImg :src="props.authorImageUrl" :alt="props.author" height="44" width="44" format="avif" quality="40" :sizes="{ 480: '44px', 1280: '44px' }" loading="lazy" decoding="async" class="size-11 rounded border border-border object-cover" />
43
- </NuxtLink>
43
+ </NuxtLinkLocale>
44
44
 
45
45
  <!-- Author name and bio -->
46
46
  <div class="min-w-0 flex-1">
47
47
  <p class="text-[11px] font-semibold tracking-wider text-muted-foreground uppercase">{{ t('uiVintage.blog.author') }}</p>
48
- <NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="group/link inline-flex items-center gap-1 text-sm font-semibold text-foreground outline-none transition-colors hover:text-primary focus-visible:underline md:text-base">
48
+ <NuxtLinkLocale :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="group/link inline-flex items-center gap-1 text-sm font-semibold text-foreground outline-none transition-colors hover:text-primary focus-visible:underline md:text-base">
49
49
  {{ props.author }}
50
50
  <HugeiconsIcon :icon="ArrowUpRight01Icon" class="size-4 text-muted-foreground transition-[translate,color] duration-300 ease-spring group-hover/link:translate-x-0.5 group-hover/link:-translate-y-0.5 group-hover/link:text-primary" />
51
- </NuxtLink>
51
+ </NuxtLinkLocale>
52
52
  <p v-if="props.authorBio" class="mt-1.5 text-xs leading-relaxed text-muted-foreground md:text-sm">
53
53
  {{ props.authorBio }}
54
54
  </p>
@@ -7,7 +7,7 @@ import BlogHeaderSection from './BlogHeaderSection.vue';
7
7
  import BlogPageBreadcrumbs from './BlogPageBreadcrumbs.vue';
8
8
  import PostsList from './PostsList.vue';
9
9
 
10
- // Props (the posts of a category or a tag, their paths already localized)
10
+ // Props (the posts of a category or a tag, the links localize their paths)
11
11
  const props = withDefaults(defineProps<{
12
12
  section: 'categories' | 'tags';
13
13
  title: string;
@@ -18,8 +18,8 @@ const props = withDefaults(defineProps<{
18
18
  <template>
19
19
  <div class="grid grid-cols-1 gap-5 sm:grid-cols-2 md:gap-6 lg:grid-cols-3 xl:grid-cols-4">
20
20
  <!-- Category card -->
21
- <NuxtLink v-for="(category, index) in props.categories" :key="category.path || category.slug" data-aos="blur-up" :data-aos-delay="(index % 4) * 80" :to="category.path" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
21
+ <NuxtLinkLocale v-for="(category, index) in props.categories" :key="category.path || category.slug" data-aos="blur-up" :data-aos-delay="(index % 4) * 80" :to="category.path" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
22
22
  <CategoryCard :name="category.name" :image="category.image" :count="category.count" />
23
- </NuxtLink>
23
+ </NuxtLinkLocale>
24
24
  </div>
25
25
  </template>
@@ -112,10 +112,10 @@ onUnmounted(() => {
112
112
  <div class="relative h-[28rem] w-full md:h-[32rem]">
113
113
  <div v-for="(post, index) in props.featuredPosts" :id="`${slideIdPrefix}-${index}`" :key="post.path || post.title || index" role="group" aria-roledescription="slide" :aria-label="`${index + 1} / ${slideCount}`" :aria-hidden="currentSlide !== index" :inert="currentSlide !== index || undefined" :class="cn('absolute inset-0 transition-[opacity,scale] duration-700 ease-out-expo', currentSlide === index ? 'z-10 scale-100 opacity-100' : 'z-0 scale-[1.02] opacity-0')">
114
114
  <!-- Slide image with a slow zoom -->
115
- <NuxtLink :to="post.path" tabindex="-1" aria-hidden="true" class="absolute inset-0 overflow-hidden">
115
+ <NuxtLinkLocale :to="post.path" tabindex="-1" aria-hidden="true" class="absolute inset-0 overflow-hidden">
116
116
  <NuxtImg v-if="post.image" :src="post.image" :alt="post.title" width="1200" height="640" format="avif" quality="45" :sizes="{ 480: '480px', 1536: '1152px' }" :loading="index === 0 ? 'eager' : 'lazy'" :fetchpriority="index === 0 ? 'high' : 'auto'" :preload="index === 0" :class="cn('size-full object-cover', currentSlide === index && 'animate-uv-ken-burns')" />
117
117
  <div v-else class="size-full bg-[radial-gradient(circle_at_20%_20%,color-mix(in_oklab,var(--primary)_45%,transparent),transparent_60%)] bg-surface" />
118
- </NuxtLink>
118
+ </NuxtLinkLocale>
119
119
 
120
120
  <!-- Legibility shade -->
121
121
  <div aria-hidden="true" class="pointer-events-none absolute inset-0 bg-linear-to-t from-black/90 via-black/45 to-black/5" />
@@ -124,16 +124,16 @@ onUnmounted(() => {
124
124
  <!-- Slide content (replays its entrance every time the slide becomes active) -->
125
125
  <div class="absolute inset-x-0 bottom-0 flex flex-col gap-4 p-6 pb-20 text-white md:max-w-3xl md:p-10 md:pb-24">
126
126
  <!-- Category badge -->
127
- <NuxtLink v-if="post.categoryText && post.categoryPath" :to="post.categoryPath" :class="cn('w-fit', currentSlide === index && 'animate-uv-fade-up')">
127
+ <NuxtLinkLocale v-if="post.categoryText && post.categoryPath" :to="post.categoryPath" :class="cn('w-fit', currentSlide === index && 'animate-uv-fade-up')">
128
128
  <Badge :text="post.categoryText" pulse class="border-white/25 bg-white/15 text-white backdrop-blur-md transition-colors hover:bg-white/25" />
129
- </NuxtLink>
129
+ </NuxtLinkLocale>
130
130
 
131
131
  <!-- Title -->
132
- <NuxtLink :to="post.path" :class="cn('outline-none', currentSlide === index && 'animate-uv-fade-up [animation-delay:90ms]')">
132
+ <NuxtLinkLocale :to="post.path" :class="cn('outline-none', currentSlide === index && 'animate-uv-fade-up [animation-delay:90ms]')">
133
133
  <h3 class="line-clamp-3 text-2xl leading-tight font-bold tracking-tight text-balance drop-shadow-sm md:text-5xl">
134
134
  {{ post.title }}
135
135
  </h3>
136
- </NuxtLink>
136
+ </NuxtLinkLocale>
137
137
 
138
138
  <!-- Description -->
139
139
  <p v-if="post.description" :class="cn('line-clamp-2 max-w-2xl text-xs leading-relaxed text-white/80 md:text-base', currentSlide === index && 'animate-uv-fade-up [animation-delay:180ms]')">
@@ -143,10 +143,10 @@ onUnmounted(() => {
143
143
  <!-- Read post button -->
144
144
  <div :class="cn(currentSlide === index && 'animate-uv-fade-up [animation-delay:260ms]')">
145
145
  <Button as-child size="lg" class="group/cta">
146
- <NuxtLink :to="post.path">
146
+ <NuxtLinkLocale :to="post.path">
147
147
  {{ t('uiVintage.blog.goToPost') }}
148
148
  <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/cta:translate-x-1" />
149
- </NuxtLink>
149
+ </NuxtLinkLocale>
150
150
  </Button>
151
151
  </div>
152
152
  </div>
@@ -19,8 +19,8 @@ const props = withDefaults(defineProps<{
19
19
  <template>
20
20
  <div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
21
21
  <!-- Post cards rise in row by row as they scroll into view -->
22
- <NuxtLink v-for="(post, index) in props.posts" :key="post.path" data-aos="blur-up" :data-aos-delay="(index % 3) * 90" :to="post.path" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
22
+ <NuxtLinkLocale v-for="(post, index) in props.posts" :key="post.path" data-aos="blur-up" :data-aos-delay="(index % 3) * 90" :to="post.path" class="block h-full rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
23
23
  <PostCard class="h-full" :image="post.image" :category="post.categoryText" :title="post.title" :description="post.description" />
24
- </NuxtLink>
24
+ </NuxtLinkLocale>
25
25
  </div>
26
26
  </template>
@@ -38,8 +38,8 @@ export const slugify = (value: unknown) => {
38
38
  // Remove the trailing slashes of a route path, since content paths never have one ("/blog/post/" -> "/blog/post")
39
39
  export const normalizeContentPath = (path: string) => path.replace(/(.)\/+$/, '$1');
40
40
 
41
- // Build the unique categories of the posts, with their post count and the image of their first post
42
- export const buildCategoriesFromPosts = (posts: BlogPostData[], getPath: (slug: string) => string): BlogCategoryData[] => {
41
+ // Build the unique categories of the posts, with their post count and the image of their first post (the path is the blog category page by default)
42
+ export const buildCategoriesFromPosts = (posts: BlogPostData[], getPath: (slug: string) => string = slug => `/blog/categories/${slug}`): BlogCategoryData[] => {
43
43
  const categoriesList = posts.map(post => post.categorySlug);
44
44
  const uniqueCategories = categoriesList.filter((category, index, self): category is string => !!category && self.indexOf(category) === index);
45
45
 
@@ -2,7 +2,7 @@
2
2
  import { NuxtImg } from '#components';
3
3
  import { computed } from 'vue';
4
4
  import { useI18n } from 'vue-i18n';
5
- import type { Component, HTMLAttributes } from 'vue';
5
+ import type { HTMLAttributes } from 'vue';
6
6
  import { HugeiconsIcon } from '@hugeicons/vue';
7
7
  import BackgroundGrid from '../background-grid/BackgroundGrid.vue';
8
8
  import { ThemeSelector } from '../theme-selector';
@@ -11,7 +11,7 @@ import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupConte
11
11
  import { cn } from '../../lib/utils';
12
12
  import type { HugeiconsIconDefinition } from '../../lib/common-types';
13
13
 
14
- // Props
14
+ // Props (item and home paths are localized by the links, external URLs work too)
15
15
  const props = withDefaults(defineProps<{
16
16
  appName?: string;
17
17
  appLogo?: string;
@@ -29,13 +29,11 @@ const props = withDefaults(defineProps<{
29
29
  label: string;
30
30
  description?: string;
31
31
  icon?: HugeiconsIconDefinition | null;
32
- href?: string;
33
- to?: unknown;
32
+ to: string;
34
33
  active?: boolean;
35
34
  newTab?: boolean;
36
35
  }>;
37
36
  }>;
38
- sidebarLinkComponent?: Component | string;
39
37
  showBackground?: boolean;
40
38
  insetClass?: HTMLAttributes['class'];
41
39
  topbarClass?: HTMLAttributes['class'];
@@ -50,26 +48,14 @@ const props = withDefaults(defineProps<{
50
48
  collapsible: 'offcanvas',
51
49
  compact: false,
52
50
  sidebarSections: () => [],
53
- sidebarLinkComponent: 'a',
54
51
  showBackground: true
55
52
  });
56
53
 
57
- // Resolve link props for sidebar items
54
+ // Get the link props of a sidebar item (new tab links open in a separate, unlinked tab)
58
55
  const getSidebarItemLinkProps = (item: NonNullable<typeof props.sidebarSections>[number]['items'][number]) => {
59
- const sharedProps = item.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {};
60
-
61
- // If the link component is an anchor, use href. Otherwise, use to for router links.
62
- if (props.sidebarLinkComponent === 'a') {
63
- return {
64
- href: item.href ?? '#',
65
- ...sharedProps
66
- };
67
- }
68
-
69
- // For non-anchor link components, assume they use a "to" prop (like Vue Router's <RouterLink>).
70
56
  return {
71
- to: item.to ?? item.href ?? '/',
72
- ...sharedProps
57
+ to: item.to,
58
+ ...(item.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})
73
59
  };
74
60
  };
75
61
 
@@ -93,8 +79,9 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
93
79
  <slot name="sidebar-header">
94
80
  <SidebarMenu>
95
81
  <SidebarMenuItem>
82
+ <!-- Home link (the path is localized) -->
96
83
  <SidebarMenuButton as-child size="lg">
97
- <a :href="props.appLinkTo" class="inline-flex items-center gap-2">
84
+ <NuxtLinkLocale :to="props.appLinkTo" class="inline-flex items-center gap-2">
98
85
  <div class="inline-flex items-center gap-2">
99
86
  <!-- App logo -->
100
87
  <template v-if="lightLogo">
@@ -105,7 +92,7 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
105
92
  <!-- App name -->
106
93
  <span v-if="props.appName" class="text-xl font-semibold tracking-tight text-foreground">{{ props.appName }}</span>
107
94
  </div>
108
- </a>
95
+ </NuxtLinkLocale>
109
96
  </SidebarMenuButton>
110
97
  </SidebarMenuItem>
111
98
  </SidebarMenu>
@@ -125,9 +112,9 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
125
112
  <SidebarMenu :class="cn('gap-1', props.compact && 'gap-0.5')">
126
113
  <SidebarMenuItem v-for="item in section.items" :key="item.id">
127
114
  <SidebarMenuButton as-child :is-active="item.active" :class="cn('group/nav relative h-auto min-h-11 overflow-hidden rounded px-3 py-2.5 text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground data-[active=true]:bg-primary/10 data-[active=true]:text-foreground sm:px-3.5', 'before:absolute before:inset-y-2 before:left-0 before:w-0.5 before:origin-center before:scale-y-0 before:bg-primary before:transition-transform before:duration-300 before:ease-spring data-[active=true]:before:scale-y-100', item.description ? 'items-start' : 'items-center')">
128
- <component :is="props.sidebarLinkComponent" v-bind="getSidebarItemLinkProps(item)" :aria-current="item.active ? 'page' : undefined">
129
- <!-- Icon -->
130
- <HugeiconsIcon v-if="item.icon" :icon="item.icon" :stroke-width="1.8" :class="cn('shrink-0 opacity-90 transition-[opacity,color] duration-150 group-hover/nav:opacity-100 group-data-[active=true]/nav:text-primary', item.description ? 'mt-0.5 size-5 sm:h-5 sm:w-5' : 'size-5 self-center sm:h-6 sm:w-6')" />
115
+ <NuxtLinkLocale v-bind="getSidebarItemLinkProps(item)" :aria-current="item.active ? 'page' : undefined">
116
+ <!-- Icon, centered on the capitals of the label (or of its first line, 1.25em tall): the line box alone centers it 0.08em too low, because of the space the font keeps for descenders (the size is important: the menu button shrinks its direct svg children to size-4) -->
117
+ <HugeiconsIcon v-if="item.icon" :icon="item.icon" :stroke-width="1.8" :class="cn('size-5! shrink-0 opacity-90 transition-[opacity,color] duration-150 group-hover/nav:opacity-100 group-data-[active=true]/nav:text-primary', item.description ? 'mt-[calc((1.25em_-_20px)/2_-_0.08em)]' : 'self-center -translate-y-[0.08em]')" />
131
118
 
132
119
  <!-- Label and optional description -->
133
120
  <div :class="cn('min-w-0 flex-1 text-left leading-tight', item.description ? 'grid' : 'flex items-center')">
@@ -139,7 +126,7 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
139
126
  {{ item.description }}
140
127
  </span>
141
128
  </div>
142
- </component>
129
+ </NuxtLinkLocale>
143
130
  </SidebarMenuButton>
144
131
  </SidebarMenuItem>
145
132
  </SidebarMenu>
@@ -2,16 +2,16 @@
2
2
  import { NuxtImg } from '#components';
3
3
  import FavouriteIcon from '@hugeicons/core-free-icons/FavouriteIcon';
4
4
  import { HugeiconsIcon } from '@hugeicons/vue';
5
- import { computed, type Component, type HTMLAttributes } from 'vue';
5
+ import { computed, type HTMLAttributes } from 'vue';
6
6
  import { useI18n } from 'vue-i18n';
7
7
  import { cn } from '../../../lib/utils';
8
8
  import type { HeadingLevel } from '../../../lib/common-types';
9
9
 
10
+ // Footer link (the path is localized by the link, external URLs work too)
10
11
  export interface LandingFooterLink {
11
12
  id: string;
12
13
  label: string;
13
- href?: string;
14
- to?: unknown;
14
+ to: string;
15
15
  newTab?: boolean;
16
16
  }
17
17
 
@@ -30,7 +30,6 @@ const props = withDefaults(defineProps<{
30
30
  logoClass?: HTMLAttributes['class'];
31
31
  appDescription?: string;
32
32
  sections?: LandingFooterSection[];
33
- linkComponent?: Component | string;
34
33
  authorName?: string;
35
34
  authorLink?: string;
36
35
  year?: number;
@@ -45,7 +44,6 @@ const props = withDefaults(defineProps<{
45
44
  appLinkTo: '/',
46
45
  appDescription: '',
47
46
  sections: () => [],
48
- linkComponent: 'a',
49
47
  authorName: '',
50
48
  authorLink: '',
51
49
  year: () => new Date().getFullYear(),
@@ -57,22 +55,11 @@ const { t } = useI18n();
57
55
  const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name: props.appName }) : t('uiVintage.common.home'));
58
56
  const hasSingleSection = computed(() => props.sections.length === 1);
59
57
 
60
- // Get link props based on the type of link component and whether the link should open in a new tab
58
+ // Get the link props (new tab links open in a separate, unlinked tab)
61
59
  const getLinkProps = (link: LandingFooterLink) => {
62
- const sharedProps = link.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {};
63
-
64
- // If the link component is an anchor, use href. Otherwise, use to for router links.
65
- if (props.linkComponent === 'a') {
66
- return {
67
- href: link.href ?? '#',
68
- ...sharedProps
69
- };
70
- }
71
-
72
- // For non-anchor link components, assume they use a "to" prop (like Vue Router's <RouterLink>).
73
60
  return {
74
- to: link.to ?? link.href ?? '/',
75
- ...sharedProps
61
+ to: link.to,
62
+ ...(link.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})
76
63
  };
77
64
  };
78
65
 
@@ -84,18 +71,19 @@ const hasDarkLogo = computed(() => darkLogo.value !== lightLogo.value);
84
71
 
85
72
  <template>
86
73
  <footer :class="cn('relative z-10 border-t border-border bg-card', props.class)">
87
- <div :class="cn('mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8', props.containerClass)">
74
+ <!-- Same width and padding as the landing content, so the footer lines up with the page -->
75
+ <div :class="cn('mx-auto max-w-6xl px-3 py-12 sm:px-6 lg:px-8', props.containerClass)">
88
76
  <div :class="cn('flex flex-col gap-8 md:flex-row md:items-start md:justify-between')">
89
77
  <!-- Brand section -->
90
78
  <div :class="cn('max-w-md flex-1', props.brandClass)">
91
- <!-- App logo and name -->
92
- <a :href="props.appLinkTo" class="mb-4 inline-flex items-center gap-2 text-lg font-semibold tracking-tight text-foreground transition-opacity duration-150 hover:opacity-80">
79
+ <!-- App logo and name (home link, the path is localized) -->
80
+ <NuxtLinkLocale :to="props.appLinkTo" class="mb-4 inline-flex items-center gap-2 text-lg font-semibold tracking-tight text-foreground transition-opacity duration-150 hover:opacity-80">
93
81
  <template v-if="lightLogo">
94
82
  <NuxtImg :src="lightLogo" :alt="logoAlt" width="32" height="32" :sizes="{ 320: '32px', 1280: '32px' }" loading="lazy" decoding="async" :class="cn('size-8 shrink-0 object-contain', hasDarkLogo && 'dark:hidden', props.logoClass)" />
95
83
  <NuxtImg v-if="hasDarkLogo" :src="darkLogo" :alt="logoAlt" width="32" height="32" :sizes="{ 320: '32px', 1280: '32px' }" loading="lazy" decoding="async" :class="cn('hidden size-8 shrink-0 object-contain dark:block', props.logoClass)" />
96
84
  </template>
97
85
  <span>{{ props.appName }}</span>
98
- </a>
86
+ </NuxtLinkLocale>
99
87
 
100
88
  <!-- App description -->
101
89
  <p v-if="props.appDescription" class="max-w-sm text-xs md:text-sm leading-relaxed text-muted-foreground">
@@ -114,9 +102,9 @@ const hasDarkLogo = computed(() => darkLogo.value !== lightLogo.value);
114
102
  <!-- Section links -->
115
103
  <ul class="space-y-3 text-xs sm:text-sm">
116
104
  <li v-for="link in props.sections[0]?.links" :key="link.id">
117
- <component :is="props.linkComponent" v-bind="getLinkProps(link)" class="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary">
105
+ <NuxtLinkLocale v-bind="getLinkProps(link)" class="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary">
118
106
  {{ link.label }}
119
- </component>
107
+ </NuxtLinkLocale>
120
108
  </li>
121
109
  </ul>
122
110
  </div>
@@ -133,9 +121,9 @@ const hasDarkLogo = computed(() => darkLogo.value !== lightLogo.value);
133
121
  <!-- Section links -->
134
122
  <ul class="space-y-3 text-xs sm:text-sm">
135
123
  <li v-for="link in section.links" :key="link.id">
136
- <component :is="props.linkComponent" v-bind="getLinkProps(link)" class="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary">
124
+ <NuxtLinkLocale v-bind="getLinkProps(link)" class="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat text-muted-foreground transition-[color,background-size] duration-300 ease-out-expo hover:bg-[length:100%_1px] hover:text-primary">
137
125
  {{ link.label }}
138
- </component>
126
+ </NuxtLinkLocale>
139
127
  </li>
140
128
  </ul>
141
129
  </div>
@@ -64,7 +64,8 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
64
64
  <!-- Left-aligned content -->
65
65
  <div :class="cn('flex items-center', props.leftClass)">
66
66
  <slot name="left">
67
- <a v-if="lightLogo || props.appName" :href="props.appLinkTo" class="inline-flex items-center gap-2 transition-opacity duration-150 hover:opacity-80">
67
+ <!-- Home link (the path is localized) -->
68
+ <NuxtLinkLocale v-if="lightLogo || props.appName" :to="props.appLinkTo" class="inline-flex items-center gap-2 transition-opacity duration-150 hover:opacity-80">
68
69
  <!-- App logo -->
69
70
  <template v-if="lightLogo">
70
71
  <NuxtImg :src="lightLogo" :alt="logoAlt" width="44" height="44" :sizes="{ 320: '44px', 640: '36px' }" loading="eager" fetchpriority="high" :class="cn('size-11 sm:size-9 shrink-0 object-contain', hasDarkLogo && 'dark:hidden', props.logoClass)" />
@@ -75,7 +76,7 @@ const logoAlt = computed(() => props.appName ? t('uiVintage.common.logo', { name
75
76
  <span v-if="props.appName" class="hidden sm:inline! text-xl font-semibold tracking-tight text-foreground">
76
77
  {{ props.appName }}
77
78
  </span>
78
- </a>
79
+ </NuxtLinkLocale>
79
80
  </slot>
80
81
  </div>
81
82
 
@@ -23,7 +23,8 @@ const hasStructuredSlots = computed(() => {
23
23
  </script>
24
24
 
25
25
  <template>
26
- <div :class="cn('relative flex min-h-screen flex-col bg-background text-foreground', props.rootClass)">
26
+ <!-- Clip the horizontal overflow of decorations (glows, marquees), so the page never scrolls sideways on mobile -->
27
+ <div :class="cn('relative flex min-h-screen flex-col overflow-x-clip bg-background text-foreground', props.rootClass)">
27
28
  <!-- Background grid -->
28
29
  <BackgroundGrid v-if="props.showBackground" />
29
30
 
@@ -23,7 +23,8 @@ const props = withDefaults(defineProps<{
23
23
  <template>
24
24
  <!-- Compact row of stats in one bordered strip -->
25
25
  <ul v-if="props.items.length > 0" data-slot="stat-strip" :class="cn('inline-flex max-w-full flex-wrap items-stretch overflow-hidden rounded border border-border bg-card text-xs shadow-elevated-sm', props.class)">
26
- <li v-for="(item, index) in props.items" :key="item.label" :class="['flex items-center gap-2 px-4 py-2.5', index > 0 && 'border-l border-border']">
26
+ <!-- Each stat draws a left and a top divider, shifted under the strip border where it is not needed (first column and first row), and grows to fill its row when the strip wraps -->
27
+ <li v-for="item in props.items" :key="item.label" class="-ml-px -mt-px flex grow items-center justify-center gap-2 border-l border-t border-border px-4 py-2.5">
27
28
  <HugeiconsIcon v-if="item.icon" :icon="item.icon" class="size-4 shrink-0 text-primary" />
28
29
  <span class="font-semibold text-foreground tabular-nums">
29
30
  <AnimatedNumber v-if="props.animate && typeof item.value === 'number'" :value="item.value" :format-options="item.formatOptions" />
@@ -1,29 +1,15 @@
1
1
  <script setup lang="ts">
2
- import type { Component } from 'vue';
3
-
4
- // Props
5
- const props = withDefaults(defineProps<{
2
+ // Props (the path is localized by the link, external URLs work too)
3
+ const props = defineProps<{
6
4
  to: string;
7
5
  text: string;
8
- linkComponent?: Component | string;
9
- }>(), {
10
- linkComponent: 'a'
11
- });
6
+ }>();
12
7
 
13
8
  // Emits
14
9
  const emits = defineEmits<{
15
10
  click: [event: MouseEvent];
16
11
  }>();
17
12
 
18
- // Get link props based on the type of link component
19
- const getLinkProps = () => {
20
- if (props.linkComponent === 'a') {
21
- return { href: props.to };
22
- } else {
23
- return { to: props.to };
24
- }
25
- };
26
-
27
13
  // Handle click
28
14
  const handleClick = (event: MouseEvent) => {
29
15
  emits('click', event);
@@ -31,7 +17,7 @@ const handleClick = (event: MouseEvent) => {
31
17
  </script>
32
18
 
33
19
  <template>
34
- <component :is="props.linkComponent" v-bind="getLinkProps()" class="cursor-pointer font-semibold text-foreground hover:underline" @click="handleClick">
20
+ <NuxtLinkLocale :to="props.to" class="cursor-pointer font-semibold text-foreground hover:underline" @click="handleClick">
35
21
  {{ props.text }}
36
- </component>
22
+ </NuxtLinkLocale>
37
23
  </template>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
+ import { NuxtLinkLocale } from '#components';
2
3
  import type { PrimitiveProps } from 'reka-ui';
3
- import type { HTMLAttributes } from 'vue';
4
+ import { computed, type HTMLAttributes } from 'vue';
4
5
  import type { ButtonVariants } from '.';
5
6
  import { Primitive } from 'reka-ui';
6
7
  import { cn } from '../../../lib/utils';
@@ -13,16 +14,36 @@ const props = withDefaults(defineProps<PrimitiveProps & {
13
14
  size?: ButtonVariants['size'];
14
15
  loading?: boolean;
15
16
  disabled?: boolean;
17
+ to?: string;
16
18
  class?: HTMLAttributes['class'];
17
19
  }>(), {
18
20
  as: 'button',
19
21
  loading: false,
20
22
  disabled: false
21
23
  });
24
+
25
+ // Disabled or loading: the button can't be pressed (or followed, for a navigation button)
26
+ const isInactive = computed(() => props.disabled || props.loading);
27
+
28
+ // Same-page anchor (like #setup): a plain link, since there is no route to localize
29
+ const isAnchor = computed(() => !!props.to?.startsWith('#'));
30
+
31
+ // Element to render: a localized link for navigation buttons (a plain anchor for same-page links, a span while inactive so it can't be followed), otherwise the as element
32
+ const element = computed(() => {
33
+ if (!props.to) return props.as;
34
+ if (isInactive.value) return 'span';
35
+ return isAnchor.value ? 'a' : NuxtLinkLocale;
36
+ });
37
+
38
+ // Link attribute for the rendered element (href for a plain anchor, to for the localized link)
39
+ const linkAttributes = computed(() => {
40
+ if (!props.to || isInactive.value) return {};
41
+ return isAnchor.value ? { href: props.to } : { to: props.to };
42
+ });
22
43
  </script>
23
44
 
24
45
  <template>
25
- <Primitive data-slot="button" :data-variant="variant" :data-size="size" :data-loading="props.loading ? '' : undefined" :aria-busy="props.loading || undefined" :disabled="props.disabled || props.loading || undefined" :as="as" :as-child="asChild" :class="cn(buttonVariants({ variant, size }), props.class)">
46
+ <Primitive data-slot="button" :data-variant="variant" :data-size="size" :data-loading="props.loading ? '' : undefined" :aria-busy="props.loading || undefined" :disabled="(!props.to && isInactive) || undefined" :aria-disabled="(props.to && isInactive) || undefined" v-bind="linkAttributes" :as="element" :as-child="asChild" :class="cn(buttonVariants({ variant, size }), props.class)">
26
47
  <!-- As-child buttons delegate rendering to the slotted element -->
27
48
  <slot v-if="asChild" />
28
49
 
@@ -32,7 +32,7 @@ export const buttonVariants: (props?: ButtonVariants & ClassProp) => string = cv
32
32
  [
33
33
  "relative isolate inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 overflow-hidden whitespace-nowrap rounded text-xs font-semibold outline-none sm:text-sm",
34
34
  "[transition:color_150ms,background-color_150ms,border-color_150ms,box-shadow_220ms,opacity_150ms,scale_380ms_var(--ease-spring)]",
35
- "enabled:active:scale-[0.97] enabled:active:duration-75 disabled:pointer-events-none disabled:opacity-50 data-loading:cursor-wait data-loading:disabled:opacity-100",
35
+ "not-disabled:not-aria-disabled:active:scale-[0.97] not-disabled:not-aria-disabled:active:duration-75 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-loading:cursor-wait data-loading:disabled:opacity-100 data-loading:aria-disabled:opacity-100",
36
36
  "focus-visible:ring-[3px] focus-visible:ring-ring/45",
37
37
  "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
38
38
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
@@ -2,4 +2,5 @@ declare module '#components' {
2
2
  import type { DefineComponent } from 'vue';
3
3
 
4
4
  export const NuxtImg: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>;
5
+ export const NuxtLinkLocale: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>;
5
6
  }