@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
@@ -1,7 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  import { HugeiconsIcon } from '@hugeicons/vue';
3
3
  import { computed } from 'vue';
4
- import { AlertCircleIcon, InformationCircleIcon, Tick02Icon } from '@hugeicons/core-free-icons';
4
+ import AlertCircleIcon from '@hugeicons/core-free-icons/AlertCircleIcon';
5
+ import InformationCircleIcon from '@hugeicons/core-free-icons/InformationCircleIcon';
6
+ import Tick02Icon from '@hugeicons/core-free-icons/Tick02Icon';
5
7
  import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '../ui/alert-dialog';
6
8
  import { Button } from '../ui/button';
7
9
  import { getUiVintageRuntimeMessage } from '../../lib/i18n';
@@ -36,21 +38,21 @@ const defaultMessageIcon = computed(() => {
36
38
  // Resolved container classes for the message icon
37
39
  const messageIconClasses = computed(() => {
38
40
  const current = currentDialog.value;
39
- if (!current) return 'border-(--border) bg-surface';
40
- if (current.options.type === 'error') return 'border-red-200 bg-red-50 dark:border-red-900/50 dark:bg-red-950/30';
41
- if (current.options.type === 'warning') return 'border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-950/30';
42
- if (current.options.type === 'success') return 'border-green-200 bg-green-50 dark:border-green-900/50 dark:bg-green-950/30';
43
- return 'border-(--border) bg-surface';
41
+ if (!current) return 'border-border bg-surface';
42
+ if (current.options.type === 'error') return 'border-destructive/25 bg-destructive/10';
43
+ if (current.options.type === 'warning') return 'border-warning/25 bg-warning/10';
44
+ if (current.options.type === 'success') return 'border-success/25 bg-success/10';
45
+ return 'border-border bg-surface';
44
46
  });
45
47
 
46
48
  // Resolved glyph color classes
47
49
  const messageGlyphClasses = computed(() => {
48
50
  const current = currentDialog.value;
49
- if (!current) return 'text-blue-600';
50
- if (current.options.type === 'error') return 'text-red-600';
51
- if (current.options.type === 'warning') return 'text-amber-600';
52
- if (current.options.type === 'success') return 'text-green-600';
53
- return 'text-blue-600';
51
+ if (!current) return 'text-info';
52
+ if (current.options.type === 'error') return 'text-destructive';
53
+ if (current.options.type === 'warning') return 'text-warning';
54
+ if (current.options.type === 'success') return 'text-success';
55
+ return 'text-info';
54
56
  });
55
57
 
56
58
  // Close the dialog when the shell requests it
@@ -91,7 +93,7 @@ const handleOpenChange = (open: boolean) => {
91
93
 
92
94
  <!-- Dialog actions -->
93
95
  <AlertDialogFooter class="items-end">
94
- <Button variant="secondary" :disabled="messageDialogState.busy" @click="resolveActiveMessageDialog">
96
+ <Button variant="secondary" @click="resolveActiveMessageDialog">
95
97
  <HugeiconsIcon v-if="currentDialog.options.closeButtonIcon" :icon="currentDialog.options.closeButtonIcon" class="size-4" />
96
98
  {{ resolvedCloseText }}
97
99
  </Button>
@@ -24,7 +24,6 @@ interface MessageDialogRequest {
24
24
 
25
25
  interface MessageDialogState {
26
26
  current: MessageDialogRequest | null;
27
- busy: boolean;
28
27
  isOpen: boolean;
29
28
  }
30
29
 
@@ -39,7 +38,6 @@ const MESSAGE_DIALOG_ROOT_ID = 'ui-vintage-message-dialog-root';
39
38
  // Shared message dialog state
40
39
  export const messageDialogState: MessageDialogState = reactive({
41
40
  current: null,
42
- busy: false,
43
41
  isOpen: false
44
42
  });
45
43
 
@@ -104,7 +102,6 @@ const enqueueMessageDialog = (request: MessageDialogRequest) => {
104
102
 
105
103
  // Clear the active dialog and continue the queue
106
104
  const clearCurrentMessageDialog = () => {
107
- messageDialogState.busy = false;
108
105
  messageDialogState.isOpen = false;
109
106
  messageDialogState.current = null;
110
107
  openNextMessageDialog();
@@ -141,7 +138,7 @@ export const closeMessageDialog = () => {
141
138
  // Resolve the active message dialog
142
139
  export const resolveActiveMessageDialog = () => {
143
140
  const current = messageDialogState.current;
144
- if (!current || messageDialogState.busy) return;
141
+ if (!current || !messageDialogState.isOpen) return;
145
142
  current.resolve();
146
143
  scheduleCloseCurrentMessageDialog();
147
144
  };
@@ -1,5 +1,8 @@
1
1
  <script setup lang="ts">
2
- import { AlertCircleIcon, Cancel01Icon, CheckmarkCircle02Icon, InformationCircleIcon } from '@hugeicons/core-free-icons';
2
+ import AlertCircleIcon from '@hugeicons/core-free-icons/AlertCircleIcon';
3
+ import Cancel01Icon from '@hugeicons/core-free-icons/Cancel01Icon';
4
+ import CheckmarkCircle02Icon from '@hugeicons/core-free-icons/CheckmarkCircle02Icon';
5
+ import InformationCircleIcon from '@hugeicons/core-free-icons/InformationCircleIcon';
3
6
  import { HugeiconsIcon } from '@hugeicons/vue';
4
7
  import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
5
8
  import { getUiVintageRuntimeMessage } from '../../lib/i18n';
@@ -30,18 +33,18 @@ const getToastIcon = (toast: MessageToastItem) => {
30
33
 
31
34
  // Resolve the tinted icon tile classes for each toast type
32
35
  const toneClasses: Record<MessageToastType, string> = {
33
- success: 'border-green-200 bg-green-50 text-green-600 dark:border-green-900/50 dark:bg-green-950/30 dark:text-green-400',
34
- info: 'border-blue-200 bg-blue-50 text-blue-600 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-400',
35
- warning: 'border-amber-200 bg-amber-50 text-amber-600 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-400',
36
- error: 'border-red-200 bg-red-50 text-red-600 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-400'
36
+ success: 'border-success/25 bg-success/10 text-success',
37
+ info: 'border-info/25 bg-info/10 text-info',
38
+ warning: 'border-warning/25 bg-warning/10 text-warning',
39
+ error: 'border-destructive/25 bg-destructive/10 text-destructive'
37
40
  };
38
41
 
39
42
  // Progress bar color for each toast type
40
43
  const progressClasses: Record<MessageToastType, string> = {
41
- success: 'bg-green-500',
42
- info: 'bg-blue-500',
43
- warning: 'bg-amber-500',
44
- error: 'bg-red-500'
44
+ success: 'bg-success',
45
+ info: 'bg-info',
46
+ warning: 'bg-warning',
47
+ error: 'bg-destructive'
45
48
  };
46
49
 
47
50
  const toasts = computed(() => messageToastState.toasts);
@@ -86,11 +89,17 @@ const getToastStyle = (toast: MessageToastItem, index: number) => {
86
89
  };
87
90
  };
88
91
 
92
+ // Toast content elements currently watched by the observer, by toast id
93
+ const observedElements = new Map<number, HTMLElement>();
94
+
89
95
  // Measure toast content heights so the stack can lay itself out
90
96
  const resizeObserver = typeof ResizeObserver !== 'undefined'
91
97
  ? new ResizeObserver((entries) => {
92
98
  for (const entry of entries) {
93
99
  const id = Number((entry.target as HTMLElement).dataset.toastId);
100
+
101
+ // Skip late notifications for toasts that already left
102
+ if (observedElements.get(id) !== entry.target) continue;
94
103
  heights.set(id, (entry.target as HTMLElement).offsetHeight);
95
104
  }
96
105
  })
@@ -99,7 +108,10 @@ const resizeObserver = typeof ResizeObserver !== 'undefined'
99
108
  // Register toast content elements with the observer
100
109
  const observeToast = (element: unknown) => {
101
110
  if (element instanceof HTMLElement && resizeObserver) {
102
- heights.set(Number(element.dataset.toastId), element.offsetHeight);
111
+ const id = Number(element.dataset.toastId);
112
+ if (observedElements.get(id) === element) return;
113
+ heights.set(id, element.offsetHeight);
114
+ observedElements.set(id, element);
103
115
  resizeObserver.observe(element);
104
116
  }
105
117
  };
@@ -153,6 +165,11 @@ const handleAction = async (toast: MessageToastItem) => {
153
165
  // Forget measurements of removed toasts
154
166
  const handleAfterLeave = (element: Element) => {
155
167
  const id = Number((element as HTMLElement).dataset.toastId);
168
+ const content = observedElements.get(id);
169
+ if (content) {
170
+ resizeObserver?.unobserve(content);
171
+ observedElements.delete(id);
172
+ }
156
173
  heights.delete(id);
157
174
  leaveDirection.delete(id);
158
175
  };
@@ -167,6 +184,7 @@ watch(() => toasts.value.length, (length) => {
167
184
  // Stop observing on unmount
168
185
  onBeforeUnmount(() => {
169
186
  resizeObserver?.disconnect();
187
+ observedElements.clear();
170
188
  });
171
189
  </script>
172
190
 
@@ -52,6 +52,7 @@ const MAX_TOASTS = 5;
52
52
  const MESSAGE_TOAST_ROOT_ID = 'ui-vintage-message-toast-root';
53
53
 
54
54
  const toastTimers = new Map<number, ToastTimer>();
55
+ const pendingToastIds = new Set<number>();
55
56
  let messageToastMountPromise: Promise<void> | null = null;
56
57
  let nextToastId = 0;
57
58
 
@@ -143,6 +144,13 @@ export const resumeMessageToasts = () => {
143
144
  export const closeMessageToast = (id?: number) => {
144
145
  const ids = id === undefined ? messageToastState.toasts.map(toast => toast.id) : [id];
145
146
 
147
+ // Cancel toasts that were requested but are still waiting for the toaster to mount
148
+ if (id === undefined) {
149
+ pendingToastIds.clear();
150
+ } else {
151
+ pendingToastIds.delete(id);
152
+ }
153
+
146
154
  // Clear timers and remove the toasts from the stack
147
155
  for (const toastId of ids) {
148
156
  stopToastTimer(toastId);
@@ -170,11 +178,23 @@ export const showMessageToast = (options: ShowMessageToastOptions) => {
170
178
  duration
171
179
  };
172
180
 
181
+ // Toasts are client-only: never store them in the module state shared across server requests
182
+ if (typeof document === 'undefined') {
183
+ return id;
184
+ }
185
+
173
186
  // Expose the newest toast synchronously, as the single-toast API always did
174
187
  messageToastState.current = { message: toast.message, type: toast.type };
175
188
 
176
189
  // Mount first so the enter transition runs once the renderer exists
190
+ pendingToastIds.add(id);
177
191
  void ensureMessageToastMounted().then(() => {
192
+ // Skip toasts that were closed before the toaster finished mounting
193
+ if (!pendingToastIds.delete(id)) {
194
+ syncLegacyState();
195
+ return;
196
+ }
197
+
178
198
  // Push the newest toast on top and trim the overflow
179
199
  messageToastState.toasts = [toast, ...messageToastState.toasts];
180
200
  for (const overflow of messageToastState.toasts.slice(MAX_TOASTS)) {
@@ -27,7 +27,7 @@ const hasSlotContent = computed(() => {
27
27
 
28
28
  // Get size classes based on size prop
29
29
  const getSizeClasses = (size: string) => {
30
- const baseClasses = 'font-bold text-foreground';
30
+ const baseClasses = 'font-semibold tracking-tight text-foreground';
31
31
 
32
32
  // Return classes based on size
33
33
  switch (size) {
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { useId } from 'vue';
2
3
  import { Progress } from '../ui/progress';
3
4
 
4
5
  // Props
@@ -12,14 +13,17 @@ const props = withDefaults(defineProps<{
12
13
  bottomLeftLabel: '',
13
14
  bottomRightLabel: ''
14
15
  });
16
+
17
+ // Name the progress bar after the visible title
18
+ const titleId = useId();
15
19
  </script>
16
20
 
17
21
  <template>
18
22
  <div class="space-y-3">
19
- <!-- Header with icon and label -->
23
+ <!-- Header with label and value -->
20
24
  <div class="flex items-center justify-between">
21
25
  <!-- Title of the progress bar -->
22
- <span class="text-sm font-semibold text-foreground">
26
+ <span :id="titleId" class="text-sm font-semibold text-foreground">
23
27
  {{ props.title }}
24
28
  </span>
25
29
 
@@ -30,7 +34,7 @@ const props = withDefaults(defineProps<{
30
34
  </div>
31
35
 
32
36
  <!-- Progress bar -->
33
- <Progress :model-value="props.value" :max="props.max" />
37
+ <Progress :model-value="props.value" :max="props.max" :aria-labelledby="titleId" />
34
38
 
35
39
  <!-- Bottom labels -->
36
40
  <div v-if="props.bottomLeftLabel || props.bottomRightLabel" class="flex items-center justify-between text-xs">
@@ -1,10 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue';
3
3
  import type { HTMLAttributes } from 'vue';
4
- import { ArrowDownRight01Icon, ArrowUpRight01Icon } from '@hugeicons/core-free-icons';
4
+ import ArrowDownRight01Icon from '@hugeicons/core-free-icons/ArrowDownRight01Icon';
5
+ import ArrowUpRight01Icon from '@hugeicons/core-free-icons/ArrowUpRight01Icon';
5
6
  import { HugeiconsIcon } from '@hugeicons/vue';
6
7
  import { AnimatedNumber } from '../ui/animated-number';
7
- import { Card, CardContent } from '../ui/card';
8
+ import { Card } from '../ui/card';
8
9
  import { cn } from '../../lib/utils';
9
10
  import type { HugeiconsIconDefinition } from '../../lib/common-types';
10
11
 
@@ -33,11 +34,11 @@ const props = withDefaults(defineProps<{
33
34
  const valueClass = computed(() => {
34
35
  switch (props.valueColor) {
35
36
  case 'green':
36
- return 'text-green-600 dark:text-green-400';
37
+ return 'text-success';
37
38
  case 'red':
38
- return 'text-red-600 dark:text-red-400';
39
+ return 'text-destructive';
39
40
  case 'gray':
40
- return 'text-gray-600 dark:text-gray-400';
41
+ return 'text-muted-foreground';
41
42
  default:
42
43
  return 'text-foreground';
43
44
  }
@@ -46,45 +47,43 @@ const valueClass = computed(() => {
46
47
  // Trend badge presentation
47
48
  const trendIsPositive = computed(() => (props.trend ?? 0) >= 0);
48
49
  const formattedTrend = computed(() => `${trendIsPositive.value ? '+' : ''}${props.trend}%`);
50
+
51
+ // Value shown without the count-up (numbers are formatted only when formatOptions is given)
52
+ const staticValue = computed(() => typeof props.value === 'number' && props.formatOptions ? new Intl.NumberFormat(undefined, props.formatOptions).format(props.value) : props.value);
49
53
  </script>
50
54
 
51
55
  <template>
52
- <Card :class="cn('group/stat flex-row items-center p-4!', props.class)">
53
- <CardContent class="w-full flex-row items-center gap-0 p-0!">
54
- <!-- Icon -->
55
- <Card color="gray" class="mr-4 flex size-12 shrink-0 items-center justify-center p-0! transition-[rotate,scale,border-color] duration-500 ease-bounce group-hover/stat:-rotate-6 group-hover/stat:scale-105 group-hover/stat:border-primary/50">
56
- <HugeiconsIcon :icon="props.icon" class="size-5 transition-colors duration-300 group-hover/stat:text-primary" />
57
- </Card>
58
-
59
- <!-- Content -->
60
- <div class="flex min-w-0 flex-1 flex-col">
61
- <!-- Label -->
62
- <span class="mb-1 truncate text-xs font-semibold text-muted-foreground">
63
- {{ props.label }}
64
- </span>
56
+ <Card :class="cn('gap-0! p-4!', props.class)">
57
+ <!-- Label and icon -->
58
+ <div class="flex items-start justify-between gap-3">
59
+ <span class="min-w-0 text-[11px] font-semibold leading-5 tracking-wider text-muted-foreground uppercase">
60
+ {{ props.label }}
61
+ </span>
62
+ <span aria-hidden="true" class="flex size-8 shrink-0 items-center justify-center rounded border border-primary/30 text-primary">
63
+ <HugeiconsIcon :icon="props.icon" class="size-4" />
64
+ </span>
65
+ </div>
65
66
 
66
- <!-- Value and trend -->
67
- <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
68
- <span :class="['text-2xl font-bold md:text-xl', valueClass]">
69
- <AnimatedNumber v-if="props.animate && typeof props.value === 'number'" :value="props.value" :format-options="props.formatOptions" />
70
- <template v-else>{{ props.value }}</template>
71
- </span>
67
+ <!-- Value and trend -->
68
+ <div class="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-1">
69
+ <span :class="['text-2xl font-semibold tracking-tight tabular-nums', valueClass]">
70
+ <AnimatedNumber v-if="props.animate && typeof props.value === 'number'" :value="props.value" :format-options="props.formatOptions" />
71
+ <template v-else>{{ staticValue }}</template>
72
+ </span>
72
73
 
73
- <!-- Trend badge -->
74
- <span v-if="props.trend !== null" :class="cn('inline-flex items-center gap-0.5 rounded-sm px-1 py-0.5 text-[11px] font-bold', trendIsPositive ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-red-500/10 text-red-600 dark:text-red-400')">
75
- <HugeiconsIcon :icon="trendIsPositive ? ArrowUpRight01Icon : ArrowDownRight01Icon" class="size-3" />
76
- {{ formattedTrend }}
77
- </span>
78
- <span v-if="props.trend !== null && props.trendLabel" class="text-[11px] text-muted-foreground">
79
- {{ props.trendLabel }}
80
- </span>
81
- </div>
74
+ <!-- Trend badge -->
75
+ <span v-if="props.trend !== null" :class="cn('inline-flex items-center gap-0.5 rounded-sm px-1 py-0.5 text-[11px] font-semibold tabular-nums', trendIsPositive ? 'bg-success/10 text-success' : 'bg-destructive/10 text-destructive')">
76
+ <HugeiconsIcon :icon="trendIsPositive ? ArrowUpRight01Icon : ArrowDownRight01Icon" class="size-3" />
77
+ {{ formattedTrend }}
78
+ </span>
79
+ <span v-if="props.trend !== null && props.trendLabel" class="text-[11px] text-muted-foreground">
80
+ {{ props.trendLabel }}
81
+ </span>
82
+ </div>
82
83
 
83
- <!-- Description -->
84
- <span v-if="props.description" class="mt-1 text-xs text-muted-foreground">
85
- {{ props.description }}
86
- </span>
87
- </div>
88
- </CardContent>
84
+ <!-- Description -->
85
+ <span v-if="props.description" class="mt-1 text-xs leading-5 text-muted-foreground">
86
+ {{ props.description }}
87
+ </span>
89
88
  </Card>
90
- </template>
89
+ </template>
@@ -57,6 +57,6 @@ const handleUpdateModelValue = (value?: number[]) => {
57
57
  </div>
58
58
 
59
59
  <!-- Slider control -->
60
- <Slider :id="props.id" :model-value="sliderValue" :min="props.min" :max="props.max" :step="props.step" @update:model-value="handleUpdateModelValue" />
60
+ <Slider :id="props.id" :model-value="sliderValue" :min="props.min" :max="props.max" :step="props.step" :thumb-label="props.label" @update:model-value="handleUpdateModelValue" />
61
61
  </Card>
62
62
  </template>
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from 'vue';
3
+ import { HugeiconsIcon } from '@hugeicons/vue';
4
+ import { AnimatedNumber } from '../ui/animated-number';
5
+ import { cn } from '../../lib/utils';
6
+ import type { HugeiconsIconDefinition } from '../../lib/common-types';
7
+
8
+ // Props
9
+ const props = withDefaults(defineProps<{
10
+ items: Array<{
11
+ label: string;
12
+ value: string | number;
13
+ icon?: HugeiconsIconDefinition;
14
+ formatOptions?: Intl.NumberFormatOptions;
15
+ }>;
16
+ animate?: boolean;
17
+ class?: HTMLAttributes['class'];
18
+ }>(), {
19
+ animate: true
20
+ });
21
+ </script>
22
+
23
+ <template>
24
+ <!-- Compact row of stats in one bordered strip -->
25
+ <ul v-if="props.items.length > 0" data-slot="stat-strip" :class="cn('inline-flex max-w-full flex-wrap items-stretch overflow-hidden rounded border border-border bg-card text-xs shadow-elevated-sm', props.class)">
26
+ <li v-for="(item, index) in props.items" :key="item.label" :class="['flex items-center gap-2 px-4 py-2.5', index > 0 && 'border-l border-border']">
27
+ <HugeiconsIcon v-if="item.icon" :icon="item.icon" class="size-4 shrink-0 text-primary" />
28
+ <span class="font-semibold text-foreground tabular-nums">
29
+ <AnimatedNumber v-if="props.animate && typeof item.value === 'number'" :value="item.value" :format-options="item.formatOptions" />
30
+ <template v-else-if="typeof item.value === 'number'">{{ new Intl.NumberFormat(undefined, item.formatOptions).format(item.value) }}</template>
31
+ <template v-else>{{ item.value }}</template>
32
+ </span>
33
+ <span class="text-muted-foreground">{{ item.label }}</span>
34
+ </li>
35
+ </ul>
36
+ </template>
@@ -0,0 +1,2 @@
1
+ export { default } from './StatStrip.vue';
2
+ export { default as StatStrip } from './StatStrip.vue';
@@ -20,17 +20,17 @@ const emits = defineEmits<{
20
20
  <Card class="flex flex-col gap-2 px-4 py-3 sm:gap-3!">
21
21
  <!-- Switch label and control -->
22
22
  <div class="flex items-center justify-between gap-4">
23
- <!-- Switch label -->
24
- <div class="text-sm font-semibold">
23
+ <!-- Switch label (names the switch and toggles it on click) -->
24
+ <label :for="props.id" class="cursor-pointer text-sm font-semibold">
25
25
  {{ props.label }}
26
- </div>
26
+ </label>
27
27
 
28
28
  <!-- Switch control -->
29
- <Switch :id="props.id" :model-value="props.modelValue" @update:model-value="emits('update:modelValue', $event)" />
29
+ <Switch :id="props.id" :aria-describedby="`${props.id}-description`" :model-value="props.modelValue" @update:model-value="emits('update:modelValue', $event)" />
30
30
  </div>
31
31
 
32
32
  <!-- Description -->
33
- <div class="text-xs text-muted-foreground">
33
+ <div :id="`${props.id}-description`" class="text-xs text-muted-foreground">
34
34
  {{ props.description }}
35
35
  </div>
36
36
  </Card>
@@ -31,7 +31,7 @@ const handleClick = (event: MouseEvent) => {
31
31
  </script>
32
32
 
33
33
  <template>
34
- <component :is="props.linkComponent" v-bind="getLinkProps()" class="cursor-pointer font-semibold text-(--text-primary-light) hover:underline dark:text-(--text-primary-dark)" @click="handleClick">
34
+ <component :is="props.linkComponent" v-bind="getLinkProps()" class="cursor-pointer font-semibold text-foreground hover:underline" @click="handleClick">
35
35
  {{ props.text }}
36
36
  </component>
37
37
  </template>
@@ -1,7 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
3
3
  import { useI18n } from 'vue-i18n';
4
- import { LaptopIcon, Moon01Icon, Sun01Icon, Tick02Icon } from '@hugeicons/core-free-icons';
4
+ import LaptopIcon from '@hugeicons/core-free-icons/LaptopIcon';
5
+ import Moon01Icon from '@hugeicons/core-free-icons/Moon01Icon';
6
+ import Sun01Icon from '@hugeicons/core-free-icons/Sun01Icon';
7
+ import Tick02Icon from '@hugeicons/core-free-icons/Tick02Icon';
5
8
  import { HugeiconsIcon } from '@hugeicons/vue';
6
9
  import { Button } from '../ui/button';
7
10
  import { Command, CommandGroup, CommandItem, CommandList } from '../ui/command';
@@ -98,8 +101,8 @@ onUnmounted(() => {
98
101
  <!-- Theme options -->
99
102
  <PopoverContent side="bottom" align="end" :side-offset="8" class="w-44 p-0!">
100
103
  <Command :model-value="currentTheme">
101
- <CommandList>
102
- <CommandGroup :heading="t('uiVintage.theme.title')">
104
+ <CommandList :aria-label="t('uiVintage.theme.title')">
105
+ <CommandGroup :heading="t('uiVintage.theme.title')" stagger>
103
106
  <CommandItem v-for="option in themeOptions" :key="option.key" :value="option.key" @select="handleSelectTheme(option.key)">
104
107
  <HugeiconsIcon :icon="option.icon" class="size-4" />
105
108
  <span class="flex-1">{{ option.label }}</span>
@@ -27,8 +27,14 @@ export const isDarkTheme = (theme: ThemeMode) => {
27
27
  // Read the persisted theme mode
28
28
  export const getStoredTheme = (): ThemeMode => {
29
29
  if (typeof localStorage === 'undefined') return 'auto';
30
- const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
31
- return storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'auto';
30
+
31
+ // Storage can throw when the browser blocks it (privacy settings, sandboxed frames)
32
+ try {
33
+ const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
34
+ return storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'auto';
35
+ } catch {
36
+ return 'auto';
37
+ }
32
38
  };
33
39
 
34
40
  // Apply the selected theme to the document and persist it
@@ -37,7 +43,13 @@ export const applyTheme = (theme: ThemeMode) => {
37
43
  const root = document.documentElement;
38
44
  root.classList.remove('light', 'dark');
39
45
  root.classList.add(isDarkTheme(theme) ? 'dark' : 'light');
40
- localStorage.setItem(THEME_STORAGE_KEY, theme);
46
+
47
+ // Persisting is best effort: the theme still applies when storage is blocked
48
+ try {
49
+ localStorage.setItem(THEME_STORAGE_KEY, theme);
50
+ } catch {
51
+ // Ignore storage errors
52
+ }
41
53
  };
42
54
 
43
55
  // Apply a theme with a circular reveal that grows from the origin point (View Transitions API)
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { HTMLAttributes } from 'vue';
3
3
  import { computed, ref, useId } from 'vue';
4
- import { ArrowDown01Icon } from '@hugeicons/core-free-icons';
4
+ import ArrowDown01Icon from '@hugeicons/core-free-icons/ArrowDown01Icon';
5
5
  import { HugeiconsIcon } from '@hugeicons/vue';
6
6
  import { Card } from '../card';
7
7
  import { cn } from '../../../lib/utils';
@@ -26,6 +26,7 @@ const emits = defineEmits<{
26
26
  }>();
27
27
 
28
28
  const contentId = useId();
29
+ const triggerId = useId();
29
30
  const internalExpanded = ref(props.initiallyExpanded);
30
31
 
31
32
  // Support both controlled (v-model:open) and uncontrolled usage
@@ -40,34 +41,33 @@ const toggleExpanded = () => {
40
41
  </script>
41
42
 
42
43
  <template>
43
- <Card :data-state="expanded ? 'open' : 'closed'" :class="cn('group/accordion gap-0! overflow-hidden sm:gap-0! p-0! data-[state=open]:border-primary/25', props.class)">
44
+ <Card :data-state="expanded ? 'open' : 'closed'" :class="cn('group/accordion gap-0! overflow-hidden p-0!', props.class)">
44
45
  <!-- Header -->
45
- <button type="button" :aria-expanded="expanded" :aria-controls="contentId" class="group/trigger flex w-full cursor-pointer items-center justify-between gap-4 p-5 text-left outline-none focus-visible:bg-surface focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/40" @click="toggleExpanded">
46
- <div class="flex min-w-0 items-center gap-3">
47
- <!-- Icon -->
48
- <div v-if="props.icon" class="flex size-8 shrink-0 items-center justify-center rounded border border-border bg-card transition-[border-color,color] duration-200 group-hover/trigger:border-primary/60 group-data-[state=open]/accordion:border-primary/60 md:size-10">
49
- <HugeiconsIcon :icon="props.icon" :class="cn('size-4 text-muted-foreground transition-colors duration-200 group-hover/trigger:text-primary group-data-[state=open]/accordion:text-primary', props.iconClass)" />
50
- </div>
46
+ <button :id="triggerId" type="button" :aria-expanded="expanded" :aria-controls="contentId" class="group/trigger flex w-full cursor-pointer items-center gap-3 px-5 py-3.5 text-left outline-none focus-visible:bg-surface focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/40" @click="toggleExpanded">
47
+ <!-- Icon -->
48
+ <span v-if="props.icon" class="flex size-7 shrink-0 items-center justify-center rounded border border-primary/30 text-primary">
49
+ <HugeiconsIcon :icon="props.icon" :class="cn('size-3.5', props.iconClass)" />
50
+ </span>
51
51
 
52
- <!-- Title -->
53
- <div class="text-left text-sm font-semibold text-foreground md:text-base!">
54
- {{ props.title }}
55
- </div>
56
- </div>
52
+ <!-- Title -->
53
+ <span class="min-w-0 flex-1 text-sm font-semibold text-foreground">
54
+ {{ props.title }}
55
+ </span>
56
+
57
+ <!-- Optional content before the toggle icon -->
58
+ <slot name="trailing" />
57
59
 
58
60
  <!-- Toggle icon -->
59
- <div class="flex size-8 shrink-0 items-center justify-center rounded border border-border bg-card transition-[rotate,border-color,background-color] duration-[420ms] ease-spring group-hover/trigger:border-primary/50 group-data-[state=open]/accordion:rotate-180 group-data-[state=open]/accordion:border-primary/50 group-data-[state=open]/accordion:bg-primary/10">
60
- <HugeiconsIcon :icon="ArrowDown01Icon" class="size-4 text-muted-foreground transition-colors duration-200 group-hover/trigger:text-primary group-data-[state=open]/accordion:text-primary" />
61
- </div>
61
+ <HugeiconsIcon :icon="ArrowDown01Icon" class="size-4 shrink-0 text-muted-foreground transition-[rotate,color] duration-[420ms] ease-spring group-hover/trigger:text-primary group-data-[state=open]/accordion:rotate-180 group-data-[state=open]/accordion:text-primary" />
62
62
  </button>
63
63
 
64
64
  <!-- Content -->
65
- <div :id="contentId" role="region" :aria-hidden="!expanded" :inert="!expanded || undefined" :class="['grid transition-[grid-template-rows,opacity] duration-[380ms] ease-out-expo', expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0']">
65
+ <div :id="contentId" role="region" :aria-labelledby="triggerId" :aria-hidden="!expanded" :inert="!expanded || undefined" :class="['grid transition-[grid-template-rows,opacity] duration-[380ms] ease-out-expo', expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0']">
66
66
  <div class="overflow-hidden">
67
- <div :class="['px-5 pb-5 transition-[translate,filter] duration-[420ms] ease-spring', expanded ? 'translate-y-0 blur-0' : '-translate-y-2 blur-[2px]']">
67
+ <div :class="['border-t border-border px-5 py-4 transition-[translate,filter] duration-[420ms] ease-spring', expanded ? 'translate-y-0 blur-none' : '-translate-y-2 blur-[2px]']">
68
68
  <slot />
69
69
  </div>
70
70
  </div>
71
71
  </div>
72
72
  </Card>
73
- </template>
73
+ </template>
@@ -9,7 +9,7 @@ const props = defineProps<{
9
9
  </script>
10
10
 
11
11
  <template>
12
- <div data-slot="alert-title" :class="cn('col-start-2 line-clamp-1 min-h-4 font-bold tracking-tight text-current', props.class)">
12
+ <div data-slot="alert-title" :class="cn('col-start-2 line-clamp-1 min-h-4 font-semibold tracking-tight text-current', props.class)">
13
13
  <slot />
14
14
  </div>
15
15
  </template>
@@ -11,5 +11,5 @@ export type AlertColors = {
11
11
  };
12
12
 
13
13
  export const alertClasses: (props?: ClassProp) => string = cva(
14
- 'relative w-full rounded px-3 py-2 text-xs transition-colors duration-300 ease-in-out grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current sm:px-4 sm:py-3 sm:text-sm',
14
+ 'relative w-full rounded px-3 py-2 text-xs transition-colors duration-300 ease-in-out grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 content-start items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current sm:px-4 sm:py-3 sm:text-sm',
15
15
  );
@@ -25,7 +25,8 @@ const initials = computed(() => getInitials(props.name));
25
25
  </script>
26
26
 
27
27
  <template>
28
- <AvatarFallback data-slot="avatar-fallback" v-bind="delegatedProps" :aria-label="props.name || undefined" :class="cn('flex size-full items-center justify-center bg-surface font-bold uppercase text-muted-foreground', textClass, shapeClass, props.class)">
28
+ <!-- With a name the initials act as an image of the person, so screen readers announce the name instead of the letters -->
29
+ <AvatarFallback data-slot="avatar-fallback" v-bind="delegatedProps" :role="props.name ? 'img' : undefined" :aria-label="props.name || undefined" :class="cn('flex size-full items-center justify-center bg-surface font-semibold uppercase text-muted-foreground', textClass, shapeClass, props.class)">
29
30
  <slot>{{ initials }}</slot>
30
31
  </AvatarFallback>
31
32
  </template>