@lovett/ui 0.0.9 → 0.0.11

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 (55) hide show
  1. package/dist/index.d.ts +823 -135
  2. package/dist/index.js +2048 -358
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +44 -2
  5. package/dist/theme-v2.css +228 -0
  6. package/dist/tokens.css +123 -8
  7. package/package.json +1 -1
  8. package/src/__tests__/anchor.test.tsx +422 -0
  9. package/src/__tests__/combobox.test.tsx +677 -0
  10. package/src/__tests__/dropdown-menu.test.tsx +418 -0
  11. package/src/__tests__/helpers/geometry.ts +58 -0
  12. package/src/__tests__/layer-stack.test.tsx +228 -0
  13. package/src/__tests__/modal.test.tsx +180 -6
  14. package/src/__tests__/popover.test.tsx +460 -0
  15. package/src/__tests__/select.test.tsx +543 -0
  16. package/src/__tests__/tooltip.test.tsx +355 -0
  17. package/src/calculator-shell-v2.tsx +19 -39
  18. package/src/code-block.tsx +15 -26
  19. package/src/combobox.tsx +796 -0
  20. package/src/dropdown-menu.tsx +142 -152
  21. package/src/icons/brand.tsx +81 -2
  22. package/src/index.ts +111 -0
  23. package/src/lib/anchor.ts +427 -0
  24. package/src/lib/focus.ts +32 -0
  25. package/src/lib/layer-stack.ts +188 -0
  26. package/src/lib/refs.ts +31 -0
  27. package/src/metric-card.tsx +57 -22
  28. package/src/modal.tsx +149 -9
  29. package/src/page-shell.tsx +91 -2
  30. package/src/popover.tsx +407 -0
  31. package/src/segmented-pill.tsx +33 -10
  32. package/src/select.tsx +646 -0
  33. package/src/stat-row.tsx +108 -70
  34. package/src/styles.css +44 -2
  35. package/src/theme-v2.css +7 -245
  36. package/src/tokens.css +123 -8
  37. package/src/tooltip.tsx +297 -0
  38. package/src/react-syntax-highlighter-prism.d.ts +0 -34
  39. package/src/v2/README.md +0 -208
  40. package/src/v2/__demo__/showcase.tsx +0 -1045
  41. package/src/v2/action.tsx +0 -91
  42. package/src/v2/callout.tsx +0 -76
  43. package/src/v2/document-section.tsx +0 -82
  44. package/src/v2/document-shell.tsx +0 -0
  45. package/src/v2/field-row.tsx +0 -113
  46. package/src/v2/icons.tsx +0 -165
  47. package/src/v2/index.ts +0 -147
  48. package/src/v2/layout.tsx +0 -293
  49. package/src/v2/progress-track.tsx +0 -89
  50. package/src/v2/stat-tile.tsx +0 -129
  51. package/src/v2/states.tsx +0 -271
  52. package/src/v2/status-pill.tsx +0 -74
  53. package/src/v2/theme.css +0 -1861
  54. package/src/v2/timeline.tsx +0 -81
  55. package/src/v2/tokens.ts +0 -228
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Tooltip — hover / focus label on lib/anchor + lib/layer-stack.
3
+ *
4
+ * Contract under test: the child is the trigger (no wrapper), role=tooltip
5
+ * + aria-describedby wiring while open, the 300 ms hover delay and 0 ms
6
+ * close, immediate open on keyboard focus but NOT on pointer focus,
7
+ * press-to-dismiss, Escape through the layer stack (a tooltip in a Modal
8
+ * closes alone), never focusable / pointer-events none, controlled state,
9
+ * child handler + ref composition, and positioning with mocked geometry.
10
+ */
11
+ import { createRef } from 'react'
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13
+ import { act, render, screen } from '@testing-library/react'
14
+ import userEvent from '@testing-library/user-event'
15
+ import Modal from '../modal'
16
+ import { Tooltip } from '../tooltip'
17
+ import { mockGeometry, setViewport } from './helpers/geometry'
18
+
19
+ const trigger = () => screen.getByRole('button', { name: 'Archive' })
20
+ const tip = () => screen.getByRole('tooltip')
21
+ const advance = (ms: number) =>
22
+ act(() => {
23
+ vi.advanceTimersByTime(ms)
24
+ })
25
+
26
+ describe('Tooltip', () => {
27
+ let user: ReturnType<typeof userEvent.setup>
28
+ beforeEach(() => {
29
+ vi.useFakeTimers()
30
+ // RTL's asyncWrapper drains with a real setTimeout(0) unless it finds a
31
+ // jest-style advanceTimersByTime; without this shim every user-event
32
+ // call hangs under vitest fake timers.
33
+ vi.stubGlobal('jest', {
34
+ advanceTimersByTime: (ms: number) => vi.advanceTimersByTime(ms),
35
+ })
36
+ user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
37
+ })
38
+ afterEach(() => {
39
+ vi.useRealTimers()
40
+ vi.unstubAllGlobals()
41
+ })
42
+
43
+ function Basic(props: Partial<React.ComponentProps<typeof Tooltip>> = {}) {
44
+ return (
45
+ <Tooltip content="Archive this task" {...props}>
46
+ <button type="button" aria-label="Archive">
47
+ A
48
+ </button>
49
+ </Tooltip>
50
+ )
51
+ }
52
+
53
+ describe('rendering', () => {
54
+ it('renders the child as-is: no wrapper, no tooltip, no aria-describedby while closed', () => {
55
+ const { container } = render(<Basic />)
56
+ expect(container.firstElementChild).toBe(trigger())
57
+ expect(screen.queryByRole('tooltip')).toBeNull()
58
+ expect(trigger()).not.toHaveAttribute('aria-describedby')
59
+ })
60
+
61
+ it('forwards the child ref', () => {
62
+ const ref = createRef<HTMLButtonElement>()
63
+ render(
64
+ <Tooltip content="x">
65
+ <button ref={ref} type="button" aria-label="Archive" />
66
+ </Tooltip>,
67
+ )
68
+ expect(ref.current).toBe(trigger())
69
+ })
70
+
71
+ it('throws for a non-element child', () => {
72
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
73
+ expect(() =>
74
+ render(
75
+ // @ts-expect-error — the contract is one element child; this pins the runtime guard.
76
+ <Tooltip content="x">plain text</Tooltip>,
77
+ ),
78
+ ).toThrow(/exactly one element child/)
79
+ spy.mockRestore()
80
+ })
81
+
82
+ it('with null content, the child passes through and nothing ever opens', async () => {
83
+ render(<Basic content={null} />)
84
+ await user.hover(trigger())
85
+ advance(1000)
86
+ expect(screen.queryByRole('tooltip')).toBeNull()
87
+ })
88
+ })
89
+
90
+ describe('hover', () => {
91
+ it('opens after the 300 ms delay, portaled to body, and wires aria-describedby', async () => {
92
+ const { container } = render(<Basic />)
93
+ await user.hover(trigger())
94
+ expect(screen.queryByRole('tooltip')).toBeNull()
95
+ advance(299)
96
+ expect(screen.queryByRole('tooltip')).toBeNull()
97
+ advance(1)
98
+ const t = tip()
99
+ expect(t).toHaveTextContent('Archive this task')
100
+ expect(t.parentElement).toBe(document.body)
101
+ expect(container.contains(t)).toBe(false)
102
+ expect(trigger()).toHaveAttribute('aria-describedby', t.id)
103
+ expect(t).toHaveAttribute('data-slot', 'tooltip')
104
+ })
105
+
106
+ it('closes immediately on unhover and drops aria-describedby', async () => {
107
+ render(<Basic />)
108
+ await user.hover(trigger())
109
+ advance(300)
110
+ expect(tip()).toBeInTheDocument()
111
+ await user.unhover(trigger())
112
+ expect(screen.queryByRole('tooltip')).toBeNull()
113
+ expect(trigger()).not.toHaveAttribute('aria-describedby')
114
+ })
115
+
116
+ it('leaving before the delay cancels the open', async () => {
117
+ render(<Basic />)
118
+ await user.hover(trigger())
119
+ advance(150)
120
+ await user.unhover(trigger())
121
+ advance(1000)
122
+ expect(screen.queryByRole('tooltip')).toBeNull()
123
+ })
124
+
125
+ it('honours custom openDelay / closeDelay', async () => {
126
+ render(<Basic openDelay={50} closeDelay={100} />)
127
+ await user.hover(trigger())
128
+ advance(50)
129
+ expect(tip()).toBeInTheDocument()
130
+ await user.unhover(trigger())
131
+ expect(tip()).toBeInTheDocument()
132
+ advance(100)
133
+ expect(screen.queryByRole('tooltip')).toBeNull()
134
+ })
135
+
136
+ it('merges an existing aria-describedby rather than replacing it', async () => {
137
+ render(
138
+ <>
139
+ <p id="hint">hint</p>
140
+ <Tooltip content="x">
141
+ <button type="button" aria-label="Archive" aria-describedby="hint" />
142
+ </Tooltip>
143
+ </>,
144
+ )
145
+ await user.hover(trigger())
146
+ advance(300)
147
+ expect(trigger().getAttribute('aria-describedby')).toBe(`hint ${tip().id}`)
148
+ await user.unhover(trigger())
149
+ expect(trigger()).toHaveAttribute('aria-describedby', 'hint')
150
+ })
151
+ })
152
+
153
+ describe('focus', () => {
154
+ it('opens immediately on keyboard focus and closes on blur', async () => {
155
+ render(
156
+ <>
157
+ <Basic />
158
+ <button type="button">Next</button>
159
+ </>,
160
+ )
161
+ await user.tab()
162
+ expect(document.activeElement).toBe(trigger())
163
+ expect(tip()).toBeInTheDocument()
164
+ expect(trigger()).toHaveAttribute('aria-describedby', tip().id)
165
+ await user.tab()
166
+ expect(screen.queryByRole('tooltip')).toBeNull()
167
+ })
168
+
169
+ it('a focus that arrives by pointer (a click) does not open it', async () => {
170
+ render(<Basic />)
171
+ await user.click(trigger())
172
+ expect(document.activeElement).toBe(trigger())
173
+ advance(1000)
174
+ expect(screen.queryByRole('tooltip')).toBeNull()
175
+ })
176
+
177
+ it('pressing the trigger dismisses an open tooltip', async () => {
178
+ render(<Basic />)
179
+ await user.hover(trigger())
180
+ advance(300)
181
+ expect(tip()).toBeInTheDocument()
182
+ await user.pointer({ keys: '[MouseLeft>]', target: trigger() })
183
+ expect(screen.queryByRole('tooltip')).toBeNull()
184
+ await user.pointer({ keys: '[/MouseLeft]', target: trigger() })
185
+ })
186
+
187
+ it('removes its document press listeners on release and on unmount (no stale closure per interrupted press)', async () => {
188
+ const added = vi.spyOn(document, 'addEventListener')
189
+ const removed = vi.spyOn(document, 'removeEventListener')
190
+ try {
191
+ const { unmount } = render(<Basic />)
192
+ const pressListeners = () =>
193
+ added.mock.calls.filter(([type]) => type === 'pointerup' || type === 'pointercancel')
194
+ const removedPressListeners = () =>
195
+ removed.mock.calls.filter(([type]) => type === 'pointerup' || type === 'pointercancel')
196
+
197
+ // A full press: the listeners go on at pointerdown and come off at pointerup.
198
+ await user.pointer({ keys: '[MouseLeft>]', target: trigger() })
199
+ expect(pressListeners()).toHaveLength(2)
200
+ await user.pointer({ keys: '[/MouseLeft]', target: trigger() })
201
+ expect(removedPressListeners().map(([, fn]) => fn)).toEqual(
202
+ pressListeners().map(([, fn]) => fn),
203
+ )
204
+
205
+ // An interrupted press: unmount mid-press must remove them too.
206
+ added.mockClear()
207
+ removed.mockClear()
208
+ await user.pointer({ keys: '[MouseLeft>]', target: trigger() })
209
+ expect(pressListeners()).toHaveLength(2)
210
+ unmount()
211
+ expect(removedPressListeners().map(([, fn]) => fn)).toEqual(
212
+ pressListeners().map(([, fn]) => fn),
213
+ )
214
+ } finally {
215
+ added.mockRestore()
216
+ removed.mockRestore()
217
+ }
218
+ })
219
+
220
+ it('never traps or moves focus: the tip is not focusable and does not take the pointer', async () => {
221
+ render(<Basic />)
222
+ await user.tab()
223
+ const t = tip()
224
+ expect(t).not.toHaveAttribute('tabindex')
225
+ expect(t).toHaveClass('pointer-events-none')
226
+ expect(document.activeElement).toBe(trigger())
227
+ })
228
+
229
+ it('composes the child handlers', async () => {
230
+ const onFocus = vi.fn()
231
+ const onPointerEnter = vi.fn()
232
+ render(
233
+ <Tooltip content="x">
234
+ <button type="button" aria-label="Archive" onFocus={onFocus} onPointerEnter={onPointerEnter} />
235
+ </Tooltip>,
236
+ )
237
+ await user.hover(trigger())
238
+ await user.tab()
239
+ expect(onPointerEnter).toHaveBeenCalledTimes(1)
240
+ expect(onFocus).toHaveBeenCalledTimes(1)
241
+ })
242
+ })
243
+
244
+ describe('Escape + layers', () => {
245
+ it('Escape dismisses and leaves focus where it was', async () => {
246
+ render(<Basic />)
247
+ await user.tab()
248
+ expect(tip()).toBeInTheDocument()
249
+ await user.keyboard('{Escape}')
250
+ expect(screen.queryByRole('tooltip')).toBeNull()
251
+ expect(document.activeElement).toBe(trigger())
252
+ })
253
+
254
+ it('inside a Modal, Escape closes only the tooltip; the next Escape closes the modal', async () => {
255
+ const onClose = vi.fn()
256
+ render(
257
+ <Modal isOpen onClose={onClose} title="Settings">
258
+ <Basic />
259
+ </Modal>,
260
+ )
261
+ await user.hover(trigger())
262
+ advance(300)
263
+ expect(tip()).toBeInTheDocument()
264
+ await user.keyboard('{Escape}')
265
+ expect(screen.queryByRole('tooltip')).toBeNull()
266
+ expect(onClose).not.toHaveBeenCalled()
267
+ await user.keyboard('{Escape}')
268
+ expect(onClose).toHaveBeenCalledTimes(1)
269
+ })
270
+
271
+ it('does not listen for Escape while closed', async () => {
272
+ const onOpenChange = vi.fn()
273
+ render(<Basic onOpenChange={onOpenChange} />)
274
+ await user.keyboard('{Escape}')
275
+ expect(onOpenChange).not.toHaveBeenCalled()
276
+ })
277
+ })
278
+
279
+ describe('controlled / uncontrolled', () => {
280
+ it('defaultOpen renders it open', () => {
281
+ render(<Basic defaultOpen />)
282
+ expect(tip()).toBeInTheDocument()
283
+ })
284
+
285
+ it('controlled `open` renders regardless of hover; onOpenChange reports intent', async () => {
286
+ const onOpenChange = vi.fn()
287
+ render(<Basic open={false} onOpenChange={onOpenChange} />)
288
+ await user.hover(trigger())
289
+ advance(300)
290
+ expect(onOpenChange).toHaveBeenCalledWith(true)
291
+ expect(screen.queryByRole('tooltip')).toBeNull()
292
+ })
293
+
294
+ it('controlled open=true shows it and unhover reports false', async () => {
295
+ const onOpenChange = vi.fn()
296
+ render(<Basic open onOpenChange={onOpenChange} />)
297
+ expect(tip()).toBeInTheDocument()
298
+ await user.hover(trigger())
299
+ await user.unhover(trigger())
300
+ expect(onOpenChange).toHaveBeenCalledWith(false)
301
+ expect(tip()).toBeInTheDocument()
302
+ })
303
+ })
304
+
305
+ describe('surface + positioning (mocked geometry)', () => {
306
+ let restore: () => void
307
+ beforeEach(() => {
308
+ restore = mockGeometry()
309
+ setViewport(1000, 800)
310
+ })
311
+ afterEach(() => restore())
312
+
313
+ it('sits above the trigger, centred, with the popover-family chrome and the entry motion', async () => {
314
+ render(
315
+ <Tooltip content="x" className="mine">
316
+ <button type="button" aria-label="Archive" data-rect="400,300,80,32" />
317
+ </Tooltip>,
318
+ )
319
+ await user.tab()
320
+ const t = tip()
321
+ expect(t.style.position).toBe('fixed')
322
+ expect(t).toHaveAttribute('data-side', 'top')
323
+ expect(t).toHaveAttribute('data-align', 'center')
324
+ expect(t.style.left).toBe('440px')
325
+ expect(t.style.top).toBe(`${300 - 6}px`)
326
+ expect(t).toHaveAttribute('data-positioned', 'true')
327
+ expect(t).toHaveClass('ds-enter-pop', 'mine')
328
+ expect(t.style.background).toContain('--popover')
329
+ expect(t.style.padding).toContain('--space-1')
330
+ })
331
+
332
+ it('flips below the trigger at the top of the viewport', async () => {
333
+ render(
334
+ <Tooltip content="x">
335
+ <button type="button" aria-label="Archive" data-rect="400,4,80,32" />
336
+ </Tooltip>,
337
+ )
338
+ await user.tab()
339
+ expect(tip()).toHaveAttribute('data-side', 'bottom')
340
+ expect(tip().style.top).toBe(`${4 + 32 + 6}px`)
341
+ })
342
+
343
+ it('honours side / align / offset', async () => {
344
+ render(
345
+ <Tooltip content="x" side="right" align="start" offset={10}>
346
+ <button type="button" aria-label="Archive" data-rect="400,300,80,32" />
347
+ </Tooltip>,
348
+ )
349
+ await user.tab()
350
+ expect(tip()).toHaveAttribute('data-side', 'right')
351
+ expect(tip().style.left).toBe(`${480 + 10}px`)
352
+ expect(tip().style.top).toBe('300px')
353
+ })
354
+ })
355
+ })
@@ -11,6 +11,9 @@
11
11
  * layout="shell" (default) — a single contained card (radius 24,
12
12
  * --shell-shadow): flat header + padded body + bordered footer.
