@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
package/src/format.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Number-formatting helpers — small set of pure, zero-dep utilities
3
+ * shared by the `<CalculatorShell>` consumers (ROAS / ROI / CTR / CPC
4
+ * / CPM / Conversion Rate and the future ad-performance + customer/
5
+ * business calculators).
6
+ *
7
+ * Locked by ADR-030 D6 (the helpers' existence + signatures) and
8
+ * sharpened by ADR-058 D8 (re-exported from `@lovett/ui` alongside the
9
+ * promoted `<CalculatorShell>`). Implementation rules:
10
+ *
11
+ * • Garbage in → `'—'` out. NaN, ±Infinity, null, undefined all
12
+ * produce the em-dash string. Calculator UI should never crash on
13
+ * partial input (empty inputs, zero divisors).
14
+ * • Uses `Intl.NumberFormat('en-US', ...)` under the hood — proper
15
+ * locale-aware separators, no manual `toFixed` rounding.
16
+ * • `formatPercent` expects PERCENT UNITS, not a 0–1 fraction. Pass
17
+ * `2.45` to get `"2.45%"`. Passing `0.0245` would yield `"0.02%"`.
18
+ *
19
+ * Zero runtime deps — `Intl.NumberFormat` is a built-in.
20
+ */
21
+
22
+ const EM_DASH = '—'
23
+
24
+ function invalid(n: unknown): boolean {
25
+ return (
26
+ typeof n !== 'number' ||
27
+ Number.isNaN(n) ||
28
+ !Number.isFinite(n)
29
+ )
30
+ }
31
+
32
+ interface FormatOptions {
33
+ /** Decimal places. Defaults vary per helper (see each helper's JSDoc). */
34
+ decimals?: number
35
+ }
36
+
37
+ /**
38
+ * Format a number as USD currency.
39
+ *
40
+ * formatCurrency(42.5) → "$42.50"
41
+ * formatCurrency(1234567.89) → "$1,234,567.89"
42
+ * formatCurrency(NaN) → "—"
43
+ *
44
+ * Default decimals: 2.
45
+ */
46
+ export function formatCurrency(n: number, opts: FormatOptions = {}): string {
47
+ if (invalid(n)) return EM_DASH
48
+ const decimals = opts.decimals ?? 2
49
+ return new Intl.NumberFormat('en-US', {
50
+ style: 'currency',
51
+ currency: 'USD',
52
+ minimumFractionDigits: decimals,
53
+ maximumFractionDigits: decimals,
54
+ }).format(n)
55
+ }
56
+
57
+ /**
58
+ * Format a number as a percent. Input is in PERCENT UNITS (i.e. pass
59
+ * `2.45` to get `"2.45%"`, NOT `0.0245`). This matches the way the
60
+ * calculator routes compute their results (e.g. CTR = clicks ÷
61
+ * impressions × 100 — the × 100 already happened upstream).
62
+ *
63
+ * formatPercent(2.45) → "2.45%"
64
+ * formatPercent(3.2, { decimals: 1 }) → "3.2%"
65
+ * formatPercent(NaN) → "—"
66
+ *
67
+ * Default decimals: 2.
68
+ */
69
+ export function formatPercent(n: number, opts: FormatOptions = {}): string {
70
+ if (invalid(n)) return EM_DASH
71
+ const decimals = opts.decimals ?? 2
72
+ // Use 'decimal' style with manual '%' suffix rather than 'percent' —
73
+ // 'percent' would re-multiply by 100, which is the wrong contract for
74
+ // this helper (see JSDoc above).
75
+ const body = new Intl.NumberFormat('en-US', {
76
+ minimumFractionDigits: decimals,
77
+ maximumFractionDigits: decimals,
78
+ }).format(n)
79
+ return `${body}%`
80
+ }
81
+
82
+ /**
83
+ * Format a number as a ratio with the `x` suffix. Used by ROAS.
84
+ *
85
+ * formatRatio(4.25) → "4.25x"
86
+ * formatRatio(10, { decimals: 0 }) → "10x"
87
+ * formatRatio(Infinity) → "—"
88
+ *
89
+ * Default decimals: 2.
90
+ */
91
+ export function formatRatio(n: number, opts: FormatOptions = {}): string {
92
+ if (invalid(n)) return EM_DASH
93
+ const decimals = opts.decimals ?? 2
94
+ const body = new Intl.NumberFormat('en-US', {
95
+ minimumFractionDigits: decimals,
96
+ maximumFractionDigits: decimals,
97
+ }).format(n)
98
+ return `${body}x`
99
+ }
100
+
101
+ /**
102
+ * Format a number with thousand-separators and configurable decimals.
103
+ *
104
+ * formatNumber(1234567) → "1,234,567"
105
+ * formatNumber(1234.567, { decimals: 2 }) → "1,234.57"
106
+ * formatNumber(NaN) → "—"
107
+ *
108
+ * Default decimals: 0.
109
+ */
110
+ export function formatNumber(n: number, opts: FormatOptions = {}): string {
111
+ if (invalid(n)) return EM_DASH
112
+ const decimals = opts.decimals ?? 0
113
+ return new Intl.NumberFormat('en-US', {
114
+ minimumFractionDigits: decimals,
115
+ maximumFractionDigits: decimals,
116
+ }).format(n)
117
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * FrameStack — slate outer-frame wrapper for the shell/tray pattern.
3
+ *
4
+ * Single purpose: wrap 2+ inner cards as ONE visual group. Renders a
5
+ * slate-tinted outer frame (--card-frame-bg, --border-card) holding
6
+ * the children with 10 px outer padding and 10 px gap between them.
7
+ *
8
+ * ┌─────────────────────────────────┐ ← <FrameStack>
9
+ * │ ┌─────────────────────────────┐ │ slate bg (--card-frame-bg)
10
+ * │ │ Card / SectionCard 01 │ │ 10 px padding, 10 px gap
11
+ * │ └─────────────────────────────┘ │ 1 px --border-card outline
12
+ * │ ┌─────────────────────────────┐ │ --radius-xl (20 px) outer
13
+ * │ │ Card / SectionCard 02 │ │
14
+ * │ └─────────────────────────────┘ │ ← child Card (tray)
15
+ * │ ┌─────────────────────────────┐ │ white bg, 1 px border,
16
+ * │ │ Card / SectionCard 03 │ │ --radius-lg (16 px)
17
+ * │ └─────────────────────────────┘ │
18
+ * └─────────────────────────────────┘
19
+ *
20
+ * Why a dedicated primitive instead of <Shell>:
21
+ * <Shell> is overloaded in the workspace ("the route shell" / "the
22
+ * primitive" / "the data-framed div"). When a directive says "use a
23
+ * shell" or "no shell," <Shell> consumers can't reliably tell which
24
+ * one is meant. FrameStack does exactly one job, named clearly. If
25
+ * you want this pattern, wrap. If you don't, don't.
26
+ *
27
+ * Anti-pattern (DO NOT replicate): rendering N <Card> / <SectionCard>
28
+ * elements as direct page-background children with `gap-3`. That's
29
+ * "free-floating cards," not the shell/tray pattern. Wrap them.
30
+ *
31
+ * Lens-level lock: `apps/workspace/src/lenses/meta-creative/CLAUDE.md`
32
+ * §"The shell/tray pattern" — the Meta Creative Builder uses this
33
+ * primitive for the 3-section form.
34
+ *
35
+ * Token discipline: internal tokens (--card-frame-bg, --border-card)
36
+ * are touched HERE per ADR-006 D2 — primitives live under
37
+ * `packages/ui/` and may reference internal tokens.
38
+ */
39
+
40
+ import { forwardRef, type HTMLAttributes } from 'react'
41
+ import { cn } from './lib/utils'
42
+
43
+ export interface FrameStackProps extends HTMLAttributes<HTMLDivElement> {
44
+ /** Visual gap between child cards. Defaults to 10 px to match the
45
+ * prototype's spec. Override only with a token value
46
+ * (e.g. `'var(--space-3)'` for 12 px) — never a literal. */
47
+ gap?: string
48
+ }
49
+
50
+ export const FrameStack = forwardRef<HTMLDivElement, FrameStackProps>(
51
+ function FrameStack({ className, style, gap = '10px', children, ...rest }, ref) {
52
+ return (
53
+ <div
54
+ ref={ref}
55
+ data-framed="true"
56
+ className={cn('flex flex-col', className)}
57
+ style={{
58
+ background: 'rgb(var(--card-frame-bg))',
59
+ border: '1px solid rgb(var(--border-card))',
60
+ borderRadius: 'var(--radius-xl)',
61
+ padding: '10px',
62
+ gap,
63
+ ...style,
64
+ }}
65
+ {...rest}
66
+ >
67
+ {children}
68
+ </div>
69
+ )
70
+ },
71
+ )
72
+
73
+ FrameStack.displayName = 'FrameStack'
@@ -0,0 +1,471 @@
1
+ /**
2
+ * HeroFormCard — centered hero-layout form scaffold.
3
+ *
4
+ * Shape:
5
+ *
6
+ * ┌──────────────────────────────────────────────────┐
7
+ * │ EYEBROW (uppercase, accent) │ ← header (outside card)
8
+ * │ Title │
9
+ * │ Subtitle / lede (muted) │
10
+ * ├──────────────────────────────────────────────────┤
11
+ * │ ╭──────────────────────────────────────────────╮ │
12
+ * │ │ Section title (optional icon) │ │ ← card body
13
+ * │ │ ┌────────────────────────────────────────┐ │ │
14
+ * │ │ │ Field label *required │ │ │
15
+ * │ │ │ <Input /> │ │ │
16
+ * │ │ └────────────────────────────────────────┘ │ │
17
+ * │ │ ... │ │
18
+ * │ ╰──────────────────────────────────────────────╯ │
19
+ * │ helper text (optional, muted, centered) │
20
+ * ├──────────────────────────────────────────────────┤
21
+ * │ Cancel Primary │ ← footer (outside card)
22
+ * └──────────────────────────────────────────────────┘
23
+ *
24
+ * Compound sub-components:
25
+ * • HeroFormCard.Section — labeled section with optional icon slot
26
+ * • HeroFormCard.Field — labeled field row with required / optional
27
+ * chip + error text
28
+ * • HeroFormCard.Advanced — collapsible <details> styled to match
29
+ * the rest of the card
30
+ * • HeroFormCard.Footer — action cluster (right-aligned)
31
+ * • HeroFormCard.Banner — informational strip above the body (e.g.,
32
+ * "From Quick Research · 80 keywords")
33
+ *
34
+ * Icon-agnostic: the primitive does NOT import or hard-code any
35
+ * specific icons. Consumers pass any Lucide (or custom) ReactNode as
36
+ * `icon` props on Section / Field / Banner.
37
+ *
38
+ * Token discipline: every spacing, radius, color, font-weight derives
39
+ * from a CSS variable in tokens.css. No hex literals.
40
+ *
41
+ * First consumers: NewSetPage + NewGroupPage. Designed to also fit the
42
+ * SEM Spec input lens — same hero-card pattern with a different body.
43
+ */
44
+
45
+ import {
46
+ forwardRef,
47
+ type FormEvent,
48
+ type FormHTMLAttributes,
49
+ type HTMLAttributes,
50
+ type ReactNode,
51
+ } from 'react'
52
+ import { cn } from './lib/utils'
53
+ import { Shell } from './shell'
54
+
55
+ // ─── HeroFormCard root ──────────────────────────────────────────────
56
+
57
+ export interface HeroFormCardProps
58
+ extends Omit<FormHTMLAttributes<HTMLFormElement>, 'title' | 'onSubmit'> {
59
+ /** Small accent text above the title, e.g. "Brand · Research". */
60
+ eyebrow?: ReactNode
61
+ /** The page-level title. Required. */
62
+ title: ReactNode
63
+ /** Supporting copy under the title. */
64
+ subtitle?: ReactNode
65
+ /** Optional helper text rendered between the body and the footer
66
+ * (centered, muted). Good for "Name is required · everything else
67
+ * is optional" affordances. */
68
+ helperText?: ReactNode
69
+ /** Body — typically composed of Section + Field. */
70
+ children: ReactNode
71
+ /** Footer action cluster (right-aligned). */
72
+ footer?: ReactNode
73
+ /** Container max-width in px (default 720). */
74
+ maxWidth?: number
75
+ /** When true the wrapping element is a <form> with onSubmit attached
76
+ * (default). When false, renders a <div> — useful when an outer form
77
+ * owns submission. */
78
+ asForm?: boolean
79
+ /** Form submit handler. Ignored when `asForm` is false. */
80
+ onSubmit?: (event: FormEvent<HTMLFormElement>) => void
81
+ className?: string
82
+ }
83
+
84
+ function HeroFormCard({
85
+ eyebrow,
86
+ title,
87
+ subtitle,
88
+ helperText,
89
+ children,
90
+ footer,
91
+ maxWidth = 720,
92
+ asForm = true,
93
+ onSubmit,
94
+ className,
95
+ ...rest
96
+ }: HeroFormCardProps) {
97
+ const bodyContent = (
98
+ <>
99
+ <div className="flex flex-col gap-2 pt-4 pb-7">
100
+ {eyebrow && (
101
+ <div
102
+ className="text-[11px] font-bold uppercase tracking-[0.12em]"
103
+ style={{ color: 'rgb(var(--accent))' }}
104
+ >
105
+ {eyebrow}
106
+ </div>
107
+ )}
108
+ <h1
109
+ className="text-[26px] font-bold tracking-[-0.02em] leading-tight"
110
+ style={{ color: 'rgb(var(--foreground))' }}
111
+ >
112
+ {title}
113
+ </h1>
114
+ {subtitle && (
115
+ <p
116
+ className="text-[14px] leading-[1.5] mt-1"
117
+ style={{
118
+ color: 'rgb(var(--text-tertiary))',
119
+ maxWidth: 'var(--prose-wide)',
120
+ }}
121
+ >
122
+ {subtitle}
123
+ </p>
124
+ )}
125
+ </div>
126
+
127
+ <Shell centered={false}>{children}</Shell>
128
+
129
+ {helperText && (
130
+ <p
131
+ className="text-[12px] mt-3"
132
+ style={{ color: 'rgb(var(--text-tertiary))' }}
133
+ >
134
+ {helperText}
135
+ </p>
136
+ )}
137
+
138
+ {footer && (
139
+ <div className="flex items-center justify-end gap-2 mt-6">
140
+ {footer}
141
+ </div>
142
+ )}
143
+ </>
144
+ )
145
+
146
+ if (!asForm) {
147
+ return (
148
+ <div
149
+ className={cn('mx-auto w-full', className)}
150
+ style={{ maxWidth }}
151
+ >
152
+ {bodyContent}
153
+ </div>
154
+ )
155
+ }
156
+
157
+ return (
158
+ <form
159
+ onSubmit={onSubmit}
160
+ className={cn('mx-auto w-full', className)}
161
+ style={{ maxWidth }}
162
+ {...rest}
163
+ >
164
+ {bodyContent}
165
+ </form>
166
+ )
167
+ }
168
+
169
+ // ─── HeroFormCard.Section ───────────────────────────────────────────
170
+
171
+ export interface HeroFormCardSectionProps
172
+ extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
173
+ /** Section heading. Optional — omit for an untitled body. */
174
+ title?: ReactNode
175
+ /** Optional icon node (Lucide or custom). Sized by the consumer. */
176
+ icon?: ReactNode
177
+ /** Optional supporting copy under the section title. */
178
+ description?: ReactNode
179
+ /** Optional content rendered to the RIGHT of the section title row.
180
+ * Good for inline helper text ("Name is required · everything else
181
+ * is optional") or action chips. */
182
+ trailing?: ReactNode
183
+ children: ReactNode
184
+ }
185
+
186
+ function Section({
187
+ title,
188
+ icon,
189
+ description,
190
+ trailing,
191
+ children,
192
+ className,
193
+ ...rest
194
+ }: HeroFormCardSectionProps) {
195
+ // `icon` is accepted by the API for back-compat but not used in the
196
+ // shell-tray rendering — the prototype's tray headers are
197
+ // icon-free. Surface lint-friendly by void-ing the binding.
198
+ void icon
199
+
200
+ const hasHeader = Boolean(title || description || trailing)
201
+
202
+ return (
203
+ <Shell.Tray className={className} {...rest}>
204
+ {hasHeader && (
205
+ <Shell.TrayHeader
206
+ title={title ?? ''}
207
+ {...(trailing ? { meta: trailing } : {})}
208
+ {...(description ? { description } : {})}
209
+ />
210
+ )}
211
+ <Shell.TrayBody spacing="form">{children}</Shell.TrayBody>
212
+ </Shell.Tray>
213
+ )
214
+ }
215
+
216
+ // ─── HeroFormCard.Field ─────────────────────────────────────────────
217
+
218
+ export interface HeroFormCardFieldProps {
219
+ /** Visible field label. */
220
+ label: ReactNode
221
+ /** HTML id of the underlying control — wires <label htmlFor>. */
222
+ htmlFor?: string
223
+ required?: boolean
224
+ optional?: boolean
225
+ /** Inline error string. Renders below the field. */
226
+ error?: string | null
227
+ /** Optional helper hint below the field (when no error). */
228
+ hint?: ReactNode
229
+ children: ReactNode
230
+ className?: string
231
+ }
232
+
233
+ function Field({
234
+ label,
235
+ htmlFor,
236
+ required,
237
+ optional,
238
+ error,
239
+ hint,
240
+ children,
241
+ className,
242
+ }: HeroFormCardFieldProps) {
243
+ return (
244
+ <div className={cn('flex flex-col gap-1.5', className)}>
245
+ <label
246
+ htmlFor={htmlFor}
247
+ className="text-[12px] font-semibold tracking-[0.02em]"
248
+ style={{ color: 'rgb(var(--foreground))' }}
249
+ >
250
+ {label}
251
+ {required && (
252
+ <span
253
+ className="ml-1"
254
+ style={{ color: 'rgb(var(--destructive))' }}
255
+ aria-hidden="true"
256
+ >
257
+ *
258
+ </span>
259
+ )}
260
+ {optional && (
261
+ <span
262
+ className="ml-1 font-normal"
263
+ style={{ color: 'rgb(var(--text-tertiary))' }}
264
+ >
265
+ (optional)
266
+ </span>
267
+ )}
268
+ </label>
269
+ {children}
270
+ {error ? (
271
+ <p
272
+ className="text-[12px]"
273
+ role="alert"
274
+ style={{ color: 'rgb(var(--destructive))' }}
275
+ >
276
+ {error}
277
+ </p>
278
+ ) : hint ? (
279
+ <p
280
+ className="text-[12px]"
281
+ style={{ color: 'rgb(var(--text-tertiary))' }}
282
+ >
283
+ {hint}
284
+ </p>
285
+ ) : null}
286
+ </div>
287
+ )
288
+ }
289
+
290
+ // ─── HeroFormCard.FieldRow ──────────────────────────────────────────
291
+ // Two-up grid for side-by-side fields. Collapses to single column on
292
+ // narrow viewports.
293
+
294
+ function FieldRow({
295
+ children,
296
+ className,
297
+ ...rest
298
+ }: HTMLAttributes<HTMLDivElement>) {
299
+ return (
300
+ <div
301
+ className={cn('grid grid-cols-1 sm:grid-cols-2 gap-4', className)}
302
+ {...rest}
303
+ >
304
+ {children}
305
+ </div>
306
+ )
307
+ }
308
+
309
+ // ─── HeroFormCard.Banner ────────────────────────────────────────────
310
+
311
+ export interface HeroFormCardBannerProps {
312
+ /** Optional leading icon. */
313
+ icon?: ReactNode
314
+ /** Body content — text + optional inline marks. */
315
+ children: ReactNode
316
+ /** Visual tone. Default `info`. */
317
+ tone?: 'info' | 'accent' | 'success' | 'warning' | 'destructive'
318
+ className?: string
319
+ }
320
+
321
+ const BANNER_TONE: Record<
322
+ NonNullable<HeroFormCardBannerProps['tone']>,
323
+ { bg: string; border: string; icon: string }
324
+ > = {
325
+ info: {
326
+ bg: 'rgb(var(--surface-overlay-soft))',
327
+ border: 'rgb(var(--border))',
328
+ icon: 'rgb(var(--text-secondary))',
329
+ },
330
+ accent: {
331
+ bg: 'rgb(var(--accent-subtle))',
332
+ border: 'rgb(var(--accent-muted))',
333
+ icon: 'rgb(var(--accent))',
334
+ },
335
+ success: {
336
+ bg: 'rgb(var(--success-bg))',
337
+ border: 'rgb(var(--success))',
338
+ icon: 'rgb(var(--success))',
339
+ },
340
+ warning: {
341
+ bg: 'rgb(var(--warning-bg))',
342
+ border: 'rgb(var(--warning))',
343
+ icon: 'rgb(var(--warning))',
344
+ },
345
+ destructive: {
346
+ bg: 'rgb(var(--surface-overlay-soft))',
347
+ border: 'rgb(var(--ring-error))',
348
+ icon: 'rgb(var(--destructive))',
349
+ },
350
+ }
351
+
352
+ function Banner({
353
+ icon,
354
+ children,
355
+ tone = 'info',
356
+ className,
357
+ }: HeroFormCardBannerProps) {
358
+ const colors = BANNER_TONE[tone]
359
+ return (
360
+ <div
361
+ className={cn('flex items-start gap-3 p-3', className)}
362
+ style={{
363
+ background: colors.bg,
364
+ border: `1px solid ${colors.border}`,
365
+ borderRadius: 'var(--radius-md)',
366
+ }}
367
+ >
368
+ {icon && (
369
+ <span
370
+ className="inline-flex shrink-0 mt-0.5"
371
+ style={{ color: colors.icon }}
372
+ aria-hidden="true"
373
+ >
374
+ {icon}
375
+ </span>
376
+ )}
377
+ <div
378
+ className="text-[13px] leading-[1.45]"
379
+ style={{ color: 'rgb(var(--text-secondary))' }}
380
+ >
381
+ {children}
382
+ </div>
383
+ </div>
384
+ )
385
+ }
386
+
387
+ // ─── HeroFormCard.Advanced ──────────────────────────────────────────
388
+ // Collapsible "Advanced options" disclosure — native <details> styled
389
+ // to match the card chrome.
390
+
391
+ export interface HeroFormCardAdvancedProps {
392
+ summary?: ReactNode
393
+ /** Optional icon node next to the summary. */
394
+ icon?: ReactNode
395
+ defaultOpen?: boolean
396
+ children: ReactNode
397
+ className?: string
398
+ }
399
+
400
+ function Advanced({
401
+ summary = 'Advanced options',
402
+ icon,
403
+ defaultOpen,
404
+ children,
405
+ className,
406
+ }: HeroFormCardAdvancedProps) {
407
+ return (
408
+ <details
409
+ open={defaultOpen}
410
+ className={cn('group flex flex-col gap-3', className)}
411
+ >
412
+ <summary
413
+ className="flex items-center gap-1.5 cursor-pointer select-none text-[12px] font-semibold uppercase tracking-[0.08em] list-none [&::-webkit-details-marker]:hidden"
414
+ style={{ color: 'rgb(var(--text-muted))' }}
415
+ >
416
+ {icon && (
417
+ <span className="inline-flex shrink-0" aria-hidden="true">
418
+ {icon}
419
+ </span>
420
+ )}
421
+ <span>{summary}</span>
422
+ </summary>
423
+ <div className="flex flex-col gap-4 pt-1">{children}</div>
424
+ </details>
425
+ )
426
+ }
427
+
428
+ // ─── HeroFormCard.Footer ────────────────────────────────────────────
429
+ // Footer rendering is folded into the root layout via the `footer`
430
+ // prop, but consumers sometimes want to render the footer cluster
431
+ // inline (e.g., right below a banner for inline confirmation flows).
432
+ // Footer compound provides the same styling for that use case.
433
+
434
+ function Footer({
435
+ children,
436
+ className,
437
+ ...rest
438
+ }: HTMLAttributes<HTMLDivElement>) {
439
+ return (
440
+ <div
441
+ className={cn('flex items-center justify-end gap-2', className)}
442
+ {...rest}
443
+ >
444
+ {children}
445
+ </div>
446
+ )
447
+ }
448
+
449
+ // ─── Compound binding ───────────────────────────────────────────────
450
+
451
+ const HeroFormCardCompound = Object.assign(
452
+ forwardRef<HTMLFormElement, HeroFormCardProps>(
453
+ function HeroFormCardForwardRef(props, _ref) {
454
+ // _ref is intentionally unused — no consumer needs focus-management
455
+ // on the <form> yet. forwardRef is kept on the public surface so a
456
+ // ref can be wired later without a breaking API change. React requires
457
+ // the two-parameter signature even when ref is not forwarded.
458
+ return <HeroFormCard {...props} />
459
+ },
460
+ ),
461
+ {
462
+ Section,
463
+ Field,
464
+ FieldRow,
465
+ Banner,
466
+ Advanced,
467
+ Footer,
468
+ },
469
+ )
470
+
471
+ export { HeroFormCardCompound as HeroFormCard }