@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,259 @@
1
+ import { memo, useState } from 'react'
2
+ import type { PreviewAdData } from './types'
3
+ import { CreativeMedia } from './CreativeMedia'
4
+ import { ProfileAvatar } from './ProfileAvatar'
5
+ import { withPreviewDefaults } from './defaults'
6
+
7
+ /**
8
+ * Instagram Feed — single image (sponsored). GREENFIELD rebuild.
9
+ *
10
+ * Not the Creative-Hub export extraction. Faithful to the real IG sponsored-post
11
+ * chrome but built cleanly: a 320px card with natural full-width flow (no fixed
12
+ * 300px inner rows, no empty placeholder divs), Meta-exact hex (self-contained —
13
+ * no design tokens), and a system font stack. Layout, top → bottom:
14
+ * header · creative · CTA bar · action row · caption
15
+ */
16
+
17
+ interface InstagramFeedPreviewProps {
18
+ adData: PreviewAdData
19
+ format: '1:1' | '4:5' | '9:16'
20
+ }
21
+
22
+ const WIDTH = 320
23
+ const FONT =
24
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
25
+
26
+ const C = {
27
+ ink: 'rgb(38, 38, 38)',
28
+ sub: 'rgb(115, 115, 115)',
29
+ hair: 'rgb(239, 239, 239)',
30
+ imageBg: '#FAFAFA',
31
+ }
32
+
33
+ // The IG story-ring gradient — used as the avatar letter-fallback fill.
34
+ const IG_RING =
35
+ 'linear-gradient(45deg, #f09433 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%)'
36
+
37
+ // Illustrative placeholder engagement — these previews are mockups, not live
38
+ // data (PreviewAdData carries no likes/comments/timestamp). Static on purpose.
39
+ const LIKES = '234'
40
+ const COMMENTS = 12
41
+ const POSTED = '2 hours ago'
42
+
43
+ export const InstagramFeedPreview = memo(function InstagramFeedPreview({
44
+ adData: rawAdData,
45
+ format,
46
+ }: InstagramFeedPreviewProps) {
47
+ const adData = withPreviewDefaults(rawAdData)
48
+ const [expanded, setExpanded] = useState(false)
49
+
50
+ const imageHeight = format === '4:5' ? 400 : WIDTH // 1:1 → 320, 4:5 → 400
51
+ const caption = adData.primaryText ?? ''
52
+ const handle = adData.brandName.trim().toLowerCase().replace(/\s+/g, '')
53
+ const LIMIT = 100
54
+ const clipped = caption.length > LIMIT && !expanded
55
+
56
+ return (
57
+ <div style={{ width: WIDTH, background: '#fff', fontFamily: FONT, color: C.ink }}>
58
+ {/* Header */}
59
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px' }}>
60
+ <div style={{ width: 30, height: 30, borderRadius: '50%', overflow: 'hidden', flexShrink: 0 }}>
61
+ <ProfileAvatar src={adData.profileImage} name={adData.brandName} fontSize={12} background={IG_RING} />
62
+ </div>
63
+ <div style={{ flex: 1, minWidth: 0 }}>
64
+ <div
65
+ style={{
66
+ fontSize: 13,
67
+ fontWeight: 600,
68
+ lineHeight: '16px',
69
+ overflow: 'hidden',
70
+ textOverflow: 'ellipsis',
71
+ whiteSpace: 'nowrap',
72
+ }}
73
+ >
74
+ {adData.brandName}
75
+ </div>
76
+ <div style={{ fontSize: 12, lineHeight: '15px', color: C.sub }}>Sponsored</div>
77
+ </div>
78
+ <Dots />
79
+ </div>
80
+
81
+ {/* Creative */}
82
+ <div style={{ width: WIDTH, height: imageHeight, background: C.imageBg, overflow: 'hidden' }}>
83
+ {adData.creativeImage ? (
84
+ <CreativeMedia
85
+ src={adData.creativeImage}
86
+ mediaType={adData.creativeMediaType}
87
+ alt=""
88
+ style={{ width: WIDTH, height: imageHeight, objectFit: 'cover', display: 'block' }}
89
+ />
90
+ ) : (
91
+ <Placeholder format={format} />
92
+ )}
93
+ </div>
94
+
95
+ {/* CTA bar — full-width tappable sponsored action */}
96
+ <button
97
+ type="button"
98
+ style={{
99
+ display: 'flex',
100
+ alignItems: 'center',
101
+ justifyContent: 'space-between',
102
+ width: '100%',
103
+ padding: '11px 14px',
104
+ border: 0,
105
+ borderBottom: `1px solid ${C.hair}`,
106
+ background: 'none',
107
+ cursor: 'pointer',
108
+ font: 'inherit',
109
+ textAlign: 'left',
110
+ }}
111
+ >
112
+ <span style={{ fontSize: 14, fontWeight: 600, color: C.ink }}>
113
+ {adData.callToAction || 'Learn more'}
114
+ </span>
115
+ <Chevron />
116
+ </button>
117
+
118
+ {/* Action row */}
119
+ <div
120
+ style={{
121
+ display: 'flex',
122
+ alignItems: 'center',
123
+ justifyContent: 'space-between',
124
+ padding: '9px 12px 5px',
125
+ }}
126
+ >
127
+ <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
128
+ <Heart />
129
+ <CommentBubble />
130
+ <Send />
131
+ </div>
132
+ <Bookmark />
133
+ </div>
134
+
135
+ {/* Likes */}
136
+ <div style={{ padding: '0 12px', fontSize: 13, fontWeight: 600, color: C.ink, lineHeight: '18px' }}>
137
+ {LIKES} likes
138
+ </div>
139
+
140
+ {/* Caption — bold handle prefix + post text (darker + a touch heavier) */}
141
+ {caption && (
142
+ <div style={{ padding: '3px 12px 0', fontSize: 13, lineHeight: '18px', fontWeight: 400, color: 'rgb(20, 20, 20)' }}>
143
+ <span style={{ fontWeight: 600 }}>{handle}</span>{' '}
144
+ {clipped ? caption.slice(0, LIMIT).trimEnd() : caption}
145
+ {clipped && (
146
+ <button
147
+ type="button"
148
+ onClick={() => setExpanded(true)}
149
+ style={{
150
+ border: 0,
151
+ background: 'none',
152
+ padding: 0,
153
+ marginLeft: 4,
154
+ color: C.sub,
155
+ fontWeight: 400,
156
+ fontSize: 13,
157
+ cursor: 'pointer',
158
+ fontFamily: FONT,
159
+ }}
160
+ >
161
+ more
162
+ </button>
163
+ )}
164
+ </div>
165
+ )}
166
+
167
+ {/* Comments */}
168
+ <div style={{ padding: '5px 12px 0', fontSize: 13, color: C.sub }}>
169
+ View all {COMMENTS} comments
170
+ </div>
171
+
172
+ {/* Timestamp */}
173
+ <div style={{ padding: '4px 12px 12px', fontSize: 11, color: C.sub }}>
174
+ {POSTED}
175
+ </div>
176
+ </div>
177
+ )
178
+ })
179
+
180
+ /* ------------------------------- icons / bits ------------------------------- */
181
+
182
+ const stroke = {
183
+ fill: 'none',
184
+ stroke: 'rgb(38, 38, 38)',
185
+ strokeWidth: 1.8,
186
+ strokeLinecap: 'round' as const,
187
+ strokeLinejoin: 'round' as const,
188
+ }
189
+
190
+ function Dots() {
191
+ return (
192
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="rgb(38,38,38)" style={{ display: 'block', flexShrink: 0 }}>
193
+ <circle cx="5" cy="12" r="1.4" />
194
+ <circle cx="12" cy="12" r="1.4" />
195
+ <circle cx="19" cy="12" r="1.4" />
196
+ </svg>
197
+ )
198
+ }
199
+
200
+ function Chevron() {
201
+ return (
202
+ <svg width="16" height="16" viewBox="0 0 24 24" {...stroke} style={{ display: 'block' }}>
203
+ <polyline points="9 18 15 12 9 6" />
204
+ </svg>
205
+ )
206
+ }
207
+
208
+ function Heart() {
209
+ return (
210
+ <svg width="24" height="24" viewBox="0 0 24 24" {...stroke} style={{ display: 'block' }}>
211
+ <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
212
+ </svg>
213
+ )
214
+ }
215
+
216
+ function CommentBubble() {
217
+ return (
218
+ <svg width="24" height="24" viewBox="0 0 24 24" {...stroke} style={{ display: 'block' }}>
219
+ <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
220
+ </svg>
221
+ )
222
+ }
223
+
224
+ function Send() {
225
+ return (
226
+ <svg width="24" height="24" viewBox="0 0 24 24" {...stroke} style={{ display: 'block' }}>
227
+ <line x1="22" y1="2" x2="11" y2="13" />
228
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
229
+ </svg>
230
+ )
231
+ }
232
+
233
+ function Bookmark() {
234
+ return (
235
+ <svg width="24" height="24" viewBox="0 0 24 24" {...stroke} style={{ display: 'block', flexShrink: 0 }}>
236
+ <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
237
+ </svg>
238
+ )
239
+ }
240
+
241
+ function Placeholder({ format }: { format: string }) {
242
+ return (
243
+ <div
244
+ style={{
245
+ width: '100%',
246
+ height: '100%',
247
+ display: 'flex',
248
+ flexDirection: 'column',
249
+ alignItems: 'center',
250
+ justifyContent: 'center',
251
+ gap: 8,
252
+ color: 'rgb(140, 140, 140)',
253
+ }}
254
+ >
255
+ <div style={{ width: 56, height: 56, borderRadius: 10, background: '#E4E6EB' }} />
256
+ <span style={{ fontSize: 13 }}>Ad creative · {format}</span>
257
+ </div>
258
+ )
259
+ }
@@ -0,0 +1,228 @@
1
+ import { memo, useState } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { ProfileAvatar } from './ProfileAvatar'
4
+ import type { CarouselViewProps } from './types'
5
+ import { PREVIEW_DEFAULTS } from './defaults'
6
+
7
+ /**
8
+ * Facebook Feed — CAROUSEL (presentational). ADR-119 Decision 1.
9
+ *
10
+ * Pure: no carousel controller, no crop system. The card list, active index +
11
+ * setter, and each card's image (`renderImage(i)` slot) all come from props, so
12
+ * the same view serves the controller-driven form (CropFrame slot) and static
13
+ * surfaces (plain <img> slot). Standard FB feed chrome around a peeking card
14
+ * strip; each card = image slot + footer (headline, description, shared CTA).
15
+ * Meta-exact hex — `packages/ui` is token-boundary exempt (ADR-119 D5).
16
+ */
17
+
18
+ const WIDTH = 320
19
+ const CARD_W = 232
20
+ const GAP = 8
21
+ const FONT =
22
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
23
+
24
+ const C = {
25
+ name: 'rgb(8, 8, 8)',
26
+ sub: 'rgb(101, 103, 107)',
27
+ body: 'rgb(0, 0, 0)',
28
+ seeMore: '#1877F2',
29
+ imageBg: '#F0F2F5',
30
+ cardBorder: 'rgba(0, 0, 0, 0.1)',
31
+ headline: 'rgb(28, 32, 40)',
32
+ ctaBtnBg: 'rgb(228, 230, 235)',
33
+ ctaBtnInk: 'rgb(8, 8, 9)',
34
+ divider: 'rgb(219, 221, 224)',
35
+ action: 'rgb(97, 103, 112)',
36
+ }
37
+
38
+ export const MetaFeedCarousel = memo(function MetaFeedCarousel({
39
+ cards,
40
+ active,
41
+ onActive,
42
+ renderImage,
43
+ brandName,
44
+ profileImage,
45
+ primaryText,
46
+ callToAction,
47
+ }: CarouselViewProps) {
48
+ const [expanded, setExpanded] = useState(false)
49
+
50
+ const brand = brandName || PREVIEW_DEFAULTS.brandName
51
+ const cta = callToAction || PREVIEW_DEFAULTS.callToAction
52
+ const maxIndex = Math.max(0, cards.length - 1)
53
+ const i = Math.min(Math.max(0, active), maxIndex)
54
+ const translate = -(i * (CARD_W + GAP))
55
+
56
+ const text = primaryText || PREVIEW_DEFAULTS.primaryText
57
+ const LIMIT = 125
58
+ const clipped = text.length > LIMIT && !expanded
59
+
60
+ return (
61
+ <div style={{ width: WIDTH, background: '#fff', fontFamily: FONT, color: C.body }}>
62
+ {/* Header */}
63
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px' }}>
64
+ <div style={{ width: 40, height: 40, borderRadius: '50%', overflow: 'hidden', flexShrink: 0 }}>
65
+ <ProfileAvatar src={profileImage} name={brand} fontSize={15} />
66
+ </div>
67
+ <div style={{ flex: 1, minWidth: 0 }}>
68
+ <div style={{ fontSize: 14, fontWeight: 600, lineHeight: '18px', color: C.name, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
69
+ {brand}
70
+ </div>
71
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 1, color: C.sub }}>
72
+ <span style={{ fontSize: 12, lineHeight: '15px' }}>Sponsored</span>
73
+ <span style={{ fontSize: 12 }}>·</span>
74
+ <Globe />
75
+ </div>
76
+ </div>
77
+ <MenuDots />
78
+ </div>
79
+
80
+ {/* Primary text */}
81
+ {text && (
82
+ <div style={{ padding: '0 12px 10px', fontSize: 14, fontWeight: 400, lineHeight: '19px', color: C.body, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
83
+ {clipped ? text.slice(0, LIMIT).trimEnd() : text}
84
+ {clipped && (
85
+ <>
86
+ {'… '}
87
+ <button type="button" onClick={() => setExpanded(true)} style={{ border: 0, background: 'none', padding: 0, color: C.seeMore, fontWeight: 600, fontSize: 14, cursor: 'pointer', fontFamily: FONT }}>
88
+ See more
89
+ </button>
90
+ </>
91
+ )}
92
+ </div>
93
+ )}
94
+
95
+ {/* Card strip */}
96
+ <div style={{ position: 'relative', overflow: 'hidden', padding: '2px 0 10px' }}>
97
+ <div style={{ display: 'flex', gap: GAP, paddingLeft: 12, transform: `translateX(${translate}px)`, transition: 'transform 280ms cubic-bezier(0.22, 1, 0.36, 1)' }}>
98
+ {cards.map((card, k) => (
99
+ <div key={card.key} style={{ width: CARD_W, flexShrink: 0, border: `1px solid ${C.cardBorder}`, borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
100
+ <div style={{ width: '100%', height: CARD_W, background: C.imageBg, overflow: 'hidden' }}>
101
+ {renderImage(k)}
102
+ </div>
103
+ <div style={{ padding: '10px 12px' }}>
104
+ <div style={{ fontSize: 13, fontWeight: 700, lineHeight: '17px', color: C.headline, overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
105
+ {card.headline || PREVIEW_DEFAULTS.headline}
106
+ </div>
107
+ {card.description && (
108
+ <div style={{ fontSize: 12, lineHeight: '16px', color: C.sub, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
109
+ {card.description}
110
+ </div>
111
+ )}
112
+ <div style={{ marginTop: 10, display: 'inline-flex', alignItems: 'center', height: 30, padding: '0 12px', borderRadius: 6, background: C.ctaBtnBg, color: C.ctaBtnInk, fontSize: 13, fontWeight: 600 }}>
113
+ {cta}
114
+ </div>
115
+ </div>
116
+ </div>
117
+ ))}
118
+ </div>
119
+ {i > 0 && <Arrow dir="left" onClick={() => onActive(i - 1)} />}
120
+ {i < maxIndex && <Arrow dir="right" onClick={() => onActive(i + 1)} />}
121
+ </div>
122
+
123
+ {/* Divider */}
124
+ <div style={{ height: 1, background: C.divider }} />
125
+
126
+ {/* Action bar */}
127
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-around', padding: '6px 8px' }}>
128
+ <FbAction label="Like"><ThumbsUp /></FbAction>
129
+ <FbAction label="Comment"><Bubble /></FbAction>
130
+ <FbAction label="Share"><ShareArrow /></FbAction>
131
+ </div>
132
+ </div>
133
+ )
134
+ })
135
+
136
+ /* ------------------------------- icons / bits ------------------------------- */
137
+
138
+ const stroke = {
139
+ fill: 'none',
140
+ stroke: C.action,
141
+ strokeWidth: 2,
142
+ strokeLinecap: 'round' as const,
143
+ strokeLinejoin: 'round' as const,
144
+ }
145
+
146
+ function Arrow({ dir, onClick }: { dir: 'left' | 'right'; onClick: () => void }) {
147
+ return (
148
+ <button
149
+ type="button"
150
+ onClick={onClick}
151
+ aria-label={dir === 'left' ? 'Previous' : 'Next'}
152
+ style={{
153
+ position: 'absolute',
154
+ top: CARD_W / 2 + 2,
155
+ transform: 'translateY(-50%)',
156
+ ...(dir === 'left' ? { left: 8 } : { right: 8 }),
157
+ width: 30,
158
+ height: 30,
159
+ borderRadius: '50%',
160
+ border: 0,
161
+ background: '#fff',
162
+ boxShadow: '0 1px 4px rgba(0,0,0,0.28)',
163
+ display: 'flex',
164
+ alignItems: 'center',
165
+ justifyContent: 'center',
166
+ cursor: 'pointer',
167
+ color: 'rgb(8,8,8)',
168
+ }}
169
+ >
170
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block' }}>
171
+ {dir === 'left' ? <polyline points="15 18 9 12 15 6" /> : <polyline points="9 18 15 12 9 6" />}
172
+ </svg>
173
+ </button>
174
+ )
175
+ }
176
+
177
+ function FbAction({ label, children }: { label: string; children: ReactNode }) {
178
+ return (
179
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', cursor: 'pointer' }}>
180
+ {children}
181
+ <span style={{ fontSize: 13, fontWeight: 600, color: C.action }}>{label}</span>
182
+ </div>
183
+ )
184
+ }
185
+
186
+ function MenuDots() {
187
+ return (
188
+ <svg width="20" height="20" viewBox="0 0 24 24" fill={C.sub} style={{ display: 'block', flexShrink: 0 }}>
189
+ <circle cx="5" cy="12" r="1.6" />
190
+ <circle cx="12" cy="12" r="1.6" />
191
+ <circle cx="19" cy="12" r="1.6" />
192
+ </svg>
193
+ )
194
+ }
195
+
196
+ function Globe() {
197
+ return (
198
+ <svg width="12" height="12" viewBox="0 0 16 16" fill={C.sub} style={{ display: 'block' }}>
199
+ <path d="M8 0a8 8 0 1 0 8 8 8 8 0 0 0-8-8zm5.9 7H11a13 13 0 0 0-.9-4.4A6 6 0 0 1 13.9 7zM8 14c-.7 0-1.8-1.8-2-5h4c-.2 3.2-1.3 5-2 5zM6 7c.2-3.2 1.3-5 2-5s1.8 1.8 2 5zm-.1-4.4A13 13 0 0 0 5 7H2.1a6 6 0 0 1 3.8-4.4zM2.1 9H5a13 13 0 0 0 .9 4.4A6 6 0 0 1 2.1 9zm8 4.4a13 13 0 0 0 .9-4.4h2.9a6 6 0 0 1-3.8 4.4z" />
200
+ </svg>
201
+ )
202
+ }
203
+
204
+ function ThumbsUp() {
205
+ return (
206
+ <svg width="18" height="18" viewBox="0 0 24 24" {...stroke} style={{ display: 'block' }}>
207
+ <path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" />
208
+ </svg>
209
+ )
210
+ }
211
+
212
+ function Bubble() {
213
+ return (
214
+ <svg width="18" height="18" viewBox="0 0 24 24" {...stroke} style={{ display: 'block' }}>
215
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
216
+ </svg>
217
+ )
218
+ }
219
+
220
+ function ShareArrow() {
221
+ return (
222
+ <svg width="18" height="18" viewBox="0 0 24 24" {...stroke} style={{ display: 'block' }}>
223
+ <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
224
+ <polyline points="16 6 12 2 8 6" />
225
+ <line x1="12" y1="2" x2="12" y2="15" />
226
+ </svg>
227
+ )
228
+ }