@antfu/design 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -16,6 +16,7 @@ pnpm add @antfu/design unocss vue
16
16
  ```ts
17
17
  // uno.config.ts
18
18
  import { presetAnthonyDesign } from '@antfu/design/unocss'
19
+ import transformerDirectives from '@unocss/transformer-directives'
19
20
  import { defineConfig, presetIcons, presetWebFonts, presetWind4 } from 'unocss'
20
21
 
21
22
  export default defineConfig({
@@ -25,10 +26,29 @@ export default defineConfig({
25
26
  presetIcons(),
26
27
  presetWebFonts({ fonts: { sans: 'DM Sans', mono: 'DM Mono' } }),
27
28
  ],
28
- content: { pipeline: { include: [/@antfu\/design/] } },
29
+ // Required the shipped styles recolor overlays with token `--at-apply`
30
+ // directives; this transformer expands them (and lets you reuse tokens in CSS).
31
+ transformers: [transformerDirectives()],
32
+ // The preset ships no z-index scale (stacking is the app's to own) and blocks
33
+ // plain `z-<number>`. Define the named layers the overlay components use here —
34
+ // these values are yours; tune them to fit your app's stack.
35
+ shortcuts: {
36
+ 'z-nav': 'z-[30]',
37
+ 'z-dropdown': 'z-[40]',
38
+ 'z-tooltip': 'z-[45]',
39
+ 'z-toast': 'z-[50]',
40
+ 'z-modal-backdrop': 'z-[60]',
41
+ 'z-modal-content': 'z-[70]',
42
+ 'z-drawer-backdrop': 'z-[80]',
43
+ 'z-drawer-content': 'z-[90]',
44
+ },
29
45
  })
30
46
  ```
31
47
 
48
+ > Pair it with [`@unocss/eslint-plugin`](https://unocss.dev/integrations/eslint)
49
+ > for the feedback loop — it surfaces the blocklist hints (e.g. a plain `z-50`) in
50
+ > your editor and CI instead of silently dropping at build time.
51
+
32
52
  ```ts
33
53
  // Components are imported by full path (no barrel) — categorized and prefixed:
34
54
  import ActionButton from '@antfu/design/components/Action/ActionButton.vue'
@@ -38,12 +58,17 @@ import '@antfu/design/styles.css'
38
58
  ```
39
59
 
40
60
  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/] } }`).
61
+ compiles it. No extra `content` config is needed: UnoCSS's default scan matches
62
+ `.vue`/`.tsx` by extension (its only default exclude is CSS, not `node_modules`),
63
+ so the components you import are picked up automatically. If you *do* set
64
+ `content.pipeline.include`, note it **replaces** the default scan rather than
65
+ extending it — restate the defaults too, or your own sources stop being generated.
43
66
 
44
67
  It's a **single** preset that is **not self-contained**: it contributes only the
45
68
  antfu design layer (theme tokens, semantic shortcuts, dynamic rules, severity).
46
- You compose the base preset, icons, fonts and reset yourself.
69
+ You compose the base preset, icons, fonts and reset yourself — and add
70
+ `@unocss/transformer-directives` (the shipped styles use token `--at-apply`
71
+ directives) plus, recommended, `@unocss/eslint-plugin` for the feedback loop.
47
72
 
48
73
  ## Exports
49
74
 
@@ -114,10 +139,18 @@ tsx node_modules/@antfu/design/a11y/cli.ts http://localhost:6006/iframe.html
114
139
  | `btn-action-active` | `color-active border-active! bg-active op100!` |
115
140
  | `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
141
  | `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` |
142
+ | `btn-icon-square` | `w-9 h-9 rounded border border-base 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
143
  | `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
144
  | `badge` | `inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-xs font-medium leading-none` |
119
145
  | `badge-active` | `badge bg-active color-active` |
120
146
  | `badge-muted` | `badge bg-#8881 color-muted` |
147
+ | `pad-safe-t` | `pt-[env(safe-area-inset-top)]` |
148
+ | `pad-safe-r` | `pr-[env(safe-area-inset-right)]` |
149
+ | `pad-safe-b` | `pb-[env(safe-area-inset-bottom)]` |
150
+ | `pad-safe-l` | `pl-[env(safe-area-inset-left)]` |
151
+ | `pad-safe-x` | `pad-safe-l pad-safe-r` |
152
+ | `pad-safe-y` | `pad-safe-t pad-safe-b` |
153
+ | `pad-safe` | `pad-safe-x pad-safe-y` |
121
154
 
122
155
  ### Severity scale
123
156
 
@@ -137,19 +170,6 @@ tsx node_modules/@antfu/design/a11y/cli.ts http://localhost:6006/iframe.html
137
170
  | `text-mini` | `text-[11px] leading-[1.45]` |
138
171
  | `text-compact` | `text-[12px] leading-[1.5]` |
139
172
 
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
173
  ### Dynamic
154
174
 
155
175
  | Token | Expands to |
