@brumaombra/ui-vintage 0.8.2 → 0.9.1

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.
Files changed (152) hide show
  1. package/AGENTS.md +41 -0
  2. package/COMPONENTS.md +89 -18
  3. package/README.md +296 -262
  4. package/module.mjs +5 -11
  5. package/package.json +30 -12
  6. package/src/components/blog/BlogContentRenderer.vue +1 -1
  7. package/src/components/blog/BlogFAQSection.vue +1 -1
  8. package/src/components/blog/BlogHeaderSection.vue +93 -19
  9. package/src/components/blog/BlogInfoSection.vue +42 -37
  10. package/src/components/blog/BlogPostHeader.vue +181 -0
  11. package/src/components/blog/BlogProgressBar.vue +3 -5
  12. package/src/components/blog/BlogSectionTitle.vue +8 -6
  13. package/src/components/blog/BlogTagsSection.vue +16 -7
  14. package/src/components/blog/CategoryCard.vue +19 -23
  15. package/src/components/blog/HeaderCarousel.vue +17 -7
  16. package/src/components/blog/PostCard.vue +14 -21
  17. package/src/components/blog/SocialShareSidebar.vue +32 -15
  18. package/src/components/blog/TableOfContents.vue +64 -29
  19. package/src/components/blog/index.ts +1 -0
  20. package/src/components/card-grid/CardGrid.vue +3 -3
  21. package/src/components/chip/Chip.vue +1 -1
  22. package/src/components/confirm-dialog/ConfirmDialog.vue +6 -1
  23. package/src/components/confirm-dialog/confirm-dialog-state.ts +22 -22
  24. package/src/components/content/BlogList.vue +29 -52
  25. package/src/components/content/BlogTable.vue +26 -30
  26. package/src/components/content/CodeBlock.vue +99 -23
  27. package/src/components/content/Flow.vue +29 -35
  28. package/src/components/content/ProseHr.vue +6 -6
  29. package/src/components/content/ProsePre.vue +9 -4
  30. package/src/components/content/Terminal.vue +45 -21
  31. package/src/components/cookie-consent/CookieConsent.vue +1 -1
  32. package/src/components/dashboard-shell/DashboardShell.vue +24 -7
  33. package/src/components/empty-state-card/EmptyStateCard.vue +3 -3
  34. package/src/components/error-page/ErrorPage.vue +4 -4
  35. package/src/components/info-card/InfoCard.vue +4 -4
  36. package/src/components/landing/landing-footer/LandingFooter.vue +17 -12
  37. package/src/components/landing/landing-navbar/LandingNavbar.vue +22 -6
  38. package/src/components/landing/landing-shell/LandingShell.vue +2 -7
  39. package/src/components/language-flag/index.ts +1 -0
  40. package/src/components/language-selector/LanguageSelector.vue +19 -11
  41. package/src/components/load-more-button/LoadMoreButton.vue +9 -13
  42. package/src/components/loading-state-card/LoadingStateCard.vue +3 -3
  43. package/src/components/message-dialog/MessageDialog.vue +14 -12
  44. package/src/components/message-dialog/message-dialog-state.ts +1 -4
  45. package/src/components/message-toast/MessageToast.vue +28 -10
  46. package/src/components/message-toast/message-toast-state.ts +20 -0
  47. package/src/components/page-header/PageHeader.vue +1 -1
  48. package/src/components/progress-component/ProgressComponent.vue +7 -3
  49. package/src/components/single-value-card/SingleValueCard.vue +39 -40
  50. package/src/components/slider-form-component/SliderFormComponent.vue +1 -1
  51. package/src/components/stat-strip/StatStrip.vue +36 -0
  52. package/src/components/stat-strip/index.ts +2 -0
  53. package/src/components/switch-form-component/SwitchFormComponent.vue +5 -5
  54. package/src/components/text-link/TextLink.vue +1 -1
  55. package/src/components/theme-selector/ThemeSelector.vue +6 -3
  56. package/src/components/theme-selector/theme-transition.ts +15 -3
  57. package/src/components/ui/accordion/Accordion.vue +19 -19
  58. package/src/components/ui/alert/AlertTitle.vue +1 -1
  59. package/src/components/ui/alert/index.ts +1 -1
  60. package/src/components/ui/avatar/AvatarFallback.vue +2 -1
  61. package/src/components/ui/avatar/AvatarGroup.vue +13 -3
  62. package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +3 -3
  63. package/src/components/ui/busy/Busy.vue +5 -7
  64. package/src/components/ui/button/Button.vue +4 -2
  65. package/src/components/ui/button/index.ts +1 -4
  66. package/src/components/ui/calendar/Calendar.vue +12 -7
  67. package/src/components/ui/calendar/CalendarCell.vue +1 -1
  68. package/src/components/ui/calendar/CalendarCellTrigger.vue +1 -1
  69. package/src/components/ui/calendar/CalendarHeadCell.vue +1 -1
  70. package/src/components/ui/calendar/CalendarNextButton.vue +2 -2
  71. package/src/components/ui/calendar/CalendarPrevButton.vue +2 -2
  72. package/src/components/ui/card/Card.vue +5 -15
  73. package/src/components/ui/checkbox/Checkbox.vue +5 -1
  74. package/src/components/ui/combobox/ComboboxInput.vue +5 -1
  75. package/src/components/ui/combobox/ComboboxItem.vue +1 -1
  76. package/src/components/ui/combobox/ComboboxSelect.vue +2 -1
  77. package/src/components/ui/combobox/ComboboxTrigger.vue +1 -1
  78. package/src/components/ui/command/CommandGroup.vue +17 -3
  79. package/src/components/ui/command/CommandInput.vue +2 -2
  80. package/src/components/ui/command/CommandList.vue +5 -1
  81. package/src/components/ui/data-table/DataTable.vue +13 -4
  82. package/src/components/ui/date-picker/DatePicker.vue +8 -2
  83. package/src/components/ui/date-time-picker/DateTimePicker.vue +5 -1
  84. package/src/components/ui/dialog/DialogContent.vue +1 -1
  85. package/src/components/ui/dialog/DialogScrollContent.vue +1 -1
  86. package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +1 -1
  87. package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  88. package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
  89. package/src/components/ui/dropdown-menu/classes.ts +2 -0
  90. package/src/components/ui/field/Field.vue +4 -0
  91. package/src/components/ui/field/FieldLabel.vue +6 -2
  92. package/src/components/ui/field/field-context.ts +62 -0
  93. package/src/components/ui/field/index.ts +1 -0
  94. package/src/components/ui/file-dropzone/FileDropzone.vue +14 -5
  95. package/src/components/ui/input/Input.vue +5 -1
  96. package/src/components/ui/native-select/NativeSelect.vue +7 -3
  97. package/src/components/ui/number-field/NumberField.vue +7 -2
  98. package/src/components/ui/pagination/PaginationEllipsis.vue +1 -1
  99. package/src/components/ui/pagination/PaginationFirst.vue +1 -1
  100. package/src/components/ui/pagination/PaginationItem.vue +1 -1
  101. package/src/components/ui/pagination/PaginationLast.vue +1 -1
  102. package/src/components/ui/pagination/PaginationNext.vue +1 -1
  103. package/src/components/ui/pagination/PaginationPrevious.vue +1 -1
  104. package/src/components/ui/pin-input/PinInputInput.vue +2 -2
  105. package/src/components/ui/progress/Progress.vue +2 -2
  106. package/src/components/ui/radio-group/RadioGroupCard.vue +1 -1
  107. package/src/components/ui/select/SelectItem.vue +2 -2
  108. package/src/components/ui/select/SelectItemContent.vue +1 -1
  109. package/src/components/ui/select/SelectScrollDownButton.vue +1 -1
  110. package/src/components/ui/select/SelectScrollUpButton.vue +1 -1
  111. package/src/components/ui/select/SelectTrigger.vue +6 -2
  112. package/src/components/ui/sidebar/Sidebar.vue +1 -1
  113. package/src/components/ui/sidebar/SidebarGroupAction.vue +1 -1
  114. package/src/components/ui/sidebar/SidebarGroupLabel.vue +1 -1
  115. package/src/components/ui/sidebar/SidebarInset.vue +1 -1
  116. package/src/components/ui/sidebar/SidebarMenuAction.vue +1 -1
  117. package/src/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
  118. package/src/components/ui/sidebar/SidebarMenuSkeleton.vue +2 -2
  119. package/src/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
  120. package/src/components/ui/sidebar/SidebarProvider.vue +17 -6
  121. package/src/components/ui/sidebar/SidebarTrigger.vue +1 -1
  122. package/src/components/ui/sidebar/index.ts +2 -2
  123. package/src/components/ui/sidebar/utils.ts +1 -1
  124. package/src/components/ui/slider/Slider.vue +5 -4
  125. package/src/components/ui/spinner/Spinner.vue +3 -2
  126. package/src/components/ui/stepper/SimpleStepper.vue +1 -1
  127. package/src/components/ui/stepper/StepperDescription.vue +5 -3
  128. package/src/components/ui/stepper/StepperIndicator.vue +3 -3
  129. package/src/components/ui/stepper/StepperTitle.vue +5 -3
  130. package/src/components/ui/switch/Switch.vue +6 -2
  131. package/src/components/ui/tabs/TabsList.vue +1 -1
  132. package/src/components/ui/tabs/TabsTrigger.vue +1 -1
  133. package/src/components/ui/tags-input/TagsInputItemDelete.vue +1 -1
  134. package/src/components/ui/textarea/Textarea.vue +5 -1
  135. package/src/components/ui/time-picker/TimePicker.vue +2 -2
  136. package/src/components/ui/toggle-group/ToggleGroup.vue +1 -1
  137. package/src/components/ui/tooltip/TooltipContent.vue +1 -1
  138. package/src/i18n/de.json +16 -6
  139. package/src/i18n/en.json +16 -6
  140. package/src/i18n/es.json +16 -6
  141. package/src/i18n/fr.json +16 -6
  142. package/src/i18n/it.json +16 -6
  143. package/src/i18n/ja.json +16 -6
  144. package/src/i18n/pt.json +16 -6
  145. package/src/i18n/ru.json +16 -6
  146. package/src/i18n/zh.json +16 -6
  147. package/src/lib/color-tokens.ts +1 -1
  148. package/src/lib/i18n.ts +32 -4
  149. package/src/lib/utils.ts +21 -1
  150. package/src/runtime/ui-vintage-i18n-plugin.ts +11 -1
  151. package/src/styles.css +12 -16
  152. package/skills/ui-vintage/SKILL.md +0 -42
