@djangocfg/ui-core 2.1.465 → 2.1.468

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
@@ -176,6 +176,11 @@ Tailwind v4 with semantic tokens, not raw color scales:
176
176
 
177
177
  Tokens live in `:root` / `.dark` as fully-wrapped CSS colors; `@theme inline` exposes them as `--color-X` references, so opacity modifiers (`bg-card/40`, `border-foreground/20`) resolve via `color-mix` for **every** semantic token.
178
178
 
179
+ Preset text/fill pairs target WCAG AA for compact product UI. For the macOS
180
+ preset this is enforced by `pnpm check:contrast`; primary and destructive
181
+ foregrounds intentionally switch between light and dark ink where the system
182
+ fill's luminance requires it.
183
+
179
184
  `@custom-variant dark (&:where(.dark, .dark *))` binds the `dark:` variant to the `.dark` class on `<html>` (not `prefers-color-scheme`) — every theme-switcher in this monorepo toggles that class.
180
185
 
181
186
  Tailwind's `text-*` size utilities are bridged to the preset `--font-size-*` scale. Product-specific density belongs in a small CSS override after the static preset import — never in a runtime token builder. See `src/styles/README.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.465",
3
+ "version": "2.1.468",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -124,10 +124,11 @@
124
124
  ],
125
125
  "scripts": {
126
126
  "lint": "eslint .",
127
- "check": "tsc --noEmit"
127
+ "check": "tsc --noEmit",
128
+ "check:contrast": "node scripts/check-preset-contrast.mjs"
128
129
  },
129
130
  "peerDependencies": {
130
- "@djangocfg/i18n": "^2.1.465",
131
+ "@djangocfg/i18n": "^2.1.468",
131
132
  "consola": "^3.4.2",
132
133
  "lucide-react": "^0.545.0",
133
134
  "moment": "^2.30.1",
@@ -205,8 +206,8 @@
205
206
  "@chenglou/pretext": "^0.0.8"
206
207
  },
