@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anthony Fu <https://github.com/antfu>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # @antfu/design
2
+
3
+ > A customizable, **composable** design system for devtools-style Vue apps: a
4
+ > UnoCSS preset (`presetAnthonyDesign`), a set of Vue primitives, a ground-up
5
+ > design skill, and a color-contrast a11y check. Something in between a component
6
+ > library and shadcn.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pnpm add @antfu/design unocss vue
12
+ ```
13
+
14
+ ## Quick start
15
+
16
+ ```ts
17
+ // uno.config.ts
18
+ import { presetAnthonyDesign } from '@antfu/design/unocss'
19
+ import { defineConfig, presetIcons, presetWebFonts, presetWind4 } from 'unocss'
20
+
21
+ export default defineConfig({
22
+ presets: [
23
+ presetAnthonyDesign({ primary: '#49833E' }),
24
+ presetWind4(), // a base preset is required — bring your own
25
+ presetIcons(),
26
+ presetWebFonts({ fonts: { sans: 'DM Sans', mono: 'DM Mono' } }),
27
+ ],
28
+ content: { pipeline: { include: [/@antfu\/design/] } },
29
+ })
30
+ ```
31
+
32
+ ```ts
33
+ // Components are imported by full path (no barrel) — categorized and prefixed:
34
+ import ActionButton from '@antfu/design/components/Action/ActionButton.vue'
35
+ import DisplayBadge from '@antfu/design/components/Display/DisplayBadge.vue'
36
+ import OverlayModal from '@antfu/design/components/Overlay/OverlayModal.vue'
37
+ import '@antfu/design/styles.css'
38
+ ```
39
+
40
+ The package ships **raw `.ts` / `.vue` source** (no bundling) — your build
41
+ compiles it. Point UnoCSS at the package so the components' classes are
42
+ generated (`content: { pipeline: { include: [/@antfu\/design/] } }`).
43
+
44
+ It's a **single** preset that is **not self-contained**: it contributes only the
45
+ antfu design layer (theme tokens, semantic shortcuts, dynamic rules, severity).
46
+ You compose the base preset, icons, fonts and reset yourself.
47
+
48
+ ## Exports
49
+
50
+ | Subpath | What |
51
+ |---|---|
52
+ | `./components/*` | one readable `.vue` per component (e.g. `./components/Display/DisplayBadge.vue`) |
53
+ | `./unocss` | the single `presetAnthonyDesign` preset |
54
+ | `./utils` | color, format, path, semver, contrast, keybinding helpers (pure, stateless) |
55
+ | `./composables/*` | Vue helpers: `colorScheme` (opt-in scheme context) and `toast` (`useToast` queue) |
56
+ | `./a11y` | programmatic color-contrast scan |
57
+ | `./styles.css`, `./styles/*` | all styles, or per-concern files |
58
+ | `./splitpanes.d.ts` | opt-in fallback types for older `splitpanes` (v4.1.2+ ships its own) |
59
+
60
+ > The package is **stateless** — no dark-mode/clipboard/toast state. Components
61
+ > that vary by scheme take a `colorScheme` prop; toasts are controlled. Use VueUse
62
+ > directly for state.
63
+ >
64
+ > Two **opt-in** helpers reduce the boilerplate without adding global state:
65
+ > `provideColorScheme(() => isDark ? 'dark' : 'light')` (from `@antfu/design/composables/colorScheme`)
66
+ > lets scheme-aware components inherit the scheme instead of threading a prop, and
67
+ > `useToast()` (from `@antfu/design/composables/toast`) owns a toast queue for
68
+ > `FeedbackToasts`. Both only read state you own.
69
+
70
+ ## Accessibility
71
+
72
+ A color-contrast scan (axe-core + Playwright) runs a URL in light **and** dark
73
+ mode. Use it programmatically:
74
+
75
+ ```ts
76
+ import { formatContrastReport, runContrastScan } from '@antfu/design/a11y'
77
+
78
+ const result = await runContrastScan({ url: 'http://localhost:6006/iframe.html' })
79
+ console.log(formatContrastReport(result))
80
+ ```
81
+
82
+ …or run the bundled script with `tsx`:
83
+
84
+ ```bash
85
+ tsx node_modules/@antfu/design/a11y/cli.ts http://localhost:6006/iframe.html
86
+ ```
87
+
88
+ ## Tokens
89
+
90
+ <!-- TOKENS:START -->
91
+ ### Semantic & composite shortcuts
92
+
93
+ | Token | Expands to |
94
+ |---|---|
95
+ | `color-base` | `color-neutral-800 dark:color-neutral-200` |
96
+ | `color-muted` | `color-neutral-600 dark:color-neutral-400` |
97
+ | `color-faint` | `color-neutral-500 dark:color-neutral-500` |
98
+ | `color-active` | `color-primary-600 dark:color-primary-300` |
99
+ | `bg-base` | `bg-white dark:bg-#111` |
100
+ | `bg-secondary` | `bg-#f5f5f5 dark:bg-#1a1a1a` |
101
+ | `bg-active` | `bg-#8881` |
102
+ | `bg-hover` | `bg-primary/5` |
103
+ | `bg-code` | `bg-gray-500/5` |
104
+ | `bg-tooltip` | `bg-white/75 dark:bg-#111/75 backdrop-blur-8` |
105
+ | `bg-gradient-more` | `bg-gradient-to-t from-white via-white/80 to-white/0 dark:from-#111 dark:via-#111/80 dark:to-#111/0` |
106
+ | `border-base` | `border-#8882` |
107
+ | `border-mute` | `border-#8881` |
108
+ | `border-active` | `border-primary-600/25 dark:border-primary-400/25` |
109
+ | `ring-base` | `ring-#8882` |
110
+ | `op-fade` | `op65 dark:op55` |
111
+ | `op-mute` | `op30 dark:op25` |
112
+ | `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` |
113
+ | `btn-action-sm` | `btn-action text-sm` |
114
+ | `btn-action-active` | `color-active border-active! bg-active op100!` |
115
+ | `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` |
116
+ | `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` |
117
+ | `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` |
118
+ | `badge` | `inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-xs font-medium leading-none` |
119
+ | `badge-active` | `badge bg-active color-active` |
120
+ | `badge-muted` | `badge bg-#8881 color-muted` |
121
+
122
+ ### Severity scale
123
+
124
+ | Token | Expands to |
125
+ |---|---|
126
+ | `color-scale-neutral` | `text-gray-700 dark:text-gray-300` |
127
+ | `color-scale-low` | `text-lime-700 dark:text-lime-300 dark:saturate-75` |
128
+ | `color-scale-medium` | `text-amber-700 dark:text-amber-300 dark:saturate-90` |
129
+ | `color-scale-high` | `text-orange-700 dark:text-orange-300` |
130
+ | `color-scale-critical` | `text-red-700 dark:text-red-300` |
131
+
132
+ ### Type sizes
133
+
134
+ | Token | Expands to |
135
+ |---|---|
136
+ | `text-micro` | `text-[10px] leading-[1.4]` |
137
+ | `text-mini` | `text-[11px] leading-[1.45]` |
138
+ | `text-compact` | `text-[12px] leading-[1.5]` |
139
+
140
+ ### Named z-index layers
141
+
142
+ | Token | Expands to |
143
+ |---|---|
144
+ | `z-nav` | `z-[30]` |
145
+ | `z-dropdown` | `z-[40]` |
146
+ | `z-tooltip` | `z-[45]` |
147
+ | `z-toast` | `z-[50]` |
148
+ | `z-modal-backdrop` | `z-[60]` |
149
+ | `z-modal-content` | `z-[70]` |
150
+ | `z-drawer-backdrop` | `z-[80]` |
151
+ | `z-drawer-content` | `z-[90]` |
152
+
153
+ ### Dynamic
154
+
155
+ | Token | Expands to |
156
+ |---|---|
157
+ | `badge-color-<name>` | a chip tinted by any palette color name (dark-aware) |
158
+ | `bg-glass` / `bg-glass:<n>` | translucent surface + `backdrop-blur` |
159
+ | `bg-dots` / `bg-dots-<n>` | radial dot-grid background, variable cell size in px (default 16) |
160
+ | `bg-grid` / `bg-grid-<n>` | crosshatch grid-lines background, variable cell size in px (default 16) |
161
+ <!-- TOKENS:END -->
162
+
163
+ ## License
164
+
165
+ [MIT](./LICENSE) © [Anthony Fu](https://github.com/antfu)
package/a11y/cli.ts ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ import type { ColorMode, ContrastScanOptions } from './scan'
3
+ import process from 'node:process'
4
+ import { formatContrastReport, runContrastScan } from './scan'
5
+
6
+ const HELP = `antfu-design-a11y — color-contrast scan (light + dark)
7
+
8
+ Usage:
9
+ antfu-design-a11y <url> [options]
10
+
11
+ Options:
12
+ --mode <light|dark> Scan a single mode (repeatable). Default: both.
13
+ --exclude <selector> Extra CSS selector to skip (repeatable).
14
+ --key <storageKey> localStorage key for color scheme. Default: antfu-design-color-scheme
15
+ --headed Run with a visible browser.
16
+ -h, --help Show this help.
17
+
18
+ Exits non-zero when any color-contrast violation is found.`
19
+
20
+ function parseArgs(argv: string[]): { options: ContrastScanOptions, help: boolean } {
21
+ let url = ''
22
+ const modes: ColorMode[] = []
23
+ const exclude: string[] = []
24
+ let colorSchemeStorageKey: string | undefined
25
+ let headless = true
26
+ let help = false
27
+
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const arg = argv[i]
30
+ if (arg === '-h' || arg === '--help')
31
+ help = true
32
+ else if (arg === '--mode')
33
+ modes.push(argv[++i] as ColorMode)
34
+ else if (arg === '--exclude')
35
+ exclude.push(argv[++i])
36
+ else if (arg === '--key')
37
+ colorSchemeStorageKey = argv[++i]
38
+ else if (arg === '--headed')
39
+ headless = false
40
+ else if (!arg.startsWith('-') && !url)
41
+ url = arg
42
+ }
43
+
44
+ return {
45
+ help,
46
+ options: {
47
+ url,
48
+ modes: modes.length ? modes : undefined,
49
+ exclude: exclude.length ? [...['.shiki', '[data-a11y-skip]'], ...exclude] : undefined,
50
+ colorSchemeStorageKey,
51
+ headless,
52
+ },
53
+ }
54
+ }
55
+
56
+ async function main(): Promise<void> {
57
+ const { options, help } = parseArgs(process.argv.slice(2))
58
+
59
+ if (help || !options.url) {
60
+ console.log(HELP)
61
+ process.exit(help ? 0 : 1)
62
+ }
63
+
64
+ const result = await runContrastScan(options)
65
+
66
+ console.log(formatContrastReport(result))
67
+ process.exit(result.passed ? 0 : 1)
68
+ }
69
+
70
+ main().catch((error) => {
71
+ console.error(error)
72
+ process.exit(1)
73
+ })
package/a11y/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ // WCAG contrast math is browser-free and reusable for unit-testing token pairs.
2
+ export {
3
+ checkContrast,
4
+ type ContrastLevel,
5
+ contrastRatio,
6
+ type ContrastResult,
7
+ meetsContrast,
8
+ parseColor,
9
+ relativeLuminance,
10
+ type RGB,
11
+ } from '../utils/contrast'
12
+
13
+ export * from './scan'
package/a11y/scan.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Programmatic color-contrast scan, adapted from config-inspector's
3
+ * `a11y.spec.ts`: launch a URL, toggle light **and** dark mode, run axe-core's
4
+ * `color-contrast` rule, and collect violations. `@axe-core/playwright` and
5
+ * `playwright` are optional peers, imported lazily so the rest of the package
6
+ * stays usable without them.
7
+ */
8
+
9
+ export type ColorMode = 'light' | 'dark'
10
+
11
+ export interface ContrastScanOptions {
12
+ /** Target URL (an app or Storybook iframe). */
13
+ url: string
14
+ /** Modes to scan. Default `['light', 'dark']`. */
15
+ modes?: ColorMode[]
16
+ /** CSS selectors to exclude (e.g. code blocks). */
17
+ exclude?: string[]
18
+ /** localStorage key the app reads its color scheme from. */
19
+ colorSchemeStorageKey?: string
20
+ /** Stored value representing light mode. */
21
+ lightValue?: string
22
+ /** Stored value representing dark mode. */
23
+ darkValue?: string
24
+ /** Headless browser. Default `true`. */
25
+ headless?: boolean
26
+ /** Max ms to wait for the `dark` class to settle. Default `5000`. */
27
+ timeout?: number
28
+ }
29
+
30
+ export interface ContrastViolationNode {
31
+ target: string[]
32
+ failureSummary?: string
33
+ }
34
+
35
+ export interface ContrastViolation {
36
+ id: string
37
+ impact?: string | null
38
+ help: string
39
+ mode: ColorMode
40
+ nodes: ContrastViolationNode[]
41
+ }
42
+
43
+ export interface ContrastScanResult {
44
+ passed: boolean
45
+ violations: ContrastViolation[]
46
+ }
47
+
48
+ const DEFAULT_EXCLUDE = ['.shiki', '[data-a11y-skip]']
49
+
50
+ /** Run the contrast scan and return structured violations. */
51
+ export async function runContrastScan(options: ContrastScanOptions): Promise<ContrastScanResult> {
52
+ const {
53
+ url,
54
+ modes = ['light', 'dark'],
55
+ exclude = DEFAULT_EXCLUDE,
56
+ colorSchemeStorageKey = 'antfu-design-color-scheme',
57
+ lightValue = 'light',
58
+ darkValue = 'dark',
59
+ headless = true,
60
+ timeout = 5000,
61
+ } = options
62
+
63
+ const { chromium } = await import('playwright')
64
+ const { default: AxeBuilder } = await import('@axe-core/playwright')
65
+
66
+ const browser = await chromium.launch({ headless })
67
+ const violations: ContrastViolation[] = []
68
+
69
+ try {
70
+ for (const mode of modes) {
71
+ const context = await browser.newContext()
72
+ const page = await context.newPage()
73
+ await page.addInitScript(
74
+ ([key, value]) => {
75
+ try {
76
+ localStorage.setItem(key, value)
77
+ }
78
+ catch {}
79
+ },
80
+ [colorSchemeStorageKey, mode === 'dark' ? darkValue : lightValue] as [string, string],
81
+ )
82
+ await page.goto(url, { waitUntil: 'networkidle' })
83
+ await page.waitForFunction(
84
+ m => document.documentElement.classList.contains('dark') === (m === 'dark'),
85
+ mode,
86
+ { timeout },
87
+ ).catch(() => {})
88
+
89
+ let builder = new AxeBuilder({ page }).withRules(['color-contrast'])
90
+ for (const sel of exclude)
91
+ builder = builder.exclude(sel)
92
+
93
+ const results = await builder.analyze()
94
+ for (const v of results.violations) {
95
+ violations.push({
96
+ id: v.id,
97
+ impact: v.impact,
98
+ help: v.help,
99
+ mode,
100
+ nodes: v.nodes.map(n => ({
101
+ target: n.target.map(String),
102
+ failureSummary: n.failureSummary,
103
+ })),
104
+ })
105
+ }
106
+ await context.close()
107
+ }
108
+ }
109
+ finally {
110
+ await browser.close()
111
+ }
112
+
113
+ return { passed: violations.length === 0, violations }
114
+ }
115
+
116
+ /** Format a scan result as a readable, multi-line report. */
117
+ export function formatContrastReport(result: ContrastScanResult): string {
118
+ if (result.passed)
119
+ return '✓ No color-contrast violations found.'
120
+ const lines: string[] = [`✗ ${result.violations.length} color-contrast violation(s):`, '']
121
+ for (const v of result.violations) {
122
+ lines.push(` [${v.mode}] ${v.help}${v.impact ? ` (${v.impact})` : ''}`)
123
+ for (const node of v.nodes)
124
+ lines.push(` - ${node.target.join(' ')}`)
125
+ }
126
+ return lines.join('\n')
127
+ }
@@ -0,0 +1,56 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import ActionButton from './ActionButton.vue'
3
+
4
+ const meta = {
5
+ title: 'Action/ActionButton',
6
+ component: ActionButton,
7
+ tags: ['autodocs'],
8
+ argTypes: {
9
+ variant: { control: 'inline-radio', options: ['action', 'primary', 'text'] },
10
+ size: { control: 'inline-radio', options: ['sm', 'md'] },
11
+ },
12
+ args: { variant: 'action', size: 'md' },
13
+ } satisfies Meta<typeof ActionButton>
14
+
15
+ export default meta
16
+ type Story = StoryObj<typeof meta>
17
+
18
+ export const Action: Story = {
19
+ render: (args: Record<string, unknown>) => ({
20
+ components: { ActionButton },
21
+ setup() {
22
+ return { args }
23
+ },
24
+ template: `<ActionButton v-bind="args">Action</ActionButton>`,
25
+ }),
26
+ }
27
+
28
+ export const Primary: Story = {
29
+ render: () => ({
30
+ components: { ActionButton },
31
+ template: `<ActionButton variant="primary">Primary</ActionButton>`,
32
+ }),
33
+ }
34
+
35
+ export const Variants: Story = {
36
+ render: () => ({
37
+ components: { ActionButton },
38
+ template: `<div class="flex items-center gap-3">
39
+ <ActionButton>Action</ActionButton>
40
+ <ActionButton variant="primary">Primary</ActionButton>
41
+ <ActionButton variant="text">Text</ActionButton>
42
+ <ActionButton :loading="true">Loading</ActionButton>
43
+ <ActionButton :disabled="true">Disabled</ActionButton>
44
+ </div>`,
45
+ }),
46
+ }
47
+
48
+ export const WithIconAndSizes: Story = {
49
+ render: () => ({
50
+ components: { ActionButton },
51
+ template: `<div class="flex items-center gap-3">
52
+ <ActionButton icon="i-ph:folder" size="sm">Small</ActionButton>
53
+ <ActionButton icon="i-ph:folder" size="md">Medium</ActionButton>
54
+ </div>`,
55
+ }),
56
+ }
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import FeedbackSpinner from '../Feedback/FeedbackSpinner.vue'
4
+
5
+ const props = withDefaults(
6
+ defineProps<{
7
+ /** Render as another element/component (e.g. `RouterLink`). */
8
+ as?: string
9
+ to?: string
10
+ href?: string
11
+ icon?: string
12
+ variant?: 'action' | 'primary' | 'text'
13
+ size?: 'sm' | 'md'
14
+ loading?: boolean
15
+ disabled?: boolean
16
+ }>(),
17
+ { variant: 'action', size: 'md' },
18
+ )
19
+
20
+ const tag = computed(() => {
21
+ if (props.as)
22
+ return props.as
23
+ if (props.href != null || props.to != null)
24
+ return 'a'
25
+ return 'button'
26
+ })
27
+
28
+ const isLink = computed(() => tag.value === 'a')
29
+ const isButton = computed(() => tag.value === 'button')
30
+ const sm = computed(() => props.size === 'sm')
31
+
32
+ const variantClass = computed(() => {
33
+ if (props.variant === 'primary')
34
+ return sm.value ? 'btn-primary text-sm px-2.5! py-1!' : 'btn-primary'
35
+ if (props.variant === 'text')
36
+ return `inline-flex items-center gap-1.5 op75 hover:op100 transition${sm.value ? ' text-sm' : ''}`
37
+ return sm.value ? 'btn-action-sm' : 'btn-action'
38
+ })
39
+
40
+ const disabledState = computed(() => props.disabled || props.loading)
41
+ </script>
42
+
43
+ <template>
44
+ <component
45
+ :is="tag"
46
+ :class="[variantClass, { 'pointer-events-none op-mute': disabledState && !isButton }]"
47
+ :href="isLink ? (href ?? to) : undefined"
48
+ :to="as && to != null ? to : undefined"
49
+ :disabled="isButton ? disabledState : undefined"
50
+ :aria-disabled="disabledState || undefined"
51
+ :aria-busy="loading || undefined"
52
+ >
53
+ <FeedbackSpinner v-if="loading" size="1em" />
54
+ <span v-else-if="icon" :class="icon" aria-hidden="true" />
55
+ <slot />
56
+ </component>
57
+ </template>
@@ -0,0 +1,31 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { ref } from 'vue'
3
+ import ActionDarkToggle from './ActionDarkToggle.vue'
4
+
5
+ const meta = {
6
+ title: 'Action/ActionDarkToggle',
7
+ component: ActionDarkToggle,
8
+ tags: ['autodocs'],
9
+ } satisfies Meta<typeof ActionDarkToggle>
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof meta>
13
+
14
+ // The component is controlled: the app owns the `colorScheme` and reacts to
15
+ // `@update:color-scheme`. Here a local ref also toggles the document class.
16
+ export const Default: Story = {
17
+ render: () => ({
18
+ components: { ActionDarkToggle },
19
+ setup() {
20
+ const colorScheme = ref<'light' | 'dark'>(
21
+ document.documentElement.classList.contains('dark') ? 'dark' : 'light',
22
+ )
23
+ function onUpdate(value: 'light' | 'dark'): void {
24
+ colorScheme.value = value
25
+ document.documentElement.classList.toggle('dark', value === 'dark')
26
+ }
27
+ return { colorScheme, onUpdate }
28
+ },
29
+ template: `<ActionDarkToggle :color-scheme="colorScheme" @update:color-scheme="onUpdate" />`,
30
+ }),
31
+ }
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick } from 'vue'
3
+
4
+ const props = withDefaults(
5
+ defineProps<{
6
+ /** Current color scheme — the app owns this state. */
7
+ colorScheme?: 'light' | 'dark'
8
+ /** Disable the view-transition circular reveal. */
9
+ animated?: boolean
10
+ }>(),
11
+ { colorScheme: 'light', animated: true },
12
+ )
13
+
14
+ const emit = defineEmits<{ 'update:colorScheme': ['light' | 'dark'] }>()
15
+
16
+ const isDark = computed(() => props.colorScheme === 'dark')
17
+
18
+ function prefersReducedMotion(): boolean {
19
+ return typeof matchMedia !== 'undefined'
20
+ && matchMedia('(prefers-reduced-motion: reduce)').matches
21
+ }
22
+
23
+ function onClick(event: MouseEvent): void {
24
+ const next = isDark.value ? 'light' : 'dark'
25
+ const doc = document as Document & {
26
+ startViewTransition?: (cb: () => Promise<void> | void) => { ready: Promise<void> }
27
+ }
28
+ const canAnimate = props.animated
29
+ && typeof doc.startViewTransition === 'function'
30
+ && !prefersReducedMotion()
31
+
32
+ if (!canAnimate) {
33
+ emit('update:colorScheme', next)
34
+ return
35
+ }
36
+
37
+ const x = event.clientX
38
+ const y = event.clientY
39
+ const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))
40
+ const toDark = next === 'dark'
41
+
42
+ const transition = doc.startViewTransition!(async () => {
43
+ emit('update:colorScheme', next)
44
+ await nextTick()
45
+ })
46
+
47
+ transition.ready.then(() => {
48
+ const clip = [
49
+ `circle(0px at ${x}px ${y}px)`,
50
+ `circle(${endRadius}px at ${x}px ${y}px)`,
51
+ ]
52
+ document.documentElement.animate(
53
+ { clipPath: toDark ? clip : [...clip].reverse() },
54
+ {
55
+ duration: 400,
56
+ easing: 'ease-in',
57
+ pseudoElement: toDark ? '::view-transition-new(root)' : '::view-transition-old(root)',
58
+ },
59
+ )
60
+ })
61
+ }
62
+ </script>
63
+
64
+ <template>
65
+ <slot :is-dark="isDark" :toggle="onClick">
66
+ <button
67
+ type="button"
68
+ class="btn-icon"
69
+ :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
70
+ :aria-pressed="isDark"
71
+ @click="onClick"
72
+ >
73
+ <svg v-if="isDark" width="1.1em" height="1.1em" viewBox="0 0 24 24" aria-hidden="true">
74
+ <path
75
+ fill="currentColor"
76
+ d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.39 5.39 0 0 1-4.4 2.26 5.4 5.4 0 0 1-5.4-5.4c0-1.81.89-3.41 2.26-4.4-.44-.06-.9-.1-1.36-.1Z"
77
+ />
78
+ </svg>
79
+ <svg v-else width="1.1em" height="1.1em" viewBox="0 0 24 24" aria-hidden="true">
80
+ <g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
81
+ <circle cx="12" cy="12" r="4" />
82
+ <path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M6.3 17.7l-1.4 1.4M19.1 4.9l-1.4 1.4" />
83
+ </g>
84
+ </svg>
85
+ </button>
86
+ </slot>
87
+ </template>
@@ -0,0 +1,47 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import ActionIconButton from './ActionIconButton.vue'
3
+
4
+ const meta = {
5
+ title: 'Action/ActionIconButton',
6
+ component: ActionIconButton,
7
+ tags: ['autodocs'],
8
+ args: { icon: 'i-ph:folder', tooltip: 'Open folder' },
9
+ } satisfies Meta<typeof ActionIconButton>
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof meta>
13
+
14
+ export const Default: Story = {
15
+ args: { icon: 'i-ph:folder', tooltip: 'Open folder' },
16
+ }
17
+
18
+ export const Active: Story = {
19
+ args: { icon: 'i-ph:gear', tooltip: 'Settings', active: true },
20
+ }
21
+
22
+ export const States: Story = {
23
+ render: () => ({
24
+ components: { ActionIconButton },
25
+ template: `<div class="flex items-center gap-2">
26
+ <ActionIconButton icon="i-ph:folder" tooltip="Open folder" />
27
+ <ActionIconButton icon="i-ph:gear" tooltip="Settings" :active="true" />
28
+ <ActionIconButton icon="i-ph:trash" tooltip="Delete" :disabled="true" />
29
+ </div>`,
30
+ }),
31
+ }
32
+
33
+ export const Compact: Story = {
34
+ args: { icon: 'i-ph:dots-three', tooltip: 'More', compact: true },
35
+ }
36
+
37
+ export const Sizes: Story = {
38
+ render: () => ({
39
+ components: { ActionIconButton },
40
+ template: `<div class="flex items-center gap-2">
41
+ <ActionIconButton icon="i-ph:gear" tooltip="sm" size="sm" />
42
+ <ActionIconButton icon="i-ph:gear" tooltip="md" size="md" />
43
+ <ActionIconButton icon="i-ph:gear" tooltip="lg" size="lg" />
44
+ <ActionIconButton icon="i-ph:gear" tooltip="compact" :compact="true" />
45
+ </div>`,
46
+ }),
47
+ }