@djangocfg/ui-core 2.1.591 → 2.1.593

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.591",
3
+ "version": "2.1.593",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -130,8 +130,8 @@
130
130
  "check:contrast": "node scripts/check-preset-contrast.mjs"
131
131
  },
132
132
  "peerDependencies": {
133
- "@djangocfg/api": "^2.1.591",
134
- "@djangocfg/i18n": "^2.1.591",
133
+ "@djangocfg/api": "^2.1.593",
134
+ "@djangocfg/i18n": "^2.1.593",
135
135
  "consola": "^3.4.2",
136
136
  "lucide-react": "^0.545.0",
137
137
  "moment": "^2.30.1",
@@ -207,10 +207,10 @@
207
207
  "vaul": "1.1.2"
208
208
  },
209
209
  "devDependencies": {
210
- "@djangocfg/api": "^2.1.591",
211
- "@djangocfg/eslint-config": "^2.1.591",
212
- "@djangocfg/i18n": "^2.1.591",
213
- "@djangocfg/typescript-config": "^2.1.591",
210
+ "@djangocfg/api": "^2.1.593",
211
+ "@djangocfg/eslint-config": "^2.1.593",
212
+ "@djangocfg/i18n": "^2.1.593",
213
+ "@djangocfg/typescript-config": "^2.1.593",
214
214
  "@storybook/react-vite": "^10.5.0",
215
215
  "@types/node": "^24.13.3",
216
216
  "@types/react": "19.2.15",
@@ -51,6 +51,8 @@ interface RelativeTimeCardProps
51
51
  locale?: string;
52
52
  /** Use UTC for all calculations. @default true */
53
53
  utc?: boolean;
54
+ /** IANA zone the wall-clock text is shown in (`useTimezone().timezone`). @default the browser zone */
55
+ timeZone?: string;
54
56
  }
55
57
 
56
58
  /* Built on `Intl`, not moment.
@@ -110,35 +112,40 @@ function getRelativeTime(date: Date, locale: string = "en"): string {
110
112
  return formatter.format(0, "second");
111
113
  }
112
114
 
113
- function getAutoFormat(date: Date, locale: string = "en"): string {
115
+ function getAutoFormat(date: Date, locale: string = "en", timeZone?: string): string {
114
116
  const diffHours = (Date.now() - date.getTime()) / 3_600_000;
115
117
  const diffDays = diffHours / 24;
116
118
 
117
119
  if (diffHours < 24) {
118
120
  return new Intl.DateTimeFormat(locale, {
121
+ timeZone,
119
122
  hour: "2-digit",
120
123
  minute: "2-digit",
121
124
  hour12: false,
122
125
  }).format(date);
123
126
  }
124
127
  if (diffDays < 7) {
125
- return new Intl.DateTimeFormat(locale, { weekday: "short" }).format(date);
128
+ return new Intl.DateTimeFormat(locale, {
129
+ timeZone, weekday: "short" }).format(date);
126
130
  }
127
131
  if (diffDays < 365) {
128
132
  return new Intl.DateTimeFormat(locale, {
133
+ timeZone,
129
134
  month: "short",
130
135
  day: "numeric",
131
136
  }).format(date);
132
137
  }
133
138
  return new Intl.DateTimeFormat(locale, {
139
+ timeZone,
134
140
  year: "numeric",
135
141
  month: "short",
136
142
  day: "numeric",
137
143
  }).format(date);
138
144
  }
139
145
 
140
- function getDateTimeFormat(date: Date, locale: string = "en"): string {
146
+ function getDateTimeFormat(date: Date, locale: string = "en", timeZone?: string): string {
141
147
  return new Intl.DateTimeFormat(locale, {
148
+ timeZone,
142
149
  year: "numeric",
143
150
  month: "short",
144
151
  day: "numeric",
@@ -149,15 +156,25 @@ function getDateTimeFormat(date: Date, locale: string = "en"): string {
149
156
  }
150
157
 
151
158
  /** The moment-style tokens this component shipped with, and only those. */
