@spree/dashboard-ui 0.1.0

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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/package.json +86 -0
  4. package/src/hooks/use-copy-to-clipboard.ts +44 -0
  5. package/src/hooks/use-mobile.ts +19 -0
  6. package/src/hooks/use-scrolled.ts +30 -0
  7. package/src/index.ts +99 -0
  8. package/src/lib/date-locale.ts +29 -0
  9. package/src/lib/utils.ts +6 -0
  10. package/src/lib/validation-messages.ts +19 -0
  11. package/src/spree/address-block.tsx +74 -0
  12. package/src/spree/back-button.tsx +48 -0
  13. package/src/spree/bulk-dialog.tsx +71 -0
  14. package/src/spree/bulk-price-table.tsx +254 -0
  15. package/src/spree/calculator-summary.tsx +120 -0
  16. package/src/spree/color-picker.tsx +202 -0
  17. package/src/spree/confirm-dialog.tsx +82 -0
  18. package/src/spree/copy-to-clipboard-button.tsx +90 -0
  19. package/src/spree/country-flag.tsx +30 -0
  20. package/src/spree/data-grid/cells.tsx +415 -0
  21. package/src/spree/data-grid/context.ts +59 -0
  22. package/src/spree/data-grid/data-grid.tsx +214 -0
  23. package/src/spree/data-grid/fill-handle.tsx +153 -0
  24. package/src/spree/data-grid/helpers.ts +18 -0
  25. package/src/spree/data-grid/index.ts +5 -0
  26. package/src/spree/data-grid/types.ts +36 -0
  27. package/src/spree/data-grid/use-data-grid-keyboard.ts +254 -0
  28. package/src/spree/data-grid/use-stable-cell-registration.ts +45 -0
  29. package/src/spree/drag-handle.tsx +51 -0
  30. package/src/spree/form-actions.tsx +140 -0
  31. package/src/spree/json-preview-drawer.tsx +254 -0
  32. package/src/spree/json-value-view.tsx +149 -0
  33. package/src/spree/language-menu-items.tsx +54 -0
  34. package/src/spree/metadata/metadata-card.tsx +62 -0
  35. package/src/spree/relative-time.tsx +49 -0
  36. package/src/spree/resource-combobox.tsx +108 -0
  37. package/src/spree/resource-layout.tsx +31 -0
  38. package/src/spree/resource-multi-autocomplete.tsx +180 -0
  39. package/src/spree/resource-name-cell.tsx +44 -0
  40. package/src/spree/route-error-boundary.tsx +102 -0
  41. package/src/spree/row-actions.tsx +85 -0
  42. package/src/spree/row-click-bridge.tsx +17 -0
  43. package/src/spree/secret-input.tsx +137 -0
  44. package/src/spree/storefront-visible-switch.tsx +58 -0
  45. package/src/spree/tag-list.tsx +26 -0
  46. package/src/spree/theme-provider.tsx +83 -0
  47. package/src/spree/theme-toggle.tsx +35 -0
  48. package/src/styles.css +338 -0
  49. package/src/ui/attachment.tsx +187 -0
  50. package/src/ui/avatar.tsx +94 -0
  51. package/src/ui/badge.tsx +161 -0
  52. package/src/ui/breadcrumb.tsx +109 -0
  53. package/src/ui/button.tsx +60 -0
  54. package/src/ui/calendar.tsx +66 -0
  55. package/src/ui/card.tsx +72 -0
  56. package/src/ui/chart.tsx +94 -0
  57. package/src/ui/checkbox.tsx +50 -0
  58. package/src/ui/collapsible.tsx +18 -0
  59. package/src/ui/combobox.tsx +270 -0
  60. package/src/ui/command.tsx +172 -0
  61. package/src/ui/data-table.tsx +60 -0
  62. package/src/ui/date-picker.tsx +253 -0
  63. package/src/ui/date-range-picker.tsx +150 -0
  64. package/src/ui/dialog.tsx +214 -0
  65. package/src/ui/dropdown-menu.tsx +345 -0
  66. package/src/ui/empty.tsx +94 -0
  67. package/src/ui/field.tsx +221 -0
  68. package/src/ui/input-group.tsx +141 -0
  69. package/src/ui/input.tsx +19 -0
  70. package/src/ui/label.tsx +29 -0
  71. package/src/ui/pagination.tsx +123 -0
  72. package/src/ui/popover.tsx +76 -0
  73. package/src/ui/progress.tsx +73 -0
  74. package/src/ui/radio-group.tsx +42 -0
  75. package/src/ui/rich-text-editor.tsx +263 -0
  76. package/src/ui/select.tsx +213 -0
  77. package/src/ui/separator.tsx +31 -0
  78. package/src/ui/sheet.tsx +208 -0
  79. package/src/ui/sidebar.tsx +679 -0
  80. package/src/ui/skeleton.tsx +13 -0
  81. package/src/ui/slot.tsx +35 -0
  82. package/src/ui/sonner.tsx +47 -0
  83. package/src/ui/switch.tsx +39 -0
  84. package/src/ui/textarea.tsx +18 -0
  85. package/src/ui/tooltip.tsx +100 -0
