@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.
- package/dist/index.d.ts +823 -135
- package/dist/index.js +2048 -358
- package/dist/index.js.map +1 -1
- package/dist/styles.css +44 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +123 -8
- package/package.json +1 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- package/src/__tests__/combobox.test.tsx +677 -0
- package/src/__tests__/dropdown-menu.test.tsx +418 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/modal.test.tsx +180 -6
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/select.test.tsx +543 -0
- package/src/__tests__/tooltip.test.tsx +355 -0
- package/src/calculator-shell-v2.tsx +19 -39
- package/src/code-block.tsx +15 -26
- package/src/combobox.tsx +796 -0
- package/src/dropdown-menu.tsx +142 -152
- package/src/icons/brand.tsx +81 -2
- package/src/index.ts +111 -0
- package/src/lib/anchor.ts +427 -0
- package/src/lib/focus.ts +32 -0
- package/src/lib/layer-stack.ts +188 -0
- package/src/lib/refs.ts +31 -0
- package/src/metric-card.tsx +57 -22
- package/src/modal.tsx +149 -9
- package/src/page-shell.tsx +91 -2
- package/src/popover.tsx +407 -0
- package/src/segmented-pill.tsx +33 -10
- package/src/select.tsx +646 -0
- package/src/stat-row.tsx +108 -70
- package/src/styles.css +44 -2
- package/src/theme-v2.css +7 -245
- package/src/tokens.css +123 -8
- package/src/tooltip.tsx +297 -0
- package/src/react-syntax-highlighter-prism.d.ts +0 -34
- package/src/v2/README.md +0 -208
- package/src/v2/__demo__/showcase.tsx +0 -1045
- package/src/v2/action.tsx +0 -91
- package/src/v2/callout.tsx +0 -76
- package/src/v2/document-section.tsx +0 -82
- package/src/v2/document-shell.tsx +0 -0
- package/src/v2/field-row.tsx +0 -113
- package/src/v2/icons.tsx +0 -165
- package/src/v2/index.ts +0 -147
- package/src/v2/layout.tsx +0 -293
- package/src/v2/progress-track.tsx +0 -89
- package/src/v2/stat-tile.tsx +0 -129
- package/src/v2/states.tsx +0 -271
- package/src/v2/status-pill.tsx +0 -74
- package/src/v2/theme.css +0 -1861
- package/src/v2/timeline.tsx +0 -81
- 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
|
|
25
|
-
*
|
|
26
|
-
* mode" placeholder
|
|
27
|
-
*
|
|
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 {
|
|
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.
|
|
94
|
-
*
|
|
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
|
|
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={{
|
|
350
|
+
className="flex flex-col p-4 sm:p-6"
|
|
351
|
+
style={{ gap: 'var(--space-4)' }}
|
|
372
352
|
>
|
|
373
353
|
{body}
|
|
374
354
|
</div>
|
package/src/code-block.tsx
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
import
|
|
27
|
-
|
|
28
|
-
import
|
|
29
|
-
import
|
|
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
|
|