@spree/dashboard-ui 0.13.0 → 1.0.0-beta.1

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 (96) hide show
  1. package/README.md +1 -1
  2. package/package.json +15 -9
  3. package/src/hooks/use-prefers-reduced-motion.ts +26 -0
  4. package/src/hooks/use-scrolled.ts +34 -14
  5. package/src/index.ts +39 -1
  6. package/src/lib/date-range-presets.ts +79 -0
  7. package/src/lib/same-rich-text.ts +145 -0
  8. package/src/spree/address-block.tsx +5 -4
  9. package/src/spree/address-book-row.tsx +96 -0
  10. package/src/spree/address-map.tsx +62 -0
  11. package/src/spree/back-button.tsx +14 -9
  12. package/src/spree/bulk-dialog.tsx +2 -2
  13. package/src/spree/bulk-price-table.tsx +145 -14
  14. package/src/spree/bulk-variants-table.tsx +434 -0
  15. package/src/spree/calculator-summary.tsx +5 -1
  16. package/src/spree/claim-resolve-dialog.tsx +219 -0
  17. package/src/spree/confirm-dialog.tsx +1 -1
  18. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  19. package/src/spree/data-grid/cells.tsx +199 -25
  20. package/src/spree/data-grid/context.ts +6 -0
  21. package/src/spree/data-grid/data-grid.tsx +149 -7
  22. package/src/spree/data-grid/helpers.ts +6 -2
  23. package/src/spree/data-grid/index.ts +9 -1
  24. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  25. package/src/spree/delivery-form-dialog.tsx +166 -0
  26. package/src/spree/drag-handle.tsx +2 -2
  27. package/src/spree/editable-note-card.tsx +103 -0
  28. package/src/spree/external-link.tsx +56 -0
  29. package/src/spree/file-type-icon.tsx +68 -0
  30. package/src/spree/form-actions.tsx +2 -3
  31. package/src/spree/form-section.tsx +46 -0
  32. package/src/spree/fulfill-items-form.tsx +282 -0
  33. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  34. package/src/spree/fulfillment-item-list.tsx +61 -0
  35. package/src/spree/fulfillment-panel.tsx +53 -0
  36. package/src/spree/icons.ts +167 -0
  37. package/src/spree/json-preview-drawer.tsx +4 -4
  38. package/src/spree/label-upload-dialog.tsx +195 -0
  39. package/src/spree/language-menu-items.tsx +37 -30
  40. package/src/spree/metadata/metadata-card.tsx +1 -1
  41. package/src/spree/order-cancel-dialog.tsx +149 -0
  42. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  43. package/src/spree/post-sale-fields.tsx +133 -0
  44. package/src/spree/product-membership-list.tsx +453 -0
  45. package/src/spree/products/status-card.tsx +79 -0
  46. package/src/spree/quantity-tier-editor.tsx +162 -0
  47. package/src/spree/resource-combobox.tsx +12 -1
  48. package/src/spree/resource-name-cell.tsx +21 -4
  49. package/src/spree/return-dialogs.tsx +238 -0
  50. package/src/spree/route-error-boundary.tsx +1 -1
  51. package/src/spree/row-actions.tsx +2 -2
  52. package/src/spree/search-input.tsx +76 -0
  53. package/src/spree/secret-input.tsx +26 -40
  54. package/src/spree/tax-identifiers-card.tsx +418 -0
  55. package/src/spree/theme-toggle.tsx +36 -23
  56. package/src/spree/wizard.tsx +254 -0
  57. package/src/styles.css +750 -75
  58. package/src/ui/accordion.tsx +83 -0
  59. package/src/ui/alert.tsx +79 -0
  60. package/src/ui/attachment.tsx +1 -1
  61. package/src/ui/badge.tsx +198 -56
  62. package/src/ui/breadcrumb.tsx +1 -1
  63. package/src/ui/button.tsx +22 -10
  64. package/src/ui/calendar.tsx +1 -1
  65. package/src/ui/card.tsx +52 -6
  66. package/src/ui/checkbox.tsx +13 -5
  67. package/src/ui/combobox.tsx +125 -6
  68. package/src/ui/command.tsx +149 -23
  69. package/src/ui/context-menu.tsx +99 -0
  70. package/src/ui/data-table.tsx +448 -9
  71. package/src/ui/date-picker.tsx +18 -3
  72. package/src/ui/date-range-picker.tsx +47 -60
  73. package/src/ui/dialog.tsx +6 -6
  74. package/src/ui/dropdown-menu.tsx +18 -11
  75. package/src/ui/field.tsx +10 -2
  76. package/src/ui/input-group.tsx +1 -1
  77. package/src/ui/input.tsx +1 -1
  78. package/src/ui/map.tsx +2110 -0
  79. package/src/ui/media-preview.tsx +105 -0
  80. package/src/ui/pagination.tsx +19 -11
  81. package/src/ui/popover.tsx +4 -2
  82. package/src/ui/progress.tsx +5 -2
  83. package/src/ui/radio-group.tsx +4 -4
  84. package/src/ui/rich-text-editor.tsx +214 -88
  85. package/src/ui/scroll-area.tsx +65 -0
  86. package/src/ui/select.tsx +17 -10
  87. package/src/ui/separator.tsx +1 -1
  88. package/src/ui/sheet.tsx +49 -7
  89. package/src/ui/sidebar.tsx +138 -37
  90. package/src/ui/skeleton.tsx +3 -1
  91. package/src/ui/tabs.tsx +84 -0
  92. package/src/ui/textarea.tsx +1 -1
  93. package/src/ui/thumbnail.tsx +80 -0
  94. package/src/ui/toast.tsx +112 -0
  95. package/src/ui/tooltip.tsx +3 -1
  96. package/src/ui/sonner.tsx +0 -47
package/src/styles.css CHANGED
@@ -29,78 +29,387 @@
29
29
  Adapted from spree/admin Tailwind design system to match look & feel.
30
30
  ============================================================================ */
31
31
 