@@ -9,16 +9,18 @@ const props = withDefaults(defineProps<{
9
9
  </script>
10
10
 
11
11
  <template>
12
- <div data-aos="blur-up" class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between md:mb-8">
12
+ <div data-aos="blur-up" class="relative mb-6 flex flex-col gap-3 border-b border-border pb-4 sm:flex-row sm:items-end sm:justify-between md:mb-8">
13
+ <!-- Primary segment at the start of the rule -->
14
+ <span aria-hidden="true" class="absolute -bottom-px left-0 h-0.5 w-12 bg-primary" />
15
+
13
16
  <div class="min-w-0">
14
- <!-- Title with accent bar -->
15
- <h2 class="flex items-center gap-3 text-xl font-bold tracking-tight text-foreground md:text-2xl">
16
- <span aria-hidden="true" class="h-6 w-1 shrink-0 rounded-full bg-primary shadow-glow md:h-7" />
17
+ <!-- Title -->
18
+ <h2 class="text-xl font-semibold tracking-tight text-foreground md:text-2xl">
17
19
  {{ props.title }}
18
20
  </h2>
19
21
 
20
22
  <!-- Description -->
21
- <p v-if="props.description" class="mt-2 max-w-2xl text-xs leading-6 text-muted-foreground md:text-sm">
23
+ <p v-if="props.description" class="mt-1 max-w-2xl text-xs leading-6 text-muted-foreground md:text-sm">
22
24
  {{ props.description }}
23
25
  </p>
24
26
  </div>
@@ -28,4 +30,4 @@ const props = withDefaults(defineProps<{
28
30
  <slot name="action" />
29
31
  </div>
30
32
  </div>
31
- </template>
33
+ </template>
@@ -6,20 +6,29 @@ const props = withDefaults(defineProps<{
6
6
  slug: string;
7
7
  count: number;
8
8
  }>;
9
+ blogPath?: string;
9
10
  }>(), {
10
- tags: () => []
11
+ tags: () => [],
12
+ blogPath: '/blog'
11
13
  });
12
14
  </script>
13
15
 
14
16
  <template>
15
- <div v-if="props.tags.length" class="flex flex-wrap gap-3">
17
+ <div v-if="props.tags.length" class="flex flex-wrap gap-2">
16
18
  <!-- Tag chip -->
17
19
  <span v-for="(tag, index) in props.tags" :key="tag.slug" data-aos="blur-up" :data-aos-delay="Math.min(index * 40, 400)" class="inline-flex">
18
- <NuxtLinkLocale :to="`/blog/tags/${tag.slug}`" class="group/tag inline-flex items-center gap-2 rounded border border-border bg-card px-3 py-2 text-xs font-semibold text-foreground shadow-elevated-sm outline-none [transition:translate_300ms_var(--ease-spring),border-color_150ms,color_150ms,box-shadow_200ms] hover:-translate-y-0.5 hover:border-primary/50 hover:text-primary hover:shadow-elevated-md focus-visible:ring-[3px] focus-visible:ring-ring/45 sm:text-sm">
19
- <span aria-hidden="true" class="text-primary transition-[rotate] duration-300 ease-bounce group-hover/tag:rotate-12">#</span>
20
- {{ tag.name }}
21
- <span class="rounded-sm bg-surface px-1.5 py-0.5 text-[11px] text-muted-foreground tabular-nums transition-colors duration-150 group-hover/tag:bg-primary/10 group-hover/tag:text-primary">{{ tag.count }}</span>
20
+ <NuxtLinkLocale :to="`${props.blogPath}/tags/${tag.slug}`" class="group/tag inline-flex items-stretch overflow-hidden rounded border border-border text-xs shadow-elevated-sm outline-none transition-colors duration-150 hover:border-primary/40 focus-visible:ring-[3px] focus-visible:ring-ring/45 sm:text-sm">
21
+ <!-- Name -->
22
+ <span class="inline-flex items-center gap-1.5 bg-card px-3 py-1.5 font-medium text-foreground transition-colors duration-150 group-hover/tag:bg-primary/10 group-hover/tag:text-primary">
23
+ <span aria-hidden="true" class="text-primary">#</span>
24
+ {{ tag.name }}
25
+ </span>
26
+
27
+ <!-- Post count -->
28
+ <span class="inline-flex items-center border-l border-border bg-surface px-2 text-[11px] font-semibold text-muted-foreground tabular-nums transition-colors duration-150 group-hover/tag:border-primary/40 group-hover/tag:bg-primary/10 group-hover/tag:text-primary">
29
+ {{ tag.count }}
30
+ </span>
22
31
  </NuxtLinkLocale>
23
32
  </span>
24
33
  </div>
25
- </template>
34
+ </template>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { NuxtImg } from '#components';
3
- import { ArrowUpRight01Icon } from '@hugeicons/core-free-icons';
3
+ import ArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';
4
+ import Folder01Icon from '@hugeicons/core-free-icons/Folder01Icon';
4
5
  import { HugeiconsIcon } from '@hugeicons/vue';
5
6
  import { Card } from '../ui/card';
6
7
 
@@ -15,31 +16,26 @@ const props = withDefaults(defineProps<{
15
16
  </script>
16
17
 
17
18
  <template>
18
- <Card interactive class="group/category h-full gap-0! overflow-hidden p-0! sm:gap-0!">
19
- <div class="relative h-40 w-full overflow-hidden bg-surface">
20
- <!-- Background image -->
21
- <NuxtImg v-if="props.image" :src="props.image" :alt="props.name" height="225" width="400" format="avif" quality="35" :sizes="{ 480: '480px', 1280: '400px' }" loading="lazy" decoding="async" class="size-full object-cover transition-[scale] duration-700 ease-out-expo group-hover/category:scale-110" />
19
+ <Card class="group/category h-full gap-0! overflow-hidden p-0! transition-[translate,box-shadow] duration-300 ease-out-expo hover:-translate-y-0.5 hover:shadow-elevated-md">
20
+ <!-- Image -->
21
+ <div class="h-32 w-full overflow-hidden border-b border-border bg-surface">
22
+ <NuxtImg v-if="props.image" :src="props.image" :alt="props.name" height="225" width="400" format="avif" quality="35" :sizes="{ 480: '480px', 1280: '400px' }" loading="lazy" decoding="async" class="size-full object-cover transition-[scale] duration-700 ease-out-expo group-hover/category:scale-[1.03]" />
22
23
 
23
24
  <!-- Fallback when there is no image -->
24
- <div v-else aria-hidden="true" class="size-full bg-[radial-gradient(circle_at_30%_20%,color-mix(in_oklab,var(--primary)_35%,transparent),transparent_65%)]" />
25
-
26
- <!-- Legibility shade -->
27
- <div aria-hidden="true" class="absolute inset-0 bg-linear-to-t from-black/80 via-black/25 to-black/0" />
25
+ <div v-else aria-hidden="true" class="flex size-full items-center justify-center text-primary/60">
26
+ <HugeiconsIcon :icon="Folder01Icon" class="size-8" />
27
+ </div>
28
+ </div>
28
29
 
29
- <!-- Arrow that appears on hover -->
30
- <span aria-hidden="true" class="absolute top-3 right-3 flex size-8 scale-75 items-center justify-center rounded border border-white/25 bg-black/30 text-white opacity-0 backdrop-blur-md transition-[opacity,scale] duration-300 ease-spring group-hover/category:scale-100 group-hover/category:opacity-100">
31
- <HugeiconsIcon :icon="ArrowUpRight01Icon" class="size-4" />
30
+ <!-- Name, post count and arrow -->
31
+ <div class="flex grow items-center gap-3 bg-surface px-4 py-3 transition-colors duration-200 group-hover/category:bg-primary/10">
32
+ <span class="min-w-0 flex-1 truncate text-sm font-semibold text-foreground transition-colors duration-200 group-hover/category:text-primary">
33
+ {{ props.name }}
32
34
  </span>
33
-
34
- <!-- Name and count -->
35
- <div class="absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 p-4">
36
- <span class="truncate text-base font-bold text-white transition-[translate] duration-300 ease-spring group-hover/category:-translate-y-0.5">
37
- {{ props.name }}
38
- </span>
39
- <span class="shrink-0 rounded-sm border border-white/25 bg-white/15 px-2 py-0.5 text-xs font-bold text-white tabular-nums backdrop-blur-md">
40
- {{ props.count }}
41
- </span>
42
- </div>
35
+ <span class="shrink-0 rounded border border-primary/30 px-1.5 text-[11px] font-semibold leading-5 text-primary tabular-nums">
36
+ {{ props.count }}
37
+ </span>
38
+ <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 shrink-0 text-muted-foreground transition-[translate,color] duration-300 ease-spring group-hover/category:translate-x-1 group-hover/category:text-primary" />
43
39
  </div>
44
40
  </Card>
45
- </template>
41
+ </template>
@@ -1,8 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  import { NuxtImg } from '#components';
3
- import { computed, onMounted, onUnmounted, ref } from 'vue';
3
+ import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue';
4
4
  import { useI18n } from 'vue-i18n';
5
- import { ArrowLeft01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons';
5
+ import ArrowLeft01Icon from '@hugeicons/core-free-icons/ArrowLeft01Icon';
6
+ import ArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';
6
7
  import { HugeiconsIcon } from '@hugeicons/vue';
7
8
  import { Badge } from '../ui/badge';
8
9
  import { Button } from '../ui/button';
@@ -30,8 +31,17 @@ const SWIPE_THRESHOLD_PX = 50;
30
31
  // Carousel state
31
32
  const currentSlide = ref(0);
32
33
  const isPaused = ref(false);
33
- const autoplayEnabled = ref(false);
34
+ const motionAllowed = ref(false);
34
35
  const slideCount = computed(() => props.featuredPosts.length || 0);
36
+ const autoplayEnabled = computed(() => motionAllowed.value && slideCount.value > 1);
37
+ const slideIdPrefix = `carousel-${useId()}-slide`;
38
+
39
+ // Keep the active slide in range when the list of posts shrinks
40
+ watch(slideCount, (count) => {
41
+ if (currentSlide.value >= count) {
42
+ currentSlide.value = Math.max(0, count - 1);
43
+ }
44
+ });
35
45
  let swipeStartX: number | null = null;
36
46
 
37
47
  // Handle next slide press
@@ -84,9 +94,9 @@ const handleProgressEnd = () => {
84
94
  if (autoplayEnabled.value) nextSlide();
85
95
  };
86
96
 
87
- // Start autoplay when component mounts (never with reduced motion)
97
+ // Allow autoplay once mounted (never with reduced motion); it starts as soon as there are at least two posts
88
98
  onMounted(() => {
89
- autoplayEnabled.value = slideCount.value > 1 && !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
99
+ motionAllowed.value = !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
90
100
  document.addEventListener('visibilitychange', handleVisibilityChange);
91
101
  });
92
102
 
@@ -100,7 +110,7 @@ onUnmounted(() => {
100
110
  <div v-if="props.featuredPosts.length > 0" role="region" aria-roledescription="carousel" :aria-label="t('uiVintage.blog.carouselNavigation')" class="group/carousel relative isolate overflow-hidden rounded border border-border bg-card shadow-elevated-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45" tabindex="0" @mouseenter="pause" @mouseleave="resume" @focusin="pause" @focusout="resume" @keydown="handleKeydown" @pointerdown="handlePointerDown" @pointerup="handlePointerUp">
101
111
  <!-- Slides (stacked; the active one crossfades in) -->
102
112
  <div class="relative h-[28rem] w-full md:h-[32rem]">
103
- <div v-for="(post, index) in props.featuredPosts" :id="`carousel-slide-${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')">
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')">
104
114
  <!-- Slide image with a slow zoom -->
105
115
  <NuxtLink :to="post.path" tabindex="-1" aria-hidden="true" class="absolute inset-0 overflow-hidden">
106
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')" />
@@ -147,7 +157,7 @@ onUnmounted(() => {
147
157
  <div v-if="slideCount > 1" class="absolute inset-x-0 bottom-0 z-20 flex items-center justify-between gap-4 p-6 md:px-10 md:pb-8">
148
158
  <!-- Slide indicators (the active one fills up until the next slide) -->
149
159
  <div class="flex items-center gap-2" role="tablist" :aria-label="t('uiVintage.blog.carouselNavigation')">
150
- <button v-for="(_, index) in props.featuredPosts" :key="index" type="button" role="tab" :aria-selected="currentSlide === index" :aria-controls="`carousel-slide-${index}`" :aria-label="t('uiVintage.blog.goToSlide', { index: index + 1 })" :class="cn('relative h-1.5 cursor-pointer overflow-hidden rounded-full bg-white/30 outline-none transition-[width,background-color] duration-500 ease-spring hover:bg-white/50 focus-visible:ring-2 focus-visible:ring-white', currentSlide === index ? 'w-12' : 'w-4')" @click="goToSlide(index)">
160
+ <button v-for="(_, index) in props.featuredPosts" :key="index" type="button" role="tab" :aria-selected="currentSlide === index" :aria-controls="`${slideIdPrefix}-${index}`" :aria-label="t('uiVintage.blog.goToSlide', { index: index + 1 })" :class="cn('relative h-1.5 cursor-pointer overflow-hidden rounded-full bg-white/30 outline-none transition-[width,background-color] duration-500 ease-spring hover:bg-white/50 focus-visible:ring-2 focus-visible:ring-white', currentSlide === index ? 'w-12' : 'w-4')" @click="goToSlide(index)">
151
161
  <span v-if="currentSlide === index" :key="`progress-${currentSlide}`" class="absolute inset-0 origin-left rounded-full bg-primary" :style="autoplayEnabled ? { animation: `uv-grow-x ${AUTOPLAY_DURATION_MS}ms linear both`, animationPlayState: isPaused ? 'paused' : 'running' } : undefined" @animationend="handleProgressEnd" />
152
162
  </button>
153
163
  </div>
@@ -1,9 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  import { NuxtImg } from '#components';
3
3
  import { useI18n } from 'vue-i18n';
4
- import { ArrowRight01Icon } from '@hugeicons/core-free-icons';
4
+ import ArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';
5
5
  import { HugeiconsIcon } from '@hugeicons/vue';
6
- import { Badge } from '../ui/badge';
7
6
  import { Card } from '../ui/card';
8
7
 
9
8
  const { t } = useI18n();
@@ -23,25 +22,19 @@ const props = withDefaults(defineProps<{
23
22
  </script>
24
23
 
25
24
  <template>
26
- <Card interactive class="group/post h-full gap-0! overflow-hidden p-0! sm:gap-0!">
25
+ <Card class="group/post h-full gap-0! overflow-hidden p-0! transition-[translate,box-shadow] duration-300 ease-out-expo hover:-translate-y-0.5 hover:shadow-elevated-md">
27
26
  <!-- Featured image -->
28
- <div v-if="props.image" class="relative aspect-video w-full overflow-hidden border-b border-border bg-surface">
29
- <NuxtImg :src="props.image" :alt="props.title" height="225" width="400" format="avif" quality="35" :sizes="{ 480: '480px', 1280: '400px' }" loading="lazy" decoding="async" class="size-full object-cover transition-[scale,filter] duration-700 ease-out-expo group-hover/post:scale-[1.06] group-hover/post:saturate-125" />
30
-
31
- <!-- Shade that deepens on hover -->
32
- <div aria-hidden="true" class="absolute inset-0 bg-linear-to-t from-black/45 via-black/0 to-black/0 opacity-60 transition-opacity duration-500 group-hover/post:opacity-100" />
33
-
34
- <!-- Category over the image -->
35
- <Badge v-if="props.category" :text="props.category" class="absolute top-3 left-3 border-white/25 bg-black/35 text-white backdrop-blur-md" />
27
+ <div v-if="props.image" class="aspect-video w-full overflow-hidden border-b border-border bg-surface">
28
+ <NuxtImg :src="props.image" :alt="props.title" height="225" width="400" format="avif" quality="35" :sizes="{ 480: '480px', 1280: '400px' }" loading="lazy" decoding="async" class="size-full object-cover transition-[scale] duration-700 ease-out-expo group-hover/post:scale-[1.03]" />
36
29
  </div>
37
30
 
38
31
  <!-- Content -->
39
- <div class="flex grow flex-col gap-3 p-5 sm:p-6">
40
- <!-- Category (when there is no image to sit on) -->
41
- <Badge v-if="props.category && !props.image" :text="props.category" />
32
+ <div class="flex grow flex-col gap-2 px-5 py-4">
33
+ <!-- Category -->
34
+ <span v-if="props.category" class="text-[11px] font-semibold uppercase tracking-wider text-primary">{{ props.category }}</span>
42
35
 
43
36
  <!-- Post title -->
44
- <h2 class="line-clamp-2 text-base leading-snug font-bold text-foreground transition-colors duration-200 group-hover/post:text-primary">
37
+ <h2 class="line-clamp-2 text-sm leading-snug font-semibold text-foreground transition-colors duration-200 group-hover/post:text-primary md:text-base">
45
38
  {{ props.title }}
46
39
  </h2>
47
40
 
@@ -49,12 +42,12 @@ const props = withDefaults(defineProps<{
49
42
  <p class="line-clamp-3 grow text-xs leading-relaxed text-muted-foreground md:text-sm">
50
43
  {{ props.description }}
51
44
  </p>
45
+ </div>
52
46
 
53
- <!-- Read more -->
54
- <span class="mt-2 inline-flex items-center gap-1.5 text-xs font-semibold text-primary sm:text-sm">
55
- {{ t('uiVintage.blog.readMore') }}
56
- <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/post:translate-x-1" />
57
- </span>
47
+ <!-- Read more -->
48
+ <div class="flex items-center justify-between border-t border-border bg-surface px-5 py-2.5 text-xs text-muted-foreground transition-colors duration-200 group-hover/post:bg-primary/10 group-hover/post:text-primary">
49
+ <span class="font-medium">{{ t('uiVintage.blog.readMore') }}</span>
50
+ <HugeiconsIcon :icon="ArrowRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/post:translate-x-1" />
58
51
  </div>
59
52
  </Card>
60
- </template>
53
+ </template>
@@ -1,7 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref } from 'vue';
3
3
  import { useI18n } from 'vue-i18n';
4
- import { Facebook01Icon, Link01Icon, Linkedin01Icon, NewTwitterIcon, Share08Icon, TelegramIcon, Tick02Icon, WhatsappIcon } from '@hugeicons/core-free-icons';
4
+ import Facebook01Icon from '@hugeicons/core-free-icons/Facebook01Icon';
5
+ import Link01Icon from '@hugeicons/core-free-icons/Link01Icon';
6
+ import Linkedin01Icon from '@hugeicons/core-free-icons/Linkedin01Icon';
7
+ import NewTwitterIcon from '@hugeicons/core-free-icons/NewTwitterIcon';
8
+ import Share08Icon from '@hugeicons/core-free-icons/Share08Icon';
9
+ import TelegramIcon from '@hugeicons/core-free-icons/TelegramIcon';
10
+ import Tick02Icon from '@hugeicons/core-free-icons/Tick02Icon';
11
+ import WhatsappIcon from '@hugeicons/core-free-icons/WhatsappIcon';
5
12
  import { HugeiconsIcon } from '@hugeicons/vue';
6
13
 
7
14
  // Props
@@ -13,7 +20,7 @@ const props = defineProps<{
13
20
  const { t } = useI18n();
14
21
  const copied = ref(false);
15
22
 
16
- // Social platforms config (each one lights up in its brand color on hover)
23
+ // Social platforms config (each icon turns its brand color on hover)
17
24
  const socialPlatforms = computed(() => {
18
25
  const encodedUrl = encodeURIComponent(props.url);
19
26
  const encodedTitle = encodeURIComponent(props.title);
@@ -22,37 +29,37 @@ const socialPlatforms = computed(() => {
22
29
  name: 'facebook',
23
30
  url: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
24
31
  icon: Facebook01Icon,
25
- color: 'hover:border-[#1877F2] hover:bg-[#1877F2]',
32
+ color: 'hover:text-[#1877F2]',
26
33
  label: t('uiVintage.blog.share.facebook')
27
34
  }, {
28
35
  name: 'twitter',
29
36
  url: `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`,
30
37
  icon: NewTwitterIcon,
31
- color: 'hover:border-foreground hover:bg-foreground hover:text-background!',
38
+ color: 'hover:text-foreground',
32
39
  label: t('uiVintage.blog.share.x')
33
40
  }, {
34
41
  name: 'whatsapp',
35
42
  url: `https://api.whatsapp.com/send?text=${encodedTitle}%20${encodedUrl}`,
36
43
  icon: WhatsappIcon,
37
- color: 'hover:border-[#25D366] hover:bg-[#25D366]',
44
+ color: 'hover:text-[#25D366]',
38
45
  label: t('uiVintage.blog.share.whatsapp')
39
46
  }, {
40
47
  name: 'linkedin',
41
48
  url: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
42
49
  icon: Linkedin01Icon,
43
- color: 'hover:border-[#0A66C2] hover:bg-[#0A66C2]',
50
+ color: 'hover:text-[#0A66C2]',
44
51
  label: t('uiVintage.blog.share.linkedin')
45
52
  }, {
46
53
  name: 'telegram',
47
54
  url: `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`,
48
55
  icon: TelegramIcon,
49
- color: 'hover:border-[#229ED9] hover:bg-[#229ED9]',
56
+ color: 'hover:text-[#229ED9]',
50
57
  label: t('uiVintage.blog.share.telegram')
51
58
  }];
52
59
  });
53
60
 
54
61
  // Shared button classes
55
- const buttonClasses = 'flex size-10 cursor-pointer items-center justify-center rounded border border-border bg-secondary text-muted-foreground shadow-elevated-sm outline-none [transition:background-color_150ms,border-color_150ms,color_150ms,scale_300ms_var(--ease-spring)] hover:scale-110 hover:text-white active:scale-95 focus-visible:ring-[3px] focus-visible:ring-ring/45';
62
+ const buttonClasses = 'flex size-9 cursor-pointer items-center justify-center rounded text-muted-foreground outline-none [transition:background-color_150ms,color_150ms,scale_300ms_var(--ease-spring)] hover:bg-accent active:scale-95 focus-visible:ring-[3px] focus-visible:ring-ring/45';
56
63
 
57
64
  // Copy the article link to the clipboard
58
65
  const copyLink = async () => {
@@ -69,9 +76,12 @@ const copyLink = async () => {
69
76
  <Teleport to="body">
70
77
  <!-- Desktop sidebar on the right -->
71
78
  <div class="fixed top-35 right-[max(6.875rem,calc(50%-38.125rem))] z-20 hidden animate-[uv-fade-up_0.7s_var(--ease-out-expo)_0.6s_both] xl:block 2xl:right-[max(0rem,calc(50%-35.125rem))]">
72
- <div class="flex flex-col items-center gap-3 rounded border border-border bg-card p-3 shadow-elevated-sm">
79
+ <div class="flex flex-col items-center gap-0.5 rounded border border-border bg-card p-1.5 shadow-elevated-sm">
73
80
  <!-- Share icon -->
74
- <HugeiconsIcon :icon="Share08Icon" class="mb-1 size-5 text-primary" />
81
+ <span aria-hidden="true" class="flex size-9 items-center justify-center text-primary">
82
+ <HugeiconsIcon :icon="Share08Icon" class="size-4" />
83
+ </span>
84
+ <span aria-hidden="true" class="my-1 h-px w-6 bg-border" />
75
85
 
76
86
  <!-- Social share links (desktop) -->
77
87
  <a v-for="platform in socialPlatforms" :key="platform.name" :href="platform.url" target="_blank" rel="noopener noreferrer" :aria-label="platform.label" :title="platform.label" :class="[buttonClasses, platform.color]">
@@ -79,8 +89,8 @@ const copyLink = async () => {
79
89
  </a>
80
90
 
81
91
  <!-- Copy link -->
82
- <div class="my-1 h-px w-6 bg-border" />
83
- <button type="button" :aria-label="copied ? t('uiVintage.blog.share.copied') : t('uiVintage.blog.share.copy')" :title="t('uiVintage.blog.share.copy')" :class="[buttonClasses, 'hover:border-primary hover:bg-primary hover:text-primary-foreground!', copied && 'border-primary text-primary']" @click="copyLink">
92
+ <span aria-hidden="true" class="my-1 h-px w-6 bg-border" />
93
+ <button type="button" :aria-label="copied ? t('uiVintage.blog.share.copied') : t('uiVintage.blog.share.copy')" :title="t('uiVintage.blog.share.copy')" :class="[buttonClasses, 'hover:text-primary', copied && 'text-primary']" @click="copyLink">
84
94
  <Transition mode="out-in" enter-active-class="transition-[opacity,scale] duration-300 ease-bounce" enter-from-class="opacity-0 scale-50" leave-active-class="transition-opacity duration-100" leave-to-class="opacity-0">
85
95
  <HugeiconsIcon :key="String(copied)" :icon="copied ? Tick02Icon : Link01Icon" class="size-4" />
86
96
  </Transition>
@@ -90,17 +100,24 @@ const copyLink = async () => {
90
100
 
91
101
  <!-- Mobile view (floating bar at bottom) -->
92
102
  <div class="fixed bottom-4 left-1/2 z-20 -translate-x-1/2 animate-[uv-fade-up_0.7s_var(--ease-out-expo)_0.8s_both] xl:hidden">
93
- <div class="flex items-center gap-2 rounded border border-border bg-card p-2 shadow-elevated-sm">
103
+ <div class="flex items-center gap-0.5 rounded border border-border bg-card p-1.5 shadow-elevated-sm">
104
+ <!-- Share icon -->
105
+ <span aria-hidden="true" class="flex size-9 items-center justify-center text-primary">
106
+ <HugeiconsIcon :icon="Share08Icon" class="size-4" />
107
+ </span>
108
+ <span aria-hidden="true" class="mx-1 h-6 w-px bg-border" />
109
+
94
110
  <!-- Social share links (mobile) -->
95
111
  <a v-for="platform in socialPlatforms" :key="platform.name" :href="platform.url" target="_blank" rel="noopener noreferrer" :aria-label="platform.label" :class="[buttonClasses, platform.color]">
96
112
  <HugeiconsIcon :icon="platform.icon" class="size-4" />
97
113
  </a>
98
114
 
99
115
  <!-- Copy link -->
100
- <button type="button" :aria-label="copied ? t('uiVintage.blog.share.copied') : t('uiVintage.blog.share.copy')" :class="[buttonClasses, 'hover:border-primary hover:bg-primary hover:text-primary-foreground!', copied && 'border-primary text-primary']" @click="copyLink">
116
+ <span aria-hidden="true" class="mx-1 h-6 w-px bg-border" />
117
+ <button type="button" :aria-label="copied ? t('uiVintage.blog.share.copied') : t('uiVintage.blog.share.copy')" :class="[buttonClasses, 'hover:text-primary', copied && 'text-primary']" @click="copyLink">
101
118
  <HugeiconsIcon :icon="copied ? Tick02Icon : Link01Icon" class="size-4" />
102
119
  </button>
103
120
  </div>
104
121
  </div>
105
122
  </Teleport>
106
- </template>
123
+ </template>
@@ -1,7 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
3
3
  import { useI18n } from 'vue-i18n';
4
- import { ArrowRight01Icon, Bookmark01Icon, Cancel01Icon } from '@hugeicons/core-free-icons';
4
+ import ArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';
5
+ import Bookmark01Icon from '@hugeicons/core-free-icons/Bookmark01Icon';
6
+ import Cancel01Icon from '@hugeicons/core-free-icons/Cancel01Icon';
5
7
  import { HugeiconsIcon } from '@hugeicons/vue';
6
8
  import { Accordion } from '../ui/accordion';
7
9
  import { Button } from '../ui/button';
@@ -18,6 +20,7 @@ interface TocHeading {
18
20
  id: string;
19
21
  text: string;
20
22
  level: number;
23
+ number: string;
21
24
  }
22
25
 
23
26
  // Props
@@ -68,7 +71,8 @@ const headings = computed(() => {
68
71
  const currentHeading = {
69
72
  id: link.id ?? '',
70
73
  text: link.text ?? '',
71
- level: link.depth ?? 0
74
+ level: link.depth ?? 0,
75
+ number: ''
72
76
  };
73
77
 
74
78
  // Recursively flatten the children links
@@ -77,13 +81,23 @@ const headings = computed(() => {
77
81
  });
78
82
  };
79
83
 
80
- // Return the flattened list of headings
81
- return flattenLinks(tocLinks).filter(heading => heading.id && heading.text && heading.level >= 2 && heading.level <= 3);
84
+ // Keep h2 and h3 headings and number the main sections
85
+ let sectionNumber = 0;
86
+ return flattenLinks(tocLinks)
87
+ .filter(heading => heading.id && heading.text && heading.level >= 2 && heading.level <= 3)
88
+ .map(heading => ({
89
+ ...heading,
90
+ number: heading.level === 2 ? String(++sectionNumber).padStart(2, '0') : ''
91
+ }));
82
92
  });
83
93
 
94
+ // Number of main sections shown in the header
95
+ const sectionCount = computed(() => headings.value.filter(heading => heading.level === 2).length);
96
+
84
97
  // Title and description for the table of contents
85
98
  const tocTitle = computed(() => t('uiVintage.blog.tableOfContents'));
86
99
  const tocDescription = computed(() => t('uiVintage.blog.tableOfContentsDescription'));
100
+ const tocSections = computed(() => t('uiVintage.blog.tableOfContentsSections', { count: sectionCount.value }, sectionCount.value));
87
101
 
88
102
  // Prefer reduced motion for heading scroll
89
103
  const prefersReducedMotion = () => {
@@ -265,11 +279,14 @@ const scrollToHeading = (id: string, options: { closeMobile?: boolean } = {}) =>
265
279
  history.replaceState(null, '', `#${id}`);
266
280
  }
267
281
  };
268
- restoreMobileTriggerFocus = false;
269
-
270
282
  if (options.closeMobile && isMobileOpen.value) {
283
+ // The heading takes focus after the scroll, so skip the trigger focus restore for this close only
284
+ restoreMobileTriggerFocus = false;
271
285
  mobileTriggerRef.value?.focus();
272
286
  isMobileOpen.value = false;
287
+ nextTick(() => {
288
+ restoreMobileTriggerFocus = true;
289
+ });
273
290
  window.clearTimeout(mobileScrollTimeout);
274
291
  mobileScrollTimeout = window.setTimeout(runScroll, MOBILE_CLOSE_SCROLL_DELAY_MS);
275
292
  return;
@@ -286,21 +303,19 @@ const openMobileToc = () => {
286
303
  // Get classes for heading button
287
304
  const getHeadingButtonClasses = (level: number, isActive: boolean) => {
288
305
  // Base classes for all titles
289
- const baseClasses = 'relative block w-full cursor-pointer rounded px-2.5 py-2 text-left outline-none transition-[background-color,color,padding] duration-200 hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/45 md:px-3 before:absolute before:inset-y-1.5 before:left-0 before:w-[3px] before:origin-center before:scale-y-0 before:rounded-full before:bg-primary before:transition-transform before:duration-300 before:ease-spring';
290
-
291
- // Add classes based on heading level
292
- let levelClasses = '';
293
- if (level === 2) {
294
- levelClasses = 'text-foreground font-semibold text-xs md:text-sm';
295
- } else {
296
- levelClasses = 'text-muted-foreground pl-6! text-xs md:text-sm';
297
- }
306
+ const baseClasses = 'flex w-full cursor-pointer gap-3 rounded-r py-1.5 pr-2 text-left text-xs outline-none transition-colors duration-200 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/45 md:text-sm';
298
307
 
299
- // Add classes if the heading is active
300
- const activeClasses = isActive ? 'bg-accent text-foreground! before:scale-y-100' : '';
308
+ // Main sections carry a number, subsections align with their text
309
+ const levelClasses = level === 2 ? 'pl-4 font-medium' : 'pl-12 md:text-xs';
310
+
311
+ // Add classes based on the active state
312
+ let stateClasses = level === 2 ? 'text-foreground/80' : 'text-muted-foreground';
313
+ if (isActive) {
314
+ stateClasses = 'font-semibold text-foreground';
315
+ }
301
316
 
302
317
  // Return combined classes
303
- return [baseClasses, levelClasses, activeClasses];
318
+ return [baseClasses, levelClasses, stateClasses];
304
319
  };
305
320
 
306
321
  // Watch for changes in the headings and update the visible heading IDs and active heading ID accordingly
@@ -343,10 +358,22 @@ onUnmounted(() => {
343
358
  <!-- In-flow accordion; replaced by the mobile control after it scrolls away -->
344
359
  <div ref="inlineRef" class="mb-6" :aria-hidden="isDocked" :inert="isDocked">
345
360
  <Accordion :title="tocTitle" :icon="Bookmark01Icon">
346
- <nav :aria-label="tocTitle" class="space-y-1">
347
- <button v-for="heading in headings" :key="`inline-${heading.id}`" type="button" :class="getHeadingButtonClasses(heading.level, heading.id === activeHeadingId)" :aria-current="heading.id === activeHeadingId ? 'location' : undefined" @click="scrollToHeading(heading.id)">
348
- {{ heading.text }}
349
- </button>
361
+ <!-- Section count -->
362
+ <template #trailing>
363
+ <span class="shrink-0 text-xs text-muted-foreground">{{ tocSections }}</span>
364
+ </template>
365
+
366
+ <nav :aria-label="tocTitle">
367
+ <ol>
368
+ <li v-for="heading in headings" :key="`inline-${heading.id}`" class="relative border-l border-border">
369
+ <!-- Active marker on the track -->
370
+ <span aria-hidden="true" :class="['absolute inset-y-1 -left-px w-0.5 origin-center bg-primary transition-transform duration-300 ease-spring', heading.id === activeHeadingId ? 'scale-y-100' : 'scale-y-0']" />
371
+ <button type="button" :class="getHeadingButtonClasses(heading.level, heading.id === activeHeadingId)" :aria-current="heading.id === activeHeadingId ? 'location' : undefined" @click="scrollToHeading(heading.id)">
372
+ <span v-if="heading.number" :class="['w-5 shrink-0 text-[11px] leading-5 tabular-nums transition-colors', heading.id === activeHeadingId ? 'text-primary' : 'text-muted-foreground']">{{ heading.number }}</span>
373
+ <span>{{ heading.text }}</span>
374
+ </button>
375
+ </li>
376
+ </ol>
350
377
  </nav>
351
378
  </Accordion>
352
379
  </div>
@@ -366,8 +393,8 @@ onUnmounted(() => {
366
393
  <!-- Mobile table of contents header -->
367
394
  <div class="flex min-w-0 items-center gap-3">
368
395
  <!-- Mobile table of contents icon -->
369
- <div class="flex size-10 shrink-0 items-center justify-center rounded border border-primary/30 bg-primary/10">
370
- <HugeiconsIcon :icon="Bookmark01Icon" class="size-4 text-primary" />
396
+ <div class="flex size-7 shrink-0 items-center justify-center rounded border border-primary/30 text-primary">
397
+ <HugeiconsIcon :icon="Bookmark01Icon" class="size-3.5" />
371
398
  </div>
372
399
 
373
400
  <!-- Mobile table of contents title and description -->
@@ -378,6 +405,9 @@ onUnmounted(() => {
378
405
  <SheetDescription class="sr-only">
379
406
  {{ tocDescription }}
380
407
  </SheetDescription>
408
+ <p aria-hidden="true" class="text-xs text-muted-foreground">
409
+ {{ tocSections }}
410
+ </p>
381
411
  </div>
382
412
  </div>
383
413
 
@@ -391,11 +421,16 @@ onUnmounted(() => {
391
421
 
392
422
  <!-- Mobile table of contents navigation -->
393
423
  <nav :aria-label="tocTitle" class="flex-1 overflow-y-auto p-5">
394
- <div class="space-y-1">
395
- <button v-for="heading in headings" :key="`mobile-${heading.id}`" type="button" :class="getHeadingButtonClasses(heading.level, heading.id === activeHeadingId)" :aria-current="heading.id === activeHeadingId ? 'location' : undefined" @click="scrollToHeading(heading.id, { closeMobile: true })">
396
- {{ heading.text }}
397
- </button>
398
- </div>
424
+ <ol>
425
+ <li v-for="heading in headings" :key="`mobile-${heading.id}`" class="relative border-l border-border">
426
+ <!-- Active marker on the track -->
427
+ <span aria-hidden="true" :class="['absolute inset-y-1 -left-px w-0.5 bg-primary', heading.id === activeHeadingId ? 'opacity-100' : 'opacity-0']" />
428
+ <button type="button" :class="getHeadingButtonClasses(heading.level, heading.id === activeHeadingId)" :aria-current="heading.id === activeHeadingId ? 'location' : undefined" @click="scrollToHeading(heading.id, { closeMobile: true })">
429
+ <span v-if="heading.number" :class="['w-5 shrink-0 text-[11px] leading-5 tabular-nums', heading.id === activeHeadingId ? 'text-primary' : 'text-muted-foreground']">{{ heading.number }}</span>
430
+ <span>{{ heading.text }}</span>
431
+ </button>
432
+ </li>
433
+ </ol>
399
434
  </nav>
400
435
  </SheetContent>
401
436
  </Sheet>
@@ -2,6 +2,7 @@ export { default as AllPostsSection } from './AllPostsSection.vue';
2
2
  export { default as BlogContentRenderer } from './BlogContentRenderer.vue';
3
3
  export { default as BlogFAQSection } from './BlogFAQSection.vue';
4
4
  export { default as BlogInfoSection } from './BlogInfoSection.vue';
5
+ export { default as BlogPostHeader } from './BlogPostHeader.vue';
5
6
  export { default as BlogHeaderSection } from './BlogHeaderSection.vue';
6
7
  export { default as BlogSectionTitle } from './BlogSectionTitle.vue';
7
8
  export { default as BlogTagsSection } from './BlogTagsSection.vue';
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts" generic="T extends { id: PropertyKey }">
2
- import { PackageIcon } from '@hugeicons/core-free-icons';
2
+ import PackageIcon from '@hugeicons/core-free-icons/PackageIcon';
3
3
  import { EmptyStateCard } from '../empty-state-card';
4
4
  import { LoadMoreButton } from '../load-more-button';
5
5
  import { LoadingStateCard } from '../loading-state-card';
@@ -52,8 +52,8 @@ const handleLoadMore = () => {
52
52
  <slot name="card" v-for="item in props.items" :item="item" :key="item.id" />
53
53
  </div>
54
54
 
55
- <!-- Load more button -->
56
- <div v-if="props.hasMore || (props.loadMoreBusy && props.items.length > 0)" class="flex justify-center mt-6">
55
+ <!-- Load more button (only under a grid, never under the loading or empty state) -->
56
+ <div v-if="!props.busy && props.items.length > 0 && (props.hasMore || props.loadMoreBusy)" class="flex justify-center mt-6">
57
57
  <LoadMoreButton :busy="props.loadMoreBusy"
58
58
  @load-more="handleLoadMore" />
59
59
  </div>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { Cancel01Icon } from '@hugeicons/core-free-icons';
2
+ import Cancel01Icon from '@hugeicons/core-free-icons/Cancel01Icon';
3
3
  import { HugeiconsIcon } from '@hugeicons/vue';
4
4
  import { computed } from 'vue';
5
5
  import type { HTMLAttributes } from 'vue';
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { HugeiconsIcon } from '@hugeicons/vue';
3
3
  import { computed, ref } from 'vue';
4
- import { InformationCircleIcon } from '@hugeicons/core-free-icons';
4
+ import InformationCircleIcon from '@hugeicons/core-free-icons/InformationCircleIcon';
5
5
  import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '../ui/alert-dialog';
6
6
  import { Button } from '../ui/button';
7
7
  import { cancelActiveConfirmDialog, closeConfirmDialog, confirmActiveDialog, confirmDialogState } from './confirm-dialog-state';
@@ -11,6 +11,11 @@ const pendingAction = ref<'confirm' | 'cancel' | null>(null);
11
11
 
12
12
  // Run an action while showing a spinner on the button that triggered it
13
13
  const runAction = async (action: 'confirm' | 'cancel') => {
14
+ // Ignore clicks while another action is still running
15
+ if (pendingAction.value) {
16
+ return;
17
+ }
18
+
14
19
  pendingAction.value = action;
15
20
  try {
16
21
  await (action === 'confirm' ? confirmActiveDialog() : cancelActiveConfirmDialog());