13
13
  * For compact calculators.
14
+ * Responsive (ADR-145 amendment): header / body / footer tighten to
15
+ * 16px padding below `sm` and the footer wraps, so a calculator reads
16
+ * on a phone without the shell owning any width logic.
14
17
  * layout="open" — the header becomes a rounded banner and the Frames
15
18
  * sit directly on the page in a vertical stack; the footer is
16
19
  * borderless/transparent. Scales to long forms (funnels).
@@ -21,17 +24,18 @@
21
24
  * `[lead, accentWord]` tuple for explicit control. No completion ring.
22
25
  *
23
26
  * Data seam (D9): `prefill` + `benchmarkSlot` are accepted but STUBBED
24
- * this ADR — `prefill` is typed and not read. Per ADR-076 Amendment 1 E,
25
- * `benchmarkSlot` now renders a muted "Benchmark unavailable in manual
26
- * mode" placeholder when absent (was: nothing). The live data wiring is a
27
- * separate future ADR.
27
+ * this ADR — `prefill` is typed and not read; `benchmarkSlot` renders only
28
+ * when a caller passes one. (ADR-076 Amendment 1 E's "Benchmark unavailable
29
+ * in manual mode" placeholder was retired by owner directive 2026-09-04
30
+ * ADR-145 amendment: a permanent "unavailable" strip on every calculator
31
+ * read as broken, not promising.) The live data wiring is a separate
32
+ * future ADR.
28
33
  *