32
+ /* Brand palette.
33
+ *
34
+ * The two themes take different neutral families, for the reason each one's
35
+ * large areas need.
36
+ *
37
+ * Light mode is Tailwind's `stone` ramp — the same lightness ladder as
38
+ * `neutral`, carrying a trace of warm chroma (0.001–0.013) rather than none.
39
+ * Far too little to read as a colour: it stops large white and grey areas
40
+ * looking blue-cold, and leaves the blue accent still the only thing in the
41
+ * palette that reads as interaction.
42
+ *
43
+ * Dark mode is a true zero-chroma grey (Geist's own neutral, and shadcn's
44
+ * default). The warm tint that flatters a white sheet works against a
45
+ * near-black one: at these lightnesses the same chroma stops reading as
46
+ * warmth and starts reading as a brown cast over the whole screen. Keep dark
47
+ * neutrals at chroma 0 — the hue channel there is noise, not decoration.
48
+ *
49
+ * In both themes the lightness decisions below are load-bearing for contrast,
50
+ * and none of them move. Light mode's values are stone's chroma and hue
51
+ * interpolated onto those lightnesses rather than snapped to the nearest ramp
52
+ * step; dark mode's are the same lightnesses with the chroma dropped.
53
+ *
54
+ * Primary CTAs stay near-black rather than adopting the site's blue button:
55
+ * in a dense admin UI the blue has to stay meaningful as a selection and
56
+ * focus signal, which it stops being once every page renders a saturated
57
+ * blue button. */
32
58
  :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);
59
+ /* Light-mode neutrals are Tailwind's **stone** scale — a warm grey. Every
60
+ neutral here is either an exact stone step (labelled with it) or, where a
61
+ step could not meet a contrast requirement, a value on stone's own hue so
62
+ it belongs to the same family. Never mix another warm-neutral ramp (zinc,
63
+ slate, neutral) into THIS block: two families read as a colour cast on
64
+ whichever surface is the odd one out. Dark mode deliberately uses a
65
+ different family — see the note on `.dark`.
66
+
67
+ Tokens that must equal another token point at it with var() rather than
68
+ repeating its value, so the two cannot drift. */
69
+ --brand-blue: oklch(0.5967 0.2212 258); /* #0077ff — site accent */
70
+ --brand-blue-strong: oklch(0.5106 0.1868 257.7); /* #0060ce — site link hover */
71
+ /* Links in body copy. The plain accent measures 4.13:1 on white and 4.21:1
72
+ on the dark card — under 4.5 in BOTH themes — so a link takes its own
73
+ step rather than the accent. */
74
+ --link: oklch(0.5106 0.1868 257.7);
75
+ --link-hover: oklch(0.44 0.16 257.7);
76
+
77
+ --background: oklch(98.5% 0.001 106.423); /* stone-50 — chrome one step below white */
78
+ --foreground: oklch(14.7% 0.004 49.25); /* stone-950 */
79
+ --card: oklch(1 0 0); /* #ffffff — cards and inputs read as raised */
80
+ --card-foreground: var(--foreground);
81
+ --popover: var(--card);
82
+ --popover-foreground: var(--foreground);
83
+ --primary: var(--foreground); /* stone-950 — near-black primary CTAs */
84
+ --primary-foreground: var(--card);
85
+ /* Filled surfaces (table headers, badges, secondary buttons) read as a
86
+ whisper against the white card rather than a distinct band. */
87
+ --secondary: oklch(97% 0.001 106.424); /* stone-100 */
88
+ --secondary-foreground: var(--foreground);
89
+ --muted: var(--background);
90
+ /* Between stone-500 and -600, on stone's hue. Clears 4.5:1 against every
91
+ surface this text lands on — white card 5.19:1, page 4.97:1, secondary and
92
+ accent 4.75:1 — where the stone-500 step itself measures 4.41:1 on
93
+ secondary and fails. Dark mode takes stone-400 for the same reason. */
94
+ --muted-foreground: oklch(0.535 0.0127 58.071);
95
+ /* Hover/highlight for menu items and table rows, and the same stone-100 as
96
+ --secondary: a hover and a filled surface are the same weight of "one step
97
+ off the card", so they are one value rather than two that drift apart.
98
+ stone-200 was tried and reads as a pressed state on a white surface.
99
+
100
+ It is also the lightest step that keeps --muted-foreground legible on it:
101
+ the previous bespoke grey measured 4.47:1, just under the 4.5 this file
102
+ claims everywhere else. The sidebar shares it via --sidebar-accent. */
103
+ --accent: var(--secondary);
104
+ --accent-foreground: var(--foreground);
105
+ /* The *selected* state, and any control that must read as raised while
106
+ sitting on an already-accented surface: the sidebar's active nav item, a
107
+ ghost button hovered inside a hovered table row.
108
+
109
+ --accent cannot do this job. It is the hover/fill surface, so it has to
110
+ stay light enough for --muted-foreground to clear 4.5:1 on it — which
111
+ leaves it at 1.04:1 against the page, invisible as a selection. Two
112
+ different jobs, two tokens. Nothing but near-black text lands on this
113
+ one, so it is free to be darker.
114
+
115
+ Interpolated on stone's hue rather than snapped to a step (like
116
+ --accent-hover and --border-control): stone-200 read heavier than a
117
+ selection needs to, and stone-100 is --accent itself, which would collapse
118
+ the two tokens into one and leave a hovered item darker than a selected
119
+ one. This sits between them. */
120
+ --accent-strong: oklch(94.7% 0.002 56);
121
+ /* Hover, between --accent and --accent-strong so a nav item has somewhere to
122
+ go on hover *and* somewhere further to go when it is the active one.
123
+ Interpolated on stone's hue rather than snapped — deliberate, like
124
+ --border-control.
125
+
126
+ --accent is too light to be a hover here: on the sidebar's ground (the
127
+ page colour, not a card) it measures 1.04:1, which is invisible. */
128
+ --accent-hover: oklch(95.9% 0.002 56);
129
+ /* The active item hovered. It darkens rather than lightening: a selected
130
+ row that goes paler under the pointer reads as being deselected. Stays one
131
+ step under --accent-strong, or the two land on the same value and a
132
+ selected item stops responding to the pointer. */
133
+ --accent-strong-hover: oklch(92.3% 0.003 48.717); /* stone-200 */
48
134
  --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);
