@djangocfg/ui-core 2.1.512 → 2.1.514

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.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Local-date <-> string helpers for the native fields.
3
+ *
4
+ * Native `type="date"` / `type="datetime-local"` speak plain, timezone-less
5
+ * wall-clock strings (`YYYY-MM-DD`, `YYYY-MM-DDTHH:mm`). We parse/format them
6
+ * MANUALLY rather than via `new Date(str)` / `.toISOString()` — those go through
7
+ * UTC and shift the calendar day for users west/east of UTC (the classic
8
+ * "picked the 5th, got the 4th" bug). The `Calendar` popover works in `Date`, so
9
+ * we bridge with these local-safe converters.
10
+ */
11
+
12
+ function pad(n: number): string {
13
+ return String(n).padStart(2, '0');
14
+ }
15
+
16
+ /** `YYYY-MM-DD` -> local `Date` (midnight local). Empty/invalid -> undefined. */
17
+ export function parseDateString(value: string): Date | undefined {
18
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
19
+ if (!match) return undefined;
20
+ const [, y, m, d] = match;
21
+ return new Date(Number(y), Number(m) - 1, Number(d));
22
+ }
23
+
24
+ /** local `Date` -> `YYYY-MM-DD`. */
25
+ export function formatDateString(date: Date): string {
26
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
27
+ }
28
+
29
+ /** `YYYY-MM-DDTHH:mm` -> { date, time } split. */
30
+ export function splitDateTime(value: string): { date: string; time: string } {
31
+ const [date = '', time = ''] = value.split('T');
32
+ return { date, time };
33
+ }
34
+
35
+ /** Join a `YYYY-MM-DD` date and `HH:mm` time into `YYYY-MM-DDTHH:mm`. Returns
36
+ * empty string when the date is missing (a time alone has no valid datetime). */
37
+ export function joinDateTime(date: string, time: string): string {
38
+ if (!date) return '';
39
+ return `${date}T${time || '00:00'}`;
40
+ }
41
+
42
+ /** `YYYY-MM-DDTHH:mm` -> local `Date`. Empty/invalid -> undefined. */
43
+ export function parseDateTimeString(value: string): Date | undefined {
44
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
45
+ if (!match) return undefined;
46
+ const [, y, mo, d, h, mi] = match;
47
+ return new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi));
48
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Native-engine date/time fields.
3
+ *
4
+ * A typeable, token-skinned field family built on the browser's native
5
+ * `<input type="date|time|datetime-local">` engine, with an optional
6
+ * calendar/columns popover for pointer users. See
7
+ * `@dev/planned/ui-core-datetime-fields/PLAN.md` for the rationale.
8
+ *
9
+ * @module datetime-field
10
+ */
11
+
12
+ export { DateField } from './date-field';
13
+ export type { DateFieldProps } from './date-field';
14
+
15
+ export { TimeField } from './time-field';
16
+ export type { TimeFieldProps } from './time-field';
17
+
18
+ export { DateTimeField } from './date-time-field';
19
+ export type { DateTimeFieldProps } from './date-time-field';
20
+
21
+ // Low-level building blocks, exported for advanced composition.
22
+ export { NativeFieldShell, NATIVE_FIELD_INPUT_CLASS } from './native-field-shell';
23
+ export type { NativeFieldShellProps } from './native-field-shell';
24
+ export {
25
+ parseDateString,
26
+ formatDateString,
27
+ parseDateTimeString,
28
+ splitDateTime,
29
+ joinDateTime,
30
+ } from './date-time-utils';
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * NativeFieldShell — the shared visual layer for the native-engine date/time
5
+ * fields (`DateField`, `TimeField`, `DateTimeField`).
6
+ *
7
+ * The strategy (see @dev/planned/ui-core-datetime-fields/PLAN.md): the browser's
8
+ * native `<input type="date|time|datetime-local">` is the *input engine* — it
9
+ * gives segmented keyboard entry, locale-aware order, 12/24h, arrow-key
10
+ * increment, screen-reader support, and OS wheels on mobile for free. This shell
11
+ * owns everything the native input CANNOT do to our standard: the token-styled
12
+ * box (`bg-input`, `border-border`, `--ring` focus, `--radius`), a leading
13
+ * lucide icon, invalid/disabled states, and an OPTIONAL trailing popover trigger
14
+ * for pointer users.
15
+ *
16
+ * The native browser indicator (the clock/calendar glyph) is suppressed via
17
+ * `native-datetime-input` (see datetime-field.css) so there is exactly one
18
+ * icon — ours — and one picker — ours. Typing in the field and opening the
19
+ * popover both write the same value.
20
+ */
21
+
22
+ import * as React from 'react';
23
+
24
+ import { cn } from '../../../lib/utils';
25
+
26
+ export interface NativeFieldShellProps {
27
+ /** Leading icon (lucide element). */
28
+ icon?: React.ReactNode;
29
+ /**
30
+ * When true, the leading icon is a real, sizeable click target that opens the
31
+ * popover — not a decorative glyph. The field wraps the shell in a
32
+ * `PopoverTrigger asChild` around the icon so tapping the icon (a much bigger,
33
+ * left-edge target than the small chevron) opens the picker. The centre of the
34
+ * field still belongs to the native `<input>` for typing.
35
+ */
36
+ iconOpensPopover?: boolean;
37
+ /** Invalid state — wires `aria-invalid` and a destructive border. Usually
38
+ * driven by the surrounding `Field` / `Form` layer. */
39
+ invalid?: boolean;
40
+ disabled?: boolean;
41
+ className?: string;
42
+ /** The native `<input>` element, already typed and wired by the field. */
43
+ children: React.ReactNode;
44
+ /**
45
+ * Optional popover affordance. When provided, a trailing chevron button is
46
+ * shown; `open`/`onOpenChange` are controlled by the field so the icon and
47
+ * the button share one popover.
48
+ */
49
+ popoverTrigger?: React.ReactNode;
50
+ }
51
+
52
+ /**
53
+ * The class applied to the native `<input>` INSIDE the shell. Mirrors the
54
+ * standard `Input` surface/focus (bg-input, border, --ring) but drops the
55
+ * focus ring here — the ring is drawn on the shell so it wraps the icon and
56
+ * popover button too, reading as one control.
57
+ */
58
+ export const NATIVE_FIELD_INPUT_CLASS = cn(
59
+ 'native-datetime-input',
60
+ 'peer w-full bg-transparent text-base md:text-sm outline-none',
61
+ 'text-foreground placeholder:text-muted-foreground',
62
+ 'disabled:cursor-not-allowed',
63
+ // Empty native date/time inputs render their placeholder segments muted so an
64
+ // unset field reads like a placeholder, not a value.
65
+ '[&:invalid]:text-muted-foreground',
66
+ );
67
+
68
+ /**
69
+ * The outer box. Draws the token surface + the crisp `--ring` focus edge when
70
+ * anything inside is focused (`focus-within`), matching the standard Input's
71
+ * sharp Vercel/Linear look rather than a blurry halo.
72
+ */
73
+ export const NativeFieldShell = React.forwardRef<HTMLDivElement, NativeFieldShellProps>(
74
+ ({ icon, iconOpensPopover, invalid, disabled, className, children, popoverTrigger }, ref) => {
75
+ return (
76
+ <div
77
+ ref={ref}
78
+ data-invalid={invalid ? '' : undefined}
79
+ data-disabled={disabled ? '' : undefined}
80
+ aria-invalid={invalid || undefined}
81
+ className={cn(
82
+ 'flex h-10 w-full items-center gap-2 rounded-[var(--radius)] border bg-input px-3 shadow-sm',
83
+ 'transition-[color,background-color,border-color,box-shadow]',
84
+ 'border-border',
85
+ // Crisp focus edge on the whole shell (border + 1px ring = ~2px clean
86
+ // outline). `:focus-within` so it lights whether the user tabs into
87
+ // the input or the popover button.
88
+ 'focus-within:border-ring focus-within:ring-1 focus-within:ring-ring',
89
+ invalid &&
90
+ 'border-destructive focus-within:border-destructive focus-within:ring-destructive',
91
+ disabled && 'cursor-not-allowed opacity-50',
92
+ className,
93
+ )}
94
+ >
95
+ {icon ? (
96
+ <span
97
+ className={cn(
98
+ 'flex shrink-0 text-muted-foreground [&_svg]:size-4',
99
+ // Decorative by default (clicks fall through to the input). When
100
+ // the icon is a real popover trigger, it must receive clicks.
101
+ !iconOpensPopover && 'pointer-events-none',
102
+ )}
103
+ >
104
+ {icon}
105
+ </span>
106
+ ) : null}
107
+ {children}
108
+ {popoverTrigger}
109
+ </div>
110
+ );
111
+ },
112
+ );
113
+ NativeFieldShell.displayName = 'NativeFieldShell';
@@ -0,0 +1,66 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Shared popover triggers for the date/time fields, so the two large click
5
+ * targets (leading icon + trailing chevron) look and behave identically across
6
+ * all three fields. Both are `PopoverTrigger asChild` children, so the shell's
7
+ * `focus-within` ring covers them.
8
+ *
9
+ * Why two targets: the CENTRE of the field is the native `<input>` (for typing),
10
+ * so the popover can't open from there without stealing keyboard entry. The
11
+ * icon and the chevron are the two edges that DON'T type, so both open the
12
+ * picker — the icon in particular is a big, obvious left-edge target, far easier
13
+ * to hit than a lone chevron.
14
+ */
15
+
16
+ import { ChevronDown } from 'lucide-react';
17
+ import * as React from 'react';
18
+
19
+ import { cn } from '../../../lib/utils';
20
+
21
+ export const PopoverChevron = React.forwardRef<
22
+ HTMLButtonElement,
23
+ React.ButtonHTMLAttributes<HTMLButtonElement> & { label?: string }
24
+ >(({ className, label = 'Open picker', ...props }, ref) => (
25
+ <button
26
+ ref={ref}
27
+ type="button"
28
+ aria-label={label}
29
+ className={cn(
30
+ 'flex size-6 shrink-0 items-center justify-center rounded-[var(--radius-sm)] text-muted-foreground',
31
+ 'transition-colors hover:bg-accent hover:text-foreground',
32
+ 'focus-visible:outline-none disabled:pointer-events-none',
33
+ className,
34
+ )}
35
+ {...props}
36
+ >
37
+ <ChevronDown className="size-4" aria-hidden />
38
+ </button>
39
+ ));
40
+ PopoverChevron.displayName = 'PopoverChevron';
41
+
42
+ /**
43
+ * IconTrigger — the leading icon rendered as a real, comfortably-sized popover
44
+ * button. It hugs the left edge (`-ml-1` reclaims the shell's px-3) and carries
45
+ * a generous padded hit area so the target is far bigger than the glyph itself.
46
+ */
47
+ export const IconTrigger = React.forwardRef<
48
+ HTMLButtonElement,
49
+ React.ButtonHTMLAttributes<HTMLButtonElement> & { label?: string }
50
+ >(({ className, label = 'Open picker', children, ...props }, ref) => (
51
+ <button
52
+ ref={ref}
53
+ type="button"
54
+ aria-label={label}
55
+ className={cn(
56
+ '-ml-1 flex h-8 shrink-0 items-center rounded-[var(--radius-sm)] px-1.5 text-muted-foreground',
57
+ 'transition-colors hover:bg-accent hover:text-foreground',
58
+ 'focus-visible:outline-none disabled:pointer-events-none [&_svg]:size-4',
59
+ className,
60
+ )}
61
+ {...props}
62
+ >
63
+ {children}
64
+ </button>
65
+ ));
66
+ IconTrigger.displayName = 'IconTrigger';
@@ -0,0 +1,103 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * TimeField — native `<input type="time">` in our token skin. Type `14:30`
5
+ * straight in (segmented keyboard entry, arrow increment); on touch the OS time
6
+ * wheel opens on tap. AM/PM vs 24-hour is decided by the browser from the user's
7
+ * locale — we don't render our own picker, so there's nothing to desync.
8
+ *
9
+ * Wire value is `HH:mm` (24h) — or `HH:mm:ss` when `step` yields seconds —
10
+ * matching the native input and the old `TimePicker` contract, so migration from
11
+ * the click-only `TimePicker` (kept as a back-compat alias) is mechanical.
12
+ */
13
+
14
+ import { Clock } from 'lucide-react';
15
+ import * as React from 'react';
16
+
17
+ import { NativeFieldShell, NATIVE_FIELD_INPUT_CLASS } from './native-field-shell';
18
+
19
+ export interface TimeFieldProps {
20
+ /** Value as `HH:mm` (24h). Controlled. */
21
+ value?: string;
22
+ /** Uncontrolled initial value. */
23
+ defaultValue?: string;
24
+ onChange?: (value: string) => void;
25
+ disabled?: boolean;
26
+ /** Invalid state — usually driven by the `Field` / `Form` layer. */
27
+ invalid?: boolean;
28
+ /** Native min/max (`HH:mm`). */
29
+ min?: string;
30
+ max?: string;
31
+ /**
32
+ * Native `step` in seconds. `60` (default) → HH:mm; smaller values expose a
33
+ * seconds segment. Controls the arrow-key increment too.
34
+ */
35
+ step?: number;
36
+ /** Form field name — the native input IS the form control. */
37
+ name?: string;
38
+ /**
39
+ * DOM id for the native control, so an external `<label htmlFor>` binds to
40
+ * the field a user actually focuses.
41
+ */
42
+ id?: string;
43
+ className?: string;
44
+ 'aria-label'?: string;
45
+ }
46
+
47
+ export const TimeField = React.forwardRef<HTMLInputElement, TimeFieldProps>(
48
+ (
49
+ {
50
+ value: controlledValue,
51
+ defaultValue,
52
+ onChange,
53
+ disabled = false,
54
+ invalid = false,
55
+ min,
56
+ max,
57
+ step,
58
+ name,
59
+ id,
60
+ className,
61
+ 'aria-label': ariaLabel,
62
+ },
63
+ ref,
64
+ ) => {
65
+ const isControlled = controlledValue !== undefined;
66
+ const [internal, setInternal] = React.useState(defaultValue ?? '');
67
+ const value = isControlled ? controlledValue : internal;
68
+
69
+ const commit = React.useCallback(
70
+ (next: string) => {
71
+ if (!isControlled) setInternal(next);
72
+ onChange?.(next);
73
+ },
74
+ [isControlled, onChange],
75
+ );
76
+
77
+ return (
78
+ <NativeFieldShell
79
+ icon={<Clock aria-hidden />}
80
+ invalid={invalid}
81
+ disabled={disabled}
82
+ className={className}
83
+ >
84
+ <input
85
+ ref={ref}
86
+ type="time"
87
+ className={NATIVE_FIELD_INPUT_CLASS}
88
+ value={value}
89
+ onChange={(e) => commit(e.target.value)}
90
+ disabled={disabled}
91
+ min={min}
92
+ max={max}
93
+ step={step}
94
+ name={name}
95
+ id={id}
96
+ aria-label={ariaLabel}
97
+ aria-invalid={invalid || undefined}
98
+ />
99
+ </NativeFieldShell>
100
+ );
101
+ },
102
+ );
103
+ TimeField.displayName = 'TimeField';
@@ -7,8 +7,16 @@ import * as LabelPrimitive from '@radix-ui/react-label';
7
7
 
