@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.
- package/README.md +28 -0
- package/package.json +53 -0
- package/src/accordion.tsx +153 -0
- package/src/atoms.tsx +197 -0
- package/src/badge.tsx +91 -0
- package/src/brand-logo-tile.tsx +200 -0
- package/src/bulk-action-bar.tsx +104 -0
- package/src/button-group.tsx +46 -0
- package/src/button.tsx +93 -0
- package/src/calculator-shell-v2.tsx +380 -0
- package/src/calculator-shell.tsx +310 -0
- package/src/card.tsx +369 -0
- package/src/checkbox.tsx +114 -0
- package/src/chip-nav.tsx +347 -0
- package/src/choropleth.tsx +62 -0
- package/src/code-block.tsx +235 -0
- package/src/completion-ring.tsx +119 -0
- package/src/copy-field.tsx +169 -0
- package/src/data-grid/column-options-menu.tsx +127 -0
- package/src/data-grid/data-grid.tsx +391 -0
- package/src/data-grid/drawer-panel.tsx +146 -0
- package/src/data-grid/index.ts +29 -0
- package/src/data-grid/sortable-parts.tsx +215 -0
- package/src/data-grid/table-body.tsx +238 -0
- package/src/data-grid/table-elements.tsx +135 -0
- package/src/data-grid/table-header.tsx +95 -0
- package/src/data-grid/table-overlays.tsx +78 -0
- package/src/data-grid/table-parts.tsx +15 -0
- package/src/data-grid/table-summary-footer.tsx +101 -0
- package/src/data-grid/table-view.tsx +176 -0
- package/src/data-grid/types.ts +141 -0
- package/src/data-grid/use-grid-columns.ts +311 -0
- package/src/data-grid/use-grid-editing.ts +154 -0
- package/src/device-frame.tsx +274 -0
- package/src/dropdown-button.tsx +221 -0
- package/src/dropdown-menu.tsx +450 -0
- package/src/empty-state.tsx +85 -0
- package/src/file-upload/file-drop-zone.tsx +156 -0
- package/src/file-upload/file-preview-grid.tsx +39 -0
- package/src/file-upload/file-preview-item.tsx +175 -0
- package/src/file-upload/file-thumbnail.tsx +98 -0
- package/src/file-upload/file-upload-button.tsx +95 -0
- package/src/file-upload/index.ts +13 -0
- package/src/file-upload/use-file-upload.ts +371 -0
- package/src/filter-bar.tsx +125 -0
- package/src/filter-dropdown.tsx +289 -0
- package/src/floating-drawer.tsx +420 -0
- package/src/folder-card.tsx +188 -0
- package/src/folder-tree-picker.tsx +511 -0
- package/src/format.ts +117 -0
- package/src/frame-stack.tsx +73 -0
- package/src/hero-form-card.tsx +471 -0
- package/src/index.ts +372 -0
- package/src/input.tsx +130 -0
- package/src/kbd.tsx +41 -0
- package/src/lib/layout.ts +22 -0
- package/src/lib/utils.ts +6 -0
- package/src/markdown.tsx +214 -0
- package/src/menu-button.tsx +142 -0
- package/src/meta-previews/CreativeMedia.tsx +119 -0
- package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
- package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
- package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
- package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
- package/src/meta-previews/MetaFeedPreview.tsx +294 -0
- package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
- package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
- package/src/meta-previews/MetaReelPreview.tsx +750 -0
- package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
- package/src/meta-previews/MetaSearchPreview.tsx +113 -0
- package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
- package/src/meta-previews/ProfileAvatar.tsx +79 -0
- package/src/meta-previews/defaults.ts +27 -0
- package/src/meta-previews/index.ts +37 -0
- package/src/meta-previews/types.ts +219 -0
- package/src/metric-card.tsx +117 -0
- package/src/modal.tsx +131 -0
- package/src/option-tile.tsx +252 -0
- package/src/page-hero.tsx +78 -0
- package/src/page-shell.tsx +440 -0
- package/src/pagination.tsx +86 -0
- package/src/pill-button.tsx +103 -0
- package/src/profile-section.tsx +389 -0
- package/src/progress-bar.tsx +84 -0
- package/src/radio.tsx +172 -0
- package/src/range-slider.tsx +161 -0
- package/src/resizable.tsx +282 -0
- package/src/search-bar.tsx +81 -0
- package/src/segmented-pill.tsx +143 -0
- package/src/shell.tsx +464 -0
- package/src/slider.tsx +107 -0
- package/src/sortable-table.tsx +198 -0
- package/src/sortable.tsx +137 -0
- package/src/stat-row.tsx +132 -0
- package/src/stats-grid.tsx +38 -0
- package/src/step-loader.tsx +141 -0
- package/src/styles.css +562 -0
- package/src/tabs.tsx +143 -0
- package/src/tag-chip-input.tsx +394 -0
- package/src/textarea.tsx +39 -0
- package/src/toast.tsx +59 -0
- 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
|
+
}
|