@antfu/design 0.1.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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/a11y/cli.ts +73 -0
  4. package/a11y/index.ts +13 -0
  5. package/a11y/scan.ts +127 -0
  6. package/components/Action/ActionButton.stories.ts +56 -0
  7. package/components/Action/ActionButton.vue +57 -0
  8. package/components/Action/ActionDarkToggle.stories.ts +31 -0
  9. package/components/Action/ActionDarkToggle.vue +87 -0
  10. package/components/Action/ActionIconButton.stories.ts +47 -0
  11. package/components/Action/ActionIconButton.vue +47 -0
  12. package/components/Display/DisplayAvatar.stories.ts +36 -0
  13. package/components/Display/DisplayAvatar.vue +58 -0
  14. package/components/Display/DisplayBadge.stories.ts +31 -0
  15. package/components/Display/DisplayBadge.vue +98 -0
  16. package/components/Display/DisplayBytes.stories.ts +28 -0
  17. package/components/Display/DisplayBytes.vue +30 -0
  18. package/components/Display/DisplayDate.stories.ts +37 -0
  19. package/components/Display/DisplayDate.vue +29 -0
  20. package/components/Display/DisplayDonut.stories.ts +26 -0
  21. package/components/Display/DisplayDonut.vue +46 -0
  22. package/components/Display/DisplayDuration.stories.ts +28 -0
  23. package/components/Display/DisplayDuration.vue +28 -0
  24. package/components/Display/DisplayFileIcon.stories.ts +27 -0
  25. package/components/Display/DisplayFileIcon.vue +30 -0
  26. package/components/Display/DisplayFilePath.stories.ts +30 -0
  27. package/components/Display/DisplayFilePath.vue +61 -0
  28. package/components/Display/DisplayKbd.stories.ts +26 -0
  29. package/components/Display/DisplayKbd.vue +27 -0
  30. package/components/Display/DisplayKeyValue.stories.ts +56 -0
  31. package/components/Display/DisplayKeyValue.vue +51 -0
  32. package/components/Display/DisplayLabel.stories.ts +27 -0
  33. package/components/Display/DisplayLabel.vue +33 -0
  34. package/components/Display/DisplayNumber.stories.ts +27 -0
  35. package/components/Display/DisplayNumber.vue +24 -0
  36. package/components/Display/DisplayNumberBadge.stories.ts +26 -0
  37. package/components/Display/DisplayNumberBadge.vue +22 -0
  38. package/components/Display/DisplayPackageName.stories.ts +26 -0
  39. package/components/Display/DisplayPackageName.vue +49 -0
  40. package/components/Display/DisplayProgressBar.stories.ts +29 -0
  41. package/components/Display/DisplayProgressBar.vue +90 -0
  42. package/components/Display/DisplayProportionBar.stories.ts +40 -0
  43. package/components/Display/DisplayProportionBar.vue +43 -0
  44. package/components/Display/DisplaySafeImage.stories.ts +43 -0
  45. package/components/Display/DisplaySafeImage.vue +30 -0
  46. package/components/Display/DisplayStatusPill.stories.ts +34 -0
  47. package/components/Display/DisplayStatusPill.vue +42 -0
  48. package/components/Display/DisplayTree.stories.ts +76 -0
  49. package/components/Display/DisplayTree.vue +102 -0
  50. package/components/Display/DisplayVersion.stories.ts +25 -0
  51. package/components/Display/DisplayVersion.vue +21 -0
  52. package/components/Feedback/FeedbackEmptyState.stories.ts +38 -0
  53. package/components/Feedback/FeedbackEmptyState.vue +21 -0
  54. package/components/Feedback/FeedbackLoading.stories.ts +23 -0
  55. package/components/Feedback/FeedbackLoading.vue +21 -0
  56. package/components/Feedback/FeedbackSpinner.stories.ts +25 -0
  57. package/components/Feedback/FeedbackSpinner.vue +22 -0
  58. package/components/Feedback/FeedbackTip.stories.ts +34 -0
  59. package/components/Feedback/FeedbackTip.vue +29 -0
  60. package/components/Feedback/FeedbackToasts.stories.ts +40 -0
  61. package/components/Feedback/FeedbackToasts.vue +105 -0
  62. package/components/Form/FormCheckbox.stories.ts +36 -0
  63. package/components/Form/FormCheckbox.vue +30 -0
  64. package/components/Form/FormCombobox.stories.ts +35 -0
  65. package/components/Form/FormCombobox.vue +83 -0
  66. package/components/Form/FormField.stories.ts +56 -0
  67. package/components/Form/FormField.vue +36 -0
  68. package/components/Form/FormNumberInput.stories.ts +47 -0
  69. package/components/Form/FormNumberInput.vue +85 -0
  70. package/components/Form/FormRadioGroup.stories.ts +47 -0
  71. package/components/Form/FormRadioGroup.vue +43 -0
  72. package/components/Form/FormSearchField.stories.ts +22 -0
  73. package/components/Form/FormSearchField.vue +32 -0
  74. package/components/Form/FormSelect.stories.ts +47 -0
  75. package/components/Form/FormSelect.vue +56 -0
  76. package/components/Form/FormSwitch.stories.ts +36 -0
  77. package/components/Form/FormSwitch.vue +26 -0
  78. package/components/Form/FormTextInput.stories.ts +39 -0
  79. package/components/Form/FormTextInput.vue +51 -0
  80. package/components/Form/FormTextarea.stories.ts +47 -0
  81. package/components/Form/FormTextarea.vue +32 -0
  82. package/components/Layout/LayoutBreadcrumb.stories.ts +54 -0
  83. package/components/Layout/LayoutBreadcrumb.vue +54 -0
  84. package/components/Layout/LayoutCard.stories.ts +31 -0
  85. package/components/Layout/LayoutCard.vue +21 -0
  86. package/components/Layout/LayoutDataTable.stories.ts +77 -0
  87. package/components/Layout/LayoutDataTable.vue +145 -0
  88. package/components/Layout/LayoutExpandableList.stories.ts +28 -0
  89. package/components/Layout/LayoutExpandableList.vue +94 -0
  90. package/components/Layout/LayoutPanelGrids.stories.ts +28 -0
  91. package/components/Layout/LayoutPanelGrids.vue +26 -0
  92. package/components/Layout/LayoutSectionBlock.stories.ts +37 -0
  93. package/components/Layout/LayoutSectionBlock.vue +37 -0
  94. package/components/Layout/LayoutSideNav.stories.ts +33 -0
  95. package/components/Layout/LayoutSideNav.vue +48 -0
  96. package/components/Layout/LayoutSplitPane.stories.ts +44 -0
  97. package/components/Layout/LayoutSplitPane.vue +30 -0
  98. package/components/Layout/LayoutTabs.stories.ts +43 -0
  99. package/components/Layout/LayoutTabs.vue +56 -0
  100. package/components/Layout/LayoutToolbar.stories.ts +60 -0
  101. package/components/Layout/LayoutToolbar.vue +28 -0
  102. package/components/Layout/LayoutVirtualList.stories.ts +30 -0
  103. package/components/Layout/LayoutVirtualList.vue +82 -0
  104. package/components/Overlay/OverlayDrawer.stories.ts +47 -0
  105. package/components/Overlay/OverlayDrawer.vue +58 -0
  106. package/components/Overlay/OverlayDropdown.stories.ts +25 -0
  107. package/components/Overlay/OverlayDropdown.vue +30 -0
  108. package/components/Overlay/OverlayDropdownItem.stories.ts +26 -0
  109. package/components/Overlay/OverlayDropdownItem.vue +31 -0
  110. package/components/Overlay/OverlayDropdownLabel.vue +9 -0
  111. package/components/Overlay/OverlayDropdownSeparator.vue +7 -0
  112. package/components/Overlay/OverlayModal.stories.ts +33 -0
  113. package/components/Overlay/OverlayModal.vue +48 -0
  114. package/components/Overlay/OverlayTooltip.stories.ts +33 -0
  115. package/components/Overlay/OverlayTooltip.vue +38 -0
  116. package/composables/colorScheme.ts +58 -0
  117. package/composables/toast.ts +81 -0
  118. package/package.json +99 -0
  119. package/skills/antfu-design/SKILL.md +65 -0
  120. package/skills/antfu-design/references/advanced-patterns.md +39 -0
  121. package/skills/antfu-design/references/best-practices.md +54 -0
  122. package/skills/antfu-design/references/core-components.md +72 -0
  123. package/skills/antfu-design/references/core-setup.md +56 -0
  124. package/skills/antfu-design/references/core-tokens.md +100 -0
  125. package/skills/antfu-design/references/features-data-presentation.md +27 -0
  126. package/splitpanes.d.ts +70 -0
  127. package/styles/animations.css +47 -0
  128. package/styles/base.css +31 -0
  129. package/styles/floating-vue.css +28 -0
  130. package/styles/index.css +7 -0
  131. package/styles/reka-ui.css +112 -0
  132. package/styles/scrollbar.css +24 -0
  133. package/styles/splitpanes.css +61 -0
  134. package/unocss/colors.ts +127 -0
  135. package/unocss/index.ts +99 -0
  136. package/unocss/options.ts +31 -0
  137. package/unocss/patterns.ts +38 -0
  138. package/unocss/rules.ts +26 -0
  139. package/unocss/severity.ts +16 -0
  140. package/unocss/shortcuts.ts +68 -0
  141. package/utils/color.ts +328 -0
  142. package/utils/contrast.ts +118 -0
  143. package/utils/format.ts +389 -0
  144. package/utils/icon.ts +200 -0
  145. package/utils/index.ts +13 -0
  146. package/utils/keybinding.ts +199 -0
  147. package/utils/misc.ts +141 -0
  148. package/utils/path.ts +243 -0
  149. package/utils/semver.ts +147 -0
  150. package/utils/tree.ts +89 -0
