@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 +3 -3
- package/src/components/button.tsx +4 -3
- package/src/components/context-menu.tsx +28 -8
- package/src/components/count-up.tsx +7 -2
- package/src/components/cta-pill.tsx +4 -3
- package/src/components/dropdown-menu.tsx +27 -17
- package/src/components/field-appearance.ts +4 -1
- package/src/components/preview-motion.tsx +215 -0
- package/src/components/section-tabs.tsx +6 -3
- package/src/components/select.tsx +2 -2
- package/src/components/settings-dialog-surface.tsx +16 -8
- package/src/components/table.tsx +2 -2
- package/src/components/tabs.tsx +5 -2
- package/src/components/template-catalog-card.tsx +15 -5
- package/src/components/textarea.tsx +4 -2
- package/src/components/tile.tsx +35 -1
- package/src/index.ts +1 -0
- package/src/layout/topbar.tsx +48 -5
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",
|
|
@@ -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.
|
|
14
|
-
//
|
|
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
|
|
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
|
|
127
|
-
//
|
|
128
|
-
//
|
|
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-[#
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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>
|
|
@@ -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
|
|
44
|
-
* radius → `rounded-lg` · `shadow-lg` · item
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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 `#
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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,
|
|
141
|
+
// `#eceae1` border, `#f7f6f2` fill, `shadow-lg`, 200ms slide/fade both ways.
|
|
132
142
|
<RACPopover
|
|
133
143
|
placement={placement}
|
|
134
144
|
className={
|
|
135
|
-
|
|
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
|
-
|
|
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-
|
|
57
|
-
'transition-colors duration-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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' })
|
package/src/components/table.tsx
CHANGED
|
@@ -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
|
|
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-
|
|
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
|
package/src/components/tabs.tsx
CHANGED
|
@@ -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-
|
|
30
|
-
'outline-none transition-
|
|
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
|
-
/**
|
|
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
|
}
|
|
@@ -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
|
-
|
|
62
|
-
|
|
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">
|
package/src/components/tile.tsx
CHANGED
|
@@ -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-
|
|
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";
|
package/src/layout/topbar.tsx
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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,
|