29
34
  * Amendment 1 additions (2026-06-05):
30
35
  * - `assumptions` (B) — manual-vs-connected inputs, "directional planning
31
36
  * math, not observed performance," and the config/formula version.
32
37
  * Rendered as a compact hover popover in the FOOTER (next to the footer
33
38
  * note), not a body strip, so honest framing never lengthens the shell.
34
- * - benchmark placeholder (E) — see above.
35
39
  * - `copyReport` (F) — the footer Copy action emits a structured
36
40
  * mini-report (inputs + result + StatRow chain + assumptions +
37
41
  * benchmark status), not the bare number. Shell-owned so every
@@ -39,7 +43,7 @@
39
43
  */
40
44
 
41
45
  import { useState, type CSSProperties, type ReactNode } from 'react'
42
- import { BarChart3, Copy, Info } from 'lucide-react'
46
+ import { Copy, Info } from 'lucide-react'
43
47
  import { Button } from './button'
44
48
  import { toast } from './toast'
45
49
 
@@ -90,8 +94,8 @@ export interface CalculatorShellV2Props {
90
94
  copyReport?: string | (() => string)
91
95
  /** STUBBED (D9) — typed, not read this ADR. */
92
96
  prefill?: CalcPrefill
93
- /** "vs. benchmark" rail. When absent, the shell renders a muted
94
- * "Benchmark unavailable in manual mode" placeholder (Amendment 1 E). */
97
+ /** "vs. benchmark" rail. Rendered only when provided (the Amendment 1 E
98
+ * placeholder is retired ADR-145 amendment). */
95
99
  benchmarkSlot?: ReactNode
96
100
  }
