@djangocfg/ui-core 2.1.427 → 2.1.429

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,7 +16,10 @@ Framework-agnostic React UI library: 70+ shadcn/Radix components on Tailwind v4,
16
16
  pnpm add @djangocfg/ui-core
17
17
  ```
18
18
 
19
- Next.js apps should import from [`@djangocfg/ui-nextjs`](../ui-nextjs) instead — it re-exports everything here plus Next-specific bindings.
19
+ Works in any React host. Next.js apps import components from here directly and
20
+ add the Next router adapter from `@djangocfg/ui-core/adapters/nextjs` (see
21
+ **Router adapters** below); Next-specific *server* utilities (sitemap, health,
22
+ OG images, config) live in the separate [`@djangocfg/nextjs`](../nextjs) package.
20
23
 
21
24
  Import styles once at the app root — use the golden path, which pins everything
22
25
  to the right cascade layers so import order can't break layout utilities:
@@ -92,8 +95,7 @@ The router-aware components (`Sidebar`, `Link`, `SSRPagination`) read the active
92
95
 
93
96
  | Adapter | Source |
94
97
  |---|---|
95
- | Next.js App Router | `@djangocfg/ui-nextjs` (auto-wired) |
96
- | Vite / SPA (`react-router`) | `@djangocfg/ui-core/adapters/react-router` |
98
+ | Next.js App Router | `@djangocfg/ui-core/adapters/nextjs` |
97
99
  | Plain `<a>` fallback | default (no adapter) |
98
100
 
99
101
  ## Theming (`/styles`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.427",
3
+ "version": "2.1.429",
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.427",
109
+ "@djangocfg/i18n": "^2.1.429",
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.427",
184
- "@djangocfg/typescript-config": "^2.1.427",
183
+ "@djangocfg/i18n": "^2.1.429",
184
+ "@djangocfg/typescript-config": "^2.1.429",
185
185
  "@types/node": "^25.2.3",
186
186
  "@types/react": "^19.2.15",
187
187
  "@types/react-dom": "^19.2.3",
@@ -311,8 +311,11 @@ const bannerVariants = cva(
311
311
  "bg-success-background text-success-foreground border-success-border",
312
312
  warning:
313
313
  "bg-warning-background text-warning-foreground border-warning-border",
314
+ // `--destructive-foreground` is white (text on a filled red button), so
315
+ // use the red itself as the surface text — readable on the pale red
316
+ // banner bg, consistent with how Alert tints status text.
314
317
  destructive:
315
- "bg-destructive-background text-destructive-foreground border-destructive-border",
318
+ "bg-destructive-background text-destructive border-destructive-border",
316
319
  },
317
320
  },
318
321
  defaultVariants: {
@@ -119,6 +119,14 @@ Button.displayName = "Button"
119
119
  * cmd-click, and the Next.js adapter (if mounted) all work out of the box.
120
120
  * Drop-in replacement for the previous native-`<a>` version.
121
121
  */
122
+ /**
123
+ * ButtonLink — anchor styled as a Button.
124
+ *
125
+ * **Disabled / loading.** An `<a>` has no native `disabled`, so we emulate it
126
+ * the shadcn-recommended way: `aria-disabled` + `tabIndex={-1}` +
127
+ * `pointer-events-none opacity-50`, and we cancel the click. `loading` adds the
128
+ * same inert state plus a spinner — keeping parity with `<Button>`.
129
+ */
122
130
  export interface ButtonLinkProps
123
131
  extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'>,
124
132
  VariantProps<typeof buttonVariants> {
@@ -129,21 +137,46 @@ export interface ButtonLinkProps
129
137
  scroll?: boolean
130
138
  /** Hint for the host framework's prefetcher (Next.js). */
131
139
  prefetch?: boolean | null
140
+ /** Inert + dimmed; click is cancelled and it's removed from tab order. */
141
+ disabled?: boolean
142
+ /** Inert + dimmed + spinner (e.g. while an action resolves). */
143
+ loading?: boolean
132
144
  }
133
145
 
134
146
  const ButtonLink = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
135
- ({ className, variant, size, elevated, href, replace, scroll, prefetch, children, ...props }, ref) => {
147
+ (
148
+ { className, variant, size, elevated, href, replace, scroll, prefetch, disabled = false, loading = false, onClick, children, ...props },
149
+ ref,
150
+ ) => {
151
+ // `<a>` can't be natively disabled — emulate it (aria + tab-order + no
152
+ // pointer events) and hard-cancel the click so navigation can't fire.
153
+ const inert = disabled || loading
154
+ const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
155
+ if (inert) {
156
+ e.preventDefault()
157
+ return
158
+ }
159
+ onClick?.(e)
160
+ }
161
+
136
162
  return (
137
163
  <Link
138
164
  href={href}
139
165
  replace={replace}
140
166
  scroll={scroll}
141
167
  prefetch={prefetch}
142
- className={cn(buttonVariants({ variant, size, elevated, className }))}
168
+ className={cn(
169
+ buttonVariants({ variant, size, elevated, className }),
170
+ inert && 'pointer-events-none opacity-50',
171
+ )}
143
172
  ref={ref}
173
+ onClick={handleClick}
174
+ aria-disabled={inert || undefined}
175
+ tabIndex={inert ? -1 : undefined}
144
176
  {...props}
145
177
  >
146
- {children}
178
+ {loading && <Loader2 className="size-4 animate-spin" />}
179
+ {loading ? filterIcons(children) : children}
147
180
  </Link>
148
181
  )
149
182
  }
@@ -115,7 +115,11 @@ export const SettingRow = React.forwardRef<HTMLDivElement, SettingRowProps>(
115
115
  <div className={cn('min-w-0', stacked ? 'w-full' : 'flex-1')}>
116
116
  <div className="text-sm text-foreground">{label}</div>
117
117
  {description && (
118
- <p className="mt-0.5 text-[13px] leading-relaxed text-muted-foreground">{description}</p>
118
+ // `div`, not `p`: `description` is arbitrary ReactNode and consumers
119
+ // legitimately embed block-level nodes (e.g. a `<Badge>` status pill,
120
+ // which renders a `<div>`) — a `<p>` cannot contain a `<div>` and
121
+ // triggers an invalid-nesting hydration error.
122
+ <div className="mt-0.5 text-[13px] leading-relaxed text-muted-foreground">{description}</div>
119
123
  )}
120
124
  </div>
121
125
  );
@@ -38,12 +38,15 @@ import { NextRouterAdapter, NextLinkProvider } from '@djangocfg/ui-core/adapters
38
38
  <I18nProvider locale={locale} messages={messages}>
39
39
  <NextRouterAdapter>
40
40
  <NextLinkProvider>
41
- <AppLayout>{children}</AppLayout>
41
+ {children}
42
42
  </NextLinkProvider>
43
43
  </NextRouterAdapter>
44
44
  </I18nProvider>
45
45
  ```
