@brumaombra/ui-vintage 0.8.1 → 0.9.0

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 (142) hide show
  1. package/AGENTS.md +41 -0
  2. package/COMPONENTS.md +91 -17
  3. package/README.md +296 -262
  4. package/module.mjs +5 -11
  5. package/package.json +31 -13
  6. package/src/components/blog/AllPostsSection.vue +1 -2
  7. package/src/components/blog/BlogContentRenderer.vue +144 -49
  8. package/src/components/blog/BlogFAQSection.vue +13 -15
  9. package/src/components/blog/BlogHeaderSection.vue +103 -11
  10. package/src/components/blog/BlogInfoSection.vue +46 -42
  11. package/src/components/blog/BlogPostHeader.vue +181 -0
  12. package/src/components/blog/BlogProgressBar.vue +3 -2
  13. package/src/components/blog/BlogSectionTitle.vue +27 -6
  14. package/src/components/blog/BlogTagsSection.vue +20 -8
  15. package/src/components/blog/CategoriesList.vue +2 -2
  16. package/src/components/blog/CategoryCard.vue +23 -19
  17. package/src/components/blog/HeaderCarousel.vue +117 -145
  18. package/src/components/blog/PostCard.vue +25 -33
  19. package/src/components/blog/PostsList.vue +3 -2
  20. package/src/components/blog/SocialShareSidebar.vue +62 -42
  21. package/src/components/blog/TableOfContents.vue +71 -34
  22. package/src/components/blog/index.ts +1 -0
  23. package/src/components/card-grid/CardGrid.vue +2 -2
  24. package/src/components/confirm-dialog/ConfirmDialog.vue +5 -0
  25. package/src/components/confirm-dialog/confirm-dialog-state.ts +22 -22
  26. package/src/components/content/BlogList.vue +30 -42
  27. package/src/components/content/BlogTable.vue +26 -30
  28. package/src/components/content/CodeBlock.vue +96 -22
  29. package/src/components/content/Flow.vue +30 -36
  30. package/src/components/content/ProseHr.vue +6 -6
  31. package/src/components/content/ProsePre.vue +9 -4
  32. package/src/components/content/Terminal.vue +42 -14
  33. package/src/components/cookie-consent/CookieConsent.vue +1 -1
  34. package/src/components/dashboard-shell/DashboardShell.vue +24 -7
  35. package/src/components/empty-state-card/EmptyStateCard.vue +2 -2
  36. package/src/components/error-page/ErrorPage.vue +3 -3
  37. package/src/components/info-card/InfoCard.vue +4 -4
  38. package/src/components/landing/landing-footer/LandingFooter.vue +16 -6
  39. package/src/components/landing/landing-navbar/LandingNavbar.vue +63 -51
  40. package/src/components/language-flag/index.ts +1 -0
  41. package/src/components/language-selector/LanguageSelector.vue +18 -10
  42. package/src/components/load-more-button/LoadMoreButton.vue +7 -12
  43. package/src/components/loading-state-card/LoadingStateCard.vue +2 -2
  44. package/src/components/message-dialog/MessageDialog.vue +11 -11
  45. package/src/components/message-dialog/message-dialog-state.ts +1 -4
  46. package/src/components/message-toast/MessageToast.vue +24 -9
  47. package/src/components/message-toast/message-toast-state.ts +20 -0
  48. package/src/components/page-header/PageHeader.vue +1 -1
  49. package/src/components/progress-component/ProgressComponent.vue +7 -3
  50. package/src/components/single-value-card/SingleValueCard.vue +37 -39
  51. package/src/components/slider-form-component/SliderFormComponent.vue +1 -1
  52. package/src/components/stat-strip/StatStrip.vue +36 -0
  53. package/src/components/stat-strip/index.ts +2 -0
  54. package/src/components/switch-form-component/SwitchFormComponent.vue +5 -5
  55. package/src/components/text-link/TextLink.vue +1 -1
  56. package/src/components/theme-selector/ThemeSelector.vue +2 -2
  57. package/src/components/theme-selector/theme-transition.ts +15 -3
  58. package/src/components/ui/accordion/Accordion.vue +18 -18
  59. package/src/components/ui/alert/AlertTitle.vue +1 -1
  60. package/src/components/ui/alert/index.ts +1 -1
  61. package/src/components/ui/avatar/AvatarFallback.vue +2 -1
  62. package/src/components/ui/avatar/AvatarGroup.vue +13 -3
  63. package/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +2 -2
  64. package/src/components/ui/breadcrumb/BreadcrumbLink.vue +1 -1
  65. package/src/components/ui/breadcrumb/BreadcrumbList.vue +1 -1
  66. package/src/components/ui/breadcrumb/BreadcrumbPage.vue +1 -1
  67. package/src/components/ui/breadcrumb/BreadcrumbSeparator.vue +2 -6
  68. package/src/components/ui/busy/Busy.vue +5 -7
  69. package/src/components/ui/button/Button.vue +4 -2
  70. package/src/components/ui/button/index.ts +1 -4
  71. package/src/components/ui/calendar/Calendar.vue +12 -7
  72. package/src/components/ui/calendar/CalendarCell.vue +1 -1
  73. package/src/components/ui/calendar/CalendarCellTrigger.vue +1 -1
  74. package/src/components/ui/calendar/CalendarHeadCell.vue +1 -1
  75. package/src/components/ui/calendar/CalendarNextButton.vue +1 -1
  76. package/src/components/ui/calendar/CalendarPrevButton.vue +1 -1
  77. package/src/components/ui/card/Card.vue +5 -15
  78. package/src/components/ui/checkbox/Checkbox.vue +5 -1
  79. package/src/components/ui/combobox/ComboboxInput.vue +5 -1
  80. package/src/components/ui/command/CommandGroup.vue +17 -3
  81. package/src/components/ui/command/CommandInput.vue +1 -1
  82. package/src/components/ui/command/CommandItem.vue +2 -2
  83. package/src/components/ui/command/CommandList.vue +5 -1
  84. package/src/components/ui/data-table/DataTable.vue +11 -3
  85. package/src/components/ui/date-picker/DatePicker.vue +7 -1
  86. package/src/components/ui/date-time-picker/DateTimePicker.vue +5 -1
  87. package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  88. package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +1 -1
  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 +4 -4
  95. package/src/components/ui/input/Input.vue +5 -1
  96. package/src/components/ui/native-select/NativeSelect.vue +6 -2
  97. package/src/components/ui/number-field/NumberField.vue +5 -1
  98. package/src/components/ui/pagination/PaginationItem.vue +1 -1
  99. package/src/components/ui/pin-input/PinInputInput.vue +2 -2
  100. package/src/components/ui/progress/Progress.vue +2 -2
  101. package/src/components/ui/radio-group/RadioGroupCard.vue +1 -1
  102. package/src/components/ui/select/SelectItem.vue +1 -1
  103. package/src/components/ui/select/SelectItemContent.vue +1 -1
  104. package/src/components/ui/select/SelectTrigger.vue +5 -1
  105. package/src/components/ui/sidebar/Sidebar.vue +1 -1
  106. package/src/components/ui/sidebar/SidebarGroupAction.vue +1 -1
  107. package/src/components/ui/sidebar/SidebarGroupLabel.vue +1 -1
  108. package/src/components/ui/sidebar/SidebarInset.vue +1 -1
  109. package/src/components/ui/sidebar/SidebarMenuAction.vue +1 -1
  110. package/src/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
  111. package/src/components/ui/sidebar/SidebarMenuSkeleton.vue +2 -2
  112. package/src/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
  113. package/src/components/ui/sidebar/SidebarProvider.vue +17 -6
  114. package/src/components/ui/sidebar/index.ts +2 -2
  115. package/src/components/ui/sidebar/utils.ts +1 -1
  116. package/src/components/ui/slider/Slider.vue +5 -4
  117. package/src/components/ui/spinner/Spinner.vue +3 -2
  118. package/src/components/ui/stepper/StepperDescription.vue +5 -3
  119. package/src/components/ui/stepper/StepperIndicator.vue +2 -2
  120. package/src/components/ui/stepper/StepperTitle.vue +5 -3
  121. package/src/components/ui/switch/Switch.vue +6 -2
  122. package/src/components/ui/tabs/TabsList.vue +1 -1
  123. package/src/components/ui/tabs/TabsTrigger.vue +1 -1
  124. package/src/components/ui/textarea/Textarea.vue +5 -1
  125. package/src/components/ui/time-picker/TimePicker.vue +2 -2
  126. package/src/components/ui/toggle-group/ToggleGroup.vue +1 -1
  127. package/src/components/ui/tooltip/TooltipContent.vue +1 -1
  128. package/src/i18n/de.json +19 -7
  129. package/src/i18n/en.json +19 -7
  130. package/src/i18n/es.json +19 -7
  131. package/src/i18n/fr.json +19 -7
  132. package/src/i18n/it.json +19 -7
  133. package/src/i18n/ja.json +19 -7
  134. package/src/i18n/pt.json +19 -7
  135. package/src/i18n/ru.json +19 -7
  136. package/src/i18n/zh.json +19 -7
  137. package/src/lib/color-tokens.ts +1 -1
  138. package/src/lib/i18n.ts +32 -4
  139. package/src/lib/utils.ts +21 -1
  140. package/src/runtime/ui-vintage-i18n-plugin.ts +11 -1
  141. package/src/styles.css +52 -16
  142. 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.1",
