@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podoba/react",
3
- "version": "0.0.37",
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.37",
32
- "@podoba/tailwind": "^0.0.37",
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, setOpen] = useState(false)
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.5 md:pr-3 md:pl-4.5 md:shadow-none'
43
- : 'min-h-16 py-2.5 pr-3 pl-4.5',
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
- <p className="min-w-0 text-heading4 font-medium leading-5 tracking-tight text-fg-on-brand">
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 border-b border-border pb-3' : 'gap-8'}>{children}</Group>
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 border-b border-border pb-3' : undefined}>{children}</Group>
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
- export const SettingsDialogLabel = uic('p', { displayName: 'SettingsDialogLabel', baseClass: 'm-0 text-panel-heading font-medium text-fg' })
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
- export function SettingsDialogHint({ style, ...props }: ComponentProps<typeof Hint>) {
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
- /** Compact launcher card. Missing artwork is not replaced by synthetic imagery. */
47
- export function CatalogLaunchCard({ title, description, details, action }: { title: string; description?: string; details: ReactNode; action: ReactNode }) {
48
- return <SummaryCard data-testid="scenario-launch-card" className="relative h-70 min-h-70 max-h-70 transition-colors duration-200 focus-within:ring-2 focus-within:ring-ring">
49
- <SummaryHead><SummaryTitle asChild><h3>{title}</h3></SummaryTitle></SummaryHead>
50
- <SummaryTail>{description ? <p className="m-0">{description}</p> : null}<span>{details}</span></SummaryTail>
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";