207
208
  "devDependencies": {
208
- "@djangocfg/i18n": "^2.1.465",
209
- "@djangocfg/typescript-config": "^2.1.465",
209
+ "@djangocfg/i18n": "^2.1.468",
210
+ "@djangocfg/typescript-config": "^2.1.468",
210
211
  "@types/node": "^25.9.5",
211
212
  "@types/react": "19.2.15",
212
213
  "@types/react-dom": "19.2.3",
@@ -48,7 +48,12 @@ const DropdownMenuSubContent = React.forwardRef<
48
48
  <DropdownMenuPrimitive.SubContent
49
49
  ref={ref}
50
50
  className={cn(
51
- "z-[700] min-w-32 overflow-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
51
+ // Height/scroll mirrors DropdownMenuContent below: a long submenu (e.g. a
52
+ // 40-vendor model list) must cap and scroll INSIDE, not overflow off-screen
53
+ // and clip. `min()` of the space Radix measured to the viewport edge and a
54
+ // comfortable ceiling — a menu that spans the WHOLE screen reads as a page,
55
+ // not a menu, so we stop well short of the viewport even when it would fit.
56
+ "z-[700] max-h-[min(var(--radix-dropdown-menu-content-available-height),24rem)] min-w-32 overflow-y-auto overflow-x-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
52
57
  className
53
58
  )}
54
59
  {...props}
@@ -66,7 +71,9 @@ const DropdownMenuContent = React.forwardRef<
66
71
  ref={ref}
67
72
  sideOffset={sideOffset}
68
73
  className={cn(
69
- "z-[700] max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-32 overflow-y-auto overflow-x-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-md",
74
+ // See DropdownMenuSubContent: cap at the SMALLER of the space to the
75
+ // viewport edge and a comfortable ceiling, then scroll inside.
76
+ "z-[700] max-h-[min(var(--radix-dropdown-menu-content-available-height),24rem)] min-w-32 overflow-y-auto overflow-x-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-md",
70
77
  "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
71
78
  className
72
79
  )}
@@ -33,6 +33,7 @@ export function MenuBuilder({
33
33
  sideOffset,
34
34
  alignOffset,
35
35
  contentClassName,
36
+ submenuClassName,
36
37
  showDescriptions = true,
37
38
  }: MenuBuilderProps) {
38
39
  return (
@@ -54,7 +55,7 @@ export function MenuBuilder({
54
55
  // select-none: menu labels shouldn't be text-selectable (feels crisp).
55
56
  className={cn('min-w-56 max-w-xs select-none', contentClassName)}
56
57
  >
57
- {renderItems(items, { showDescriptions })}
58
+ {renderItems(items, { showDescriptions, submenuClassName })}
58
59
  </DropdownMenuContent>
59
60
  </DropdownMenu>
60
61
  );
@@ -1,5 +1,6 @@
1
1
  import { Fragment, type ReactNode } from 'react';
2
2
 
3
+ import { cn } from '../../../lib';
3
4
  import {
4
5
  DropdownMenuCheckboxItem,
5
6
  DropdownMenuGroup,
@@ -18,6 +19,9 @@ import type { MenuItem } from './types';
18
19
 
19
20
  interface RenderOptions {
20
21
  showDescriptions: boolean;
22
+ /** Applied to every submenu panel at any depth; a `MenuSubmenuItem` may
23
+ * override it per-branch with its own `contentClassName`. */
24
+ submenuClassName?: string;
21
25
  }
22
26
 
23
27
  /** Drop `hidden` rows before rendering. */
@@ -83,7 +87,11 @@ export function renderItems(
83
87
  />
84
88
  </DropdownMenuSubTrigger>
85
89
  <DropdownMenuPortal>
86
- <DropdownMenuSubContent>
90
+ {/* Per-branch class wins over the builder-wide one. Height +
91
+ scroll come from the primitive — see DropdownMenuSubContent. */}
92
+ <DropdownMenuSubContent
93
+ className={cn(opts.submenuClassName, item.contentClassName)}
94
+ >
87
95
  {renderItems(item.items, opts)}
88
96
  </DropdownMenuSubContent>
89
97
  </DropdownMenuPortal>
@@ -37,6 +37,12 @@ export interface MenuActionItem extends MenuRowBase {
37
37
  export interface MenuSubmenuItem extends MenuRowBase {
38
38
  kind: 'submenu';
39
39
  items: MenuItem[];
40
+ /**
41
+ * Extra class for THIS submenu's panel — overrides the builder-wide
42
+ * `submenuClassName` for one branch (e.g. only the vendor list needs to be
43
+ * wider). Height/scroll are already handled by the primitive.
44
+ */
45
+ contentClassName?: string;
40
46
  }
41
47
 
42
48
  /** A single checkbox row. */
@@ -135,8 +141,16 @@ export interface MenuBuilderProps {
135
141
  align?: 'start' | 'center' | 'end';
136
142
  sideOffset?: number;
137
143
  alignOffset?: number;
138
- /** Extra class for the content panel. */
144
+ /** Extra class for the ROOT content panel. */
139
145
  contentClassName?: string;
146
+ /**
147
+ * Extra class for EVERY submenu panel (applied at any nesting depth).
148
+ * Both root and sub panels already cap their height to the space Radix
149
+ * measured to the viewport edge and scroll inside, so this is for WIDTH and
150
+ * the like — e.g. `sm:max-w-md` when rows carry long ids that would
151
+ * otherwise be clipped by the default `max-w-xs`.
152
+ */
153
+ submenuClassName?: string;
140
154
  /** Render the muted description line on rows that have one. Default: true. */
141
155
  showDescriptions?: boolean;
142
156
  }
@@ -56,7 +56,8 @@ not depend on the physical path. The preset defines both `:root` (light) and
56
56
  For a product-specific adjustment, add a small override after the preset:
57
57
 
58
58
  ```css
59
- :root, .dark {
59
+ :root,
60
+ .dark {
60
61
  --font-size-base: 0.9375rem;
61
62
  --font-size-sm: 0.875rem;
62
63
  }
@@ -133,8 +134,12 @@ CSS rather than duplicating a pixel value in React.
133
134
  Color variables are complete CSS colors, never bare HSL triplets:
134
135
 
135
136
  ```css
136
- :root { --background: hsl(240 17% 97%); }
137
- .dark { --background: hsl(240 5% 8%); }
137
+ :root {
138
+ --background: hsl(240 17% 97%);
139
+ }
140
+ .dark {
141
+ --background: hsl(240 5% 8%);
142
+ }
138
143
  ```
139
144
 
140
145
  Tailwind maps semantic utilities to these variables through `@theme inline`.
@@ -144,7 +149,8 @@ Use `var(--background)` or `bg-background`; do not write
144
149
  Typography variables are ordinary CSS values:
145
150
 
146
151
  ```css
147
- :root, .dark {
152
+ :root,
153
+ .dark {
148
154
  --font-size-base: 0.8125rem;
149
155
  --font-size-sm: 0.75rem;
150
156
  }
@@ -157,6 +163,9 @@ Typography variables are ordinary CSS values:
157
163
  CSS color values.
158
164
  - Keep light and dark pairs together in the same file.
159
165
  - A preset may omit tokens that intentionally inherit from `theme.css`.
166
+ - In light mode, `--muted` must be at least 2 HSL lightness points darker
167
+ than `--background`. This keeps translucent muted fills visible on the
168
+ canvas and is enforced by `check:contrast`.
160
169
  - Changes to a preset require checking both light and dark modes.
161
170
  - Product density overrides belong in the consuming app's CSS, not in a copied
162
171
  TypeScript map.
@@ -164,9 +173,17 @@ Typography variables are ordinary CSS values:
164
173
  ## Verification
165
174
 
166
175
  ```bash
176
+ pnpm check:contrast
167
177
  pnpm check
168
178
  ```
169
179
 
180
+ `check:contrast` merges every static preset over the base theme and validates
181
+ the light-mode background/muted surface hierarchy. It also keeps the complete
182
+ macOS compact-text audit: semantic pairs must reach WCAG AA 4.5:1 in both
183
+ modes, including primary, destructive, sidebar, status-surface, on-fill, and
184
+ muted text against every common surface. When tuning a token, change the
185
+ semantic pair together rather than overriding text in a consumer component.
186
+
170
187
  At the consumer, build the app that imports the preset and verify both modes.
171
188
  The important invariant is that changing a preset requires editing one CSS
172
189
  file, and no React runtime code is involved in applying it.
@@ -6,16 +6,20 @@
6
6
  --card-foreground: hsl(0 0% 7%);
7
7
  --popover: hsl(0 0% 100%);
8
8
  --popover-foreground: hsl(0 0% 7%);
9
- --primary: hsl(211 100% 50%);
9
+ /* Apple system-blue hue, lowered just enough for white small text (4.95:1). */
10
+ --primary: hsl(211 100% 44%);
10
11
  --primary-foreground: hsl(0 0% 100%);
11
12
  --secondary: hsl(240 12% 95%);
12
13
  --secondary-foreground: hsl(0 0% 7%);
13
14
  --muted: hsl(240 8% 91%);
14
- --muted-foreground: hsl(240 3% 44%);
15
+ /* Readable even when muted text sits on the muted fill itself (4.63:1). */
16
+ --muted-foreground: hsl(240 3% 41%);
15
17
  --accent: hsl(211 50% 96%);
16
18
  --accent-foreground: hsl(211 100% 36%);
17
- --destructive: hsl(2 100% 59%);
19
+ /* Keep white destructive labels AA-readable in the light theme (5.69:1). */
20
+ --destructive: hsl(2 80% 44%);
18
21
  --destructive-foreground: hsl(0 0% 100%);
22
+ --on-destructive: hsl(0 0% 100%);
19
23
  --border: hsl(240 3% 78%);
20
24
  --input: hsl(240 8% 93%);
21
25
  --divider: hsl(240 4% 83%);
@@ -24,7 +28,7 @@
24
28
  --overlay: hsl(240 6% 10% / 0.32);
25
29
  --sidebar-background: hsl(240 12% 94%);
26
30
  --sidebar-foreground: hsl(0 0% 7%);
27
- --sidebar-primary: hsl(211 100% 50%);
31
+ --sidebar-primary: hsl(211 100% 44%);
28
32
  --sidebar-primary-foreground: hsl(0 0% 100%);
29
33
  --sidebar-accent: hsl(211 50% 93%);
30
34
  --sidebar-accent-foreground: hsl(211 100% 36%);
@@ -76,15 +80,17 @@
76
80
  --popover: hsl(240 3% 20%);
77
81
  --popover-foreground: hsl(0 0% 95%);
78
82
  --primary: hsl(211 100% 58%);
79
- --primary-foreground: hsl(0 0% 100%);
83
+ /* Bright system blue needs dark ink for compact controls (5.59:1). */
84
+ --primary-foreground: hsl(0 0% 9%);
80
85
  --secondary: hsl(240 3% 20%);
81
86
  --secondary-foreground: hsl(0 0% 88%);
82
87
  --muted: hsl(240 3% 11%);
83
- --muted-foreground: hsl(240 4% 58%);
88
+ /* Clears AA on background, card, popover, muted, and accent surfaces. */
89
+ --muted-foreground: hsl(240 4% 62%);
84
90
  --accent: hsl(211 25% 19%);
85
91
  --accent-foreground: hsl(211 100% 72%);
86
92
  --destructive: hsl(3 100% 62%);
87
- --destructive-foreground: hsl(0 0% 100%);
93
+ --destructive-foreground: hsl(0 0% 9%);
88
94
  --border: hsl(240 3% 22%);
89
95
  --input: hsl(240 3% 22%);
90
96
  --divider: hsl(240 4% 46% / 0.18);
@@ -94,7 +100,7 @@
94
100
  --sidebar-background: hsl(240 5% 5%);
95
101
  --sidebar-foreground: hsl(0 0% 90%);
96
102
  --sidebar-primary: hsl(211 100% 58%);
97
- --sidebar-primary-foreground: hsl(0 0% 100%);
103
+ --sidebar-primary-foreground: hsl(0 0% 9%);
98
104
  --sidebar-accent: hsl(211 22% 16%);
99
105
  --sidebar-accent-foreground: hsl(211 100% 72%);
100
106
  --sidebar-border: hsl(240 3% 22%);
@@ -10,8 +10,10 @@
10
10
  */
11
11
 
12
12
  :root {
13
- /* Base colors - Clean white with neutral grays */
14
- --background: hsl(0 0% 94%);
13
+ /* Base colors - an off-white canvas with darker neutral fills and a white
14
+ * raised card. Keep `muted` below `background` in light mode so chips,
15
+ * message bubbles, and secondary controls never dissolve into the page. */
16
+ --background: hsl(0 0% 98%);
15
17
  --foreground: hsl(0 0% 9%);
16
18
  --card: hsl(0 0% 100%);
17
19
  --card-foreground: hsl(0 0% 9%);
@@ -22,7 +24,7 @@
22
24
  --primary-foreground: hsl(0 0% 100%);
23
25
  --secondary: hsl(0 0% 9%);
24
26
  --secondary-foreground: hsl(0 0% 98%);
25
- --muted: hsl(0 0% 96%);
27
+ --muted: hsl(0 0% 94%);
26
28
  --muted-foreground: hsl(0 0% 45%);
27
29
  /* Neutral interaction surface: a quiet step below the current light canvas.
28
30
  * It deliberately keys off --background, not white --card, so hover remains