@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,79 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { CSSProperties } from 'react';
3
+
4
+ interface ProfileAvatarProps {
5
+ /** The pulled Meta page profile image. Empty/missing OR a load failure falls
6
+ * back to the brand-initial letter. */
7
+ src?: string | undefined;
8
+ /** Brand / page name — its first character is the letter fallback. */
9
+ name: string;
10
+ /** Letter-fallback background — a color OR a gradient (platform-specific).
11
+ * Defaults to Meta blue. */
12
+ background?: string;
13
+ /** Letter-fallback text color. Defaults to white. */
14
+ color?: string;
15
+ /** Letter-fallback font size in px. Defaults to 14. */
16
+ fontSize?: number;
17
+ /** Geometry applied to BOTH the image and the letter so they stay identical.
18
+ * Defaults to filling the parent circle (the caller owns the size / radius /
19
+ * ring); pass explicit width/height/margin/borderRadius for avatars that are
20
+ * not a plain parent-fill (e.g. Messenger). */
21
+ style?: CSSProperties;
22
+ }
23
+
24
+ const FILL: CSSProperties = { width: '100%', height: '100%' };
25
+
26
+ /**
27
+ * Unified advertiser avatar for every Meta / Instagram preview. Renders the
28
+ * pulled page profile image (object-cover), falling back to the brand's initial
29
+ * when the image is missing OR fails to load. Centralising the image↔letter
30
+ * logic here is what keeps every preview consistent — no preview can drift to a
31
+ * grey box (Search/Explore did) or an empty circle (a broken CDN URL did).
32
+ */
33
+ export function ProfileAvatar({
34
+ src,
35
+ name,
36
+ background = 'rgb(24, 119, 242)',
37
+ color = 'white',
38
+ fontSize = 14,
39
+ style,
40
+ }: ProfileAvatarProps) {
41
+ const [failed, setFailed] = useState(false);
42
+ // A new src (e.g. brand switch) gets a fresh chance to load.
43
+ useEffect(() => {
44
+ setFailed(false);
45
+ }, [src]);
46
+
47
+ const geom = style ?? FILL;
48
+
49
+ if (src && !failed) {
50
+ return (
51
+ <img
52
+ src={src}
53
+ alt={name}
54
+ style={{ objectFit: 'cover', display: 'block', ...geom }}
55
+ loading="lazy"
56
+ decoding="async"
57
+ onError={() => setFailed(true)}
58
+ />
59
+ );
60
+ }
61
+
62
+ return (
63
+ <div
64
+ aria-label={name}
65
+ style={{
66
+ display: 'flex',
67
+ alignItems: 'center',
68
+ justifyContent: 'center',
69
+ background,
70
+ color,
71
+ fontSize: `${fontSize}px`,
72
+ fontWeight: 600,
73
+ ...geom,
74
+ }}
75
+ >
76
+ {(name.trim().charAt(0) || '?').toUpperCase()}
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,27 @@
1
+ import type { PreviewAdData } from './types'
2
+
3
+ /**
4
+ * Placeholder copy shown in a preview when the corresponding input field is
5
+ * empty — so a fresh ad reads as a complete ad instead of blank gaps. Only
6
+ * fills BLANK fields; any typed value passes through unchanged.
7
+ */
8
+ export const PREVIEW_DEFAULTS = {
9
+ brandName: 'Your Brand',
10
+ primaryText: 'Your primary text will show up here.',
11
+ headline: 'Your headline',
12
+ displayLink: 'yoursite.com',
13
+ callToAction: 'Learn more',
14
+ } as const
15
+
16
+ /** Fill empty `PreviewAdData` copy fields with placeholders (image/media fields
17
+ * are left untouched — those have their own in-component placeholders). */
18
+ export function withPreviewDefaults(ad: PreviewAdData): PreviewAdData {
19
+ return {
20
+ ...ad,
21
+ brandName: ad.brandName || PREVIEW_DEFAULTS.brandName,
22
+ primaryText: ad.primaryText || PREVIEW_DEFAULTS.primaryText,
23
+ headline: ad.headline || PREVIEW_DEFAULTS.headline,
24
+ displayLink: ad.displayLink || PREVIEW_DEFAULTS.displayLink,
25
+ callToAction: ad.callToAction || PREVIEW_DEFAULTS.callToAction,
26
+ }
27
+ }
@@ -0,0 +1,37 @@
1
+ // @lovett/ui/meta-previews — public surface.
2
+ //
3
+ // Promoted from docs/prototype/meta/preview-package/ per ADR-060 D9a
4
+ // (amended 2026-05-28). Pixel-accurate Meta + Instagram + Messenger
5
+ // preview renderers; zero coupling to stores, hooks, routers, or API
6
+ // clients. Designed for portable drop-in reuse across the workspace's
7
+ // Meta-related lenses.
8
+ //
9
+ // API contract: every renderer takes one prop, `adData: PreviewAdData`.
10
+ // Several also accept `device`, `platform`, `format`, or dominant-color
11
+ // tinting hints — see each component's prop signature for specifics.
12
+
13
+ export { CreativeMedia } from './CreativeMedia';
14
+ export { MetaFeedPreview } from './MetaFeedPreview';
15
+ export { MetaStoryPreview } from './MetaStoryPreview';
16
+ export { MetaReelPreview } from './MetaReelPreview';
17
+ export { MetaMessengerPreview } from './MetaMessengerPreview';
18
+ export { MetaRightColumnPreview } from './MetaRightColumnPreview';
19
+ export { MetaSearchPreview } from './MetaSearchPreview';
20
+ export { MetaInstreamPreview } from './MetaInstreamPreview';
21
+ export { InstagramFeedPreview } from './InstagramFeedPreview';
22
+ export { InstagramExplorePreview } from './InstagramExplorePreview';
23
+ export { MetaFeedCarousel } from './MetaFeedCarousel';
24
+ export { InstagramFeedCarousel } from './InstagramFeedCarousel';
25
+
26
+ export type {
27
+ PreviewAdData,
28
+ PreviewPlatform,
29
+ PreviewPlacement,
30
+ PreviewDevice,
31
+ PlacementPreview,
32
+ PreviewConfiguration,
33
+ CarouselCardView,
34
+ CarouselViewProps,
35
+ } from './types';
36
+
37
+ export { PLACEMENT_CONFIGS } from './types';
@@ -0,0 +1,219 @@
1
+ // @lovett/ui — Meta / Instagram platform-preview types.
2
+ //
3
+ // Promoted from docs/prototype/meta/preview-package/types/previews.ts
4
+ // per ADR-060 D9a (amended 2026-05-28). Pixel-accurate FB+IG preview
5
+ // renderers consume `PreviewAdData`; the 10 renderers + this types
6
+ // module + the PLACEMENT_CONFIGS constant comprise the public surface
7
+ // of `@lovett/ui/meta-previews`.
8
+
9
+ import type { ReactNode } from 'react';
10
+
11
+ export type PreviewPlatform = 'facebook' | 'instagram' | 'messenger';
12
+ export type PreviewPlacement =
13
+ | 'feed'
14
+ | 'story'
15
+ | 'reel'
16
+ | 'inbox'
17
+ | 'instream'
18
+ | 'search'
19
+ | 'rightcolumn'
20
+ | 'explore';
21
+ export type PreviewDevice = 'mobile' | 'desktop';
22
+
23
+ export interface PreviewConfiguration {
24
+ platform: PreviewPlatform;
25
+ placement: PreviewPlacement;
26
+ device: PreviewDevice;
27
+ format: '1:1' | '4:5' | '9:16';
28
+ }
29
+
30
+ export interface PreviewAdData {
31
+ adName: string;
32
+ primaryText: string;
33
+ headline: string;
34
+ description: string;
35
+ callToAction: string;
36
+ websiteUrl: string;
37
+ displayLink: string;
38
+ brandName: string;
39
+ profileImage: string;
40
+ creativeImage: string;
41
+ // `| undefined` is explicit so callers may assign an absent media type
42
+ // under the workspace's exactOptionalPropertyTypes tsconfig.
43
+ creativeMediaType?: 'image' | 'video' | undefined;
44
+ }
45
+
46
+ /**
47
+ * A single carousel card for the presentational carousel views (ADR-119 D1).
48
+ * Per-card copy/destination only — the CTA is ad-level/shared (ADR-119 D3).
49
+ */
50
+ export interface CarouselCardView {
51
+ key: string;
52
+ headline?: string | undefined;
53
+ description?: string | undefined;
54
+ url?: string | undefined;
55
+ }
56
+
57
+ /**
58
+ * Props for the pure presentational carousels (`MetaFeedCarousel`,
59
+ * `InstagramFeedCarousel`). They own NO data or crop logic: the caller supplies
60
+ * the card list, the active index + setter, and an image-render slot
61
+ * (`CropFrame` on the controller-driven form side; a plain <img> on static
62
+ * surfaces). Because the view imports neither the controller nor the crop
63
+ * system, it lives in `packages/ui` and earns Meta-exact hex (ADR-119 D1/D5).
64
+ */
65
+ export interface CarouselViewProps {
66
+ cards: CarouselCardView[];
67
+ active: number;
68
+ onActive: (index: number) => void;
69
+ renderImage: (index: number) => ReactNode;
70
+ brandName: string;
71
+ profileImage?: string | undefined;
72
+ primaryText?: string | undefined;
73
+ /** Ad-level CTA, shared across cards (ADR-119 D3). */
74
+ callToAction: string;
75
+ }
76
+
77
+ export interface PlacementPreview {
78
+ id: string;
79
+ platform: PreviewPlatform;
80
+ placement: PreviewPlacement;
81
+ label: string;
82
+ description: string;
83
+ aspectRatio: '1:1' | '4:5' | '9:16';
84
+ device: PreviewDevice;
85
+ enabled: boolean;
86
+ }
87
+
88
+ export const PLACEMENT_CONFIGS: PlacementPreview[] = [
89
+ {
90
+ id: 'fb-feed',
91
+ platform: 'facebook',
92
+ placement: 'feed',
93
+ label: 'Facebook Feed',
94
+ description: 'Standard news feed placement',
95
+ aspectRatio: '1:1',
96
+ device: 'desktop',
97
+ enabled: true,
98
+ },
99
+ {
100
+ id: 'ig-feed',
101
+ platform: 'instagram',
102
+ placement: 'feed',
103
+ label: 'Instagram Feed',
104
+ description: 'Standard Instagram feed',
105
+ aspectRatio: '1:1',
106
+ device: 'mobile',
107
+ enabled: true,
108
+ },
109
+ {
110
+ id: 'fb-rightcolumn',
111
+ platform: 'facebook',
112
+ placement: 'rightcolumn',
113
+ label: 'Facebook Right column',
114
+ description: 'Desktop right column placement',
115
+ aspectRatio: '1:1',
116
+ device: 'desktop',
117
+ enabled: true,
118
+ },
119
+ {
120
+ id: 'fb-feed-mobile',
121
+ platform: 'facebook',
122
+ placement: 'feed',
123
+ label: 'Facebook Feed (Mobile)',
124
+ description: 'Mobile news feed',
125
+ aspectRatio: '4:5',
126
+ device: 'mobile',
127
+ enabled: false,
128
+ },
129
+ {
130
+ id: 'ig-story',
131
+ platform: 'instagram',
132
+ placement: 'story',
133
+ label: 'Instagram Stories',
134
+ description: 'Full-screen vertical stories',
135
+ aspectRatio: '9:16',
136
+ device: 'mobile',
137
+ enabled: true,
138
+ },
139
+ {
140
+ id: 'fb-story',
141
+ platform: 'facebook',
142
+ placement: 'story',
143
+ label: 'Facebook Stories',
144
+ description: 'Full-screen vertical stories',
145
+ aspectRatio: '9:16',
146
+ device: 'mobile',
147
+ enabled: true,
148
+ },
149
+ {
150
+ id: 'messenger-story',
151
+ platform: 'messenger',
152
+ placement: 'story',
153
+ label: 'Messenger Stories',
154
+ description: 'Full-screen vertical stories',
155
+ aspectRatio: '9:16',
156
+ device: 'mobile',
157
+ enabled: true,
158
+ },
159
+ {
160
+ id: 'ig-reel',
161
+ platform: 'instagram',
162
+ placement: 'reel',
163
+ label: 'Instagram Reels',
164
+ description: 'Short-form video placement',
165
+ aspectRatio: '9:16',
166
+ device: 'mobile',
167
+ enabled: true,
168
+ },
169
+ {
170
+ id: 'fb-reel',
171
+ platform: 'facebook',
172
+ placement: 'reel',
173
+ label: 'Facebook Reels',
174
+ description: 'Short-form video placement',
175
+ aspectRatio: '9:16',
176
+ device: 'mobile',
177
+ enabled: true,
178
+ },
179
+ {
180
+ id: 'ig-explore',
181
+ platform: 'instagram',
182
+ placement: 'explore',
183
+ label: 'Instagram Explore home',
184
+ description: 'Explore grid placement',
185
+ aspectRatio: '1:1',
186
+ device: 'mobile',
187
+ enabled: true,
188
+ },
189
+ {
190
+ id: 'messenger-inbox',
191
+ platform: 'messenger',
192
+ placement: 'inbox',
193
+ label: 'Messenger Inbox',
194
+ description: 'Sponsored message in inbox',
195
+ aspectRatio: '1:1',
196
+ device: 'mobile',
197
+ enabled: true,
198
+ },
199
+ {
200
+ id: 'fb-instream',
201
+ platform: 'facebook',
202
+ placement: 'instream',
203
+ label: 'Facebook In-stream videos',
204
+ description: 'Video ads in Facebook Watch and video feeds',
205
+ aspectRatio: '1:1',
206
+ device: 'mobile',
207
+ enabled: true,
208
+ },
209
+ {
210
+ id: 'fb-search',
211
+ platform: 'facebook',
212
+ placement: 'search',
213
+ label: 'Facebook Marketplace Search',
214
+ description: 'Ads in Facebook Marketplace search results',
215
+ aspectRatio: '1:1',
216
+ device: 'mobile',
217
+ enabled: true,
218
+ },
219
+ ];
@@ -0,0 +1,117 @@
1
+ /**
2
+ * MetricCard — KPI tile with large value + label + optional tone variant.
3
+ *
4
+ * Promoted in ADR-020 (Phase 3a Discovery Company Research, first
5
+ * committed consumer). Cross-batch consumers: Phase 3c (admin stats),
6
+ * Phase 8 (results summary), Phase 12 (review-overview summary).
7
+ *
8
+ * Designed for use inside `<StatsGrid>` (also new this batch — see
9
+ * `stats-grid.tsx`) but works standalone in any flex/grid container.
10
+ *
11
+ * Token discipline: all colors via public tokens. Tone variant gives
12
+ * the value text an accent/success/warning/destructive/info color;
13
+ * background is always neutral surface-overlay.
14
+ */
15
+
16
+ import type { ReactNode } from 'react'
17
+
18
+ export type MetricCardTone = 'neutral' | 'accent' | 'success' | 'warning' | 'destructive' | 'info'
19
+
20
+ const TONE_COLOR: Record<MetricCardTone, string> = {
21
+ neutral: 'rgb(var(--foreground))',
22
+ accent: 'rgb(var(--accent))',
23
+ success: 'rgb(var(--success))',
24
+ warning: 'rgb(var(--warning))',
25
+ destructive: 'rgb(var(--destructive))',
26
+ info: 'rgb(var(--info))',
27
+ }
28
+
29
+ export interface MetricCardProps {
30
+ /** Headline value (large, semibold, tone-colored). Numbers should
31
+ * use `font-mono` for tabular alignment when relevant. */
32
+ value: ReactNode
33
+ /** Small descriptive label below the value. */
34
+ label: string
35
+ /** Optional tone — colors the value text. Background stays neutral. */
36
+ tone?: MetricCardTone
37
+ /** Optional icon shown to the left of the value (h-5 w-5 expected). */
38
+ icon?: ReactNode
39
+ /**
40
+ * Where the label sits relative to the value. Default `'bottom'`
41
+ * (value-then-label — the original shape; existing consumers are
42
+ * unchanged). `'top'` renders a small uppercase label above the
43
+ * value — the KPI-chip shape used by Calculator Shell v2 (ADR-076
44
+ * D8). The value size is unchanged in both positions.
45
+ */
46
+ labelPosition?: 'top' | 'bottom'
47
+ /** Optional className override for the outer container. */
48
+ className?: string
49
+ }
50
+
51
+ export function MetricCard({
52
+ value,
53
+ label,
54
+ tone = 'neutral',
55
+ icon,
56
+ labelPosition = 'bottom',
57
+ className,
58
+ }: MetricCardProps) {
59
+ const labelEl =
60
+ labelPosition === 'top' ? (
61
+ <div
62
+ className="text-[10px] font-bold uppercase tracking-[0.05em]"
63
+ style={{ color: 'rgb(var(--text-tertiary))' }}
64
+ >
65
+ {label}
66
+ </div>
67
+ ) : (
68
+ <div
69
+ className="text-[12px]"
70
+ style={{ color: 'rgb(var(--text-tertiary))' }}
71
+ >
72
+ {label}
73
+ </div>
74
+ )
75
+
76
+ const valueEl = (
77
+ <div className="flex items-center gap-2">
78
+ {icon && (
79
+ <span style={{ color: TONE_COLOR[tone] }} className="flex-shrink-0">
80
+ {icon}
81
+ </span>
82
+ )}
83
+ <div
84
+ className="text-[24px] font-semibold tabular-nums"
85
+ style={{ color: TONE_COLOR[tone] }}
86
+ >
87
+ {value}
88
+ </div>
89
+ </div>
90
+ )
91
+
92
+ return (
93
+ <div
94
+ className={
95
+ className ??
96
+ 'flex flex-col gap-1 px-4 py-3 border'
97
+ }
98
+ style={{
99
+ background: 'rgb(var(--surface-overlay-soft))',
100
+ borderColor: 'rgb(var(--border))',
101
+ borderRadius: 'var(--radius-lg)',
102
+ }}
103
+ >
104
+ {labelPosition === 'top' ? (
105
+ <>
106
+ {labelEl}
107
+ {valueEl}
108
+ </>
109
+ ) : (
110
+ <>
111
+ {valueEl}
112
+ {labelEl}
113
+ </>
114
+ )}
115
+ </div>
116
+ )
117
+ }
package/src/modal.tsx ADDED
@@ -0,0 +1,131 @@
1
+ import { useEffect, type HTMLAttributes, type ReactNode } from 'react'
2
+ import { X } from 'lucide-react'
3
+ import { cn } from './lib/utils'
4
+
5
+ /**
6
+ * Modal — a centered floating panel on top of a backdrop. Built on
7
+ * `.ds-card-surface` so it inherits the premium card chrome (border,
8
+ * radius, multi-layer shadow with inner highlight in light mode).
9
+ *
10
+ * Two layout modes:
11
+ *
12
+ * 1. Flat children (back-compat) — pass content directly:
13
+ *
14
+ * <Modal title="..." onClose={...}>
15
+ * <p>body</p>
16
+ * <Button>action</Button>
17
+ * </Modal>
18
+ *
19
+ * 2. Shell / Tray pattern (recommended) — body in a recessed tray,
20
+ * action buttons on the outer-card bottom strip below the tray:
21
+ *
22
+ * <Modal title="..." onClose={...}>
23
+ * <Modal.Tray>
24
+ * <p>body content sits in a recessed shelf</p>
25
+ * <textarea ... />
26
+ * </Modal.Tray>
27
+ * <Modal.Footer>
28
+ * <Button variant="ghost" onClick={onClose}>Cancel</Button>
29
+ * <Button>Confirm</Button>
30
+ * </Modal.Footer>
31
+ * </Modal>
32
+ */
33
+
34
+ interface ModalProps {
35
+ isOpen: boolean
36
+ onClose: () => void
37
+ title: string
38
+ children: ReactNode
39
+ className?: string
40
+ }
41
+
42
+ function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
43
+ useEffect(() => {
44
+ if (!isOpen) return
45
+ const handleEscape = (e: KeyboardEvent) => {
46
+ if (e.key === 'Escape') onClose()
47
+ }
48
+ document.addEventListener('keydown', handleEscape)
49
+ return () => document.removeEventListener('keydown', handleEscape)
50
+ }, [isOpen, onClose])
51
+
52
+ if (!isOpen) return null
53
+
54
+ return (
55
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
56
+ {/* Backdrop */}
57
+ <div
58
+ className="absolute inset-0"
59
+ style={{ background: 'rgb(var(--modal-overlay))' }}
60
+ onClick={onClose}
61
+ />
62
+ {/* Outer card — uses .ds-card-surface so the modal gets the
63
+ same chrome as cards (border + multi-layer shadow + inner
64
+ highlight in light mode). overflow-hidden clips children to
65
+ the rounded corners; the tray inside handles its own scroll. */}
66
+ <div
67
+ data-framed="true"
68
+ className={cn(
69
+ 'ds-card-surface relative w-full max-w-2xl flex flex-col max-h-[90vh] overflow-hidden',
70
+ className,
71
+ )}
72
+ style={{ borderRadius: 'var(--radius-xl)' }}
73
+ >
74
+ {/* Header — title + close. Sits on the outer card surface,
75
+ outside any tray. Shrink-0 so it never collapses when the
76
+ body grows. */}
77
+ <div className="flex items-center justify-between shrink-0 px-6 pt-6 pb-4">
78
+ <h2 className="text-xl font-bold text-[rgb(var(--foreground))]">
79
+ {title}
80
+ </h2>
81
+ <button
82
+ onClick={onClose}
83
+ className="rounded-lg p-1.5 text-[rgb(var(--muted-foreground))] hover:bg-[rgb(var(--muted))] hover:text-[rgb(var(--foreground))] transition-colors"
84
+ aria-label="Close"
85
+ >
86
+ <X className="h-5 w-5" />
87
+ </button>
88
+ </div>
89
+ {children}
90
+ </div>
91
+ </div>
92
+ )
93
+ }
94
+
95
+ /**
96
+ * Modal.Tray — the recessed body panel. Sits inside the modal's outer
97
+ * card with --tray-inset margin and its own bg. Scrolls if content
98
+ * exceeds the modal's max-height.
99
+ */
100
+ function Tray({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
101
+ return (
102
+ <div
103
+ className={cn(
104
+ 'ds-card-tray flex-1 min-h-0 overflow-y-auto p-5',
105
+ className,
106
+ )}
107
+ {...rest}
108
+ />
109
+ )
110
+ }
111
+
112
+ /**
113
+ * Modal.Footer — bottom action strip on the outer card, below the
114
+ * tray inset. `shrink-0` keeps it pinned even when the tray scrolls.
115
+ */
116
+ function Footer({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
117
+ return (
118
+ <div
119
+ className={cn(
120
+ 'flex items-center justify-end gap-2 shrink-0 px-6 py-4',
121
+ className,
122
+ )}
123
+ {...rest}
124
+ />
125
+ )
126
+ }
127
+
128
+ Modal.Tray = Tray
129
+ Modal.Footer = Footer
130
+
131
+ export default Modal