@@ -0,0 +1,389 @@
1
+ // @unocss-include — this file embeds `color-scale-*` utility classes that a
2
+ // consumer's UnoCSS must generate; the marker forces full extraction.
3
+
4
+ /**
5
+ * Pure formatting helpers shared by the display components, plus the
6
+ * severity/age → `color-scale-*` mappings. Re-implemented once here instead of
7
+ * being duplicated in every badge across the source projects.
8
+ */
9
+
10
+ /**
11
+ * The severity color-scale class names (see `presetAnthonySeverity`).
12
+ *
13
+ * Maps each severity level to its `color-scale-*` CSS class, ordered from least
14
+ * to most severe.
15
+ *
16
+ * @example
17
+ * colorScale.neutral // → 'color-scale-neutral'
18
+ * colorScale.critical // → 'color-scale-critical'
19
+ */
20
+ export const colorScale = {
21
+ neutral: 'color-scale-neutral',
22
+ low: 'color-scale-low',
23
+ medium: 'color-scale-medium',
24
+ high: 'color-scale-high',
25
+ critical: 'color-scale-critical',
26
+ } as const
27
+
28
+ export type ColorScaleClass = (typeof colorScale)[keyof typeof colorScale]
29
+ export type SeverityScale = readonly (readonly [max: number, cls: ColorScaleClass])[]
30
+
31
+ /**
32
+ * Map a value through an ascending threshold scale to a color-scale class.
33
+ *
34
+ * Returns the class of the first `[max, cls]` entry whose `max` the value does
35
+ * not exceed; if the value is above every threshold, the last entry's class is
36
+ * returned.
37
+ *
38
+ * @param value - The numeric value to classify.
39
+ * @param scale - Ascending list of `[max, colorScaleClass]` thresholds.
40
+ * @returns The matching {@link ColorScaleClass}.
41
+ *
42
+ * @example
43
+ * mapSeverity(999, [[10, 'color-scale-low']]) // → 'color-scale-low' (above all thresholds)
44
+ */
45
+ export function mapSeverity(value: number, scale: SeverityScale): ColorScaleClass {
46
+ for (const [max, cls] of scale) {
47
+ if (value <= max)
48
+ return cls
49
+ }
50
+ return scale[scale.length - 1][1]
51
+ }
52
+
53
+ // ── Locale ──────────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * The default BCP-47 locale used by the `Intl`-backed formatters
57
+ * (`formatNumber`, `formatPercent`, `formatDateTime`) when no `locale` argument
58
+ * is passed. Defaults to `'en-US'`; set it once at app startup for i18n.
59
+ */
60
+ let defaultLocale = 'en-US'
61
+
62
+ /**
63
+ * Set the process-wide default locale for the `Intl`-backed formatters.
64
+ *
65
+ * @param locale - A BCP-47 locale tag, e.g. `'de-DE'` or `'ja-JP'`.
66
+ *
67
+ * @example
68
+ * setDefaultLocale('de-DE')
69
+ * formatNumber(1234567) // → '1.234.567'
70
+ */
71
+ export function setDefaultLocale(locale: string): void {
72
+ defaultLocale = locale
73
+ }
74
+
75
+ /**
76
+ * Get the current default locale used by the `Intl`-backed formatters.
77
+ *
78
+ * @returns The current default BCP-47 locale tag.
79
+ */
80
+ export function getDefaultLocale(): string {
81
+ return defaultLocale
82
+ }
83
+
84
+ // ── Numbers ───────────────────────────────────────────────────────────────
85
+
86
+ /**
87
+ * Format a number with locale-aware grouping via `Intl.NumberFormat`.
88
+ *
89
+ * @param value - The number to format.
90
+ * @param options - Optional `Intl.NumberFormatOptions` to customize formatting.
91
+ * @param locale - BCP-47 locale tag. Defaults to {@link getDefaultLocale}.
92
+ * @returns The formatted, grouped number string.
93
+ *
94
+ * @example
95
+ * formatNumber(1234567) // → '1,234,567'
96
+ * formatNumber(1234567, undefined, 'de-DE') // → '1.234.567'
97
+ */
98
+ export function formatNumber(value: number, options?: Intl.NumberFormatOptions, locale?: string): string {
99
+ return new Intl.NumberFormat(locale ?? defaultLocale, options).format(value)
100
+ }
101
+
102
+ /**
103
+ * Format a 0–1 fraction as a locale-aware percentage.
104
+ *
105
+ * @param value - The fraction to format, where `1` represents 100%.
106
+ * @param digits - Maximum number of fraction digits. Defaults to `1`.
107
+ * @param locale - BCP-47 locale tag. Defaults to {@link getDefaultLocale}.
108
+ * @returns The formatted percentage string.
109
+ *
110
+ * @example
111
+ * formatPercent(0.1234) // → '12.3%'
112
+ * formatPercent(0.5, 0) // → '50%'
113
+ */
114
+ export function formatPercent(value: number, digits = 1, locale?: string): string {
115
+ return new Intl.NumberFormat(locale ?? defaultLocale, {
116
+ style: 'percent',
117
+ minimumFractionDigits: 0,
118
+ maximumFractionDigits: digits,
119
+ }).format(value)
120
+ }
121
+
122
+ // ── Bytes ─────────────────────────────────────────────────────────────────
123
+
124
+ const BYTE_UNITS_1024 = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
125
+ const BYTE_UNITS_1000 = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'] as const
126
+
127
+ export interface FormatBytesOptions {
128
+ /** `1024` (binary, default) or `1000` (decimal). The source projects disagree, so it is an option. */
129
+ base?: 1024 | 1000
130
+ digits?: number
131
+ }
132
+
133
+ /**
134
+ * Humanize a byte count → `[value, unit]`.
135
+ *
136
+ * Picks the largest unit whose value is ≥ 1, formats to `digits` decimals with
137
+ * trailing zeros trimmed, and returns the value and unit separately so callers
138
+ * can style them independently. Non-positive or falsy inputs yield `['0', 'B']`.
139
+ *
140
+ * @param bytes - The byte count to humanize.
141
+ * @param options - Formatting options.
142
+ * @param options.base - `1024` (binary, default) or `1000` (decimal).
143
+ * @param options.digits - Maximum decimal places before trimming. Defaults to `2`.
144
+ * @returns A `[value, unit]` tuple, e.g. `['1.5', 'KB']`.
145
+ *
146
+ * @example
147
+ * formatBytes(0) // → ['0', 'B']
148
+ * formatBytes(512) // → ['512', 'B']
149
+ * formatBytes(1024) // → ['1', 'KB']
150
+ * formatBytes(1536) // → ['1.5', 'KB']
151
+ * formatBytes(1_000_000, { base: 1000 }) // → ['1', 'MB']
152
+ */
153
+ export function formatBytes(bytes: number, options: FormatBytesOptions = {}): [string, string] {
154
+ const { base = 1024, digits = 2 } = options
155
+ if (!bytes || bytes < 0)
156
+ return ['0', 'B']
157
+ const units = base === 1000 ? BYTE_UNITS_1000 : BYTE_UNITS_1024
158
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(base)), units.length - 1)
159
+ if (i === 0)
160
+ return [String(bytes), 'B']
161
+ const value = (bytes / base ** i).toFixed(digits).replace(/\.?0+$/, '')
162
+ return [value, units[i]]
163
+ }
164
+
165
+ /**
166
+ * Byte length of a string's UTF-8 encoding.
167
+ *
168
+ * Counts encoded bytes rather than code units, so multi-byte characters are
169
+ * measured accurately.
170
+ *
171
+ * @param str - The string to measure.
172
+ * @returns The number of UTF-8 bytes.
173
+ *
174
+ * @example
175
+ * getContentByteSize('hello') // → 5
176
+ * getContentByteSize('héllo') // → 6 (é encodes to 2 bytes)
177
+ */
178
+ export function getContentByteSize(str: string): number {
179
+ return new TextEncoder().encode(str).length
180
+ }
181
+
182
+ const KB = 1024
183
+ const MB = 1024 * KB
184
+
185
+ /** Default severity thresholds for byte sizes. Pass a custom one to {@link getBytesColor}. */
186
+ export const BYTES_SCALE: SeverityScale = [
187
+ [80 * KB, colorScale.neutral],
188
+ [500 * KB, colorScale.low],
189
+ [MB, colorScale.medium],
190
+ [10 * MB, colorScale.high],
191
+ [Number.POSITIVE_INFINITY, colorScale.critical],
192
+ ]
193
+
194
+ /**
195
+ * Color a byte size on a severity scale (small = neutral, large = critical).
196
+ *
197
+ * Default thresholds: ≤80 KB neutral, ≤500 KB low, ≤1 MB medium, ≤10 MB high, else critical.
198
+ *
199
+ * @param bytes - The byte size to classify.
200
+ * @param scale - Ascending threshold scale. Defaults to {@link BYTES_SCALE}.
201
+ * @returns The matching {@link ColorScaleClass}.
202
+ *
203
+ * @example
204
+ * getBytesColor(10 * 1024) // → 'color-scale-neutral'
205
+ * getBytesColor(50 * 1024 * 1024) // → 'color-scale-critical'
206
+ */
207
+ export function getBytesColor(bytes: number, scale: SeverityScale = BYTES_SCALE): ColorScaleClass {
208
+ return mapSeverity(bytes, scale)
209
+ }
210
+
211
+ // ── Durations ───────────────────────────────────────────────────────────────
212
+
213
+ /** Input time unit accepted by {@link formatDuration}. */
214
+ export type DurationUnit = 'ns' | 'us' | 'ms' | 's'
215
+
216
+ const DURATION_FACTOR_MS: Record<DurationUnit, number> = { ns: 1e-6, us: 1e-3, ms: 1, s: 1000 }
217
+
218
+ /**
219
+ * Humanize a duration → `[value, unit]`, accepting nanosecond/microsecond input.
220
+ *
221
+ * Input is interpreted in `options.unit` (default `'ms'`) and scaled to
222
+ * milliseconds, then formatted: sub-millisecond values from a finer input unit
223
+ * render as `ns`/`µs`, otherwise the ladder runs `ms` → `s` → `min` → `h` → `d`.
224
+ * For backwards compatibility a sub-1 **ms** input still renders `['<1', 'ms']`,
225
+ * and `null`/`undefined` renders `['', '-']`.
226
+ *
227
+ * @param value - The duration in `options.unit`, or `null`/`undefined` for no value.
228
+ * @param options - Formatting options.
229
+ * @param options.unit - Input unit: `'ns'`, `'us'`, `'ms'` (default) or `'s'`.
230
+ * @returns A `[value, unit]` tuple, e.g. `['1.5', 's']`.
231
+ *
232
+ * @example
233
+ * formatDuration(null) // → ['', '-']
234
+ * formatDuration(0.5) // → ['<1', 'ms']
235
+ * formatDuration(250) // → ['250', 'ms']
236
+ * formatDuration(1500) // → ['1.5', 's']
237
+ * formatDuration(1500, { unit: 'ns' }) // → ['1.5', 'µs']
238
+ * formatDuration(820, { unit: 'us' }) // → ['820', 'µs']
239
+ */
240
+ export function formatDuration(
241
+ value: number | null | undefined,
242
+ options: { unit?: DurationUnit } = {},
243
+ ): [string, string] {
244
+ if (value == null)
245
+ return ['', '-']
246
+ const unit = options.unit ?? 'ms'
247
+ const ms = value * DURATION_FACTOR_MS[unit]
248
+ if (ms < 1) {
249
+ if (unit === 'ms')
250
+ return ['<1', 'ms']
251
+ const us = ms * 1000
252
+ if (us < 1)
253
+ return [Math.round(us * 1000).toString(), 'ns']
254
+ return [(us < 10 ? us.toFixed(1) : us.toFixed(0)).replace(/\.0$/, ''), 'µs']
255
+ }
256
+ if (ms < 1000)
257
+ return [ms.toFixed(0), 'ms']
258
+ if (ms < 60_000)
259
+ return [(ms / 1000).toFixed(1), 's']
260
+ if (ms < 3_600_000)
261
+ return [(ms / 60_000).toFixed(1), 'min']
262
+ if (ms < 86_400_000)
263
+ return [(ms / 3_600_000).toFixed(1), 'h']
264
+ return [(ms / 86_400_000).toFixed(1), 'd']
265
+ }
266
+
267
+ /** Default severity thresholds (in ms) for durations. Pass a custom one to {@link getDurationColor}. */
268
+ export const DURATION_SCALE: SeverityScale = [
269
+ [50, colorScale.neutral],
270
+ [200, colorScale.low],
271
+ [1000, colorScale.medium],
272
+ [5000, colorScale.high],
273
+ [Number.POSITIVE_INFINITY, colorScale.critical],
274
+ ]
275
+
276
+ /**
277
+ * Color a duration on a severity scale (fast = neutral, slow = critical).
278
+ *
279
+ * Default thresholds: ≤50 ms neutral, ≤200 ms low, ≤1000 ms medium, ≤5000 ms high, else critical.
280
+ *
281
+ * @param ms - The duration in milliseconds to classify.
282
+ * @param scale - Ascending threshold scale (in ms). Defaults to {@link DURATION_SCALE}.
283
+ * @returns The matching {@link ColorScaleClass}.
284
+ *
285
+ * @example
286
+ * getDurationColor(30) // → 'color-scale-neutral'
287
+ * getDurationColor(500) // → 'color-scale-medium'
288
+ * getDurationColor(10_000) // → 'color-scale-critical'
289
+ */
290
+ export function getDurationColor(ms: number, scale: SeverityScale = DURATION_SCALE): ColorScaleClass {
291
+ return mapSeverity(ms, scale)
292
+ }
293
+
294
+ // ── Relative time ─────────────────────────────────────────────────────────
295
+
296
+ const SECOND = 1000
297
+ const MINUTE = 60 * SECOND
298
+ const HOUR = 60 * MINUTE
299
+ const DAY = 24 * HOUR
300
+ const MONTH = 30 * DAY
301
+ const YEAR = 365 * DAY
302
+
303
+ const TIME_UNITS: readonly (readonly [limit: number, divisor: number, unit: string])[] = [
304
+ [MINUTE, SECOND, 's'],
305
+ [HOUR, MINUTE, 'min'],
306
+ [DAY, HOUR, 'h'],
307
+ [MONTH, DAY, 'd'],
308
+ [YEAR, MONTH, 'mo'],
309
+ [Number.POSITIVE_INFINITY, YEAR, 'y'],
310
+ ]
311
+
312
+ /**
313
+ * No-dependency relative time: `"3 d ago"`, `"in 2 h"`, `"just now"`.
314
+ *
315
+ * Computes the signed delta between `input` and `now`, picks the largest unit
316
+ * (`s`/`min`/`h`/`d`/`mo`/`y`) and renders past times as `"N unit ago"` and
317
+ * future times as `"in N unit"`. Deltas under a second are `"just now"`.
318
+ *
319
+ * @param input - The timestamp to describe, as a `Date` or epoch milliseconds.
320
+ * @param now - Reference time in epoch milliseconds. Defaults to `Date.now()`.
321
+ * @returns A human-readable relative time string.
322
+ *
323
+ * @example
324
+ * const now = 1_000_000_000_000
325
+ * formatTimeAgo(now, now) // → 'just now'
326
+ * formatTimeAgo(now - 3 * 86_400_000, now) // → '3 d ago'
327
+ * formatTimeAgo(now + 2 * 3_600_000, now) // → 'in 2 h'
328
+ */
329
+ export function formatTimeAgo(input: Date | number, now: number = Date.now()): string {
330
+ const time = input instanceof Date ? input.getTime() : input
331
+ const delta = now - time
332
+ const abs = Math.abs(delta)
333
+ if (abs < SECOND)
334
+ return 'just now'
335
+ for (const [limit, divisor, unit] of TIME_UNITS) {
336
+ if (abs < limit) {
337
+ const value = Math.round(abs / divisor)
338
+ return delta >= 0 ? `${value} ${unit} ago` : `in ${value} ${unit}`
339
+ }
340
+ }
341
+ return ''
342
+ }
343
+
344
+ /** Default severity thresholds (in ms of age) for freshness. Pass a custom one to {@link getAgeColor}. */
345
+ export const AGE_SCALE: SeverityScale = [
346
+ [180 * DAY, colorScale.neutral],
347
+ [365 * DAY, colorScale.low],
348
+ [3 * YEAR, colorScale.medium],
349
+ [5 * YEAR, colorScale.high],
350
+ [Number.POSITIVE_INFINITY, colorScale.critical],
351
+ ]
352
+
353
+ /**
354
+ * Color an age (ms since something) on the freshness scale.
355
+ *
356
+ * Default thresholds: ≤180 d neutral, ≤365 d low, ≤3 y medium, ≤5 y high, else critical.
357
+ *
358
+ * @param ageMs - The age in milliseconds to classify.
359
+ * @param scale - Ascending threshold scale (in ms). Defaults to {@link AGE_SCALE}.
360
+ * @returns The matching {@link ColorScaleClass}.
361
+ *
362
+ * @example
363
+ * getAgeColor(10 * 86_400_000) // → 'color-scale-neutral' (10 days old)
364
+ */
365
+ export function getAgeColor(ageMs: number, scale: SeverityScale = AGE_SCALE): ColorScaleClass {
366
+ return mapSeverity(ageMs, scale)
367
+ }
368
+
369
+ /**
370
+ * Format a date/time with a locale-aware `Intl.DateTimeFormat`.
371
+ *
372
+ * Defaults to a medium date and short time when no options are given. Exact
373
+ * output depends on the host locale data and time zone.
374
+ *
375
+ * @param input - The date to format, as a `Date` or epoch milliseconds.
376
+ * @param options - Optional `Intl.DateTimeFormatOptions`. Defaults to `{ dateStyle: 'medium', timeStyle: 'short' }`.
377
+ * @param locale - BCP-47 locale tag. Defaults to {@link getDefaultLocale}.
378
+ * @returns The formatted date/time string.
379
+ *
380
+ * @example
381
+ * formatDateTime(new Date('2026-06-26T15:30:00Z'))
382
+ * // → 'Jun 26, 2026, 3:30 PM' (UTC; varies by time zone)
383
+ */
384
+ export function formatDateTime(input: Date | number, options?: Intl.DateTimeFormatOptions, locale?: string): string {
385
+ return new Intl.DateTimeFormat(locale ?? defaultLocale, options ?? {
386
+ dateStyle: 'medium',
387
+ timeStyle: 'short',
388
+ }).format(input)
389
+ }
package/utils/icon.ts ADDED
@@ -0,0 +1,200 @@
1
+ // @unocss-include — this file embeds `i-catppuccin:*` icon classes that a
2
+ // consumer's UnoCSS must generate; the marker forces full extraction.
3
+
4
+ /**
5
+ * Map a file path / module id to an icon class. The rule list is configurable
6
+ * — the default targets `@iconify-json/catppuccin`, but a caller can pass a
7
+ * vscode-icons / octicon map instead. Backs the `FileIcon` component.
8
+ */
9
+
10
+ export interface FileIconRule {
11
+ match: RegExp
12
+ name: string
13
+ /** UnoCSS icon class, e.g. `i-catppuccin:typescript`. */
14
+ icon: string
15
+ description?: string
16
+ }
17
+
18
+ /**
19
+ * Default file-icon rule list targeting `@iconify-json/catppuccin`.
20
+ *
21
+ * Each rule pairs a filename `RegExp` with a `name` and UnoCSS `icon` class; the
22
+ * list is ordered so the first matching rule wins (e.g. `.d.ts` before `.ts`).
23
+ * Pass a custom list to {@link getFileType}/{@link getFileIcon} to use a
24
+ * different icon set.
25
+ *
26
+ * @example
27
+ * defaultFileIconRules[0] // → { match: /\.vue$/, name: 'vue', icon: 'i-catppuccin:vue' }
28
+ */
29
+ export const defaultFileIconRules: FileIconRule[] = [
30
+ { match: /\.vue$/, name: 'vue', icon: 'i-catppuccin:vue' },
31
+ { match: /\.tsx$/, name: 'tsx', icon: 'i-catppuccin:typescript-react' },
32
+ { match: /\.jsx$/, name: 'jsx', icon: 'i-catppuccin:javascript-react' },
33
+ { match: /\.d\.[cm]?ts$/, name: 'dts', icon: 'i-catppuccin:typescript-def' },
34
+ { match: /\.[cm]?ts$/, name: 'ts', icon: 'i-catppuccin:typescript' },
35
+ { match: /\.[cm]?js$/, name: 'js', icon: 'i-catppuccin:javascript' },
36
+ { match: /\.json5?$/, name: 'json', icon: 'i-catppuccin:json' },
37
+ { match: /\.ya?ml$/, name: 'yaml', icon: 'i-catppuccin:yaml' },
38
+ { match: /\.toml$/, name: 'toml', icon: 'i-catppuccin:toml' },
39
+ { match: /\.md$/, name: 'markdown', icon: 'i-catppuccin:markdown' },
40
+ { match: /\.html?$/, name: 'html', icon: 'i-catppuccin:html' },
41
+ { match: /\.(?:css|postcss)$/, name: 'css', icon: 'i-catppuccin:css' },
42
+ { match: /\.s[ac]ss$/, name: 'sass', icon: 'i-catppuccin:sass' },
43
+ { match: /\.svg$/, name: 'svg', icon: 'i-catppuccin:svg' },
44
+ { match: /\.(?:png|jpe?g|gif|webp|avif|ico)$/, name: 'image', icon: 'i-catppuccin:image' },
45
+ { match: /\.(?:woff2?|ttf|otf|eot)$/, name: 'font', icon: 'i-catppuccin:font' },
46
+ { match: /\.wasm$/, name: 'wasm', icon: 'i-catppuccin:webassembly' },
47
+ { match: /package\.json$/, name: 'npm', icon: 'i-catppuccin:npm' },
48
+ { match: /\.(?:test|spec)\.[cm]?[jt]sx?$/, name: 'test', icon: 'i-catppuccin:typescript-test' },
49
+ ]
50
+
51
+ const FALLBACK_ICON = 'i-catppuccin:file'
52
+
53
+ /**
54
+ * Strip a trailing query and/or hash from a module id.
55
+ *
56
+ * Removes everything from the first `?` or `#` onward, so build-tool suffixes
57
+ * like `?vue&type=script` or `?v=123` don't defeat extension matching.
58
+ *
59
+ * @param id - The module id to clean.
60
+ * @returns The id with any query/hash removed.
61
+ *
62
+ * @example
63
+ * stripModuleQuery('/src/App.vue?vue&type=script') // → '/src/App.vue'
64
+ */
65
+ export function stripModuleQuery(id: string): string {
66
+ return id.replace(/[?#].*$/, '')
67
+ }
68
+
69
+ /**
70
+ * Resolve `{ name, icon }` for a path. Returns the fallback when nothing matches.
71
+ *
72
+ * Strips any query/hash, then returns the first matching rule's metadata; if no
73
+ * rule matches, returns the generic file fallback (`i-catppuccin:file`).
74
+ *
75
+ * @param path - The file path or module id to classify.
76
+ * @param rules - Rule list to match against. Defaults to {@link defaultFileIconRules}.
77
+ * @returns The matched `{ name, icon, description? }`, or the file fallback.
78
+ *
79
+ * @example
80
+ * getFileType('/src/App.vue') // → { name: 'vue', icon: 'i-catppuccin:vue' }
81
+ * getFileType('/src/file.unknown') // → { name: 'file', icon: 'i-catppuccin:file' }
82
+ */
83
+ export function getFileType(
84
+ path: string,
85
+ rules: FileIconRule[] = defaultFileIconRules,
86
+ ): { name: string, icon: string, description?: string } {
87
+ const clean = stripModuleQuery(path)
88
+ for (const rule of rules) {
89
+ if (rule.match.test(clean))
90
+ return { name: rule.name, icon: rule.icon, description: rule.description }
91
+ }
92
+ return { name: 'file', icon: FALLBACK_ICON }
93
+ }
94
+
95
+ /**
96
+ * Convenience: just the icon class for a path.
97
+ *
98
+ * Thin wrapper over {@link getFileType} that returns only the `icon` field.
99
+ *
100
+ * @param path - The file path or module id to classify.
101
+ * @param rules - Rule list to match against. Defaults to {@link defaultFileIconRules}.
102
+ * @returns The UnoCSS icon class, or the fallback `i-catppuccin:file`.
103
+ *
104
+ * @example
105
+ * getFileIcon('/src/main.ts') // → 'i-catppuccin:typescript'
106
+ */
107
+ export function getFileIcon(path: string, rules?: FileIconRule[]): string {
108
+ return getFileType(path, rules).icon
109
+ }
110
+
111
+ // ── Folders ──────────────────────────────────────────────────────────────
112
+
113
+ const FOLDER_ICON = 'i-catppuccin:folder'
114
+ const FOLDER_OPEN_ICON = 'i-catppuccin:folder-open'
115
+
116
+ /**
117
+ * Named folder icons (catppuccin), keyed by lower-cased folder basename. Used by
118
+ * {@link getFolderIcon} so well-known directories get a recognisable glyph.
119
+ */
120
+ export const defaultFolderIconRules: Record<string, string> = {
121
+ src: 'i-catppuccin:folder-src',
122
+ dist: 'i-catppuccin:folder-dist',
123
+ node_modules: 'i-catppuccin:folder-node',
124
+ test: 'i-catppuccin:folder-test',
125
+ tests: 'i-catppuccin:folder-test',
126
+ public: 'i-catppuccin:folder-public',
127
+ components: 'i-catppuccin:folder-components',
128
+ utils: 'i-catppuccin:folder-utils',
129
+ config: 'i-catppuccin:folder-config',
130
+ assets: 'i-catppuccin:folder-images',
131
+ scripts: 'i-catppuccin:folder-scripts',
132
+ styles: 'i-catppuccin:folder-css',
133
+ }
134
+
135
+ /**
136
+ * Icon class for a directory, optionally open and optionally name-aware.
137
+ *
138
+ * Well-known folder names (`src`, `dist`, `node_modules`, …) map to a recognisable
139
+ * glyph; everything else uses the generic folder icon. When `open` is `true`, the
140
+ * generic open-folder glyph is returned.
141
+ *
142
+ * @param name - The folder basename (case-insensitive). Optional.
143
+ * @param open - Whether the folder is expanded. Defaults to `false`.
144
+ * @param rules - Named-folder lookup. Defaults to {@link defaultFolderIconRules}.
145
+ * @returns A UnoCSS icon class.
146
+ *
147
+ * @example
148
+ * getFolderIcon('src') // → 'i-catppuccin:folder-src'
149
+ * getFolderIcon('anything', true) // → 'i-catppuccin:folder-open'
150
+ */
151
+ export function getFolderIcon(
152
+ name?: string,
153
+ open = false,
154
+ rules: Record<string, string> = defaultFolderIconRules,
155
+ ): string {
156
+ if (open)
157
+ return FOLDER_OPEN_ICON
158
+ const named = name ? rules[name.toLowerCase()] : undefined
159
+ return named ?? FOLDER_ICON
160
+ }
161
+
162
+ // ── Alternate icon presets ─────────────────────────────────────────────────
163
+ // Opt-in rule lists for other icon collections. The consumer must install the
164
+ // matching `@iconify-json/*` collection and pass the preset to `getFileType` /
165
+ // the `FileIcon` component's `rules` prop.
166
+
167
+ /**
168
+ * File-icon rules targeting `@iconify-json/vscode-icons` (the Seti-style set).
169
+ * Pass to {@link getFileType}/{@link getFileIcon} or the `FileIcon` `rules` prop.
170
+ */
171
+ export const vscodeFileIconRules: FileIconRule[] = [
172
+ { match: /\.vue$/, name: 'vue', icon: 'i-vscode-icons:file-type-vue' },
173
+ { match: /\.tsx$/, name: 'tsx', icon: 'i-vscode-icons:file-type-reactts' },
174
+ { match: /\.jsx$/, name: 'jsx', icon: 'i-vscode-icons:file-type-reactjs' },
175
+ { match: /\.d\.[cm]?ts$/, name: 'dts', icon: 'i-vscode-icons:file-type-typescriptdef' },
176
+ { match: /\.[cm]?ts$/, name: 'ts', icon: 'i-vscode-icons:file-type-typescript' },
177
+ { match: /\.[cm]?js$/, name: 'js', icon: 'i-vscode-icons:file-type-js' },
178
+ { match: /\.json5?$/, name: 'json', icon: 'i-vscode-icons:file-type-json' },
179
+ { match: /\.ya?ml$/, name: 'yaml', icon: 'i-vscode-icons:file-type-yaml' },
180
+ { match: /\.toml$/, name: 'toml', icon: 'i-vscode-icons:file-type-toml' },
181
+ { match: /\.md$/, name: 'markdown', icon: 'i-vscode-icons:file-type-markdown' },
182
+ { match: /\.html?$/, name: 'html', icon: 'i-vscode-icons:file-type-html' },
183
+ { match: /\.(?:css|postcss)$/, name: 'css', icon: 'i-vscode-icons:file-type-css' },
184
+ { match: /\.s[ac]ss$/, name: 'sass', icon: 'i-vscode-icons:file-type-sass' },
185
+ { match: /\.svg$/, name: 'svg', icon: 'i-vscode-icons:file-type-svg' },
186
+ { match: /\.(?:png|jpe?g|gif|webp|avif|ico)$/, name: 'image', icon: 'i-vscode-icons:file-type-image' },
187
+ ]
188
+
189
+ /**
190
+ * Minimal monochrome file-icon rules targeting `@iconify-json/octicon` — handy
191
+ * for terminals/GitHub-style UIs where a single-color glyph reads better.
192
+ */
193
+ export const octiconFileIconRules: FileIconRule[] = [
194
+ { match: /\.json5?$/, name: 'json', icon: 'i-octicon:file-code-16' },
195
+ { match: /\.md$/, name: 'markdown', icon: 'i-octicon:markdown-16' },
196
+ { match: /\.(?:png|jpe?g|gif|webp|avif|svg|ico)$/, name: 'image', icon: 'i-octicon:image-16' },
197
+ { match: /\.(?:zip|tar|gz|tgz)$/, name: 'archive', icon: 'i-octicon:file-zip-16' },
198
+ { match: /\.[cm]?[jt]sx?$/, name: 'code', icon: 'i-octicon:file-code-16' },
199
+ { match: /./, name: 'file', icon: 'i-octicon:file-16' },
200
+ ]
package/utils/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export * from './color'
2
+
3
+ export * from './contrast'
4
+ export * from './format'
5
+ export * from './icon'
6
+ export * from './keybinding'
7
+ export * from './misc'
8
+ export * from './path'
9
+ export * from './semver'
10
+ export * from './tree'
11
+ // Generic helpers come from @antfu/utils (re-exported so `@antfu/design/utils`
12
+ // stays a one-stop import), so we don't reimplement or ship our own.
13
+ export { type Arrayable, clamp, toArray } from '@antfu/utils'