@@ -0,0 +1,137 @@
1
+ import { EyeIcon, EyeOffIcon } from 'lucide-react'
2
+ import { useState } from 'react'
3
+ import { useTranslation } from 'react-i18next'
4
+ import { Button } from '../ui/button'
5
+ import { Field, FieldLabel } from '../ui/field'
6
+ import { Input } from '../ui/input'
7
+
8
+ // Server masks stored secrets as `••••…` on read so admins can see "yes,
9
+ // something is stored" without exposing the actual value. Detecting that
10
+ // prefix tells us whether the field is round-tripping the existing secret
11
+ // (the backend has a guard that ignores writes still carrying the mask).
12
+ const PREFERENCE_MASK_TOKEN = '••••'
13
+ function isMaskedSecret(value: unknown): value is string {
14
+ return typeof value === 'string' && value.startsWith(PREFERENCE_MASK_TOKEN)
15
+ }
16
+
17
+ interface SecretInputProps {
18
+ id: string
19
+ label: string
20
+ value: unknown
21
+ onChange: (value: unknown) => void
22
+ /**
23
+ * When true, a value that arrives carrying the server's mask token
24
+ * (`••••3K9z`) is treated as "already stored" — show the masked badge
25
+ * with a Replace button instead of an editable input. Cancel restores
26
+ * the original masked value so the backend's round-trip guard keeps
27
+ * the existing secret.
28
+ */
29
+ redactWhenMasked?: boolean
30
+ /** Replaces the default "Stored on the server. Click Replace to rotate." caption. */
31
+ helpText?: string
32
+ /** Placeholder for the editable input — only shown when not redacted. */
33
+ placeholder?: string
34
+ }
35
+
36
+ /**
37
+ * Stripe-style credential field. When the value comes back from the API
38
+ * masked (`••••3K9z`), display it as a read-only badge with a "Replace"
39
+ * button. Replace switches to a password input pre-filled empty so the
40
+ * admin opts in to rotation; Cancel reverts to the masked badge so the
41
+ * next save preserves the existing secret via the backend's masked
42
+ * round-trip guard.
43
+ *
44
+ * Domain-agnostic — used by `<PreferencesForm>` for `:password`-typed
45
+ * preferences, but also suitable for any other secret that round-trips
46
+ * through the API (webhook signing keys, OAuth client secrets, etc).
47
+ */
48
+ export function SecretInput({
49
+ id,
50
+ label,
51
+ value,
52
+ onChange,
53
+ redactWhenMasked = false,
54
+ helpText,
55
+ placeholder,
56
+ }: SecretInputProps) {
57
+ const { t } = useTranslation()
58
+ const storedMask = isMaskedSecret(value) ? value : null
59
+ // Captured at click time so Cancel can restore the original mask even
60
+ // after `onChange('')` has cleared the parent value.
61
+ const [pendingMask, setPendingMask] = useState<string | null>(null)
62
+ const [revealed, setRevealed] = useState(false)
63
+ const replacing = pendingMask !== null
64
+
65
+ if (redactWhenMasked && storedMask && !replacing) {
66
+ return (
67
+ <Field>
68
+ <FieldLabel htmlFor={id}>{label}</FieldLabel>
69
+ <div className="flex items-center gap-2">
70
+ <div className="flex min-h-8 w-full min-w-0 items-center rounded-lg border border-border bg-muted/40 px-2.5 py-1.5 shadow-xs">
71
+ <span className="font-mono text-sm tabular-nums text-muted-foreground" id={id}>
72
+ {storedMask}
73
+ </span>
74
+ </div>
75
+ <Button
76
+ type="button"
77
+ size="sm"
78
+ variant="outline"
79
+ onClick={() => {
80
+ setPendingMask(storedMask)
81
+ onChange('')
82
+ }}
83
+ >
84
+ {t('admin.components.secret_input.replace')}
85
+ </Button>
86
+ </div>
87
+ <span className="text-xs text-muted-foreground">
88
+ {helpText ?? t('admin.components.secret_input.stored_help')}
89
+ </span>
90
+ </Field>
91
+ )
92
+ }
93
+
94
+ return (
95
+ <Field>
96
+ <FieldLabel htmlFor={id}>{label}</FieldLabel>
97
+ <div className="flex items-center gap-2">
98
+ <div className="relative flex-1">
99
+ <Input
100
+ id={id}
101
+ type={revealed ? 'text' : 'password'}
102
+ autoComplete="new-password"
103
+ placeholder={
104
+ replacing ? t('admin.components.secret_input.new_value_placeholder') : placeholder
105
+ }
106
+ value={(value as string) ?? ''}
107
+ onChange={(e) => onChange(e.target.value)}
108
+ className="pr-9"
109
+ />
110
+ <button
111
+ type="button"
112
+ aria-label={revealed ? t('admin.a11y.hide_value') : t('admin.a11y.show_value')}
113
+ aria-pressed={revealed}
114
+ onClick={() => setRevealed((v) => !v)}
115
+ className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground"
116
+ >
117
+ {revealed ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
118
+ </button>
119
+ </div>
120
+ {replacing && (
121
+ <Button
122
+ type="button"
123
+ size="sm"
124
+ variant="ghost"
125
+ onClick={() => {
126
+ onChange(pendingMask)
127
+ setPendingMask(null)
128
+ setRevealed(false)
129
+ }}
130
+ >
131
+ {t('admin.actions.cancel')}
132
+ </Button>
133
+ )}
134
+ </div>
135
+ </Field>
136
+ )
137
+ }
@@ -0,0 +1,58 @@
1
+ import { type Control, Controller, type FieldPath, type FieldValues } from 'react-hook-form'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { Field, FieldLabel } from '../ui/field'
4
+ import { Switch } from '../ui/switch'
5
+
6
+ interface StorefrontVisibleSwitchProps<TFieldValues extends FieldValues> {
7
+ control: Control<TFieldValues>
8
+ name: FieldPath<TFieldValues>
9
+ /** Defaults to "Visible on storefront". */
10
+ label?: string
11
+ /** Helper text under the row. Defaults to the on/off explanation. */
12
+ description?: string
13
+ /** Switch id — defaults to the field name. */
14
+ id?: string
15
+ }
16
+
17
+ /**
18
+ * Canonical 5.5+ "Visible on storefront" control. Wraps a `Switch` with
19
+ * a labelled row mirroring the Active / Auto-capture rows on the same
20
+ * forms. Used by every resource that exposes `storefront_visible` on
21
+ * the API (PaymentMethod today; ShippingMethod/DeliveryMethod when
22
+ * those admin pages land — see `docs/plans/5.5-6.0-display-on-to-boolean.md`).
23
+ *
24
+ * For column-cell rendering use `<ActiveBadge active={…} activeLabel="Visible"
25
+ * inactiveLabel="Admin only" />` so the visibility column matches the
26
+ * Active column's visual treatment.
27
+ */
28
+ export function StorefrontVisibleSwitch<TFieldValues extends FieldValues>({
29
+ control,
30
+ name,
31
+ label,
32
+ description,
33
+ id,
34
+ }: StorefrontVisibleSwitchProps<TFieldValues>) {
35
+ const { t } = useTranslation()
36
+ const resolvedLabel = label ?? t('admin.fields.storefront_visible.label')
37
+ const resolvedDescription = description ?? t('admin.fields.storefront_visible.help')
38
+ const fieldId = id ?? name
39
+ return (
40
+ <Field>
41
+ <div className="flex items-start justify-between gap-4">
42
+ <div className="flex flex-col">
43
+ <FieldLabel htmlFor={fieldId} className="cursor-pointer">
44
+ {resolvedLabel}
45
+ </FieldLabel>
46
+ <span className="text-xs text-muted-foreground">{resolvedDescription}</span>
47
+ </div>
48
+ <Controller
49
+ name={name}
50
+ control={control}
51
+ render={({ field }) => (
52
+ <Switch id={fieldId} checked={!!field.value} onCheckedChange={field.onChange} />
53
+ )}
54
+ />
55
+ </div>
56
+ </Field>
57
+ )
58
+ }
@@ -0,0 +1,26 @@
1
+ import { Badge } from '../ui/badge'
2
+
3
+ interface TagListProps {
4
+ tags: string[] | null | undefined
5
+ /** Rendered when there are no tags. Defaults to em-dash to match other empty cells. */
6
+ empty?: string
7
+ }
8
+
9
+ /**
10
+ * Renders a list of tag names as small secondary badges, wrapping when the
11
+ * column is narrow. Used as the default cell renderer for the `tags` column
12
+ * on resource tables that expose taggable resources (products, customers,
13
+ * orders).
14
+ */
15
+ export function TagList({ tags, empty = '—' }: TagListProps) {
16
+ if (!tags || tags.length === 0) return <>{empty}</>
17
+ return (
18
+ <div className="flex flex-wrap gap-1">
19
+ {tags.map((tag) => (
20
+ <Badge key={tag} variant="secondary">
21
+ {tag}
22
+ </Badge>
23
+ ))}
24
+ </div>
25
+ )
26
+ }
@@ -0,0 +1,83 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useState,
9
+ } from 'react'
10
+
11
+ type ThemeMode = 'light' | 'dark' | 'system'
12
+ type ResolvedTheme = 'light' | 'dark'
13
+
14
+ interface ThemeContextValue {
15
+ mode: ThemeMode
16
+ resolved: ResolvedTheme
17
+ setMode: (mode: ThemeMode) => void
18
+ }
19
+
20
+ const STORAGE_KEY = 'spree-admin-theme'
21
+ const ThemeContext = createContext<ThemeContextValue | null>(null)
22
+
23
+ function readStoredMode(): ThemeMode {
24
+ if (typeof localStorage === 'undefined') return 'system'
25
+ const value = localStorage.getItem(STORAGE_KEY)
26
+ return value === 'light' || value === 'dark' || value === 'system' ? value : 'system'
27
+ }
28
+
29
+ function systemPrefersDark(): boolean {
30
+ return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
31
+ }
32
+
33
+ function resolveTheme(mode: ThemeMode): ResolvedTheme {
34
+ if (mode === 'system') return systemPrefersDark() ? 'dark' : 'light'
35
+ return mode
36
+ }
37
+
38
+ function applyTheme(theme: ResolvedTheme) {
39
+ const root = document.documentElement
40
+ if (theme === 'dark') root.classList.add('dark')
41
+ else root.classList.remove('dark')
42
+ root.style.colorScheme = theme
43
+ }
44
+
45
+ export function ThemeProvider({ children }: { children: ReactNode }) {
46
+ const [mode, setModeState] = useState<ThemeMode>(() => readStoredMode())
47
+ const [resolved, setResolved] = useState<ResolvedTheme>(() => resolveTheme(readStoredMode()))
48
+
49
+ useEffect(() => {
50
+ const next = resolveTheme(mode)
51
+ setResolved(next)
52
+ applyTheme(next)
53
+ }, [mode])
54
+
55
+ useEffect(() => {
56
+ if (mode !== 'system') return
57
+ const mql = window.matchMedia('(prefers-color-scheme: dark)')
58
+ const onChange = () => {
59
+ const next: ResolvedTheme = mql.matches ? 'dark' : 'light'
60
+ setResolved(next)
61
+ applyTheme(next)
62
+ }
63
+ mql.addEventListener('change', onChange)
64
+ return () => mql.removeEventListener('change', onChange)
65
+ }, [mode])
66
+
67
+ const setMode = useCallback((next: ThemeMode) => {
68
+ if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, next)
69
+ setModeState(next)
70
+ }, [])
71
+
72
+ const value = useMemo<ThemeContextValue>(
73
+ () => ({ mode, resolved, setMode }),
74
+ [mode, resolved, setMode],
75
+ )
76
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
77
+ }
78
+
79
+ export function useTheme(): ThemeContextValue {
80
+ const ctx = useContext(ThemeContext)
81
+ if (!ctx) throw new Error('useTheme must be used within <ThemeProvider>')
82
+ return ctx
83
+ }
@@ -0,0 +1,35 @@
1
+ import { CheckIcon, MonitorIcon, MoonIcon, SunIcon } from 'lucide-react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '../ui/dropdown-menu'
4
+ import { useTheme } from './theme-provider'
5
+
6
+ /**
7
+ * Theme picker rendered inside the user dropdown menu. Three-way (Light / Dark
8
+ * / System) — System follows `prefers-color-scheme`.
9
+ */
10
+ export function ThemeMenuItems() {
11
+ const { mode, setMode } = useTheme()
12
+ const { t } = useTranslation()
13
+
14
+ const items = [
15
+ { value: 'light', label: t('admin.components.theme_toggle.light'), icon: SunIcon },
16
+ { value: 'dark', label: t('admin.components.theme_toggle.dark'), icon: MoonIcon },
17
+ { value: 'system', label: t('admin.components.theme_toggle.system'), icon: MonitorIcon },
18
+ ] as const
19
+
20
+ return (
21
+ <>
22
+ <DropdownMenuLabel className="text-xs">
23
+ {t('admin.components.theme_toggle.label')}
24
+ </DropdownMenuLabel>
25
+ {items.map(({ value, label, icon: Icon }) => (
26
+ <DropdownMenuItem key={value} closeOnClick={false} onClick={() => setMode(value)}>
27
+ <Icon className="size-4" />
28
+ {label}
29
+ {mode === value && <CheckIcon className="ml-auto size-3.5" />}
30
+ </DropdownMenuItem>
31
+ ))}
32
+ <DropdownMenuSeparator />
33
+ </>
34
+ )
35
+ }
package/src/styles.css ADDED
@@ -0,0 +1,338 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+ @import "shadcn/tailwind.css";
4
+ @import "@fontsource-variable/geist";
5
+ @import "@fontsource-variable/geist-mono";
6
+
7
+ /* Tailwind v4 only scans the consuming project's own source by default.
8
+ * `@source` lets us point it at additional roots — the consumer's own
9
+ * sources are auto-detected; this directive teaches Tailwind to also scan
10
+ * the dashboard-ui source files that live alongside this CSS.
11
+ *
12
+ * Path is relative to this CSS file (`packages/dashboard-ui/src/styles.css`)
13
+ * so it resolves correctly whether dashboard-ui is consumed via the workspace
14
+ * symlink (`node_modules/@spree/dashboard-ui` → `packages/dashboard-ui`) or
15
+ * eventually from a published npm package (where the same relative layout
16
+ * holds inside the tarball).
17
+ */
18
+ @source "./ui/**/*.{ts,tsx}";
19
+ @source "./spree/**/*.{ts,tsx}";
20
+ /* Country flag icons — `<CountryFlag iso="US" />` resolves to `.fi.fi-us`,
21
+ which lazy-loads `flags/4x3/us.svg` via background-image. Only flags
22
+ actually rendered hit the network. */
23
+ @import "flag-icons/css/flag-icons.min.css";
24
+
25
+ @custom-variant dark (&:is(.dark *));
26
+
27
+ /* ============================================================================
28
+ Spree Admin Theme
29
+ Adapted from spree/admin Tailwind design system to match look & feel.
30
+ ============================================================================ */
31
+
32
+ :root {
33
+ /* Vercel/Geist-style neutral palette — pure greyscale. */
34
+ --background: oklch(0.985 0 0); /* #fafafa — gray-50 */
35
+ --foreground: oklch(0.145 0 0); /* near-black */
36
+ --card: oklch(1 0 0);
37
+ --card-foreground: oklch(0.145 0 0);
38
+ --popover: oklch(1 0 0);
39
+ --popover-foreground: oklch(0.145 0 0);
40
+ --primary: oklch(0 0 0); /* pure black for primary CTAs */
41
+ --primary-foreground: oklch(1 0 0);
42
+ --secondary: oklch(0.97 0 0); /* #f5f5f5 — gray-100 */
43
+ --secondary-foreground: oklch(0.205 0 0);
44
+ --muted: oklch(0.985 0 0); /* #fafafa — gray-50 */
45
+ --muted-foreground: oklch(0.4685 0 0); /* #737373 — Vercel #666 */
46
+ --accent: oklch(0.97 0 0); /* #f5f5f5 */
47
+ --accent-foreground: oklch(0.205 0 0);
48
+ --destructive: oklch(0.58 0.22 27); /* red-600 */
49
+ --destructive-foreground: oklch(1 0 0); /* #ffffff */
50
+ --border: oklch(0.928 0 0); /* #eaeaea — Vercel border */
51
+ --input: oklch(0.928 0 0);
52
+ --ring: oklch(0.546 0.245 262.881); /* blue-500 focus ring (UNCHANGED) */
53
+ --chart-1: oklch(0.809 0.105 251.813);
54
+ --chart-2: oklch(0.623 0.214 259.815);
55
+ --chart-3: oklch(0.546 0.245 262.881);
56
+ --chart-4: oklch(0.488 0.243 264.376);
57
+ --chart-5: oklch(0.424 0.199 265.638);
58
+ --radius: 0.75rem;
59
+
60
+ /* Sidebar — slightly muted off-white surface. */
61
+ --sidebar: oklch(0.985 0 0); /* #fafafa */
62
+ --sidebar-foreground: oklch(0.145 0 0);
63
+ --sidebar-primary: oklch(0 0 0);
64
+ --sidebar-primary-foreground: oklch(1 0 0);
65
+ --sidebar-accent: oklch(0.945 0 0); /* slightly darker than sidebar bg for hover */
66
+ --sidebar-accent-foreground: oklch(0.145 0 0);
67
+ --sidebar-border: oklch(0.928 0 0);
68
+ --sidebar-ring: oklch(0.546 0.245 262.881); /* blue-500 focus ring (UNCHANGED) */
69
+ }
70
+
71
+ .dark {
72
+ /* Vercel-style "true black" base, with surfaces stepping up in lightness. */
73
+ --background: oklch(0 0 0); /* #000000 */
74
+ --foreground: oklch(0.95 0 0); /* #ededed */
75
+ --card: oklch(0.145 0 0); /* #0a0a0a — surface */
76
+ --card-foreground: oklch(0.95 0 0);
77
+ --popover: oklch(0.145 0 0);
78
+ --popover-foreground: oklch(0.95 0 0);
79
+ --primary: oklch(0.95 0 0); /* light-on-dark primary */
80
+ --primary-foreground: oklch(0 0 0);
81
+ --secondary: oklch(0.205 0 0); /* #1a1a1a */
82
+ --secondary-foreground: oklch(0.95 0 0);
83
+ --muted: oklch(0.18 0 0); /* #161616 */
84
+ --muted-foreground: oklch(0.62 0 0); /* #888 */
85
+ --accent: oklch(0.27 0 0); /* hover surface */
86
+ --accent-foreground: oklch(0.95 0 0);
87
+ --destructive: oklch(0.704 0.191 22.216);
88
+ --border: oklch(1 0 0 / 12%);
89
+ --input: oklch(1 0 0 / 15%);
90
+ --ring: oklch(0.552 0.016 285); /* same blue ring as light, for focus parity */
91
+ --chart-1: oklch(0.809 0.105 251.813);
92
+ --chart-2: oklch(0.623 0.214 259.815);
93
+ --chart-3: oklch(0.546 0.245 262.881);
94
+ --chart-4: oklch(0.488 0.243 264.376);
95
+ --chart-5: oklch(0.424 0.199 265.638);
96
+ --sidebar: oklch(0.145 0 0); /* #0a0a0a — slightly elevated from background */
97
+ --sidebar-foreground: oklch(0.95 0 0);
98
+ --sidebar-primary: oklch(0.95 0 0);
99
+ --sidebar-primary-foreground: oklch(0 0 0);
100
+ --sidebar-accent: oklch(0.27 0 0);
101
+ --sidebar-accent-foreground: oklch(0.95 0 0);
102
+ --sidebar-border: oklch(1 0 0 / 10%);
103
+ --sidebar-ring: oklch(0.708 0 0);
104
+ }
105
+
106
+ @theme inline {
107
+ --font-sans: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
108
+ --font-mono: "Geist Mono Variable", ui-monospace, monospace;
109
+
110
+ /* Typography scale — matches Spree admin (smaller than Tailwind defaults) */
111
+ --text-xs: 0.75rem;
112
+ --text-sm: 0.8125rem;
113
+ --text-base: 0.875rem;
114
+ --text-lg: 1rem;
115
+ --text-xl: 1.25rem;
116
+ --text-2xl: 1.5rem;
117
+ --text-3xl: 1.75rem;
118
+ --text-4xl: 2rem;
119
+
120
+ /* Shadows — subtle like Rails admin */
121
+ --shadow-xs: rgba(43, 34, 51, 0.05) 0px 1px 1px;
122
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
123
+ --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;
124
+
125
+ /* Sidebar + header tokens */
126
+ --spacing-sidebar-width: 220px;
127
+ --spacing-header-height: 58px;
128
+
129
+ /* Color mappings */
130
+ --color-sidebar-ring: var(--sidebar-ring);
131
+ --color-sidebar-border: var(--sidebar-border);
132
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
133
+ --color-sidebar-accent: var(--sidebar-accent);
134
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
135
+ --color-sidebar-primary: var(--sidebar-primary);
136
+ --color-sidebar-foreground: var(--sidebar-foreground);
137
+ --color-sidebar: var(--sidebar);
138
+ --color-chart-5: var(--chart-5);
139
+ --color-chart-4: var(--chart-4);
140
+ --color-chart-3: var(--chart-3);
141
+ --color-chart-2: var(--chart-2);
142
+ --color-chart-1: var(--chart-1);
143
+ --color-ring: var(--ring);
144
+ --color-input: var(--input);
145
+ --color-border: var(--border);
146
+ --color-destructive: var(--destructive);
147
+ --color-destructive-foreground: var(--destructive-foreground);
148
+ --color-accent-foreground: var(--accent-foreground);
149
+ --color-accent: var(--accent);
150
+ --color-muted-foreground: var(--muted-foreground);
151
+ --color-muted: var(--muted);
152
+ --color-secondary-foreground: var(--secondary-foreground);
153
+ --color-secondary: var(--secondary);
154
+ --color-primary-foreground: var(--primary-foreground);
155
+ --color-primary: var(--primary);
156
+ --color-popover-foreground: var(--popover-foreground);
157
+ --color-popover: var(--popover);
158
+ --color-card-foreground: var(--card-foreground);
159
+ --color-card: var(--card);
160
+ --color-foreground: var(--foreground);
161
+ --color-background: var(--background);
162
+
163
+ /* Spree status colors — for badges & alerts */
164
+ --color-success: var(--color-green-500);
165
+ --color-warning: var(--color-yellow-500);
166
+ --color-info: var(--color-blue-500);
167
+ --color-danger: var(--color-red-500);
168
+ }
169
+
170
+ @utility focus-ring {
171
+ /* Unified focus treatment — matches the form-input focus (soft blue glow,
172
+ no hard outline). Apply to buttons, links, menu triggers, and any other
173
+ interactive control so every focusable element looks the same on
174
+ keyboard focus. Inputs/textarea/select keep their inline equivalent. */
175
+ outline: none;
176
+ &:focus-visible {
177
+ border-color: var(--ring);
178
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 15%, transparent);
179
+ }
180
+ }
181
+
182
+ @utility themed-scrollbar {
183
+ /* Theme-aware thin scrollbar utility. Track is transparent, thumb uses
184
+ CSS vars defined in :root and .dark, so it adapts to the active theme.
185
+ Apply explicitly to any element you want styled (e.g. the JSON preview
186
+ drawer); floating surfaces like Sheet/Dialog/Dropdown pick it up via
187
+ the [data-slot=...] selectors below. */
188
+ scrollbar-color: var(--scrollbar-thumb) transparent;
189
+ scrollbar-width: thin;
190
+
191
+ &::-webkit-scrollbar {
192
+ width: 10px;
193
+ height: 10px;
194
+ }
195
+ &::-webkit-scrollbar-track {
196
+ background: transparent;
197
+ }
198
+ &::-webkit-scrollbar-thumb {
199
+ background-color: var(--scrollbar-thumb);
200
+ border: 2px solid transparent;
201
+ border-radius: 9999px;
202
+ background-clip: padding-box;
203
+ }
204
+ &::-webkit-scrollbar-thumb:hover {
205
+ background-color: var(--scrollbar-thumb-hover);
206
+ }
207
+ &::-webkit-scrollbar-corner {
208
+ background: transparent;
209
+ }
210
+ }
211
+
212
+ @layer base {
213
+ :root {
214
+ --scrollbar-thumb: oklch(0.7 0 0); /* gray-400-ish */
215
+ --scrollbar-thumb-hover: oklch(0.55 0 0);
216
+ }
217
+ .dark {
218
+ --scrollbar-thumb: oklch(0.35 0 0); /* zinc-700-ish */
219
+ --scrollbar-thumb-hover: oklch(0.45 0 0);
220
+ }
221
+
222
+ /* Themed scrollbar inside floating surfaces only — page scrollbar stays
223
+ browser default. Add the .themed-scrollbar utility class to opt other
224
+ elements in. */
225
+ [data-slot='dialog-content'],
226
+ [data-slot='sheet-content'],
227
+ [data-slot='dropdown-menu-content'],
228
+ [data-slot='dropdown-menu-sub-content'],
229
+ [data-slot='popover-content'] {
230
+ & *,
231
+ & *::before,
232
+ & *::after {
233
+ scrollbar-color: var(--scrollbar-thumb) transparent;
234
+ scrollbar-width: thin;
235
+ }
236
+ & *::-webkit-scrollbar {
237
+ width: 10px;
238
+ height: 10px;
239
+ }
240
+ & *::-webkit-scrollbar-track {
241
+ background: transparent;
242
+ }
243
+ & *::-webkit-scrollbar-thumb {
244
+ background-color: var(--scrollbar-thumb);
245
+ border: 2px solid transparent;
246
+ border-radius: 9999px;
247
+ background-clip: padding-box;
248
+ }
249
+ & *::-webkit-scrollbar-thumb:hover {
250
+ background-color: var(--scrollbar-thumb-hover);
251
+ }
252
+ & *::-webkit-scrollbar-corner {
253
+ background: transparent;
254
+ }
255
+ }
256
+
257
+ * {
258
+ @apply border-border outline-ring/50;
259
+ }
260
+
261
+ /* Floating-surface popup containers (menu/popover/select/dialog) receive
262
+ focus when they open. Base UI marks that focus as `:focus-visible`, so the
263
+ universal `outline-ring/50` above renders as a stray blue ring around the
264
+ whole surface. These are containers — focus belongs to the items inside —
265
+ so suppress the container outline entirely. */
266
+ [data-slot='dropdown-menu-content'],
267
+ [data-slot='dropdown-menu-sub-content'],
268
+ [data-slot='popover-content'],
269
+ [data-slot='select-content'] {
270
+ outline: none;
271
+ }
272
+
273
+ html {
274
+ @apply font-sans antialiased;
275
+ text-rendering: optimizeLegibility;
276
+ overflow-y: scroll;
277
+ /* Clip horizontal overflow at the document root so wide tables on mobile
278
+ * can't widen the page. Must live on <html>, not <body>: `overflow-x:
279
+ * hidden` on body makes `overflow-y` compute to `auto`, turning body into
280
+ * a scroll container while the document actually scrolls on html — which
281
+ * breaks `position: sticky` for PageHeader and other descendants. */
282
+ overflow-x: clip;
283
+ max-width: 100vw;
284
+ }
285
+
286
+ body {
287
+ @apply bg-background text-foreground text-base;
288
+ line-height: 1.5;
289
+ }
290
+
291
+ /* Headings — semibold (600) like Rails admin */
292
+ h1,
293
+ h2,
294
+ h3,
295
+ h4,
296
+ h5,
297
+ h6 {
298
+ @apply font-semibold leading-tight;
299
+ }
300
+
301
+ h1 {
302
+ @apply text-4xl;
303
+ }
304
+ h2 {
305
+ @apply text-3xl;
306
+ }
307
+ h3 {
308
+ @apply text-2xl;
309
+ }
310
+ h4 {
311
+ @apply text-xl;
312
+ }
313
+ h5 {
314
+ @apply text-lg;
315
+ }
316
+ h6 {
317
+ @apply text-base;
318
+ }
319
+ }
320
+
321
+ @layer components {
322
+ /* Checkerboard for the color picker's "no color selected" swatch.
323
+ Two-tone diagonal pattern using `--border` + `--background` so it's
324
+ visible in both light and dark themes. */
325
+ .color-picker-empty-swatch {
326
+ background-color: var(--background);
327
+ background-image: linear-gradient(45deg, var(--border) 25%, transparent 25%),
328
+ linear-gradient(-45deg, var(--border) 25%, transparent 25%),
329
+ linear-gradient(45deg, transparent 75%, var(--border) 75%),
330
+ linear-gradient(-45deg, transparent 75%, var(--border) 75%);
331
+ background-size: 10px 10px;
332
+ background-position:
333
+ 0 0,
334
+ 0 5px,
335
+ 5px -5px,
336
+ -5px 0;
337
+ }
338
+ }