3
+ "version": "0.9.0",
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.1.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
- "vue-i18n": "^9.0.0 || ^10.0.0"
144
+ "vue-i18n": "^9.0.0 || ^10.0.0 || ^11.0.0"
145
+ },
146
+ "peerDependenciesMeta": {
147
+ "@nuxt/content": {
148
+ "optional": true
149
+ }
137
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.1.1",
163
+ "@hugeicons/vue": "1.0.5",
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
  }
@@ -56,7 +56,6 @@ const visiblePostsCount = computed(() => Math.min(props.posts.length, props.tota
56
56
  :busy="props.isLoading"
57
57
  :text="t('uiVintage.blog.loadMoreWithNumbers', { current: visiblePostsCount, total: props.totalPosts })"
58
58
  @load-more="handleLoadMorePosts"
59
- class="mt-8"
60
- data-aos="fade-up" />
59
+ class="mt-8" />
61
60
  </div>
62
61
  </template>
@@ -13,24 +13,15 @@ const props = defineProps<{
13
13
 
14
14
  <style scoped>
15
15
  .blog-content {
16
- --blog-content-text: var(--text-primary-light);
17
- --blog-content-code-bg: var(--bg-selected-light);
18
- --blog-content-inline-code-bg: #f9fafb;
19
- --blog-content-inline-code-border: var(--border-light);
20
- color: var(--blog-content-text);
21
- }
22
-
23
- .dark .blog-content {
24
- --blog-content-text: var(--text-primary-dark);
25
- --blog-content-code-bg: var(--bg-selected-dark);
26
- --blog-content-inline-code-bg: var(--bg-card-dark);
27
- --blog-content-inline-code-border: var(--border-dark);
16
+ color: var(--foreground);
28
17
  }
29
18
 
19
+ /* Headings */
30
20
  .blog-content :deep(h1:not(.not-prose):not(.not-prose *)),
31
21
  .blog-content :deep(h2:not(.not-prose):not(.not-prose *)),
32
22
  .blog-content :deep(h3:not(.not-prose):not(.not-prose *)) {
33
- color: var(--blog-content-text);
23
+ color: var(--foreground);
24
+ letter-spacing: -0.01em;
34
25
  scroll-margin-top: 6rem;
35
26
  }
36
27
 
@@ -41,91 +32,195 @@ const props = defineProps<{
41
32
  }
42
33
 
43
34
  .blog-content :deep(h2:not(.not-prose):not(.not-prose *)) {
44
- margin-top: 2rem;
45
- margin-bottom: 1.5rem;
35
+ margin-top: 3rem;
36
+ margin-bottom: 1.25rem;
37
+ }
38
+
39
+ .blog-content :deep(h3:not(.not-prose):not(.not-prose *)) {
40
+ margin-top: 2.25rem;
41
+ margin-bottom: 1rem;
46
42
  }
47
43
 
48
44
  .blog-content :deep(h2:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *)) {
49
- font-size: 1.375rem;
45
+ font-size: 1.5rem;
50
46
  font-weight: 700;
51
47
  line-height: 2rem;
52
48
  }
53
49
 
54
- .blog-content :deep(h3:not(.not-prose):not(.not-prose *)) {
55
- margin-top: 2rem;
56
- margin-bottom: 1rem;
57
- }
58
-
59
50
  .blog-content :deep(h3:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *)) {
60
- font-size: 1.125rem;
51
+ font-size: 1.15rem;
61
52
  font-weight: 700;
62
53
  line-height: 1.75rem;
63
54
  }
64
55
 
56
+ /* Heading anchors: a "#" fades in on hover */
57
+ .blog-content :deep(:is(h2, h3):not(.not-prose):not(.not-prose *) > a) {
58
+ position: relative;
59
+ color: inherit;
60
+ text-decoration: none;
61
+ }
62
+
63
+ .blog-content :deep(:is(h2, h3):not(.not-prose):not(.not-prose *) > a::before) {
64
+ content: '#';
65
+ position: absolute;
66
+ left: -1.1em;
67
+ color: var(--primary);
68
+ opacity: 0;
69
+ translate: 4px 0;
70
+ transition: opacity 0.2s var(--ease-out-expo), translate 0.3s var(--ease-spring);
71
+ }
72
+
73
+ .blog-content :deep(:is(h2, h3):not(.not-prose):not(.not-prose *):hover > a::before) {
74
+ opacity: 1;
75
+ translate: 0 0;
76
+ }
77
+
78
+ /* Body text */
65
79
  .blog-content :deep(p:not(.not-prose):not(.not-prose *)),
66
80
  .blog-content :deep(li:not(.not-prose):not(.not-prose *)),
67
81
  .blog-content :deep(blockquote:not(.not-prose):not(.not-prose *)),
68
82
  .blog-content :deep(td:not(.not-prose):not(.not-prose *)),
69
83
  .blog-content :deep(th:not(.not-prose):not(.not-prose *)) {
70
- color: var(--blog-content-text);
84
+ color: var(--foreground);
71
85
  }
72
86
 
73
87
  .blog-content :deep(p:not(.not-prose):not(.not-prose *)) {
74
88
  font-size: 15px;
75
89
  margin-top: 1rem;
76
90
  margin-bottom: 1rem;
77
- line-height: 1.75;
91
+ line-height: 1.85;
78
92
  }
79
93
 
80
- .blog-content :deep(p:not(.not-prose):not(.not-prose *) strong:not(.not-prose):not(.not-prose *)),
81
- .blog-content :deep(blockquote:not(.not-prose):not(.not-prose *) strong:not(.not-prose):not(.not-prose *)) {
82
- font-weight: 700;
94
+ /* Lede: the opening paragraph reads a little larger */
95
+ .blog-content :deep(p:not(.not-prose):not(.not-prose *):first-of-type) {
96
+ font-size: 17px;
97
+ line-height: 1.8;
98
+ color: var(--muted-foreground);
83
99
  }
84
100
 
85
- .blog-content :deep(p:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *)),
86
- .blog-content :deep(li:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *)),
87
- .blog-content :deep(blockquote:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *)) {
88
- color: var(--blog-content-text);
89
- text-decoration: none;
101
+ .blog-content :deep(:is(p, blockquote, li):not(.not-prose):not(.not-prose *) strong) {
90
102
  font-weight: 700;
103
+ color: var(--foreground);
91
104
  }
92
105
 
93
- .blog-content :deep(p:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *):hover),
94
- .blog-content :deep(li:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *):hover),
95
- .blog-content :deep(blockquote:not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *):hover) {
106
+ /* Links: a primary underline that thickens on hover */
107
+ .blog-content :deep(:is(p, li, blockquote):not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *)) {
108
+ color: var(--foreground);
109
+ font-weight: 700;
96
110
  text-decoration: underline;
111
+ text-decoration-color: color-mix(in oklab, var(--primary) 45%, transparent);
112
+ text-decoration-thickness: 2px;
113
+ text-underline-offset: 4px;
114
+ transition: color 0.15s, text-decoration-color 0.15s, text-decoration-thickness 0.15s;
115
+ }
116
+
117
+ .blog-content :deep(:is(p, li, blockquote):not(.not-prose):not(.not-prose *) a:not(.not-prose):not(.not-prose *):hover) {
118
+ color: var(--primary);
119
+ text-decoration-color: var(--primary);
120
+ text-decoration-thickness: 3px;
121
+ }
122
+
123
+ /* Lists */
124
+ .blog-content :deep(:is(ul, ol):not(.not-prose):not(.not-prose *)) {
125
+ margin: 1.25rem 0;
126
+ padding-left: 1.4rem;
127
+ }
128
+
129
+ .blog-content :deep(ul:not(.not-prose):not(.not-prose *)) {
130
+ list-style: square;
97
131
  }