135
+ --destructive-foreground: var(--card);
136
+ /* The full-strength rule colour. Nothing styles with it directly — the two
137
+ tokens below are both a share of it, so neither compounds the other's
138
+ reduction.
139
+
140
+ stone-200. This is the floor for a hairline: anything lighter stops
141
+ holding a container's edge on a white sheet, and the row rules of a long
142
+ table start to disappear before the frame around them does. */
143
+ --border-base: oklch(92.3% 0.003 48.717); /* stone-200 */
144
+ /* A structural edge — the content sheet, a dialog, a dropdown, a popover, a
145
+ table's frame. These are surfaces that float above the page or divide it,
146
+ and their edge is what says where one ends and the next begins, so it
147
+ carries the base at full strength. Cards take --border-card instead.
148
+
149
+ Carries its strength in the token, so call sites never restate it — an
150
+ `/80` utility would compound on top of this. */
151
+ --border: var(--border-base);
152
+ /* A card's own edge. Softer than a structural one: a card is a region OF the
153
+ sheet rather than a surface floating above it, so its edge only has to
154
+ suggest where the region ends. Used by `CardContent`'s frame; a dialog or
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);
157
+ /* Dividers *inside* a container — card header/footer splits, dialog and
158
+ sheet separators, table row rules.
159
+
160
+ Half of the BASE, not of --border, so it is a true 50% rather than one
161
+ token compounding the other. The same value as --border-card, so a card's
162
+ edge and the rules dividing its contents are one hairline weight.
163
+
164
+ Resolved to a real colour rather than an opacity utility so it also works
165
+ inside arbitrary values such as the sticky table header's inset shadow,
166
+ where `/50` has no effect. */
167
+ --border-subtle: color-mix(in srgb, var(--border-base) 50%, transparent);
168
+ /* A mark that has to hold its own against the surface with nothing else
169
+ drawing it — the bar of a scrollbar, the focus ring the skip link draws.
170
+ WCAG 1.4.11 asks 3:1 of exactly that case: 3.25:1 on the white card,
171
+ 3.11:1 on the page ground, the lightest step that clears the minimum on
172
+ both.
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.
177
+
178
+ A tinted fill on fields was tried and reverted. Disabled fields are
179
+ `--muted`, so any resting tint either collides with that meaning or (as
180
+ happened) lands darker than it and inverts the signal outright. On this
181
+ palette the enabled state has to stay on the card colour. */
182
+ --border-control: oklch(86.9% 0.005 56.366);
183
+ /* A card that *holds* other records — an order's fulfillments, its returns,
184
+ its claims — rather than holding content directly.
185
+
186
+ It takes the page's own colour, so the card contributes a border and a
187
+ title strip and nothing else: the white records inside are the only thing
188
+ with a surface, and they carry all the weight. Points at --background
189
+ rather than repeating its value, so the two cannot drift apart.
190
+
191
+ Giving the container a fill of its own was tried and reads worse — it
192
+ tints a large area to say something the border already says, and the
193
+ records lose contrast against it. Sitting at the page colour is safe
194
+ because the card still draws its 1px border at 1.21:1; the fill was never
195
+ carrying that boundary.
196
+
197
+ Records inside stay on `--card`, so what a merchant reads is the raised,
198
+ white thing. Pushing them into a grey well instead reads as content that
199
+ has been demoted, which is backwards — the fulfillment is the point of
200
+ the card, not its chrome. */
201
+ --card-container: var(--background);
202
+ /* Light mode's raised record is simply the card surface. */
203
+ --nested-raised: var(--card);
204
+ /* The recessed well a raised thing sits in — a tab strip's track, a
205
+ segmented control. Paired with --nested-raised: the track always sits one
206
+ step under it, which is why the two themes name different tokens rather
207
+ than sharing one. Light steps down from white to stone-100. */
208
+ --track-recessed: var(--accent);
209
+ --input: color-mix(in srgb, var(--border-base) 80%, transparent);
210
+ --ring: var(--brand-blue);
211
+ --chart-1: oklch(0.7663 0.1067 254.4);
212
+ --chart-2: oklch(0.5967 0.2212 258); /* brand blue */
213
+ --chart-3: oklch(0.5106 0.1868 257.7);
214
+ --chart-4: oklch(0.4232 0.1524 257.4);
215
+ --chart-5: oklch(0.3358 0.118 257.1);
58
216
  --radius: 0.75rem;
59
217
 
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) */
218
+ /* Sidebar — the ground the content sheet is inset into, so it sits one step
219
+ back from the page rather than level with it. That difference is what
220
+ makes the sheet's rounded edge and shadow read at all: when the two were
221
+ the same colour the inset was invisible. Everything else still points at
222
+ the page token it matches, so the rest cannot drift. */
223
+ --sidebar: oklch(97% 0.001 106.424); /* stone-100 */
224
+ --sidebar-foreground: oklch(26.8% 0.007 34.298);
225
+ --sidebar-primary: var(--primary);
226
+ --sidebar-primary-foreground: var(--primary-foreground);
227
+ --sidebar-accent: var(--accent);
228
+ --sidebar-accent-foreground: var(--accent-foreground);
229
+ --sidebar-accent-strong: var(--accent-strong);
230
+ --sidebar-accent-hover: var(--accent-strong);
231
+ --sidebar-accent-strong-hover: var(--accent-strong-hover);
232
+ /* Matches the rule the top bar draws, so every edge framing the shell —
233
+ the sidebar's, the top bar's, the settings header's — is one weight. */
234
+ --sidebar-border: color-mix(in srgb, var(--border) 75%, transparent);
235
+ --sidebar-ring: var(--ring);
236
+
237
+ /* Status ramps, following Vercel's Geist colour system.
238
+ *
239
+ * Geist gives each step of a ramp a fixed job: low steps are component
240
+ * backgrounds, mid steps borders, high steps text and icons. Its badges pair
241
+ * a light tint with a dark reading colour from the same hue, which is what
242
+ * these trios reproduce — a fill light enough to sit on a white card, under
243
+ * text that is a true text colour rather than the fill darkened.
244
+ *
245
+ * The values come from Tailwind's own palette rather than Geist's raw
246
+ * numbers: each step below is the closest match in oklch to its Geist
247
+ * counterpart, so the result reads the same while staying on the palette the
248
+ * rest of the dashboard is built from.
249
+ *
250
+ * Text lands on step 700 in every ramp, over a step-100 fill. Green is the
251
+ * one exception: Tailwind's green-700 is lighter than its siblings and lands
252
+ * at 4.497:1 on green-100, a hair under the 4.5:1 that 12px badge text
253
+ * needs, so green's fill drops one step to clear it. The difference is
254
+ * invisible beside the other three but keeps the whole set measurably legible
255
+ * rather than passing only by eye. */
256
+ --status-green-bg: var(--color-green-100);
257
+ --status-green-border: var(--color-green-200);
258
+ --status-green-fg: var(--color-green-700);
259
+
260
+ --status-amber-bg: var(--color-amber-100);
261
+ --status-amber-border: var(--color-amber-200);
262
+ --status-amber-fg: var(--color-amber-700);
263
+
264
+ --status-red-bg: var(--color-red-100);
265
+ --status-red-border: var(--color-red-200);
266
+ --status-red-fg: var(--color-red-700);
267
+
268
+ /* Blue reads off the sky ramp rather than Tailwind's blue: `--color-blue-500`
269
+ and `-600` are remapped to the brand accent further down, so a badge built
270
+ on those steps would read as a brand mark rather than a neutral status. */
271
+ --status-blue-bg: var(--color-sky-100);
272
+ --status-blue-border: var(--color-sky-200);
273
+ --status-blue-fg: var(--color-sky-700);
69
274
  }
