@lovett/ui 0.0.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 (102) hide show
  1. package/README.md +28 -0
  2. package/package.json +53 -0
  3. package/src/accordion.tsx +153 -0
  4. package/src/atoms.tsx +197 -0
  5. package/src/badge.tsx +91 -0
  6. package/src/brand-logo-tile.tsx +200 -0
  7. package/src/bulk-action-bar.tsx +104 -0
  8. package/src/button-group.tsx +46 -0
  9. package/src/button.tsx +93 -0
  10. package/src/calculator-shell-v2.tsx +380 -0
  11. package/src/calculator-shell.tsx +310 -0
  12. package/src/card.tsx +369 -0
  13. package/src/checkbox.tsx +114 -0
  14. package/src/chip-nav.tsx +347 -0
  15. package/src/choropleth.tsx +62 -0
  16. package/src/code-block.tsx +235 -0
  17. package/src/completion-ring.tsx +119 -0
  18. package/src/copy-field.tsx +169 -0
  19. package/src/data-grid/column-options-menu.tsx +127 -0
  20. package/src/data-grid/data-grid.tsx +391 -0
  21. package/src/data-grid/drawer-panel.tsx +146 -0
  22. package/src/data-grid/index.ts +29 -0
  23. package/src/data-grid/sortable-parts.tsx +215 -0
  24. package/src/data-grid/table-body.tsx +238 -0
  25. package/src/data-grid/table-elements.tsx +135 -0
  26. package/src/data-grid/table-header.tsx +95 -0
  27. package/src/data-grid/table-overlays.tsx +78 -0
  28. package/src/data-grid/table-parts.tsx +15 -0
  29. package/src/data-grid/table-summary-footer.tsx +101 -0
  30. package/src/data-grid/table-view.tsx +176 -0
  31. package/src/data-grid/types.ts +141 -0
  32. package/src/data-grid/use-grid-columns.ts +311 -0
  33. package/src/data-grid/use-grid-editing.ts +154 -0
  34. package/src/device-frame.tsx +274 -0
  35. package/src/dropdown-button.tsx +221 -0
  36. package/src/dropdown-menu.tsx +450 -0
  37. package/src/empty-state.tsx +85 -0
  38. package/src/file-upload/file-drop-zone.tsx +156 -0
  39. package/src/file-upload/file-preview-grid.tsx +39 -0
  40. package/src/file-upload/file-preview-item.tsx +175 -0
  41. package/src/file-upload/file-thumbnail.tsx +98 -0
  42. package/src/file-upload/file-upload-button.tsx +95 -0
  43. package/src/file-upload/index.ts +13 -0
  44. package/src/file-upload/use-file-upload.ts +371 -0
  45. package/src/filter-bar.tsx +125 -0
  46. package/src/filter-dropdown.tsx +289 -0
  47. package/src/floating-drawer.tsx +420 -0
  48. package/src/folder-card.tsx +188 -0
  49. package/src/folder-tree-picker.tsx +511 -0
  50. package/src/format.ts +117 -0
  51. package/src/frame-stack.tsx +73 -0
  52. package/src/hero-form-card.tsx +471 -0
  53. package/src/index.ts +372 -0
  54. package/src/input.tsx +130 -0
  55. package/src/kbd.tsx +41 -0
  56. package/src/lib/layout.ts +22 -0
  57. package/src/lib/utils.ts +6 -0
  58. package/src/markdown.tsx +214 -0
  59. package/src/menu-button.tsx +142 -0
  60. package/src/meta-previews/CreativeMedia.tsx +119 -0
  61. package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
  62. package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
  63. package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
  64. package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
  65. package/src/meta-previews/MetaFeedPreview.tsx +294 -0
  66. package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
  67. package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
  68. package/src/meta-previews/MetaReelPreview.tsx +750 -0
  69. package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
  70. package/src/meta-previews/MetaSearchPreview.tsx +113 -0
  71. package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
  72. package/src/meta-previews/ProfileAvatar.tsx +79 -0
  73. package/src/meta-previews/defaults.ts +27 -0
  74. package/src/meta-previews/index.ts +37 -0
  75. package/src/meta-previews/types.ts +219 -0
  76. package/src/metric-card.tsx +117 -0
  77. package/src/modal.tsx +131 -0
  78. package/src/option-tile.tsx +252 -0
  79. package/src/page-hero.tsx +78 -0
  80. package/src/page-shell.tsx +440 -0
  81. package/src/pagination.tsx +86 -0
  82. package/src/pill-button.tsx +103 -0
  83. package/src/profile-section.tsx +389 -0
  84. package/src/progress-bar.tsx +84 -0
  85. package/src/radio.tsx +172 -0
  86. package/src/range-slider.tsx +161 -0
  87. package/src/resizable.tsx +282 -0
  88. package/src/search-bar.tsx +81 -0
  89. package/src/segmented-pill.tsx +143 -0
  90. package/src/shell.tsx +464 -0
  91. package/src/slider.tsx +107 -0
  92. package/src/sortable-table.tsx +198 -0
  93. package/src/sortable.tsx +137 -0
  94. package/src/stat-row.tsx +132 -0
  95. package/src/stats-grid.tsx +38 -0
  96. package/src/step-loader.tsx +141 -0
  97. package/src/styles.css +562 -0
  98. package/src/tabs.tsx +143 -0
  99. package/src/tag-chip-input.tsx +394 -0
  100. package/src/textarea.tsx +39 -0
  101. package/src/toast.tsx +59 -0
  102. package/src/tokens.css +579 -0
