@spree/dashboard-ui 1.0.0-beta.2 → 1.0.0-beta.4

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@spree/dashboard-ui",
3
3
  "license": "MIT",
4
- "version": "1.0.0-beta.2",
4
+ "version": "1.0.0-beta.4",
5
5
  "description": "Spree Dashboard design system — shadcn primitives, headless composed components, and design tokens. Source-only package; consumers compile via their own Vite/Tailwind setup. Developer Preview.",
6
6
  "author": "Vendo Connect Inc.",
7
7
  "homepage": "https://spreecommerce.org",
@@ -66,7 +66,7 @@
66
66
  "date-fns": "^4.1.0",
67
67
  "date-fns-tz": "^3.2.0",
68
68
  "flag-icons": "^7.5.0",
69
- "lucide-react": "^1.14.0",
69
+ "lucide-react": "^1.47.0",
70
70
  "react-colorful": "^5.7.0",
71
71
  "react-day-picker": "^9.14.0",
72
72
  "recharts": "^3.8.1",
@@ -7,8 +7,9 @@ interface UseCopyToClipboardOptions {
7
7
 
8
8
  /**
9
9
  * Wraps `navigator.clipboard.writeText` with a `copied` flag that flashes true
10
- * for `resetMs` after a successful copy. Failure (insecure context, denied
11
- * permissions) is swallowed; the flag stays false.
10
+ * for `resetMs` after a successful copy. `copy` resolves to whether it worked:
11
+ * it fails in insecure contexts or when permission is denied, and the flag
12
+ * then stays false.
12
13
  *
13
14
  * The reset timer is cleared on unmount so the component doesn't try to
14
15
  * `setState` after it's gone.
@@ -24,7 +25,7 @@ export function useCopyToClipboard({ resetMs = 1200 }: UseCopyToClipboardOptions
24
25
  }, [])
25
26
 
26
27
  const copy = useCallback(
27
- async (value: string) => {
28
+ async (value: string): Promise<boolean> => {
28
29
  try {
29
30
  await navigator.clipboard.writeText(value)
30
31
  setCopied(true)
@@ -33,8 +34,9 @@ export function useCopyToClipboard({ resetMs = 1200 }: UseCopyToClipboardOptions
33
34
  setCopied(false)
34
35
  timerRef.current = null
35
36
  }, resetMs)
37
+ return true
36
38
  } catch {
37
- // Clipboard API can fail in insecure contexts; ignore silently.
39
+ return false
38
40
  }
39
41
  },
40
42
  [resetMs],
@@ -92,6 +92,10 @@ export interface BulkPriceTableProps {
92
92
  onTierAdd?: (rowId: string) => void
93
93
  /** Removes a tier row. */
94
94
  onTierRemove?: (rowId: string) => void
95
+ /** Why the last save was refused. Rendered above the grid rather than
96
+ * toasted: this table lives in a dialog, and the toast viewport sits
97
+ * below the overlay layer so nothing raised from in here is visible. */
98
+ error?: string | null
95
99
 
96
100
  // Optional state for the toolbar + pagination footer. Omitting all of these
97
101
  // hides the toolbar/pagination entirely (use for in-memory single-page lists).
@@ -125,6 +129,7 @@ export function BulkPriceTable({
125
129
  totalPages,
126
130
  onPageChange,
127
131
  isLoading,
132
+ error,
128
133
  }: BulkPriceTableProps) {
129
134
  const columns = useMemo<ColumnDef<BulkPriceRow>[]>(
130
135
  () => [
@@ -306,6 +311,14 @@ export function BulkPriceTable({
306
311
  // grid between them should run to the container's edges, the way a
307
312
  // table in a card does.
308
313
  <div className="flex h-full flex-col gap-3">
314
+ {error && (
315
+ <div
316
+ role="alert"
317
+ className="mx-3 shrink-0 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-destructive text-sm"
318
+ >
319
+ {error}
320
+ </div>
321
+ )}
309
322
  {showToolbar && (
310
323
  // Always-mounted toolbar. Conditionally rendering the search input
311
324
  // would unmount it whenever a deferred query refetches into the
@@ -144,30 +144,26 @@ interface MoneyCellProps extends BaseCellProps {
144
144
  /** Canonical decimal string (`12.50`) coming back from the API, or
145
145
  * `null`/blank for unset. */
146
146
  value: string | null
147
- /** Receives the user's raw input — locale-formatted strings like
148
- * `"12,50"` are fine, the backend's `Spree::LocalizedNumber` does the
149
- * parsing. `null` only when the user explicitly blanks the field. */
147
+ /** Receives the user's raw, locale-formatted input (`"12,50"`). The caller
148
+ * normalizes it to canonical and passes that back as `value`, so the cell
149
+ * always shows what will be saved. `null` only when the user explicitly
150
+ * blanks the field. */
150
151
  onChange: (next: string | null) => void
151
152
  /** Currency symbol rendered as a non-editable prefix (`$`, `€`, `kr`, …).
152
153
  * Omit for a plain decimal cell with no prefix. */
153
154
  symbol?: string
154
155
  /**
155
156
  * Locale decimal separator used only for display (canonical `.` →
156
- * locale char). Whatever the user types is shipped as-is; the
157
- * backend handles thousands separators, grouped digits, and so on.
158
- * Default `.`.
157
+ * locale char). Default `.`.
159
158
  */
160
159
  decimal?: string
161
160
  }
162
161
 
163
162
  /**
164
163
  * Editable money cell. Like `NumberCell` but keeps decimals (no truncation)
165
- * and renders a currency-symbol prefix. Mirrors the rails admin's
166
- * `edit_prices` bulk editor: format the API's canonical value with the
167
- * locale's decimal mark for display, but ship the user's raw input
168
- * untouched on save. `Spree::LocalizedNumber.parse` on the backend handles
169
- * locale-aware parsing (thousands separators, decimal comma, etc.) so the
170
- * SPA doesn't have to reimplement it.
164
+ * and renders a currency-symbol prefix. Formats the canonical value with the
165
+ * locale's decimal mark for display and hands the user's raw input to
166
+ * `onChange` for the caller to normalize.
171
167
  */
172
168
  export function MoneyCell({
173
169
  coords,
@@ -219,9 +215,7 @@ export function MoneyCell({
219
215
  })
220
216
 
221
217
  function commit() {
222
- // Ship the user's raw input; the backend's `Spree::LocalizedNumber`
223
- // handles locale-aware parsing. Empty string is the only "clear"
224
- // signal we generate frontend-side.
218
+ // Empty string is the only "clear" signal we generate frontend-side.
225
219
  onChange(draft.trim() ? draft : null)
226
220
  }
227
221
 
@@ -241,7 +241,11 @@ function DataGridShell<T>({
241
241
  className,
242
242
  )}
243
243
  aria-label={ariaLabel}
244
- onBlurCapture={(e) => {
244
+ // Bubble phase, never capture: React dispatches the two phases from
245
+ // separate native listeners and renders queued updates between
246
+ // them, so clearing `editing` here first would re-render the cell
247
+ // out of edit mode before its own blur commits the typed value.
248
+ onBlur={(e) => {
245
249
  // A cell unmounting mid-commit is not the merchant leaving the
246
250
  // grid: `relatedTarget` is null there, and the dialog's focus
247
251
  // trap then claims the focus a frame later. Take it back on the
@@ -293,7 +297,7 @@ function DataGridShell<T>({
293
297
  // The table-level classes zero this cell's own top and
294
298
  // bottom borders: a border sits outside the padding box and
295
299
  // the shadow inside, so leaving both draws two rules at rest.
296
- className="h-8 bg-muted px-3 text-left font-medium shadow-[inset_0_-1px_0_0_var(--border)]"
300
+ className="h-8 bg-muted px-3 text-left font-medium text-nowrap shadow-[inset_0_-1px_0_0_var(--border)]"
297
301
  >
298
302
  {header.isPlaceholder
299
303
  ? null
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from 'react'
1
2
  import { Thumbnail } from '../ui/thumbnail'
2
3
  import { PackageIcon } from './icons'
3
4
 
@@ -5,6 +6,9 @@ import { PackageIcon } from './icons'
5
6
  * One row of a fulfillment's item list, already joined to its line item by
6
7
  * the caller. The image and the price are optional: a surface that does not
7
8
  * expose them renders the name and the count alone.
9
+ *
10
+ * `priceSource` is a node because saying where a price came from means
11
+ * linking to it, and routing belongs to the app.
8
12
  */
9
13
  export type FulfillmentItemRowData = {
10
14
  key: string
@@ -12,6 +16,7 @@ export type FulfillmentItemRowData = {
12
16
  optionsText?: string | null
13
17
  thumbnailUrl?: string | null
14
18
  displayPrice?: string | null
19
+ priceSource?: ReactNode
15
20
  quantity: number
16
21
  }
17
22
 
@@ -35,14 +40,15 @@ function ItemRow({ row }: { row: FulfillmentItemRowData }) {
35
40
  )}
36
41
  </div>
37
42
 
38
- <div className="shrink-0 whitespace-nowrap text-right text-muted-foreground text-sm">
43
+ <div className="min-w-0 shrink text-right text-muted-foreground text-sm">
39
44
  {row.displayPrice ? (
40
- <span>
45
+ <span className="whitespace-nowrap">
41
46
  {row.displayPrice} × {row.quantity}
42
47
  </span>
43
48
  ) : (
44
- <span>× {row.quantity}</span>
49
+ <span className="whitespace-nowrap">× {row.quantity}</span>
45
50
  )}
51
+ {row.priceSource && <div className="truncate text-xs">{row.priceSource}</div>}
46
52
  </div>
47
53
  </div>
48
54
  )
@@ -53,6 +53,7 @@ export {
53
53
  FileArchiveIcon,
54
54
  FileAudioIcon,
55
55
  FileCodeIcon,
56
+ FileDownIcon,
56
57
  FileIcon,
57
58
  FileImageIcon,
58
59
  FileSpreadsheetIcon,
@@ -135,10 +136,12 @@ export {
135
136
  SlidersHorizontalIcon,
136
137
  SparklesIcon,
137
138
  SplitIcon,
139
+ SquareTextIcon,
138
140
  StarIcon,
139
141
  StoreIcon,
140
142
  StrikethroughIcon,
141
143
  SunIcon,
144
+ SwatchBookIcon,
142
145
  TableIcon,
143
146
  TagIcon,
144
147
  TagsIcon,
@@ -22,7 +22,10 @@ export function MetadataCard({ metadata, title, emptyTitle, emptyDescription }:
22
22
  return (
23
23
  <Card>
24
24
  <CardHeader>
25
- <CardTitle>{title}</CardTitle>
25
+ <CardTitle>
26
+ <BracesIcon className="size-4" />
27
+ {title}
28
+ </CardTitle>
26
29
  </CardHeader>
27
30
  <CardContent>
28
31
  {entries.length === 0 ? (
@@ -19,6 +19,7 @@ export function StatusCard<TFieldValues extends FieldValues>({
19
19
  control,
20
20
  name,
21
21
  title,
22
+ icon,
22
23
  label,
23
24
  options,
24
25
  disabled,
@@ -28,6 +29,7 @@ export function StatusCard<TFieldValues extends FieldValues>({
28
29
  control: Control<TFieldValues>
29
30
  name: Path<TFieldValues>
30
31
  title: string
32
+ icon?: React.ReactNode
31
33
  label: string
32
34
  options: StatusOption[]
33
35
  disabled?: boolean
@@ -42,7 +44,10 @@ export function StatusCard<TFieldValues extends FieldValues>({
42
44
  return (
43
45
  <Card>
44
46
  <CardHeader>
45
- <CardTitle>{title}</CardTitle>
47
+ <CardTitle>
48
+ {icon}
49
+ {title}
50
+ </CardTitle>
46
51
  </CardHeader>
47
52
  <CardContent className="flex flex-col gap-4">
48
53
  <Field>
package/src/styles.css CHANGED
@@ -153,7 +153,7 @@
153
153
  sheet rather than a surface floating above it, so its edge only has to
154
154
  suggest where the region ends. Used by `CardContent`'s frame; a dialog or
155
155
  the sheet itself must keep --border, or the shell loses its outline. */
156
- --border-card: color-mix(in srgb, var(--border-base) 50%, transparent);
156
+ --border-card: color-mix(in srgb, var(--border-base) 60%, transparent);
157
157
  /* Dividers *inside* a container — card header/footer splits, dialog and
158
158
  sheet separators, table row rules.
159
159
 
@@ -171,9 +171,9 @@
171
171
  3.11:1 on the page ground, the lightest step that clears the minimum on
172
172
  both.
173
173
 
174
- No form control uses this. Fields, selects, comboboxes, checkboxes and
175
- radios all take `--border` with `--shadow-xs`, the same pair the outline
176
- button wears, so every bordered control on a card reads as one family.
174
+ No form control uses this. Fields, selects and comboboxes take `--border`
175
+ with `--shadow-control`; checkboxes and radios, too small to carry that
176
+ lift, keep `--shadow-xs` like the outline button.
177
177
 
178
178
  A tinted fill on fields was tried and reverted. Disabled fields are
179
179
  `--muted`, so any resting tint either collides with that meaning or (as
@@ -430,10 +430,12 @@
430
430
  a card. Measured off a reference: the ground under a field's bottom edge
431
431
  recovers over about 2px and nothing spreads above it or to the sides, so
432
432
  this is purely directional, with no ambient layer. */
433
- --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
434
- /* The same lift, under its Tailwind-conventional name. Points at the token
435
- above rather than repeating the value, so the two cannot drift. */
436
- --shadow-sm: var(--shadow-xs);
433
+ --shadow-xs: 0 0px 1px 0 rgb(0 0 0 / 0.05);
434
+ /* A form field's resting lift. Heavier than --shadow-xs and weighted to the
435
+ bottom edge only, so the field reads as a slightly raised well without a
436
+ tinted fill — the fill stays on the card colour (see --border-control). */
437
+ --shadow-control: 0 0.5px 1px 0 rgb(0 0 0 / 0.05);
438
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
437
439
  --shadow-nav: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.04) 0px 0px 0px 1px, rgba(0, 0, 0, 0.04) 0px 1px 2px -1px, rgba(0, 0, 0, 0.02) 0px 2px 4px 0px;
438
440
 
439
441
  /* Floating surfaces — menus, popovers, dialogs, sheets. Wide and faint
@@ -576,6 +578,36 @@
576
578
  }
577
579
  }
578
580
 
581
+ @utility link {
582
+ /* The one treatment for an inline textual link — a link in prose, in help
583
+ text, inside an alert or an empty state. Colour rather than a permanent
584
+ underline, because the dashboard puts links inside dense body copy where
585
+ a rule under every one reads as noise; the underline arrives on hover so
586
+ the affordance is still there when the pointer looks for it.
587
+
588
+ NOT for links that are really something else: a table row, a breadcrumb,
589
+ a nav item, a card wrapped in an anchor, a button rendered as `<a>`.
590
+ Those carry their own treatment and `.link` would fight it.
591
+
592
+ `focus-visible` draws a ring rather than borrowing `focus-ring`'s border
593
+ +shadow: a text link has no border to recolour, so it needs an outline
594
+ that follows the text box instead. */
595
+ color: var(--link);
596
+ text-decoration-line: none;
597
+ text-underline-offset: 3px;
598
+ border-radius: var(--radius-sm);
599
+
600
+ &:hover {
601
+ color: var(--link-hover);
602
+ text-decoration-line: underline;
603
+ }
604
+
605
+ &:focus-visible {
606
+ outline: 2px solid var(--ring);
607
+ outline-offset: 2px;
608
+ }
609
+ }
610
+
579
611
  @utility themed-scrollbar {
580
612
  /* Theme-aware thin scrollbar utility. Track is transparent, thumb uses
581
613
  CSS vars defined in :root and .dark, so it adapts to the active theme.
@@ -25,7 +25,7 @@ function Checkbox({
25
25
  disabled={disabled}
26
26
  name={name}
27
27
  className={cn(
28
- 'peer relative inline-block size-4 shrink-0 cursor-pointer rounded-[4px] border border-border bg-card align-[-3px] outline-none transition-[color,background-color,border-color,box-shadow] duration-100 ease-out',
28
+ 'peer relative inline-block size-4 shrink-0 cursor-pointer rounded-[4px] border border-border shadow-xs bg-card align-[-3px] outline-none transition-[color,background-color,border-color,box-shadow] duration-100 ease-out',
29
29
  // The 16px box is the right *visual* size in a dense table, but on its
30
30
  // own it is a 16px target — under the 24px WCAG 2.2 asks for, on the
31
31
  // control every bulk action starts from. A pseudo-element extends the
@@ -57,7 +57,7 @@ function ComboboxButtonTrigger({
57
57
  data-slot="combobox-button-trigger"
58
58
  data-size={size}
59
59
  className={cn(
60
- "group/combobox-trigger flex w-full min-h-8 cursor-pointer items-center justify-between gap-1.5 rounded-md border border-border bg-card py-1.5 pr-2 pl-2.5 text-base font-normal leading-normal text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none select-none focus:border-blue-500 focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground aria-invalid:border-destructive data-[size=sm]:shadow-xs data-[size=sm]:h-8 data-[size=sm]:min-h-8 data-[size=sm]:gap-2 data-[size=sm]:px-2 data-[size=sm]:py-1 data-[size=sm]:text-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
60
+ "group/combobox-trigger flex w-full min-h-8 cursor-pointer items-center justify-between gap-1.5 rounded-md border border-border shadow-control bg-card py-1.5 pr-2 pl-2.5 text-base font-normal leading-normal text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none select-none focus:border-blue-500 focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground aria-invalid:border-destructive data-[size=sm]:shadow-control data-[size=sm]:h-8 data-[size=sm]:min-h-8 data-[size=sm]:gap-2 data-[size=sm]:px-2 data-[size=sm]:py-1 data-[size=sm]:text-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
61
61
  className,
62
62
  )}
63
63
  {...props}
@@ -295,7 +295,7 @@ function ComboboxChips({
295
295
  // resize lets the positioner follow the anchor smoothly; making it
296
296
  // instant jumps the open popup out from under the pointer, so the next
297
297
  // option click lands on nothing.
298
- 'flex flex-wrap items-center gap-1.5 rounded-md border border-border bg-card text-foreground px-2.5 py-1.5 text-base transition-all duration-100 ease-out focus-within:border-blue-500 focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:p-1.5',
298
+ 'flex flex-wrap items-center gap-1.5 rounded-md border border-border shadow-control bg-card text-foreground px-2.5 py-1.5 text-base transition-all duration-100 ease-out focus-within:border-blue-500 focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:p-1.5',
299
299
  className,
300
300
  )}
301
301
  {...props}
@@ -11,7 +11,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
11
11
  data-slot="input-group"
12
12
  role="group"
13
13
  className={cn(
14
- 'group/input-group relative flex w-full min-w-0 items-center rounded-md border border-border bg-card text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:shadow-none has-disabled:bg-muted has-disabled:cursor-not-allowed has-disabled:text-muted-foreground has-[[data-slot=input-group-control]:focus-visible]:border-blue-500 has-[[data-slot=input-group-control]:focus-visible]:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] has-[[data-slot][aria-invalid=true]]:border-destructive has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
14
+ 'group/input-group relative flex w-full min-w-0 items-center rounded-md border border-border shadow-control bg-card text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:shadow-none has-disabled:bg-muted has-disabled:cursor-not-allowed has-disabled:text-muted-foreground has-[[data-slot=input-group-control]:focus-visible]:border-blue-500 has-[[data-slot=input-group-control]:focus-visible]:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] has-[[data-slot][aria-invalid=true]]:border-destructive has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
15
15
  className,
16
16
  )}
17
17
  {...props}
package/src/ui/input.tsx CHANGED
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
8
8
  type={type}
9
9
  data-slot="input"
10
10
  className={cn(
11
- 'block w-full min-w-0 min-h-8 rounded-md border border-border bg-card py-1.5 px-2.5 text-base font-normal leading-normal text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none placeholder:text-muted-foreground focus:border-blue-500 focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground aria-invalid:border-destructive file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
11
+ 'block w-full min-w-0 min-h-8 rounded-md border border-border shadow-control bg-card py-1.5 px-2.5 text-base font-normal leading-normal text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none placeholder:text-muted-foreground focus:border-blue-500 focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground aria-invalid:border-destructive file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
12
12
  className,
13
13
  )}
14
14
  {...props}
@@ -191,7 +191,7 @@ export function RichTextEditor({
191
191
  // in a form beside them without reading as a different kind of control.
192
192
  // `focus-within` rather than `focus`: the element that takes the caret
193
193
  // is Tiptap's inner contenteditable, not this wrapper.
194
- 'rounded-md border border-border bg-card text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out focus-within:border-blue-500 focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)]',
194
+ 'rounded-md border border-border shadow-control bg-card text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out focus-within:border-blue-500 focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)]',
195
195
  disabled && 'pointer-events-none bg-muted text-muted-foreground shadow-none',
196
196
  className,
197
197
  )}
@@ -252,7 +252,7 @@ function EditorToolbar({
252
252
  return (
253
253
  <div
254
254
  data-slot="rich-text-editor-toolbar"
255
- className="flex items-center gap-0.5 border-b border-border px-2 py-1.5"
255
+ className="flex items-center gap-0.5 border-b border-border-subtle px-2 py-1.5"
256
256
  >
257
257
  <ToolbarButton
258
258
  active={toolbar.isBold}
package/src/ui/select.tsx CHANGED
@@ -66,7 +66,7 @@ function SelectTrigger({
66
66
  // A row mixing a select with a popover trigger (the dashboard's channel
67
67
  // picker beside its date range) then reads as one set of controls
68
68
  // rather than two. The default size is a form field and stays flat.
69
- "group/select-trigger flex w-full min-h-8 cursor-pointer items-center justify-between gap-1.5 rounded-md border border-border bg-card py-1.5 pr-2 pl-2.5 text-base font-normal leading-normal text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none select-none focus:border-blue-500 focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground aria-invalid:border-destructive data-[placeholder]:text-muted-foreground data-[size=sm]:shadow-xs data-[size=sm]:h-8 data-[size=sm]:min-h-8 data-[size=sm]:gap-2 data-[size=sm]:px-2 data-[size=sm]:py-1 data-[size=sm]:text-sm data-[size=sm]:[&_svg:not([class*='size-'])]:size-3.5 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
69
+ "group/select-trigger flex w-full min-h-8 cursor-pointer items-center justify-between gap-1.5 rounded-md border border-border shadow-control bg-card py-1.5 pr-2 pl-2.5 text-base font-normal leading-normal text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none select-none focus:border-blue-500 focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground aria-invalid:border-destructive data-[placeholder]:text-muted-foreground data-[size=sm]:shadow-control data-[size=sm]:h-8 data-[size=sm]:min-h-8 data-[size=sm]:gap-2 data-[size=sm]:px-2 data-[size=sm]:py-1 data-[size=sm]:text-sm data-[size=sm]:[&_svg:not([class*='size-'])]:size-3.5 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
70
70
  className,
71
71
  )}
72
72
  {...props}
@@ -421,13 +421,67 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S
421
421
  )
422
422
  }
423
423
 
424
- function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
424
+ /**
425
+ * Width of `element`'s vertical scrollbar in pixels — 0 while it does not
426
+ * overflow, and 0 wherever the platform overlays its scrollbars. A scrollbar
427
+ * appearing shrinks the content box, so observing the size catches it.
428
+ */
429
+ function useScrollbarWidth(element: HTMLElement | null) {
430
+ const [width, setWidth] = React.useState(0)
431
+
432
+ React.useLayoutEffect(() => {
433
+ if (!element) return
434
+ const measure = () => setWidth(element.offsetWidth - element.clientWidth)
435
+ const observer = new ResizeObserver(measure)
436
+ observer.observe(element)
437
+ measure()
438
+ return () => observer.disconnect()
439
+ }, [element])
440
+
441
+ return width
442
+ }
443
+
444
+ function SidebarContent({ className, style, ref, ...props }: React.ComponentProps<'div'>) {
445
+ const [element, setElement] = React.useState<HTMLDivElement | null>(null)
446
+ const scrollbarWidth = useScrollbarWidth(element)
447
+
448
+ const setRefs = React.useCallback(
449
+ (node: HTMLDivElement | null) => {
450
+ setElement(node)
451
+ if (typeof ref === 'function') ref(node)
452
+ else if (ref) ref.current = node
453
+ },
454
+ [ref],
455
+ )
456
+
425
457
  return (
426
458
  <div
459
+ ref={setRefs}
427
460
  data-slot="sidebar-content"
428
461
  data-sidebar="content"
462
+ style={
463
+ { '--sidebar-scrollbar-width': `${scrollbarWidth}px`, ...style } as React.CSSProperties
464
+ }
429
465
  className={cn(
430
- 'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 pt-3 overflow-auto group-data-[collapsible=icon]:overflow-visible',
466
+ // `scroll-fade` dissolves the rows into the header above and the pinned
467
+ // entries below as they scroll past. Its defaults suit a long page, not
468
+ // a rail: at most 40px of fade, easing out across the last 96px of
469
+ // scroll, which left a row sliced flat against Settings whenever the
470
+ // list was nearly at its end. A row-deep fade that holds until the
471
+ // final 1rem reads as a fade at every position; `pb-3` lets the last
472
+ // row come clear of it at the end.
473
+ //
474
+ // The icon rail scrolls too, with no scrollbar: a rail too narrow for
475
+ // one would otherwise let its icons run over the pinned entries and the
476
+ // account row. Its tooltips and hover menus are portalled, so nothing
477
+ // needs to escape the box.
478
+ 'quiet-scrollbar scroll-fade [--scroll-fade-size:3rem] [--scroll-fade-reveal:1rem] flex min-h-0 flex-1 flex-col gap-0 pt-3 pb-3 overflow-auto group-data-[collapsible=icon]:no-scrollbar',
479
+ // A scrollbar would otherwise take its width out of the rows, leaving
480
+ // them short of the header and the pinned entries. It moves into the
481
+ // sidebar's own padding instead (up to 0.5rem), and anything wider
482
+ // comes out of the groups' end padding — so rows end in the same place
483
+ // whether the list scrolls or not.
484
+ '-me-[min(var(--sidebar-scrollbar-width),0.5rem)] **:data-[slot=sidebar-group]:pe-[calc(0.5rem_-_max(0px,var(--sidebar-scrollbar-width)_-_0.5rem))] group-data-[collapsible=icon]:**:data-[slot=sidebar-group]:pe-0',
431
485
  className,
432
486
  )}
433
487
  {...props}
@@ -654,7 +708,7 @@ function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>)
654
708
  data-slot="sidebar-menu-badge"
655
709
  data-sidebar="menu-badge"
656
710
  className={cn(
657
- 'pointer-events-none flex h-5 min-w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border px-1.5 text-[11px] font-medium text-sidebar-foreground/70 tabular-nums select-none',
711
+ 'pointer-events-none flex h-5 min-w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border bg-sidebar px-1.5 text-[11px] font-medium text-sidebar-foreground/70 tabular-nums select-none',
658
712
  'transition-colors duration-100 ease-out group-data-[collapsible=icon]:hidden',
659
713
  // Follows its own row: brighter under the pointer, full strength when
660
714
  // the item is the active one.
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
7
7
  <textarea
8
8
  data-slot="textarea"
9
9
  className={cn(
10
- 'flex field-sizing-content min-h-16 w-full rounded-md border border-border bg-card px-2.5 py-1.5 text-base font-normal leading-normal text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none placeholder:text-muted-foreground focus:border-blue-500 focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground aria-invalid:border-destructive',
10
+ 'flex field-sizing-content min-h-16 w-full rounded-md border border-border shadow-control bg-card px-2.5 py-1.5 text-base font-normal leading-normal text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none placeholder:text-muted-foreground focus:border-blue-500 focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground aria-invalid:border-destructive',
11
11
  className,
12
12
  )}
13
13
  {...props}