@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,347 @@
1
+ /**
2
+ * ChipNav — sticky pill bar with built-in scroll-spy.
3
+ *
4
+ * Renders a horizontally-scrolling row of chips; each chip targets a
5
+ * sibling element marked `data-section-id="<id>"`. On mount it wires up
6
+ * an IntersectionObserver to highlight the chip whose section is
7
+ * currently near the top of the scroll container; clicks smooth-scroll
8
+ * the section into view.
9
+ *
10
+ * Usage:
11
+ * <ChipNav
12
+ * items={[
13
+ * { id: 'identity', label: 'Identity' },
14
+ * { id: 'colors', label: 'Colors' },
15
+ * ]}
16
+ * scrollContainer={mainScrollRef} // or `'#scroll'` selector
17
+ * stickyTop={56} // px from top when stuck
18
+ * />
19
+ *
20
+ * Pure presentational primitive — no brand-profile coupling.
21
+ */
22
+
23
+ import {
24
+ useEffect,
25
+ useRef,
26
+ useState,
27
+ type CSSProperties,
28
+ type ReactNode,
29
+ } from 'react'
30
+ import { cn } from './lib/utils'
31
+ import { HEADER_HEIGHT, SECTION_SCROLL_OFFSET } from './lib/layout'
32
+
33
+ export interface ChipNavItem {
34
+ id: string
35
+ label: string
36
+ }
37
+
38
+ interface ChipNavProps {
39
+ items: ChipNavItem[]
40
+ /**
41
+ * Element (ref or CSS selector) used as the scroll container for both
42
+ * the IntersectionObserver root and the smooth-scroll target. Falls
43
+ * back to the document if omitted.
44
+ */
45
+ scrollContainer?: React.RefObject<HTMLElement | null> | string
46
+ /** Pixel offset for sticky positioning (default: 0 — caller manages). */
47
+ stickyTop?: number
48
+ /** Pixels above the section to offset when click-scrolling. */
49
+ scrollOffset?: number
50
+ /**
51
+ * Optional content rendered on the right side of the bar, separated
52
+ * from the chips by a flexible spacer. Use for page-level actions
53
+ * that should stay pinned to the sticky bar (e.g. Export, Generate).
54
+ */
55
+ actions?: ReactNode
56
+ /** Extra Tailwind classes on the outer wrapper. */
57
+ className?: string
58
+ /** Inline style on the outer wrapper. */
59
+ style?: CSSProperties
60
+ /**
61
+ * Background painted on the sticky wrapper so page content scrolling
62
+ * behind the chip pill gets masked cleanly.
63
+ *
64
+ * - `'none'` (default) — wrapper is transparent. Correct inside a Card,
65
+ * inside a tray, or anywhere the chip nav sits over a non-page surface.
66
+ * - `'page'` — wrapper paints `rgb(var(--main-content-bg))`. Correct
67
+ * when the chip nav is mounted directly under the AppHeader, against
68
+ * the page background, and needs to mask scrolling content beneath.
69
+ *
70
+ * Default was page-colored in the original brand-ai port; that bled
71
+ * into Card / Modal / Tray contexts as a discordant horizontal strip.
72
+ * Phase 2 callers that mount ChipNav against the page background must
73
+ * pass `surface="page"`.
74
+ */
75
+ surface?: 'none' | 'page'
76
+ /**
77
+ * Controlled mode: when provided, the parent owns the active chip.
78
+ * Scroll-spy is disabled and `onItemClick` is invoked instead of the
79
+ * default smooth-scroll behavior. Phase 7a GeoSearch uses this to
80
+ * drive mode-switch tabs (not scroll targets).
81
+ */
82
+ activeId?: string
83
+ /**
84
+ * Optional click handler. When provided, takes precedence over the
85
+ * built-in scroll-to-section behavior. Required for controlled-mode
86
+ * (`activeId`) use.
87
+ */
88
+ onItemClick?: (id: string) => void
89
+ /**
90
+ * `'sticky'` (default) — the wrapper is `sticky top-{stickyTop}` for
91
+ * the canonical scroll-spy use case.
92
+ * `'static'` — wrapper is `relative` with no inset; the consumer owns
93
+ * positioning. Used by GeoSearch (Phase 7a) to mount ChipNav as a
94
+ * floating island absolute-positioned above the map.
95
+ */
96
+ position?: 'sticky' | 'static'
97
+ /**
98
+ * `'comfortable'` (default, 32px chip height) — the canonical
99
+ * brand-profile lens density.
100
+ * `'compact'` (24px chip height) — slim variant for tight chrome
101
+ * like the `<PageHeaderHost />` center slot, where the surrounding
102
+ * header is only 48px tall. Reduces inner pill padding and chip
103
+ * vertical padding by 2px each.
104
+ */
105
+ density?: 'comfortable' | 'compact'
106
+ }
107
+
108
+ function resolveScrollEl(
109
+ scrollContainer: ChipNavProps['scrollContainer'],
110
+ ): HTMLElement | null {
111
+ if (!scrollContainer) return null
112
+ if (typeof scrollContainer === 'string') {
113
+ return document.querySelector(scrollContainer) as HTMLElement | null
114
+ }
115
+ return scrollContainer.current ?? null
116
+ }
117
+
118
+ export default function ChipNav({
119
+ items,
120
+ scrollContainer,
121
+ // Default lands the chip bar directly under the AppHeader. Callers
122
+ // only need to override when the chip bar lives under additional
123
+ // sticky chrome.
124
+ stickyTop = HEADER_HEIGHT,
125
+ scrollOffset = SECTION_SCROLL_OFFSET,
126
+ actions,
127
+ className,
128
+ style,
129
+ surface = 'none',
130
+ activeId: activeIdProp,
131
+ onItemClick,
132
+ position = 'sticky',
133
+ density = 'comfortable',
134
+ }: ChipNavProps) {
135
+ const isControlled = activeIdProp !== undefined
136
+ const [internalActiveId, setInternalActiveId] = useState<string>(
137
+ items[0]?.id ?? '',
138
+ )
139
+ const activeId = isControlled ? activeIdProp : internalActiveId
140
+ const setActiveId = (id: string) => {
141
+ if (!isControlled) setInternalActiveId(id)
142
+ }
143
+ const [stuck, setStuck] = useState(false)
144
+ const wrapRef = useRef<HTMLDivElement>(null)
145
+ const navRef = useRef<HTMLDivElement>(null)
146
+
147
+ // Scroll-spy. Skipped in controlled mode — parent owns activeId.
148
+ useEffect(() => {
149
+ if (isControlled) return
150
+ const scrollEl = resolveScrollEl(scrollContainer)
151
+ const root = scrollEl ?? null
152
+
153
+ const sections = items
154
+ .map((it) =>
155
+ (root ?? document).querySelector<HTMLElement>(
156
+ `[data-section-id="${it.id}"]`,
157
+ ),
158
+ )
159
+ .filter((el): el is HTMLElement => !!el)
160
+
161
+ if (sections.length === 0) return
162
+
163
+ const observer = new IntersectionObserver(
164
+ (entries) => {
165
+ const visible = entries
166
+ .filter((e) => e.isIntersecting)
167
+ .map((e) => ({
168
+ id: e.target.getAttribute('data-section-id') ?? '',
169
+ top: e.boundingClientRect.top,
170
+ }))
171
+ .sort((a, b) => a.top - b.top)
172
+ const first = visible[0]
173
+ // Effect early-returns when isControlled (line above), so this
174
+ // path always wants to update the uncontrolled state directly.
175
+ // Inlining `setInternalActiveId` (a stable useState setter)
176
+ // avoids closing over `setActiveId`, which would require
177
+ // including it in the deps array even though its identity
178
+ // changes on every render.
179
+ if (first) setInternalActiveId(first.id)
180
+ },
181
+ {
182
+ root,
183
+ rootMargin: `-${stickyTop + 60}px 0px -55% 0px`,
184
+ threshold: 0,
185
+ },
186
+ )
187
+
188
+ sections.forEach((s) => observer.observe(s))
189
+ return () => observer.disconnect()
190
+ }, [items, scrollContainer, stickyTop, isControlled])
191
+
192
+ // Track "stuck" state for backdrop blur / shadow style.
193
+ useEffect(() => {
194
+ const scrollEl = resolveScrollEl(scrollContainer)
195
+ if (!scrollEl || !wrapRef.current) return
196
+ const wrap = wrapRef.current
197
+ const update = () => {
198
+ const wRect = wrap.getBoundingClientRect()
199
+ const sRect = scrollEl.getBoundingClientRect()
200
+ setStuck(wRect.top - sRect.top <= stickyTop + 1)
201
+ }
202
+ update()
203
+ scrollEl.addEventListener('scroll', update, { passive: true })
204
+ return () => scrollEl.removeEventListener('scroll', update)
205
+ }, [scrollContainer, stickyTop])
206
+
207
+ // Auto-scroll active chip into view in the horizontal row.
208
+ useEffect(() => {
209
+ if (!navRef.current) return
210
+ const nav = navRef.current
211
+ const active = nav.querySelector<HTMLElement>(
212
+ `[data-chip-id="${activeId}"]`,
213
+ )
214
+ if (!active) return
215
+ const navRect = nav.getBoundingClientRect()
216
+ const aRect = active.getBoundingClientRect()
217
+ if (aRect.left < navRect.left) {
218
+ nav.scrollTo({ left: nav.scrollLeft + (aRect.left - navRect.left) - 12, behavior: 'smooth' })
219
+ } else if (aRect.right > navRect.right) {
220
+ nav.scrollTo({
221
+ left: nav.scrollLeft + (aRect.right - navRect.right) + 12,
222
+ behavior: 'smooth',
223
+ })
224
+ }
225
+ }, [activeId])
226
+
227
+ const handleClick = (id: string) => {
228
+ if (onItemClick) {
229
+ onItemClick(id)
230
+ setActiveId(id)
231
+ return
232
+ }
233
+ const scrollEl = resolveScrollEl(scrollContainer)
234
+ const section = (scrollEl ?? document).querySelector<HTMLElement>(
235
+ `[data-section-id="${id}"]`,
236
+ )
237
+ if (!section) return
238
+ if (scrollEl) {
239
+ const top =
240
+ section.getBoundingClientRect().top -
241
+ scrollEl.getBoundingClientRect().top +
242
+ scrollEl.scrollTop -
243
+ scrollOffset
244
+ scrollEl.scrollTo({ top, behavior: 'smooth' })
245
+ } else {
246
+ section.scrollIntoView({ behavior: 'smooth', block: 'start' })
247
+ }
248
+ setActiveId(id)
249
+ }
250
+
251
+ const isStatic = position === 'static'
252
+ return (
253
+ <div
254
+ ref={wrapRef}
255
+ data-stuck={stuck ? 'true' : undefined}
256
+ className={cn(
257
+ isStatic ? 'relative' : 'sticky z-20 py-3 -mt-3',
258
+ className,
259
+ )}
260
+ style={{
261
+ top: isStatic ? undefined : stickyTop,
262
+ background:
263
+ surface === 'page' ? 'rgb(var(--main-content-bg))' : 'transparent',
264
+ ...style,
265
+ }}
266
+ >
267
+ <div
268
+ className={cn(
269
+ 'flex items-center gap-2',
270
+ density === 'compact' ? 'p-[3px]' : 'p-[5px]',
271
+ )}
272
+ style={{
273
+ background: 'rgb(var(--bg-card))',
274
+ border: '1px solid rgb(var(--border))',
275
+ borderRadius: 10,
276
+ boxShadow: stuck ? 'var(--shadow-md)' : 'var(--shadow-sm)',
277
+ }}
278
+ >
279
+ <div
280
+ ref={navRef}
281
+ role="tablist"
282
+ className="flex gap-1 overflow-x-auto scrollbar-hide flex-1 min-w-0"
283
+ >
284
+ {items.map((item) => {
285
+ const active = item.id === activeId
286
+ return (
287
+ <button
288
+ key={item.id}
289
+ type="button"
290
+ role="tab"
291
+ aria-selected={active}
292
+ data-chip-id={item.id}
293
+ onClick={() => handleClick(item.id)}
294
+ className={cn(
295
+ 'inline-flex items-center gap-[7px] rounded-[7px] text-[12.5px] whitespace-nowrap cursor-pointer transition-colors border-0',
296
+ density === 'compact'
297
+ ? 'px-[10px] py-[4px]'
298
+ : 'px-[11px] py-[7px]',
299
+ active ? 'font-semibold' : 'font-medium',
300
+ )}
301
+ style={{
302
+ fontFamily: 'var(--font-sans)',
303
+ letterSpacing: '-0.005em',
304
+ color: active
305
+ ? 'rgb(var(--surface-on-accent))'
306
+ : 'rgb(var(--text-tertiary))',
307
+ background: active
308
+ ? 'rgb(var(--accent))'
309
+ : 'transparent',
310
+ }}
311
+ onMouseEnter={(e) => {
312
+ if (active) return
313
+ e.currentTarget.style.color = 'rgb(var(--foreground))'
314
+ e.currentTarget.style.background =
315
+ 'rgb(var(--surface-overlay-soft))'
316
+ }}
317
+ onMouseLeave={(e) => {
318
+ if (active) return
319
+ e.currentTarget.style.color = 'rgb(var(--text-tertiary))'
320
+ e.currentTarget.style.background = 'transparent'
321
+ }}
322
+ >
323
+ {item.label}
324
+ </button>
325
+ )
326
+ })}
327
+ </div>
328
+ {actions && (
329
+ <>
330
+ <div
331
+ aria-hidden
332
+ className="shrink-0"
333
+ style={{
334
+ width: 1,
335
+ height: 18,
336
+ background: 'rgb(var(--border))',
337
+ }}
338
+ />
339
+ <div className="shrink-0 flex items-center gap-1.5 pr-0.5">
340
+ {actions}
341
+ </div>
342
+ </>
343
+ )}
344
+ </div>
345
+ </div>
346
+ )
347
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Choropleth — decorative concentric trade-zone rings (ADR-104 D3).
3
+ *
4
+ * A glanceable "trade zone" graphic for the GeoDiscovery rail's report cards and
5
+ * the geo surface's empty state. It is DECORATIVE only — the real interactive map
6
+ * lives in the GeoDiscovery lens, one deep-link away. SVG is the right tool for
7
+ * ring geometry; the "no inline SVG" guidance applies to icons (use Lucide), not
8
+ * chart/decoration primitives like this one (CLAUDE.md §6).
9
+ *
10
+ * Token choice (ADR-104 D3, recorded): the ring fills use the existing
11
+ * ACCENT-TINT tier — `rgb(var(--accent) / α)` — NOT a new `--choropleth-ring-*`
12
+ * token tier, so no new public token shape and no ADR proposal is required. No
13
+ * hex / oklch literal lands in this SVG; every colour references a token.
14
+ *
15
+ * <Choropleth /> // radius (concentric circles)
16
+ * <Choropleth mode="isochrone" /> // drive-time (concentric polygons)
17
+ */
18
+
19
+ import type { CSSProperties } from 'react'
20
+
21
+ export interface ChoroplethProps {
22
+ /** `radius` → concentric circles; `isochrone` → concentric polygons. */
23
+ mode?: 'radius' | 'isochrone'
24
+ className?: string
25
+ style?: CSSProperties
26
+ }
27
+
28
+ const RING_FILLS = ['rgb(var(--accent) / 0.45)', 'rgb(var(--accent) / 0.28)', 'rgb(var(--accent) / 0.14)']
29
+ const RING_STROKE = 'rgb(var(--accent) / 0.5)'
30
+ const GRID_STROKE = 'rgb(var(--foreground) / 0.04)'
31
+ const RADII = [34, 24, 14]
32
+
33
+ export default function Choropleth({ mode = 'radius', className, style }: ChoroplethProps) {
34
+ return (
35
+ <svg
36
+ viewBox="0 0 240 84"
37
+ width="100%"
38
+ height="100%"
39
+ preserveAspectRatio="xMidYMid slice"
40
+ className={className}
41
+ style={{ display: 'block', ...style }}
42
+ aria-hidden="true"
43
+ >
44
+ {[20, 60, 100, 140, 180, 220].map((x) => (
45
+ <line key={`v${x}`} x1={x} y1="0" x2={x} y2="84" stroke={GRID_STROKE} strokeWidth="1" />
46
+ ))}
47
+ {[21, 42, 63].map((y) => (
48
+ <line key={`h${y}`} x1="0" y1={y} x2="240" y2={y} stroke={GRID_STROKE} strokeWidth="1" />
49
+ ))}
50
+ {mode === 'radius'
51
+ ? RADII.map((r, i) => (
52
+ <circle key={r} cx="120" cy="42" r={r} fill={RING_FILLS[i] ?? RING_STROKE} stroke={RING_STROKE} strokeWidth="1" />
53
+ ))
54
+ : [0, 1, 2].map((i) => {
55
+ const s = 1 - i * 0.32
56
+ const pts = `120,${42 - 30 * s} ${120 + 40 * s},${42 - 8 * s} ${120 + 28 * s},${42 + 26 * s} ${120 - 30 * s},${42 + 22 * s} ${120 - 42 * s},${42 - 6 * s}`
57
+ return <polygon key={i} points={pts} fill={RING_FILLS[i] ?? RING_STROKE} stroke={RING_STROKE} strokeWidth="1" />
58
+ })}
59
+ <circle cx="120" cy="42" r="3" fill="rgb(var(--accent))" stroke="rgb(var(--surface-on-accent))" strokeWidth="1.5" />
60
+ </svg>
61
+ )
62
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * CodeBlock — syntax-highlighted code block with language label + copy button.
3
+ * MarkdownCode — react-markdown `code` element adapter (inline + fenced).
4
+ *
5
+ * Uses PrismLight (synchronous, tree-shakeable) so only registered languages
6
+ * are bundled. Theme follows the html data-theme attribute via a local
7
+ * MutationObserver hook.
8
+ *
9
+ * Promoted from brand-ai-extension (ADR-036 D20).
10
+ */
11
+
12
+ import { useEffect, useState, type HTMLAttributes } from 'react'
13
+ import { Check, Copy } from 'lucide-react'
14
+ import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'
15
+ import { vscDarkPlus, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
16
+
17
+ import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash'
18
+ import css from 'react-syntax-highlighter/dist/esm/languages/prism/css'
19
+ import diff from 'react-syntax-highlighter/dist/esm/languages/prism/diff'
20
+ import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript'
21
+ import json from 'react-syntax-highlighter/dist/esm/languages/prism/json'
22
+ import jsx from 'react-syntax-highlighter/dist/esm/languages/prism/jsx'
23
+ import markdown from 'react-syntax-highlighter/dist/esm/languages/prism/markdown'
24
+ import markup from 'react-syntax-highlighter/dist/esm/languages/prism/markup'
25
+ import python from 'react-syntax-highlighter/dist/esm/languages/prism/python'
26
+ import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql'
27
+ import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx'
28
+ import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript'
29
+ import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml'
30
+
31
+ import { cn } from './lib/utils'
32
+
33
+ /**
34
+ * Reads the html data-theme attribute and re-fires whenever it changes.
35
+ * Kept local until a workspace-wide useTheme hook is promoted.
36
+ */
37
+ function useDataTheme(): 'light' | 'dark' {
38
+ const read = (): 'light' | 'dark' => {
39
+ if (typeof document === 'undefined') return 'dark'
40
+ const attr = document.documentElement.getAttribute('data-theme')
41
+ return attr === 'light' ? 'light' : 'dark'
42
+ }
43
+
44
+ const [theme, setTheme] = useState<'light' | 'dark'>(read)
45
+
46
+ useEffect(() => {
47
+ if (typeof window === 'undefined') return
48
+ const observer = new MutationObserver(() => setTheme(read()))
49
+ observer.observe(document.documentElement, {
50
+ attributes: true,
51
+ attributeFilter: ['data-theme'],
52
+ })
53
+ return () => observer.disconnect()
54
+ }, [])
55
+
56
+ return theme
57
+ }
58
+
59
+ // Register the language allowlist once at module scope.
60
+ const LANGUAGES: Record<string, unknown> = {
61
+ bash,
62
+ shell: bash,
63
+ sh: bash,
64
+ css,
65
+ diff,
66
+ javascript,
67
+ js: javascript,
68
+ json,
69
+ jsx,
70
+ markdown,
71
+ md: markdown,
72
+ html: markup,
73
+ xml: markup,
74
+ python,
75
+ py: python,
76
+ sql,
77
+ tsx,
78
+ typescript,
79
+ ts: typescript,
80
+ yaml,
81
+ yml: yaml,
82
+ }
83
+
84
+ for (const [name, lang] of Object.entries(LANGUAGES)) {
85
+ SyntaxHighlighter.registerLanguage(name, lang as never)
86
+ }
87
+
88
+ const LANGUAGE_LABELS: Record<string, string> = {
89
+ bash: 'bash',
90
+ sh: 'shell',
91
+ css: 'css',
92
+ diff: 'diff',
93
+ html: 'html',
94
+ javascript: 'javascript',
95
+ js: 'javascript',
96
+ json: 'json',
97
+ jsx: 'jsx',
98
+ markdown: 'markdown',
99
+ md: 'markdown',
100
+ python: 'python',
101
+ py: 'python',
102
+ shell: 'shell',
103
+ sql: 'sql',
104
+ tsx: 'tsx',
105
+ ts: 'typescript',
106
+ typescript: 'typescript',
107
+ xml: 'xml',
108
+ yaml: 'yaml',
109
+ yml: 'yaml',
110
+ }
111
+
112
+ export interface CodeBlockProps {
113
+ /** Lowercased language identifier, e.g. "typescript" or "json". */
114
+ language?: string
115
+ /** Raw source text. */
116
+ children: string
117
+ }
118
+
119
+ export function CodeBlock({ language, children }: CodeBlockProps) {
120
+ const [copied, setCopied] = useState(false)
121
+ const theme = useDataTheme()
122
+ const syntaxStyle = theme === 'light' ? oneLight : vscDarkPlus
123
+ const lang = (language ?? '').toLowerCase()
124
+ const supported = lang in LANGUAGES
125
+ const label = (LANGUAGE_LABELS[lang] ?? lang) || 'plaintext'
126
+ const source = String(children).replace(/\n$/, '')
127
+
128
+ const handleCopy = async () => {
129
+ try {
130
+ await navigator.clipboard.writeText(source)
131
+ setCopied(true)
132
+ setTimeout(() => setCopied(false), 2000)
133
+ } catch {
134
+ // Clipboard can fail in insecure contexts — silently no-op.
135
+ }
136
+ }
137
+
138
+ return (
139
+ <div
140
+ className="my-4 overflow-hidden text-sm"
141
+ style={{
142
+ background: 'rgb(var(--bg-elevated))',
143
+ border: '1px solid rgb(var(--border))',
144
+ borderRadius: 'var(--radius-md)',
145
+ }}
146
+ >
147
+ {/* Header: language label + copy button */}
148
+ <div
149
+ className="flex items-center justify-between px-3 py-1.5 text-xs"
150
+ style={{
151
+ background: 'rgb(var(--muted))',
152
+ borderBottom: '1px solid rgb(var(--border))',
153
+ color: 'rgb(var(--muted-foreground))',
154
+ }}
155
+ >
156
+ <span className="font-mono lowercase">{label}</span>
157
+ <button
158
+ type="button"
159
+ onClick={handleCopy}
160
+ className={cn(
161
+ 'flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors',
162
+ !copied && 'hover:text-[rgb(var(--foreground))]',
163
+ )}
164
+ style={copied ? { color: 'rgb(var(--success))' } : undefined}
165
+ aria-label={copied ? 'Copied' : 'Copy code'}
166
+ >
167
+ {copied ? (
168
+ <>
169
+ <Check className="h-3.5 w-3.5" /> Copied
170
+ </>
171
+ ) : (
172
+ <>
173
+ <Copy className="h-3.5 w-3.5" /> Copy
174
+ </>
175
+ )}
176
+ </button>
177
+ </div>
178
+
179
+ {/* Body */}
180
+ {supported ? (
181
+ <SyntaxHighlighter
182
+ language={lang}
183
+ style={syntaxStyle}
184
+ customStyle={{
185
+ margin: 0,
186
+ padding: '0.875rem 1rem',
187
+ background: 'transparent',
188
+ fontSize: '0.8125rem',
189
+ lineHeight: 1.6,
190
+ }}
191
+ codeTagProps={{
192
+ style: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' },
193
+ }}
194
+ >
195
+ {source}
196
+ </SyntaxHighlighter>
197
+ ) : (
198
+ <pre
199
+ className="overflow-x-auto px-4 py-3.5 font-mono text-[0.8125rem] leading-relaxed"
200
+ style={{ color: 'rgb(var(--foreground))' }}
201
+ >
202
+ {source}
203
+ </pre>
204
+ )}
205
+ </div>
206
+ )
207
+ }
208
+
209
+ /**
210
+ * MarkdownCode — react-markdown code element adapter.
211
+ *
212
+ * Inline code (single backtick, no className) renders as a small chip.
213
+ * Fenced code (triple backtick, className="language-xxx") delegates to
214
+ * CodeBlock for syntax highlighting + copy button.
215
+ */
216
+ export function MarkdownCode({ className, children }: HTMLAttributes<HTMLElement>) {
217
+ if (!className) {
218
+ return (
219
+ <code
220
+ className="rounded px-1.5 py-0.5 font-mono text-[0.85em]"
221
+ style={{
222
+ background: 'rgb(var(--muted))',
223
+ color: 'rgb(var(--foreground))',
224
+ borderRadius: 'var(--radius-xs)',
225
+ }}
226
+ >
227
+ {children}
228
+ </code>
229
+ )
230
+ }
231
+
232
+ const match = /language-([\w-]+)/.exec(className)
233
+ const language = match?.[1]
234
+ return <CodeBlock {...(language !== undefined ? { language } : {})}>{String(children)}</CodeBlock>
235
+ }