@podoba/react 0.0.36 → 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.36",
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.36",
32
- "@podoba/tailwind": "^0.0.36",
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",
@@ -10,10 +10,11 @@ import { uic } from '../utils/uic'
10
10
  export const Button = uic(RACButton, {
11
11
  displayName: 'Button',
12
12
  // gs `Button.module.scss`: pill radius, FIXED 13px / weight-400 text (`text-compact`)
13
- // on every size — size variants change PADDING ONLY, never the text scale. 200ms
14
- // ease-in-out transition.
13
+ // on every size — size variants change PADDING ONLY, never the text scale. The
14
+ // source eases background-color, color, box-shadow and border-color over 120ms `ease`.
15
15
  baseClass:
16
- 'inline-flex items-center justify-center gap-2 rounded-full text-compact leading-4 transition-all duration-200 ease-in-out ' +
16
+ 'inline-flex items-center justify-center gap-2 rounded-full text-compact leading-4 ' +
17
+ 'transition-[background-color,color,box-shadow,border-color] duration-120 ease-[ease] motion-reduce:transition-none ' +
17
18
  'outline-none data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2 ' +
18
19
  // Inactive states (disabled / pending) soften the LABEL ONLY and leave every
19
20
  // variant on its own surface. Two reasons:
@@ -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)
@@ -118,16 +123,17 @@ const MenuItem = uic(RACMenuItem, {
118
123
  // upstream API — a hand-written custom property has nowhere to be defined and
119
124
  // silently resolves to nothing.
120
125
  baseClass:
121
- 'flex min-h-8.5 cursor-pointer select-none items-center gap-2.5 rounded-[10px] px-3 py-1.5 ' +
126
+ 'flex min-h-8.5 min-w-0 cursor-pointer select-none items-center gap-2.5 rounded-[10px] px-3 py-1.5 ' +
122
127
  'text-compact leading-4 tracking-normal font-normal text-white outline-none ' +
123
128
  'data-[focused]:bg-[#2f2f2f] data-[focused]:font-medium data-[hovered]:bg-[#2f2f2f] data-[hovered]:font-medium ' +
124
129
  'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-45',
125
130
  variants: {
126
- // gs `--color-error-light` (#ffb5b5), a light red tuned for this dark panel.
127
- // `--color-danger-light` DOES exist, but it flips to a ~15% translucent red
128
- // under `[data-theme="dark"]` and would all but vanish here; `text-danger`
131
+ // gs `var(--color-error-light, #ffb5b5)`: the Manager defines `--color-error-light`
132
+ // as #fee2e2, so the rendered source ink is #fee2e2 (the #ffb5b5 fallback never
133
+ // applies). `--color-danger-light` DOES exist, but it flips to a ~15% translucent
134
+ // red under `[data-theme="dark"]` and would all but vanish here; `text-danger`
129
135
  // (#dc2626) is a light-surface red that fails contrast on #242424.
130
- destructive: { true: 'text-[#ffb5b5] data-[focused]:text-[#ffb5b5]' },
136
+ destructive: { true: 'text-[#fee2e2] data-[focused]:text-[#fee2e2]' },
131
137
  },
132
138
  }) as (
133
139
  props: React.ComponentProps<typeof RACMenuItem> & { destructive?: boolean },
@@ -142,7 +148,9 @@ const panelClass =
142
148
  'w-[258px] max-w-[calc(100vw-16px)] max-h-[calc(100vh-16px)] box-border overflow-y-auto rounded-xl ' +
143
149
  'bg-gradient-to-b from-[#242424] to-[#1f1f1f] ' +
144
150
  'px-1.5 py-2 shadow-[0_12px_28px_rgba(0,0,0,0.22)] outline-none'
145
- const menuClass = 'grid gap-0.5 outline-none'
151
+ // `minmax(0, 1fr)` tracks: an `auto` grid column grows to the items' nowrap
152
+ // min-content, so a long label plus a trailing pill pushed rows past the 258px panel.
153
+ const menuClass = 'grid grid-cols-[minmax(0,1fr)] gap-0.5 outline-none'
146
154
 
147
155
  const CursorPopoverSurface = uic(RACPopover, {
148
156
  displayName: 'ContextMenuPopoverSurface',
@@ -264,7 +272,7 @@ function ContextMenuBody({
264
272
  {groups.map((group, gi) => (
265
273
  // gs separates groups with an 18px top margin (`.group + .group`), not a
266
274
  // rule line — so the first group sits flush, the rest gain the gap.
267
- <RACMenuSection key={groupId(group, gi)} className={gi > 0 ? 'mt-4.5 grid gap-0.5' : 'grid gap-0.5'}>
275
+ <RACMenuSection key={groupId(group, gi)} className={gi > 0 ? 'mt-4.5 grid min-w-0 grid-cols-[minmax(0,1fr)] gap-0.5' : 'grid min-w-0 grid-cols-[minmax(0,1fr)] gap-0.5'}>
268
276
  {group.label ? (
269
277
  // The grid contributes 2px: 2px margin + 2px gap = source 4px below label.
270
278
  <Header className="mx-2 mt-1.5 mb-0.5 text-compact leading-4 tracking-normal font-medium text-white">{group.label}</Header>
@@ -350,8 +358,17 @@ function WrapperContextMenu({
350
358
  soonLabel,
351
359
  'aria-label': ariaLabel = 'Actions',
352
360
  className,
361
+ onOpenChange,
353
362
  }: ContextMenuWrapperProps): React.JSX.Element {
354
- 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
+ }, [])
355
372
  const wrapperRef = useRef<HTMLDivElement>(null)
356
373
  const popoverRef = useRef<HTMLElement>(null)
357
374
  const [position, setPosition] = useState({ x: 0, y: 0 })
@@ -371,6 +388,7 @@ function WrapperContextMenu({
371
388
  setResolved(groupsRef.current({ target }))
372
389
  }
373
390
  setPosition({ x, y })
391
+ targetRef.current = target
374
392
  setOpen(true)
375
393
  }
376
394
 
@@ -431,6 +449,8 @@ function WrapperContextMenu({
431
449
  setResolved(groupsRef.current({ target }))
432
450
  }
433
451
  setPosition({ x: clientX, y: clientY })
452
+ targetRef.current = target
453
+ onOpenChangeRef.current?.(true, target)
434
454
  }
435
455
  document.addEventListener('contextmenu', handle, true)
436
456
  return () => document.removeEventListener('contextmenu', handle, true)
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState, type ElementType } from 'react'
1
+ import { useEffect, useLayoutEffect, useRef, useState, type ElementType } from 'react'
2
2
 
3
3
  /**
4
4
  * CountUp — animates a number from 0 up to `value` on mount (and whenever `value`
@@ -20,6 +20,10 @@ export interface CountUpProps {
20
20
 
21
21
  const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3)
22
22
 
23
+ // Layout effect in the browser so the reset to 0 commits before the first paint
24
+ // (a passive effect painted the final value for one frame, then jumped to 0).
25
+ const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
26
+
23
27
  export function CountUp({
24
28
  value,
25
29
  durationMs = 900,
@@ -31,13 +35,14 @@ export function CountUp({
31
35
  const rafRef = useRef<number | null>(null)
32
36
  const startRef = useRef<number | null>(null)
33
37
 
34
- useEffect(() => {
38
+ useBrowserLayoutEffect(() => {
35
39
  if (typeof window === 'undefined') return
36
40
  const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
37
41
  if (reduce || durationMs <= 0) {
38
42
  setDisplay(value)
39
43
  return
40
44
  }
45
+ setDisplay(0)
41
46
  startRef.current = null
42
47
  const tick = (now: number) => {
43
48
  if (startRef.current === null) startRef.current = now
@@ -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>
@@ -40,14 +40,17 @@ import { uic } from '../utils/uic'
40
40
  * ```
41
41
  *
42
42
  * gs token map (this is the LIGHT menu, so the gs literals map cleanly to our
43
- * tokens): content bg white → `surface` · 1px `#eceae1` border → `border` · 8px
44
- * radius → `rounded-lg` · `shadow-lg` · item 12/16px padding → `py-3 px-4` · 6px
45
- * item radius → `rounded-md` · item hover `#f7f6f2` → `surface-card` · item text
46
- * `#0d0d0d` → `fg` · section label `#aba89c` 13px → `text-fg-muted text-compact`
43
+ * tokens): content bg `--color-background-secondary` (#f7f6f2) → `surface-card` ·
44
+ * 1px `#eceae1` border → `border` · 8px radius → `rounded-lg` · `shadow-lg` · item
45
+ * 12/16px padding → `py-3 px-4` · 16px/22px base text → `text-body leading-5.5` ·
46
+ * 6px item radius → `rounded-md` · item highlight `--color-background-hover`
47
+ * (#eceae1) → `surface-muted` · item text `#0d0d0d` → `fg` · section label 13px →
48
+ * `text-fg-muted text-compact`. Motion: gs `slideDownAndFade` in / `slideUpAndFade`
49
+ * out, 200ms `cubic-bezier(0.16, 1, 0.3, 1)` (`animate-dropdown-in` / `-out`).
47
50
  * (#25: the source grey is 2.10:1 on `surface`; a section label is read, not ornament)
48
51
  * · separator `#eceae1` → `border`. The destructive item uses `text-danger`
49
52
  * (`#dc2626`) — a light-surface red that reads correctly here (unlike the dark
50
- * ContextMenu, which needs the lighter `#ffb5b5`).
53
+ * ContextMenu, which needs the lighter `#fee2e2`).
51
54
  *
52
55
  * Presentational only (hard rule #1) — no app imports.
53
56
  */
@@ -55,18 +58,25 @@ import { uic } from '../utils/uic'
55
58
  /** Re-export of RAC `MenuTrigger`; owns the open/close state for the pair. */
56
59
  export const DropdownMenuTrigger = RACMenuTrigger
57
60
 
58
- // gs `.item`: 12/16px padding, 6px radius, base text on white; hover/focus wash
59
- // `#f7f6f2` (→ surface-card). We light up both `data-[focused]` (keyboard) and
60
- // `data-[hovered]` (pointer) so keyboard focus stays clearly visible. Item text
61
- // size follows the house light Select (`text-small`) rather than gs's 16px base so
62
- // the two light dropdowns stay consistent.
61
+ /** Shared gs dropdown panel skin (also used by the Topbar `UserMenu`). */
62
+ export const dropdownMenuPanelClass =
63
+ 'min-w-[220px] rounded-lg border border-border bg-surface-card p-2 shadow-lg outline-none ' +
64
+ 'data-[entering]:animate-dropdown-in data-[exiting]:animate-dropdown-out motion-reduce:animate-none'
65
+
66
+ // gs `.item`: 12/16px padding, 6px radius, 16px/22px base text; the Radix
67
+ // `[data-highlighted]` wash is `#eceae1` (→ surface-muted). Radix highlights on
68
+ // pointer hover and keyboard focus, never on a pointer open, so we key on
69
+ // `data-[hovered]` + `data-[focus-visible]` rather than RAC's plain `data-[focused]`
70
+ // (which also marks the first item when a mouse press opens the menu).
71
+ export const dropdownMenuItemClass =
72
+ 'flex cursor-pointer select-none items-center gap-2.5 rounded-md px-4 py-3 ' +
73
+ 'text-body leading-5.5 font-normal text-fg outline-none ' +
74
+ 'data-[focus-visible]:bg-surface-muted data-[hovered]:bg-surface-muted ' +
75
+ 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50'
76
+
63
77
  export const DropdownMenuItem = uic(RACMenuItem, {
64
78
  displayName: 'DropdownMenuItem',
65
- baseClass:
66
- 'flex cursor-pointer select-none items-center gap-2.5 rounded-md px-4 py-3 ' +
67
- 'text-small text-fg outline-none ' +
68
- 'data-[focused]:bg-surface-card data-[hovered]:bg-surface-card ' +
69
- 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
79
+ baseClass: dropdownMenuItemClass,
70
80
  variants: {
71
81
  // gs has no destructive item style on the light menu; we add the house danger
72
82
  // token (red text), which reads correctly on the white / cream surface.
@@ -128,11 +138,11 @@ export function DropdownMenu<T extends object>({
128
138
  }: DropdownMenuProps<T>) {
129
139
  return (
130
140
  // gs `.content`: min-width 220px, 8px padding (`p-2`), 8px radius, 1px
131
- // `#eceae1` border, white fill, `shadow-lg`.
141
+ // `#eceae1` border, `#f7f6f2` fill, `shadow-lg`, 200ms slide/fade both ways.
132
142
  <RACPopover
133
143
  placement={placement}
134
144
  className={
135
- 'min-w-[220px] rounded-lg border border-border bg-surface p-2 shadow-lg outline-none' +
145
+ dropdownMenuPanelClass +
136
146
  (popoverClassName ? ` ${popoverClassName}` : '')
137
147
  }
138
148
  >
@@ -9,6 +9,9 @@ export const filledFieldClasses =
9
9
  'border-0 bg-surface-card text-small font-normal leading-4.5 text-fg ' +
10
10
  'outline-none transition-colors duration-200 motion-reduce:transition-none placeholder:text-fg-muted placeholder:font-normal ' +
11
11
  'data-[hovered]:bg-surface-muted data-[focused]:bg-surface-card ' +
12
- 'focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 ' +
12
+ // `outline-none` above zeroes Tailwind v4's `--tw-outline-style`, so the focus
13
+ // width alone would still resolve to `outline-style: none`. Restate the solid
14
+ // style or the gs 2px focus outline never paints (WCAG 2.4.7).
15
+ 'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 ' +
13
16
  'data-[invalid]:ring-1 data-[invalid]:ring-danger data-[invalid]:outline-danger ' +
14
17
  'data-[disabled]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed'
@@ -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
+ }
@@ -52,9 +52,12 @@ const GridGlyph = () => (
52
52
  </svg>
53
53
  )
54
54
 
55
+ // gs `.tabButton`: inactive ink `#242423` (the light-theme neutral that
56
+ // `surface-inverted` carries; it flips to a light ink on the dark shell), selected
57
+ // and hovered `fg` on `surface-muted`, background + colour eased over 120ms `ease`.
55
58
  const tabBase =
56
- 'inline-flex h-7 shrink-0 items-center gap-2 rounded-sm px-nav-x py-1.5 text-compact font-normal text-fg-muted outline-none ' +
57
- 'transition-colors duration-150 ease-in-out data-[hovered]:bg-surface-muted data-[hovered]:text-fg ' +
59
+ 'inline-flex h-7 shrink-0 items-center gap-2 rounded-sm px-nav-x py-1.5 text-compact font-normal text-surface-inverted outline-none ' +
60
+ 'transition-colors duration-120 ease-[ease] motion-reduce:transition-none data-[hovered]:bg-surface-muted data-[hovered]:text-fg ' +
58
61
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
59
62
  'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[disabled]:bg-transparent data-[disabled]:text-fg-muted'
60
63
 
@@ -90,7 +93,7 @@ export function SectionTabs({
90
93
  aria-pressed={isResetSelected}
91
94
  onPress={onReset}
92
95
  className={[
93
- resetContent ? tabBase : 'h-7 w-7 rounded-sm p-0 text-fg-muted data-[hovered]:text-fg',
96
+ resetContent ? tabBase : 'h-7 w-7 rounded-sm p-0 text-surface-inverted data-[hovered]:text-fg',
94
97
  isResetSelected ? tabActive : '',
95
98
  ]
96
99
  .filter(Boolean)
@@ -43,7 +43,7 @@ const SelectTrigger = uic(RACButton, {
43
43
  appearance: {
44
44
  filled: 'min-h-control-tall border-0 bg-surface-card py-5 font-normal leading-4.5 duration-200 motion-reduce:transition-none ' +
45
45
  'data-[hovered]:bg-surface-muted group-data-[open]:bg-surface-card ' +
46
- 'data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:outline-offset-2 ' +
46
+ 'data-[focus-visible]:outline-solid data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:outline-offset-2 ' +
47
47
  'group-data-[invalid]:ring-1 group-data-[invalid]:ring-danger ' +
48
48
  'data-[disabled]:bg-surface-card data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
49
49
  outlined: 'h-control-tall border border-border bg-surface ' +
@@ -66,7 +66,7 @@ const StyledSelectItem = uic(ListBoxItem, {
66
66
  appearance: {
67
67
  filled: 'min-w-0 overflow-hidden px-5 py-0 font-normal leading-4.5 max-md:min-h-11 ' +
68
68
  'data-[hovered]:font-medium data-[focused]:font-medium data-[selected]:font-medium ' +
69
- 'data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:-outline-offset-2',
69
+ 'data-[focus-visible]:outline-solid data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:-outline-offset-2',
70
70
  outlined: 'px-3 py-2 ' +
71
71
  'data-[hovered]:bg-surface-muted data-[focused]:bg-surface-muted data-[selected]:font-medium ' +
72
72
  'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
@@ -98,7 +98,8 @@ export function SettingsDialogSurface({ variant, isOpen, onOpenChange, isPending
98
98
  <Body ref={bodyRef} className={bodyClass} data-testid={bodyTestId}>{children}</Body>
99
99
  </Container>
100
100
  </ScrollBody>
101
- <footer className={catalog ? 'shrink-0 border-t border-border bg-surface pt-3' : create ? 'shrink-0 border-t border-border bg-surface pt-4' : 'shrink-0 border-t border-border bg-surface pt-5'}>
101
+ {/* gs dialog footers sit on the white surface with no rule above them. */}
102
+ <footer className={catalog ? 'shrink-0 bg-surface pt-3' : create ? 'shrink-0 bg-surface pt-4' : 'shrink-0 bg-surface pt-5'}>
102
103
  <Container className={containerClass}>
103
104
  <div className={`flex justify-end ${csv || basic ? 'items-center gap-1' : variant === 'edit' ? 'gap-3' : 'gap-2'}`}>{footer}</div>
104
105
  </Container>
@@ -130,15 +131,16 @@ export function WizardFormDialog({ isOpen, onOpenChange, isPending = false, isEx
130
131
  </Button>
131
132
  <header className="shrink-0"><Container className="max-w-384 px-5 min-[901px]:px-6"><DisplayHeading id={titleId} className="m-0 whitespace-pre-line pb-0.5 text-fg-muted" style={{ letterSpacing: 0 }}>{title}</DisplayHeading></Container></header>
132
133
  <ScrollBody style={{ scrollbarGutter: 'stable' }}><Container className="max-w-384 px-5 min-[901px]:px-6"><Body className="gap-8">{children}</Body></Container></ScrollBody>
133
- <footer className="shrink-0 border-t border-border bg-surface pt-5"><Container className="max-w-384 px-5 min-[901px]:px-6"><div className="flex flex-wrap justify-end gap-2">{footer}</div></Container></footer>
134
+ <footer className="shrink-0 bg-surface pt-5"><Container className="max-w-384 px-5 min-[901px]:px-6"><div className="flex flex-wrap justify-end gap-2">{footer}</div></Container></footer>
134
135
  </Layout>
135
136
  </Panel>
136
137
  </ModalOverlay>
137
138
  }
138
139
 
140
+ // The source New section dialog separates its groups by spacing only, no hairlines.
139
141
  export function SettingsDialogIdentity({ children }: { children: ReactNode }) {
140
142
  const { variant } = useContext(LayoutContext)
141
- 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>
142
144
  }
143
145
  export function SettingsDialogDescription({ children }: { children: ReactNode }) {
144
146
  const { variant } = useContext(LayoutContext)
@@ -151,7 +153,7 @@ export function SettingsDialogColumns({ children, metadata = false }: { children
151
153
  }
152
154
  export function SettingsDialogGroup({ children }: { children: ReactNode }) {
153
155
  const { variant } = useContext(LayoutContext)
154
- 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>
155
157
  }
156
158
 
157
159
  /** Two-column production metadata grid from the split settings family.
@@ -172,12 +174,18 @@ export function SettingsDialogControl({ children }: { children: ReactNode }) {
172
174
  const { variant } = useContext(LayoutContext)
173
175
  return <Group className={variant === 'edit' ? 'mt-6' : undefined}>{children}</Group>
174
176
  }
175
- 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
+ }
176
183
  export const SettingsDialogEmphasis = uic('span', { displayName: 'SettingsDialogEmphasis', baseClass: 'text-fg' })
177
184
  const Hint = uic('p', { displayName: 'SettingsDialogHint', baseClass: 'm-0 text-small font-normal text-fg-workflow-muted' })
178
- 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>) {
179
187
  const { variant } = useContext(LayoutContext)
180
- 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 }} />
181
189
  }
182
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 } })
183
191
 
@@ -220,7 +228,7 @@ export function ProductionSettingsDialog({ isOpen, onOpenChange, prefix, title,
220
228
  }
221
229
 
222
230
  const DisclosureRoot = uic(AriaDisclosure, { displayName: 'SettingsDisclosureRoot', baseClass: 'group/settings-disclosure flex min-w-0 flex-col gap-3' })
223
- const DisclosureTrigger = uic(AriaButton, { displayName: 'SettingsDisclosureTrigger', baseClass: 'flex w-full cursor-pointer items-start justify-between gap-3 bg-transparent p-0 text-left outline-none data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:outline-offset-2' })
231
+ const DisclosureTrigger = uic(AriaButton, { displayName: 'SettingsDisclosureTrigger', baseClass: 'flex w-full cursor-pointer items-start justify-between gap-3 bg-transparent p-0 text-left outline-none data-[focus-visible]:outline-solid data-[focus-visible]:outline-2 data-[focus-visible]:outline-ring data-[focus-visible]:outline-offset-2' })
224
232
  const DisclosureCopy = uic('span', { displayName: 'SettingsDisclosureCopy', baseClass: 'flex min-w-0 flex-col gap-1' })
225
233
  const DisclosureTitle = uic('strong', { displayName: 'SettingsDisclosureTitle', baseClass: 'text-body font-medium leading-normal text-fg' })
226
234
  const DisclosureHint = uic('span', { displayName: 'SettingsDisclosureHint', baseClass: 'text-body font-normal leading-normal text-fg-workflow-muted' })
@@ -116,7 +116,7 @@ export function Table<Row>({
116
116
 
117
117
  return (
118
118
  <div className={['w-full overflow-x-auto', className].filter(Boolean).join(' ')}>
119
- <table style={team ? { '--table-columns': gridTemplate } as CSSProperties : undefined} className={`w-full border-collapse text-small ${worksheet ? 'border-b border-border font-normal leading-4.5' : ''} ${team ? 'block border-b border-border font-normal leading-4.5' : ''}`} {...aria}>
119
+ <table style={team ? { '--table-columns': gridTemplate } as CSSProperties : undefined} className={`w-full border-collapse text-small ${worksheet ? 'border-b border-border font-normal leading-4.5' : ''} ${team ? 'block font-normal leading-4.5' : ''}`} {...aria}>
120
120
  <thead className={team ? 'block' : undefined}>
121
121
  <tr className={`${worksheet ? '' : 'border-b border-border'} ${team ? 'grid grid-cols-[var(--table-columns)] gap-3 bg-surface-muted px-4 max-[1023px]:hidden' : ''}`}>
122
122
  {columns.map((column) => {
@@ -131,7 +131,7 @@ export function Table<Row>({
131
131
  aria-sort={
132
132
  canSort ? (active ? (sort?.dir === 'asc' ? 'ascending' : 'descending') : 'none') : undefined
133
133
  }
134
- className={`${ALIGN_CLASS[align]} ${worksheet ? 'border-0 px-3 py-4 align-middle font-mono text-small font-normal leading-4.5 tracking-normal text-fg normal-case bg-surface whitespace-nowrap' : team ? 'border-0 px-4 py-3 align-middle font-mono text-small font-normal leading-4.5 tracking-normal text-fg normal-case whitespace-nowrap' : 'px-4 py-3 text-label font-medium tracking-wide text-fg-muted uppercase'}`}
134
+ className={`${ALIGN_CLASS[align]} ${worksheet ? 'border-0 px-3 py-4 align-middle font-mono text-small font-normal leading-4.5 tracking-normal text-fg normal-case bg-surface whitespace-nowrap' : team ? 'border-0 px-0 py-3 align-middle font-mono text-small font-normal leading-4.5 tracking-normal text-fg normal-case whitespace-nowrap' : 'px-4 py-3 text-label font-medium tracking-wide text-fg-muted uppercase'}`}
135
135
  >
136
136
  {canSort ? (
137
137
  <button
@@ -25,9 +25,12 @@ export const TabList = uic(RACTabList, {
25
25
 
26
26
  export const Tab = uic(RACTab, {
27
27
  displayName: 'Tab',
28
+ // gs `Tabs.module.scss` `.trigger`: 12/16px padding, heading-5 (16px/20px) medium,
29
+ // `--color-text-secondary` ink (#242423, the light neutral `surface-inverted`
30
+ // carries), 2px underline, `all 200ms ease-in-out`.
28
31
  baseClass:
29
- 'cursor-pointer select-none border-b-2 border-transparent px-4 py-3 text-small font-medium text-fg-muted ' +
30
- 'outline-none transition-colors ' +
32
+ 'cursor-pointer select-none border-b-2 border-transparent px-4 py-3 text-heading5 font-medium text-surface-inverted ' +
33
+ 'outline-none transition-all duration-200 ease-[ease-in-out] motion-reduce:transition-none ' +
31
34
  'data-[hovered]:text-fg ' +
32
35
  'data-[selected]:border-brand-primary data-[selected]:text-fg ' +
33
36
  'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring ' +
@@ -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
  }
@@ -58,8 +58,10 @@ export type TextareaProps = TextFieldProps & {
58
58
  }
59
59
 
60
60
  export const Textarea = ({ label, description, errorMessage, placeholder, rows, textAreaClassName, appearance = 'outlined', ...props }: TextareaProps) => (
61
- <TextField {...props} className={`flex w-full flex-col ${appearance === 'filled' ? 'gap-3' : 'gap-2'}`}>
62
- <Label className={`${appearance === 'filled' ? 'text-panel-heading' : 'text-small'} font-medium text-fg`}>{label}</Label>
61
+ // Label and gap match `Input` in both appearances (gs 17px/20px field label,
62
+ // 12px to the control), so a Title + Description pair reads as one form.
63
+ <TextField {...props} className="flex w-full flex-col gap-3">
64
+ <Label className="text-panel-heading font-medium text-fg">{label}</Label>
63
65
  <StyledTextArea className={textAreaClassName} placeholder={placeholder} rows={rows} appearance={appearance} />
64
66
  {description ? (
65
67
  <Text slot="description" className="text-label text-fg-muted">
@@ -395,10 +395,44 @@ const BADGE: Record<BadgeColor, string> = {
395
395
 
396
396
  export function Badge({ label, color = 'grey' }: { label: ReactNode; color?: BadgeColor }) {
397
397
  return (
398
+ // gs `Tag.module.scss`: 24px tall, 0 10px padding, 16px (`radius-2xl`) corners,
399
+ // 10px/20px medium label (Label 4). The source's `margin-left: 12px` is layout
400
+ // the caller owns, so it is not baked in here.
398
401
  <span
399
- className={`inline-flex items-center rounded-full px-3 py-1 text-caption font-medium leading-none ${BADGE[color]}`}
402
+ className={`inline-flex h-6 shrink-0 items-center whitespace-nowrap rounded-2xl px-2.5 text-micro font-medium leading-5 ${BADGE[color]}`}
400
403
  >
401
404
  {label}
402
405
  </span>
403
406
  )
404
407
  }
408
+
409
+ /**
410
+ * Chip: the gs `GSChip` dot pill used for task status and priority cells.
411
+ *
412
+ * Distinct from {@link Badge} (gs `Tag`): a Chip is the table-cell pill with a
413
+ * leading 8px dot, 13px/16px regular label, 24px height and 0 12px padding. gs has
414
+ * two tones only: `green` (translucent mint surface, brand-green dot) for work that
415
+ * is moving or finished, and `grey` (neutral surface, neutral dot) for everything
416
+ * else. The dot is decorative; the label carries the meaning.
417
+ */
418
+ export type ChipColor = 'green' | 'grey'
419
+
420
+ const CHIP: Record<ChipColor, { surface: string; dot: string }> = {
421
+ // gs `rgba(150, 246, 194, 0.8)` → accent-green-lighter at 80%; dot brand green.
422
+ green: { surface: 'bg-accent-green-lighter/80 text-fg-on-brand', dot: 'bg-brand-green' },
423
+ // gs neutral-100 surface; the source neutral-200 dot (#aba89c) maps to the
424
+ // nearest shared neutral. Ornamental: the adjacent label is the content.
425
+ grey: { surface: 'bg-surface-muted text-fg', dot: 'bg-neutral-300' },
426
+ }
427
+
428
+ export function Chip({ label, color = 'grey' }: { label: ReactNode; color?: ChipColor }) {
429
+ const tone = CHIP[color]
430
+ return (
431
+ <span
432
+ className={`inline-flex h-6 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-2xl px-3 text-compact font-normal leading-4 ${tone.surface}`}
433
+ >
434
+ <span aria-hidden="true" className={`size-2 shrink-0 rounded-full ${tone.dot}`} />
435
+ {label}
436
+ </span>
437
+ )
438
+ }
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";
@@ -8,13 +8,16 @@ import {
8
8
  ListBox,
9
9
  ListBoxItem,
10
10
  type ListBoxItemProps,
11
+ Header,
11
12
  Menu as RACMenu,
12
13
  MenuItem as RACMenuItem,
13
14
  type MenuItemProps,
15
+ MenuSection,
14
16
  MenuTrigger,
15
17
  Popover,
16
18
  } from 'react-aria-components'
17
19
  import { uic } from '../utils/uic'
20
+ import { dropdownMenuItemClass, dropdownMenuPanelClass, DropdownMenuSeparator } from '../components/dropdown-menu'
18
21
 
19
22
  /**
20
23
  * Topbar — sticky application header (ported pattern from gs-manager's
@@ -87,8 +90,12 @@ const TopbarActions = uic('div', {
87
90
  const TopbarNavLink = uic('a', {
88
91
  displayName: 'Topbar.NavLink',
89
92
  baseClass:
93
+ // gs inactive ink is `#242423` (the neutral that `surface-inverted` carries in
94
+ // light theme and flips to a light ink on the dark shell); hover/active darken to
95
+ // `fg`. gs `Button.module.scss` eases bg + colour over 120ms `ease`.
90
96
  'inline-flex items-center rounded-sm px-[13px] py-[6px] text-compact leading-4 tracking-[0] whitespace-nowrap ' +
91
- 'text-fg no-underline transition-colors hover:bg-surface-muted hover:text-fg outline-none ' +
97
+ 'text-surface-inverted no-underline transition-colors duration-120 ease-[ease] motion-reduce:transition-none ' +
98
+ 'hover:bg-surface-muted hover:text-fg outline-none ' +
92
99
  'focus-visible:ring-2 focus-visible:ring-ring ' +
93
100
  'data-[active]:bg-surface-muted data-[active]:text-fg',
94
101
  // `active` is accepted both as an explicit prop (uic emits `data-active=""` when
@@ -194,7 +201,8 @@ export const UserMenu = ({ trigger, triggerLabel, onAction, children }: UserMenu
194
201
  >
195
202
  {trigger}
196
203
  </RACButton>
197
- <Popover className="min-w-40 rounded-md border border-border bg-surface p-1 shadow-lg">
204
+ {/* gs Hub account menu = the shared gs DropdownMenu panel, bottom-end aligned. */}
205
+ <Popover placement="bottom end" className={dropdownMenuPanelClass}>
198
206
  <RACMenu onAction={onAction} className="outline-none">
199
207
  {children}
200
208
  </RACMenu>
@@ -204,11 +212,46 @@ export const UserMenu = ({ trigger, triggerLabel, onAction, children }: UserMenu
204
212
 
205
213
  export const UserMenuItem = uic(RACMenuItem, {
206
214
  displayName: 'UserMenu.Item',
207
- baseClass:
208
- 'flex cursor-pointer select-none items-center rounded-sm px-3 py-2 text-small text-fg outline-none ' +
209
- 'data-[focused]:bg-surface-muted data-[disabled]:opacity-50 data-[disabled]:pointer-events-none',
215
+ baseClass: `${dropdownMenuItemClass} data-[disabled]:pointer-events-none`,
210
216
  }) as (props: MenuItemProps) => ReactNode
211
217
 
218
+ /**
219
+ * Two-line item copy (gs `.menuItemStack`): a primary label with a small muted hint
220
+ * underneath, e.g. "Manager / Current" or "Settings / Opens in GS Manager".
221
+ */
222
+ export const UserMenuItemText = ({ label, hint }: { label: ReactNode; hint?: ReactNode }) => (
223
+ <span className="flex min-w-0 flex-col items-start gap-1">
224
+ <span>{label}</span>
225
+ {hint ? <span className="text-micro leading-5 font-normal text-fg-muted">{hint}</span> : null}
226
+ </span>
227
+ )
228
+
229
+ /**
230
+ * Non-interactive identity block at the top of the account menu (gs
231
+ * `.userMenuLabel`): the person's name (13px medium) over a muted detail line.
232
+ */
233
+ export const UserMenuIdentity = ({ name, detail }: { name: ReactNode; detail?: ReactNode }) => (
234
+ <MenuSection>
235
+ <Header className="flex flex-col gap-1 px-4 py-3">
236
+ <span className="text-compact font-medium text-fg">{name}</span>
237
+ {detail ? <span className="mt-1 text-micro leading-5 font-medium text-fg-muted">{detail}</span> : null}
238
+ </Header>
239
+ </MenuSection>
240
+ )
241
+
242
+ export type UserMenuSectionProps = { label?: ReactNode; children: ReactNode; 'aria-label'?: string }
243
+
244
+ /** A labelled group of account-menu items (gs `.menuSectionLabel`, e.g. "Apps"). */
245
+ export const UserMenuSection = ({ label, children, ...props }: UserMenuSectionProps) => (
246
+ <MenuSection {...props}>
247
+ {label ? <Header className="px-2 pt-2 pb-1 text-micro leading-5 font-medium text-fg-muted">{label}</Header> : null}
248
+ {children}
249
+ </MenuSection>
250
+ )
251
+
252
+ /** 1px rule between account-menu groups (gs `.separator`). */
253
+ export const UserMenuSeparator = DropdownMenuSeparator
254
+
212
255
  export const Topbar = Object.assign(TopbarRoot, {
213
256
  Brand: TopbarBrand,
214
257
  Nav: TopbarNav,