97
101
 
@@ -119,29 +123,6 @@ function HeaderTitle({ title }: { title: string | [string, string] }) {
119
123
  )
120
124
  }
121
125
 
122
- /** Muted rail shown in place of `benchmarkSlot` until the benchmark data
123
- * layer exists (Amendment 1 E) — reinforces the data-backed promise. */
124
- function BenchmarkPlaceholder() {
125
- return (
126
- <div
127
- className="flex items-center"
128
- style={{
129
- gap: 10,
130
- padding: '12px 16px',
131
- borderRadius: 'var(--radius-lg)',
132
- background: 'rgb(var(--muted))',
133
- border: '1px dashed rgb(var(--border))',
134
- color: 'rgb(var(--text-muted))',
135
- }}
136
- >
137
- <BarChart3 size={15} />
138
- <span style={{ fontSize: 12, fontWeight: 500 }}>
139
- Benchmark unavailable in manual mode
140
- </span>
141
- </div>
142
- )
143
- }
144
-
145
126
  /** Footer assumptions affordance (Amendment 1 B). A compact "Assumptions"
146
127
  * trigger that reveals the full disclaimer in a hover/focus popover. The
147
128
  * popover is absolutely positioned, so it never lengthens the shell — the
@@ -237,10 +218,9 @@ export function CalculatorShellV2({
237
218
 
238
219
  const header = (
239
220
  <header
240
- className="flex items-center"
221
+ className="flex items-center px-4 py-4 sm:px-6 sm:py-5"
241
222
  style={{
242
223
  gap: 16,
243
- padding: '20px 24px',
244
224
  background: 'rgb(var(--brand-ink))',
245
225
  borderRadius: isOpen ? 'var(--radius-xl)' : 0,
246
226
  }}
@@ -260,7 +240,8 @@ export function CalculatorShellV2({
260
240
  </span>
261
241
  <div className="flex-1 min-w-0">
262
242
  <div
263
- className="font-semibold uppercase"
243
+ className="font-semibold uppercase truncate"
244
+ title={eyebrow}
264
245
  style={{
265
246
  fontFamily: 'var(--font-mono)',
266
247
  fontSize: 11,
@@ -287,10 +268,9 @@ export function CalculatorShellV2({
287
268
 
288
269
  const footer = (
289
270
  <footer
290
- className="flex items-center"
271
+ className="flex flex-wrap items-center px-4 py-4 sm:px-6"
291
272
  style={{
292
273
  gap: 12,
293
- padding: '16px 24px',
294
274
  borderTop: isOpen ? 'none' : '1px solid rgb(var(--border))',
295
275
  marginTop: isOpen ? 4 : 0,
296
276
  background: isOpen ? 'transparent' : 'rgb(var(--bg-card))',
@@ -336,7 +316,7 @@ export function CalculatorShellV2({
336
316
  <>
337
317
  {inputs}
338
318
  {result}
339
- {benchmarkSlot ?? <BenchmarkPlaceholder />}
319
+ {benchmarkSlot}
340
320
  </>
341
321
  )
342
322
 
@@ -367,8 +347,8 @@ export function CalculatorShellV2({
367
347
  >
368
348
  {header}
369
349
  <div
370
- className="flex flex-col"
371
- style={{ padding: 'var(--space-6)', gap: 'var(--space-4)' }}
350
+ className="flex flex-col p-4 sm:p-6"
351
+ style={{ gap: 'var(--space-4)' }}
372
352
  >
373
353
  {body}
374
354
  </div>
@@ -12,32 +12,21 @@
12
12
  import { useEffect, useState, type HTMLAttributes } from 'react'
13
13
  import { Check, Copy } from 'lucide-react'
14
14
  import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'
15
- // The `/index.js` is REQUIRED and is not a style preference.
16
- //
17
- // `react-syntax-highlighter/dist/esm/styles/prism` is a DIRECTORY. Vite's
18
- // resolver follows a directory import; **Node's ESM resolver does not**, and
19
- // `tsup` externalises this specifier verbatim into `dist/index.js`. So the
20
- // package built green and published, and any consumer loading it under Node ESM
21
- // vitest, most obviously — failed at IMPORT, before a single assertion ran.
22
- //
23
- // Reported by the studios plane against `@lovett/ui@0.0.7` and reproduced from
24
- // the registry: two of three suites failed to load, not to assert. A green build
25
- // and dead tests, with nothing connecting the two facts.
26
- import { vscDarkPlus, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism/index.js'
27
-
28
- import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash.js'
29
- import css from 'react-syntax-highlighter/dist/esm/languages/prism/css.js'
30
- import diff from 'react-syntax-highlighter/dist/esm/languages/prism/diff.js'
31
- import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript.js'
32
- import json from 'react-syntax-highlighter/dist/esm/languages/prism/json.js'
33
- import jsx from 'react-syntax-highlighter/dist/esm/languages/prism/jsx.js'
34
- import markdown from 'react-syntax-highlighter/dist/esm/languages/prism/markdown.js'
35
- import markup from 'react-syntax-highlighter/dist/esm/languages/prism/markup.js'
36
- import python from 'react-syntax-highlighter/dist/esm/languages/prism/python.js'
37
- import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql.js'
38
- import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx.js'
39
- import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript.js'
40
- import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml.js'
15
+ import { vscDarkPlus, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
16
+
17
+ import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash'
18
+ import css from 'react-syntax-highlighter/dist/esm/languages/prism/css'
19
+ import diff from 'react-syntax-highlighter/dist/esm/languages/prism/diff'
20
+ import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript'
21
+ import json from 'react-syntax-highlighter/dist/esm/languages/prism/json'
22
+ import jsx from 'react-syntax-highlighter/dist/esm/languages/prism/jsx'
23
+ import markdown from 'react-syntax-highlighter/dist/esm/languages/prism/markdown'
24
+ import markup from 'react-syntax-highlighter/dist/esm/languages/prism/markup'
25
+ import python from 'react-syntax-highlighter/dist/esm/languages/prism/python'
26
+ import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql'
27
+ import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx'
28
+ import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript'
29
+ import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml'
41
30
 
42
31
  import { cn } from './lib/utils'
43
32