@@ -0,0 +1,380 @@
1
+ /**
2
+ * CalculatorShell v2 — the superset calculator shell (ADR-076 D1/D4).
3
+ *
4
+ * A presentational shell with two layouts and a flat brand-ink header.
5
+ * It owns NO form state (same contract as v1, ADR-030 D5) — each
6
+ * calculator owns its `useState` and composes the `inputs` / `result`
7
+ * slots from kit pieces (ProfileSection Frames, StatRow, MetricCard,
8
+ * Slider, Input). v1 `<CalculatorShell>` is retained untouched for the
9
+ * existing six math calculators; v2 is the path forward.
10
+ *
11
+ * layout="shell" (default) — a single contained card (radius 24,
12
+ * --shell-shadow): flat header + padded body + bordered footer.
13
+ * For compact calculators.
14
+ * layout="open" — the header becomes a rounded banner and the Frames
15
+ * sit directly on the page in a vertical stack; the footer is
16
+ * borderless/transparent. Scales to long forms (funnels).
17
+ *
18
+ * Header (D4): a flat `--brand-ink` strip with a 46px red-glyph tile, a
19
+ * mono uppercase eyebrow, and an uppercase title whose LAST word renders
20
+ * in `--accent`. A single-word title renders entirely in accent; pass a
21
+ * `[lead, accentWord]` tuple for explicit control. No completion ring.
22
+ *
23
+ * 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.
28
+ *
29
+ * Amendment 1 additions (2026-06-05):
30
+ * - `assumptions` (B) — manual-vs-connected inputs, "directional planning
31
+ * math, not observed performance," and the config/formula version.
32
+ * Rendered as a compact hover popover in the FOOTER (next to the footer
33
+ * note), not a body strip, so honest framing never lengthens the shell.
34
+ * - benchmark placeholder (E) — see above.
35
+ * - `copyReport` (F) — the footer Copy action emits a structured
36
+ * mini-report (inputs + result + StatRow chain + assumptions +
37
+ * benchmark status), not the bare number. Shell-owned so every
38
+ * calculator's Copy is consistent.
39
+ */
40
+
41
+ import { useState, type CSSProperties, type ReactNode } from 'react'
42
+ import { BarChart3, Copy, Info } from 'lucide-react'
43
+ import { Button } from './button'
44
+ import { toast } from './toast'
45
+
46
+ export type CalcLayout = 'shell' | 'open'
47
+
48
+ /**
49
+ * Brand-profile prefill source (ADR-076 D9). Typed now so the data
50
+ * connection is a later drop-in, not a refactor.
51
+ *
52
+ * TODO(data-layer): source = useActiveBrandProfile().brand — prefill
53
+ * audience / CPM / etc. from the active brand (canonical brn_ id).
54
+ */
55
+ export interface CalcPrefill {
56
+ source: 'none' | 'brand'
57
+ values?: Record<string, number>
58
+ }
59
+
60
+ export interface CalculatorShellV2Props {
61
+ /** Mono uppercase eyebrow (e.g. "Streaming TV · Reality Check"). */
62
+ eyebrow: string
63
+ /** Uppercase title. The last whitespace-delimited word renders in
64
+ * `--accent`; a single word renders entirely in accent. Pass a
65
+ * `[lead, accentWord]` tuple for explicit control (D4). */
66
+ title: string | [string, string]
67
+ /** Lucide glyph rendered red inside the header tile. */
68
+ icon: ReactNode
69
+ /** Layout — `'shell'` (compact card) or `'open'` (long-form). */
70
+ layout?: CalcLayout
71
+ /** One or more `<ProfileSection>` Frames composing the inputs. */
72
+ inputs: ReactNode
73
+ /** A `<ProfileSection>` Frame wrapping the result block + StatRow. */
74
+ result: ReactNode
75
+ /** Left-aligned muted footer note (also the data-mode signal, D9). */
76
+ footerNote: ReactNode
77
+ /** Right-aligned footer actions (Reset / Save). The Copy action is
78
+ * shell-owned via `copyReport` (Amendment 1 F) — do not pass a Copy
79
+ * button here. */
80
+ actions?: ReactNode
81
+ /** Assumptions content (Amendment 1 B): manual-vs-connected inputs, the
82
+ * "directional planning math" disclaimer, and the config/formula version.
83
+ * Rendered as a footer hover popover (not a body strip) so it never adds
84
+ * shell height. Omitted when absent. */
85
+ assumptions?: ReactNode
86
+ /** Structured mini-report for the shell-owned Copy action (Amendment 1
87
+ * F). A string, or a function returning one (so it captures live state
88
+ * at click time). When set, the shell renders a Copy button that writes
89
+ * this to the clipboard. */
90
+ copyReport?: string | (() => string)
91
+ /** STUBBED (D9) — typed, not read this ADR. */
92
+ prefill?: CalcPrefill
93
+ /** "vs. benchmark" rail. When absent, the shell renders a muted
94
+ * "Benchmark unavailable in manual mode" placeholder (Amendment 1 E). */
95
+ benchmarkSlot?: ReactNode
96
+ }
97
+
98
+ function HeaderTitle({ title }: { title: string | [string, string] }) {
99
+ const accent: CSSProperties = { color: 'rgb(var(--accent))' }
100
+ if (Array.isArray(title)) {
101
+ const [lead, accentWord] = title
102
+ return (
103
+ <>
104
+ {lead ? `${lead} ` : ''}
105
+ <span style={accent}>{accentWord}</span>
106
+ </>
107
+ )
108
+ }
109
+ const parts = title.trim().split(/\s+/)
110
+ if (parts.length <= 1) {
111
+ return <span style={accent}>{title}</span>
112
+ }
113
+ const last = parts[parts.length - 1]
114
+ const lead = parts.slice(0, -1).join(' ')
115
+ return (
116
+ <>
117
+ {lead} <span style={accent}>{last}</span>
118
+ </>
119
+ )
120
+ }
121
+
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
+ /** Footer assumptions affordance (Amendment 1 B). A compact "Assumptions"
146
+ * trigger that reveals the full disclaimer in a hover/focus popover. The
147
+ * popover is absolutely positioned, so it never lengthens the shell — the
148
+ * honest framing is one hover away, not a permanent strip. The calculator
149
+ * supplies the content. */
150
+ function AssumptionsPopover({ children }: { children: ReactNode }) {
151
+ const [open, setOpen] = useState(false)
152
+ return (
153
+ <span
154
+ className="inline-flex"
155
+ style={{ position: 'relative' }}
156
+ onMouseEnter={() => setOpen(true)}
157
+ onMouseLeave={() => setOpen(false)}
158
+ >
159
+ <button
160
+ type="button"
161
+ aria-label="Planning assumptions"
162
+ onFocus={() => setOpen(true)}
163
+ onBlur={() => setOpen(false)}
164
+ className="inline-flex items-center"
165
+ style={{
166
+ gap: 5,
167
+ fontSize: 12,
168
+ fontWeight: 500,
169
+ color: 'rgb(var(--text-muted))',
170
+ background: 'transparent',
171
+ border: 'none',
172
+ padding: 0,
173
+ cursor: 'help',
174
+ }}
175
+ >
176
+ <Info size={14} />
177
+ Assumptions
178
+ </button>
179
+ <span
180
+ role="note"
181
+ style={{
182
+ position: 'absolute',
183
+ bottom: 'calc(100% + 8px)',
184
+ left: 0,
185
+ width: 320,
186
+ maxWidth: '78vw',
187
+ padding: '12px 14px',
188
+ borderRadius: 'var(--radius-lg)',
189
+ background: 'rgb(var(--bg-card))',
190
+ border: '1px solid rgb(var(--border))',
191
+ boxShadow: 'var(--shadow-lg)',
192
+ color: 'rgb(var(--muted-foreground))',
193
+ fontSize: 11.5,
194
+ lineHeight: 1.5,
195
+ fontWeight: 500,
196
+ opacity: open ? 1 : 0,
197
+ transform: open ? 'translateY(0)' : 'translateY(4px)',
198
+ pointerEvents: open ? 'auto' : 'none',
199
+ transition:
200
+ 'opacity 160ms var(--ease-out), transform 160ms var(--ease-out)',
201
+ zIndex: 20,
202
+ }}
203
+ >
204
+ {children}
205
+ </span>
206
+ </span>
207
+ )
208
+ }
209
+
210
+ export function CalculatorShellV2({
211
+ eyebrow,
212
+ title,
213
+ icon,
214
+ layout = 'shell',
215
+ inputs,
216
+ result,
217
+ footerNote,
218
+ actions,
219
+ assumptions,
220
+ copyReport,
221
+ benchmarkSlot,
222
+ }: CalculatorShellV2Props) {
223
+ const isOpen = layout === 'open'
224
+
225
+ // Shell-owned Copy = structured mini-report (Amendment 1 F). `copyReport`
226
+ // may be a function so it captures live state at click time.
227
+ const handleCopy = async () => {
228
+ const text = typeof copyReport === 'function' ? copyReport() : copyReport
229
+ if (!text) return
230
+ try {
231
+ await navigator.clipboard.writeText(text)
232
+ toast.success('Report copied to clipboard')
233
+ } catch {
234
+ toast.error('Clipboard unavailable')
235
+ }
236
+ }
237
+
238
+ const header = (
239
+ <header
240
+ className="flex items-center"
241
+ style={{
242
+ gap: 16,
243
+ padding: '20px 24px',
244
+ background: 'rgb(var(--brand-ink))',
245
+ borderRadius: isOpen ? 'var(--radius-xl)' : 0,
246
+ }}
247
+ >
248
+ <span
249
+ className="grid place-items-center shrink-0"
250
+ style={{
251
+ width: 46,
252
+ height: 46,
253
+ borderRadius: 'var(--radius-md)',
254
+ background: 'rgb(255 255 255 / 0.07)',
255
+ border: '1px solid rgb(255 255 255 / 0.1)',
256
+ color: 'rgb(var(--accent))',
257
+ }}
258
+ >
259
+ {icon}
260
+ </span>
261
+ <div className="flex-1 min-w-0">
262
+ <div
263
+ className="font-semibold uppercase"
264
+ style={{
265
+ fontFamily: 'var(--font-mono)',
266
+ fontSize: 11,
267
+ letterSpacing: '0.14em',
268
+ color: 'rgb(255 255 255 / 0.5)',
269
+ }}
270
+ >
271
+ {eyebrow}
272
+ </div>
273
+ <h1
274
+ className="font-extrabold uppercase"
275
+ style={{
276
+ margin: '3px 0 0',
277
+ fontSize: 21,
278
+ letterSpacing: '-0.01em',
279
+ color: 'rgb(255 255 255)',
280
+ }}
281
+ >
282
+ <HeaderTitle title={title} />
283
+ </h1>
284
+ </div>
285
+ </header>
286
+ )
287
+
288
+ const footer = (
289
+ <footer
290
+ className="flex items-center"
291
+ style={{
292
+ gap: 12,
293
+ padding: '16px 24px',
294
+ borderTop: isOpen ? 'none' : '1px solid rgb(var(--border))',
295
+ marginTop: isOpen ? 4 : 0,
296
+ background: isOpen ? 'transparent' : 'rgb(var(--bg-card))',
297
+ }}
298
+ >
299
+ <span
300
+ className="flex items-center"
301
+ style={{ gap: 8, minWidth: 0 }}
302
+ >
303
+ <span
304
+ style={{
305
+ fontSize: 12,
306
+ fontWeight: 500,
307
+ color: 'rgb(var(--text-muted))',
308
+ }}
309
+ >
310
+ {footerNote}
311
+ </span>
312
+ {assumptions != null && (
313
+ <>
314
+ <span style={{ color: 'rgb(var(--text-muted))', fontSize: 12 }}>
315
+ ·
316
+ </span>
317
+ <AssumptionsPopover>{assumptions}</AssumptionsPopover>
318
+ </>
319
+ )}
320
+ </span>
321
+ <span style={{ flex: 1 }} />
322
+ {copyReport != null && (
323
+ <Button
324
+ variant="secondary"
325
+ onClick={handleCopy}
326
+ leadingIcon={<Copy size={15} />}
327
+ >
328
+ Copy
329
+ </Button>
330
+ )}
331
+ {actions}
332
+ </footer>
333
+ )
334
+
335
+ const body = (
336
+ <>
337
+ {inputs}
338
+ {result}
339
+ {benchmarkSlot ?? <BenchmarkPlaceholder />}
340
+ </>
341
+ )
342
+
343
+ return (
344
+ <div
345
+ className="flex flex-col items-center"
346
+ style={{ paddingTop: 'var(--space-8)', paddingBottom: 'var(--space-12)' }}
347
+ >
348
+ {isOpen ? (
349
+ <div
350
+ className="flex flex-col"
351
+ style={{ width: 'min(980px, 100%)', gap: 'var(--space-4)' }}
352
+ >
353
+ {header}
354
+ {body}
355
+ {footer}
356
+ </div>
357
+ ) : (
358
+ <main
359
+ style={{
360
+ width: 'min(980px, 100%)',
361
+ background: 'rgb(var(--bg-card))',
362
+ border: '1px solid rgb(var(--border-card))',
363
+ borderRadius: 'var(--radius-2xl)',
364
+ boxShadow: 'var(--shell-shadow)',
365
+ overflow: 'hidden',
366
+ }}
367
+ >
368
+ {header}
369
+ <div
370
+ className="flex flex-col"
371
+ style={{ padding: 'var(--space-6)', gap: 'var(--space-4)' }}
372
+ >
373
+ {body}
374
+ </div>
375
+ {footer}
376
+ </main>
377
+ )}
378
+ </div>
379
+ )
380
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * CalculatorShell — centered, fixed-width layout primitive for
3
+ * marketing / ad-performance / customer-economics calculators.
4
+ *
5
+ * Locked by ADR-030 D2 / D5 / D6 / D8 (the original shape spec) and
6
+ * sharpened by ADR-058 D8 (final API + first 6 consumers). Promoted to
7
+ * `@lovett/ui` with 6 first-day consumers (ROAS / ROI / CTR / CPC /
8
+ * CPM / Conversion Rate) which clears ADR-008 D3's 2+ consumer gate
9
+ * three-times over.
10
+ *
11
+ * Layout (per ADR-030 D2 + D8):
12
+ * <PageShell crumbs={[{label: 'Tools', to: '/tools'}, {label: title}]}>
13
+ * <div max-width: var(--prose-narrow), centered>
14
+ * <header>
15
+ * <h1>{title}</h1>
16
+ * {subtitle && <p>{subtitle}</p>}
17
+ * </header>
18
+ * <div role="group" aria-label="${title} inputs">
19
+ * {inputs}
20
+ * </div>
21
+ * {formula && <div>FORMULA · {formula}</div>}
22
+ * <section aria-live="polite" aria-atomic="true">
23
+ * RESULT · <big>{result}</big>
24
+ * </section>
25
+ * {secondaryStats && <div>{secondaryStats}</div>}
26
+ * {(onReset || copyValue || shareUrl) && <action row>}
27
+ * </div>
28
+ * </PageShell>
29
+ *
30
+ * Interaction model (ADR-030 D5):
31
+ * • No submit button. The result updates live as inputs change.
32
+ * • The shell is presentational — it does NOT own form state. Each
33
+ * calculator route owns its `useState` and computes `result` /
34
+ * `secondaryStats` reactively before passing in.
35
+ * • `aria-live="polite"` on the result region so screen readers
36
+ * announce the value once it stabilises (the user can finish
37
+ * typing without the AT interrupting).
38
+ *
39
+ * Token + visual discipline (ADR-030 D8 / ADR-006):
40
+ * • Result card: --accent-subtle background, --accent-muted border,
41
+ * --accent text. Stands out from the form.
42
+ * • Formula label: --text-tertiary, monospace.
43
+ * • All sizing via --space-* / --radius-* / --ctrl-* tokens. Zero
44
+ * hex literals.
45
+ * • `tabular-nums` font-variant on the result so digit widths don't
46
+ * shift during typing.
47
+ */
48
+
49
+ import { useId, useState, type CSSProperties, type ReactNode } from 'react'
50
+ import { Check, Link2, RotateCcw } from 'lucide-react'
51
+ import PageShell from './page-shell'
52
+ import { Button } from './button'
53
+ import { toast } from './toast'
54
+
55
+ export interface CalculatorShellProps {
56
+ /** Display title (e.g. "ROAS Calculator"). Rendered as H1 + last
57
+ * crumb in <PageShell>. */
58
+ title: string
59
+ /** One-line subtitle below the title. Optional — describes what the
60
+ * calculator computes. */
61
+ subtitle?: string
62
+ /** Form inputs slot. Typically a vertical stack of @lovett/ui
63
+ * <Input> primitives wrapped in <label>s with `type="number"`. */
64
+ inputs: ReactNode
65
+ /** Headline result value — large, accent-tinted, tabular-nums. Pass
66
+ * the FORMATTED string (use `formatCurrency` / `formatPercent` /
67
+ * `formatRatio` / `formatNumber`). NaN inputs surface as `'—'`. */
68
+ result: ReactNode
69
+ /** Optional formula label (e.g. "ROAS = Revenue ÷ Ad Spend"). Small,
70
+ * dim, monospace-feel. Render as a <code> node so monospace +
71
+ * semantics carry. */
72
+ formula?: ReactNode
73
+ /** Optional row of secondary derived stats (smaller, dim). Pass a
74
+ * node containing 1-3 stat tiles or a single inline span. */
75
+ secondaryStats?: ReactNode
76
+ /** Optional reset to defaults. Renders a secondary "Reset" button
77
+ * when provided. */
78
+ onReset?: () => void
79
+ /** Optional copy-result value (the formatted result string). Renders
80
+ * a "Copy result" secondary button when provided. The value is
81
+ * written to the clipboard on click; the button flashes a checkmark
82
+ * + emits a success toast on success. Pass `undefined` to omit the
83
+ * button — typically when the underlying number is NaN. */
84
+ copyValue?: string | undefined
85
+ /** Optional share-link URL — full or path. When provided, renders a
86
+ * "Copy link" secondary button; click writes the URL to the
87
+ * clipboard. (No tinyURL-style encoding here — the consumer
88
+ * pre-builds the URL with encoded inputs.) */
89
+ shareUrl?: string | undefined
90
+ }
91
+
92
+ /**
93
+ * CalculatorShell — the layout primitive. See file-level JSDoc for the
94
+ * locked layout / interaction / token rules.
95
+ */
96
+ export function CalculatorShell({
97
+ title,
98
+ subtitle,
99
+ inputs,
100
+ result,
101
+ formula,
102
+ secondaryStats,
103
+ onReset,
104
+ copyValue,
105
+ shareUrl,
106
+ }: CalculatorShellProps) {
107
+ const inputsGroupId = useId()
108
+ const [copiedResult, setCopiedResult] = useState(false)
109
+ const [copiedShare, setCopiedShare] = useState(false)
110
+
111
+ const hasActions = Boolean(onReset || copyValue || shareUrl)
112
+
113
+ const handleCopy = async (
114
+ value: string,
115
+ setFlag: (b: boolean) => void,
116
+ successMsg: string,
117
+ ) => {
118
+ if (!value) return
119
+ let ok = false
120
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
121
+ try {
122
+ await navigator.clipboard.writeText(value)
123
+ ok = true
124
+ } catch {
125
+ ok = false
126
+ }
127
+ }
128
+ if (ok) {
129
+ setFlag(true)
130
+ setTimeout(() => setFlag(false), 1500)
131
+ toast.success(successMsg)
132
+ } else {
133
+ toast.error('Clipboard unavailable')
134
+ }
135
+ }
136
+
137
+ return (
138
+ <PageShell
139
+ crumbs={[{ label: 'Tools', to: '/tools' }, { label: title }]}
140
+ >
141
+ <div
142
+ className="mx-auto flex flex-col"
143
+ style={{
144
+ maxWidth: 'var(--prose-narrow)',
145
+ gap: 'var(--space-6)',
146
+ paddingTop: 'var(--space-4)',
147
+ paddingBottom: 'var(--space-8)',
148
+ }}
149
+ >
150
+ {/* Header */}
151
+ <header className="flex flex-col" style={{ gap: 'var(--space-2)' }}>
152
+ <h1
153
+ className="text-2xl font-semibold tracking-tight"
154
+ style={{ color: 'rgb(var(--foreground))' }}
155
+ >
156
+ {title}
157
+ </h1>
158
+ {subtitle && (
159
+ <p
160
+ className="text-sm"
161
+ style={{ color: 'rgb(var(--text-secondary))' }}
162
+ >
163
+ {subtitle}
164
+ </p>
165
+ )}
166
+ </header>
167
+
168
+ {/* Inputs */}
169
+ <div
170
+ role="group"
171
+ aria-labelledby={inputsGroupId}
172
+ className="flex flex-col"
173
+ style={{ gap: 'var(--space-4)' }}
174
+ >
175
+ <span id={inputsGroupId} className="sr-only">
176
+ {title} inputs
177
+ </span>
178
+ {inputs}
179
+ </div>
180
+
181
+ {/* Formula (optional) */}
182
+ {formula && (
183
+ <div
184
+ className="flex items-center"
185
+ style={{ gap: 'var(--space-3)' }}
186
+ >
187
+ <span
188
+ className="text-[10px] font-semibold uppercase tracking-[0.08em]"
189
+ style={{ color: 'rgb(var(--text-tertiary))' }}
190
+ >
191
+ Formula
192
+ </span>
193
+ <span
194
+ className="text-sm font-mono"
195
+ style={{ color: 'rgb(var(--text-tertiary))' }}
196
+ >
197
+ {formula}
198
+ </span>
199
+ </div>
200
+ )}
201
+
202
+ {/* Result card */}
203
+ <section
204
+ aria-live="polite"
205
+ aria-atomic="true"
206
+ className="flex flex-col items-center text-center"
207
+ style={
208
+ {
209
+ gap: 'var(--space-2)',
210
+ padding: 'var(--space-6)',
211
+ background: 'rgb(var(--accent-subtle))',
212
+ border: '1px solid rgb(var(--accent-muted))',
213
+ borderRadius: 'var(--radius-lg)',
214
+ } satisfies CSSProperties
215
+ }
216
+ >
217
+ <span
218
+ className="text-[10px] font-semibold uppercase tracking-[0.08em]"
219
+ style={{ color: 'rgb(var(--text-tertiary))' }}
220
+ >
221
+ Result
222
+ </span>
223
+ <div
224
+ className="text-4xl font-semibold"
225
+ style={{
226
+ fontVariantNumeric: 'tabular-nums',
227
+ color: 'rgb(var(--accent))',
228
+ lineHeight: 1.1,
229
+ }}
230
+ >
231
+ {result}
232
+ </div>
233
+ {secondaryStats && (
234
+ <div
235
+ className="flex flex-wrap items-center justify-center"
236
+ style={{
237
+ gap: 'var(--space-3)',
238
+ color: 'rgb(var(--text-tertiary))',
239
+ fontVariantNumeric: 'tabular-nums',
240
+ }}
241
+ >
242
+ {secondaryStats}
243
+ </div>
244
+ )}
245
+ </section>
246
+
247
+ {/* Action row (optional) */}
248
+ {hasActions && (
249
+ <div
250
+ className="flex flex-wrap items-center justify-end"
251
+ style={{ gap: 'var(--space-2)' }}
252
+ >
253
+ {onReset && (
254
+ <Button
255
+ variant="secondary"
256
+ size="sm"
257
+ onClick={onReset}
258
+ leadingIcon={<RotateCcw className="h-4 w-4" />}
259
+ >
260
+ Reset
261
+ </Button>
262
+ )}
263
+ {copyValue && (
264
+ <Button
265
+ variant="secondary"
266
+ size="sm"
267
+ onClick={() =>
268
+ handleCopy(
269
+ copyValue,
270
+ setCopiedResult,
271
+ 'Result copied to clipboard',
272
+ )
273
+ }
274
+ leadingIcon={
275
+ copiedResult ? (
276
+ <Check className="h-4 w-4" />
277
+ ) : undefined
278
+ }
279
+ >
280
+ {copiedResult ? 'Copied' : 'Copy result'}
281
+ </Button>
282
+ )}
283
+ {shareUrl && (
284
+ <Button
285
+ variant="secondary"
286
+ size="sm"
287
+ onClick={() =>
288
+ handleCopy(
289
+ shareUrl,
290
+ setCopiedShare,
291
+ 'Share link copied to clipboard',
292
+ )
293
+ }
294
+ leadingIcon={
295
+ copiedShare ? (
296
+ <Check className="h-4 w-4" />
297
+ ) : (
298
+ <Link2 className="h-4 w-4" />
299
+ )
300
+ }
301
+ >
302
+ {copiedShare ? 'Copied' : 'Copy link'}
303
+ </Button>
304
+ )}
305
+ </div>
306
+ )}
307
+ </div>
308
+ </PageShell>
309
+ )
310
+ }