152
- function applyCustomFormat(date: Date, pattern: string): string {
153
- const pad = (n: number) => String(n).padStart(2, "0");
159
+ function applyCustomFormat(date: Date, pattern: string, timeZone?: string): string {
160
+ const parts = new Intl.DateTimeFormat("en-GB", {
161
+ timeZone,
162
+ year: "numeric",
163
+ month: "2-digit",
164
+ day: "2-digit",
165
+ hour: "2-digit",
166
+ minute: "2-digit",
167
+ second: "2-digit",
168
+ hourCycle: "h23",
169
+ }).formatToParts(date);
170
+ const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
154
171
  const tokens: Record<string, string> = {
155
- YYYY: String(date.getFullYear()),
156
- MM: pad(date.getMonth() + 1),
157
- DD: pad(date.getDate()),
158
- HH: pad(date.getHours()),
159
- mm: pad(date.getMinutes()),
160
- ss: pad(date.getSeconds()),
172
+ YYYY: get("year"),
173
+ MM: get("month"),
174
+ DD: get("day"),
175
+ HH: get("hour"),
176
+ mm: get("minute"),
177
+ ss: get("second"),
161
178
  };
162
179
  return pattern.replace(/YYYY|MM|DD|HH|mm|ss/g, (t) => tokens[t] ?? t);
163
180
  }
@@ -166,17 +183,18 @@ function formatDate(
166
183
  date: Date,
167
184
  format: "relative" | "auto" | "datetime" | "custom",
168
185
  locale: string,
169
- outputFormat?: string
186
+ outputFormat?: string,
187
+ timeZone?: string
170
188
  ): string {
171
189
  switch (format) {
172
190
  case "relative":
173
191
  return getRelativeTime(date, locale);
174
192
  case "auto":
175
- return getAutoFormat(date, locale);
193
+ return getAutoFormat(date, locale, timeZone);
176
194
  case "datetime":
177
- return getDateTimeFormat(date, locale);
195
+ return getDateTimeFormat(date, locale, timeZone);
178
196
  case "custom":
179
- return applyCustomFormat(date, outputFormat || "YYYY-MM-DD HH:mm");
197
+ return applyCustomFormat(date, outputFormat || "YYYY-MM-DD HH:mm", timeZone);
180
198
  }
181
199
  }
182
200
 
@@ -195,6 +213,7 @@ const RelativeTimeCard = React.forwardRef<
195
213
  suffix,
196
214
  locale = "en",
197
215
  utc = true,
216
+ timeZone,
198
217
  className,
199
218
  variant,
200
219
  size,
@@ -205,17 +224,17 @@ const RelativeTimeCard = React.forwardRef<
205
224
  const dateObj = React.useMemo(() => toDate(date, utc), [date, utc]);
206
225
 
207
226
  const [display, setDisplay] = React.useState(() =>
208
- formatDate(dateObj, format, locale, outputFormat)
227
+ formatDate(dateObj, format, locale, outputFormat, timeZone)
209
228
  );
210
229
 
211
230
  React.useEffect(() => {
212
- setDisplay(formatDate(dateObj, format, locale, outputFormat));
231
+ setDisplay(formatDate(dateObj, format, locale, outputFormat, timeZone));
213
232
  if (format !== "relative") return;
214
233
  const id = setInterval(() => {
215
- setDisplay(formatDate(dateObj, format, locale, outputFormat));
234
+ setDisplay(formatDate(dateObj, format, locale, outputFormat, timeZone));
216
235
  }, updateInterval);
217
236
  return () => clearInterval(id);
218
- }, [dateObj, format, locale, outputFormat, updateInterval]);
237
+ }, [dateObj, format, locale, outputFormat, timeZone, updateInterval]);
219
238
 
220
239
  // An invalid date would throw from `toISOString`, where moment returned
221
240
  // null and rendered "Invalid date". Both attributes are omitted instead.
@@ -223,6 +242,7 @@ const RelativeTimeCard = React.forwardRef<
223
242
  const isoString = valid ? dateObj.toISOString() : undefined;
224
243
  const title = valid
225
244
  ? new Intl.DateTimeFormat(locale, {
245
+ timeZone,
226
246
  dateStyle: "full",
227
247
  timeStyle: "short",
228
248
  }).format(dateObj)
@@ -326,7 +326,7 @@ export interface SettingsBlockProps {
326
326
  defaultOpen?: boolean;
327
327
  }
328
328
 
329
- const BLOCK_TITLE = 'text-[13px] font-semibold uppercase tracking-wide text-muted-foreground';
329
+ const BLOCK_TITLE = 'text-base font-medium text-foreground';
330
330
 