70
275
 
71
276
  .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);
277
+ /* Vercel-style "true black" base, with surfaces stepping up in lightness.
278
+ *
279
+ * Every neutral below is zero-chroma — a true grey, not light mode's warm
280
+ * stone. See the palette note at the top of this file for why the two
281
+ * themes split. The lightness ladder is unchanged from the warm version;
282
+ * only the tint was removed.
283
+ *
284
+ * Dark surfaces step in roughly 4-5 points of OKLCH lightness, not the 1-2
285
+ * light mode uses. Near-black compresses perceived difference, so a step
286
+ * that reads clearly on white disappears down here: an earlier version of
287
+ * this ramp had sidebar, muted, background and card inside a 3.5-point band
288
+ * and the whole middle of the screen read as one flat surface, which in turn
289
+ * made the one boundary that was visible — the sidebar's — look like the
290
+ * sheet had been lit. Reference dark dashboards space adjacent surfaces
291
+ * 5-6 points apart for the same reason.
292
+ *
293
+ * The ladder, ground upward: sidebar 14 / muted + background 17 / card 21.6
294
+ * / secondary + nested-raised 25 / accent 29.5 / accent-hover 33 /
295
+ * accent-strong 38. --card is the anchor, since the inset sheet is the
296
+ * surface a merchant actually reads; keep the gaps if you move anything. */
297
+ --background: oklch(17% 0 0);
298
+ --foreground: oklch(95% 0 0);
299
+ --card: oklch(21.6% 0 0);
300
+ --card-foreground: var(--foreground);
301
+ --popover: var(--card);
302
+ --popover-foreground: var(--foreground);
303
+ /* Mirrors the light theme's primary: there a navy fill carries white text,
304
+ so here the two swap rather than the button taking on its own colour. */
305
+ --primary: var(--foreground);
306
+ --primary-foreground: oklch(14.7% 0 0); /* light theme's primary */
307
+ /* Above --card, but only just. These fill elements that sit *on* a card
308
+ (combobox chips, badges, secondary buttons, table header rows), so a value
309
+ at or under the card's lightness erases them — while too big a step reads
310
+ as a bright band across a full-width table header. Dark mode raises where
311
+ light mode lowers, since a surface reads as raised by getting lighter. */
312
+ --secondary: oklch(25% 0 0);
313
+ --secondary-foreground: var(--foreground);
314
+ --muted: oklch(17% 0 0);
315
+ /* At stone-400's lightness. The old value failed 4.5:1 on --accent
316
+ (4.20:1); this clears every dark ground, the accent included. */
317
+ --muted-foreground: oklch(70.9% 0 0);
318
+ --accent: oklch(29.5% 0 0);
319
+ --accent-foreground: var(--foreground);
320
+ --accent-strong: oklch(38% 0 0);
321
+ --accent-hover: oklch(33% 0 0);
322
+ --accent-strong-hover: oklch(44.4% 0 0);
87
323
  --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 */
324
+ /* A white veil over whatever surface it crosses, so one value works on the
325
+ page ground and the raised sheet alike. Both shares below are half of it,
326
+ as in light mode, so a container's edge and the rules inside it are one
327
+ hairline weight. */
328
+ --border-base: oklch(1 0 0 / 16%);
329
+ --border: var(--border-base);
330
+ --border-card: color-mix(in srgb, var(--border-base) 50%, transparent);
331
+ --border-subtle: color-mix(in srgb, var(--border-base) 50%, transparent);
332
+ /* Light mode darkens the control edge away from the card; dark mode lightens
333
+ it. Not the light value inverted — measured on this theme's own surfaces:
334
+ 3.22:1 on the card, 3.34:1 on the page ground. */
335
+ --border-control: oklch(52% 0 0);
336
+ /* Same arrangement as light — container at the page colour, records raised
337
+ above it. 1.20:1 between them here. Records take `--nested-raised` rather
338
+ than `--card`: the card token is a step above the page, which is where the
339
+ container would sit if it had a fill of its own. */
340
+ --card-container: var(--background);
341
+ --nested-raised: oklch(25% 0 0);
342
+ /* Not --accent here: dark mode's accent is lighter than every raised
343
+ surface, so a shared token would put the selected tab below its own
344
+ track. --muted is the step under --nested-raised. */
345
+ --track-recessed: var(--muted);
346
+ --input: color-mix(in srgb, var(--border-base) 80%, transparent);
347
+ /* The same blue as light mode, lifted for a dark ground. The previous value
348
+ was a near-grey (chroma 0.016) despite claiming parity, so a focused field
349
+ in dark mode drew a grey halo and lost the one signal that says "this is
350
+ where typing goes". */
351
+ --ring: oklch(0.68 0.19 258);
352
+ --link: oklch(0.7 0.16 258);
353
+ --link-hover: oklch(0.78 0.14 258);
91
354
  --chart-1: oklch(0.809 0.105 251.813);