98
132
 
99
- .blog-content :deep(p:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)),
100
- .blog-content :deep(li:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)),
101
- .blog-content :deep(blockquote:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)),
102
- .blog-content :deep(td:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)),
103
- .blog-content :deep(th:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)) {
104
- color: var(--blog-content-text);
105
- background-color: var(--blog-content-inline-code-bg);
106
- border: 1px solid var(--blog-content-inline-code-border);
133
+ .blog-content :deep(ol:not(.not-prose):not(.not-prose *)) {
134
+ list-style: decimal;
135
+ }
136
+
137
+ .blog-content :deep(li:not(.not-prose):not(.not-prose *)) {
138
+ font-size: 15px;
139
+ line-height: 1.8;
140
+ margin: 0.4rem 0;
141
+ padding-left: 0.25rem;
142
+ }
143
+
144
+ .blog-content :deep(li:not(.not-prose):not(.not-prose *)::marker) {
145
+ color: var(--primary);
146
+ font-weight: 700;
147
+ }
148
+
149
+ /* Blockquotes */
150
+ .blog-content :deep(blockquote:not(.not-prose):not(.not-prose *)) {
151
+ position: relative;
152
+ margin: 2rem 0;
153
+ padding: 1rem 1.25rem 1rem 1.5rem;
154
+ border: 1px solid var(--border);
155
+ border-left: 3px solid var(--primary);
156
+ border-radius: var(--radius-sm);
157
+ background-color: var(--surface);
158
+ font-size: 15px;
159
+ font-style: italic;
160
+ line-height: 1.8;
161
+ }
162
+
163
+ .blog-content :deep(blockquote:not(.not-prose):not(.not-prose *) p) {
164
+ margin: 0;
165
+ }
166
+
167
+ /* Inline code */
168
+ .blog-content :deep(:is(p, li, blockquote, td, th):not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)) {
169
+ color: var(--foreground);
170
+ background-color: var(--surface);
171
+ border: 1px solid var(--border);
107
172
  border-radius: 0.25rem;
108
173
  display: inline-block;
109
174
  font-size: 0.75rem;
110
175
  font-weight: 600;
111
176
  line-height: 1rem;
112
177
  padding: 0.125rem 0.5rem;
113
- transition: color 0.3s ease-in-out, background-color 0.3s ease-in-out, border-color 0.3s ease-in-out;
114
178
  white-space: nowrap;
115
179
  }
