@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,214 @@
1
+ /**
2
+ * MarkdownRenderer — renders a markdown string using react-markdown + remark-gfm.
3
+ * Component overrides match the chat-message.tsx config from brand-ai-extension.
4
+ *
5
+ * Promoted from brand-ai-extension (ADR-036 D20).
6
+ * Extracted from components/chat/chat-message.tsx (lines 15-110 — the
7
+ * MARKDOWN_COMPONENTS and REMARK_PLUGINS constants + renderMarkdown helper).
8
+ *
9
+ * Token note: the source referenced --primary for blockquote border, link
10
+ * colour, and ordered-list marker accent. --primary is an internal token.
11
+ * Replaced with the public --accent token throughout.
12
+ */
13
+
14
+ import type {
15
+ InputHTMLAttributes,
16
+ HTMLAttributes,
17
+ AnchorHTMLAttributes,
18
+ } from 'react'
19
+ import ReactMarkdown, { type Components } from 'react-markdown'
20
+ import remarkGfm from 'remark-gfm'
21
+ import { MarkdownCode } from './code-block'
22
+
23
+ type NodeProps = HTMLAttributes<HTMLElement>
24
+ type AnchorProps = AnchorHTMLAttributes<HTMLAnchorElement>
25
+
26
+ const MARKDOWN_COMPONENTS: Components = {
27
+ h1: ({ children }: NodeProps) => (
28
+ <h1
29
+ className="mt-6 mb-3 text-xl font-bold first:mt-0"
30
+ style={{ color: 'rgb(var(--foreground))' }}
31
+ >
32
+ {children}
33
+ </h1>
34
+ ),
35
+ h2: ({ children }: NodeProps) => (
36
+ <h2
37
+ className="mt-5 mb-2 text-lg font-bold first:mt-0"
38
+ style={{ color: 'rgb(var(--foreground))' }}
39
+ >
40
+ {children}
41
+ </h2>
42
+ ),
43
+ h3: ({ children }: NodeProps) => (
44
+ <h3
45
+ className="mt-4 mb-2 text-base font-semibold first:mt-0"
46
+ style={{ color: 'rgb(var(--foreground))' }}
47
+ >
48
+ {children}
49
+ </h3>
50
+ ),
51
+ p: ({ children }: NodeProps) => (
52
+ <p
53
+ className="mb-4 leading-relaxed last:mb-0"
54
+ style={{ color: 'rgb(var(--foreground))' }}
55
+ >
56
+ {children}
57
+ </p>
58
+ ),
59
+ // list-outside + left padding so wrapped lines indent under the text (not the
60
+ // marker). Bullets use text-tertiary; ordered markers use --accent + bold so
61
+ // the numbering reads as deliberate hierarchy rather than browser-default grey.
62
+ ul: ({ children }: NodeProps) => (
63
+ <ul
64
+ className="mb-4 list-disc list-outside space-y-2 pl-6"
65
+ style={{ color: 'rgb(var(--text-tertiary))' }}
66
+ >
67
+ {children}
68
+ </ul>
69
+ ),
70
+ ol: ({ children }: NodeProps) => (
71
+ <ol
72
+ className="mb-4 list-decimal list-outside space-y-2 pl-6 marker:font-semibold"
73
+ style={{ color: 'rgb(var(--foreground))' }}
74
+ >
75
+ {children}
76
+ </ol>
77
+ ),
78
+ li: ({ children }: NodeProps) => (
79
+ <li
80
+ className="pl-1 leading-relaxed"
81
+ style={{ color: 'rgb(var(--foreground))' }}
82
+ >
83
+ {children}
84
+ </li>
85
+ ),
86
+ strong: ({ children }: NodeProps) => (
87
+ <strong className="font-semibold" style={{ color: 'rgb(var(--foreground))' }}>
88
+ {children}
89
+ </strong>
90
+ ),
91
+ em: ({ children }: NodeProps) => (
92
+ <em className="italic" style={{ color: 'rgb(var(--foreground))' }}>
93
+ {children}
94
+ </em>
95
+ ),
96
+ // code: inline chip or fenced block (handled by CodeBlock via MarkdownCode).
97
+ // pre just passes through so the highlighter owns its own container.
98
+ code: MarkdownCode,
99
+ pre: ({ children }: NodeProps) => <>{children}</>,
100
+ blockquote: ({ children }: NodeProps) => (
101
+ <blockquote
102
+ className="my-4 border-l-4 py-1 pl-4 italic"
103
+ style={{
104
+ borderColor: 'rgb(var(--accent))',
105
+ color: 'rgb(var(--muted-foreground))',
106
+ }}
107
+ >
108
+ {children}
109
+ </blockquote>
110
+ ),
111
+ hr: () => (
112
+ <hr className="my-6" style={{ borderColor: 'rgb(var(--border))' }} />
113
+ ),
114
+ a: ({ href, children }: AnchorProps) => (
115
+ <a
116
+ href={href}
117
+ className="hover:underline"
118
+ style={{ color: 'rgb(var(--accent))' }}
119
+ target="_blank"
120
+ rel="noopener noreferrer"
121
+ >
122
+ {children}
123
+ </a>
124
+ ),
125
+ // GFM additions — tables, task lists, strikethrough.
126
+ table: ({ children }: NodeProps) => (
127
+ <div
128
+ className="my-4 overflow-x-auto"
129
+ style={{
130
+ borderRadius: 'var(--radius-md)',
131
+ border: '1px solid rgb(var(--border))',
132
+ }}
133
+ >
134
+ <table className="w-full border-collapse text-sm">{children}</table>
135
+ </div>
136
+ ),
137
+ thead: ({ children }: NodeProps) => (
138
+ <thead
139
+ style={{
140
+ background: 'rgb(var(--muted))',
141
+ color: 'rgb(var(--muted-foreground))',
142
+ }}
143
+ >
144
+ {children}
145
+ </thead>
146
+ ),
147
+ tbody: ({ children }: NodeProps) => <tbody>{children}</tbody>,
148
+ tr: ({ children }: NodeProps) => (
149
+ <tr
150
+ className="first:border-t-0"
151
+ style={{ borderTop: '1px solid rgb(var(--border))' }}
152
+ >
153
+ {children}
154
+ </tr>
155
+ ),
156
+ th: ({ children }: NodeProps) => (
157
+ <th className="px-3 py-2 text-left font-medium">{children}</th>
158
+ ),
159
+ td: ({ children }: NodeProps) => (
160
+ <td
161
+ className="px-3 py-2 align-top"
162
+ style={{ color: 'rgb(var(--foreground))' }}
163
+ >
164
+ {children}
165
+ </td>
166
+ ),
167
+ del: ({ children }: NodeProps) => (
168
+ <del className="line-through" style={{ color: 'rgb(var(--muted-foreground))' }}>
169
+ {children}
170
+ </del>
171
+ ),
172
+ // Task-list checkboxes rendered by remark-gfm for "- [ ] ..." / "- [x] ...".
173
+ input: (props: InputHTMLAttributes<HTMLInputElement>) =>
174
+ props.type === 'checkbox' ? (
175
+ <input
176
+ {...props}
177
+ disabled
178
+ className="mr-1.5 -translate-y-px"
179
+ style={{ accentColor: 'rgb(var(--accent))' }}
180
+ />
181
+ ) : (
182
+ <input {...props} />
183
+ ),
184
+ }
185
+
186
+ const REMARK_PLUGINS = [remarkGfm]
187
+
188
+ export interface MarkdownRendererProps {
189
+ /** Raw markdown string to render. */
190
+ children: string
191
+ /** Optional className applied to the wrapping div. */
192
+ className?: string
193
+ }
194
+
195
+ /**
196
+ * MarkdownRenderer — drop-in markdown surface with GFM support.
197
+ * Renders headings, lists, code (via CodeBlock), tables, task lists,
198
+ * strikethrough, blockquotes, and links with workspace token styling.
199
+ */
200
+ export function MarkdownRenderer({ children, className }: MarkdownRendererProps) {
201
+ return (
202
+ <div className={className}>
203
+ <ReactMarkdown
204
+ components={MARKDOWN_COMPONENTS}
205
+ remarkPlugins={REMARK_PLUGINS}
206
+ >
207
+ {children}
208
+ </ReactMarkdown>
209
+ </div>
210
+ )
211
+ }
212
+
213
+ // Re-export for callers that just want the raw component map
214
+ export { MARKDOWN_COMPONENTS as markdownComponents }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * MenuButton — menu-bar dropdown for File / Edit-style affordances.
3
+ *
4
+ * The classic app menu-bar pattern: a bordered secondary button with a
5
+ * trailing chevron that opens a compact menu of actions.
6
+ *
7
+ * Distinct from <DropdownButton>:
8
+ * • DropdownButton — a primary/secondary CTA whose items are
9
+ * icon-tile + title + description rows. For "primary action +
10
+ * secondary picker".
11
+ * • MenuButton — a compact secondary button whose items are
12
+ * `icon? + label + shortcut?` rows. For a menu bar.
13
+ *
14
+ * Trigger is a bordered `.btn .btn-secondary` (NOT a ghost button) —
15
+ * per CLAUDE.md §2, buttons carry a 1px border by default.
16
+ *
17
+ * Built on the <DropdownMenu> compound. Items accept a
18
+ * `{ type: 'separator' }` sentinel for dividers.
19
+ *
20
+ * Workspace ADR: ADR-031 Decision 1.
21
+ */
22
+
23
+ import { useId, type ReactNode } from 'react'
24
+ import { ChevronDown } from 'lucide-react'
25
+ import {
26
+ DropdownMenu,
27
+ DropdownMenuContent,
28
+ DropdownMenuItem,
29
+ DropdownMenuSeparator,
30
+ DropdownMenuTrigger,
31
+ type DropdownMenuContentProps,
32
+ } from './dropdown-menu'
33
+ import { cn } from './lib/utils'
34
+
35
+ export interface MenuButtonItem {
36
+ /** Stable id for keying. Falls back to the index. */
37
+ id?: string
38
+ /** Optional leading icon (Lucide or any ReactNode), sized ~14px. */
39
+ icon?: ReactNode
40
+ /** Primary label. */
41
+ label: string
42
+ /** Optional right-aligned shortcut hint, e.g. "⌘C". */
43
+ shortcut?: string
44
+ /** Visual variant. `destructive` tints the row red. */
45
+ variant?: 'default' | 'destructive'
46
+ disabled?: boolean
47
+ onSelect: () => void
48
+ }
49
+
50
+ /** A divider between menu items. */
51
+ export interface MenuButtonSeparator {
52
+ type: 'separator'
53
+ }
54
+
55
+ export type MenuButtonEntry = MenuButtonItem | MenuButtonSeparator
56
+
57
+ export interface MenuButtonProps {
58
+ /** Trigger label — e.g. "File", "Edit". */
59
+ label: string
60
+ /** Menu entries, in order. */
61
+ items: MenuButtonEntry[]
62
+ /** Optional leading icon on the trigger. */
63
+ leadingIcon?: ReactNode
64
+ /** Disable the trigger entirely. */
65
+ disabled?: boolean
66
+ /** Popover alignment relative to the trigger. Default `start`. */
67
+ align?: DropdownMenuContentProps['align']
68
+ /** Popover width. Default `14rem`. */
69
+ menuWidth?: string
70
+ /** Forwarded to the trigger className for layout overrides. */
71
+ className?: string
72
+ /** aria-label for the trigger when the visible label is ambiguous. */
73
+ triggerAriaLabel?: string
74
+ }
75
+
76
+ function isSeparator(entry: MenuButtonEntry): entry is MenuButtonSeparator {
77
+ return 'type' in entry && entry.type === 'separator'
78
+ }
79
+
80
+ export function MenuButton({
81
+ label,
82
+ items,
83
+ leadingIcon,
84
+ disabled,
85
+ align = 'start',
86
+ menuWidth = '14rem',
87
+ className,
88
+ triggerAriaLabel,
89
+ }: MenuButtonProps) {
90
+ const reactId = useId()
91
+
92
+ return (
93
+ <DropdownMenu>
94
+ <DropdownMenuTrigger
95
+ unstyled
96
+ disabled={disabled}
97
+ aria-label={triggerAriaLabel}
98
+ className={cn('btn btn-secondary size-sm', className)}
99
+ >
100
+ {leadingIcon}
101
+ <span>{label}</span>
102
+ <ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
103
+ </DropdownMenuTrigger>
104
+ <DropdownMenuContent
105
+ align={align}
106
+ sideOffset={6}
107
+ style={{ width: menuWidth, padding: 6 }}
108
+ >
109
+ {items.map((entry, index) => {
110
+ if (isSeparator(entry)) {
111
+ return <DropdownMenuSeparator key={`${reactId}-sep-${index}`} />
112
+ }
113
+ return (
114
+ <DropdownMenuItem
115
+ key={entry.id ?? `${reactId}-${index}`}
116
+ {...(entry.variant ? { variant: entry.variant } : {})}
117
+ {...(entry.disabled ? { disabled: entry.disabled } : {})}
118
+ onClick={entry.onSelect}
119
+ className="!py-1.5 !px-2 !gap-2 text-[13px]"
120
+ >
121
+ <span
122
+ className="inline-flex h-4 w-4 shrink-0 items-center justify-center"
123
+ aria-hidden="true"
124
+ >
125
+ {entry.icon}
126
+ </span>
127
+ <span className="flex-1 truncate">{entry.label}</span>
128
+ {entry.shortcut && (
129
+ <span
130
+ className="shrink-0 text-[11px] tabular-nums"
131
+ style={{ color: 'rgb(var(--text-tertiary))' }}
132
+ >
133
+ {entry.shortcut}
134
+ </span>
135
+ )}
136
+ </DropdownMenuItem>
137
+ )
138
+ })}
139
+ </DropdownMenuContent>
140
+ </DropdownMenu>
141
+ )
142
+ }
@@ -0,0 +1,119 @@
1
+ import React, { useState, useRef, useCallback } from 'react';
2
+ import { Play, Volume2, VolumeX } from 'lucide-react';
3
+
4
+ // `| undefined` on every optional so the workspace's
5
+ // exactOptionalPropertyTypes consumers may pass `undefined` literals.
6
+ interface CreativeMediaProps {
7
+ src: string;
8
+ alt?: string | undefined;
9
+ mediaType?: 'image' | 'video' | undefined;
10
+ className?: string | undefined;
11
+ style?: React.CSSProperties | undefined;
12
+ autoPlay?: boolean | undefined;
13
+ loop?: boolean | undefined;
14
+ showPlayOverlay?: boolean | undefined;
15
+ thumbnailSrc?: string | undefined;
16
+ onError?: ((e: React.SyntheticEvent) => void) | undefined;
17
+ }
18
+
19
+ export const CreativeMedia: React.FC<CreativeMediaProps> = ({
20
+ src,
21
+ alt = 'Ad creative',
22
+ mediaType,
23
+ className,
24
+ style,
25
+ autoPlay = false,
26
+ loop = true,
27
+ showPlayOverlay = true,
28
+ thumbnailSrc,
29
+ onError,
30
+ }) => {
31
+ const isVideo = mediaType === 'video' || /\.(mp4|mov|webm|m4v)$/i.test(src);
32
+ const videoRef = useRef<HTMLVideoElement>(null);
33
+ const [isPlaying, setIsPlaying] = useState(autoPlay ?? false);
34
+ const [isMuted, setIsMuted] = useState(true);
35
+
36
+ // Hooks must run on every render — keep them above the early return.
37
+ const togglePlay = useCallback(() => {
38
+ const v = videoRef.current;
39
+ if (!v) return;
40
+ if (v.paused) {
41
+ void v.play();
42
+ setIsPlaying(true);
43
+ } else {
44
+ v.pause();
45
+ setIsPlaying(false);
46
+ }
47
+ }, []);
48
+
49
+ const toggleMute = useCallback((e: React.MouseEvent) => {
50
+ e.stopPropagation();
51
+ const v = videoRef.current;
52
+ if (!v) return;
53
+ v.muted = !v.muted;
54
+ setIsMuted(v.muted);
55
+ }, []);
56
+
57
+ if (!isVideo) {
58
+ return (
59
+ <img
60
+ src={src}
61
+ alt={alt}
62
+ className={className}
63
+ style={style}
64
+ loading="lazy"
65
+ onError={onError || ((e) => { (e.currentTarget as HTMLElement).style.display = 'none'; })}
66
+ />
67
+ );
68
+ }
69
+
70
+ const { objectFit, ...wrapperStyle } =
71
+ (style ?? {}) as React.CSSProperties & { objectFit?: React.CSSProperties['objectFit'] };
72
+
73
+ return (
74
+ <div
75
+ className={`relative ${className || ''}`}
76
+ style={wrapperStyle}
77
+ onClick={togglePlay}
78
+ >
79
+ <video
80
+ ref={videoRef}
81
+ src={src}
82
+ poster={thumbnailSrc}
83
+ style={{ width: '100%', height: '100%', objectFit: objectFit || 'cover' }}
84
+ muted={isMuted}
85
+ loop={loop}
86
+ playsInline
87
+ autoPlay={autoPlay}
88
+ preload="metadata"
89
+ onPlay={() => setIsPlaying(true)}
90
+ onPause={() => setIsPlaying(false)}
91
+ onError={onError || ((e) => { (e.currentTarget as HTMLElement).style.display = 'none'; })}
92
+ />
93
+
94
+ {/* Play overlay — shown when paused */}
95
+ {showPlayOverlay && !isPlaying && (
96
+ <div className="absolute inset-0 flex items-center justify-center bg-black/20 cursor-pointer">
97
+ <div className="w-12 h-12 rounded-full bg-white/90 flex items-center justify-center shadow-lg">
98
+ <Play className="w-5 h-5 text-[#1c1e21] ml-0.5" fill="currentColor" />
99
+ </div>
100
+ </div>
101
+ )}
102
+
103
+ {/* Sound toggle — shown when playing */}
104
+ {isPlaying && (
105
+ <button
106
+ type="button"
107
+ onClick={toggleMute}
108
+ className="absolute bottom-2 right-2 w-7 h-7 rounded-full bg-black/60 flex items-center justify-center hover:bg-black/80 transition-colors"
109
+ >
110
+ {isMuted ? (
111
+ <VolumeX className="w-3.5 h-3.5 text-white" />
112
+ ) : (
113
+ <Volume2 className="w-3.5 h-3.5 text-white" />
114
+ )}
115
+ </button>
116
+ )}
117
+ </div>
118
+ );
119
+ };