8
8
  import { cn } from '../../../lib/utils';
9
9
 
10
+ // `select-none` is not cosmetic here.
11
+ //
12
+ // A label is text whose whole purpose is to be CLICKED, and a click that lands
13
+ // slightly off — or a click that drags a pixel — leaves the word highlighted in
14
+ // the selection colour. In a filter rail that reads as a selected FILTER rather
15
+ // than selected TEXT, so the control reports a state the user never set. The
16
+ // same shape appears in every checkbox list, radio group and switch row, which
17
+ // is why it belongs to the primitive and not to any one screen.
10
18
  const labelVariants = cva(
11
- "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
19
+ "select-none text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
12
20
  )
13
21
 
14
22
  const Label = React.forwardRef<
@@ -29,7 +29,9 @@ const RadioGroupItem = React.forwardRef<
29
29
  <RadioGroupPrimitive.Item
30
30
  ref={ref}
31
31
  className={cn(
32
- "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
32
+ // `select-none` for the same reason as Checkbox and Switch: the dot is
33
+ // a click target, and a click that drags must not select text.
34
+ "aspect-square h-4 w-4 shrink-0 select-none rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
33
35
  className
34
36
  )}
35
37
  {...props}
@@ -13,7 +13,9 @@ const Switch = React.forwardRef<
13
13
  <SwitchPrimitives.Root