116
180
 
117
181
  .blog-content :deep(pre:not(.not-prose):not(.not-prose *) code:not(.not-prose):not(.not-prose *)) {
118
- color: var(--blog-content-text);
119
- background-color: var(--blog-content-code-bg);
182
+ color: var(--foreground);
183
+ background-color: var(--accent);
184
+ }
185
+
186
+ /* Images */
187
+ .blog-content :deep(img:not(.not-prose):not(.not-prose *)) {
188
+ border: 1px solid var(--border);
189
+ border-radius: var(--radius-sm);
190
+ box-shadow: var(--uv-shadow-sm);
191
+ }
192
+
193
+ /* Plain markdown tables */
194
+ .blog-content :deep(table:not(.not-prose):not(.not-prose *)) {
195
+ width: 100%;
196
+ margin: 2rem 0;
197
+ border-collapse: collapse;
198
+ font-size: 14px;
199
+ }
200
+
201
+ .blog-content :deep(:is(th, td):not(.not-prose):not(.not-prose *)) {
202
+ border-bottom: 1px solid var(--border);
203
+ padding: 0.65rem 0.75rem;
204
+ text-align: left;
205
+ }
206
+
207
+ .blog-content :deep(th:not(.not-prose):not(.not-prose *)) {
208
+ background-color: var(--surface);
209
+ font-weight: 700;
120
210
  }