92
355
  --chart-2: oklch(0.623 0.214 259.815);
93
356
  --chart-3: oklch(0.546 0.245 262.881);
94
357
  --chart-4: oklch(0.488 0.243 264.376);
95
358
  --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);
359
+ /* One step back from the page, as in light mode — see the note there. Darker
360
+ rather than lighter: the sheet is the raised surface, so the ground behind
361
+ it recedes.
362
+
363
+ Measured against --card, not --background: the shell renders the sidebar's
364
+ `inset` variant, so the sheet beside it is the card surface and that
365
+ pairing is what a merchant actually sees.
366
+
367
+ The sheet looking "too bright" was never this token being too dark — it
368
+ was the four surfaces above it sitting within half a point of each other,
369
+ so the sidebar edge was the only visible boundary on the screen and it
370
+ carried the whole contrast budget alone. Widening those steps (see the
371
+ ramp note on --background) fixes it; this stays low so the ground still
372
+ recedes. */
373
+ --sidebar: oklch(14% 0 0);
374
+ --sidebar-foreground: var(--foreground);
375
+ --sidebar-primary: var(--primary);
376
+ --sidebar-primary-foreground: var(--primary-foreground);
377
+ --sidebar-accent: var(--accent);
378
+ --sidebar-accent-foreground: var(--accent-foreground);
379
+ --sidebar-accent-strong: var(--accent-strong);
380
+ --sidebar-accent-hover: var(--accent-hover);
381
+ --sidebar-accent-strong-hover: var(--accent-strong-hover);
382
+ /* Matches the rule the top bar draws, so every edge framing the shell —
383
+ the sidebar's, the top bar's, the settings header's — is one weight. */
384
+ --sidebar-border: color-mix(in srgb, var(--border) 75%, transparent);
385
+ --sidebar-ring: var(--ring);
386
+
387
+ /* Geist's dark ramps are not the light ones dimmed — the fill becomes a deep
388
+ shade of the hue and the text becomes brighter than its light-mode
389
+ counterpart. That inversion is why a tint expressed as an opacity over
390
+ black goes muddy: these are separate colours, not one hue at lower alpha.
391
+ Border and fill share a step here, since a deep fill already separates
392
+ from a black card and a lighter rule around it would draw more attention
393
+ than the badge itself.
394
+
395
+ Text sits on step 300 across all four ramps — the brightest of the steps
396
+ Geist treats as text, and the only one that clears 4.5:1 on a 900 fill for
397
+ every hue (red and sky fall under it by step 400). */
398
+ --status-green-bg: var(--color-green-900);
399
+ --status-green-border: var(--color-green-900);
400
+ --status-green-fg: var(--color-green-300);
401
+
402
+ --status-amber-bg: var(--color-amber-900);
403
+ --status-amber-border: var(--color-amber-900);
404
+ --status-amber-fg: var(--color-amber-300);
405
+
406
+ --status-red-bg: var(--color-red-900);
407
+ --status-red-border: var(--color-red-900);
408
+ --status-red-fg: var(--color-red-300);
409
+
410
+ --status-blue-bg: var(--color-sky-900);
411
+ --status-blue-border: var(--color-sky-900);
412
+ --status-blue-fg: var(--color-sky-300);
104
413
  }
105
414
 
106
415
  @theme inline {
@@ -117,19 +426,67 @@
117
426
  --text-3xl: 1.75rem;
118
427
  --text-4xl: 2rem;
119
428
 
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);
429
+ /* The resting lift on a control that sits ON a surface — a field, a button,
430
+ a card. Measured off a reference: the ground under a field's bottom edge
431
+ recovers over about 2px and nothing spreads above it or to the sides, so
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);
123
437
  --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
438
 
439
+ /* Floating surfaces — menus, popovers, dialogs, sheets. Wide and faint
440
+ rather than tight and dark: the job is to say "this is above the page",
441
+ which a large soft spread does without drawing an edge of its own. A
442
+ hairline ring carries the actual boundary. */
443
+ --shadow-md: 0 4px 16px -4px rgb(0 0 0 / 0.08), 0 1px 4px -2px rgb(0 0 0 / 0.05);
444
+ --shadow-lg: 0 8px 32px -8px rgb(0 0 0 / 0.10), 0 2px 8px -4px rgb(0 0 0 / 0.06);
445
+
446
+ /* Radius scale, remapped one step rounder than Tailwind's defaults.
447
+ Corner radius is most of what reads as "soft", and remapping the scale
448
+ reaches every `rounded-lg`/`rounded-xl` already written in the components
449
+ — including the `calc(var(--radius-2xl) - 1px)` a card header derives its
450
+ own corner from, which would drift out of alignment if the components
451
+ were each edited by hand instead. */
452
+ --radius-sm: 0.4rem;
453
+ --radius-md: 0.5rem;
454
+ --radius-lg: 0.625rem;
455
+ --radius-xl: 0.875rem;
456
+ --radius-2xl: 1.125rem;
457
+
125
458
  /* Sidebar + header tokens */
126
- --spacing-sidebar-width: 220px;
127
- --spacing-header-height: 58px;
459
+ --spacing-sidebar-width: 240px;
460
+ /* The offset pinned bands (a table's column row, the bulk-action bar) sit
461
+ below. There is no top bar, so the only chrome above the scrolling content
462
+ is a page's own `PageHeader` — which measures itself and overwrites this
463
+ at runtime. `0px` is therefore the honest default: it is what a page with
464
+ no header should use, and a non-zero constant here would strand the column
465
+ row that far down on every one of them. */
466
+ --spacing-header-height: 0px;
467
+ /* The height of a rail header row — the store/seller switcher, the settings
468
+ rail's title. A fixed constant, deliberately NOT the sticky offset above:
469
+ that one is measured and can legitimately be zero, which would collapse
470
+ these rows to nothing. */
471
+ --spacing-rail-header-height: 42px;
472
+
473
+ /* Easing. Overrides Tailwind's built-in `ease-out`, so every `ease-out`
474
+ utility in the app picks this up: the stock curve is too weak to read as
475
+ deliberate on anything larger than a colour change. `ease-out` is the
476
+ right default for UI — it starts fast, so the moment the user is watching
477
+ happens immediately. Movement across the screen wants `ease-in-out`
478
+ instead; `ease-in` is wrong for UI at any size, since it delays that
479
+ same moment. */
480
+ --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
481
+ --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
128
482
 
