@djangocfg/ui-core 2.1.432 → 2.1.434

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.432",
3
+ "version": "2.1.434",
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.432",
109
+ "@djangocfg/i18n": "^2.1.434",
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.432",
184
- "@djangocfg/typescript-config": "^2.1.432",
183
+ "@djangocfg/i18n": "^2.1.434",
184
+ "@djangocfg/typescript-config": "^2.1.434",
185
185
  "@types/node": "^25.2.3",
186
186
  "@types/react": "^19.2.15",
187
187
  "@types/react-dom": "^19.2.3",
@@ -12,9 +12,9 @@ import { createPasteHandler, useSmartOTP } from './use-otp-input';
12
12
  import type { SmartOTPProps } from './types'
13
13
 
14
14
  /**
15
- * Size variants for OTP slots.
16
- * In fluid mode these only set font-size width/height are driven by the container.
17
- * In fixed mode they set explicit w/h dimensions.
15
+ * Size variants for OTP slots — fixed square dimensions (Vercel/Linear look).
16
+ * The boxes never stretch to the container width: a code is a small, tidy
17
+ * cluster of fixed cells, centered, not a row of full-width fields.
18
18
  */
19
19
  const sizeVariants = {
20
20
  sm: 'h-10 w-10 text-base',
@@ -22,14 +22,6 @@ const sizeVariants = {
22
22
  lg: 'h-14 w-14 text-xl',
23
23
  }
24
24
 
25
- // Fluid mode: width is fed by flex-1, height by aspect-square. We only
26
- // need to size the text inside the box.
27
- const sizeTextVariants = {
28
- sm: 'text-base',
29
- default: 'text-lg',
30
- lg: 'text-xl',
31
- }
32
-
33
25
  /**
34
26
  * OTP Separator Component
35
27
  */
@@ -99,7 +91,6 @@ export const OTPInput = React.forwardRef<
99
91
  size,
100
92
  error = false,
101
93
  success = false,
102
- fluid = false,
103
94
  ...props
104
95
  },
105
96
  ref
@@ -160,9 +151,7 @@ export const OTPInput = React.forwardRef<
160
151
  key={i}
161
152
  index={i}
162
153
  className={cn(
163
- fluid
164
- ? `flex-1 min-w-0 aspect-square ${sizeTextVariants[resolvedSize]}`
165
- : sizeVariants[resolvedSize],
154
+ sizeVariants[resolvedSize],
166
155
  error && 'border-destructive ring-destructive/20',
167
156
  success && 'border-success ring-success/20',
168
157
  slotClassName
@@ -172,7 +161,7 @@ export const OTPInput = React.forwardRef<
172
161
  }
173
162
 
174
163
  return slotElements
175
- }, [length, showSeparator, separatorPosition, separatorClassName, resolvedSize, fluid, error, success, slotClassName])
164
+ }, [length, showSeparator, separatorPosition, separatorClassName, resolvedSize, error, success, slotClassName])
176
165
 
177
166
  return (
178
167
  <InputOTP
@@ -182,12 +171,12 @@ export const OTPInput = React.forwardRef<
182
171
  onChange={handleChange}
183
172
  onComplete={handleComplete}
184
173
  disabled={disabled}
185
- containerClassName={cn('gap-2', fluid && 'w-full', containerClassName)}
174
+ containerClassName={cn('gap-2', containerClassName)}
186
175
  autoFocus={autoFocus}
187
176
  onPaste={pasteHandler}
188
177
  {...props}
189
178
  >
190
- <InputOTPGroup className={cn('gap-2', fluid && 'w-full')}>{slots}</InputOTPGroup>
179
+ <InputOTPGroup className="gap-2">{slots}</InputOTPGroup>
191
180
  </InputOTP>
192
181
  )
193
182
  }
@@ -118,13 +118,6 @@ export interface SmartOTPProps {
118
118
  * Success state
119
119
  */
120
120
  success?: boolean
121
-
122
- /**
123
- * Fluid mode — slots stretch to fill the full container width.
124
- * Useful for responsive layouts where fixed slot widths would overflow.
125
- * @default false
126
- */
127
- fluid?: boolean
128
121
  }
129
122
 