121
211
 
122
212
  @media (max-width: 640px) {
123
- .blog-content :deep(p:not(.not-prose):not(.not-prose *)) {
124
- font-size: 13px;
125
- line-height: 1.75;
213
+ .blog-content :deep(:is(p, li):not(.not-prose):not(.not-prose *)) {
214
+ font-size: 14px;
215
+ line-height: 1.8;
216
+ }
217
+
218
+ .blog-content :deep(p:not(.not-prose):not(.not-prose *):first-of-type) {
219
+ font-size: 15px;
126
220
  }
127
221
 
128
222
  .blog-content :deep(h2:not(.not-prose):not(.not-prose *)) {
223
+ margin-top: 2.25rem;
129
224
  margin-bottom: 1rem;
130
225
  }
131
226
  }
@@ -1,7 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue';
3
3
  import { useI18n } from 'vue-i18n';
4
- import { Accordion } from '@brumaombra/ui-vintage/accordion';
4
+ import { HelpCircleIcon } from '@hugeicons/core-free-icons';
5
+ import { Accordion } from '../ui/accordion';
5
6
  import BlogSectionTitle from './BlogSectionTitle.vue';
6
7
 
7
8
  // Props
@@ -20,21 +21,18 @@ const hasValidFAQs = computed(() => props.faqs && props.faqs.length > 0);
20
21
 
21
22
  <template>
22
23
  <div v-if="hasValidFAQs" class="mt-12 lg:mt-16">
23
- <!-- Title -->
24
- <BlogSectionTitle :title="t('uiVintage.blog.faq.title')" />
24
+ <!-- Title and description -->
25
+ <BlogSectionTitle :title="t('uiVintage.blog.faq.title')" :description="t('uiVintage.blog.faq.description')" />
25
26
 
26
- <!-- Description -->
27
- <p class="text-base md:text-lg text-muted-foreground mb-8">
28
- {{ t('uiVintage.blog.faq.description') }}
29
- </p>
30
-
31
- <!-- FAQ Items -->
32
- <div class="space-y-4">
33
- <Accordion v-for="(faq, index) in props.faqs" :key="index" :title="faq.question" :initially-expanded="index === 0">
34
- <p class="text-xs md:text-sm text-muted-foreground leading-relaxed">
35
- {{ faq.answer }}
36
- </p>
37
- </Accordion>
27
+ <!-- FAQ items -->
28
+ <div class="space-y-3">
29
+ <div v-for="(faq, index) in props.faqs" :key="index" data-aos="blur-up" :data-aos-delay="index * 70">
30
+ <Accordion :title="faq.question" :icon="HelpCircleIcon" :initially-expanded="index === 0">
31
+ <p class="text-xs leading-relaxed text-muted-foreground md:text-sm">
32
+ {{ faq.answer }}
33
+ </p>
34
+ </Accordion>
35
+ </div>
38
36
  </div>
39
37
  </div>
40
38
  </template>
@@ -1,6 +1,13 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useI18n } from 'vue-i18n';
4
+ import { ArrowRight01Icon } from '@hugeicons/core-free-icons';
5
+ import { HugeiconsIcon } from '@hugeicons/vue';
6
+ import type { HugeiconsIconDefinition } from '../../lib/common-types';
2
7
  import type { ToneColor } from '../../lib/color-tokens';