129
483
  /* Color mappings */
130
484
  --color-sidebar-ring: var(--sidebar-ring);
131
485
  --color-sidebar-border: var(--sidebar-border);
132
486
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
487
+ --color-sidebar-accent-strong: var(--sidebar-accent-strong);
488
+ --color-sidebar-accent-hover: var(--sidebar-accent-hover);
489
+ --color-sidebar-accent-strong-hover: var(--sidebar-accent-strong-hover);
133
490
  --color-sidebar-accent: var(--sidebar-accent);
134
491
  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
135
492
  --color-sidebar-primary: var(--sidebar-primary);
@@ -143,9 +500,18 @@
143
500
  --color-ring: var(--ring);
144
501
  --color-input: var(--input);
145
502
  --color-border: var(--border);
503
+ --color-border-card: var(--border-card);
504
+ --color-border-subtle: var(--border-subtle);
505
+ --color-border-control: var(--border-control);
506
+ --color-card-container: var(--card-container);
507
+ --color-nested-raised: var(--nested-raised);
508
+ --color-track-recessed: var(--track-recessed);
146
509
  --color-destructive: var(--destructive);
147
510
  --color-destructive-foreground: var(--destructive-foreground);
148
511
  --color-accent-foreground: var(--accent-foreground);
512
+ --color-accent-strong: var(--accent-strong);
513
+ --color-accent-hover: var(--accent-hover);
514
+ --color-accent-strong-hover: var(--accent-strong-hover);
149
515
  --color-accent: var(--accent);
150
516
  --color-muted-foreground: var(--muted-foreground);
151
517
  --color-muted: var(--muted);
@@ -160,18 +526,49 @@
160
526
  --color-foreground: var(--foreground);
161
527
  --color-background: var(--background);
162
528
 
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);
529
+ /* Status colours — badges, alerts and any other success/warning/error mark.
530
+ Each is a background/border/foreground trio rather than a single hue, so a
531
+ component states which role it wants instead of picking an opacity and
532
+ hoping it lands. This replaces the older single-value `--color-success`
533
+ family, which had to be re-tinted at every call site and was bypassed by
534
+ most of them for exactly that reason. */
535
+ --color-success: var(--status-green-fg);
536
+ --color-success-bg: var(--status-green-bg);
537
+ --color-success-border: var(--status-green-border);
538
+
539
+ --color-warning: var(--status-amber-fg);
540
+ --color-warning-bg: var(--status-amber-bg);
541
+ --color-warning-border: var(--status-amber-border);
542
+
543
+ --color-danger: var(--status-red-fg);
544
+ --color-danger-bg: var(--status-red-bg);
545
+ --color-danger-border: var(--status-red-border);
546
+
547
+ --color-info: var(--status-blue-fg);
548
+ --color-info-bg: var(--status-blue-bg);
549
+ --color-info-border: var(--status-blue-border);
550
+
551
+ /* Re-point Tailwind's blue ramp at the brand accent. Interactive blue is
552
+ used throughout the component layer for focus borders, checked states and
553
+ selected calendar days (`border-blue-500`, `bg-blue-500/15`, …); mapping
554
+ the palette entry keeps every one of those on the site's accent without
555
+ each component having to know the token name. Only the interactive steps
556
+ are overridden — the rest of the ramp keeps Tailwind's values for
557
+ incidental use such as charts and illustrations. */
558
+ --color-blue-500: var(--brand-blue);
559
+ --color-blue-600: var(--brand-blue-strong);
560
+ --color-link: var(--link);
561
+ --color-link-hover: var(--link-hover);
168
562
  }
169
563
 
170
564
  @utility focus-ring {
171
565
  /* Unified focus treatment — matches the form-input focus (soft blue glow,
172
566
  no hard outline). Apply to buttons, links, menu triggers, and any other
173
567
  interactive control so every focusable element looks the same on
174
- keyboard focus. Inputs/textarea/select keep their inline equivalent. */
568
+ keyboard focus. Text fields spell the same treatment out inline with a
569
+ `focus:` variant rather than `focus-visible:` — clicking into a field puts
570
+ the caret there, so it must show it owns the keyboard even when the focus
571
+ came from a pointer. */
175
572
  outline: none;
176
573
  &:focus-visible {
177
574
  border-color: var(--ring);
@@ -209,14 +606,78 @@
209
606
  }
210
607
  }
211
608
 