130
123
  /**
@@ -65,7 +65,13 @@ export type { TCountryCode } from 'countries-list';
65
65
 
66
66
  // LanguageSelect
67
67
  export { LanguageSelect } from './language-select';
68
- export type { LanguageSelectProps, LanguageOption, TLanguageCode } from './language-select';
68
+ export type {
69
+ LanguageSelectProps,
70
+ LanguageSelectSize,
71
+ LanguageSelectVariant,
72
+ LanguageOption,
73
+ TLanguageCode,
74
+ } from './language-select';
69
75
 
70
76
  // Shared types
71
77
  export type {
@@ -25,6 +25,10 @@ export interface LanguageOption {
25
25
 
26
26
  export type LanguageSelectVariant = 'dropdown' | 'inline';
27
27
 
28
+ /** Trigger size for the dropdown variant — mirrors Button/Input sizing.
29
+ * `default` = 40px form control; `sm` = 32px compact (settings rows, chips). */
30
+ export type LanguageSelectSize = 'default' | 'sm';
31
+
28
32
  export interface LanguageSelectProps {
29
33
  /** Selected language codes (ISO 639-1) */
30
34
  value?: string[];
@@ -34,6 +38,8 @@ export interface LanguageSelectProps {
34
38
  multiple?: boolean;
35
39
  /** Display variant: dropdown (popover) or inline (scrollable list) */
36
40
  variant?: LanguageSelectVariant;
41
+ /** Trigger size for the dropdown variant (default | sm). Mirrors Button. */
42
+ size?: LanguageSelectSize;
37
43
  /** Placeholder text (default: "Select language...") */
38
44
  placeholder?: string;
39
45
  /** Search placeholder text (default: "Search...") */
@@ -121,6 +127,7 @@ export function LanguageSelect({
121
127
  onChange,
122
128
  multiple = false,
123
129
  variant = 'dropdown',
130
+ size = 'default',
124
131
  placeholder,
125
132
  searchPlaceholder,
126
133
  emptyText,
@@ -363,10 +370,17 @@ export function LanguageSelect({
363
370
  <PopoverTrigger asChild>
364
371
  <Button
365
372
  variant="outline"
373
+ size={size === 'sm' ? 'sm' : 'default'}
366
374
  role="combobox"
367
375
  aria-expanded={open}
368
376
  className={cn(
369
- "w-full justify-between min-h-10 h-auto py-2",
377
+ "w-full justify-between",
378
+ // Single-select: a fixed-height pill (the Button size sets height).
379
+ // Multiple: allow growth so wrapped badges aren't clipped — keep the
380
+ // auto-height min behaviour, scaled to the chosen size.
381
+ multiple
382
+ ? (size === 'sm' ? "min-h-8 h-auto py-1" : "min-h-10 h-auto py-2")
383
+ : (size === 'sm' ? "text-xs" : "h-10"),
370
384
  className
371
385
  )}
372
386
  disabled={disabled}
@@ -374,17 +388,25 @@ export function LanguageSelect({
374
388
  <div className="flex-1 text-left overflow-hidden">
375
389
  {displayValue}
376
390
  </div>
377
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
391
+ <ChevronsUpDown className={cn("ml-2 shrink-0 opacity-50", size === 'sm' ? "h-3.5 w-3.5" : "h-4 w-4")} />
378
392
  </Button>
379
393
  </PopoverTrigger>
380
- <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
394
+ {/* The list must stay readable even when the trigger is a narrow
395
+ status-bar/settings pill: grow to the trigger width but never below a
396
+ legible min, so language names aren't clipped to "En…". */}
397
+ <PopoverContent
398
+ className="w-auto min-w-[var(--radix-popover-trigger-width)] p-0 [--lang-min:11rem] [min-width:max(var(--radix-popover-trigger-width),var(--lang-min))]"
399
+ align="start"
400
+ >
381
401
  <Command shouldFilter={false} className="flex flex-col">
382
- <CommandInput
383
- placeholder={resolvedSearchPlaceholder}
384
- className="shrink-0"
385
- value={search}
386
- onValueChange={setSearch}
387
- />
402
+ {showSearch && (
403
+ <CommandInput
404
+ placeholder={resolvedSearchPlaceholder}
405
+ className="shrink-0"
406
+ value={search}
407
+ onValueChange={setSearch}
408
+ />
409
+ )}
388
410
  <CommandList className="max-h-[300px] overflow-y-auto">
389
411
  {filteredLanguages.length === 0 ? (
390
412
  <CommandEmpty>{resolvedEmptyText}</CommandEmpty>
@@ -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.
@@ -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".