46
46
 
47
+ > Using `@djangocfg/layouts`? `BaseApp` already mounts both adapters — you don't
48
+ > need this manual wiring.
49
+
47
50
  `next` is an **optional peer dependency** — the package never imports from `next/*` from the main entry. The Next adapter lives behind the `/adapters/nextjs` sub-path entry, so non-Next consumers don't pull `next` into their bundle.
48
51
 
49
52
  For other routers (TanStack Router, wouter, Remix, custom transports) — write a ~20-line custom adapter:
package/src/lib/env.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  /**
2
- * Environment constants — shared across all @djangocfg packages.
2
+ * Pure environment constants — shared across all @djangocfg UI packages.
3
+ *
4
+ * Keep this to framework-agnostic env primitives only. Feature flags that carry
5
+ * domain meaning (e.g. DPoP auth, static-build behaviour) live in the package
6
+ * that owns them — `dpopEnabled` / `isStaticBuild` are exported from
7
+ * `@djangocfg/api` (the lowest shared package, which implements DPoP).
3
8
  */
4
9
 
5
10
  export const nodeEnv = process.env.NODE_ENV || 'development';
@@ -84,7 +84,12 @@ Available statuses: **`warning`** · **`success`** · **`destructive`** · **`in
84
84
  </div>
85
85
  ```
86
86
 
87
- Most presets don't override `*-background` / `*-border` / `*-foreground`. For preset-agnostic banners, use opacity-derived surfaces:
87
+ The **brand presets** (`macos` / `ios` / `windows` / `django-cfg`) retint the full
88
+ status set to their own canvas, so banners read correctly on their custom
89
+ backgrounds. The **modifier presets** (`soft` / `dense` / `high-contrast`) and
90
+ `default` inherit the base status surfaces. Either way the four-token set is
91
+ always defined, so `bg-warning-background` etc. are safe everywhere. For a fully
92
+ preset-agnostic surface you can still derive from the base color with opacity:
88
93
 