8
+ import { cn } from '../../lib/utils';
3
9
  import { Badge } from '../ui/badge';
10
+ import { StatStrip } from '../stat-strip';
4
11
 
5
12
  // Props
6
13
  const props = withDefaults(defineProps<{
@@ -8,31 +15,116 @@ const props = withDefaults(defineProps<{
8
15
  color: ToneColor;
9
16
  text: string;
10
17
  }>;
18
+ announcement?: {
19
+ text: string;
20
+ to: string;
21
+ label?: string;
22
+ } | null;
11
23
  title: string;
24
+ highlight?: string;
12
25
  description: string;
26
+ stats?: Array<{
27
+ label: string;
28
+ value: string | number;
29
+ icon?: HugeiconsIconDefinition;
30
+ }>;
31
+ align?: 'left' | 'center' | 'right';
13
32
  }>(), {
14
- badges: () => []
33
+ badges: () => [],
34
+ announcement: null,
35
+ highlight: '',
36
+ stats: () => [],
37
+ align: 'center'
38
+ });
39
+
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
+ };
15
59
  });
60
+
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`);
16
79
  </script>
17
80
 
18
81
  <template>
19
- <div class="flex flex-col space-y-6 md:space-y-8!">
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>
95
+
20
96
  <!-- Badges -->
21
- <div v-if="props.badges.length > 0" class="flex flex-wrap gap-3 mb-6 md:mb-8">
22
- <Badge v-for="badge in props.badges"
97
+ <div v-if="props.badges.length > 0" :class="cn('flex flex-wrap gap-2', alignClass.row)">
98
+ <Badge v-for="(badge, index) in props.badges"
23
99
  :key="`${badge.color}-${badge.text}`"
24
100
  :color="badge.color"
25
- :text="badge.text" />
101
+ :text="badge.text"
102
+ :pulse="index === 0 && !props.announcement"
103
+ class="animate-uv-fade-up"
104
+ :style="{ animationDelay: `${index * 70}ms` }" />
26
105
  </div>
27
106
 
28
- <!-- Title -->
29
- <h1 class="text-3xl md:text-6xl font-bold text-foreground leading-tight">
30
- {{ props.title }}
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>
119
+ </template>
31
120
  </h1>
32
121
 
33
122
  <!-- Description -->
34
- <p class="text-sm md:text-xl text-muted-foreground leading-relaxed">
123
+ <p class="max-w-2xl animate-uv-fade-up text-sm leading-7 text-muted-foreground sm:text-base" :style="{ animationDelay: descriptionDelay }">
35
124
  {{ props.description }}
36
125
  </p>
37
- </div>
38
- </template>
126
+
127
+ <!-- Stats strip -->
128
+ <StatStrip :items="props.stats" :class="cn('animate-uv-fade-up', alignClass.row)" :style="{ animationDelay: statsDelay }" />
129
+ </header>
130
+ </template>