@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.
- package/README.md +28 -0
- package/package.json +53 -0
- package/src/accordion.tsx +153 -0
- package/src/atoms.tsx +197 -0
- package/src/badge.tsx +91 -0
- package/src/brand-logo-tile.tsx +200 -0
- package/src/bulk-action-bar.tsx +104 -0
- package/src/button-group.tsx +46 -0
- package/src/button.tsx +93 -0
- package/src/calculator-shell-v2.tsx +380 -0
- package/src/calculator-shell.tsx +310 -0
- package/src/card.tsx +369 -0
- package/src/checkbox.tsx +114 -0
- package/src/chip-nav.tsx +347 -0
- package/src/choropleth.tsx +62 -0
- package/src/code-block.tsx +235 -0
- package/src/completion-ring.tsx +119 -0
- package/src/copy-field.tsx +169 -0
- package/src/data-grid/column-options-menu.tsx +127 -0
- package/src/data-grid/data-grid.tsx +391 -0
- package/src/data-grid/drawer-panel.tsx +146 -0
- package/src/data-grid/index.ts +29 -0
- package/src/data-grid/sortable-parts.tsx +215 -0
- package/src/data-grid/table-body.tsx +238 -0
- package/src/data-grid/table-elements.tsx +135 -0
- package/src/data-grid/table-header.tsx +95 -0
- package/src/data-grid/table-overlays.tsx +78 -0
- package/src/data-grid/table-parts.tsx +15 -0
- package/src/data-grid/table-summary-footer.tsx +101 -0
- package/src/data-grid/table-view.tsx +176 -0
- package/src/data-grid/types.ts +141 -0
- package/src/data-grid/use-grid-columns.ts +311 -0
- package/src/data-grid/use-grid-editing.ts +154 -0
- package/src/device-frame.tsx +274 -0
- package/src/dropdown-button.tsx +221 -0
- package/src/dropdown-menu.tsx +450 -0
- package/src/empty-state.tsx +85 -0
- package/src/file-upload/file-drop-zone.tsx +156 -0
- package/src/file-upload/file-preview-grid.tsx +39 -0
- package/src/file-upload/file-preview-item.tsx +175 -0
- package/src/file-upload/file-thumbnail.tsx +98 -0
- package/src/file-upload/file-upload-button.tsx +95 -0
- package/src/file-upload/index.ts +13 -0
- package/src/file-upload/use-file-upload.ts +371 -0
- package/src/filter-bar.tsx +125 -0
- package/src/filter-dropdown.tsx +289 -0
- package/src/floating-drawer.tsx +420 -0
- package/src/folder-card.tsx +188 -0
- package/src/folder-tree-picker.tsx +511 -0
- package/src/format.ts +117 -0
- package/src/frame-stack.tsx +73 -0
- package/src/hero-form-card.tsx +471 -0
- package/src/index.ts +372 -0
- package/src/input.tsx +130 -0
- package/src/kbd.tsx +41 -0
- package/src/lib/layout.ts +22 -0
- package/src/lib/utils.ts +6 -0
- package/src/markdown.tsx +214 -0
- package/src/menu-button.tsx +142 -0
- package/src/meta-previews/CreativeMedia.tsx +119 -0
- package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
- package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
- package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
- package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
- package/src/meta-previews/MetaFeedPreview.tsx +294 -0
- package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
- package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
- package/src/meta-previews/MetaReelPreview.tsx +750 -0
- package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
- package/src/meta-previews/MetaSearchPreview.tsx +113 -0
- package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
- package/src/meta-previews/ProfileAvatar.tsx +79 -0
- package/src/meta-previews/defaults.ts +27 -0
- package/src/meta-previews/index.ts +37 -0
- package/src/meta-previews/types.ts +219 -0
- package/src/metric-card.tsx +117 -0
- package/src/modal.tsx +131 -0
- package/src/option-tile.tsx +252 -0
- package/src/page-hero.tsx +78 -0
- package/src/page-shell.tsx +440 -0
- package/src/pagination.tsx +86 -0
- package/src/pill-button.tsx +103 -0
- package/src/profile-section.tsx +389 -0
- package/src/progress-bar.tsx +84 -0
- package/src/radio.tsx +172 -0
- package/src/range-slider.tsx +161 -0
- package/src/resizable.tsx +282 -0
- package/src/search-bar.tsx +81 -0
- package/src/segmented-pill.tsx +143 -0
- package/src/shell.tsx +464 -0
- package/src/slider.tsx +107 -0
- package/src/sortable-table.tsx +198 -0
- package/src/sortable.tsx +137 -0
- package/src/stat-row.tsx +132 -0
- package/src/stats-grid.tsx +38 -0
- package/src/step-loader.tsx +141 -0
- package/src/styles.css +562 -0
- package/src/tabs.tsx +143 -0
- package/src/tag-chip-input.tsx +394 -0
- package/src/textarea.tsx +39 -0
- package/src/toast.tsx +59 -0
- package/src/tokens.css +579 -0
package/src/chip-nav.tsx
ADDED
|
@@ -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
|
+
}
|