14
14
  className={cn(
15
15
  // Track: generous Cloudflare-style pill (44×24) with an inset thumb.
16
- "peer group relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full",
16
+ // `select-none`: a drag starting on the pill must toggle or do nothing —
17
+ // never paint a text selection across the row the switch sits in.
18
+ "peer group relative inline-flex h-6 w-11 shrink-0 cursor-pointer select-none items-center rounded-full",
17
19
  "p-0.5 outline-none transition-colors duration-200 ease-out",
18
20
  // On/off surfaces — all on-token so every preset & theme follows along.
19
21
  "data-[state=unchecked]:bg-input data-[state=checked]:bg-primary",
@@ -42,10 +42,30 @@ export type { SegmentedInputProps } from './forms/segmented-input';
42
42
  export { TagsInput, TagsInputInput, TagsInputItem, TagsInputItemText, TagsInputItemDelete } from './forms/tags-input';
43
43
  export type { TagsInputRootProps, TagsInputInputProps, TagsInputItemProps, TagsInputItemTextProps, TagsInputItemDeleteProps } from './forms/tags-input';
44
44
 
45
- // Time Picker
45
+ // Time Picker (click-only legacy; prefer the native-engine TimeField below).
46
46
  export { TimePicker } from './forms/time-picker';
47
47
  export type { TimePickerProps } from './forms/time-picker';
48
48
 
49
+ // Native-engine date/time fields — typeable, token-skinned, optional popover.
50
+ export {
51
+ DateField,
52
+ TimeField,
53
+ DateTimeField,
54
+ NativeFieldShell,
55
+ NATIVE_FIELD_INPUT_CLASS,
56
+ parseDateString,
57
+ formatDateString,
58
+ parseDateTimeString,
59
+ splitDateTime,
60
+ joinDateTime,
61
+ } from './forms/datetime-field';
62
+ export type {
63
+ DateFieldProps,
64
+ TimeFieldProps,
65
+ DateTimeFieldProps,
66
+ NativeFieldShellProps,
67
+ } from './forms/datetime-field';
68
+
49
69
  // Editable
50
70
  export { Editable, EditablePreview, EditableInput, EditableTextarea } from './forms/editable';
51
71
  export type { EditableRootProps, EditablePreviewProps, EditableInputProps, EditableTextareaProps } from './forms/editable';
@@ -34,6 +34,13 @@ const PopoverContent = React.forwardRef<
34
34
  <PopoverPrimitive.Content
35
35
  forceMount={forceMount}
36
36
  ref={ref}
37
+ // `data-slot` lets descendants detect they're inside a popover surface.
38
+ // The Calendar keys its `bg-transparent` override off
39
+ // `[[data-slot=popover-content]_&]` so a calendar rendered in a popover
40
+ // drops its own `bg-background` panel and inherits the popover surface —
41
+ // without this attribute that rule never matches and the calendar shows a
42
+ // mismatched darker rectangle inside the popover.
43
+ data-slot="popover-content"
37
44
  aria-describedby={undefined}
38
45
  align={align}
39
46
  sideOffset={sideOffset}
@@ -21,9 +21,20 @@
21
21
  --radius: 0.25rem;
22
22
  --border: hsl(0 0% 24%);
23
23
  --input: hsl(0 0% 24%);
24
- --divider: hsl(0 0% 46% / 0.18);
24
+ /* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
25
+ weaker than that panel's own --border — otherwise the five lines between
26
+ rows shout as loudly as the one frame around them, which is what was
27
+ left of "кондово" after the fills were calmed. Over a 12% card this
28
+ lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
29
+ hierarchy, frame first. */
30
+ --divider: hsl(0 0% 46% / 0.10);
25
31
  --muted: hsl(0 0% 12%);
26
- --card: hsl(0 0% 10%);
32
+ /* 12%, against an INHERITED --background of 14.5% from dark.css — this
33
+ preset declares no background of its own, so the card sits DARKER than the
34
+ page here rather than lighter. Direction is not the contract; the size of
35
+ the step is. Capped by MAXIMUM_RESTING_SURFACE_GAP in
36
+ check-preset-contrast.mjs. */
37
+ --card: hsl(0 0% 12%);
27
38
  /* 20%, not 14%: --background is 14.5%, so this was HALF a point — the
28
39
  hover was effectively invisible. */
29
40
  --accent: hsl(0 0% 20%);
@@ -62,7 +62,11 @@
62
62
  .dark {
63
63
  --background: hsl(240 6% 10%);
64
64
  --foreground: hsl(0 0% 96%);
65
- --card: hsl(240 6% 16%);
65
+ /* Three points above --background, not seven. A resting surface is
66
+ separated by its BORDER; when the fill does that job the page reads as
67
+ stacked plates and a table looks like a sticker laid over it. Capped by
68
+ MAXIMUM_RESTING_SURFACE_GAP in check-preset-contrast.mjs. */
69
+ --card: hsl(240 6% 14%);
66
70
  --card-foreground: hsl(0 0% 96%);
67
71
  --popover: hsl(240 6% 20%);
68
72
  --popover-foreground: hsl(0 0% 96%);
@@ -76,10 +80,20 @@
76
80
  --accent-foreground: hsl(0 0% 96%);
77
81
  --destructive: hsl(0 100% 67%);
78
82
  --destructive-foreground: hsl(0 0% 100%);
79
- --border: hsl(240 5% 27%);
83
+ /* A hairline divides, it does not glow. Ten points over --background is a
84
+ definite edge at one device pixel without becoming the loudest thing on
85
+ the page — which it was once the fills stopped separating surfaces.
86
+ Capped by MAXIMUM_BORDER_GAP in check-preset-contrast.mjs. */
87
+ --border: hsl(240 5% 20%);
80
88
  --input: hsl(240 5% 22%);
81
89
  --ring: hsl(211 100% 55%);
82
- --divider: hsl(240 5% 48% / 0.18);
90
+ /* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
91
+ weaker than that panel's own --border — otherwise the five lines between
92
+ rows shout as loudly as the one frame around them, which is what was
93
+ left of "кондово" after the fills were calmed. Over a 12% card this
94
+ lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
95
+ hierarchy, frame first. */
96
+ --divider: hsl(240 5% 48% / 0.10);
83
97
  --radius: 0.75rem;
84
98
  --overlay: hsl(0 0% 0% / 0.55);
85
99
  --sidebar-background: hsl(240 6% 8%);
@@ -83,7 +83,11 @@
83
83
  .dark {
84
84
  --background: hsl(240 5% 8%);
85
85
  --foreground: hsl(0 0% 95%);
86
- --card: hsl(240 3% 15%);
86
+ /* Three points above --background, not seven. A resting surface is
87
+ separated by its BORDER; when the fill does that job the page reads as
88
+ stacked plates and a table looks like a sticker laid over it. Capped by
89
+ MAXIMUM_RESTING_SURFACE_GAP in check-preset-contrast.mjs. */
90
+ --card: hsl(240 3% 12%);
87
91
  --card-foreground: hsl(0 0% 95%);
88
92
  --popover: hsl(240 3% 20%);
89
93
  --popover-foreground: hsl(0 0% 95%);
@@ -99,9 +103,19 @@
99
103
  --accent-foreground: hsl(211 100% 72%);
100
104
  --destructive: hsl(3 100% 62%);
101
105
  --destructive-foreground: hsl(0 0% 9%);
102
- --border: hsl(240 3% 22%);
106
+ /* A hairline divides, it does not glow. Ten points over --background is a
107
+ definite edge at one device pixel without becoming the loudest thing on
108
+ the page — which it was once the fills stopped separating surfaces.
109
+ Capped by MAXIMUM_BORDER_GAP in check-preset-contrast.mjs. */
110
+ --border: hsl(240 3% 18%);
103
111
  --input: hsl(240 3% 22%);
104
- --divider: hsl(240 4% 46% / 0.18);
112
+ /* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
113
+ weaker than that panel's own --border — otherwise the five lines between
114
+ rows shout as loudly as the one frame around them, which is what was
115
+ left of "кондово" after the fills were calmed. Over a 12% card this
116
+ lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
117
+ hierarchy, frame first. */
118
+ --divider: hsl(240 4% 46% / 0.10);
105
119
  --ring: hsl(211 100% 58%);
106
120
  --radius: 0.625rem;
107
121
  --overlay: hsl(0 0% 0% / 0.55);
@@ -34,9 +34,19 @@
34
34
  --muted-foreground: hsl(240 5% 64%);
35
35
  --accent: hsl(240 5% 16%);
36
36
  --accent-foreground: hsl(0 0% 96%);
37
- --border: hsl(240 5% 20%);
37
+ /* A hairline divides, it does not glow. Ten points over --background is a
38
+ definite edge at one device pixel without becoming the loudest thing on
39
+ the page — which it was once the fills stopped separating surfaces.
40
+ Capped by MAXIMUM_BORDER_GAP in check-preset-contrast.mjs. */
41
+ --border: hsl(240 5% 18%);
38
42
  --input: hsl(240 5% 20%);
39
- --divider: hsl(240 5% 48% / 0.18);
43
+ /* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
44
+ weaker than that panel's own --border — otherwise the five lines between
45
+ rows shout as loudly as the one frame around them, which is what was
46
+ left of "кондово" after the fills were calmed. Over a 12% card this
47
+ lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
48
+ hierarchy, frame first. */
49
+ --divider: hsl(240 5% 48% / 0.10);
40
50
  --radius: 1rem;
41
51
  --sidebar-background: hsl(240 6% 6%);
42
52
  --sidebar-accent: hsl(240 5% 14%);
@@ -92,9 +92,19 @@
92
92
  --accent-foreground: hsl(200 100% 75%);
93
93
  --destructive: hsl(0 90% 62%);
94
94
  --destructive-foreground: hsl(0 0% 100%);
95
- --border: hsl(0 0% 28%);
95
+ /* A hairline divides, it does not glow. Ten points over --background is a
96
+ definite edge at one device pixel without becoming the loudest thing on
97
+ the page — which it was once the fills stopped separating surfaces.
98
+ Capped by MAXIMUM_BORDER_GAP in check-preset-contrast.mjs. */
99
+ --border: hsl(0 0% 23%);
96
100
  --input: hsl(0 0% 24%);
97
- --divider: hsl(0 0% 46% / 0.18);
101
+ /* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
102
+ weaker than that panel's own --border — otherwise the five lines between
103
+ rows shout as loudly as the one frame around them, which is what was
104
+ left of "кондово" after the fills were calmed. Over a 12% card this
105
+ lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
106
+ hierarchy, frame first. */
107
+ --divider: hsl(0 0% 46% / 0.10);
98
108
  --ring: hsl(200 100% 69%);
99
109
  --overlay: hsl(0 0% 0% / 0.55);
100
110
  --radius: 0.375rem;
@@ -44,14 +44,24 @@
44
44
  --destructive-foreground: hsl(0 0% 98%);
45
45
  /* Soft warm border — Claude draws hairlines from a light border at low
46
46
  * contrast, so a mid warm-gray keeps lines visible but never harsh. */
47
- --border: hsl(48 3% 28%);
47
+ /* A hairline divides, it does not glow. Ten points over --background is a
48
+ definite edge at one device pixel without becoming the loudest thing on
49
+ the page — which it was once the fills stopped separating surfaces.
50
+ Capped by MAXIMUM_BORDER_GAP in check-preset-contrast.mjs. */
51
+ --border: hsl(48 3% 24%);
48
52
  /* Input surface — a notch above card so fields read as raised controls. */
49
53
  --input: hsl(48 2.5% 24%);
50
54
  /* Divider — TRANSLUCENT hairline (Apple-style): a light warm-gray at low alpha
51
55
  * that dissolves into whatever sits behind it, so structural separators
52
56
  * (columns, headers, rows) stay quiet on the dark page instead of reading as a
53
57
  * hard opaque rule. Backs default + django-cfg (they don't re-declare it). */
54
- --divider: hsl(48 4% 46% / 0.18);
58
+ /* 0.10, not 0.18. A divider separates rows INSIDE a panel, so it must be
59
+ weaker than that panel's own --border — otherwise the five lines between
60
+ rows shout as loudly as the one frame around them, which is what was
61
+ left of "кондово" after the fills were calmed. Over a 12% card this
62
+ lands at 15.4% (step 3.4) against the border's 18% (step 6.0): a clear
63
+ hierarchy, frame first. */
64
+ --divider: hsl(48 4% 46% / 0.10);
55
65
  /* Overlay — modal scrim / backdrop behind dialogs, drawers, sheets. Black in
56
66
  * both themes; slightly darker here so it still reads on the dark page. */
57
67
  --overlay: hsl(0 0% 0% / 0.7);