89
94
  ```tsx
90
95
  <div className="rounded-md border border-warning/30 bg-warning/10 text-warning">…</div>
@@ -99,6 +104,41 @@ Most presets don't override `*-background` / `*-border` / `*-foreground`. For pr
99
104
  | `bg-sidebar` / `text-sidebar-foreground` / `border-sidebar-border` | App sidebar chrome |
100
105
  | `bg-sidebar-accent` / `text-sidebar-accent-foreground` | Sidebar hover state |
101
106
 
107
+ ### Chart tokens (categorical palette)
108
+
109
+ `--chart-1 … --chart-5` are the categorical series colors (chart-1 = brand hue).
110
+ Like every color token they are **fully-wrapped `hsl(...)`** and bound to
111
+ Tailwind via `--color-chart-*` in `tokens.css`, so the utilities work with
112
+ opacity modifiers:
113
+
114
+ ```tsx
115
+ <div className="bg-chart-1" /> {/* solid */}
116
+ <div className="bg-chart-3/40" /> {/* 40% via color-mix */}
117
+ <span className="text-chart-2" />
118
+ ```
119
+
120
+ For Recharts / SVG / Canvas, pass the variable directly — **never** wrap it:
121
+
122
+ ```tsx
123
+ <Bar fill="var(--chart-1)" /> {/* ✅ */}
124
+ <Bar fill="hsl(var(--chart-1))" /> {/* ❌ hsl(hsl(...)) — invalid */}
125
+ ```
126
+
127
+ > **JIT-scan gotcha (charts/status).** `bg-chart-${n}` / `bg-${status}-background`
128
+ > built from template literals are invisible to Tailwind's static scanner —
129
+ > only literal classes get a CSS rule. Use literal class names (or inline
130
+ > `style={{ background: 'var(--chart-N)' }}`) when the index is dynamic.
131
+
132
+ ### Typography tokens
133
+
134
+ `--font-sans` / `--font-mono` and the size scale (`--font-size-xs … -xl`,
135
+ `--line-height-base`, `--letter-spacing-base`) live in `base.css` and are
136
+ **overridable per preset** (e.g. `macos` → SF Pro, `windows` → Segoe UI
137
+ Variable). `tokens.css` bridges the size scale onto Tailwind's `--text-*` tokens,
138
+ so `font-sans` / `font-mono` and `text-xs … text-xl` follow the active preset
139
+ instead of Tailwind's hardcoded defaults. `body` applies font-sans + base
140
+ size/line-height/tracking directly.
141
+
102
142
  ## Radius tokens
103
143
 
104
144
  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):
@@ -138,16 +178,25 @@ Some classes are authored as **plain CSS** in `utilities/*.css` (not Tailwind ut
138
178
 
139
179
  ## Theme presets — 8 production-ready
140
180
 
141
- | ID | Use case |
142
- |---|---|
143
- | `default` | Default ui-core themecyan brand |
144
- | `django-cfg` | Brand override (deeper cyan-blue primary) |
145
- | `macos` | Pixel-accurate Apple HIG (Sequoia / Tahoe 26) — Wails/Electron desktop |
146
- | `ios` | iOS app feel 0.75rem radius, systemBlue |
147
- | `windows` | Microsoft Fluent 2 Segoe UI Variable, 0.375rem radius |
148
- | `soft` | Larger radius (1rem) friendly marketing surfaces |
149
- | `dense` | Smaller radius (0.25rem) — data-heavy admin UIs |
150
- | `high-contrast` | A11y boost stronger borders, harder text |
181
+ Two families, with different coverage by design:
182
+
183
+ - **Brand / OS presets** declare a *full* token set colors, sidebar, charts,
184
+ status surfaces, divider, and (macos/windows) typography so the identity is
185
+ self-contained and survives layering over any base.
186
+ - **Modifier presets** override only the chrome they're about (radius / borders /
187
+ contrast) and **inherit** brand colors, charts, and status from whatever is
188
+ active beneath them so they compose (`django-cfg` + `dense`, etc.).
189
+
190
+ | ID | Family | Use case |
191
+ |---|---|---|
192
+ | `default` | base | Default ui-core theme — cyan brand |
193
+ | `django-cfg` | brand | Brand identity — brand-washed accent/sidebar + on-brand `info` |
194
+ | `macos` | brand | Pixel-accurate Apple HIG (Sequoia / Tahoe 26) — SF Pro, Wails/Electron desktop |
195
+ | `ios` | brand | iOS app feel — 0.75rem radius, systemBlue, iOS status colors |
196
+ | `windows` | brand | Microsoft Fluent 2 — Segoe UI Variable, 0.375rem radius |
197
+ | `soft` | modifier | Larger radius (1rem) — friendly marketing surfaces |
198
+ | `dense` | modifier | Smaller radius (0.25rem) — data-heavy admin UIs |
199
+ | `high-contrast` | modifier | A11y boost — stronger borders, harder text, pure canvas |
151
200
 
152
201
  ### Apply a preset
153
202
 
@@ -297,7 +346,12 @@ Now any of these work:
297
346
 
298
347
  ## Theme Showcase story
299
348
 
300
- The `UI Core/Theme Showcase` story in djangocfg storybook renders every base token, status surface, button variant, card, form control, glass utility, and opacity sanity-check on one page. Switch the `preset` control to flip across all 8 themes; flip light/dark from the toolbar.
349
+ The `UI Core/Theme Showcase` story in djangocfg storybook renders every base
350
+ token (incl. `divider`), the real status surfaces (`*-background`/`*-foreground`/
351
+ `*-border`), the chart palette (solid + /40 opacity), the typography scale,
352
+ button variants, cards, form controls, glass utilities, and an opacity
353
+ sanity-check on one page. Switch the `preset` control to flip across all 8
354
+ themes; flip light/dark from the toolbar.
301
355
 
302
356
  Use it to validate any token change before publishing — every regression shows up on one screen.
303
357
 
@@ -5,6 +5,7 @@ export type {
5
5
  ThemeCssVarKey,
6
6
  ThemeCssVarMap,
7
7
  ThemeCssVarSidebarKey,
8
+ ThemeCssVarStatusKey,
8
9
  ThemeCssVarTypographyKey,
9
10
  ThemeStyleConfig,
10
11
  ThemeStylePresetId,
@@ -1,3 +1,12 @@
1
+ /**
2
+ * dense — density modifier (NOT a full color theme).
3
+ *
4
+ * Tight radius (0.25rem) + slightly stronger neutral chrome for data-heavy
5
+ * admin tables/grids where rows need crisp separation. Overrides only neutral
6
+ * surfaces (radius/border/input/divider/muted/card/accent) and inherits brand
7
+ * colors from the active base/brand preset, so it composes with `django-cfg`.
8
+ * Light and dark are symmetric.
9
+ */
1
10
  import type { ThemePreset } from './types';
2
11
 
