@podoba/react 0.0.37 → 0.0.38
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/package.json +3 -3
- package/src/components/context-menu.tsx +18 -1
- package/src/components/cta-pill.tsx +4 -3
- package/src/components/preview-motion.tsx +215 -0
- package/src/components/settings-dialog-surface.tsx +12 -5
- package/src/components/template-catalog-card.tsx +15 -5
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.38",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
|
|
6
6
|
"repository": {
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"typecheck": "tsc --build"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@podoba/tokens": "^0.0.
|
|
32
|
-
"@podoba/tailwind": "^0.0.
|
|
31
|
+
"@podoba/tokens": "^0.0.38",
|
|
32
|
+
"@podoba/tailwind": "^0.0.38",
|
|
33
33
|
"react-aria-components": "1.18.0",
|
|
34
34
|
"class-variance-authority": "0.7.1",
|
|
35
35
|
"clsx": "2.1.1",
|
|
@@ -96,6 +96,11 @@ interface ContextMenuWrapperProps {
|
|
|
96
96
|
soonLabel?: ReactNode
|
|
97
97
|
'aria-label'?: string
|
|
98
98
|
className?: string
|
|
99
|
+
/**
|
|
100
|
+
* Notified when the wrapper's menu opens or closes. `target` is the element that
|
|
101
|
+
* was right-clicked (null on close), so a surface can mark the item the menu acts on.
|
|
102
|
+
*/
|
|
103
|
+
onOpenChange?: (isOpen: boolean, target: HTMLElement | null) => void
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
const itemId = (item: ContextMenuItem, fallback: number): string => item.id ?? item.key ?? String(fallback)
|
|
@@ -353,8 +358,17 @@ function WrapperContextMenu({
|
|
|
353
358
|
soonLabel,
|
|
354
359
|
'aria-label': ariaLabel = 'Actions',
|
|
355
360
|
className,
|
|
361
|
+
onOpenChange,
|
|
356
362
|
}: ContextMenuWrapperProps): React.JSX.Element {
|
|
357
|
-
const [isOpen,
|
|
363
|
+
const [isOpen, setOpenState] = useState(false)
|
|
364
|
+
const onOpenChangeRef = useRef(onOpenChange)
|
|
365
|
+
onOpenChangeRef.current = onOpenChange
|
|
366
|
+
const targetRef = useRef<HTMLElement | null>(null)
|
|
367
|
+
const setOpen = useCallback((open: boolean) => {
|
|
368
|
+
setOpenState(open)
|
|
369
|
+
if (!open) targetRef.current = null
|
|
370
|
+
onOpenChangeRef.current?.(open, open ? targetRef.current : null)
|
|
371
|
+
}, [])
|
|
358
372
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
359
373
|
const popoverRef = useRef<HTMLElement>(null)
|
|
360
374
|
const [position, setPosition] = useState({ x: 0, y: 0 })
|
|
@@ -374,6 +388,7 @@ function WrapperContextMenu({
|
|
|
374
388
|
setResolved(groupsRef.current({ target }))
|
|
375
389
|
}
|
|
376
390
|
setPosition({ x, y })
|
|
391
|
+
targetRef.current = target
|
|
377
392
|
setOpen(true)
|
|
378
393
|
}
|
|
379
394
|
|
|
@@ -434,6 +449,8 @@ function WrapperContextMenu({
|
|
|
434
449
|
setResolved(groupsRef.current({ target }))
|
|
435
450
|
}
|
|
436
451
|
setPosition({ x: clientX, y: clientY })
|
|
452
|
+
targetRef.current = target
|
|
453
|
+
onOpenChangeRef.current?.(true, target)
|
|
437
454
|
}
|
|
438
455
|
document.addEventListener('contextmenu', handle, true)
|
|
439
456
|
return () => document.removeEventListener('contextmenu', handle, true)
|
|
@@ -39,11 +39,12 @@ export function CtaPill({ lead, emphasis, tail, children, mobileHeader = false }
|
|
|
39
39
|
className={[
|
|
40
40
|
'flex h-full w-full items-center justify-between gap-nav-x rounded-lg bg-brand-green',
|
|
41
41
|
mobileHeader
|
|
42
|
-
? 'min-h-mobile-cta px-12 py-5 shadow-mobile-cta md:min-h-16 md:py-2.
|
|
43
|
-
: 'min-h-16 py-2.
|
|
42
|
+
? 'min-h-mobile-cta px-12 py-5 shadow-mobile-cta md:min-h-16 md:py-2.75 md:pr-3 md:pl-4.5 md:shadow-none'
|
|
43
|
+
: 'min-h-16 py-2.75 pr-3 pl-4.5',
|
|
44
44
|
].join(' ')}
|
|
45
45
|
>
|
|
46
|
-
|
|
46
|
+
{/* gs `CTA`: 11px 12px 11px 18px box, 18px/20px medium heading at -0.36px (-0.02em). */}
|
|
47
|
+
<p className="min-w-0 text-heading4 font-medium leading-5 tracking-[-0.02em] text-fg-on-brand">
|
|
47
48
|
{lead} <span className="font-semibold">{emphasis}</span> {tail}
|
|
48
49
|
</p>
|
|
49
50
|
<div className="shrink-0">{children}</div>
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Preview loading and reveal motion, ported from gs-platform `PreviewSkeletonGradient`
|
|
5
|
+
* and `TemplatePreview`'s particulate reveal.
|
|
6
|
+
*
|
|
7
|
+
* - {@link PreviewSkeleton}: the mint shimmer shown while a template preview renders
|
|
8
|
+
* (80deg gradient, 240% background, 2400ms ease-in-out pan). Pair it with
|
|
9
|
+
* {@link useMinimumVisible} for the source 500ms minimum.
|
|
10
|
+
* - {@link PreviewReveal}: wraps rendered artwork. When `revealKey` changes (a new
|
|
11
|
+
* preview decoded) or the artwork is pressed, the artwork dims to 22% with
|
|
12
|
+
* `saturate(.7) contrast(.86)` for `durationMs` (source 1400ms) while a blurred
|
|
13
|
+
* green/blue sweep and 64 resolving particles play over it, then fades back in.
|
|
14
|
+
*
|
|
15
|
+
* All of it is ornamental: every layer is `aria-hidden`, nothing here carries content,
|
|
16
|
+
* and under `prefers-reduced-motion: reduce` neither the timed states nor any motion
|
|
17
|
+
* run (the skeleton shows the source static gradient, the reveal never engages).
|
|
18
|
+
* Colours are the source literals; the skeleton is a fixed light surface by design.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
function usePrefersReducedMotion(): boolean {
|
|
22
|
+
const [reduce, setReduce] = useState(false)
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (typeof window === 'undefined' || !window.matchMedia) return
|
|
25
|
+
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
26
|
+
const change = () => setReduce(media.matches)
|
|
27
|
+
change()
|
|
28
|
+
media.addEventListener('change', change)
|
|
29
|
+
return () => media.removeEventListener('change', change)
|
|
30
|
+
}, [])
|
|
31
|
+
return reduce
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Keeps `active` true for at least `minMs` after it turns on (source 500ms skeleton
|
|
36
|
+
* minimum), so a cached preview does not flash a one-frame skeleton. Under reduced
|
|
37
|
+
* motion it returns `active` unchanged.
|
|
38
|
+
*/
|
|
39
|
+
export function useMinimumVisible(active: boolean, minMs = 500): boolean {
|
|
40
|
+
const reduce = usePrefersReducedMotion()
|
|
41
|
+
const [held, setHeld] = useState(active)
|
|
42
|
+
const since = useRef<number | null>(active ? Date.now() : null)
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (reduce) {
|
|
45
|
+
setHeld(active)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (active) {
|
|
49
|
+
if (since.current === null) since.current = Date.now()
|
|
50
|
+
setHeld(true)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (since.current === null) {
|
|
54
|
+
setHeld(false)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
const remaining = minMs - (Date.now() - since.current)
|
|
58
|
+
if (remaining <= 0) {
|
|
59
|
+
since.current = null
|
|
60
|
+
setHeld(false)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
since.current = null
|
|
65
|
+
setHeld(false)
|
|
66
|
+
}, remaining)
|
|
67
|
+
return () => clearTimeout(timer)
|
|
68
|
+
}, [active, minMs, reduce])
|
|
69
|
+
return reduce ? active : held
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const SKELETON_ANIMATED =
|
|
73
|
+
'linear-gradient(80deg, #ffffff 0%, #ffffff 12.5%, #c7fee0 27.5%, #ffffff 41.5%, #ffffff 50%, #ffffff 62.5%, #c7fee0 77.5%, #ffffff 91.5%, #ffffff 100%)'
|
|
74
|
+
const SKELETON_STATIC = 'linear-gradient(80deg, #ffffff 25%, #c7fee0 55%, #ffffff 83%)'
|
|
75
|
+
|
|
76
|
+
export interface PreviewSkeletonProps {
|
|
77
|
+
/** Accessible status text (translated by the caller), e.g. "Rendering preview". */
|
|
78
|
+
label?: string
|
|
79
|
+
className?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The source mint shimmer. Fills its positioned parent. */
|
|
83
|
+
export function PreviewSkeleton({ label, className }: PreviewSkeletonProps) {
|
|
84
|
+
const reduce = usePrefersReducedMotion()
|
|
85
|
+
const style: CSSProperties = reduce
|
|
86
|
+
? { backgroundImage: SKELETON_STATIC, backgroundSize: '100% 100%', backgroundPosition: '50% 50%' }
|
|
87
|
+
: { backgroundImage: SKELETON_ANIMATED, backgroundSize: '240% 240%' }
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
role={label ? 'status' : undefined}
|
|
91
|
+
aria-label={label}
|
|
92
|
+
aria-hidden={label ? undefined : true}
|
|
93
|
+
data-preview-skeleton=""
|
|
94
|
+
className={['absolute inset-0 overflow-hidden', reduce ? '' : 'animate-preview-skeleton-shimmer', className]
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.join(' ')}
|
|
97
|
+
style={style}
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const PARTICLES = Array.from({ length: 64 }, (_, index) => ({
|
|
103
|
+
x: 3 + ((index * 37) % 94),
|
|
104
|
+
y: 3 + ((index * 53) % 94),
|
|
105
|
+
size: 6 + (index % 7) * 2,
|
|
106
|
+
dx: ((index * 29) % 48) - 24,
|
|
107
|
+
dy: ((index * 17) % 44) - 22,
|
|
108
|
+
delay: (index % 16) * 45,
|
|
109
|
+
duration: 1200 + (index % 5) * 140,
|
|
110
|
+
color: index % 5 === 0 ? 'var(--color-accent-blue)' : index % 3 === 0 ? 'var(--color-brand-green)' : '#0d0d0d',
|
|
111
|
+
square: index % 4 === 3,
|
|
112
|
+
wide: index % 6 === 5,
|
|
113
|
+
}))
|
|
114
|
+
|
|
115
|
+
export interface PreviewRevealProps {
|
|
116
|
+
children: ReactNode
|
|
117
|
+
/**
|
|
118
|
+
* Changing this (e.g. to the decoded preview URL or a render revision) plays the
|
|
119
|
+
* reveal. `null`/`undefined` means nothing is rendered yet.
|
|
120
|
+
*/
|
|
121
|
+
revealKey?: string | number | null
|
|
122
|
+
/** How long the reveal stays visible (source 1400ms). */
|
|
123
|
+
durationMs?: number
|
|
124
|
+
/** Replay the reveal when the artwork is pressed (source behaviour, default true). */
|
|
125
|
+
replayOnPress?: boolean
|
|
126
|
+
className?: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function PreviewReveal({ children, revealKey, durationMs = 1400, replayOnPress = true, className }: PreviewRevealProps) {
|
|
130
|
+
const reduce = usePrefersReducedMotion()
|
|
131
|
+
const [revealing, setRevealing] = useState(false)
|
|
132
|
+
const [run, setRun] = useState(0)
|
|
133
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
134
|
+
|
|
135
|
+
const play = () => {
|
|
136
|
+
if (reduce) return
|
|
137
|
+
setRun((n) => n + 1)
|
|
138
|
+
setRevealing(true)
|
|
139
|
+
if (timer.current) clearTimeout(timer.current)
|
|
140
|
+
timer.current = setTimeout(() => setRevealing(false), durationMs)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (revealKey === null || revealKey === undefined) return
|
|
145
|
+
play()
|
|
146
|
+
// Replays only when the artwork identity changes; `play` is recreated each render.
|
|
147
|
+
}, [revealKey])
|
|
148
|
+
|
|
149
|
+
useEffect(() => () => {
|
|
150
|
+
if (timer.current) clearTimeout(timer.current)
|
|
151
|
+
}, [])
|
|
152
|
+
|
|
153
|
+
const active = revealing && !reduce
|
|
154
|
+
return (
|
|
155
|
+
<div
|
|
156
|
+
className={['relative', className].filter(Boolean).join(' ')}
|
|
157
|
+
data-revealing={active || undefined}
|
|
158
|
+
onPointerDown={replayOnPress ? play : undefined}
|
|
159
|
+
>
|
|
160
|
+
<div
|
|
161
|
+
className={`h-full w-full transition-[opacity,filter] duration-220 ease-[ease] motion-reduce:transition-none ${
|
|
162
|
+
active ? 'opacity-22 saturate-70 contrast-86' : ''
|
|
163
|
+
}`}
|
|
164
|
+
>
|
|
165
|
+
{children}
|
|
166
|
+
</div>
|
|
167
|
+
<div
|
|
168
|
+
aria-hidden="true"
|
|
169
|
+
className={`pointer-events-none absolute inset-0 z-2 transition-opacity duration-180 ease-[ease] ${active ? 'opacity-100' : 'opacity-0'}`}
|
|
170
|
+
>
|
|
171
|
+
{active ? (
|
|
172
|
+
<div
|
|
173
|
+
key={run}
|
|
174
|
+
className="absolute inset-0 overflow-hidden"
|
|
175
|
+
style={{
|
|
176
|
+
background:
|
|
177
|
+
'radial-gradient(circle at 18% 24%, color-mix(in srgb, var(--color-accent-blue) 18%, transparent), transparent 22%), radial-gradient(circle at 82% 74%, color-mix(in srgb, var(--color-brand-green) 22%, transparent), transparent 24%)',
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
<div
|
|
181
|
+
className="absolute -top-[36%] -bottom-[36%] -left-[32%] w-[44%] animate-preview-sweep opacity-90 blur-[14px]"
|
|
182
|
+
style={{
|
|
183
|
+
background:
|
|
184
|
+
'linear-gradient(90deg, transparent 0%, color-mix(in srgb, #ffffff 52%, transparent) 42%, color-mix(in srgb, var(--color-brand-green) 58%, transparent) 54%, color-mix(in srgb, var(--color-accent-blue) 32%, transparent) 66%, transparent 100%)',
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
{PARTICLES.map((p, index) => (
|
|
188
|
+
<span
|
|
189
|
+
// Fixed, index-derived particle field; the order never changes.
|
|
190
|
+
key={index}
|
|
191
|
+
className={`absolute animate-preview-particle opacity-58 mix-blend-screen ${p.square ? 'rounded-[4px] rotate-24' : p.wide ? 'rounded-[4px]' : 'rounded-full'}`}
|
|
192
|
+
style={
|
|
193
|
+
{
|
|
194
|
+
left: `${p.x}%`,
|
|
195
|
+
top: `${p.y}%`,
|
|
196
|
+
width: p.wide ? p.size * 1.8 : p.size,
|
|
197
|
+
height: p.size,
|
|
198
|
+
marginLeft: -p.size / 2,
|
|
199
|
+
marginTop: -p.size / 2,
|
|
200
|
+
background: p.color,
|
|
201
|
+
boxShadow: `0 0 0 1px color-mix(in srgb, #ffffff 38%, transparent), 0 0 18px color-mix(in srgb, ${p.color} 72%, transparent)`,
|
|
202
|
+
animationDuration: `${p.duration}ms`,
|
|
203
|
+
animationDelay: `${p.delay}ms`,
|
|
204
|
+
'--particle-dx': `${p.dx}px`,
|
|
205
|
+
'--particle-dy': `${p.dy}px`,
|
|
206
|
+
} as CSSProperties
|
|
207
|
+
}
|
|
208
|
+
/>
|
|
209
|
+
))}
|
|
210
|
+
</div>
|
|
211
|
+
) : null}
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
@@ -137,9 +137,10 @@ export function WizardFormDialog({ isOpen, onOpenChange, isPending = false, isEx
|
|
|
137
137
|
</ModalOverlay>
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// The source New section dialog separates its groups by spacing only, no hairlines.
|
|
140
141
|
export function SettingsDialogIdentity({ children }: { children: ReactNode }) {
|
|
141
142
|
const { variant } = useContext(LayoutContext)
|
|
142
|
-
return <Group className={variant === 'create' ? 'gap-4
|
|
143
|
+
return <Group className={variant === 'create' ? 'gap-4 pb-3' : 'gap-8'}>{children}</Group>
|
|
143
144
|
}
|
|
144
145
|
export function SettingsDialogDescription({ children }: { children: ReactNode }) {
|
|
145
146
|
const { variant } = useContext(LayoutContext)
|
|
@@ -152,7 +153,7 @@ export function SettingsDialogColumns({ children, metadata = false }: { children
|
|
|
152
153
|
}
|
|
153
154
|
export function SettingsDialogGroup({ children }: { children: ReactNode }) {
|
|
154
155
|
const { variant } = useContext(LayoutContext)
|
|
155
|
-
return <Group className={variant === 'create' ? 'gap-3
|
|
156
|
+
return <Group className={variant === 'create' ? 'gap-3 pb-3' : undefined}>{children}</Group>
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
/** Two-column production metadata grid from the split settings family.
|
|
@@ -173,12 +174,18 @@ export function SettingsDialogControl({ children }: { children: ReactNode }) {
|
|
|
173
174
|
const { variant } = useContext(LayoutContext)
|
|
174
175
|
return <Group className={variant === 'edit' ? 'mt-6' : undefined}>{children}</Group>
|
|
175
176
|
}
|
|
176
|
-
|
|
177
|
+
const Label = uic('p', { displayName: 'SettingsDialogLabel', baseClass: 'm-0 text-panel-heading font-medium text-fg' })
|
|
178
|
+
/** Group label. The create variant uses the source New section group label: 16px/22px semibold. */
|
|
179
|
+
export function SettingsDialogLabel({ className, ...props }: ComponentProps<typeof Label>) {
|
|
180
|
+
const { variant } = useContext(LayoutContext)
|
|
181
|
+
return <Label {...props} className={[variant === 'create' ? 'text-body leading-5.5 font-semibold' : '', className].filter(Boolean).join(' ') || undefined} />
|
|
182
|
+
}
|
|
177
183
|
export const SettingsDialogEmphasis = uic('span', { displayName: 'SettingsDialogEmphasis', baseClass: 'text-fg' })
|
|
178
184
|
const Hint = uic('p', { displayName: 'SettingsDialogHint', baseClass: 'm-0 text-small font-normal text-fg-workflow-muted' })
|
|
179
|
-
|
|
185
|
+
/** Helper line. The create variant uses the source 16px/22px `#242423` hint. */
|
|
186
|
+
export function SettingsDialogHint({ style, className, ...props }: ComponentProps<typeof Hint>) {
|
|
180
187
|
const { variant } = useContext(LayoutContext)
|
|
181
|
-
return <Hint {...props} style={{ ...(variant === 'basic' ? { maxWidth: '48ch' } : {}), ...style }} />
|
|
188
|
+
return <Hint {...props} className={[variant === 'create' ? 'text-body leading-5.5 text-surface-inverted' : '', className].filter(Boolean).join(' ') || undefined} style={{ ...(variant === 'basic' ? { maxWidth: '48ch' } : {}), ...style }} />
|
|
182
189
|
}
|
|
183
190
|
export const SettingsDialogAction = uic(Button, { displayName: 'SettingsDialogAction', baseClass: 'h-11.5 rounded-full px-6 text-base font-medium leading-5', style: { letterSpacing: 0 } })
|
|
184
191
|
|
|
@@ -43,11 +43,21 @@ export function CatalogSummaryCard({ title, badge, description, details }: { tit
|
|
|
43
43
|
return <SummaryCard><SummaryHead><SummaryTitle>{title}</SummaryTitle><span className="shrink-0">{badge}</span></SummaryHead><SummaryTail>{description ? <p className="m-0">{description}</p> : null}<span>{details}</span></SummaryTail></SummaryCard>
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Compact launcher card. Missing artwork is not replaced by synthetic imagery.
|
|
48
|
+
* `preview` (optional, caller-rendered real artwork) fills the card as a cover layer
|
|
49
|
+
* under the source gs Tile image gradient (transparent → 30% → 50% black), and the
|
|
50
|
+
* title and tail switch to the source white / white 88% ink.
|
|
51
|
+
*/
|
|
52
|
+
export function CatalogLaunchCard({ title, description, details, action, preview }: { title: string; description?: string; details: ReactNode; action: ReactNode; preview?: ReactNode }) {
|
|
53
|
+
const covered = preview != null
|
|
54
|
+
return <SummaryCard data-testid="scenario-launch-card" data-preview={covered || undefined} className="relative isolate h-70 min-h-70 max-h-70 transition-colors duration-200 focus-within:ring-2 focus-within:ring-ring">
|
|
55
|
+
{covered ? <>
|
|
56
|
+
<div aria-hidden="true" className="absolute inset-0 -z-10 overflow-hidden [&_canvas]:size-full [&_canvas]:object-cover [&_img]:size-full [&_img]:object-cover">{preview}</div>
|
|
57
|
+
<div aria-hidden="true" className="absolute inset-0 -z-10 bg-linear-to-b from-black/0 via-black/30 to-black/50" />
|
|
58
|
+
</> : null}
|
|
59
|
+
<SummaryHead><SummaryTitle asChild className={covered ? 'text-white' : undefined}><h3>{title}</h3></SummaryTitle></SummaryHead>
|
|
60
|
+
<SummaryTail className={covered ? 'text-white/88' : undefined}>{description ? <p className="m-0">{description}</p> : null}<span>{details}</span></SummaryTail>
|
|
51
61
|
{action}
|
|
52
62
|
</SummaryCard>
|
|
53
63
|
}
|
package/src/index.ts
CHANGED
|
@@ -67,6 +67,7 @@ export * from "./layout/persistent-page-shell";
|
|
|
67
67
|
export * from "./components/avatar";
|
|
68
68
|
export * from "./components/brand-page-header";
|
|
69
69
|
export * from "./components/count-up";
|
|
70
|
+
export * from "./components/preview-motion";
|
|
70
71
|
export * from "./components/cta-pill";
|
|
71
72
|
export * from "./components/stat-stack";
|
|
72
73
|
export * from "./components/icons";
|