331
331
  export const SettingsBlock: React.FC<SettingsBlockProps> = ({
332
332
  title,
@@ -156,7 +156,7 @@ const ResizableHandle = ({
156
156
  className={cn(
157
157
  "relative flex items-center justify-center bg-divider",
158
158
  "w-px h-full",
159
- "hover:bg-primary/20 active:bg-primary/30",
159
+ "hover:bg-primary/60 active:bg-primary",
160
160
  "transition-colors",
161
161
  // Touch handling for better drag performance
162
162
  "touch-none select-none",
@@ -127,7 +127,7 @@ const DropdownMenuItem = React.forwardRef<
127
127
  }
128
128
  >(({ className, inset, href, variant = "default", children, ...props }, ref) => {
129
129
  const classes = cn(
130
- "relative flex cursor-default select-none items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
130
+ "relative flex cursor-default select-none items-center gap-2 rounded-[var(--radius-sm)] px-2.5 py-1 text-sm outline-none transition-colors focus:bg-foreground/[0.07] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
131
131
  "data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive",
132
132
  inset && "pl-8",
133
133
  className
@@ -228,7 +228,7 @@ const DropdownMenuSeparator = React.forwardRef<
228
228
  ref={ref}
229
229
  // Soft hairline via the --divider token (was bg-muted, which is darker than
230
230
  // the popover surface and read as a hard dark groove).
231
- className={cn("-mx-1 my-1 h-px bg-divider", className)}
231
+ className={cn("mx-1.5 my-1 h-px bg-divider", className)}
232
232
  {...props}
233
233
  />
234
234
  ))
@@ -44,7 +44,7 @@ export function renderItems(
44
44
 
45
45
  case 'label':
46
46
  return (
47
- <DropdownMenuLabel key={item.id ?? `label-${index}`}>
47
+ <DropdownMenuLabel key={item.id ?? `label-${index}`} className="font-normal text-muted-foreground">
48
48
  {item.label}
49
49
  </DropdownMenuLabel>
50
50
  );
@@ -196,7 +196,7 @@ const Sidebar = React.forwardRef<
196
196
  data-sidebar="sidebar"
197
197
  data-mobile="true"
198
198
  className={cn(
199
- "flex h-[100dvh] max-h-[100dvh] min-h-0 !w-[min(80vw,320px)] max-w-[min(80vw,320px)] flex-col gap-0 border-sidebar-border/60 p-0 text-sidebar-foreground shadow-xl",
199
+ "flex h-[100dvh] max-h-[100dvh] min-h-0 !w-[min(88vw,22rem)] max-w-[min(88vw,22rem)] flex-col gap-0 border-sidebar-border/60 p-0 text-sidebar-foreground shadow-xl",
200
200
  drawerSide === "left" ? "border-r" : "border-l",
201
201
  )}
202
202
  style={
@@ -287,11 +287,7 @@ const Sidebar = React.forwardRef<
287
287
  {variant === "sidebar" && (
288
288
  <div
289
289
  aria-hidden
290
- className={cn(
291
- "pointer-events-none absolute inset-y-0 right-0 z-[1] w-px",
292
- "bg-[linear-gradient(180deg,color-mix(in_oklab,var(--sidebar-border)_2%,transparent)_0%,color-mix(in_oklab,var(--sidebar-border)_22%,transparent)_18%,color-mix(in_oklab,var(--sidebar-border)_40%,transparent)_50%,color-mix(in_oklab,var(--sidebar-border)_20%,transparent)_82%,color-mix(in_oklab,var(--sidebar-border)_3%,transparent)_100%)]",
293
- "dark:bg-[linear-gradient(180deg,color-mix(in_oklab,var(--sidebar-border)_8%,transparent)_0%,color-mix(in_oklab,var(--sidebar-border)_34%,transparent)_22%,color-mix(in_oklab,var(--sidebar-border)_55%,transparent)_50%,color-mix(in_oklab,var(--sidebar-border)_28%,transparent)_78%,color-mix(in_oklab,var(--sidebar-border)_6%,transparent)_100%)]"
294
- )}
290
+ className="pointer-events-none absolute inset-y-0 right-0 z-[1] w-px bg-sidebar-border"
295
291
  />
296
292
  )}
297
293
  <div className="relative z-0 flex min-h-0 flex-1 flex-col">{children}</div>
@@ -47,6 +47,12 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
47
47
  * widens it and the viewport margin is kept for you. A Tailwind `max-w-*`
48
48
  * instead does nothing — the width is declared, and a maximum above a declared
49
49
  * width is inert. Ignored when `fullscreen`.
50
+ *
51
+ * Proportional cards: set `--aspect-dialog` (width / height) and the card keeps
52
+ * that ratio at every size — the width shrinks to `(100dvh - 2rem) * ratio` when
53
+ * the window is short, so the height never overflows. Pure CSS, no measuring:
54
+ *
55
+ * <DialogContent className="[--width-dialog:1024px] [--aspect-dialog:1.28]">
50
56
  */
51
57
  interface DialogContentProps
52
58
  extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
@@ -117,7 +123,7 @@ const DialogContent = React.forwardRef<
117
123
  // scrolls. The cap is only a ceiling — a card that must scroll adds
118
124
  // `overflow-y-auto`, and one laying out its own regions overrides
119
125
  // `max-h` as before.
120
- : "left-1/2 top-1/2 grid max-h-[calc(100dvh-2rem)] grid-cols-[minmax(0,1fr)] w-[min(var(--width-dialog),calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-[var(--radius-dialog)] border p-6 shadow-lg data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
126
+ : "left-1/2 top-1/2 grid max-h-[calc(100dvh-2rem)] grid-cols-[minmax(0,1fr)] [aspect-ratio:var(--aspect-dialog,auto)] w-[min(var(--width-dialog),calc(100vw-2rem),calc((100dvh-2rem)*var(--aspect-dialog,1000)))] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-[var(--radius-dialog)] border p-6 shadow-lg data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
121
127
  className
122
128
  )}
123
129
  {...props}
@@ -15,7 +15,7 @@ const PopoverAnchor = PopoverPrimitive.Anchor
15
15
  const PopoverContent = React.forwardRef<
16
16
  React.ElementRef<typeof PopoverPrimitive.Content>,
17
17
  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
18
- >(({ className, align = "center", sideOffset = 4, collisionPadding = 8, forceMount, ...props }, ref) => (
18
+ >(({ className, align = "center", sideOffset = 4, collisionPadding = 8, forceMount, onWheel, onTouchMove, ...props }, ref) => (
19
19
  // `forceMount` must reach BOTH halves, and that is the whole reason it is
20
20
  // destructured rather than swept up by `...props` (live 2026-08-15).
21
21
  //
@@ -42,6 +42,12 @@ const PopoverContent = React.forwardRef<
42
42
  // mismatched darker rectangle inside the popover.
43
43
  data-slot="popover-content"
44
44
  aria-describedby={undefined}
45
+ // A popover is portaled to <body>, outside an open Dialog's content, so the
46
+ // Dialog's scroll lock (react-remove-scroll) swallows wheel/touch scrolling
47
+ // inside it — a long list inside a settings dialog would not scroll at all.
48
+ // Stopping propagation here keeps those events from reaching the lock.
49
+ onWheel={(e) => { onWheel?.(e); e.stopPropagation(); }}
50
+ onTouchMove={(e) => { onTouchMove?.(e); e.stopPropagation(); }}
45
51
  align={align}
46
52
  sideOffset={sideOffset}
47
53
  // Radix defaults this to 0, which lets a popover that does not fit below
@@ -63,6 +63,10 @@ export { CountrySelect } from './country-select';
63
63
  export type { CountrySelectProps, CountryOption } from './country-select';
64
64
  export type { TCountryCode } from 'countries-list';
65
65
 
66
+ // TimezoneSelect
67
+ export { TimezoneSelect, detectTimezone, timezoneOffsetLabel } from './timezone-select';
68
+ export type { TimezoneSelectProps } from './timezone-select';
69
+
66
70
  // LanguageSelect
67
71
  export { LanguageSelect } from './language-select';
68
72
  export type {
@@ -0,0 +1,146 @@
1
+ "use client"
2
+
3
+ import * as React from 'react';
4
+
5
+ import { Combobox, type ComboboxOption } from './combobox';
6
+
7
+ /** Sentinel option value for "follow the browser" — never an IANA name. */
8
+ const AUTO = '__auto__';
9
+
10
+ export interface TimezoneSelectProps {
11
+ /** IANA zone (e.g. `Europe/Madrid`); `null`/`undefined` = automatic (browser). */
12
+ value?: string | null;
13
+ onChange?: (timezone: string | null) => void;
14
+ /** Offer the "Automatic" option. Default: true. */
15
+ allowAuto?: boolean;
16
+ /** Label of the automatic option (the detected zone is its description). */
17
+ autoLabel?: string;
18
+ placeholder?: string;
19
+ searchPlaceholder?: string;
20
+ emptyText?: string;
21
+ className?: string;
22
+ disabled?: boolean;
23
+ }
24
+
25
+ /** The browser's zone, or `UTC` where Intl cannot tell. */
26
+ export function detectTimezone(): string {
27
+ try {
28
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
29
+ } catch {
30
+ return 'UTC';
31
+ }
32
+ }
33
+
34
+ /** Current offset of a zone as `UTC+01:00` (`UTC` for zero). */
35
+ export function timezoneOffsetLabel(timeZone: string, at: Date = new Date()): string {
36
+ try {
37
+ const part = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' })
38
+ .formatToParts(at)
39
+ .find((p) => p.type === 'timeZoneName')?.value;
40
+ if (!part || part === 'GMT') return 'UTC';
41
+ return part.replace('GMT', 'UTC');
42
+ } catch {
43
+ return 'UTC';
44
+ }
45
+ }
46
+
47
+ /** Offset in minutes, for ordering. */
48
+ function offsetMinutes(label: string): number {
49
+ const m = /UTC([+-])(\d{2}):(\d{2})/.exec(label);
50
+ if (!m) return 0;
51
+ return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]));
52
+ }
53
+
54
+ /** `America/Argentina/Buenos_Aires` → `Buenos Aires`. */
55
+ function cityOf(timeZone: string): string {
56
+ return (timeZone.split('/').pop() ?? timeZone).replace(/_/g, ' ');
57
+ }
58
+
59
+ function listTimezones(): string[] {
60
+ const supported = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf;
61
+ const zones = supported ? supported('timeZone') : [];
62
+ // `UTC` is not always part of supportedValuesOf — keep it reachable.
63
+ return zones.includes('UTC') ? zones : ['UTC', ...zones];
64
+ }
65
+
66
+ /**
67
+ * Time zone picker — a thin layer over `Combobox`: options come from
68
+ * `Intl.supportedValuesOf('timeZone')` (no data dependency), each shown as
69
+ * city + region with its current UTC offset, ordered by offset.
70
+ *
71
+ * `value` is an IANA name or `null` for "Automatic" (browser zone), so the
72
+ * caller can store `null` and keep following the device.
73
+ *
74
+ * @example
75
+ * ```tsx
76
+ * <TimezoneSelect value={tz} onChange={setTz} />
77
+ * ```
78
+ */
79
+ export function TimezoneSelect({
80
+ value,
81
+ onChange,
82
+ allowAuto = true,
83
+ autoLabel = 'Automatic',
84
+ placeholder = 'Select time zone…',
85
+ searchPlaceholder = 'Search city or offset…',
86
+ emptyText = 'No time zones found',
87
+ className,
88
+ disabled,
89
+ }: TimezoneSelectProps) {
90
+ const detected = React.useMemo(detectTimezone, []);
91
+
92
+ const options = React.useMemo<ComboboxOption[]>(() => {
93
+ const now = new Date();
94
+ const zones = listTimezones()
95
+ .map((tz) => ({ tz, offset: timezoneOffsetLabel(tz, now) }))
96
+ .sort((a, b) => offsetMinutes(a.offset) - offsetMinutes(b.offset) || a.tz.localeCompare(b.tz))
97
+ .map<ComboboxOption>(({ tz, offset }) => ({
98
+ value: tz,
99
+ label: cityOf(tz),
100
+ description: tz.includes('/') ? tz.split('/')[0]! : undefined,
101
+ badge: offset,
102
+ }));
103
+ return allowAuto
104
+ ? [{ value: AUTO, label: autoLabel, description: detected, badge: timezoneOffsetLabel(detected, now) }, ...zones]
105
+ : zones;
106
+ }, [allowAuto, autoLabel, detected]);
107
+
108
+ const filter = React.useCallback((o: ComboboxOption, search: string) => {
109
+ const q = search.trim().toLowerCase();
110
+ if (!q) return true;
111
+ // `+9` must find `UTC+09:00`: compare offsets without prefix, leading zero and `:00`.
112
+ const short = (v: string) => v.toLowerCase().replace(/utc/g, '').replace(/([+-])0/g, '$1').replace(/:00/g, '');
113
+ const offset = typeof o.badge === 'string' ? o.badge : '';
114
+ return (
115
+ o.value.toLowerCase().replace(/_/g, ' ').includes(q) ||
116
+ o.label.toLowerCase().includes(q) ||
117
+ offset.toLowerCase().includes(q) ||
118
+ short(offset).includes(short(q))
119
+ );
120
+ }, []);
121
+
122
+ return (
123
+ <Combobox
124
+ options={options}
125
+ value={value ?? (allowAuto ? AUTO : '')}
126
+ onValueChange={(v) => onChange?.(v === AUTO || v === '' ? null : v)}
127
+ placeholder={placeholder}
128
+ searchPlaceholder={searchPlaceholder}
129
+ emptyText={emptyText}
130
+ className={className}
131
+ disabled={disabled}
132
+ filterFunction={filter}
133
+ contentClassName="w-72"
134
+ renderValue={(o) =>
135
+ o ? (
136
+ <span className="flex min-w-0 items-baseline gap-2">
137
+ <span className="truncate">{o.value === AUTO ? `${o.label} · ${cityOf(detected)}` : o.label}</span>
138
+ <span className="shrink-0 text-xs text-muted-foreground">{o.badge}</span>
139
+ </span>
140
+ ) : (
141
+ <span className="text-muted-foreground">{placeholder}</span>
142
+ )
143
+ }
144
+ />
145
+ );
146
+ }
@@ -110,4 +110,6 @@ body {
110
110
  line-height: var(--line-height-base);
111
111
  letter-spacing: var(--letter-spacing-base);
112
112
  font-feature-settings: "rlig" 1, "calt" 1;
113
+ -webkit-font-smoothing: antialiased;
114
+ -moz-osx-font-smoothing: grayscale;
113
115
  }
@@ -31,25 +31,6 @@
31
31
 
32
32
  .dark {
33
33
  --radius: 0.25rem;
34
- --border: hsl(0 0% 24%);
35
- --input: hsl(0 0% 24%);
36
- /* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
37
- weaker than that panel's own --border — otherwise the five lines between
38
- rows shout as loudly as the one frame around them, which is what was
39
- left of "кондово" after the fills were calmed. Over a 12% card this
40
- lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
41
- hierarchy, frame first. */
42
- --divider: hsl(0 0% 46% / 0.10);
43
- --muted: hsl(0 0% 12%);
44
- /* 12%, against an INHERITED --background of 14.5% from dark.css — this
45
- preset declares no background of its own, so the card sits DARKER than the
46
- page here rather than lighter. Direction is not the contract; the size of
47
- the step is. Capped by MAXIMUM_RESTING_SURFACE_GAP in
48
- check-preset-contrast.mjs. */
49
- --card: hsl(0 0% 12%);
50
- /* 20%, not 14%: --background is 14.5%, so this was HALF a point — the
51
- hover was effectively invisible. */
52
- --accent: hsl(0 0% 20%);
53
34
  --radius-xs: calc(0.25rem - 6px);
54
35
  --radius-sm: calc(0.25rem - 4px);
55
36
  --radius-md: calc(0.25rem - 2px);
@@ -27,13 +27,13 @@
27
27
  .dark {
28
28
  --primary: hsl(189 100% 50%);
29
29
  --primary-foreground: hsl(0 0% 9%);
30
- /* 20%, not 18%: --background is 14.5%, so this was 3.5 points. */
30
+ /* 20%: a clear step above the 10% page. */
31
31
  --accent: hsl(189 40% 20%);
32
32
  --accent-foreground: hsl(189 100% 78%);
33
33
  --ring: hsl(189 100% 50%);
34
34
  --sidebar-primary: hsl(189 100% 50%);
35
35
  --sidebar-primary-foreground: hsl(0 0% 9%);
36
- --sidebar-accent: hsl(189 35% 16%);
36
+ --sidebar-accent: hsl(189 35% 17.5%);
37
37
  --sidebar-accent-foreground: hsl(189 100% 78%);
38
38
  --sidebar-ring: hsl(189 100% 50%);
39
39
  --info: hsl(189 100% 55%);
@@ -81,24 +81,24 @@
81
81
  }
82
82
 
83
83
  .dark {
84
- --background: hsl(240 5% 8%);
85
- --foreground: hsl(0 0% 95%);
84
+ --background: hsl(0 0% 10%);
85
+ --foreground: hsl(0 0% 97.6%);
86
86
  /* Three points above --background, not seven. A resting surface is
87
87
  separated by its BORDER; when the fill does that job the page reads as
88
88
  stacked plates and a table looks like a sticker laid over it. Capped by
89
89
  MAXIMUM_RESTING_SURFACE_GAP in check-preset-contrast.mjs. */
90
- --card: hsl(240 3% 12%);
91
- --card-foreground: hsl(0 0% 95%);
92
- --popover: hsl(240 3% 20%);
93
- --popover-foreground: hsl(0 0% 95%);
90
+ --card: hsl(0 0% 13%);
91
+ --card-foreground: hsl(0 0% 97.6%);
92
+ --popover: hsl(0 0% 18.8%);
93
+ --popover-foreground: hsl(0 0% 97.6%);
94
94
  --primary: hsl(211 100% 58%);
95
95
  /* Bright system blue needs dark ink for compact controls (5.59:1). */
96
96
  --primary-foreground: hsl(0 0% 9%);
97
- --secondary: hsl(240 3% 20%);
97
+ --secondary: hsl(0 0% 17%);
98
98
  --secondary-foreground: hsl(0 0% 88%);
99
- --muted: hsl(240 3% 11%);
99
+ --muted: hsl(0 0% 8%);
100
100
  /* Clears AA on background, card, popover, muted, and accent surfaces. */
101
- --muted-foreground: hsl(240 4% 62%);
101
+ --muted-foreground: hsl(0 0% 62%);
102
102
  --accent: hsl(211 25% 19%);
103
103
  --accent-foreground: hsl(211 100% 72%);
104
104
  --destructive: hsl(3 100% 62%);
@@ -114,25 +114,25 @@
114
114
  definite edge at one device pixel without becoming the loudest thing on
115
115
  the page — which it was once the fills stopped separating surfaces.
116
116
  Capped by MAXIMUM_BORDER_GAP in check-preset-contrast.mjs. */
117
- --border: hsl(240 3% 18%);
118
- --input: hsl(240 3% 22%);
117
+ --border: hsl(0 0% 18%);
118
+ --input: hsl(0 0% 20%);
119
119
  /* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
120
120
  weaker than that panel's own --border — otherwise the five lines between
121
121
  rows shout as loudly as the one frame around them, which is what was
122
122
  left of "кондово" after the fills were calmed. Over a 12% card this
123
123
  lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
124
124
  hierarchy, frame first. */
125
- --divider: hsl(240 4% 46% / 0.10);
125
+ --divider: hsl(0 0% 100% / 0.08);
126
126
  --ring: hsl(211 100% 58%);
127
127
  --radius: 0.625rem;
128
128
  --overlay: hsl(0 0% 0% / 0.55);
129
- --sidebar-background: hsl(240 5% 5%);
130
- --sidebar-foreground: hsl(0 0% 90%);
129
+ --sidebar-background: hsl(0 0% 12.5%);
130
+ --sidebar-foreground: hsl(0 0% 97.6%);
131
131
  --sidebar-primary: hsl(211 100% 58%);
132
132
  --sidebar-primary-foreground: hsl(0 0% 9%);
133
- --sidebar-accent: hsl(211 22% 16%);
134
- --sidebar-accent-foreground: hsl(211 100% 72%);
135
- --sidebar-border: hsl(240 3% 22%);
133
+ --sidebar-accent: hsl(0 0% 19%);
134
+ --sidebar-accent-foreground: hsl(0 0% 97.6%);
135
+ --sidebar-border: hsl(0 0% 18%);
136
136
  --sidebar-ring: hsl(211 100% 58%);
137
137
  --chart-1: hsl(211 100% 58%);
138
138
  --chart-2: hsl(142 52% 50%);
@@ -14,27 +14,26 @@
14
14
  */
15
15
 
16
16
  .dark {
17
- /* Warm-gray surface ladder mapped from Claude's dark theme (hue ~48–60, a
18
- * faint warm tint instead of pure neutral). The VISIBLE page background is
19
- * Claude's bg-100 (~14.5%) — NOT near-black; card/popover lift above it so
17
+ /* Neutral surface ladder (hue 0) measured on the Claude app: page 10%,
18
+ * sidebar/card 12.5–13%. Hue 0 on purpose — the earlier warm tint read as brown; card/popover lift above it so
20
19
  * panels and menus read as raised. muted recesses below the page.
21
- * page ≈ bg-100 card ≈ bg-000(18.4%) popover ≈ slightly above card */
22
- --background: hsl(60 2.7% 14.5%);
23
- --foreground: hsl(48 33% 97%);
24
- --card: hsl(60 2.1% 18.4%);
25
- --card-foreground: hsl(48 33% 97%);
26
- --popover: hsl(48 2.5% 21%);
27
- --popover-foreground: hsl(48 33% 97%);
20
+ * page ≈ bg-100 card ≈ bg-000(18.4%) popover 18.8% (claude.ai menus) */
21
+ --background: hsl(0 0% 10%);
22
+ --foreground: hsl(0 0% 97.6%);
23
+ --card: hsl(0 0% 13%);
24
+ --card-foreground: hsl(0 0% 97.6%);
25
+ --popover: hsl(0 0% 18.8%);
26
+ --popover-foreground: hsl(0 0% 97.6%);
28
27
  /* Brand clay #d97757 (Claude). Bright enough on dark chrome to carry the full swatch. */
29
28
  --primary: hsl(15 63% 60%);
30
29
  /* Dark label on clay — 5.8:1, stronger than white-on-clay (3.1:1) */
31
30
  --primary-foreground: hsl(0 0% 9%);
32
31
  /* Secondary: elevated warm-gray control surface + light label */
33
- --secondary: hsl(60 2.1% 18.4%);
34
- --secondary-foreground: hsl(48 33% 97%);
32
+ --secondary: hsl(0 0% 17%);
33
+ --secondary-foreground: hsl(0 0% 97.6%);
35
34
  /* Recessed surface — below the page (Claude bg-200), for input rest / chips. */
36
- --muted: hsl(30 3.3% 11.8%);
37
- --muted-foreground: hsl(48 5% 59%);
35
+ --muted: hsl(0 0% 8%);
36
+ --muted-foreground: hsl(0 0% 62%);
38
37
  /* Neutral interaction surface: a quiet lift above the current dark page. */
39
38
  --surface-hover: color-mix(in oklab, var(--card) 96%, var(--foreground));
40
39
  /* The counterpart to --surface-hover: a surface that reads as sunk BELOW the
@@ -50,8 +49,8 @@
50
49
  * and composites over whatever surface the band happens to sit on. */
51
50
  --surface-recessed: rgb(0 0 0 / 0.35);
52
51
  /* Hover/active surface (rails, menus, tabs) — a quiet warm lift above the page. */
53
- --accent: hsl(48 3% 24%);
54
- --accent-foreground: hsl(48 33% 97%);
52
+ --accent: hsl(0 0% 15.5%);
53
+ --accent-foreground: hsl(0 0% 97.6%);
55
54
  --destructive: hsl(0 67% 60%);
56
55
  --destructive-foreground: hsl(0 0% 98%);
57
56
  /* Soft warm border — Claude draws hairlines from a light border at low
@@ -60,9 +59,9 @@
60
59
  definite edge at one device pixel without becoming the loudest thing on
61
60
  the page — which it was once the fills stopped separating surfaces.
62
61
  Capped by MAXIMUM_BORDER_GAP in check-preset-contrast.mjs. */
63
- --border: hsl(48 3% 24%);
62
+ --border: hsl(0 0% 18%);
64
63
  /* Input surface — a notch above card so fields read as raised controls. */
65
- --input: hsl(48 2.5% 24%);
64
+ --input: hsl(0 0% 17%);
66
65
  /* Divider — TRANSLUCENT hairline (Apple-style): a light warm-gray at low alpha
67
66
  * that dissolves into whatever sits behind it, so structural separators
68
67
  * (columns, headers, rows) stay quiet on the dark page instead of reading as a
@@ -73,7 +72,7 @@
73
72
  left of "кондово" after the fills were calmed. Over a 12% card this
74
73
  lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
75
74
  hierarchy, frame first. */
76
- --divider: hsl(48 4% 46% / 0.10);
75
+ --divider: hsl(0 0% 100% / 0.08);
77
76
  /* Overlay — modal scrim / backdrop behind dialogs, drawers, sheets. Black in
78
77
  * both themes; slightly darker here so it still reads on the dark page. */
79
78
  --overlay: hsl(0 0% 0% / 0.7);
@@ -94,13 +93,13 @@
94
93
  /* Sidebar — same warm-gray family as the page (Claude has NO black sidebar
95
94
  * block; rail and page share one tone). A hair darker than --background for
96
95
  * subtle separation, not a hard black slab. */
97
- --sidebar-background: hsl(30 3% 11.8%);
98
- --sidebar-foreground: hsl(48 33% 97%);
96
+ --sidebar-background: hsl(0 0% 12.5%);
97
+ --sidebar-foreground: hsl(0 0% 97.6%);
99
98
  --sidebar-primary: hsl(15 63% 60%);
100
99
  --sidebar-primary-foreground: hsl(0 0% 9%);
101
- --sidebar-accent: hsl(48 3% 24%);
102
- --sidebar-accent-foreground: hsl(48 33% 97%);
103
- --sidebar-border: hsl(48 3% 28%);
100
+ --sidebar-accent: hsl(0 0% 19%);
101
+ --sidebar-accent-foreground: hsl(0 0% 97.6%);
102
+ --sidebar-border: hsl(0 0% 18%);
104
103
  --sidebar-ring: hsl(217 91% 60%);
105
104
  /* ── Status surfaces (dark) ──────────────────────────────────────
106
105
  * Dark variants: dim backgrounds (~8-12% lightness) so banners
@@ -16,7 +16,6 @@
16
16
  */
17
17
  .glass-macos {
18
18
  backdrop-filter: blur(20px) saturate(180%);
19
- -webkit-backdrop-filter: blur(20px) saturate(180%);
20
19
  background-color: color-mix(in oklab, var(--background) 72%, transparent);
21
20
  }
22
21
 
@@ -27,7 +26,6 @@
27
26
  */
28
27
  .glass-win11 {
29
28
  backdrop-filter: blur(60px) saturate(125%);
30
- -webkit-backdrop-filter: blur(60px) saturate(125%);
31
29
  background-color: color-mix(in oklab, var(--background) 85%, transparent);
32
30
  }
33
31
 
@@ -38,7 +36,6 @@
38
36
  */
39
37
  .glass-liquid {
40
38
  backdrop-filter: blur(12px) saturate(180%);
41
- -webkit-backdrop-filter: blur(12px) saturate(180%);
42
39
  background-color: color-mix(in oklab, var(--card) 60%, transparent);
43
40
  border: 1px solid color-mix(in oklab, var(--foreground) 8%, transparent);
44
41
  box-shadow:
@@ -52,7 +49,6 @@
52
49
  */
53
50
  .glass-header {
54
51
  backdrop-filter: blur(8px) saturate(160%);
55
- -webkit-backdrop-filter: blur(8px) saturate(160%);
56
52
  background-color: color-mix(in oklab, var(--background) 80%, transparent);
57
53
  }
58
54
 
@@ -64,7 +60,6 @@
64
60
  */
65
61
  .nav-dropdown-glass {
66
62
  backdrop-filter: blur(var(--overlay-blur, 5px)) saturate(150%);
67
- -webkit-backdrop-filter: blur(var(--overlay-blur, 5px)) saturate(150%);
68
63
  background-color: color-mix(in oklab, var(--background) 55%, transparent);
69
64
  }
70
65
 
@@ -12,6 +12,9 @@
12
12
  * override it per theme/preset to make the backdrop crisper or softer, or set
13
13
  * it to `0px` to disable frosting entirely without touching the scrim color.
14
14
  *
15
+ * Only the unprefixed `backdrop-filter` is written: lightningcss (Tailwind v4, Next) adds `-webkit-` itself, and when both are
16
+ * written it DROPS the unprefixed one (verified), leaving Chromium with no blur at all.
17
+ *
15
18
  * Plain CSS (NOT a JIT-scanned Tailwind utility) so it compiles regardless of
16
19
  * which package's source the Tailwind content scan covers — same rationale as
17
20
  * `divider.css`.
@@ -20,7 +23,6 @@
20
23
  .bg-overlay {
21
24
  /* `--overlay-blur` defaults in base.css; a 0px value cleanly no-ops the blur. */
22
25
  backdrop-filter: blur(var(--overlay-blur, 0px));
23
- -webkit-backdrop-filter: blur(var(--overlay-blur, 0px));
24
26
  }
25
27
 
26
28
  /**