3
12
  export const densePreset: ThemePreset = {
@@ -5,6 +14,7 @@ export const densePreset: ThemePreset = {
5
14
  radius: '0.25rem',
6
15
  border: 'hsl(0 0% 82%)',
7
16
  input: 'hsl(0 0% 82%)',
17
+ divider: 'hsl(0 0% 88%)',
8
18
  muted: 'hsl(0 0% 94%)',
9
19
  card: 'hsl(0 0% 100%)',
10
20
  accent: 'hsl(0 0% 93%)',
@@ -13,6 +23,7 @@ export const densePreset: ThemePreset = {
13
23
  radius: '0.25rem',
14
24
  border: 'hsl(0 0% 24%)',
15
25
  input: 'hsl(0 0% 24%)',
26
+ divider: 'hsl(0 0% 18%)',
16
27
  muted: 'hsl(0 0% 12%)',
17
28
  card: 'hsl(0 0% 10%)',
18
29
  accent: 'hsl(0 0% 14%)',
@@ -1,21 +1,62 @@
1
+ /**
2
+ * django-cfg brand preset.
3
+ *
4
+ * The default ui-core theme already uses the django-cfg cyan family, so this
5
+ * preset is the *explicit, self-contained* brand declaration: it re-states the
6
+ * full brand-bearing token set (primary, ring, accent wash, sidebar, charts,
7
+ * info status) so the identity survives even when layered over a non-default
8
+ * base, and tints the neutral hover/accent surfaces with a faint brand wash
9
+ * instead of the base's pure grey.
10
+ *
11
+ * Brand cyan: #0989aa (light, `192 90% 35%`) / #00d9ff (dark, `189 100% 50%`).
12
+ * Only color tokens are set — radius/typography inherit base so the brand can
13
+ * be combined with `soft`/`dense` density modifiers downstream.
14
+ */
1
15
  import type { ThemePreset } from './types';
2
16
 
3
17
  export const djangoCfgPreset: ThemePreset = {
4
18
  light: {
5
19
  primary: 'hsl(192 90% 35%)',
6
20
  'primary-foreground': 'hsl(0 0% 100%)',
7
- ring: 'hsl(192 90% 35%)',
21
+ // Faint brand wash on hover/selected surfaces (vs base's neutral grey)
22
+ accent: 'hsl(192 60% 94%)',
23
+ 'accent-foreground': 'hsl(192 90% 22%)',
24
+ ring: 'hsl(192 90% 40%)',
8
25
  'sidebar-primary': 'hsl(192 90% 35%)',
9
- 'sidebar-ring': 'hsl(192 90% 35%)',
26
+ 'sidebar-primary-foreground': 'hsl(0 0% 100%)',
27
+ 'sidebar-accent': 'hsl(192 55% 93%)',
28
+ 'sidebar-accent-foreground': 'hsl(192 90% 22%)',
29
+ 'sidebar-ring': 'hsl(192 90% 40%)',
30
+ // Info status routed to the brand hue so banners read as on-brand
31
+ info: 'hsl(192 90% 35%)',
32
+ 'info-background': 'hsl(192 100% 96%)',
33
+ 'info-foreground': 'hsl(192 90% 22%)',
34
+ 'info-border': 'hsl(192 80% 78%)',
10
35
  'chart-1': 'hsl(192 90% 35%)',
36
+ 'chart-2': 'hsl(142 76% 36%)',
37
+ 'chart-3': 'hsl(262 83% 58%)',
38
+ 'chart-4': 'hsl(26 90% 57%)',
39
+ 'chart-5': 'hsl(346 77% 50%)',
11
40
  },
12
41
  dark: {
13
42
  primary: 'hsl(189 100% 50%)',
14
43
  'primary-foreground': 'hsl(0 0% 9%)',
44
+ accent: 'hsl(189 40% 18%)',
45
+ 'accent-foreground': 'hsl(189 100% 78%)',
15
46
  ring: 'hsl(189 100% 50%)',
16
47
  'sidebar-primary': 'hsl(189 100% 50%)',
17
48
  'sidebar-primary-foreground': 'hsl(0 0% 9%)',
49
+ 'sidebar-accent': 'hsl(189 35% 16%)',
50
+ 'sidebar-accent-foreground': 'hsl(189 100% 78%)',
18
51
  'sidebar-ring': 'hsl(189 100% 50%)',
52
+ info: 'hsl(189 100% 55%)',
53
+ 'info-background': 'hsl(189 70% 10%)',
54
+ 'info-foreground': 'hsl(189 90% 75%)',
55
+ 'info-border': 'hsl(189 50% 28%)',
19
56
  'chart-1': 'hsl(189 100% 50%)',
57
+ 'chart-2': 'hsl(142 76% 50%)',
58
+ 'chart-3': 'hsl(262 83% 65%)',
59
+ 'chart-4': 'hsl(26 90% 60%)',
60
+ 'chart-5': 'hsl(346 77% 58%)',
20
61
  },
21
62
  };
@@ -1,19 +1,35 @@
1
+ /**
2
+ * high-contrast — accessibility modifier (NOT a full color theme).
3
+ *
4
+ * Pushes border/divider/text contrast to meet stronger WCAG ratios: harder
5
+ * borders, darker (light) / brighter (dark) text, a stronger focus ring, and a
6
+ * pure white / near-black canvas so foreground separation is maximal. Brand
7
+ * colors (primary/charts/status) are inherited so the a11y boost stacks on top
8
+ * of any base or brand preset. Light and dark are symmetric.
9
+ */
1
10
  import type { ThemePreset } from './types';
2
11
 
3
12
  export const highContrastPreset: ThemePreset = {
4
13
  light: {
5
- border: 'hsl(0 0% 72%)',
6
- input: 'hsl(0 0% 72%)',
7
- 'muted-foreground': 'hsl(0 0% 32%)',
14
+ background: 'hsl(0 0% 100%)',
8
15
  foreground: 'hsl(0 0% 6%)',
9
- ring: 'hsl(0 0% 20%)',
16
+ card: 'hsl(0 0% 100%)',
17
+ 'card-foreground': 'hsl(0 0% 6%)',
18
+ border: 'hsl(0 0% 62%)',
19
+ input: 'hsl(0 0% 62%)',
20
+ divider: 'hsl(0 0% 68%)',
21
+ 'muted-foreground': 'hsl(0 0% 28%)',
22
+ ring: 'hsl(0 0% 12%)',
10
23
  },
11
24
  dark: {
12
- border: 'hsl(0 0% 38%)',
13
- input: 'hsl(0 0% 38%)',
14
- 'muted-foreground': 'hsl(0 0% 78%)',
25
+ background: 'hsl(0 0% 4%)',
15
26
  foreground: 'hsl(0 0% 100%)',
16
- background: 'hsl(0 0% 6%)',
17
- ring: 'hsl(0 0% 85%)',
27
+ card: 'hsl(0 0% 8%)',
28
+ 'card-foreground': 'hsl(0 0% 100%)',
29
+ border: 'hsl(0 0% 48%)',
30
+ input: 'hsl(0 0% 48%)',
31
+ divider: 'hsl(0 0% 42%)',
32
+ 'muted-foreground': 'hsl(0 0% 82%)',
33
+ ring: 'hsl(0 0% 92%)',
18
34
  },
19
35
  };
@@ -21,6 +21,7 @@ export const iosPreset: ThemePreset = {
21
21
  border: 'hsl(220 9% 88%)',
22
22
  input: 'hsl(220 9% 88%)',
23
23
  ring: 'hsl(211 100% 50%)',
24
+ divider: 'hsl(220 9% 84%)', // hairline, a touch lighter than border
24
25
  radius: '0.75rem',
25
26
  'sidebar-background': 'hsl(0 0% 99%)',
26
27
  'sidebar-foreground': 'hsl(220 9% 12%)',
@@ -35,6 +36,21 @@ export const iosPreset: ThemePreset = {
35
36
  'chart-3': 'hsl(262 83% 58%)',
36
37
  'chart-4': 'hsl(35 100% 50%)',
37
38
  'chart-5': 'hsl(346 77% 50%)',
39
+ // Status surfaces — iOS system colors
40
+ warning: 'hsl(35 100% 50%)',
41
+ 'warning-background': 'hsl(35 100% 95%)',
42
+ 'warning-foreground': 'hsl(28 80% 30%)',
43
+ 'warning-border': 'hsl(35 90% 78%)',
44
+ success: 'hsl(145 63% 42%)',
45
+ 'success-background': 'hsl(145 60% 95%)',
46
+ 'success-foreground': 'hsl(145 60% 24%)',
47
+ 'success-border': 'hsl(145 55% 76%)',
48
+ 'destructive-background': 'hsl(0 100% 96%)',
49
+ 'destructive-border': 'hsl(0 90% 80%)',
50
+ info: 'hsl(211 100% 50%)',
51
+ 'info-background': 'hsl(211 100% 96%)',
52
+ 'info-foreground': 'hsl(211 80% 30%)',
53
+ 'info-border': 'hsl(211 90% 80%)',
38
54
  },
39
55
  dark: {
40
56
  background: 'hsl(240 6% 10%)',
@@ -56,6 +72,7 @@ export const iosPreset: ThemePreset = {
56
72
  border: 'hsl(240 5% 22%)',
57
73
  input: 'hsl(240 5% 22%)',
58
74
  ring: 'hsl(211 100% 55%)',
75
+ divider: 'hsl(240 5% 18%)', // hairline, softer than border
59
76
  radius: '0.75rem',
60
77
  'sidebar-background': 'hsl(240 6% 8%)',
61
78
  'sidebar-foreground': 'hsl(0 0% 96%)',
@@ -70,5 +87,20 @@ export const iosPreset: ThemePreset = {
70
87
  'chart-3': 'hsl(262 83% 65%)',
71
88
  'chart-4': 'hsl(35 100% 58%)',
72
89
  'chart-5': 'hsl(346 77% 58%)',
90
+ // Status surfaces (dark)
91
+ warning: 'hsl(35 100% 58%)',
92
+ 'warning-background': 'hsl(35 90% 10%)',
93
+ 'warning-foreground': 'hsl(35 100% 72%)',
94
+ 'warning-border': 'hsl(35 80% 28%)',
95
+ success: 'hsl(145 65% 52%)',
96
+ 'success-background': 'hsl(145 60% 9%)',
97
+ 'success-foreground': 'hsl(145 60% 70%)',
98
+ 'success-border': 'hsl(145 50% 26%)',
99
+ 'destructive-background': 'hsl(0 80% 11%)',
100
+ 'destructive-border': 'hsl(0 70% 30%)',
101
+ info: 'hsl(211 100% 55%)',
102
+ 'info-background': 'hsl(211 90% 11%)',
103
+ 'info-foreground': 'hsl(211 100% 74%)',
104
+ 'info-border': 'hsl(211 80% 30%)',
73
105
  },
74
106
  };
@@ -56,6 +56,8 @@ export const macosPreset: ThemePreset = {
56
56
  // Kept intentionally light — Apple hairline, not a thick rule
57
57
  border: 'hsl(240 3% 78%)',
58
58
  input: 'hsl(240 8% 93%)',
59
+ // Row hairline — a touch lighter than border so it reads on white cards
60
+ divider: 'hsl(240 4% 85%)',
59
61
  ring: 'hsl(211 100% 50%)',
60
62
  radius: '0.625rem',
61
63
  // Sidebar: slighly darker than page, matches macOS sidebar material
@@ -73,6 +75,22 @@ export const macosPreset: ThemePreset = {
73
75
  'chart-3': 'hsl(262 60% 56%)',
74
76
  'chart-4': 'hsl(35 100% 48%)',
75
77
  'chart-5': 'hsl(2 100% 59%)',
78
+ // Status surfaces — Apple system colors (systemOrange/Green/Red/Blue) with
79
+ // faint tinted banner fills that sit on the #F2F2F7 grouped canvas.
80
+ warning: 'hsl(35 100% 48%)', // systemOrange #FF9500
81
+ 'warning-background': 'hsl(35 100% 95%)',
82
+ 'warning-foreground': 'hsl(28 80% 30%)',
83
+ 'warning-border': 'hsl(35 90% 78%)',
84
+ success: 'hsl(142 71% 45%)', // systemGreen #34C759
85
+ 'success-background': 'hsl(142 60% 95%)',
86
+ 'success-foreground': 'hsl(142 60% 24%)',
87
+ 'success-border': 'hsl(142 55% 76%)',
88
+ 'destructive-background': 'hsl(2 100% 96%)',
89
+ 'destructive-border': 'hsl(2 90% 80%)',
90
+ info: 'hsl(211 100% 50%)', // systemBlue #007AFF
91
+ 'info-background': 'hsl(211 100% 96%)',
92
+ 'info-foreground': 'hsl(211 80% 30%)',
93
+ 'info-border': 'hsl(211 90% 80%)',
76
94
  ...appleTypography,
77
95
  },
78
96
  dark: {
@@ -103,6 +121,8 @@ export const macosPreset: ThemePreset = {
103
121
  // Separator dark: rgba(84,84,88,0.36) — ultra-thin, almost invisible
104
122
  border: 'hsl(240 3% 22%)',
105
123
  input: 'hsl(240 3% 19%)',
124
+ // Row hairline — softer than border so dark rows don't read as a hard rule
125
+ divider: 'hsl(240 3% 18%)',
106
126
  ring: 'hsl(211 100% 58%)',
107
127
  radius: '0.625rem',
108
128
  // Sidebar: near-black floor — #0D0D0F
@@ -120,6 +140,22 @@ export const macosPreset: ThemePreset = {
120
140
  'chart-3': 'hsl(262 60% 65%)',
121
141
  'chart-4': 'hsl(35 100% 56%)',
122
142
  'chart-5': 'hsl(3 100% 62%)',
143
+ // Status surfaces (dark) — Apple dark system colors, dim banner fills on the
144
+ // #141414 canvas with raised foregrounds for contrast.
145
+ warning: 'hsl(35 100% 56%)', // systemOrange dark #FF9F0A
146
+ 'warning-background': 'hsl(35 70% 10%)',
147
+ 'warning-foreground': 'hsl(35 95% 70%)',
148
+ 'warning-border': 'hsl(35 50% 26%)',
149
+ success: 'hsl(142 60% 50%)', // systemGreen dark #30D158
150
+ 'success-background': 'hsl(142 50% 9%)',
151
+ 'success-foreground': 'hsl(142 65% 68%)',
152
+ 'success-border': 'hsl(142 40% 24%)',
153
+ 'destructive-background': 'hsl(3 70% 11%)',
154
+ 'destructive-border': 'hsl(3 55% 30%)',
155
+ info: 'hsl(211 100% 58%)', // systemBlue dark #0A84FF
156
+ 'info-background': 'hsl(211 70% 11%)',
157
+ 'info-foreground': 'hsl(211 90% 72%)',
158
+ 'info-border': 'hsl(211 50% 30%)',
123
159
  ...appleTypography,
124
160
  },
125
161
  };
@@ -1,3 +1,13 @@
1
+ /**
2
+ * soft — density/feel modifier (NOT a full color theme).
3
+ *
4
+ * Larger radius (1rem) + gently lifted neutral surfaces for friendly marketing
5
+ * UIs. It deliberately only overrides the neutral chrome (background/card/muted/
6
+ * accent/border/input/divider/radius + sidebar surfaces) and inherits brand
7
+ * colors (primary/ring/charts/status) from whatever base or brand preset is
8
+ * active, so `soft` composes with `django-cfg` etc. Light and dark are kept
9
+ * symmetric — every key set in one mode is set in the other.
10
+ */
1
11
  import type { ThemePreset } from './types';
2
12
 
3
13
  export const softPreset: ThemePreset = {
@@ -12,7 +22,9 @@ export const softPreset: ThemePreset = {
12
22
  'accent-foreground': 'hsl(240 6% 10%)',
13
23
  border: 'hsl(240 5% 91%)',
14
24
  input: 'hsl(240 5% 91%)',
25
+ divider: 'hsl(240 5% 93%)',
15
26
  radius: '1rem',
27
+ 'sidebar-background': 'hsl(0 0% 99%)',
16
28
  'sidebar-accent': 'hsl(240 5% 94%)',
17
29
  'sidebar-border': 'hsl(240 5% 91%)',
18
30
  },
@@ -27,6 +39,7 @@ export const softPreset: ThemePreset = {
27
39
  'accent-foreground': 'hsl(0 0% 96%)',
28
40
  border: 'hsl(240 5% 20%)',
29
41
  input: 'hsl(240 5% 20%)',
42
+ divider: 'hsl(240 5% 16%)',
30
43
  radius: '1rem',
31
44
  'sidebar-background': 'hsl(240 6% 6%)',
32
45
  'sidebar-accent': 'hsl(240 5% 14%)',
@@ -44,6 +44,8 @@ export const windowsPreset: ThemePreset = {
44
44
  // ControlStrokeColorDefault: rgba(0,0,0,0.0578)
45
45
  border: 'hsl(0 0% 87%)',
46
46
  input: 'hsl(0 0% 90%)',
47
+ // Hairline between rows — slightly lighter than border
48
+ divider: 'hsl(0 0% 84%)',
47
49
  ring: 'hsl(210 100% 45%)',
48
50
  // WinUI 3: 4px controls, 8px cards/dialogs
49
51
  radius: '0.375rem',
@@ -60,6 +62,21 @@ export const windowsPreset: ThemePreset = {
60
62
  'chart-3': 'hsl(262 83% 55%)',
61
63
  'chart-4': 'hsl(35 100% 48%)',
62
64
  'chart-5': 'hsl(346 77% 50%)',
65
+ // Status surfaces — Fluent system colors
66
+ warning: 'hsl(38 92% 50%)',
67
+ 'warning-background': 'hsl(38 100% 95%)',
68
+ 'warning-foreground': 'hsl(30 80% 28%)',
69
+ 'warning-border': 'hsl(38 85% 78%)',
70
+ success: 'hsl(120 78% 27%)',
71
+ 'success-background': 'hsl(120 55% 95%)',
72
+ 'success-foreground': 'hsl(120 60% 28%)',
73
+ 'success-border': 'hsl(120 45% 78%)',
74
+ 'destructive-background': 'hsl(0 90% 96%)',
75
+ 'destructive-border': 'hsl(0 85% 78%)',
76
+ info: 'hsl(210 100% 38%)',
77
+ 'info-background': 'hsl(210 100% 95%)',
78
+ 'info-foreground': 'hsl(210 90% 28%)',
79
+ 'info-border': 'hsl(210 80% 78%)',
63
80
  ...fluentTypography,
64
81
  },
65
82
  dark: {
@@ -86,6 +103,8 @@ export const windowsPreset: ThemePreset = {
86
103
  // ControlStrokeColorDefault dark: rgba(255,255,255,0.0837)
87
104
  border: 'hsl(0 0% 28%)',
88
105
  input: 'hsl(0 0% 24%)',
106
+ // Hairline between rows — softer than border
107
+ divider: 'hsl(0 0% 22%)',
89
108
  ring: 'hsl(200 100% 69%)',
90
109
  radius: '0.375rem',
91
110
  // NavigationView pane background
@@ -102,6 +121,21 @@ export const windowsPreset: ThemePreset = {
102
121
  'chart-3': 'hsl(262 83% 65%)',
103
122
  'chart-4': 'hsl(35 100% 58%)',
104
123
  'chart-5': 'hsl(346 77% 58%)',
124
+ // Status surfaces — Fluent system colors
125
+ warning: 'hsl(38 95% 58%)',
126
+ 'warning-background': 'hsl(38 60% 10%)',
127
+ 'warning-foreground': 'hsl(38 90% 72%)',
128
+ 'warning-border': 'hsl(38 50% 28%)',
129
+ success: 'hsl(120 60% 55%)',
130
+ 'success-background': 'hsl(120 40% 10%)',
131
+ 'success-foreground': 'hsl(120 55% 72%)',
132
+ 'success-border': 'hsl(120 35% 28%)',
133
+ 'destructive-background': 'hsl(0 60% 11%)',
134
+ 'destructive-border': 'hsl(0 50% 30%)',
135
+ info: 'hsl(200 100% 69%)',
136
+ 'info-background': 'hsl(200 60% 11%)',
137
+ 'info-foreground': 'hsl(200 90% 74%)',
138
+ 'info-border': 'hsl(200 50% 30%)',
105
139
  ...fluentTypography,
106
140
  },
107
141
  };
@@ -31,8 +31,35 @@ export type ThemeCssVarColorKey =
31
31
  | 'destructive'
32
32
  | 'destructive-foreground';
33
33
 
34
- /** Layout / focus tokens — `radius` is a CSS length, rest are HSL */
35
- export type ThemeCssVarChromeKey = 'border' | 'input' | 'ring' | 'radius';
34
+ /**
35
+ * Layout / focus tokens `radius` is a CSS length, the rest are wrapped colors.
36
+ * `divider` is the hairline-between-rows token (deliberately *lighter* than
37
+ * `--card` so it stays visible on elevated surfaces — see styles README).
38
+ */
39
+ export type ThemeCssVarChromeKey = 'border' | 'input' | 'divider' | 'ring' | 'radius';
40
+
41
+ /**
42
+ * Status surfaces — each role has a 4-token set (icon/accent, banner background,
43
+ * readable foreground, border ring) used by banners/alerts. Presets with a
44
+ * custom `background` should re-tint these so banners don't clash with the
45
+ * canvas; presets that keep the default canvas can omit them. All are
46
+ * **fully-wrapped CSS colors** (same rule as `ThemeCssVarColorKey`).
47
+ */
48
+ export type ThemeCssVarStatusKey =
49
+ | 'warning'
50
+ | 'warning-background'
51
+ | 'warning-foreground'
52
+ | 'warning-border'
53
+ | 'success'
54
+ | 'success-background'
55
+ | 'success-foreground'
56
+ | 'success-border'
57
+ | 'destructive-background'
58
+ | 'destructive-border'
59
+ | 'info'
60
+ | 'info-background'
61
+ | 'info-foreground'
62
+ | 'info-border';
36
63
 
37
64
  /**
38
65
  * Typography tokens — override system font stack and scale per preset.
@@ -59,12 +86,19 @@ export type ThemeCssVarSidebarKey =
59
86
  | 'sidebar-border'
60
87
  | 'sidebar-ring';
61
88
 
89
+ /**
90
+ * Categorical chart colors. **Fully-wrapped CSS colors** (`hsl(...)`) — same
91
+ * rule as every color key. Consume via the Tailwind utility (`bg-chart-1`,
92
+ * backed by `--color-chart-N` in tokens.css) or `var(--chart-N)`; never wrap as
93
+ * `hsl(var(--chart-N))` (that double-wraps to `hsl(hsl(...))` and is invalid).
94
+ */
62
95
  export type ThemeCssVarChartKey = 'chart-1' | 'chart-2' | 'chart-3' | 'chart-4' | 'chart-5';
63
96
 
64
97
  /** All keys that map to `--${key}` in ui-core CSS files */
65
98
  export type ThemeCssVarKey =
66
99
  | ThemeCssVarColorKey
67
100
  | ThemeCssVarChromeKey
101
+ | ThemeCssVarStatusKey
68
102
  | ThemeCssVarTypographyKey
69
103
  | ThemeCssVarSidebarKey
70
104
  | ThemeCssVarChartKey;
@@ -99,10 +99,12 @@
99
99
  /* Surface gradient dark */
100
100
  --surface-gradient: linear-gradient(to bottom, var(--background), color-mix(in oklab, var(--background) 80%, transparent));
101
101
 
102
- /* Chart colors — chart-1 aligned with brand */
103
- --chart-1: 189 100% 50%;
104
- --chart-2: 142 76% 36%;
105
- --chart-3: 262 83% 58%;
106
- --chart-4: 26 90% 57%;
107
- --chart-5: 346 77% 50%;
102
+ /* Chart colors — chart-1 aligned with brand.
103
+ * Fully-wrapped hsl() like every other token (consume via `bg-chart-N` or
104
+ * `var(--chart-N)`, never `hsl(var(--chart-N))`). */
105
+ --chart-1: hsl(189 100% 50%);
106
+ --chart-2: hsl(142 76% 36%);
107
+ --chart-3: hsl(262 83% 58%);
108
+ --chart-4: hsl(26 90% 57%);
109
+ --chart-5: hsl(346 77% 50%);
108
110
  }
@@ -92,10 +92,12 @@
92
92
  /* Surface gradient */
93
93
  --surface-gradient: linear-gradient(to bottom, var(--background), color-mix(in oklab, var(--background) 80%, transparent));
94
94
 
95
- /* Chart colors — chart-1 aligned with brand hue */
96
- --chart-1: 192 90% 35%;
97
- --chart-2: 142 76% 36%;
98
- --chart-3: 262 83% 58%;
99
- --chart-4: 26 90% 57%;
100
- --chart-5: 346 77% 50%;
95
+ /* Chart colors — chart-1 aligned with brand hue.
96
+ * Fully-wrapped hsl() like every other token (consume via `bg-chart-N` or
97
+ * `var(--chart-N)`, never `hsl(var(--chart-N))`). */
98
+ --chart-1: hsl(192 90% 35%);
99
+ --chart-2: hsl(142 76% 36%);
100
+ --chart-3: hsl(262 83% 58%);
101
+ --chart-4: hsl(26 90% 57%);
102
+ --chart-5: hsl(346 77% 50%);
101
103
  }
@@ -83,6 +83,28 @@
83
83
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
84
84
  --color-sidebar-border: var(--sidebar-border);
85
85
  --color-sidebar-ring: var(--sidebar-ring);
86
+
87
+ /* Charts — categorical series. `--chart-N` are fully-wrapped colors in
88
+ * light/dark/preset, so this is a plain reference (NOT `hsl(var(...))`).
89
+ * Backs the `bg-chart-1` / `text-chart-2` / `border-chart-3` utilities and
90
+ * their opacity modifiers (`bg-chart-1/40`). For Recharts/shadcn `color:`
91
+ * props pass `var(--chart-N)` directly. */
92
+ --color-chart-1: var(--chart-1);
93
+ --color-chart-2: var(--chart-2);
94
+ --color-chart-3: var(--chart-3);
95
+ --color-chart-4: var(--chart-4);
96
+ --color-chart-5: var(--chart-5);
97
+
98
+ /* Typography — `font-sans` / `font-mono` utilities read `--font-sans` /
99
+ * `--font-mono` directly (those live in base.css, retinted per preset), so
100
+ * no binding is needed for them. Tailwind's text scale reads `--text-*`,
101
+ * which we bridge to the preset-overridable `--font-size-*` tokens so
102
+ * `text-xs…text-xl` follow the active preset instead of Tailwind defaults. */
103
+ --text-xs: var(--font-size-xs);
104
+ --text-sm: var(--font-size-sm);
105
+ --text-base: var(--font-size-base);
106
+ --text-lg: var(--font-size-lg);
107
+ --text-xl: var(--font-size-xl);
86
108
  }
87
109
 
88
110
  @theme {