package/a11y/cli.ts CHANGED
@@ -27,13 +27,15 @@ function parseArgs(argv: string[]): { options: ContrastScanOptions, help: boolea
27
27
 
28
28
  for (let i = 0; i < argv.length; i++) {
29
29
  const arg = argv[i]
30
+ if (arg == null)
31
+ continue
30
32
  if (arg === '-h' || arg === '--help')
31
33
  help = true
32
- else if (arg === '--mode')
34
+ else if (arg === '--mode' && argv[i + 1] != null)
33
35
  modes.push(argv[++i] as ColorMode)
34
- else if (arg === '--exclude')
35
- exclude.push(argv[++i])
36
- else if (arg === '--key')
36
+ else if (arg === '--exclude' && argv[i + 1] != null)
37
+ exclude.push(argv[++i]!)
38
+ else if (arg === '--key' && argv[i + 1] != null)
37
39
  colorSchemeStorageKey = argv[++i]
38
40
  else if (arg === '--headed')
39
41
  headless = false
@@ -2,7 +2,7 @@
2
2
  * Opt-in color-scheme context.
3
3
  *
4
4
  * The package is stateless: most components flip light/dark automatically from
5
- * the `html.dark` class + `--af-*` tokens, and the handful that compute colors in
5
+ * the `html.dark` class + the token shortcuts, and the handful that compute colors in
6
6
  * JS (badges/labels/proportion bars) take a `colorScheme` prop. Threading that
7
7
  * prop everywhere is tedious, so this provides an *opt-in* context: call
8
8
  * {@link provideColorScheme} once near the app root (feeding it your own
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@antfu/design",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "A customizable, composable design system for devtools-style Vue apps — a UnoCSS preset, Vue primitives, a design skill, and an a11y contrast check",
6
6
  "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
7
  "license": "MIT",
@@ -46,7 +46,11 @@
46
46
  ],
47
47
  "peerDependencies": {
48
48
  "@axe-core/playwright": "^4.0.0",
49
+ "@tanstack/vue-virtual": "^3.0.0",
50
+ "floating-vue": "^5.0.0",
49
51
  "playwright": "^1.0.0",
52
+ "reka-ui": "^2.0.0",
53
+ "splitpanes": "^4.0.0",
50
54
  "unocss": ">=66.0.0",
51
55
  "vue": "^3.5.0"
52
56
  },
@@ -54,34 +58,48 @@
54
58
  "@axe-core/playwright": {
55
59
  "optional": true
56
60
  },
61
+ "@tanstack/vue-virtual": {
62
+ "optional": true
63
+ },
64
+ "floating-vue": {
65
+ "optional": true
66
+ },
57
67
  "playwright": {
58
68
  "optional": true
69
+ },
70
+ "reka-ui": {
71
+ "optional": true
72
+ },
73
+ "splitpanes": {
74
+ "optional": true
59
75
  }
60
76
  },
61
77
  "dependencies": {
62
78
  "@antfu/utils": "^9.3.0",
63
79
  "@iconify-json/catppuccin": "^1.2.17",
64
- "@tanstack/vue-virtual": "^3.13.29",
65
- "@unocss/core": "^66.7.2",
80
+ "@unocss/core": ">=66.0.0",
66
81
  "@vueuse/core": "^14.3.0",
67
- "colorjs.io": "^0.6.1",
68
- "floating-vue": "^5.2.2",
69
- "reka-ui": "^2.10.0",
70
- "splitpanes": "^4.1.2"
82
+ "colorjs.io": "^0.6.1"
71
83
  },
72
84
  "devDependencies": {
73
85
  "@arethetypeswrong/cli": "^0.18.2",
74
86
  "@axe-core/playwright": "^4.12.1",
75
87
  "@storybook/vue3-vite": "^10.4.6",
88
+ "@tanstack/vue-virtual": "^3.13.29",
76
89
  "@unocss/preset-icons": "^66.7.2",
77
90
  "@unocss/preset-mini": "^66.7.2",
78
91
  "@unocss/preset-web-fonts": "^66.7.2",
79
92
  "@unocss/preset-wind3": "^66.7.2",
80
93
  "@unocss/preset-wind4": "^66.7.2",
94
+ "@unocss/transformer-directives": "^66.7.2",
81
95
  "@vitejs/plugin-vue": "^6.0.7",
82
96
  "@vue/test-utils": "^2.4.11",
97
+ "floating-vue": "^5.2.2",
83
98
  "happy-dom": "^20.10.6",
99
+ "magic-string": "^0.30.21",
84
100
  "playwright": "^1.61.1",
101
+ "reka-ui": "^2.10.0",
102
+ "splitpanes": "^4.1.2",
85
103
  "tsdown": "^0.22.0",
86
104
  "tsdown-stale-guard": "^0.1.2",
87
105
  "tsnapi": "^0.3.3",
@@ -12,6 +12,7 @@ pnpm add @antfu/design unocss
12
12
  ```ts
13
13
  // uno.config.ts
14
14
  import { presetAnthonyDesign } from '@antfu/design/unocss'
15
+ import transformerDirectives from '@unocss/transformer-directives'
15
16
  import { defineConfig, presetIcons, presetWebFonts, presetWind4 } from 'unocss'
16
17
 
17
18
  export default defineConfig({
@@ -19,13 +20,19 @@ export default defineConfig({
19
20
  presetAnthonyDesign({
20
21
  primary: '#49833E', // string | full color-scale object (default antfu green)
21
22
  darkBackground: '#111', // near-black for dark surfaces
23
+ // overrides: { 'bg-base': 'bg-zinc-50 dark:bg-zinc-950' }, // retune any built-in shortcut
22
24
  }),
23
25
  presetWind4(), // a base preset is REQUIRED — shortcuts expand into its utilities
24
26
  presetIcons(),
25
27
  presetWebFonts({ fonts: { sans: 'DM Sans', mono: 'DM Mono' } }),
26
28
  ],
27
- // Generate the components' classes by scanning the installed package:
28
- content: { pipeline: { include: [/@antfu\/design/] } },
29
+ // REQUIRED: the shipped `@antfu/design/styles` recolor third-party overlays with
30
+ // token `--at-apply` directives this transformer is what expands them.
31
+ transformers: [transformerDirectives()],
32
+ // No `content.pipeline.include` needed: UnoCSS's default scan matches `.vue`/`.tsx`
33
+ // by extension (its only default exclude is CSS, not `node_modules`), so imported
34
+ // components are picked up. If you DO set `include`, it REPLACES the default scan —
35
+ // restate your own sources or they stop generating.
29
36
  })
30
37
  ```
31
38
 
@@ -33,6 +40,64 @@ export default defineConfig({
33
40
  > `presetMini`). Without one, the semantic shortcuts have nothing to expand into.
34
41
  > The design layer is **one preset** — there are no sub-presets to compose.
35
42
 
43
+ > **`@unocss/transformer-directives` is required**, not optional: the design
44
+ > system's own CSS (`base.css`, `floating-vue.css`, `splitpanes.css`) styles
45
+ > surfaces with token directives like `--at-apply: 'bg-base color-base'` instead of
46
+ > hand-duplicated hex values. Without the transformer those rules are dropped and
47
+ > overlays/surfaces lose their theming. It also lets *you* reuse the tokens in your
48
+ > own CSS (`.panel { --at-apply: 'bg-base border border-base'; }`).
49
+
50
+ ## Recommended: the UnoCSS ESLint plugin
51
+
52
+ Add [`@unocss/eslint-plugin`](https://unocss.dev/integrations/eslint) so the
53
+ guardrails surface as you type instead of silently dropping at build time. It's
54
+ where the blocklist's messages show up — e.g. a plain `z-50` is flagged with the
55
+ "use a named layer" hint — and it keeps class order/duplication tidy. This
56
+ feedback loop is the intended way to work with the design system.
57
+
58
+ ```js
59
+ // eslint.config.js
60
+ import unocss from '@unocss/eslint-plugin'
61
+
62
+ export default [
63
+ unocss(),
64
+ ]
65
+ ```
66
+
67
+ ## z-index layers (you own them)
68
+
69
+ Stacking is a whole-app concern, so the preset ships **no** z-index scale and
70
+ **blocks plain `z-<number>` / `z-[…]` utilities** (`z-auto` stays allowed) — every
71
+ z-index must flow through a *named* layer you define in your own UnoCSS
72
+ `shortcuts`. The overlay components reference these names; without them, overlays
73
+ have no stacking value. Define them once, alongside the preset, and tune the
74
+ numbers to fit your app:
75
+
76
+ ```ts
77
+ // uno.config.ts (top-level — NOT inside the preset)
78
+ export default defineConfig({
79
+ presets: [presetAnthonyDesign(), presetWind4()],
80
+ transformers: [transformerDirectives()],
81
+ shortcuts: {
82
+ 'z-nav': 'z-[30]',
83
+ 'z-dropdown': 'z-[40]',
84
+ 'z-tooltip': 'z-[45]',
85
+ 'z-toast': 'z-[50]',
86
+ 'z-modal-backdrop': 'z-[60]',
87
+ 'z-modal-content': 'z-[70]',
88
+ 'z-drawer-backdrop': 'z-[80]',
89
+ 'z-drawer-content': 'z-[90]',
90
+ },
91
+ })
92
+ ```
93
+
94
+ These are *reference* values — the only contract is the **ordering** (`z-nav` <
95
+ `z-dropdown` < `z-tooltip` < `z-toast` < `z-modal-backdrop` < `z-modal-content` <
96
+ `z-drawer-backdrop` < `z-drawer-content`) so a modal sits over a dropdown, a drawer
97
+ over a modal. The block lifts each utility *defined in `shortcuts`* — only plain
98
+ z-index written in markup is rejected. Disable the guardrail with
99
+ `presetAnthonyDesign({ blocklists: { plainZIndex: false } })`.
100
+
36
101
  ## Styles
37
102
 
38
103
  ```ts
@@ -34,10 +34,18 @@ never drift from what the shortcuts actually resolve to.
34
34
  | `btn-action-active` | `color-active border-active! bg-active op100!` |
35
35
  | `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` |
36
36
  | `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` |
37
+ | `btn-icon-square` | `w-9 h-9 rounded border border-base 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` |
37
38
  | `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` |
38
39
  | `badge` | `inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-xs font-medium leading-none` |
39
40
  | `badge-active` | `badge bg-active color-active` |
40
41
  | `badge-muted` | `badge bg-#8881 color-muted` |
42
+ | `pad-safe-t` | `pt-[env(safe-area-inset-top)]` |
43
+ | `pad-safe-r` | `pr-[env(safe-area-inset-right)]` |
44
+ | `pad-safe-b` | `pb-[env(safe-area-inset-bottom)]` |
45
+ | `pad-safe-l` | `pl-[env(safe-area-inset-left)]` |
46
+ | `pad-safe-x` | `pad-safe-l pad-safe-r` |
47
+ | `pad-safe-y` | `pad-safe-t pad-safe-b` |
48
+ | `pad-safe` | `pad-safe-x pad-safe-y` |
41
49
 
42
50
  ### Severity scale
43
51
 
@@ -57,19 +65,6 @@ never drift from what the shortcuts actually resolve to.
57
65
  | `text-mini` | `text-[11px] leading-[1.45]` |
58
66
  | `text-compact` | `text-[12px] leading-[1.5]` |
59
67
 
60
- ### Named z-index layers
61
-
62
- | Token | Expands to |
63
- |---|---|
64
- | `z-nav` | `z-[30]` |
65
- | `z-dropdown` | `z-[40]` |
66
- | `z-tooltip` | `z-[45]` |
67
- | `z-toast` | `z-[50]` |
68
- | `z-modal-backdrop` | `z-[60]` |
69
- | `z-modal-content` | `z-[70]` |
70
- | `z-drawer-backdrop` | `z-[80]` |
71
- | `z-drawer-content` | `z-[90]` |
72
-
73
68
  ### Dynamic
74
69
 
75
70
  | Token | Expands to |
@@ -92,9 +87,10 @@ never drift from what the shortcuts actually resolve to.
92
87
  - **Severity** `color-scale-{neutral,low,medium,high,critical}` is the one ramp
93
88
  for fresh→stale / fast→slow / small→large. Prefer the `colorize` prop on display
94
89
  components over using these directly.
95
- - **Named z-index layers** (`z-nav` < `z-dropdown` < `z-tooltip` < `z-toast` <
96
- `z-modal-backdrop` < `z-modal-content` < `z-drawer-backdrop` < `z-drawer-content`)
97
- never raw `z-<n>`.
90
+ - **z-index**: always a named layer (`z-nav`, `z-dropdown`, `z-modal-content`, …),
91
+ never plain `z-<n>` the preset blocks plain z-index. The preset ships **no**
92
+ values; the app defines the named layers in its own `shortcuts`. See
93
+ [core-setup.md](core-setup.md#z-index-layers-you-own-them).
98
94
  - **Theme**: `font-sans` = DM Sans, `font-mono` = DM Mono; extra sizes
99
95
  `text-micro` / `text-mini` / `text-compact`; color ramps `primary` (default
100
96
  antfu green), `warning`, `success`, `error`.
package/styles/base.css CHANGED
@@ -1,31 +1,19 @@
1
1
  /**
2
- * Root surface + a small set of CSS custom properties mirroring the token
3
- * defaults. The overlay-engine override files reference these vars, so they
4
- * recolor with light/dark automatically and work on plain `import` (without
5
- * needing UnoCSS to process them).
2
+ * Root surface + text, applied straight from the design tokens. The `--at-apply`
3
+ * directives below (and in the other style files) need
4
+ * `@unocss/transformer-directives` in the consuming app's UnoCSS config (see
5
+ * Setup) that's what expands `bg-base` / `color-base` etc., so the surface
6
+ * follows light/dark with no duplicated set of CSS variables to maintain.
6
7
  */
7
8
 
8
9
  :root {
9
- --af-bg-base: #ffffff;
10
- --af-bg-secondary: #f5f5f5;
11
- --af-color-base: #262626; /* neutral-800 */
12
- --af-color-muted: #525252; /* neutral-600 */
13
- --af-border-base: rgba(136, 136, 136, 0.13); /* ~#8882 */
14
- --af-border-mute: rgba(136, 136, 136, 0.07); /* ~#8881 */
15
- --af-tooltip-bg: rgba(255, 255, 255, 0.75);
16
10
  color-scheme: light;
17
11
  }
18
12
 
19
13
  html.dark {
20
- --af-bg-base: #111111;
21
- --af-bg-secondary: #1a1a1a;
22
- --af-color-base: #e5e5e5; /* neutral-200 */
23
- --af-color-muted: #a3a3a3; /* neutral-400 */
24
- --af-tooltip-bg: rgba(17, 17, 17, 0.75);
25
14
  color-scheme: dark;
26
15
  }
27
16
 
28
17
  html {
29
- background-color: var(--af-bg-base);
30
- color: var(--af-color-base);
18
+ --at-apply: 'bg-base color-base';
31
19
  }
@@ -1,28 +1,25 @@
1
1
  /**
2
- * Recolor floating-vue tooltips/dropdowns to the design tokens. Driven by the
3
- * `--af-*` custom properties from `base.css` so they follow light/dark.
2
+ * Recolor floating-vue tooltips/dropdowns to the design tokens via
3
+ * `@unocss/transformer-directives` (see Setup): `bg-tooltip` carries the
4
+ * translucent surface + backdrop blur, and `color-base` / `border-base` follow
5
+ * light/dark automatically.
4
6
  */
5
7
 
6
8
  .v-popper__popper .v-popper__inner {
7
- background: var(--af-tooltip-bg);
8
- color: var(--af-color-base);
9
- border: 1px solid var(--af-border-base);
10
- border-radius: 6px;
9
+ --at-apply: 'bg-tooltip color-base border border-base rounded-md text-xs px-2 py-1';
11
10
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
12
- backdrop-filter: blur(8px);
13
- padding: 4px 8px;
14
- font-size: 12px;
15
11
  }
16
12
 
17
13
  .v-popper__popper .v-popper__arrow-outer {
18
- border-color: var(--af-border-base);
14
+ --at-apply: 'border-base';
19
15
  }
20
16
 
21
17
  .v-popper__popper .v-popper__arrow-inner {
22
- border-color: var(--af-tooltip-bg);
18
+ /* Matches the `bg-tooltip` fill so the arrow reads as part of the surface. */
19
+ --at-apply: 'border-white/75 dark:border-[#111]/75';
23
20
  visibility: visible;
24
21
  }
25
22
 
26
23
  .v-popper--theme-dropdown .v-popper__inner {
27
- padding: 4px;
24
+ --at-apply: 'p-1';
28
25
  }
@@ -33,15 +33,16 @@
33
33
  pointer-events: none;
34
34
  }
35
35
 
36
- /* Splitter — recolored to the design tokens. */
36
+ /* Splitter — divider tinted to the neutral border token (see Setup for the
37
+ * `@unocss/transformer-directives` requirement). */
37
38
  .splitpanes__splitter {
38
39
  position: relative;
39
40
  touch-action: none;
40
- background-color: var(--af-border-base);
41
+ --at-apply: 'bg-#8882';
41
42
  transition: background-color 0.2s;
42
43
  }
43
44
  .splitpanes__splitter:hover {
44
- background-color: rgba(136, 136, 136, 0.4);
45
+ --at-apply: 'bg-#8886';
45
46
  }
46
47
  .splitpanes--vertical > .splitpanes__splitter {
47
48
  min-width: 1px;
@@ -0,0 +1,44 @@
1
+ import type { BlocklistRule } from '@unocss/core'
2
+
3
+ /**
4
+ * Granular toggle for the preset's blocklists. `true` (default) enables them all,
5
+ * `false` disables them all, or pass an object to flip individual entries. Kept
6
+ * open-ended so future guardrails slot in without another option.
7
+ */
8
+ export type BlocklistsOption = boolean | {
9
+ /**
10
+ * Block plain `z-<number>` / `z-[…]` utilities so every z-index flows through a
11
+ * named, app-owned layer (see Setup). Default `true`.
12
+ */
13
+ plainZIndex?: boolean
14
+ }
15
+
16
+ /**
17
+ * Matches a plain z-index utility — `z-50`, `z-[70]`, `-z-10`, `z-[var(--x)]` —
18
+ * but **not** a named layer (`z-modal-content`, `z-nav`) nor the `z-auto` reset.
19
+ *
20
+ * The named layers are shortcuts the app defines in its own UnoCSS `shortcuts`
21
+ * config; their expansion to `z-[70]` happens *inside* the shortcut, which the
22
+ * blocklist never re-checks. So the layers keep resolving while a plain z-index
23
+ * written in markup fails to generate — pushing authors to semantic layers.
24
+ */
25
+ export const RE_PLAIN_Z_INDEX = /^-?z-(?:\d+|\[[^\]]+\])$/
26
+
27
+ /** Blocklist entry banning plain z-index utilities (see {@link RE_PLAIN_Z_INDEX}). */
28
+ export const plainZIndexBlocklist: BlocklistRule = [
29
+ RE_PLAIN_Z_INDEX,
30
+ {
31
+ message: (selector: string) =>
32
+ `[@antfu/design] "${selector}" — plain z-index is blocked. Define a named layer in your UnoCSS \`shortcuts\` (e.g. z-modal-content: 'z-[70]') and use that, or pass \`blocklists: { plainZIndex: false }\` to opt out.`,
33
+ },
34
+ ]
35
+
36
+ /** Resolve the preset's `blocklist` array from the `blocklists` option (default all on). */
37
+ export function buildBlocklist(option: BlocklistsOption = true): BlocklistRule[] {
38
+ if (option === false)
39
+ return []
40
+ const plainZIndex = option === true ? true : option.plainZIndex !== false
41
+ return [
42
+ ...(plainZIndex ? [plainZIndexBlocklist] : []),
43
+ ]
44
+ }
package/unocss/colors.ts CHANGED
@@ -76,9 +76,20 @@ export const error: ColorRamp = {
76
76
  DEFAULT: '#f63d68',
77
77
  }
78
78
 
79
- const RAMP_STOPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const
80
- /** Target OKLCH lightness (0–1) per stop. */
81
- const RAMP_LIGHTNESS = [0.97, 0.94, 0.87, 0.78, 0.68, 0.58, 0.50, 0.42, 0.35, 0.28, 0.18] as const
79
+ /** Each stop paired with its target OKLCH lightness (0–1). */
80
+ const RAMP_STEPS = [
81
+ [50, 0.97],
82
+ [100, 0.94],
83
+ [200, 0.87],
84
+ [300, 0.78],
85
+ [400, 0.68],
86
+ [500, 0.58],
87
+ [600, 0.50],
88
+ [700, 0.42],
89
+ [800, 0.35],
90
+ [900, 0.28],
91
+ [950, 0.18],
92
+ ] as const
82
93
 
83
94
  /**
84
95
  * Generate an 11-stop color ramp (`50`..`950` + `DEFAULT`) from a single color,
@@ -97,8 +108,7 @@ export function generateColorRamp(input: string): ColorRamp {
97
108
  const [, chroma, rawHue] = new Color(input).to('oklch').coords
98
109
  const hue = Number.isFinite(rawHue) ? rawHue : 0
99
110
  const ramp: ColorRamp = { DEFAULT: input }
100
- RAMP_STOPS.forEach((stop, i) => {
101
- const l = RAMP_LIGHTNESS[i]
111
+ RAMP_STEPS.forEach(([stop, l]) => {
102
112
  // Taper chroma at the lightest/darkest stops so they don't look muddy.
103
113
  const c = (chroma || 0) * (l > 0.9 || l < 0.25 ? 0.6 : 1)
104
114
  ramp[stop] = new Color('oklch', [l, c, hue]).to('srgb').toGamut({ space: 'srgb' }).toString({ format: 'hex' })
package/unocss/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Preset, UserShortcuts } from '@unocss/core'
2
2
  import type { PresetAnthonyDesignOptions } from './options'
3
3
  import { definePreset, mergeDeep } from '@unocss/core'
4
+ import { buildBlocklist } from './blocklist'
4
5
  import { error, resolvePrimary, success, warning } from './colors'
5
6
  import { DEFAULT_DARK_BG, DEFAULT_FONTS } from './options'
6
7
  import { patternRules } from './patterns'
@@ -26,12 +27,19 @@ function assertOptions(options: PresetAnthonyDesignOptions): void {
26
27
  * consumer composes those themselves. The semantic layer is base-agnostic, so it
27
28
  * resolves under Wind4, Wind3 or Mini.
28
29
  *
30
+ * It deliberately ships **no z-index scale** — stacking is a whole-app concern.
31
+ * The overlay components reference named layers (`z-modal-content`, `z-dropdown`,
32
+ * …); define those in your own UnoCSS `shortcuts` config (see Setup). As a
33
+ * guardrail the preset blocks plain `z-<number>` utilities so every z-index goes
34
+ * through a named layer (opt out with `blocklists: { plainZIndex: false }`).
35
+ *
29
36
  * @param options - Theme + dark-surface options (see {@link PresetAnthonyDesignOptions}).
30
37
  * @returns A single UnoCSS `Preset`.
31
38
  *
32
39
  * @example
33
40
  * ```ts
34
41
  * import { presetAnthonyDesign } from '@antfu/design/unocss'
42
+ * import transformerDirectives from '@unocss/transformer-directives'
35
43
  * import { defineConfig, presetIcons, presetWebFonts, presetWind4 } from 'unocss'
36
44
  *
37
45
  * export default defineConfig({
@@ -41,6 +49,8 @@ function assertOptions(options: PresetAnthonyDesignOptions): void {
41
49
  * presetIcons(),
42
50
  * presetWebFonts({ fonts: { sans: 'DM Sans', mono: 'DM Mono' } }),
43
51
  * ],
52
+ * // Required — expands the token `--at-apply` directives in the shipped styles.
53
+ * transformers: [transformerDirectives()],
44
54
  * })
45
55
  * ```
46
56
  */
@@ -71,6 +81,8 @@ export const presetAnthonyDesign = definePreset((options: PresetAnthonyDesignOpt
71
81
  ...buildRules(darkBackground),
72
82
  ...severityShortcuts,
73
83
  ...extend,
84
+ // Appended last so a same-named entry wins over everything above it.
85
+ ...(options.overrides ? [options.overrides] : []),
74
86
  ]
75
87
 
76
88
  return {
@@ -78,11 +90,20 @@ export const presetAnthonyDesign = definePreset((options: PresetAnthonyDesignOpt
78
90
  extendTheme: theme => mergeDeep(theme as any, themeOverrides as any),
79
91
  shortcuts,
80
92
  rules: patternRules,
93
+ // Best-practice guardrails (default all on); see the `blocklists` option.
94
+ blocklist: buildBlocklist(options.blocklists),
81
95
  }
82
96
  })
83
97
 
84
98
  export default presetAnthonyDesign
85
99
 
100
+ export {
101
+ type BlocklistsOption,
102
+ buildBlocklist,
103
+ plainZIndexBlocklist,
104
+ RE_PLAIN_Z_INDEX,
105
+ } from './blocklist'
106
+
86
107
  export {
87
108
  type ColorRamp,
88
109
  error as errorRamp,
package/unocss/options.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { UserShortcuts } from '@unocss/core'
2
+ import type { BlocklistsOption } from './blocklist'
2
3
  import type { ColorRamp } from './colors'
3
4
 
4
5
  /** Default near-black used for dark surfaces. Overridable via `darkBackground`. */
@@ -28,4 +29,19 @@ export interface PresetAnthonyDesignOptions {
28
29
  theme?: Record<string, any>
29
30
  /** Extra shortcuts appended after the built-in layer (so they can override it). */
30
31
  extendShortcuts?: UserShortcuts
32
+ /**
33
+ * Override built-in shortcuts by name. Merged in with the **highest** precedence
34
+ * (after the built-in layer and `extendShortcuts`), so e.g.
35
+ * `{ 'bg-base': 'bg-zinc-50 dark:bg-zinc-950' }` redefines what `bg-base` resolves
36
+ * to. Use this to retune the semantic vocabulary; use `extendShortcuts` to add new
37
+ * ones.
38
+ */
39
+ overrides?: Record<string, string>
40
+ /**
41
+ * Best-practice guardrails. Defaults to `true` (all on). Currently the only
42
+ * entry is `plainZIndex`, which blocks plain `z-<number>` / `z-[…]` utilities so
43
+ * every z-index goes through a named layer the app defines (see Setup). Pass
44
+ * `false` to disable all, or `{ plainZIndex: false }` to opt out of one.
45
+ */
46
+ blocklists?: BlocklistsOption
31
47
  }
@@ -42,6 +42,9 @@ export function buildShortcuts(db: string): (StaticShortcutMap | DynamicShortcut
42
42
  'btn-action-active': 'color-active border-active! bg-active op100!',
43
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
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
+ // Bordered, square counterpart to the round/borderless `btn-icon` — for
46
+ // toolbar-style icon buttons that read as a distinct affordance.
47
+ 'btn-icon-square': 'w-9 h-9 rounded border border-base 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
48
  '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
49
 
47
50
  // ── Badges ────────────────────────────────────────────────────────
@@ -54,15 +57,20 @@ export function buildShortcuts(db: string): (StaticShortcutMap | DynamicShortcut
54
57
  'text-mini': 'text-[11px] leading-[1.45]',
55
58
  'text-compact': 'text-[12px] leading-[1.5]',
56
59
 
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]',
60
+ // ── Safe-area padding (notches / home indicators) ─────────────────
61
+ 'pad-safe-t': 'pt-[env(safe-area-inset-top)]',
62
+ 'pad-safe-r': 'pr-[env(safe-area-inset-right)]',
63
+ 'pad-safe-b': 'pb-[env(safe-area-inset-bottom)]',
64
+ 'pad-safe-l': 'pl-[env(safe-area-inset-left)]',
65
+ 'pad-safe-x': 'pad-safe-l pad-safe-r',
66
+ 'pad-safe-y': 'pad-safe-t pad-safe-b',
67
+ 'pad-safe': 'pad-safe-x pad-safe-y',
68
+
69
+ // NOTE: the preset deliberately ships **no** z-index scale. Stacking is a
70
+ // whole-app concern, so the layer values are the app's to own — it defines
71
+ // the named layers (`z-modal-content`, `z-dropdown`, …) that the overlay
72
+ // components reference in its own top-level UnoCSS `shortcuts`. The preset
73
+ // blocks plain `z-<number>` to keep usage semantic (see `./blocklist`).
66
74
  },
67
75
  ]
68
76
  }
package/utils/color.ts CHANGED
@@ -243,8 +243,9 @@ export function getPluginColor(
243
243
  const hues = map === defaultBrandHues ? defaultBrandHues : { ...defaultBrandHues, ...map }
244
244
  const bare = stripPluginPrefix(name).toLowerCase()
245
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)
246
+ const hue = key != null ? hues[key] : undefined
247
+ if (hue != null)
248
+ return getHsla(hue, opacity, dark)
248
249
  return getHashColorFromString(name, opacity, dark)
249
250
  }
250
251
 
package/utils/format.ts CHANGED
@@ -47,7 +47,7 @@ export function mapSeverity(value: number, scale: SeverityScale): ColorScaleClas
47
47
  if (value <= max)
48
48
  return cls
49
49
  }
50
- return scale[scale.length - 1][1]
50
+ return scale.at(-1)?.[1] ?? colorScale.neutral
51
51
  }
52
52
 
53
53
  // ── Locale ──────────────────────────────────────────────────────────────────
@@ -159,7 +159,7 @@ export function formatBytes(bytes: number, options: FormatBytesOptions = {}): [s
159
159
  if (i === 0)
160
160
  return [String(bytes), 'B']
161
161
  const value = (bytes / base ** i).toFixed(digits).replace(/\.?0+$/, '')
162
- return [value, units[i]]
162
+ return [value, units[i] ?? 'B']
163
163
  }
164
164
 
165
165
  /**
@@ -78,8 +78,9 @@ export function parseChord(input: string): ParsedChord {
78
78
  let key = ''
79
79
  for (const token of tokens) {
80
80
  const lower = token.toLowerCase()
81
- if (lower in MOD_ALIASES)
82
- modifiers.add(MOD_ALIASES[lower])
81
+ const mod = MOD_ALIASES[lower]
82
+ if (mod != null)
83
+ modifiers.add(mod)
83
84
  else
84
85
  key = KEY_ALIASES[lower] ?? (token.length === 1 ? token.toLowerCase() : token)
85
86
  }
@@ -177,7 +178,7 @@ const KEY_GLYPHS: Record<string, string> = {
177
178
  * // → ['⌃', 'K'] on macOS, ['Ctrl', 'K'] elsewhere
178
179
  */
179
180
  export function chordDisplay(chord: ParsedChord): string[] {
180
- const mods = chord.modifiers.map(m => isMac ? GLYPHS_MAC[m] : LABELS[m])
181
+ const mods = chord.modifiers.map(m => (isMac ? GLYPHS_MAC[m] : LABELS[m]) ?? m)
181
182
  const key = KEY_GLYPHS[chord.key] ?? (chord.key.length === 1 ? chord.key.toUpperCase() : chord.key)
182
183
  return [...mods, key]
183
184
  }
package/utils/path.ts CHANGED
@@ -77,7 +77,7 @@ export interface PnpmPackageInfo {
77
77
  */
78
78
  export function parsePnpmSegment(segment: string): PnpmPackageInfo | undefined {
79
79
  // Strip peer-dependency suffix (`_react@18...`).
80
- const base = segment.split('_')[0]
80
+ const base = segment.split('_')[0] ?? ''
81
81
  const at = base.lastIndexOf('@')
82
82
  if (at <= 0)
83
83
  return undefined
@@ -109,7 +109,7 @@ export function parsePnpmSegment(segment: string): PnpmPackageInfo | undefined {
109
109
  */
110
110
  export function getPnpmPackageInfoFromPath(path: string): PnpmPackageInfo | undefined {
111
111
  const match = normalizeModulePath(path).match(/\.pnpm\/([^/]+)/)
112
- if (!match)
112
+ if (match?.[1] == null)
113
113
  return undefined
114
114
  return parsePnpmSegment(match[1])
115
115
  }
@@ -138,8 +138,10 @@ export function getModuleNameFromPath(path: string): string | undefined {
138
138
  if (idx === -1)
139
139
  return undefined
140
140
  const rest = normalized.slice(idx + 'node_modules/'.length)
141
- const segments = rest.split('/')
142
- return segments[0].startsWith('@') ? `${segments[0]}/${segments[1]}` : segments[0]
141
+ const [first, second] = rest.split('/')
142
+ if (first == null)
143
+ return undefined
144
+ return first.startsWith('@') && second != null ? `${first}/${second}` : first
143
145
  }
144
146
 
145
147
  /**
package/utils/semver.ts CHANGED
@@ -47,9 +47,10 @@ export function compareSemver(a: string, b: string): number {
47
47
  const pb = parseVersion(b)
48
48
  if (!pa || !pb)
49
49
  return 0
50
- for (let i = 0; i < 3; i++) {
51
- if (pa[i] !== pb[i])
52
- return pa[i] < pb[i] ? -1 : 1
50
+ const core = [[pa[0], pb[0]], [pa[1], pb[1]], [pa[2], pb[2]]] as const
51
+ for (const [x, y] of core) {
52
+ if (x !== y)
53
+ return x < y ? -1 : 1
53
54
  }
54
55
  // Equal core: a version without prerelease is greater than one with.
55
56
  if (pa[3] === pb[3])
package/utils/tree.ts CHANGED
@@ -24,6 +24,8 @@ export interface ToTreeOptions {
24
24
  function flattenChain<T>(node: TreeNode<T>, separator: string): void {
25
25
  while (node.children.length === 1 && node.item == null) {
26
26
  const child = node.children[0]
27
+ if (child == null)
28
+ break
27
29
  node.name = `${node.name}${separator}${child.name}`
28
30
  node.path = child.path
29
31
  node.item = child.item