@djangocfg/ui-core 2.1.431 → 2.1.433

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
@@ -68,7 +68,7 @@ Imports stay flat — group folders are organisational.
68
68
  | Topic | Hooks |
69
69
  |---|---|
70
70
  | `dom/` | `useSize` · `useResizeObserver` · `useMeasure` · `useMutationObserver` · `useIntersection` |
71
- | `device/` | `useIsMobile` · `useMediaQuery` · `useOnline` · `useViewportSize` · `useOrientation` |
71
+ | `device/` | `useIsMobile` · `useIsTouch` · `useMediaQuery` · `useOnline` · `useViewportSize` · `useOrientation` |
72
72
  | `state/` | `useLocalStorage` · `useSessionStorage` · `useToggle` · `useCounter` · `useDebouncedValue` |
73
73
  | `events/` | `useEventListener` · `useClickOutside` · `useKeyPress` · `useFocusWithin` |
74
74
  | `theme/` | `useTheme` · `useResolvedTheme` · `useThemePreset` |
@@ -112,6 +112,8 @@ Tokens live in `:root` / `.dark` as fully-wrapped CSS colors; `@theme inline` ex
112
112
 
113
113
  `@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.
114
114
 
115
+ Tailwind's `text-*` size utilities are bridged to the preset-overridable `--font-size-*` scale, so overriding those vars (globally via `buildThemeStyleSheet({ vars })` or scoped on a selector) re-sizes all `text-*` at once — no per-component edits. See `src/styles/README.md` § Typography tokens.
116
+
115
117
  **Programmatic theme colors** for Canvas / SVG / Mermaid:
116
118
 
117
119
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.431",
3
+ "version": "2.1.433",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -106,7 +106,7 @@
106
106
  "check": "tsc --noEmit"
107
107
  },
108
108
  "peerDependencies": {
109
- "@djangocfg/i18n": "^2.1.431",
109
+ "@djangocfg/i18n": "^2.1.433",
110
110
  "consola": "^3.4.2",
111
111
  "lucide-react": "^0.545.0",
112
112
  "moment": "^2.30.1",
@@ -180,8 +180,8 @@
180
180
  "@chenglou/pretext": "*"
181
181
  },
182
182
  "devDependencies": {
183
- "@djangocfg/i18n": "^2.1.431",
184
- "@djangocfg/typescript-config": "^2.1.431",
183
+ "@djangocfg/i18n": "^2.1.433",
184
+ "@djangocfg/typescript-config": "^2.1.433",
185
185
  "@types/node": "^25.2.3",
186
186
  "@types/react": "^19.2.15",
187
187
  "@types/react-dom": "^19.2.3",
@@ -47,7 +47,7 @@ const ContextMenuSubContent = React.forwardRef<
47
47
  <ContextMenuPrimitive.SubContent
48
48
  ref={ref}
49
49
  className={cn(
50
- "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",
50
+ "z-[700] min-w-32 overflow-hidden rounded-[var(--radius-popover)] border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-75 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-98 motion-reduce:data-[state=open]:animate-none",
51
51
  className
52
52
  )}
53
53
  {...props}
@@ -63,7 +63,7 @@ const ContextMenuContent = React.forwardRef<
63
63
  <ContextMenuPrimitive.Content
64
64
  ref={ref}
65
65
  className={cn(
66
- "z-[700] max-h-[--radix-context-menu-content-available-height] min-w-[8rem] 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",
66
+ "z-[700] max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-[var(--radius-popover)] border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-75 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-98 motion-reduce:data-[state=open]:animate-none",
67
67
  className
68
68
  )}
69
69
  {...props}
@@ -19,10 +19,10 @@ const AlertDialogOverlay = React.forwardRef<
19
19
  >(({ className, style, ...props }, ref) => (
20
20
  <AlertDialogPrimitive.Overlay
21
21
  className={cn(
22
- "fixed inset-0 z-600 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
22
+ "fixed inset-0 z-600 bg-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
23
23
  className
24
24
  )}
25
- style={{ backgroundColor: 'rgb(0 0 0 / 0.8)', ...style }}
25
+ style={style}
26
26
  {...props}
27
27
  ref={ref}
28
28
  />
@@ -22,10 +22,10 @@ const DialogOverlay = React.forwardRef<
22
22
  <DialogPrimitive.Overlay
23
23
  ref={ref}
24
24
  className={cn(
25
- "fixed inset-0 z-600 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
25
+ "fixed inset-0 z-600 bg-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
26
26
  className
27
27
  )}
28
- style={{ backgroundColor: 'rgb(0 0 0 / 0.8)', ...style }}
28
+ style={style}
29
29
  {...props}
30
30
  />
31
31
  ))
@@ -50,8 +50,8 @@ const DrawerOverlay = React.forwardRef<
50
50
  >(({ className, style, ...props }, ref) => (
51
51
  <DrawerPrimitive.Overlay
52
52
  ref={ref}
53
- className={cn("fixed inset-0 z-500 transition-opacity duration-200", className)}
54
- style={{ backgroundColor: 'rgb(0 0 0 / 0.8)', ...style }}
53
+ className={cn("fixed inset-0 z-500 bg-overlay transition-opacity duration-200", className)}
54
+ style={style}
55
55
  {...props}
56
56
  />
57
57
  ))
@@ -22,10 +22,10 @@ const SheetOverlay = React.forwardRef<
22
22
  >(({ className, style, ...props }, ref) => (
23
23
  <SheetPrimitive.Overlay
24
24
  className={cn(
25
- "fixed inset-0 z-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
25
+ "fixed inset-0 z-200 bg-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
26
26
  className
27
27
  )}
28
- style={{ backgroundColor: 'rgb(0 0 0 / 0.8)', ...style }}
28
+ style={style}
29
29
  {...props}
30
30
  ref={ref}
31
31
  />
@@ -3,3 +3,4 @@
3
3
  export { useMediaQuery, BREAKPOINTS as MEDIA_BREAKPOINTS } from './useMediaQuery';
4
4
  export { useIsMobile, useIsPhone, useIsTabletOrBelow, BREAKPOINTS } from './useMobile';
5
5
  export type { Breakpoint } from './useMobile';
6
+ export { useIsTouch } from './useIsTouch';
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+
3
+ import { useMediaQuery } from './useMediaQuery';
4
+
5
+ /**
6
+ * Is the primary pointer a touch (coarse) pointer?
7
+ *
8
+ * Reads `(pointer: coarse)` via `matchMedia` — NOT the userAgent. A media
9
+ * query reflects real touchscreens, DevTools device emulation and updates
10
+ * live; UA sniffing misses touch laptops, mis-detects iPadOS, and never
11
+ * reacts to a change. SSR-safe (false until mounted).
12
+ *
13
+ * For "touch OR narrow viewport" (e.g. lock an embedded map to a tap-to-
14
+ * expand affordance), compose with `useMediaQuery`:
15
+ *
16
+ * @example
17
+ * const isTouch = useIsTouch()
18
+ * const isNarrow = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`)
19
+ * const isMobileSurface = isTouch || isNarrow
20
+ */
21
+ export function useIsTouch(): boolean {
22
+ return useMediaQuery('(pointer: coarse)');
23
+ }
package/src/lib/styles.ts CHANGED
@@ -24,7 +24,7 @@ const visuallyHidden: React.CSSProperties = {
24
24
  const overlay: React.CSSProperties = {
25
25
  position: "fixed",
26
26
  inset: 0,
27
- backgroundColor: "rgba(0, 0, 0, 0.4)",
27
+ backgroundColor: "var(--overlay)",
28
28
  backdropFilter: "blur(2px)",
29
29
  zIndex: 50,
30
30
  } as const;
@@ -42,6 +42,16 @@ This makes opacity modifiers (`bg-card/40`, `border-foreground/20`) resolve thro
42
42
 
43
43
  > **Do NOT wrap tokens in `hsl(var(--X))`.** Tokens are already full colors, so `hsl(hsl(...))` is invalid and falls back to the default. Use `var(--X)` or `color-mix(in oklab, var(--X) N%, transparent)` for manual opacity.
44
44
 
45
+ > **Do NOT set a token to a bare HSL triplet.** The utilities read the token
46
+ > **raw** (`.bg-muted { background-color: var(--muted) }`), so `--muted: 0 0% 10%`
47
+ > resolves to the *string* `"0 0% 10%"` — not a color — and the declaration is
48
+ > silently dropped: **transparent fills + white-fallback borders.** Always write
49
+ > the full color: `--muted: hsl(0 0% 10%)`. This bites most often when a component
50
+ > overrides tokens **inline** (e.g. an old `ForceTheme` wrapper). If part of a page
51
+ > has vanished backgrounds / white borders, that's this — see
52
+ > [`../theme/TROUBLESHOOTING.md`](../theme/TROUBLESHOOTING.md) and prefer
53
+ > `ThemeOverride` over inline token maps.
54
+
45
55
  ## Semantic tokens — never use raw color scales
46
56
 
47
57
  **Rule:** never write `bg-amber-500`, `text-green-700`, `border-gray-200`. Use semantic tokens — they adapt to both themes and to whatever preset is active.
@@ -60,6 +70,7 @@ This makes opacity modifiers (`bg-card/40`, `border-foreground/20`) resolve thro
60
70
  | `bg-destructive` / `text-destructive-foreground` | `--destructive` | Error / delete filled controls |
61
71
  | `border-border` | `--border` | Card outlines, control borders |
62
72
  | `border-divider` / `.divider-b` | `--divider` | **Hairline between rows** — deliberately *lighter than `--card`* so it stays visible on elevated surfaces (a `--border` line vanishes on a card) |
73
+ | `bg-overlay` | `--overlay` | Modal scrim / backdrop behind dialogs, drawers, sheets — black scrim in both themes, the token owns the opacity |
63
74
  | `bg-input` | `--input` | Input **fill** — a notch off the panel so fields read as real controls (not flush holes). The input *border* uses `--border`, not `--input` |
64
75
  | `ring-ring` | `--ring` | Focus rings, selected outlines — **blue** (system-accent feel), independent of the cyan brand |
65
76
 
@@ -84,6 +95,29 @@ Available statuses: **`warning`** · **`success`** · **`destructive`** · **`in
84
95
  </div>
85
96
  ```
86
97
 
98
+ #### On-fill text — `*-foreground` vs `on-*` (read this before styling a badge)
99
+
100
+ There are **two** text tokens per status and they are NOT interchangeable:
101
+
102
+ | Token | Class | Sits on | Use for |
103
+ |---|---|---|---|
104
+ | `--{status}-foreground` | `text-success-foreground` | the tinted **`*-background`** | banner / alert copy (a *colored* tint) |
105
+ | `--on-{status}` | `text-on-success` | the **solid `*` fill** | text/icon on a filled badge, unread pill, filled chip |
106
+
107
+ `*-foreground` is a *status-colored* tint tuned for the faint banner surface — on the solid fill it produces **green-text-on-green-fill** (unreadable). `on-*` is a near-black / near-white contrast ink (the WhatsApp/Telegram pattern: dark text on the green pill) that meets WCAG AA against the fill in both themes.
108
+
109
+ ```tsx
110
+ {/* ✅ unread count pill — dark ink on the green fill */}
111
+ <span className="rounded-full bg-success px-2 py-0.5 text-xs font-semibold text-on-success">
112
+ {unread}
113
+ </span>
114
+
115
+ {/* ❌ green-on-green — *-foreground is for banners, not the fill */}
116
+ <span className="bg-success text-success-foreground">{unread}</span>
117
+ ```
118
+
119
+ On-fill tokens exist for **`success`** · **`warning`** · **`info`** · **`destructive`** in both themes. (`--primary-foreground` / `--secondary-foreground` / `--destructive-foreground` already play the on-fill role for those base fills — they're genuine contrast colors, not tints, so no `on-*` is needed for them.) The base `on-*` is a near-black; a preset only re-declares it when it retints a fill dark enough that near-black fails — e.g. **`windows`** light mode sets `on-success` / `on-info` to white because Fluent's light green/blue fills are very dark.
120
+
87
121
  The **brand presets** (`macos` / `ios` / `windows` / `django-cfg`) retint the full
88
122
  status set to their own canvas, so banners read correctly on their custom
89
123
  backgrounds. The **modifier presets** (`soft` / `dense` / `high-contrast`) and
@@ -139,6 +173,73 @@ so `font-sans` / `font-mono` and `text-xs … text-xl` follow the active preset
139
173
  instead of Tailwind's hardcoded defaults. `body` applies font-sans + base
140
174
  size/line-height/tracking directly.
141
175
 
176
+ #### The font-size scale → `text-*` bridge (one source of truth)
177
+
178
+ The size tokens are plain rem values. The `macos` preset
179
+ (`presets/themes/macos.ts`) sets them to the Apple HIG point scale:
180
+
181
+ | Token | macos value | px (@1×) | Used for |
182
+ |---|---|---|---|
183
+ | `--font-size-xs` | `0.6875rem` | 11px | captions, timestamps |
184
+ | `--font-size-sm` | `0.75rem` | 12px | footnotes, secondary labels |
185
+ | `--font-size-base` | `0.8125rem` | 13px | HIG default body |
186
+ | `--font-size-lg` | `0.9375rem` | 15px | subheadings |
187
+ | `--font-size-xl` | `1.0625rem` | 17px | titles, nav bar |
188
+
189
+ **The key fact:** Tailwind's `text-*` utilities don't read their own hardcoded
190
+ sizes — they're bridged to these vars in `tokens.css` via `@theme inline`:
191
+
192
+ ```css
193
+ @theme inline {
194
+ --text-xs: var(--font-size-xs);
195
+ --text-sm: var(--font-size-sm);
196
+ --text-base: var(--font-size-base);
197
+ --text-lg: var(--font-size-lg);
198
+ --text-xl: var(--font-size-xl);
199
+ }
200
+ ```
201
+
202
+ So `--font-size-*` is the **single source of truth** for text sizing: change one
203
+ var and **every** `text-sm` / `text-base` / … in that scope re-sizes uniformly —
204
+ no per-component edits, no chasing `text-[15px]` literals across the tree.
205
+
206
+ #### Override recipe (a) — global bump via `buildThemeStyleSheet`
207
+
208
+ To lift the whole UI a notch (e.g. a desktop consumer that wants 15px body),
209
+ pass `vars` alongside the preset — they merge on top of the preset's values per
210
+ mode (`buildThemeStyleSheet` → `mergeLayer`), emitting `:root` (light) and
211
+ `.dark` blocks. Every `text-*` utility moves with them:
212
+
213
+ ```ts
214
+ import { buildThemeStyleSheet } from '@djangocfg/ui-core/styles/presets';
215
+
216
+ const css = buildThemeStyleSheet({
217
+ preset: 'macos',
218
+ vars: {
219
+ light: { 'font-size-base': '0.9375rem', 'font-size-sm': '0.8125rem' },
220
+ dark: { 'font-size-base': '0.9375rem', 'font-size-sm': '0.8125rem' },
221
+ },
222
+ });
223
+ // inject `css` after ui-core/styles (cmdop does exactly this)
224
+ ```
225
+
226
+ > Keys are the bare token name (no `--` prefix); `buildThemeStyleSheet` adds it.
227
+
228
+ #### Override recipe (b) — scoped bump on a selector
229
+
230
+ To re-size only a subtree, re-declare the font-size vars on a selector. The
231
+ bridge re-points `text-*` for everything inside it — no preset rebuild:
232
+
233
+ ```css
234
+ .compact-panel {
235
+ --font-size-base: 0.75rem; /* 12px */
236
+ --font-size-sm: 0.6875rem; /* 11px */
237
+ }
238
+ ```
239
+
240
+ **Prefer either recipe over per-component `text-[15px]` hacks** — those drift
241
+ from the scale and don't follow the preset or theme.
242
+
142
243
  ## Radius tokens
143
244
 
144
245
  The radius **scale** (`--radius`, `--radius-sm`, …) is theme-independent and lives in `base.css`; presets that set a semantic `radius` regenerate the scale via `presets/build.ts`. A few named radii are fixed defaults (default preset only):
@@ -46,6 +46,38 @@
46
46
  outline: none;
47
47
  }
48
48
 
49
+ /* Pointer cursor on every interactive control.
50
+ *
51
+ * Tailwind v4 preflight sets `cursor: default` on <button>; a browser adds
52
+ * the implicit pointer back, but a native webview (Wails/Electron WKWebView)
53
+ * does NOT — buttons then feel "dead" (arrow cursor on hover). Restore it
54
+ * here, in the design system, so every consumer gets it without sprinkling
55
+ * `cursor-pointer` on each control. `:where()` keeps specificity at zero, so
56
+ * a component's explicit `cursor-*` (text inputs, drag handles, the
57
+ * `disabled:` states below) always wins. Disabled controls get `not-allowed`.
58
+ */
59
+ :where(
60
+ button,
61
+ a[href],
62
+ [role="button"],
63
+ [role="tab"],
64
+ [role="menuitem"],
65
+ [role="option"],
66
+ [role="switch"],
67
+ [role="checkbox"],
68
+ [role="radio"],
69
+ summary,
70
+ label[for],
71
+ select
72
+ ):not(:disabled):not([aria-disabled="true"]) {
73
+ cursor: pointer;
74
+ }
75
+
76
+ :where(button, a, [role="button"]):disabled,
77
+ :where(button, a, [role="button"])[aria-disabled="true"] {
78
+ cursor: not-allowed;
79
+ }
80
+
49
81
  html {
50
82
  font-weight: 400;
51
83
  }
@@ -77,6 +77,13 @@ export const windowsPreset: ThemePreset = {
77
77
  'info-background': 'hsl(210 100% 95%)',
78
78
  'info-foreground': 'hsl(210 90% 28%)',
79
79
  'info-border': 'hsl(210 80% 78%)',
80
+ // On-fill ink: Fluent's light success/info fills are DARK (27%/38% L), so
81
+ // near-black (base --on-*) fails — these two need WHITE ink instead.
82
+ // on-success vs success (120 78% 27%) → 5.5:1 (AA)
83
+ // on-info vs info (210 100% 38%) → 6.0:1 (AA)
84
+ // warning/destructive light fills stay near-black (base value is correct).
85
+ 'on-success': 'hsl(0 0% 100%)',
86
+ 'on-info': 'hsl(0 0% 100%)',
80
87
  ...fluentTypography,
81
88
  },
82
89
  dark: {
@@ -44,6 +44,13 @@ export type ThemeCssVarChromeKey = 'border' | 'input' | 'divider' | 'ring' | 'ra
44
44
  * custom `background` should re-tint these so banners don't clash with the
45
45
  * canvas; presets that keep the default canvas can omit them. All are
46
46
  * **fully-wrapped CSS colors** (same rule as `ThemeCssVarColorKey`).
47
+ *
48
+ * `*-foreground` = status TEXT on the tinted `*-background` (banners).
49
+ * `on-*` = readable text/icon ON the solid `*` fill (badges, pills, filled
50
+ * chips) — a near-black/near-white contrast color, NOT the banner tint. The
51
+ * base `on-*` (near-black) is safe for most fills; a preset only needs to set
52
+ * `on-*` when it retints a fill DARK enough that near-black fails (e.g.
53
+ * Fluent's dark light-mode green/blue → white ink).
47
54
  */
48
55
  export type ThemeCssVarStatusKey =
49
56
  | 'warning'
@@ -59,7 +66,11 @@ export type ThemeCssVarStatusKey =
59
66
  | 'info'
60
67
  | 'info-background'
61
68
  | 'info-foreground'
62
- | 'info-border';
69
+ | 'info-border'
70
+ | 'on-success'
71
+ | 'on-warning'
72
+ | 'on-info'
73
+ | 'on-destructive';
63
74
 
64
75
  /**
65
76
  * Typography tokens — override system font stack and scale per preset.
@@ -47,6 +47,9 @@
47
47
  /* Divider — hairline; slightly softer than --border so rows don't read as a
48
48
  * hard rule (Claude menus/rows). */
49
49
  --divider: hsl(48 3% 24%);
50
+ /* Overlay — modal scrim / backdrop behind dialogs, drawers, sheets. Black in
51
+ * both themes; slightly darker here so it still reads on the dark page. */
52
+ --overlay: hsl(0 0% 0% / 0.7);
50
53
  /* Focus ring — blue (system-accent feel), independent of the cyan brand. */
51
54
  --ring: hsl(217 91% 60%);
52
55
  --shadow-card: none;
@@ -76,6 +79,11 @@
76
79
  * Dark variants: dim backgrounds (~8-12% lightness) so banners
77
80
  * don't blow out against the near-black page. Foreground raised
78
81
  * to ~70-80% lightness for contrast. Borders kept muted.
82
+ *
83
+ * `--{status}-foreground` is the LIGHT status tint for text on the
84
+ * `*-background` banner — NOT a contrast color for the solid `*` fill
85
+ * (light-green-on-green is unreadable). For text/icons ON the solid
86
+ * fill use `--on-{status}` below.
79
87
  * ─────────────────────────────────────────────────────────────── */
80
88
 
81
89
  /* Warning — amber */
@@ -96,6 +104,18 @@
96
104
  --info-background: hsl(200 80% 8%);
97
105
  --info-foreground: hsl(200 80% 75%);
98
106
  --info-border: hsl(200 60% 25%);
107
+
108
+ /* On-fill text/icon — readable color ON the solid `*` fill (badges,
109
+ * unread pills, filled chips). The status fills here are bright/mid
110
+ * (45-60% L) so near-black ink contrasts best (WhatsApp pattern).
111
+ * on-success vs --success (142 60% 45%) → 6.9:1 (AA)
112
+ * on-warning vs --warning (38 92% 50%) → 8.4:1 (AA)
113
+ * on-info vs --info (200 80% 55%) → 6.9:1 (AA)
114
+ * on-destructive vs --destructive (0 67% 60%) → 4.7:1 (AA) */
115
+ --on-success: hsl(0 0% 9%);
116
+ --on-warning: hsl(0 0% 9%);
117
+ --on-info: hsl(0 0% 9%);
118
+ --on-destructive: hsl(0 0% 9%);
99
119
  /* Surface gradient dark */
100
120
  --surface-gradient: linear-gradient(to bottom, var(--background), color-mix(in oklab, var(--background) 80%, transparent));
101
121
 
@@ -36,6 +36,9 @@
36
36
  /* Divider — hairline between rows; slightly darker than --border so it reads
37
37
  * on white cards. */
38
38
  --divider: hsl(0 0% 88%);
39
+ /* Overlay — modal scrim / backdrop behind dialogs, drawers, sheets. A scrim
40
+ * is black in both themes; the token owns the opacity. */
41
+ --overlay: hsl(0 0% 0% / 0.6);
39
42
  /* Focus ring — blue (system-accent feel), independent of the cyan brand. */
40
43
  --ring: hsl(217 91% 55%);
41
44
  --shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.04);
@@ -69,6 +72,11 @@
69
72
  * readable foreground text, and border ring.
70
73
  * Use bg-warning-background / text-warning-foreground / border-warning-border
71
74
  * in components — never raw amber-* or green-* classes.
75
+ *
76
+ * `--{status}-foreground` is status TEXT on the tinted `*-background`
77
+ * (banners) — it is a colored tint, NOT a contrast color for the solid
78
+ * `*` fill. For text/icons sitting ON the solid fill (badges, pills,
79
+ * filled chips) use the `--on-{status}` tokens below instead.
72
80
  * ─────────────────────────────────────────────────────────────── */
73
81
 
74
82
  /* Warning — amber */
@@ -89,6 +97,19 @@
89
97
  --info-background: hsl(200 100% 97%);
90
98
  --info-foreground: hsl(200 80% 20%);
91
99
  --info-border: hsl(200 80% 65%);
100
+
101
+ /* On-fill text/icon — readable color sitting ON the solid `*` fill
102
+ * (badges, unread pills, filled chips), distinct from the `*-foreground`
103
+ * banner tints above. The mid-lightness green/amber/blue/red fills all
104
+ * take a near-black ink (WhatsApp/Telegram pattern) for the best contrast.
105
+ * on-success vs --success (142 71% 45%) → 7.8:1 (AA)
106
+ * on-warning vs --warning (38 92% 50%) → 8.4:1 (AA)
107
+ * on-info vs --info (200 90% 40%) → 4.4:1 (AA-large / borderline AA)
108
+ * on-destructive vs --destructive (0 84% 60%) → 4.7:1 (AA) */
109
+ --on-success: hsl(0 0% 9%);
110
+ --on-warning: hsl(0 0% 9%);
111
+ --on-info: hsl(0 0% 9%);
112
+ --on-destructive: hsl(0 0% 9%);
92
113
  /* Surface gradient */
93
114
  --surface-gradient: linear-gradient(to bottom, var(--background), color-mix(in oklab, var(--background) 80%, transparent));
94
115
 
@@ -45,6 +45,7 @@
45
45
  --color-border: var(--border);
46
46
  --color-input: var(--input);
47
47
  --color-divider: var(--divider);
48
+ --color-overlay: var(--overlay);
48
49
  --color-ring: var(--ring);
49
50
 
50
51
  /* Status surfaces */
@@ -66,6 +67,15 @@
66
67
  --color-info-foreground: var(--info-foreground);
67
68
  --color-info-border: var(--info-border);
68
69
 
70
+ /* On-fill text/icon — contrast color sitting ON the solid `*` fill
71
+ * (badges, unread pills, filled chips). Distinct from `*-foreground`,
72
+ * which is the status tint on the `*-background` banner surface.
73
+ * Backs `text-on-success` / `bg-on-warning` etc. (+ opacity modifiers). */
74
+ --color-on-success: var(--on-success);
75
+ --color-on-warning: var(--on-warning);
76
+ --color-on-info: var(--on-info);
77
+ --color-on-destructive: var(--on-destructive);
78
+
69
79
  /* Code surface */
70
80
  --color-code: var(--code);
71
81
  --color-code-foreground: var(--code-foreground);
@@ -18,88 +18,59 @@ interface ForceThemeProps {
18
18
  className?: string;
19
19
  }
20
20
 
21
- // Dark theme CSS variables
21
+ /**
22
+ * Token contract (ui-core Tailwind v4): base tokens are **full CSS colors**,
23
+ * never bare HSL triplets. The utilities consume them raw — `.bg-muted {
24
+ * background-color: var(--muted) }` — so `--muted: 0 0% 10%` (a triplet) is an
25
+ * invalid color and the declaration is dropped (transparent fills, white
26
+ * borders). Every value here is therefore wrapped in `hsl(...)`. The separate
27
+ * `--color-*` block is intentionally gone: ui-core's `@theme inline` already
28
+ * maps `--color-X: var(--X)`, so re-declaring it here is redundant and was the
29
+ * only reason this component half-worked before. See ui-core styles README
30
+ * §"Token format".
31
+ */
22
32
  const darkThemeVars = {
23
- // Base HSL values
24
- '--background': '0 0% 4%',
25
- '--foreground': '0 0% 98%',
26
- '--card': '0 0% 8%',
27
- '--card-foreground': '0 0% 98%',
28
- '--popover': '0 0% 12%',
29
- '--popover-foreground': '0 0% 98%',
30
- '--primary': '217 91% 60%',
31
- '--primary-foreground': '0 0% 100%',
32
- '--secondary': '0 0% 98%',
33
- '--secondary-foreground': '0 0% 9%',
34
- '--muted': '0 0% 10%',
35
- '--muted-foreground': '0 0% 60%',
36
- '--accent': '0 0% 15%',
37
- '--accent-foreground': '0 0% 98%',
38
- '--destructive': '0 84% 60%',
39
- '--destructive-foreground': '0 0% 98%',
40
- '--border': '0 0% 15%',
41
- '--input': '0 0% 15%',
42
- '--ring': '217 91% 60%',
43
- // Tailwind color tokens (used by bg-*, text-*, etc)
44
- '--color-background': 'hsl(0 0% 4%)',
45
- '--color-foreground': 'hsl(0 0% 98%)',
46
- '--color-card': 'hsl(0 0% 8%)',
47
- '--color-card-foreground': 'hsl(0 0% 98%)',
48
- '--color-primary': 'hsl(217 91% 60%)',
49
- '--color-primary-foreground': 'hsl(0 0% 100%)',
50
- '--color-secondary': 'hsl(0 0% 98%)',
51
- '--color-secondary-foreground': 'hsl(0 0% 9%)',
52
- '--color-muted': 'hsl(0 0% 10%)',
53
- '--color-muted-foreground': 'hsl(0 0% 60%)',
54
- '--color-accent': 'hsl(0 0% 15%)',
55
- '--color-accent-foreground': 'hsl(0 0% 98%)',
56
- '--color-destructive': 'hsl(0 84% 60%)',
57
- '--color-destructive-foreground': 'hsl(0 0% 98%)',
58
- '--color-border': 'hsl(0 0% 15%)',
59
- '--color-input': 'hsl(0 0% 15%)',
60
- '--color-ring': 'hsl(217 91% 60%)',
33
+ '--background': 'hsl(0 0% 4%)',
34
+ '--foreground': 'hsl(0 0% 98%)',
35
+ '--card': 'hsl(0 0% 8%)',
36
+ '--card-foreground': 'hsl(0 0% 98%)',
37
+ '--popover': 'hsl(0 0% 12%)',
38
+ '--popover-foreground': 'hsl(0 0% 98%)',
39
+ '--primary': 'hsl(217 91% 60%)',
40
+ '--primary-foreground': 'hsl(0 0% 100%)',
41
+ '--secondary': 'hsl(0 0% 98%)',
42
+ '--secondary-foreground': 'hsl(0 0% 9%)',
43
+ '--muted': 'hsl(0 0% 10%)',
44
+ '--muted-foreground': 'hsl(0 0% 60%)',
45
+ '--accent': 'hsl(0 0% 15%)',
46
+ '--accent-foreground': 'hsl(0 0% 98%)',
47
+ '--destructive': 'hsl(0 84% 60%)',
48
+ '--destructive-foreground': 'hsl(0 0% 98%)',
49
+ '--border': 'hsl(0 0% 15%)',
50
+ '--input': 'hsl(0 0% 15%)',
51
+ '--ring': 'hsl(217 91% 60%)',
61
52
  } as React.CSSProperties;
62
53
 
63
- // Light theme CSS variables
64
54
  const lightThemeVars = {
65
- // Base HSL values
66
- '--background': '0 0% 96%',
67
- '--foreground': '0 0% 9%',
68
- '--card': '0 0% 100%',
69
- '--card-foreground': '0 0% 9%',
70
- '--popover': '0 0% 100%',
71
- '--popover-foreground': '0 0% 9%',
72
- '--primary': '217 91% 60%',
73
- '--primary-foreground': '0 0% 100%',
74
- '--secondary': '0 0% 9%',
75
- '--secondary-foreground': '0 0% 98%',
76
- '--muted': '0 0% 96%',
77
- '--muted-foreground': '0 0% 40%',
78
- '--accent': '0 0% 92%',
79
- '--accent-foreground': '0 0% 9%',
80
- '--destructive': '0 84% 60%',
81
- '--destructive-foreground': '0 0% 98%',
82
- '--border': '0 0% 90%',
83
- '--input': '0 0% 90%',
84
- '--ring': '217 91% 60%',
85
- // Tailwind color tokens (used by bg-*, text-*, etc)
86
- '--color-background': 'hsl(0 0% 96%)',
87
- '--color-foreground': 'hsl(0 0% 9%)',
88
- '--color-card': 'hsl(0 0% 100%)',
89
- '--color-card-foreground': 'hsl(0 0% 9%)',
90
- '--color-primary': 'hsl(217 91% 60%)',
91
- '--color-primary-foreground': 'hsl(0 0% 100%)',
92
- '--color-secondary': 'hsl(0 0% 9%)',
93
- '--color-secondary-foreground': 'hsl(0 0% 98%)',
94
- '--color-muted': 'hsl(0 0% 96%)',
95
- '--color-muted-foreground': 'hsl(0 0% 40%)',
96
- '--color-accent': 'hsl(0 0% 92%)',
97
- '--color-accent-foreground': 'hsl(0 0% 9%)',
98
- '--color-destructive': 'hsl(0 84% 60%)',
99
- '--color-destructive-foreground': 'hsl(0 0% 98%)',
100
- '--color-border': 'hsl(0 0% 90%)',
101
- '--color-input': 'hsl(0 0% 90%)',
102
- '--color-ring': 'hsl(217 91% 60%)',
55
+ '--background': 'hsl(0 0% 96%)',
56
+ '--foreground': 'hsl(0 0% 9%)',
57
+ '--card': 'hsl(0 0% 100%)',
58
+ '--card-foreground': 'hsl(0 0% 9%)',
59
+ '--popover': 'hsl(0 0% 100%)',
60
+ '--popover-foreground': 'hsl(0 0% 9%)',
61
+ '--primary': 'hsl(217 91% 60%)',
62
+ '--primary-foreground': 'hsl(0 0% 100%)',
63
+ '--secondary': 'hsl(0 0% 9%)',
64
+ '--secondary-foreground': 'hsl(0 0% 98%)',
65
+ '--muted': 'hsl(0 0% 96%)',
66
+ '--muted-foreground': 'hsl(0 0% 40%)',
67
+ '--accent': 'hsl(0 0% 92%)',
68
+ '--accent-foreground': 'hsl(0 0% 9%)',
69
+ '--destructive': 'hsl(0 84% 60%)',
70
+ '--destructive-foreground': 'hsl(0 0% 98%)',
71
+ '--border': 'hsl(0 0% 90%)',
72
+ '--input': 'hsl(0 0% 90%)',
73
+ '--ring': 'hsl(217 91% 60%)',
103
74
  } as React.CSSProperties;
104
75
 
105
76
  export function ForceTheme({ theme, children, className }: ForceThemeProps) {
@@ -0,0 +1,85 @@
1
+ # Theme — providers, switches, and forced themes
2
+
3
+ The runtime side of the design system. `styles/` ships the **tokens** (CSS
4
+ variables) and the Tailwind utilities that read them; `theme/` is the **React**
5
+ layer that decides *which* token set is live (light / dark / a preset) and lets
6
+ you switch or pin it.
7
+
8
+ > Read [`../styles/README.md`](../styles/README.md) first for the **token
9
+ > contract** — it is load-bearing for everything here. The one rule that bites:
10
+ > **base tokens are full CSS colors (`hsl(...)`), never bare triplets.**
11
+
12
+ ## Exports
13
+
14
+ | Export | What it does |
15
+ |---|---|
16
+ | `ThemeProvider` / `useThemeContext` | Wraps `next-themes`. Owns the `html.dark` class + the user's light/dark/system choice. Mount once at the app root (via `BaseApp` in `@djangocfg/layouts`, which mounts it for you). |
17
+ | `ThemeToggle` | A light/dark toggle button. |
18
+ | `ThemeSegmented` | A segmented light/dark/system control. |
19
+ | `ThemeOverride` | **Pathname-aware forced theme.** Mutates the real `next-themes` value while the route matches a rule, restores the user's pick when it leaves. Globs (`*`, `**`) supported. This is the **correct** way to force a route's theme. |
20
+ | `resolveForcedTheme` | Pure helper — first matching rule → forced theme (or `null`). Pair with `ForcedThemeProvider`. |
21
+ | `ForcedThemeProvider` / `useForcedTheme` | Lets descendants read the currently-forced theme. |
22
+ | `ForceTheme` | **Scoped, inline-var theme for a subtree.** A `<div class={theme}>` that re-declares the token vars inline. Use sparingly — see the trap below. |
23
+
24
+ ## Choosing: `ThemeOverride` vs `ForceTheme`
25
+
26
+ They look similar; they are not interchangeable.
27
+
28
+ | | `ThemeOverride` (preferred) | `ForceTheme` (last resort) |
29
+ |---|---|---|
30
+ | How | mutates the real `next-themes` value → toggles `html.dark` | wraps children in `<div class={theme} style={inline vars}>` |
31
+ | Scope | the **whole app** while the route matches | just that **subtree** |
32
+ | Tokens | the real preset/`theme.css` tokens cascade — **always valid** | re-declares a hardcoded token map inline |
33
+ | Active preset | **respected** (macos/ios/django-cfg/…) | **ignored** — ships its own generic palette |
34
+ | Risk | none | bare-triplet trap (below); also overrides your brand accent |
35
+
36
+ **Default to `ThemeOverride`** for "this route is always dark" (the common case —
37
+ a marketing landing, a docs section). It's how the cmdop site forces its homepage
38
+ dark:
39
+
40
+ ```tsx
41
+ // in the app shell (RootAppLayout)
42
+ <BaseApp theme={{ defaultTheme: 'dark' }}>
43
+ <ThemeOverride pathname={pathnameWithoutLocale} rules={[{ path: '/', theme: 'dark' }]} />
44
+ {children}
45
+ </BaseApp>
46
+ ```
47
+
48
+ Reach for `ForceTheme` **only** when you need a single subtree in the opposite
49
+ theme of the rest of the page (e.g. a dark preview card on a light page) and you
50
+ cannot drive it through the router.
51
+
52
+ ## ⚠️ The `ForceTheme` bare-triplet trap
53
+
54
+ `ForceTheme` re-declares tokens inline. Under the **Tailwind v4 token contract**,
55
+ base tokens are **full colors** and the utilities read them raw:
56
+
57
+ ```css
58
+ .bg-muted { background-color: var(--muted); }
59
+ .border-border { border-color: var(--border); }
60
+ ```
61
+
62
+ So a token set to a **bare HSL triplet** is a broken color:
63
+
64
+ ```css
65
+ --muted: 0 0% 10%; /* ❌ var(--muted) → "0 0% 10%" → not a color → declaration dropped */
66
+ --muted: hsl(0 0% 10%);/* ✅ valid color */
67
+ ```
68
+
69
+ The failure is **silent and confusing**: inside the `ForceTheme` subtree
70
+ `bg-muted` renders **transparent** and `border-border` renders the inherited
71
+ fallback (≈ white), while `border-divider` (a token `ForceTheme` doesn't
72
+ re-declare) still works — so it looks like "random borders are white and some
73
+ fills vanished." It is NOT a Tailwind content-scan problem; the utilities exist,
74
+ their *input* is invalid. Full write-up + how to diagnose:
75
+ [`TROUBLESHOOTING.md`](TROUBLESHOOTING.md).
76
+
77
+ `ForceTheme`'s token map is wrapped in `hsl(...)` as of this version, so the trap
78
+ is closed for the values it ships. But the lesson stands for **any** inline token
79
+ override you write: wrap in `hsl(...)`, or prefer `ThemeOverride` so the real
80
+ (already-valid) preset tokens cascade and you never hand-maintain a palette.
81
+
82
+ ## Maintenance rule
83
+
84
+ After changing a component or the token map here, update this README and bump the
85
+ package patch version (consumers pin npm versions; this file is the changelog).
@@ -0,0 +1,89 @@
1
+ # Theme & token troubleshooting
2
+
3
+ Symptom-first. Each entry: what you see → why → the fix.
4
+
5
+ ---
6
+
7
+ ## "Random white borders + transparent fills on part of the page"
8
+
9
+ ### What you see
10
+ - Some surfaces render with **no background** (`bg-muted` / `bg-card` / a custom
11
+ `bg-bubble-*` come out transparent).
12
+ - Some borders render **bright white / light grey** instead of the hairline token
13
+ — `border-border` looks white, but `border-divider` next to it looks correct.
14
+ - It only happens in **one region** of the page (often a marketing landing or a
15
+ card), not the whole app. A sibling app/route with the same components is fine.
16
+
17
+ ### Why (the actual mechanism)
18
+ A subtree is wrapped in **`<ForceTheme>`** (or any hand-written element that sets
19
+ token CSS vars inline) where a token is declared as a **bare HSL triplet**:
20
+
21
+ ```css
22
+ --muted: 0 0% 10%; /* ❌ */
23
+ --border: 0 0% 15%; /* ❌ */
24
+ ```
25
+
26
+ ui-core's utilities consume tokens **raw** (`@theme inline` maps
27
+ `--color-muted: var(--muted)`, and `.bg-muted { background-color: var(--muted) }`).
28
+ So `var(--muted)` resolves to the **string** `"0 0% 10%"`, which is not a valid
29
+ color → the browser **drops the declaration**:
30
+
31
+ - `background-color` drop → falls back to `transparent`.
32
+ - `border-color` drop → falls back to the inherited `currentColor` (≈ the light
33
+ text color on a dark UI → "white border").
34
+
35
+ `border-divider` keeps working because that subtree doesn't *re-declare*
36
+ `--divider`, so it still inherits the valid preset value.
37
+
38
+ **This is NOT a Tailwind content-scan / `@source` problem.** The `.bg-muted`
39
+ rule exists in the built CSS; its *input variable* is the broken thing.
40
+
41
+ ### How to confirm (60 seconds in the browser console)
42
+ ```js
43
+ // 1) is the utility rule transparent on a real element?
44
+ getComputedStyle($0).backgroundColor // rgba(0,0,0,0) on a bg-muted node = bug
45
+
46
+ // 2) is there a ForceTheme div with bare-triplet vars above it?
47
+ [...document.querySelectorAll('div[style*="--muted"]')]
48
+ .map(d => d.style.getPropertyValue('--muted')) // "0 0% 10%" (no hsl) = the culprit
49
+
50
+ // 3) sanity: a probe OUTSIDE the subtree works
51
+ const p = document.createElement('div'); p.className='bg-muted';
52
+ document.body.appendChild(p);
53
+ getComputedStyle(p).backgroundColor // rgb(57,57,60) → proves the utility is fine
54
+ p.remove();
55
+ ```
56
+ If (1) is transparent but (3) is a real color, the input var is broken, not the
57
+ utility — look up the tree for an inline token override.
58
+
59
+ ### Fix (in order of preference)
60
+ 1. **Stop forcing the theme with `ForceTheme`.** If the goal was just "this route
61
+ is dark", drive it through the router with **`ThemeOverride`** in the app shell
62
+ (`rules={[{ path: '/', theme: 'dark' }]}`) and delete the wrapper. The real
63
+ preset tokens (already valid `hsl(...)`) cascade and the bug is gone. This is
64
+ the right fix 90% of the time.
65
+ 2. **If you genuinely need `ForceTheme`** (a subtree in the opposite theme), make
66
+ sure every inline token value is a **full color**: `--muted: hsl(0 0% 10%)`,
67
+ not `0 0% 10%`. ui-core's shipped `ForceTheme` already does this.
68
+ 3. **Never** "fix" it by adding plain `.bg-x { background: var(--x) }` overrides in
69
+ the app's `globals.css` — that masks the broken input and rots.
70
+
71
+ ---
72
+
73
+ ## "ALL utilities missing (only @theme vars load)" — a different bug
74
+
75
+ If `bg-*` / `border-*` are missing **everywhere** (not just one subtree) while the
76
+ `--token` variables are present, that IS a content-scan problem, not this one:
77
+ Tailwind v4 didn't scan your app source. See
78
+ [`../styles/README.md`](../styles/README.md) §"Why `@source` is required" — add
79
+ `@source "../../app"` (path relative to the CSS file) or check the app dir isn't
80
+ `.gitignore`d. Distinguish the two: **scan bug = global**, **token bug = one
81
+ subtree under an inline override.**
82
+
83
+ ---
84
+
85
+ ## "`hsl(var(--x))` shows the default / no color"
86
+
87
+ Tokens are already full colors. `hsl(hsl(0 0% 10%))` is invalid. Use
88
+ `var(--x)` directly, or `color-mix(in oklab, var(--x) N%, transparent)` for
89
+ opacity. See `styles/README.md` §"Token format".