609
+ @utility quiet-scrollbar {
610
+ /* Like `themed-scrollbar`, but the thumb stays invisible until the element
611
+ is hovered. For navigation panels and other chrome, where a permanently
612
+ visible track draws a heavy stripe down an otherwise calm surface. The
613
+ gutter is reserved either way, so revealing the thumb never reflows the
614
+ content. */
615
+ /* `scrollbar-color` is not animatable, so the Firefox path snaps between
616
+ states; the webkit thumb below fades. Both are acceptable — the reveal is
617
+ incidental, not something the user is watching for. */
618
+ scrollbar-color: transparent transparent;
619
+ scrollbar-width: thin;
620
+
621
+ &:hover,
622
+ &:focus-within {
623
+ scrollbar-color: var(--scrollbar-thumb) transparent;
624
+ }
625
+
626
+ &::-webkit-scrollbar {
627
+ width: 10px;
628
+ height: 10px;
629
+ }
630
+ &::-webkit-scrollbar-track {
631
+ background: transparent;
632
+ }
633
+ &::-webkit-scrollbar-thumb {
634
+ background-color: transparent;
635
+ border: 2px solid transparent;
636
+ border-radius: 9999px;
637
+ background-clip: padding-box;
638
+ transition: background-color 150ms ease-out;
639
+ }
640
+ &:hover::-webkit-scrollbar-thumb,
641
+ &:focus-within::-webkit-scrollbar-thumb {
642
+ background-color: var(--scrollbar-thumb);
643
+ }
644
+ &::-webkit-scrollbar-thumb:hover {
645
+ background-color: var(--scrollbar-thumb-hover);
646
+ }
647
+ &::-webkit-scrollbar-corner {
648
+ background: transparent;
649
+ }
650
+ }
651
+
652
+ /* A bare `divide-y`/`divide-x` is a rule *inside* a container — a list's row
653
+ splits, a stacked set of fields — which is exactly what --border-subtle is
654
+ for.
655
+
656
+ Tailwind v4's divide utilities set only the border WIDTH on the children;
657
+ their colour comes from `border-color`, which the universal `*` rule in the
658
+ base layer sets to --border. This has to live in the `utilities` layer to
659
+ outrank that — layer order decides before specificity does — and has to
660
+ match the same children the utilities do.
661
+
662
+ The WHOLE selector goes inside `:where()`, not just the parent: `:not()`
663
+ carries the specificity of its argument, so leaving `:not(:last-child)`
664
+ outside gave this rule a pseudo-class's weight and let it beat an explicit
665
+ `divide-border` at the call site — the opposite of the intent. Inside
666
+ `:where()` the rule has zero specificity and any named colour wins. */
667
+ @layer utilities {
668
+ :where(.divide-y > :not(:last-child), .divide-x > :not(:last-child)) {
669
+ border-color: var(--border-subtle);
670
+ }
671
+ }
672
+
212
673
  @layer base {
213
674
  :root {
214
- --scrollbar-thumb: oklch(0.7 0 0); /* gray-400-ish */
215
- --scrollbar-thumb-hover: oklch(0.55 0 0);
675
+ --scrollbar-thumb: oklch(86.9% 0.005 56.366); /* stone-300 */
676
+ --scrollbar-thumb-hover: oklch(70.9% 0.01 56.259); /* stone-400 */
216
677
  }
217
678
  .dark {
218
- --scrollbar-thumb: oklch(0.35 0 0); /* zinc-700-ish */
219
- --scrollbar-thumb-hover: oklch(0.45 0 0);
679
+ --scrollbar-thumb: oklch(37.4% 0.01 67.558); /* stone-700 */
680
+ --scrollbar-thumb-hover: oklch(44.4% 0.011 73.639); /* stone-600 */
220
681
  }
221
682
 
222
683
  /* Themed scrollbar inside floating surfaces only — page scrollbar stays
@@ -270,10 +731,99 @@
270
731
  outline: none;
271
732
  }
272
733
 
734
+ /* iOS Safari zooms the viewport when a focused text field's font is under
735
+ * 16px, and this app's `text-base` is 14px — so tapping any input on an
736
+ * iPhone jumped the page and left it zoomed. Raise the rendered size on
737
+ * coarse pointers only; the desktop density is unchanged.
738
+ *
739
+ * `pointer: coarse` rather than a width breakpoint: this is about the input
740
+ * method, and a small window on a laptop should keep the compact type. */
741
+ /* Nav rows and menu labels read small on a phone at the desktop 14px.
742
+ * 16px is the size the platform uses for a primary list row. Sizing is left
743
+ * to the components themselves — buttons carry their own touch targets. */
744
+ @media (pointer: coarse) {
745
+ [data-slot='sidebar-menu-button'],
746
+ [data-slot='sidebar-menu-sub-button'],
747
+ [data-slot='dropdown-menu-item'],
748
+ [data-slot='select-item'],
749
+ [data-slot='command-item'],
750
+ [data-slot='filter-panel-item'] {
751
+ font-size: 1rem !important;
752
+ }
753
+ }
754
+
755
+ /* Menu and listbox rows are sized for a pointer — roughly 29px, against a
756
+ * 44pt touch minimum — so on a phone they are hard to hit and easy to hit
757
+ * wrongly. Raise them on coarse pointers only; the dense desktop menus are
758
+ * unchanged. Same `!important` reasoning as the font-size rule below: these
759
+ * rows carry utility classes that outrank any selector written here. */
760
+ @media (pointer: coarse) {
761
+ [data-slot='dropdown-menu-item'],
762
+ [data-slot='dropdown-menu-checkbox-item'],
763
+ [data-slot='dropdown-menu-radio-item'],
764
+ [data-slot='dropdown-menu-sub-trigger'],
765
+ [data-slot='select-item'],
766
+ [data-slot='command-item'],
767
+ [data-slot='combobox-item'],
768
+ /* The filter panel's own rows: fields, values and date presets. They are
769
+ hand-rolled buttons rather than menu items, so they carry this slot
770
+ deliberately to opt into the same touch sizing. */
771
+ [data-slot='filter-panel-item'] {
772
+ min-height: 2.75rem !important;
773
+ }
774
+
775
+ /* The rules above size what is *inside* an open menu; these size the
776
+ * control that opens it. A row's "⋮" is 24px, the sidebar toggle 28px and
777
+ * a table's sort/columns buttons 34px — each clears the 24px web minimum
778
+ * but sits well under what a finger wants, and the row action is both the
779
+ * smallest and the most repeated target on a list.
780
+ *
781
+ * Restricted to `button` because these slots wrap arbitrary children:
782
+ * `tooltip-trigger` in particular renders whatever it is given, including
783
+ * plain text, which must not be inflated to 44px. Width as well as height,
784
+ * since these are square icon buttons rather than full-width rows. */
785
+ button[data-slot='dropdown-menu-trigger'],
786
+ button[data-slot='sidebar-trigger'],
787
+ button[data-slot='popover-trigger'],
788
+ button[data-slot='tooltip-trigger'] {
789
+ min-height: 2.75rem !important;
790
+ min-width: 2.75rem !important;
791
+ }
792
+
793
+ /* The controls inside the filter panel's value step — the operator select,
794
+ its input and the buttons under them. They are sized for a pointer
795
+ (`size="sm"`, roughly 30px), which is below the touch minimum on the one
796
+ surface where a merchant is most likely to be on a phone. */
797
+ [data-slot='filter-panel-controls'] [data-slot='select-trigger'],
798
+ [data-slot='filter-panel-controls'] [data-slot='input'],
799
+ [data-slot='filter-panel-controls'] button {
800
+ min-height: 2.75rem !important;
801
+ }
802
+ }
803
+
804
+ @media (pointer: coarse) {
805
+ /* `!important` is load-bearing here, not laziness: these controls carry a
806
+ * `text-*` utility class, which outranks any element selector this rule
807
+ * could write. Without it the declaration loses to `.text-base` and the
808
+ * zoom still happens. The rule is deliberately narrow — one property, on
809
+ * text-entry controls, under a coarse pointer. */
810
+ input:not([type='checkbox']):not([type='radio']),
811
+ textarea,
812
+ select,
813
+ [contenteditable] {
814
+ font-size: max(1rem, var(--text-base)) !important;
815
+ }
816
+ }
817
+
273
818
  html {
274
819
  @apply font-sans antialiased;
275
820
  text-rendering: optimizeLegibility;
276
- overflow-y: scroll;
821
+ /* No `overflow-y: scroll` here. It used to reserve the scrollbar gutter so
822
+ * the page would not jump sideways between a short route and a tall one,
823
+ * back when the document was what scrolled. The inset shell ended that:
824
+ * it is exactly viewport-height and scrolls inside itself, so the document
825
+ * can never overflow — and the reserved gutter became a permanently
826
+ * disabled second scrollbar sitting beside the sheet's real one. */
277
827
  /* Clip horizontal overflow at the document root so wide tables on mobile
278
828
  * can't widen the page. Must live on <html>, not <body>: `overflow-x:
279
829
  * hidden` on body makes `overflow-y` compute to `auto`, turning body into
@@ -335,4 +885,129 @@
335
885
  5px -5px,
336
886
  -5px 0;
337
887
  }
888
+
889
+ /* Tiptap editor surface. Tailwind preflight strips list markers, link
890
+ underlines and block margins, so lists, quotes and links would save
891
+ correctly but look like plain paragraphs until the storefront rendered
892
+ them. These rules restore that chrome only inside the editor. */
893
+ .tiptap {
894
+ min-height: 8rem;
895
+ outline: none;
896
+
897
+ & p.is-editor-empty:first-child::before {
898
+ color: var(--muted-foreground);
899
+ content: attr(data-placeholder);
900
+ float: left;
901
+ height: 0;
902
+ pointer-events: none;
903
+ }
904
+
905
+ & p {
906
+ margin-block: 0.5em;
907
+ }
908
+
909
+ & p:first-child {
910
+ margin-block-start: 0;
911
+ }
912
+
913
+ & p:last-child {
914
+ margin-block-end: 0;
915
+ }
916
+
917
+ & ul {
918
+ list-style-type: disc;
919
+ margin-block: 0.5em;
920
+ padding-inline-start: 1.5em;
921
+ }
922
+
923
+ & ol {
924
+ list-style-type: decimal;
925
+ margin-block: 0.5em;
926
+ padding-inline-start: 1.5em;
927
+ }
928
+
929
+ & ul ul,
930
+ & ol ul {
931
+ list-style-type: circle;
932
+ margin-block: 0.15em;
933
+ }
934
+
935
+ & ul ul ul,
936
+ & ol ul ul,
937
+ & ol ol ul,
938
+ & ul ol ul {
939
+ list-style-type: square;
940
+ }
941
+
942
+ & ol ol,
943
+ & ul ol {
944
+ list-style-type: lower-alpha;
945
+ margin-block: 0.15em;
946
+ }
947
+
948
+ & li {
949
+ margin-block: 0.15em;
950
+ }
951
+
952
+ & li > p {
953
+ margin-block: 0;
954
+ }
955
+
956
+ & blockquote {
957
+ border-inline-start: 3px solid var(--border);
958
+ color: var(--muted-foreground);
959
+ margin-block: 0.75em;
960
+ padding-inline-start: 1em;
961
+ }
962
+
963
+ & a {
964
+ color: var(--brand-blue);
965
+ text-decoration: underline;
966
+ text-underline-offset: 2px;
967
+ }
968
+
969
+ & img {
970
+ max-width: 100%;
971
+ border-radius: var(--radius-md);
972
+ }
973
+
974
+ & img.ProseMirror-selectednode {
975
+ outline: 2px solid var(--brand-blue);
976
+ }
977
+ }
978
+ }
979
+
980
+ /* Reduced motion. Deliberately NOT inside `@layer base`: unlayered CSS always
981
+ outranks layered CSS, so a base-layer rule loses to every `transition-*`
982
+ utility and the preference would be silently ignored. Unlayered and last in
983
+ the file, this wins on source order instead.
984
+
985
+ Movement is what the setting asks us to drop, so `transform`, `translate`
986
+ and `scale` are absent from the property list below — no element can animate
987
+ position or size however it is styled. Colour, shadow and opacity keep their
988
+ normal timing: they change appearance in place rather than moving anything,
989
+ and killing them too reads as broken rather than calm.
990
+
991
+ Components needing more than this — where suppressing a transition leaves a
992
+ jump rather than removing a movement — read the preference directly via
993
+ `usePrefersReducedMotion` and hold position, or opt out with a
994
+ `motion-reduce:` utility. */
995
+ @media (prefers-reduced-motion: reduce) {
996
+ *,
997
+ *::before,
998
+ *::after {
999
+ transition-property: color, background-color, border-color, box-shadow, opacity, fill, stroke;
1000
+ transition-duration: 100ms;
1001
+ /* Holds looping decorative animations at their first frame. */
1002
+ animation-duration: 0.01ms;
1003
+ animation-iteration-count: 1;
1004
+ scroll-behavior: auto;
1005
+ }
1006
+
1007
+ /* Spinners are the deliberate exception: they signal that work is ongoing
1008
+ and have no static equivalent. */
1009
+ .animate-spin {
1010
+ animation-duration: 1s;
1011
+ animation-iteration-count: infinite;
1012
+ }
338
1013
  }