@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,68 @@
1
+ import type { DynamicShortcut, StaticShortcutMap } from '@unocss/core'
2
+
3
+ /**
4
+ * Build the semantic shortcut map. Written **base-agnostic** (uses only
5
+ * `/opacity` modifiers + hex-with-alpha, never colon-opacity) so it resolves
6
+ * identically under Wind4, Wind3 and Mini — the `shared-shortcuts.ts` from
7
+ * `@vitejs/devtools-ui` is the proven template this generalizes.
8
+ *
9
+ * `db` is the configurable near-black for dark surfaces.
10
+ */
11
+ export function buildShortcuts(db: string): (StaticShortcutMap | DynamicShortcut)[] {
12
+ return [
13
+ {
14
+ // ── Text ──────────────────────────────────────────────────────────
15
+ 'color-base': 'color-neutral-800 dark:color-neutral-200',
16
+ 'color-muted': 'color-neutral-600 dark:color-neutral-400',
17
+ 'color-faint': 'color-neutral-500 dark:color-neutral-500',
18
+ 'color-active': 'color-primary-600 dark:color-primary-300',
19
+
20
+ // ── Surfaces ──────────────────────────────────────────────────────
21
+ 'bg-base': `bg-white dark:bg-${db}`,
22
+ 'bg-secondary': 'bg-#f5f5f5 dark:bg-#1a1a1a',
23
+ 'bg-active': 'bg-#8881',
24
+ 'bg-hover': 'bg-primary/5',
25
+ 'bg-code': 'bg-gray-500/5',
26
+ 'bg-tooltip': `bg-white/75 dark:bg-${db}/75 backdrop-blur-8`,
27
+ 'bg-gradient-more': `bg-gradient-to-t from-white via-white/80 to-white/0 dark:from-${db} dark:via-${db}/80 dark:to-${db}/0`,
28
+
29
+ // ── Borders / rings ───────────────────────────────────────────────
30
+ 'border-base': 'border-#8882',
31
+ 'border-mute': 'border-#8881',
32
+ 'border-active': 'border-primary-600/25 dark:border-primary-400/25',
33
+ 'ring-base': 'ring-#8882',
34
+
35
+ // ── Opacity ───────────────────────────────────────────────────────
36
+ 'op-fade': 'op65 dark:op55',
37
+ 'op-mute': 'op30 dark:op25',
38
+
39
+ // ── Buttons ───────────────────────────────────────────────────────
40
+ 'btn-action': 'border border-base rounded flex gap-2 items-center px2 py1 op75 hover:op100 hover:bg-active transition disabled:pointer-events-none disabled:op30! outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40',
41
+ 'btn-action-sm': 'btn-action text-sm',
42
+ 'btn-action-active': 'color-active border-active! bg-active op100!',
43
+ 'btn-icon': 'w-9 h-9 rounded-full op-fade hover:op100 hover:bg-active transition flex items-center justify-center disabled:pointer-events-none disabled:op30 outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40',
44
+ 'btn-icon-compact': 'w-6 h-6 rounded op-fade hover:op100 hover:bg-active transition flex items-center justify-center disabled:pointer-events-none disabled:op30 outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40',
45
+ 'btn-primary': 'px3 py1.5 rounded flex gap-2 items-center bg-primary-500 hover:bg-primary-600 text-white transition disabled:op50 disabled:pointer-events-none outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40',
46
+
47
+ // ── Badges ────────────────────────────────────────────────────────
48
+ 'badge': 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-xs font-medium leading-none',
49
+ 'badge-active': 'badge bg-active color-active',
50
+ 'badge-muted': 'badge bg-#8881 color-muted',
51
+
52
+ // ── Type sizes (base-agnostic) ────────────────────────────────────
53
+ 'text-micro': 'text-[10px] leading-[1.4]',
54
+ 'text-mini': 'text-[11px] leading-[1.45]',
55
+ 'text-compact': 'text-[12px] leading-[1.5]',
56
+
57
+ // ── Named z-index layers (ascending) ──────────────────────────────
58
+ 'z-nav': 'z-[30]',
59
+ 'z-dropdown': 'z-[40]',
60
+ 'z-tooltip': 'z-[45]',
61
+ 'z-toast': 'z-[50]',
62
+ 'z-modal-backdrop': 'z-[60]',
63
+ 'z-modal-content': 'z-[70]',
64
+ 'z-drawer-backdrop': 'z-[80]',
65
+ 'z-drawer-content': 'z-[90]',
66
+ },
67
+ ]
68
+ }
package/utils/color.ts ADDED
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Deterministic string → HSL color utilities.
3
+ *
4
+ * Hue-based so the same string always maps to the same hue, with saturation /
5
+ * lightness chosen to "adapt better contrast in light/dark mode" (the note from
6
+ * config-inspector's color composable). Pure and framework-agnostic — the
7
+ * dark-mode flag is an explicit argument so these stay unit-testable without a
8
+ * DOM. Vue callers pass the scheme flag (e.g. `colorScheme === 'dark'`).
9
+ *
10
+ * Color-space manipulation (the OKLCH `labelStyle`) is delegated to colorjs.io.
11
+ */
12
+ import Color from 'colorjs.io'
13
+
14
+ /**
15
+ * Build an `hsla()` string for a given hue, dark-aware.
16
+ *
17
+ * Saturation and lightness are chosen per scheme (65%/40% in light mode, 50%/60%
18
+ * in dark) so the resulting color keeps reasonable contrast against the page.
19
+ *
20
+ * @param hue - The hue in degrees (0–360).
21
+ * @param opacity - The alpha channel, a number or CSS string. Defaults to `1`.
22
+ * @param dark - Whether to use the dark-mode saturation/lightness. Defaults to `false`.
23
+ * @returns An `hsla(...)` CSS color string.
24
+ *
25
+ * @example
26
+ * getHsla(180) // → 'hsla(180, 65%, 40%, 1)'
27
+ * getHsla(180, 0.5) // → 'hsla(180, 65%, 40%, 0.5)'
28
+ * getHsla(180, 1, true) // → 'hsla(180, 50%, 60%, 1)'
29
+ */
30
+ export function getHsla(
31
+ hue: number,
32
+ opacity: number | string = 1,
33
+ dark = false,
34
+ ): string {
35
+ const saturation = dark ? 50 : 65
36
+ const lightness = dark ? 60 : 40
37
+ return `hsla(${hue}, ${saturation}%, ${lightness}%, ${opacity})`
38
+ }
39
+
40
+ /**
41
+ * Map an arbitrary string to a stable hue and return an `hsla()` color.
42
+ *
43
+ * Uses a deterministic string hash folded into the 0–360 hue range, so the same
44
+ * input always yields the same color across runs and machines.
45
+ *
46
+ * @param name - The string to derive a hue from.
47
+ * @param opacity - The alpha channel, a number or CSS string. Defaults to `1`.
48
+ * @param dark - Whether to use the dark-mode saturation/lightness. Defaults to `false`.
49
+ * @returns A deterministic `hsla(...)` CSS color string for the input.
50
+ *
51
+ * @example
52
+ * getHashColorFromString('Vite') === getHashColorFromString('Vite') // → true (deterministic)
53
+ * getHashColorFromString('Vite') !== getHashColorFromString('DevTools') // → true
54
+ */
55
+ export function getHashColorFromString(
56
+ name: string,
57
+ opacity: number | string = 1,
58
+ dark = false,
59
+ ): string {
60
+ let hash = 0
61
+ for (let i = 0; i < name.length; i++)
62
+ hash = name.charCodeAt(i) + ((hash << 5) - hash)
63
+ const h = ((hash % 360) + 360) % 360
64
+ return getHsla(h, opacity, dark)
65
+ }
66
+
67
+ /**
68
+ * Curated brand hues (in degrees) for well-known ecosystems, so common
69
+ * package/plugin names get an on-brand color instead of an arbitrary hash.
70
+ * Overridable by callers via {@link getPluginColor}'s `map` argument.
71
+ *
72
+ * @example
73
+ * defaultBrandHues.vue // → 153
74
+ * defaultBrandHues.react // → 193
75
+ */
76
+ export const defaultBrandHues: Record<string, number> = {
77
+ vue: 153,
78
+ nuxt: 153,
79
+ vite: 265,
80
+ react: 193,
81
+ preact: 280,
82
+ solid: 217,
83
+ svelte: 15,
84
+ angular: 348,
85
+ qwik: 265,
86
+ lit: 210,
87
+ astro: 270,
88
+ remix: 220,
89
+ next: 220,
90
+ node: 120,
91
+ deno: 160,
92
+ bun: 45,
93
+ npm: 0,
94
+ pnpm: 35,
95
+ yarn: 200,
96
+ ts: 211,
97
+ typescript: 211,
98
+ js: 47,
99
+ javascript: 47,
100
+ unocss: 190,
101
+ tailwind: 198,
102
+ rollup: 12,
103
+ rolldown: 25,
104
+ webpack: 200,
105
+ esbuild: 49,
106
+ vitest: 120,
107
+ jest: 340,
108
+ eslint: 265,
109
+ prettier: 330,
110
+ electron: 200,
111
+ tauri: 200,
112
+ }
113
+
114
+ export interface LabelStyle {
115
+ color: string
116
+ background: string
117
+ borderColor: string
118
+ }
119
+
120
+ const labelCache = new Map<string, LabelStyle>()
121
+
122
+ function oklchToHex(l: number, c: number, h: number): string {
123
+ return new Color('oklch', [l, c, h]).to('srgb').toGamut({ space: 'srgb' }).toString({ format: 'hex', collapse: false })
124
+ }
125
+
126
+ /**
127
+ * Contrast-aware foreground / background / border for a label from a base color,
128
+ * dark-aware. Uses OKLCH (via colorjs.io) for perceptually even results — the
129
+ * chroma is clamped so arbitrary inputs never look garish — and caches by
130
+ * `color|scheme`. (The OKLCH lightness/chroma stops match ghfs's `labelStyle`.)
131
+ *
132
+ * @param input - The base color as any CSS color string.
133
+ * @param dark - Whether to produce the dark-mode variant. Defaults to `false`.
134
+ * @returns A {@link LabelStyle} with hex `color`, `background` and `borderColor`.
135
+ *
136
+ * @example
137
+ * labelStyle('#d73a4a') // → { color: '#…', background: '#…', borderColor: '#…' } (light)
138
+ * labelStyle('#d73a4a', true) // → the dark-mode variant
139
+ */
140
+ export function labelStyle(input: string, dark = false): LabelStyle {
141
+ const key = `${input}|${dark ? 'd' : 'l'}`
142
+ const cached = labelCache.get(key)
143
+ if (cached)
144
+ return cached
145
+
146
+ const [, rawChroma, rawHue] = new Color(input).to('oklch').coords
147
+ const h = rawHue == null || !Number.isFinite(rawHue) ? 0 : rawHue
148
+ const c = Math.min(rawChroma == null || !Number.isFinite(rawChroma) ? 0 : rawChroma, 0.18)
149
+
150
+ const style: LabelStyle = dark
151
+ ? {
152
+ color: oklchToHex(0.80, c, h),
153
+ background: oklchToHex(0.22, c * 0.5, h),
154
+ borderColor: oklchToHex(0.38, c * 0.7, h),
155
+ }
156
+ : {
157
+ color: oklchToHex(0.42, c, h),
158
+ background: oklchToHex(0.94, c * 0.35, h),
159
+ borderColor: oklchToHex(0.82, c * 0.5, h),
160
+ }
161
+
162
+ labelCache.set(key, style)
163
+ return style
164
+ }
165
+
166
+ /**
167
+ * The plugin prefixes {@link stripPluginPrefix} strips by default. Includes the
168
+ * common build-tool plugin conventions plus the `vite:`/`rollup:`-style internal
169
+ * namespace prefixes used by core plugins.
170
+ */
171
+ export const defaultPluginPrefixes: readonly string[] = [
172
+ 'vite-plugin-',
173
+ 'rollup-plugin-',
174
+ 'webpack-plugin-',
175
+ 'unplugin-',
176
+ 'nuxt-',
177
+ 'eslint-plugin-',
178
+ 'postcss-',
179
+ 'vite:',
180
+ 'rollup:',
181
+ 'rolldown:',
182
+ 'webpack:',
183
+ '__',
184
+ ]
185
+
186
+ function buildPrefixRe(prefixes: readonly string[]): RegExp {
187
+ const alt = prefixes.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
188
+ return new RegExp(`^(?:@[^/]+\\/)?(?:${alt})`)
189
+ }
190
+
191
+ const DEFAULT_PREFIX_RE = buildPrefixRe(defaultPluginPrefixes)
192
+
193
+ /**
194
+ * Strip common tool prefixes/scopes to get the meaningful part of a name.
195
+ *
196
+ * Removes a recognised plugin prefix (`vite-plugin-`, `rollup-plugin-`,
197
+ * `unplugin-`, `nuxt-`, `eslint-plugin-`, `postcss-`, the `vite:`/`rollup:`
198
+ * internal namespaces, …, optionally scoped) and any remaining leading
199
+ * `@scope/`. Pass a custom prefix list or `RegExp` to override the defaults.
200
+ *
201
+ * @param name - The package or plugin name to strip.
202
+ * @param prefixes - Custom prefix list or anchored `RegExp`. Defaults to {@link defaultPluginPrefixes}.
203
+ * @returns The meaningful suffix of the name.
204
+ *
205
+ * @example
206
+ * stripPluginPrefix('vite-plugin-inspect') // → 'inspect'
207
+ * stripPluginPrefix('@antfu/eslint-config') // → 'eslint-config'
208
+ * stripPluginPrefix('vite:import-analysis') // → 'import-analysis'
209
+ */
210
+ export function stripPluginPrefix(name: string, prefixes?: readonly string[] | RegExp): string {
211
+ const re = prefixes == null
212
+ ? DEFAULT_PREFIX_RE
213
+ : prefixes instanceof RegExp ? prefixes : buildPrefixRe(prefixes)
214
+ return name.replace(re, '').replace(/^@[^/]+\//, '')
215
+ }
216
+
217
+ /**
218
+ * Color a package/plugin name: brand hue when recognised, deterministic hash
219
+ * otherwise.
220
+ *
221
+ * The name is stripped of plugin prefixes and lower-cased, then matched against
222
+ * `map` (exact, or as a `key-` / `key.` prefix). On a hit the brand hue is used;
223
+ * otherwise it falls back to {@link getHashColorFromString} on the original name.
224
+ *
225
+ * @param name - The package or plugin name to color.
226
+ * @param opacity - The alpha channel, a number or CSS string. Defaults to `1`.
227
+ * @param dark - Whether to use the dark-mode variant. Defaults to `false`.
228
+ * @param map - Extra brand hues, **merged over** {@link defaultBrandHues} (pass only your additions/overrides).
229
+ * @returns An `hsla(...)` CSS color string.
230
+ *
231
+ * @example
232
+ * getPluginColor('vue') // → 'hsla(153, 65%, 40%, 1)'
233
+ * getPluginColor('react') // → 'hsla(193, 65%, 40%, 1)'
234
+ * getPluginColor('vite-plugin-vue') // → 'hsla(153, 65%, 40%, 1)' (matched by suffix)
235
+ * getPluginColor('acme', 1, false, { acme: 300 }) // → custom hue merged over the defaults
236
+ */
237
+ export function getPluginColor(
238
+ name: string,
239
+ opacity: number | string = 1,
240
+ dark = false,
241
+ map: Record<string, number> = {},
242
+ ): string {
243
+ const hues = map === defaultBrandHues ? defaultBrandHues : { ...defaultBrandHues, ...map }
244
+ const bare = stripPluginPrefix(name).toLowerCase()
245
+ const key = Object.keys(hues).find(k => bare === k || bare.startsWith(`${k}-`) || bare.startsWith(`${k}.`))
246
+ if (key != null)
247
+ return getHsla(hues[key], opacity, dark)
248
+ return getHashColorFromString(name, opacity, dark)
249
+ }
250
+
251
+ // ── Color math (colorjs.io) ───────────────────────────────────────────────
252
+
253
+ /**
254
+ * Convert any CSS color to a `#rrggbb[aa]` hex string (gamut-mapped to sRGB).
255
+ *
256
+ * @param input - Any CSS color string (hex, `rgb()`, `hsl()`, named, `oklch()`, …).
257
+ * @returns A sRGB hex string.
258
+ *
259
+ * @example
260
+ * toHex('hsl(0, 100%, 50%)') // → '#ff0000'
261
+ */
262
+ export function toHex(input: string): string {
263
+ return new Color(input).to('srgb').toGamut({ space: 'srgb' }).toString({ format: 'hex', collapse: false })
264
+ }
265
+
266
+ /**
267
+ * Lighten a color by an OKLCH lightness delta (perceptually even).
268
+ *
269
+ * @param input - The base color as any CSS color string.
270
+ * @param amount - Lightness delta in the 0–1 OKLCH range. Defaults to `0.1`.
271
+ * @returns The lightened color as a sRGB hex string.
272
+ *
273
+ * @example
274
+ * lighten('#336699', 0.1) // → a lighter blue
275
+ */
276
+ export function lighten(input: string, amount = 0.1): string {
277
+ const c = new Color(input).to('oklch')
278
+ c.l = Math.max(0, Math.min(1, (c.l ?? 0) + amount))
279
+ return c.to('srgb').toGamut({ space: 'srgb' }).toString({ format: 'hex', collapse: false })
280
+ }
281
+
282
+ /**
283
+ * Darken a color by an OKLCH lightness delta (perceptually even).
284
+ *
285
+ * @param input - The base color as any CSS color string.
286
+ * @param amount - Lightness delta in the 0–1 OKLCH range. Defaults to `0.1`.
287
+ * @returns The darkened color as a sRGB hex string.
288
+ *
289
+ * @example
290
+ * darken('#336699', 0.1) // → a darker blue
291
+ */
292
+ export function darken(input: string, amount = 0.1): string {
293
+ return lighten(input, -amount)
294
+ }
295
+
296
+ /**
297
+ * Mix two colors in OKLCH space.
298
+ *
299
+ * @param a - The first color as any CSS color string.
300
+ * @param b - The second color as any CSS color string.
301
+ * @param weight - Weight of `b` in the mix, 0–1. Defaults to `0.5`.
302
+ * @returns The mixed color as a sRGB hex string.
303
+ *
304
+ * @example
305
+ * mix('#ff0000', '#0000ff') // → a purple midpoint
306
+ */
307
+ export function mix(a: string, b: string, weight = 0.5): string {
308
+ return (Color.mix(new Color(a), new Color(b), weight, { space: 'oklch' }) as Color)
309
+ .to('srgb')
310
+ .toGamut({ space: 'srgb' })
311
+ .toString({ format: 'hex', collapse: false })
312
+ }
313
+
314
+ /**
315
+ * Set a color's alpha channel, returning an `rgb(...)`/`rgba(...)` string.
316
+ *
317
+ * @param input - The base color as any CSS color string.
318
+ * @param alpha - The alpha channel, 0–1.
319
+ * @returns The color with the given alpha, as a CSS string.
320
+ *
321
+ * @example
322
+ * withAlpha('#336699', 0.5) // → 'rgb(51 102 153 / 0.5)'
323
+ */
324
+ export function withAlpha(input: string, alpha: number): string {
325
+ const c = new Color(input).to('srgb')
326
+ c.alpha = alpha
327
+ return c.toString()
328
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * WCAG contrast helpers — relative luminance and contrast ratio — built on
3
+ * [colorjs.io](https://colorjs.io) rather than hand-rolled color math, so token
4
+ * pairs can be asserted in unit tests (e.g. `color-base` on `bg-base` meets AA
5
+ * in both themes). Complements the runnable axe-core scan in `../a11y`.
6
+ */
7
+ import Color from 'colorjs.io'
8
+
9
+ export interface RGB {
10
+ r: number
11
+ g: number
12
+ b: number
13
+ }
14
+
15
+ function toColor(input: string | RGB): Color {
16
+ return typeof input === 'string'
17
+ ? new Color(input)
18
+ : new Color('srgb', [input.r / 255, input.g / 255, input.b / 255])
19
+ }
20
+
21
+ /**
22
+ * Parse any CSS color into `{ r, g, b }` (0–255), via colorjs.io. An already
23
+ * parsed {@link RGB} is returned unchanged.
24
+ *
25
+ * @param input - A CSS color string (hex, `rgb()`, `hsl()`, named, …) or an {@link RGB}.
26
+ * @returns The color as `{ r, g, b }` with 0–255 channels.
27
+ *
28
+ * @example
29
+ * parseColor('#fff') // → { r: 255, g: 255, b: 255 }
30
+ * parseColor('#000000') // → { r: 0, g: 0, b: 0 }
31
+ * parseColor('white') // → { r: 255, g: 255, b: 255 }
32
+ */
33
+ export function parseColor(input: string | RGB): RGB {
34
+ if (typeof input !== 'string')
35
+ return input
36
+ const [r, g, b] = new Color(input).to('srgb').coords
37
+ return { r: Math.round((r ?? 0) * 255), g: Math.round((g ?? 0) * 255), b: Math.round((b ?? 0) * 255) }
38
+ }
39
+
40
+ /**
41
+ * WCAG relative luminance of a color (0 = black, 1 = white).
42
+ *
43
+ * @param color - The color as a CSS string or {@link RGB} object.
44
+ * @returns The relative luminance in the range 0–1.
45
+ *
46
+ * @example
47
+ * relativeLuminance('#000') // → 0
48
+ * relativeLuminance('#fff') // → 1
49
+ */
50
+ export function relativeLuminance(color: string | RGB): number {
51
+ return toColor(color).luminance
52
+ }
53
+
54
+ /**
55
+ * WCAG 2.1 contrast ratio between two colors (1 → 21). Argument order does not
56
+ * matter.
57
+ *
58
+ * @param a - The first color, as a CSS string or {@link RGB} object.
59
+ * @param b - The second color, as a CSS string or {@link RGB} object.
60
+ * @returns The contrast ratio, from `1` (identical) up to `21` (black on white).
61
+ *
62
+ * @example
63
+ * Math.round(contrastRatio('#000', '#fff')) // → 21
64
+ */
65
+ export function contrastRatio(a: string | RGB, b: string | RGB): number {
66
+ return toColor(a).contrast(toColor(b), 'WCAG21')
67
+ }
68
+
69
+ export type ContrastLevel = 'AA' | 'AAA'
70
+
71
+ /**
72
+ * Whether a ratio satisfies a WCAG level for normal or large text.
73
+ *
74
+ * Thresholds are 4.5 (AA) / 7 (AAA) for normal text and 3 (AA) / 4.5 (AAA) for
75
+ * large text.
76
+ *
77
+ * @param ratio - The contrast ratio to test (see {@link contrastRatio}).
78
+ * @param level - The WCAG conformance level, `'AA'` or `'AAA'`. Defaults to `'AA'`.
79
+ * @param large - Whether to use the relaxed large-text thresholds. Defaults to `false`.
80
+ * @returns `true` if the ratio meets or exceeds the threshold.
81
+ *
82
+ * @example
83
+ * meetsContrast(4.5) // → true
84
+ * meetsContrast(4.49) // → false
85
+ */
86
+ export function meetsContrast(ratio: number, level: ContrastLevel = 'AA', large = false): boolean {
87
+ const thresholds = { AA: large ? 3 : 4.5, AAA: large ? 4.5 : 7 }
88
+ return ratio >= thresholds[level]
89
+ }
90
+
91
+ export interface ContrastResult {
92
+ ratio: number
93
+ AA: boolean
94
+ AALarge: boolean
95
+ AAA: boolean
96
+ AAALarge: boolean
97
+ }
98
+
99
+ /**
100
+ * Full contrast report between a foreground and background color.
101
+ *
102
+ * @param foreground - The foreground color, as a CSS string or {@link RGB} object.
103
+ * @param background - The background color, as a CSS string or {@link RGB} object.
104
+ * @returns A {@link ContrastResult} with the rounded `ratio` and per-level booleans.
105
+ *
106
+ * @example
107
+ * checkContrast('#525252', '#fff').AA // → true
108
+ */
109
+ export function checkContrast(foreground: string | RGB, background: string | RGB): ContrastResult {
110
+ const ratio = Math.round(contrastRatio(foreground, background) * 100) / 100
111
+ return {
112
+ ratio,
113
+ AA: meetsContrast(ratio, 'AA'),
114
+ AALarge: meetsContrast(ratio, 'AA', true),
115
+ AAA: meetsContrast(ratio, 'AAA'),
116
+ AAALarge: meetsContrast(ratio, 'AAA', true),
117
+ }
118
+ }