@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
package/module.mjs CHANGED
@@ -28,24 +28,18 @@ const loadNuxtKit = async () => {
28
28
  throw new Error('Unable to resolve @nuxt/kit for @brumaombra/ui-vintage. Install the package dependencies or add Nuxt to the consuming app.');
29
29
  };
30
30
 
31
- const { addPlugin, createResolver, defineNuxtModule, installModule } = await loadNuxtKit();
31
+ const { addPlugin, createResolver, defineNuxtModule, hasNuxtModule, installModule } = await loadNuxtKit();
32
+ const { version } = moduleRequire('./package.json');
32
33
  const resolver = createResolver(import.meta.url);
33
34
  const sourceRoot = resolver.resolve('src');
34
35
  const stylesPath = resolver.resolve('src/styles.css');
35
36
 
36
- // Check whether the consuming app already has a specific module installed
37
- const hasModuleInstalled = (modules, moduleName) => {
38
- return modules.some((entry) => {
39
- if (typeof entry === 'string') return entry === moduleName;
40
- if (Array.isArray(entry)) return entry[0] === moduleName;
41
- return false;
42
- });
43
- };
44
-
45
37
  export default defineNuxtModule({
46
38
  // Module meta information
47
39
  meta: {
48
40
  name: '@brumaombra/ui-vintage',
41
+ configKey: 'uiVintage',
42
+ version,
49
43
  compatibility: {
50
44
  nuxt: '>=4.0.0'
51
45
  }
@@ -63,7 +57,7 @@ export default defineNuxtModule({
63
57
  nuxt.options.build.transpile.push(new RegExp(`^${cleanSourceRoot}`));
64
58
 
65
59
  // Ensure Nuxt Image is available for components that render NuxtImg
66
- if (!hasModuleInstalled(nuxt.options.modules, '@nuxt/image')) {
60
+ if (!hasNuxtModule('@nuxt/image', nuxt)) {
67
61
  await installModule('@nuxt/image');
68
62
  }
69
63
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brumaombra/ui-vintage",
3
- "version": "0.8.2",
3
+ "version": "0.9.1",
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",
@@ -30,12 +30,14 @@
30
30
  "src",
31
31
  "module.mjs",
32
32
  "COMPONENTS.md",
33
- "skills"
33
+ "AGENTS.md"
34
34
  ],
35
35
  "exports": {
36
36
  ".": {
37
- "import": "./module.mjs"
37
+ "import": "./module.mjs",
38
+ "default": "./module.mjs"
38
39
  },
40
+ "./package.json": "./package.json",
39
41
  "./style.css": "./src/styles.css",
40
42
  "./accordion": "./src/components/ui/accordion/index.ts",
41
43
  "./alert": "./src/components/ui/alert/index.ts",
@@ -107,6 +109,7 @@
107
109
  "./slider": "./src/components/ui/slider/index.ts",
108
110
  "./slider-form-component": "./src/components/slider-form-component/index.ts",
109
111
  "./spinner": "./src/components/ui/spinner/index.ts",
112
+ "./stat-strip": "./src/components/stat-strip/index.ts",
110
113
  "./stepper": "./src/components/ui/stepper/index.ts",
111
114
  "./switch": "./src/components/ui/switch/index.ts",
112
115
  "./switch-form-component": "./src/components/switch-form-component/index.ts",
@@ -127,32 +130,47 @@
127
130
  "scripts": {
128
131
  "typecheck": "vue-tsc --noEmit",
129
132
  "docs:components": "node scripts/generate-components-doc.mjs",
130
- "docs:components:check": "node scripts/generate-components-doc.mjs --check"
133
+ "docs:components:check": "node scripts/generate-components-doc.mjs --check",
134
+ "test": "npm --prefix demo-app run test"
131
135
  },
132
136
  "peerDependencies": {
137
+ "@hugeicons/core-free-icons": "^4.3.0",
138
+ "@hugeicons/vue": "^1.0.0",
139
+ "@nuxt/content": "^3.0.0",
133
140
  "@nuxt/image": "^2.0.0",
134
141
  "nuxt": "^4.0.0",
142
+ "tailwindcss": "^4.0.0",
135
143
  "vue": "^3.5.0",
136
144
  "vue-i18n": "^9.0.0 || ^10.0.0 || ^11.0.0"
137
145
  },
146
+ "peerDependenciesMeta": {
147
+ "@nuxt/content": {
148
+ "optional": true
149
+ }
150
+ },
138
151
  "dependencies": {
139
- "@hugeicons/core-free-icons": "4.1.1",
140
- "@hugeicons/vue": "1.0.5",
141
152
  "@internationalized/date": "3.10.0",
142
- "@nuxt/kit": "4.4.4",
153
+ "@nuxt/kit": "4.5.2",
143
154
  "@vueuse/core": "14.2.1",
144
155
  "class-variance-authority": "0.7.1",
145
156
  "clsx": "2.1.1",
146
157
  "reka-ui": "2.9.2",
147
158
  "shiki": "4.4.3",
148
- "tailwind-merge": "3.5.0",
149
- "tailwindcss": "4.2.4"
159
+ "tailwind-merge": "3.5.0"
150
160
  },
151
161
  "devDependencies": {
152
- "@types/node": "24.0.0",
153
- "nuxt": "4.4.4",
162
+ "@hugeicons/core-free-icons": "4.3.5",
163
+ "@hugeicons/vue": "1.0.8",
164
+ "nuxt": "4.5.2",
165
+ "tailwindcss": "4.2.4",
154
166
  "typescript": "5.9.3",
155
- "vue": "3.5.13",
167
+ "vue": "3.5.42",
168
+ "vue-i18n": "11.4.13",
156
169
  "vue-tsc": "2.2.10"
170
+ },
171
+ "overrides": {
172
+ "archiver-utils": {
173
+ "glob": "13.0.6"
174
+ }
157
175
  }
158
176
  }
@@ -187,7 +187,7 @@ const props = defineProps<{
187
187
  .blog-content :deep(img:not(.not-prose):not(.not-prose *)) {
188
188
  border: 1px solid var(--border);
189
189
  border-radius: var(--radius-sm);
190
- box-shadow: var(--uv-shadow-md);
190
+ box-shadow: var(--uv-shadow-sm);
191
191
  }
192
192
 
193
193
  /* Plain markdown tables */
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue';
3
3
  import { useI18n } from 'vue-i18n';
4
- import { HelpCircleIcon } from '@hugeicons/core-free-icons';
4
+ import HelpCircleIcon from '@hugeicons/core-free-icons/HelpCircleIcon';
5
5
  import { Accordion } from '../ui/accordion';
6
6
  import BlogSectionTitle from './BlogSectionTitle.vue';
7
7
 
@@ -1,7 +1,13 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue';
3
+ import { useI18n } from 'vue-i18n';
4
+ import ArrowRight01Icon from '@hugeicons/core-free-icons/ArrowRight01Icon';
5
+ import { HugeiconsIcon } from '@hugeicons/vue';
6
+ import type { HugeiconsIconDefinition } from '../../lib/common-types';
3
7
  import type { ToneColor } from '../../lib/color-tokens';
8
+ import { cn } from '../../lib/utils';
4
9
  import { Badge } from '../ui/badge';
10
+ import { StatStrip } from '../stat-strip';
5
11
 
6
12
  // Props
7
13
  const props = withDefaults(defineProps<{
@@ -9,48 +15,116 @@ const props = withDefaults(defineProps<{
9
15
  color: ToneColor;
10
16
  text: string;
11
17
  }>;
18
+ announcement?: {
19
+ text: string;
20
+ to: string;
21
+ label?: string;
22
+ } | null;
12
23
  title: string;
24
+ highlight?: string;
13
25
  description: string;
26
+ stats?: Array<{
27
+ label: string;
28
+ value: string | number;
29
+ icon?: HugeiconsIconDefinition;
30
+ }>;
31
+ align?: 'left' | 'center' | 'right';
14
32
  }>(), {
15
- badges: () => []
33
+ badges: () => [],
34
+ announcement: null,
35
+ highlight: '',
36
+ stats: () => [],
37
+ align: 'center'
16
38
  });
17
39
 
18
- // Split the title so each word can rise into place on its own
19
- const titleWords = computed(() => props.title.split(/\s+/).filter(Boolean));
40
+ const { t } = useI18n();
41
+
42
+ // Alignment of the whole header: the column, its text, and the rows of badges and stats
43
+ const alignClasses = {
44
+ left: { header: 'items-start text-left', row: 'justify-start' },
45
+ center: { header: 'items-center text-center', row: 'justify-center' },
46
+ right: { header: 'items-end text-right', row: 'justify-end' }
47
+ } as const;
48
+ const alignClass = computed(() => alignClasses[props.align] ?? alignClasses.center);
49
+
50
+ // Split the title around the highlighted part (when it appears in the title)
51
+ const titleParts = computed(() => {
52
+ const index = props.highlight ? props.title.indexOf(props.highlight) : -1;
53
+ if (index === -1) return { before: props.title, highlight: '', after: '' };
54
+ return {
55
+ before: props.title.slice(0, index),
56
+ highlight: props.highlight,
57
+ after: props.title.slice(index + props.highlight.length)
58
+ };
59
+ });
20
60
 
21
- // The description follows the last word of the title
22
- const descriptionDelay = computed(() => `${Math.min(titleWords.value.length, 12) * 55 + 200}ms`);
61
+ // Words that rise in one by one (the highlight rises in as a single block between them)
62
+ const splitWords = (text: string) => text.split(/\s+/).filter(Boolean);
63
+ const beforeWords = computed(() => splitWords(titleParts.value.before));
64
+ const afterWords = computed(() => splitWords(titleParts.value.after));
65
+ // Space after a word before the highlight: always between words, and before the highlight only when the title has one there
66
+ const wordGap = (index: number) => {
67
+ if (index < beforeWords.value.length - 1) return ' ';
68
+ return titleParts.value.highlight && /\s$/.test(titleParts.value.before) ? ' ' : '';
69
+ };
70
+ const wordDelay = (index: number) => `${120 + Math.min(index, 12) * 55}ms`;
71
+ const highlightDelay = computed(() => wordDelay(beforeWords.value.length));
72
+ const afterDelay = (index: number) => wordDelay(beforeWords.value.length + 1 + index);
73
+
74
+ // The rest of the header follows the last word of the title
75
+ const titleLength = computed(() => beforeWords.value.length + afterWords.value.length + (titleParts.value.highlight ? 1 : 0));
76
+ const descriptionDelay = computed(() => `${Math.min(titleLength.value, 12) * 55 + 200}ms`);
77
+ const statsDelay = computed(() => `${Math.min(titleLength.value, 12) * 55 + 300}ms`);
78
+ const underlineDelay = computed(() => `${beforeWords.value.length * 55 + 600}ms`);
23
79
  </script>
24
80
 
25
81
  <template>
26
- <header class="relative isolate flex flex-col gap-6 pt-4 pb-2 md:gap-8 md:pt-10">
27
- <!-- Ambient glow -->
28
- <div aria-hidden="true" class="pointer-events-none absolute -top-24 -left-24 -z-10 h-80 w-[36rem] max-w-[120%] rounded-full bg-primary/12 blur-[100px]" />
82
+ <header :class="cn('relative flex flex-col gap-6 pt-6 md:pt-12', alignClass.header)">
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">
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
+ <span aria-hidden="true" class="relative flex size-1.5 shrink-0 text-warning">
87
+ <span class="absolute inline-flex size-full animate-uv-ping-soft rounded-full bg-current opacity-60" />
88
+ <span class="relative inline-flex size-1.5 rounded-full bg-current" />
89
+ </span>
90
+ <span class="shrink-0 font-semibold">{{ props.announcement.label || t('uiVintage.blog.new') }}</span>
91
+ <span class="truncate opacity-80">{{ props.announcement.text }}</span>
92
+ <HugeiconsIcon :icon="ArrowRight01Icon" class="size-3.5 shrink-0 transition-transform duration-300 ease-spring group-hover/announce:translate-x-0.5" />
93
+ </span>
94
+ </NuxtLink>
29
95
 
30
96
  <!-- Badges -->
31
- <div v-if="props.badges.length > 0" class="flex flex-wrap gap-3">
97
+ <div v-if="props.badges.length > 0" :class="cn('flex flex-wrap gap-2', alignClass.row)">
32
98
  <Badge v-for="(badge, index) in props.badges"
33
99
  :key="`${badge.color}-${badge.text}`"
34
100
  :color="badge.color"
35
101
  :text="badge.text"
36
- :pulse="index === 0"
102
+ :pulse="index === 0 && !props.announcement"
37
103
  class="animate-uv-fade-up"
38
104
  :style="{ animationDelay: `${index * 70}ms` }" />
39
105
  </div>
40
106
 
41
- <!-- Title (each word rises in with a spring) -->
42
- <h1 class="text-3xl leading-[1.05] font-bold tracking-tight text-foreground md:text-6xl">
43
- <template v-for="(word, index) in titleWords" :key="`${word}-${index}`">
44
- <span class="inline-block animate-uv-word-in" :style="{ animationDelay: `${120 + Math.min(index, 12) * 55}ms` }">{{ word }}</span>{{ index < titleWords.length - 1 ? ' ' : '' }}
107
+ <!-- Title (each word rises in with a spring, the highlight gets a hand-drawn underline) -->
108
+ <h1 class="max-w-4xl text-4xl leading-[1.05] font-semibold tracking-tight text-foreground sm:text-5xl md:text-6xl">
109
+ <template v-for="(word, index) in beforeWords" :key="`before-${word}-${index}`">
110
+ <span class="inline-block animate-uv-word-in" :style="{ animationDelay: wordDelay(index) }">{{ word }}</span>{{ wordGap(index) }}
111
+ </template>
112
+ <!-- The text and the underline sit on one line so no stray space ends up inside the highlight -->
113
+ <span v-if="titleParts.highlight" class="relative inline-block animate-uv-word-in text-primary" :style="{ animationDelay: highlightDelay }">{{ titleParts.highlight }}<svg class="absolute -bottom-2 left-0 h-3 w-full text-primary/60" viewBox="0 0 200 12" fill="none" preserveAspectRatio="none" aria-hidden="true">
114
+ <path d="M2 9C40 3 80 2 198 7" stroke="currentColor" stroke-width="4" stroke-linecap="round" pathLength="1" class="animate-uv-draw [stroke-dasharray:1]" :style="{ animationDelay: underlineDelay }" />
115
+ </svg>
116
+ </span>
117
+ <template v-for="(word, index) in afterWords" :key="`after-${word}-${index}`">
118
+ {{ index === 0 && !/^\s/.test(titleParts.after) ? '' : ' ' }}<span class="inline-block animate-uv-word-in" :style="{ animationDelay: afterDelay(index) }">{{ word }}</span>
45
119
  </template>
46
120
  </h1>
47
121
 
48
- <!-- Accent rule -->
49
- <div aria-hidden="true" class="h-1 w-20 origin-left animate-uv-grow-x rounded-full bg-primary shadow-glow" :style="{ animationDelay: descriptionDelay }" />
50
-
51
122
  <!-- Description -->
52
- <p class="max-w-3xl animate-uv-fade-up text-sm leading-relaxed text-muted-foreground md:text-xl" :style="{ animationDelay: descriptionDelay }">
123
+ <p class="max-w-2xl animate-uv-fade-up text-sm leading-7 text-muted-foreground sm:text-base" :style="{ animationDelay: descriptionDelay }">
53
124
  {{ props.description }}
54
125
  </p>
126
+
127
+ <!-- Stats strip -->
128
+ <StatStrip :items="props.stats" :class="cn('animate-uv-fade-up', alignClass.row)" :style="{ animationDelay: statsDelay }" />
55
129
  </header>
56
- </template>
130
+ </template>
@@ -2,9 +2,11 @@
2
2
  import { NuxtImg } from '#components';
3
3
  import { computed } from 'vue';
4
4
  import { useI18n } from 'vue-i18n';
5
- import { ArrowUpRight01Icon, Calendar03Icon, RefreshIcon } from '@hugeicons/core-free-icons';
5
+ import ArrowUpRight01Icon from '@hugeicons/core-free-icons/ArrowUpRight01Icon';
6
+ import Calendar03Icon from '@hugeicons/core-free-icons/Calendar03Icon';
7
+ import RefreshIcon from '@hugeicons/core-free-icons/RefreshIcon';
6
8
  import { HugeiconsIcon } from '@hugeicons/vue';
7
- import { Card, CardContent } from '../ui/card';
9
+ import { Card } from '../ui/card';
8
10
  import { formatDateLongItalyTimezone } from '../../lib/utils';
9
11
 
10
12
  // Props
@@ -12,9 +14,11 @@ const props = withDefaults(defineProps<{
12
14
  author: string;
13
15
  authorUrl: string;
14
16
  authorImageUrl: string;
17
+ authorBio?: string;
15
18
  datePublished: string;
16
19
  dateModified?: string | null;
17
20
  }>(), {
21
+ authorBio: '',
18
22
  dateModified: null
19
23
  });
20
24
 
@@ -30,44 +34,45 @@ const showModifiedDate = computed(() => Boolean(props.dateModified) && formatted
30
34
 
31
35
  <template>
32
36
  <!-- Author and date information -->
33
- <Card data-aos="blur-up" class="group/author relative overflow-hidden">
34
- <!-- Accent glow -->
35
- <div aria-hidden="true" class="pointer-events-none absolute -top-16 -left-16 size-48 rounded-full bg-primary/10 blur-3xl" />
37
+ <Card data-aos="blur-up" class="gap-0! overflow-hidden p-0!">
38
+ <!-- Author -->
39
+ <div :class="['flex gap-3 px-5 py-4', props.authorBio ? 'items-start' : 'items-center']">
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">
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>
36
44
 
37
- <CardContent class="relative flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
38
- <!-- Author -->
39
- <div class="flex items-center gap-4">
40
- <!-- Author image -->
41
- <NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="relative shrink-0 rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/45">
42
- <NuxtImg :src="props.authorImageUrl" :alt="props.author" height="56" width="56" format="avif" quality="40" :sizes="{ 480: '56px', 1280: '56px' }" loading="lazy" decoding="async" class="size-14 rounded border border-border object-cover shadow-elevated-sm transition-[scale] duration-300 ease-spring hover:scale-105" />
45
+ <!-- Author name and bio -->
46
+ <div class="min-w-0 flex-1">
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">
49
+ {{ props.author }}
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" />
43
51
  </NuxtLink>
44
-
45
- <!-- Author name -->
46
- <div class="min-w-0">
47
- <p class="text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase">{{ t('uiVintage.blog.author') }}</p>
48
- <NuxtLink :to="props.authorUrl" target="_blank" rel="noopener noreferrer" class="group/link mt-0.5 inline-flex items-center gap-1 text-base font-bold text-foreground outline-none hover:text-primary focus-visible:underline">
49
- {{ props.author }}
50
- <HugeiconsIcon :icon="ArrowUpRight01Icon" class="size-4 transition-[translate] duration-300 ease-spring group-hover/link:translate-x-0.5 group-hover/link:-translate-y-0.5" />
51
- </NuxtLink>
52
- </div>
52
+ <p v-if="props.authorBio" class="mt-1.5 text-xs leading-relaxed text-muted-foreground md:text-sm">
53
+ {{ props.authorBio }}
54
+ </p>
53
55
  </div>
56
+ </div>
54
57
 
55
- <!-- Date information -->
56
- <div class="flex flex-wrap gap-2 sm:justify-end">
57
- <!-- Published date -->
58
- <div v-if="props.datePublished" class="flex items-center gap-2 rounded border border-border bg-surface px-3 py-2 text-xs text-muted-foreground">
59
- <HugeiconsIcon :icon="Calendar03Icon" class="size-4 shrink-0 text-primary" />
60
- <span class="font-semibold">{{ t('uiVintage.blog.publishedDate') }}</span>
61
- <time :datetime="props.datePublished" class="text-foreground">{{ formattedPublishedDate }}</time>
62
- </div>
58
+ <!-- Date information -->
59
+ <div v-if="props.datePublished || showModifiedDate" class="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-border bg-surface px-5 py-2.5 text-xs text-muted-foreground">
60
+ <!-- Published date -->
61
+ <span v-if="props.datePublished" class="inline-flex items-center gap-2">
62
+ <HugeiconsIcon :icon="Calendar03Icon" class="size-3.5 shrink-0 text-primary" />
63
+ {{ t('uiVintage.blog.publishedDate') }}
64
+ <time :datetime="props.datePublished" class="text-foreground">{{ formattedPublishedDate }}</time>
65
+ </span>
63
66
 
64
- <!-- Last updated date -->
65
- <div v-if="showModifiedDate" class="flex items-center gap-2 rounded border border-border bg-surface px-3 py-2 text-xs text-muted-foreground">
66
- <HugeiconsIcon :icon="RefreshIcon" class="size-4 shrink-0 text-primary" />
67
- <span class="font-semibold">{{ t('uiVintage.blog.lastUpdatedDate') }}</span>
68
- <time :datetime="props.dateModified ?? undefined" class="text-foreground">{{ formattedModifiedDate }}</time>
69
- </div>
70
- </div>
71
- </CardContent>
67
+ <!-- Divider between the dates (hidden when they wrap on small screens) -->
68
+ <span v-if="props.datePublished && showModifiedDate" aria-hidden="true" class="hidden h-3.5 w-px bg-border sm:block" />
69
+
70
+ <!-- Last updated date -->
71
+ <span v-if="showModifiedDate" class="inline-flex items-center gap-2">
72
+ <HugeiconsIcon :icon="RefreshIcon" class="size-3.5 shrink-0 text-primary" />
73
+ {{ t('uiVintage.blog.lastUpdatedDate') }}
74
+ <time :datetime="props.dateModified ?? undefined" class="text-foreground">{{ formattedModifiedDate }}</time>
75
+ </span>
76
+ </div>
72
77
  </Card>
73
- </template>
78
+ </template>
@@ -0,0 +1,181 @@
1
+ <script setup lang="ts">
2
+ import { NuxtImg } from '#components';
3
+ import { computed } from 'vue';
4
+ import { useI18n } from 'vue-i18n';
5
+ import { Badge } from '../ui/badge';
6
+ import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '../ui/breadcrumb';
7
+ import { formatDateLongItalyTimezone, getReadingMinutes } from '../../lib/utils';
8
+
9
+ // Props
10
+ const props = withDefaults(defineProps<{
11
+ title: string;
12
+ description?: string;
13
+ image?: string;
14
+ author?: string;
15
+ authorImageUrl?: string;
16
+ datePublished?: string;
17
+ categoryText?: string;
18
+ categorySlug?: string;
19
+ tags?: Array<{
20
+ name: string;
21
+ slug: string;
22
+ }>;
23
+ body?: unknown;
24
+ readingMinutes?: number | null;
25
+ blogPath?: string;
26
+ blogLabel?: string;
27
+ animated?: boolean;
28
+ }>(), {
29
+ description: '',
30
+ image: '',
31
+ author: '',
32
+ authorImageUrl: '',
33
+ datePublished: '',
34
+ categoryText: '',
35
+ categorySlug: '',
36
+ tags: () => [],
37
+ readingMinutes: null,
38
+ blogPath: '/blog',
39
+ blogLabel: '',
40
+ animated: true
41
+ });
42
+
43
+ const { t, locale } = useI18n();
44
+
45
+ // Split the title so each word can rise into place on its own
46
+ const titleWords = computed(() => props.title.split(/\s+/).filter(Boolean));
47
+
48
+ // Long publication date in the current locale
49
+ const formattedDate = computed(() => formatDateLongItalyTimezone(props.datePublished, locale.value));
50
+
51
+ // Explicit reading time wins, otherwise estimate it from the body
52
+ const resolvedReadingMinutes = computed(() => {
53
+ if (props.readingMinutes) return props.readingMinutes;
54
+ return props.body ? getReadingMinutes(props.body) : null;
55
+ });
56
+
57
+ // Links built from the blog base path
58
+ const categoryPath = computed(() => `${props.blogPath}/categories/${props.categorySlug}`);
59
+ const tagPath = (slug: string) => `${props.blogPath}/tags/${slug}`;
60
+
61
+ // Keep only the entrance classes when animations are enabled
62
+ const enter = (classes: string) => props.animated ? classes : '';
63
+
64
+ // Whether the category link can be built
65
+ const hasCategory = computed(() => Boolean(props.categoryText && props.categorySlug));
66
+
67
+ // Whether the details row has any column to show
68
+ const hasDetails = computed(() => Boolean(props.author || formattedDate.value || resolvedReadingMinutes.value || hasCategory.value));
69
+
70
+ const badgeLinkClasses = 'rounded outline-none transition-opacity duration-150 hover:opacity-80 focus-visible:ring-[3px] focus-visible:ring-ring/45';
71
+
72
+ // Shared classes for the details cells (exposed to the meta slot)
73
+ const detailCellClasses = 'min-w-0 border-t border-l border-border py-4 pl-4 pr-4';
74
+ const detailLabelClasses = 'text-[11px] font-semibold tracking-wider text-muted-foreground uppercase';
75
+ </script>
76
+
77
+ <template>
78
+ <header class="relative isolate mb-10 md:mb-14">
79
+ <!-- Breadcrumbs -->
80
+ <Breadcrumb :class="enter('animate-uv-fade-up')">
81
+ <BreadcrumbList>
82
+ <BreadcrumbItem>
83
+ <BreadcrumbLink as-child>
84
+ <NuxtLinkLocale :to="props.blogPath">
85
+ {{ props.blogLabel || t('uiVintage.blog.breadcrumbBlog') }}
86
+ </NuxtLinkLocale>
87
+ </BreadcrumbLink>
88
+ </BreadcrumbItem>
89
+ <BreadcrumbSeparator />
90
+ <template v-if="hasCategory">
91
+ <BreadcrumbItem>
92
+ <BreadcrumbLink as-child>
93
+ <NuxtLinkLocale :to="categoryPath">
94
+ {{ props.categoryText }}
95
+ </NuxtLinkLocale>
96
+ </BreadcrumbLink>
97
+ </BreadcrumbItem>
98
+ <BreadcrumbSeparator />
99
+ </template>
100
+ <BreadcrumbItem class="min-w-0">
101
+ <BreadcrumbPage class="truncate">
102
+ {{ props.title }}
103
+ </BreadcrumbPage>
104
+ </BreadcrumbItem>
105
+ </BreadcrumbList>
106
+ </Breadcrumb>
107
+
108
+ <!-- Title and lede, closed by a rule with a primary segment -->
109
+ <div class="relative mt-8 border-b border-border pb-8">
110
+ <span aria-hidden="true" :class="['absolute -bottom-px left-0 h-0.5 w-20 origin-left bg-primary', enter('animate-uv-grow-x')]" :style="props.animated ? { animationDelay: '500ms' } : undefined" />
111
+
112
+ <!-- Title (each word rises in) -->
113
+ <h1 class="text-3xl leading-[1.08] font-semibold tracking-tight text-foreground sm:text-4xl md:text-5xl">
114
+ <template v-for="(word, index) in titleWords" :key="`${word}-${index}`">
115
+ <span :class="['inline-block', enter('animate-uv-word-in')]" :style="props.animated ? { animationDelay: `${120 + Math.min(index, 12) * 50}ms` } : undefined">{{ word }}</span>{{ index < titleWords.length - 1 ? ' ' : '' }}
116
+ </template>
117
+ </h1>
118
+
119
+ <!-- Lede -->
120
+ <p v-if="props.description" :class="['mt-5 max-w-3xl text-sm leading-relaxed text-muted-foreground md:text-lg', enter('animate-uv-fade-up [animation-delay:450ms]')]">
121
+ {{ props.description }}
122
+ </p>
123
+ </div>
124
+
125
+ <!-- Labelled details (columns without data are left out; the meta slot adds more) -->
126
+ <div v-if="hasDetails || $slots.meta" :class="['overflow-hidden border-b border-border', enter('animate-uv-fade-up [animation-delay:550ms]')]">
127
+ <dl class="-mt-px -ml-[calc(1rem+1px)] grid grid-cols-2 md:grid-flow-col md:auto-cols-fr md:grid-cols-none">
128
+ <!-- Author -->
129
+ <div v-if="props.author" :class="detailCellClasses">
130
+ <dt :class="detailLabelClasses">{{ t('uiVintage.blog.author') }}</dt>
131
+ <dd class="mt-2 flex min-w-0 items-center gap-2 text-sm font-semibold text-foreground">
132
+ <NuxtImg v-if="props.authorImageUrl" :src="props.authorImageUrl" :alt="props.author" width="24" height="24" format="avif" quality="40" class="size-6 shrink-0 rounded border border-border object-cover" />
133
+ <span class="truncate">{{ props.author }}</span>
134
+ </dd>
135
+ </div>
136
+
137
+ <!-- Publication date -->
138
+ <div v-if="formattedDate" :class="detailCellClasses">
139
+ <dt :class="detailLabelClasses">{{ t('uiVintage.blog.publishedDate') }}</dt>
140
+ <dd class="mt-2 text-sm text-foreground">
141
+ <time :datetime="props.datePublished">{{ formattedDate }}</time>
142
+ </dd>
143
+ </div>
144
+
145
+ <!-- Reading time -->
146
+ <div v-if="resolvedReadingMinutes" :class="detailCellClasses">
147
+ <dt :class="detailLabelClasses">{{ t('uiVintage.blog.readingTime') }}</dt>
148
+ <dd class="mt-2 text-sm text-foreground">{{ t('uiVintage.blog.readingTimeValue', { minutes: resolvedReadingMinutes }) }}</dd>
149
+ </div>
150
+
151
+ <!-- Category -->
152
+ <div v-if="hasCategory" :class="detailCellClasses">
153
+ <dt :class="detailLabelClasses">{{ t('uiVintage.blog.category') }}</dt>
154
+ <dd class="mt-1.5">
155
+ <NuxtLinkLocale :to="categoryPath" :class="['inline-flex', badgeLinkClasses]">
156
+ <Badge color="yellow" :text="props.categoryText" pulse />
157
+ </NuxtLinkLocale>
158
+ </dd>
159
+ </div>
160
+
161
+ <!-- Extra columns from the app (use the same cell structure: a div with a dt and a dd) -->
162
+ <slot name="meta" :cell-classes="detailCellClasses" :label-classes="detailLabelClasses" />
163
+ </dl>
164
+ </div>
165
+
166
+ <!-- Tags -->
167
+ <div v-if="props.tags.length > 0" :class="['mt-4 flex flex-wrap items-center gap-2', enter('animate-uv-fade-up [animation-delay:600ms]')]">
168
+ <NuxtLinkLocale v-for="tag in props.tags" :key="tag.slug" :to="tagPath(tag.slug)" :class="badgeLinkClasses">
169
+ <Badge :text="`#${tag.name}`" />
170
+ </NuxtLinkLocale>
171
+ </div>
172
+
173
+ <!-- Featured image (replaceable through the media slot) -->
174
+ <slot name="media">
175
+ <div v-if="props.image" :class="['relative mt-8 aspect-video w-full overflow-hidden rounded border border-border shadow-elevated-xl', enter('animate-uv-fade-up [animation-delay:650ms]')]">
176
+ <NuxtImg :src="props.image" :alt="props.title" format="avif" quality="50" :sizes="{ 480: '480px', 1536: '896px' }" loading="eager" fetchpriority="high" preload :class="['size-full object-cover', enter('animate-uv-ken-burns')]" />
177
+ <div aria-hidden="true" class="pointer-events-none absolute inset-0 bg-linear-to-t from-black/20 to-transparent" />
178
+ </div>
179
+ </slot>
180
+ </header>
181
+ </template>
@@ -43,16 +43,14 @@ onUnmounted(() => {
43
43
  </script>
44
44
 
45
45
  <template>
46
- <div :class="cn('relative h-[3px] w-full bg-border/40 landing-navbar-progress', props.class)"
46
+ <div :class="cn('h-0.5 w-full bg-border/60 landing-navbar-progress', props.class)"
47
47
  role="progressbar"
48
48
  :aria-label="resolvedAriaLabel"
49
49
  aria-valuemin="0"
50
50
  aria-valuemax="100"
51
51
  :aria-valuenow="Math.round(progress)">
52
- <!-- Fill with a glowing leading edge (the dot stays inside the fill, so it is never clipped and never widens the page) -->
53
- <div class="relative h-full bg-linear-to-r from-primary/40 via-primary to-primary transition-[width] duration-150 ease-out" :style="{ width: `${progress}%` }">
54
- <span v-if="progress > 0" aria-hidden="true" class="absolute top-1/2 right-0 size-2 -translate-y-1/2 rounded-full bg-primary shadow-[0_0_10px_2px_color-mix(in_oklab,var(--primary)_70%,transparent)]" />
55
- </div>
52
+ <!-- Solid fill -->
53
+ <div class="h-full bg-primary transition-[width] duration-150 ease-out" :style="{ width: `${progress}%` }" />
56
54
  </div>
57
55
  </template>
58
56