minka-ds 0.4.0 → 0.4.2

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": "minka-ds",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Minka product design system — tokenized component library",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -17,6 +17,8 @@ interface AvatarProps {
17
17
  size?: "sm" | "md" | "lg"
18
18
  /** Background for the initials state. Defaults to a brand color. */
19
19
  background?: string
20
+ /** Foreground (initials) color. Defaults to the inverse text token. */
21
+ color?: string
20
22
  className?: string
21
23
  }
22
24
 
@@ -25,7 +27,7 @@ function deriveInitials(name?: string): string {
25
27
  return name.trim().split(/\s+/).map(p => p[0]).join("").slice(0, 2).toUpperCase()
26
28
  }
27
29
 
28
- function Avatar({ src, name, initials, size = "md", background, className }: AvatarProps) {
30
+ function Avatar({ src, name, initials, size = "md", background, color, className }: AvatarProps) {
29
31
  return (
30
32
  <div
31
33
  data-slot="avatar"
@@ -34,7 +36,10 @@ function Avatar({ src, name, initials, size = "md", background, className }: Ava
34
36
  SIZE[size],
35
37
  className
36
38
  )}
37
- style={{ background: src ? undefined : (background ?? "var(--color-brand-blue)") }}
39
+ style={{
40
+ background: src ? undefined : (background ?? "var(--color-brand-blue)"),
41
+ ...(color ? { color } : {}),
42
+ }}
38
43
  >
39
44
  {src
40
45
  ? <img src={src} alt={name ?? ""} className="size-full object-cover" />
@@ -47,6 +47,44 @@ function DialogOverlay({
47
47
  )
48
48
  }
49
49
 
50
+ type PanelPlacement = "side" | "top"
51
+
52
+ /**
53
+ * Optional contextual panel rendered inside DialogContent. Holds supporting
54
+ * media or guidance (illustration, brand texture, action summary, help text).
55
+ * Place it as a direct child of DialogContent; the content adapts its layout.
56
+ *
57
+ * By default the panel bleeds to the dialog edges. Pass `inset` to float it
58
+ * inside the dialog with an 8px frame and rounded corners.
59
+ */
60
+ function DialogPanel({
61
+ className,
62
+ placement = "side",
63
+ inset = false,
64
+ ...props
65
+ }: React.ComponentProps<"div"> & { placement?: PanelPlacement; inset?: boolean }) {
66
+ return (
67
+ <div
68
+ data-slot="dialog-panel"
69
+ data-placement={placement}
70
+ data-inset={inset || undefined}
71
+ className={cn(
72
+ "relative flex flex-col justify-center bg-[var(--color-bg-canvas)]",
73
+ // inset panels are tighter — the 8px frame already adds breathing room
74
+ inset ? "p-4" : "p-6",
75
+ // side: a column down the left; top: a banner across the top
76
+ placement === "side" ? "shrink-0 sm:w-2/5" : "min-h-32",
77
+ // inset: float inside the dialog with an 8px frame on the dialog-facing
78
+ // edges only — the interior edge stays flush, the body padding separates
79
+ inset && "overflow-hidden [border-radius:var(--radius-card)]",
80
+ inset && (placement === "side" ? "ml-2 my-2" : "mt-2 mx-2"),
81
+ className
82
+ )}
83
+ {...props}
84
+ />
85
+ )
86
+ }
87
+
50
88
  function DialogContent({
51
89
  className,
52
90
  children,
@@ -57,31 +95,66 @@ function DialogContent({
57
95
  showCloseButton?: boolean
58
96
  container?: HTMLElement | null
59
97
  }) {
98
+ // Detect an optional DialogPanel child and split it out from the body.
99
+ const childArray = React.Children.toArray(children)
100
+ const panel = childArray.find(
101
+ (c): c is React.ReactElement<{ placement?: PanelPlacement; children?: React.ReactNode }> =>
102
+ React.isValidElement(c) && (c.type as { displayName?: string })?.displayName === "DialogPanel"
103
+ )
104
+ const body = childArray.filter(c => c !== panel)
105
+ const placement = panel?.props.placement ?? "side"
106
+ const hasPanel = Boolean(panel)
107
+
108
+ const closeButton = showCloseButton && (
109
+ <DialogPrimitive.Close asChild>
110
+ <Button
111
+ data-slot="dialog-close"
112
+ variant="ghost"
113
+ size="icon-sm"
114
+ aria-label="Close"
115
+ className="absolute top-5 right-5 z-10 translate-y-[5px] text-current opacity-70 hover:opacity-100"
116
+ >
117
+ <XIcon />
118
+ </Button>
119
+ </DialogPrimitive.Close>
120
+ )
121
+
60
122
  return (
61
123
  <DialogPortal container={container}>
62
124
  <DialogOverlay />
63
125
  <DialogPrimitive.Content
64
126
  data-slot="dialog-content"
127
+ data-panel={hasPanel ? placement : undefined}
65
128
  className={cn(
66
- "fixed top-[50%] left-[50%] [z-index:var(--z-modal)] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 [border-radius:var(--radius-modal)] border border-[var(--color-border-default)] bg-[var(--color-bg-overlay)] p-6 shadow-[var(--shadow-modal)] duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
129
+ "fixed top-[50%] left-[50%] [z-index:var(--z-modal)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] overflow-hidden [border-radius:var(--radius-modal)] border border-[var(--color-border-default)] bg-[var(--color-bg-overlay)] shadow-[var(--shadow-modal)] duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
130
+ // layout: plain (padded grid) vs panelled (flex split, panel bleeds to edges)
131
+ hasPanel
132
+ ? placement === "side"
133
+ ? "flex flex-col sm:flex-row sm:max-w-2xl"
134
+ : "flex flex-col"
135
+ : "grid gap-4 p-5",
67
136
  className
68
137
  )}
69
138
  {...props}
70
139
  >
71
- {children}
72
- {showCloseButton && (
73
- <DialogPrimitive.Close
74
- data-slot="dialog-close"
75
- className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
76
- >
77
- <XIcon />
78
- <span className="sr-only">Close</span>
79
- </DialogPrimitive.Close>
140
+ {/* top: close lives inside the panel so it inherits the panel's foreground */}
141
+ {placement === "top" && panel
142
+ ? React.cloneElement(panel, {}, panel.props.children, closeButton)
143
+ : panel}
144
+ {hasPanel ? (
145
+ <div data-slot="dialog-body" className="relative flex flex-1 flex-col gap-4 p-5">
146
+ {body}
147
+ </div>
148
+ ) : (
149
+ body
80
150
  )}
151
+ {/* side / no panel: close sits over the content body */}
152
+ {placement !== "top" && closeButton}
81
153
  </DialogPrimitive.Content>
82
154
  </DialogPortal>
83
155
  )
84
156
  }
157
+ DialogPanel.displayName = "DialogPanel"
85
158
 
86
159
  function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
87
160
  return (
@@ -127,7 +200,7 @@ function DialogTitle({
127
200
  return (
128
201
  <DialogPrimitive.Title
129
202
  data-slot="dialog-title"
130
- className={cn("text-heading-4", className)}
203
+ className={cn("text-heading-2-serif", className)}
131
204
  {...props}
132
205
  />
133
206
  )
@@ -154,6 +227,7 @@ export {
154
227
  DialogFooter,
155
228
  DialogHeader,
156
229
  DialogOverlay,
230
+ DialogPanel,
157
231
  DialogPortal,
158
232
  DialogTitle,
159
233
  DialogTrigger,
@@ -0,0 +1,257 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ /** Brand pair used to give a node / the flow a semantic accent. */
6
+ export type FlowAccent =
7
+ | "yellow-darkforest"
8
+ | "rose-coral"
9
+ | "blue-navy"
10
+ | "beige-bronze"
11
+ | "gray-black"
12
+
13
+ export interface FlowNode {
14
+ /** Node label (party name). */
15
+ name: string
16
+ /** Current balance; null when hidden (e.g. balance not shown). */
17
+ current: number | null
18
+ /** Projected balance after the movement; null when no amount yet or hidden. */
19
+ after: number | null
20
+ /** Shown in place of a balance when `current` is null (e.g. "Master balance"). */
21
+ subtitle?: string
22
+ /** True when this node is an unfilled slot (e.g. nothing selected yet). */
23
+ empty?: boolean
24
+ /** Optional leading icon (used by state nodes like issue/destroy). */
25
+ icon?: React.ReactNode
26
+ /**
27
+ * When set, this node is a "state" node (no balance) rendered in the pair's
28
+ * colors — used for the abstract create/destroy end of issue/destroy flows.
29
+ */
30
+ accent?: FlowAccent
31
+ /**
32
+ * Invert the accent fill: light member as background, dark as text/border
33
+ * (instead of dark bg + light text). Used to contrast destroy vs issue.
34
+ */
35
+ accentInverted?: boolean
36
+ }
37
+
38
+ type FormatFn = (n: number) => string
39
+ const defaultFormat: FormatFn = (n) => "$" + n.toLocaleString("en-US")
40
+
41
+ /**
42
+ * Exact-dash rounded border via an SVG rect overlay (CSS border-dashed has a
43
+ * fixed dash pattern that can't be sized). 6px dash / 6px gap. Fills its
44
+ * relatively-positioned parent.
45
+ */
46
+ function DashedBorder({ radius = 10 }: { radius?: number }) {
47
+ return (
48
+ <svg className="pointer-events-none absolute inset-0 size-full" aria-hidden>
49
+ <rect
50
+ x="0.75" y="0.75"
51
+ width="100%" height="100%"
52
+ rx={radius} ry={radius}
53
+ fill="none"
54
+ stroke="var(--color-border-default)"
55
+ strokeWidth="1.5"
56
+ strokeDasharray="6 6"
57
+ style={{ width: "calc(100% - 1.5px)", height: "calc(100% - 1.5px)" }}
58
+ />
59
+ </svg>
60
+ )
61
+ }
62
+
63
+ export interface FlowDiagramProps {
64
+ /** Top node (fixed position). */
65
+ top: FlowNode
66
+ /** Bottom node (fixed position). */
67
+ bottom: FlowNode
68
+ /** The amount flowing; 0 = idle/empty state. */
69
+ amount: number
70
+ /** Flow direction — arrow + sheen travel this way. */
71
+ direction: "up" | "down"
72
+ /** Unit shown beside the amount. */
73
+ currency?: string
74
+ /** Custom amount/balance formatter (defaults to "$" + en-US grouping). */
75
+ format?: FormatFn
76
+ /** Optional content rendered below the diagram (e.g. a reference pill). */
77
+ footer?: React.ReactNode
78
+ /**
79
+ * Semantic accent (brand pair) for the flow — tints the arrow, sheen and
80
+ * amount chip. Used by issue/destroy to read as generative/destructive.
81
+ */
82
+ accent?: FlowAccent
83
+ }
84
+
85
+ /**
86
+ * Vertical flow diagram for a value movement between two parties. Positions are
87
+ * FIXED (`top` / `bottom`); only the `direction` changes which way the arrow
88
+ * points and the light sheen travels. Each node shows its current balance,
89
+ * which demotes to a "was …" caption as the new (after) balance slides in once
90
+ * an amount is entered. Unfilled nodes render as a dashed empty slot. Neutral
91
+ * styling — suited to neutral transfers.
92
+ */
93
+ function FlowDiagram({ top, bottom, amount, direction, currency = "COP", format = defaultFormat, footer, accent }: FlowDiagramProps) {
94
+ const active = amount > 0
95
+ const glintKey = active ? amount : 0
96
+
97
+ return (
98
+ <div data-slot="flow-diagram" className="relative flex flex-col items-center">
99
+ <style>{`
100
+ @keyframes flow-slide-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
101
+ @keyframes flow-pop { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
102
+ `}</style>
103
+
104
+ <FlowNodeCard key={top.empty ? "top-empty" : `top-${top.name}`} node={top} active={active} format={format} pop />
105
+
106
+ <Connector active={active} direction={direction} amount={amount} currency={currency} format={format} glintKey={glintKey} accent={accent} />
107
+
108
+ <FlowNodeCard key={bottom.empty ? "bottom-empty" : `bottom-${bottom.name}`} node={bottom} active={active} format={format} pop />
109
+
110
+ {/* footer (e.g. reference pill) is absolutely positioned above the diagram
111
+ so it doesn't shift the diagram's vertical centering when it appears */}
112
+ {footer && (
113
+ <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-6">{footer}</div>
114
+ )}
115
+ </div>
116
+ )
117
+ }
118
+
119
+ // Full connector height: enough for top gap + badge + bottom gap.
120
+ const CONNECTOR_H = 80
121
+
122
+ function Connector({
123
+ active, direction, amount, currency, format, glintKey, accent,
124
+ }: {
125
+ active: boolean
126
+ direction: "up" | "down"
127
+ amount: number
128
+ currency: string
129
+ format: FormatFn
130
+ glintKey: number
131
+ accent?: FlowAccent
132
+ }) {
133
+ const gid = React.useId()
134
+ const H = CONNECTOR_H
135
+ const headUp = direction === "up"
136
+
137
+ // active stroke: semantic pair-dark when accented, else default ink
138
+ const activeStroke = accent ? `var(--color-pair-${accent}-dark)` : "var(--color-text-default)"
139
+
140
+ const head = headUp ? "M2 6 L7 0 L12 6" : `M2 ${H - 6} L7 ${H} L12 ${H - 6}`
141
+ const shapes = (
142
+ <>
143
+ <line x1="7" y1="0" x2="7" y2={H} />
144
+ <path d={head} />
145
+ </>
146
+ )
147
+
148
+ return (
149
+ <div className="relative flex items-center justify-center my-1" style={{ height: H }}>
150
+ <svg width="14" height={H} viewBox={`0 0 14 ${H}`} className="block overflow-visible" aria-hidden>
151
+ <g stroke={active ? activeStroke : "var(--color-border-strong)"} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none">
152
+ {shapes}
153
+ </g>
154
+ {active && (
155
+ <>
156
+ <defs>
157
+ <linearGradient id={gid} gradientUnits="userSpaceOnUse" x1="7" y1="0" x2="7" y2="18">
158
+ <stop offset="0%" stopColor="var(--color-bg-raised)" stopOpacity="0" />
159
+ <stop offset="50%" stopColor="var(--color-bg-raised)" stopOpacity="0.5" />
160
+ <stop offset="100%" stopColor="var(--color-bg-raised)" stopOpacity="0" />
161
+ <animate attributeName="y1" values={direction === "down" ? `-20;${H}` : `${H};-20`} dur="2.4s" repeatCount="indefinite" />
162
+ <animate attributeName="y2" values={direction === "down" ? `-2;${H + 18}` : `${H + 18};-2`} dur="2.4s" repeatCount="indefinite" />
163
+ </linearGradient>
164
+ </defs>
165
+ <g stroke={`url(#${gid})`} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none">
166
+ {shapes}
167
+ </g>
168
+ </>
169
+ )}
170
+ </svg>
171
+
172
+ <span
173
+ key={glintKey}
174
+ className={`absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 px-2.5 py-1 [border-radius:var(--radius-badge)] text-label-mono transition-colors duration-300 ${
175
+ active
176
+ ? "bg-[var(--color-bg-inverted)] text-[var(--color-text-inverse)]"
177
+ : "bg-[var(--color-bg-base)] text-[var(--color-text-hint)]"
178
+ }`}
179
+ >
180
+ {!active && <DashedBorder radius={16} />}
181
+ {active ? format(amount) : format(0)}
182
+ <span className={`text-caption ml-1 ${active ? "text-[var(--color-text-inverse-muted)]" : "text-[var(--color-text-muted)]"}`}>{currency}</span>
183
+ </span>
184
+ </div>
185
+ )
186
+ }
187
+
188
+ function FlowNodeCard({ node, active, format, pop }: { node: FlowNode; active: boolean; format: FormatFn; pop?: boolean }) {
189
+ const showAfter = active && node.after !== null
190
+ const overdrawn = node.after !== null && node.after < 0
191
+
192
+ if (node.empty) {
193
+ return (
194
+ <div className="relative flex flex-col items-center justify-center gap-1 [border-radius:var(--radius-card)] bg-[var(--color-bg-base)] px-5 py-3 text-center min-w-[180px] min-h-[64px]">
195
+ <DashedBorder radius={10} />
196
+ <span className="text-body-sm text-[var(--color-text-hint)]">{node.name}</span>
197
+ </div>
198
+ )
199
+ }
200
+
201
+ // State node (issue/destroy abstract end): pair-colored, no balance. Icon sits
202
+ // inline next to the main label; subtext below.
203
+ if (node.accent) {
204
+ // default: dark fill + light text; inverted: light fill + dark text.
205
+ const fill = node.accentInverted ? `var(--color-pair-${node.accent}-light)` : `var(--color-pair-${node.accent}-dark)`
206
+ const ink = node.accentInverted ? `var(--color-pair-${node.accent}-dark)` : `var(--color-pair-${node.accent}-light)`
207
+ const ring = node.accentInverted ? `var(--color-pair-${node.accent}-dark)` : `var(--color-pair-${node.accent}-light)`
208
+ const subInk = node.accentInverted ? "var(--color-text-default)" : "var(--color-text-inverse)"
209
+ return (
210
+ <div
211
+ className="flex flex-col items-center gap-1 [border-radius:var(--radius-card)] border px-5 py-3 text-center min-w-[180px]"
212
+ style={{ backgroundColor: fill, color: ink, borderColor: ring }}
213
+ >
214
+ <span className="flex items-center gap-1.5 text-body-sm">
215
+ {node.icon && <span className="flex items-center [&_svg]:size-4">{node.icon}</span>}
216
+ {node.name}
217
+ </span>
218
+ {node.subtitle && <span className="text-caption" style={{ color: subInk }}>{node.subtitle}</span>}
219
+ </div>
220
+ )
221
+ }
222
+
223
+ return (
224
+ <div
225
+ className="flex flex-col items-center gap-1 [border-radius:var(--radius-card)] border border-[var(--color-border-default)] bg-[var(--color-bg-raised)] px-5 py-3 text-center min-w-[180px]"
226
+ style={pop ? { animation: "flow-pop .18s cubic-bezier(0.16,1,0.3,1) both" } : undefined}
227
+ >
228
+ <span className="text-body-sm text-[var(--color-text-default)]">{node.name}</span>
229
+
230
+ {node.current === null ? (
231
+ node.subtitle ? <span className="text-caption text-[var(--color-text-muted)]">{node.subtitle}</span> : null
232
+ ) : (
233
+ <div className="flex flex-col items-center leading-none">
234
+ <span
235
+ className={`origin-bottom transition-all duration-300 ${
236
+ showAfter
237
+ ? "scale-[0.78] text-caption text-[var(--color-text-muted)] mb-0.5"
238
+ : "text-label-mono text-[var(--color-text-default)]"
239
+ }`}
240
+ >
241
+ {showAfter ? `was ${format(node.current)}` : format(node.current)}
242
+ </span>
243
+ {showAfter && (
244
+ <span
245
+ className={`text-label-mono ${overdrawn ? "text-[var(--color-feedback-error)]" : "text-[var(--color-text-default)]"}`}
246
+ style={{ animation: "flow-slide-in .3s ease both" }}
247
+ >
248
+ {format(node.after!)}
249
+ </span>
250
+ )}
251
+ </div>
252
+ )}
253
+ </div>
254
+ )
255
+ }
256
+
257
+ export { FlowDiagram }
@@ -0,0 +1,140 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Popover as PopoverPrimitive } from "radix-ui"
5
+ import { HelpCircle, X, ExternalLink } from "lucide-react"
6
+ import { cn } from "../../lib/utils"
7
+
8
+ type Anchor = "bottom-right" | "bottom-left" | "top-right" | "top-left"
9
+
10
+ export interface HelpExpanderProps {
11
+ /** Card heading. */
12
+ title: string
13
+ /** Card body (text or nodes). */
14
+ children: React.ReactNode
15
+ /**
16
+ * "popover" (default) floats the card next to the trigger (portaled — works
17
+ * anywhere). "inset" expands the card inside the nearest positioned container,
18
+ * frosting whatever is behind it — for panels with empty space to fill.
19
+ */
20
+ mode?: "popover" | "inset"
21
+ /** Trigger appearance. Defaults to a circular "?" icon button, no label. */
22
+ trigger?: { icon?: React.ReactNode; label?: string }
23
+ /** inset mode: which corner the button sits in / the card expands from. */
24
+ anchor?: Anchor
25
+ /** Optional doc link rendered under the body. */
26
+ docHref?: string
27
+ docLabel?: string
28
+ className?: string
29
+ }
30
+
31
+ const ANCHOR_POS: Record<Anchor, string> = {
32
+ "bottom-right": "bottom-3 right-3 items-end",
33
+ "bottom-left": "bottom-3 left-3 items-start",
34
+ "top-right": "top-3 right-3 items-end",
35
+ "top-left": "top-3 left-3 items-start",
36
+ }
37
+
38
+ // shared trigger button
39
+ function TriggerButton({ trigger, ...props }: { trigger?: HelpExpanderProps["trigger"] } & React.ComponentProps<"button">) {
40
+ const icon = trigger?.icon ?? <HelpCircle className="size-4" />
41
+ return (
42
+ <button
43
+ type="button"
44
+ aria-label="Help"
45
+ className={cn(
46
+ "flex items-center justify-center gap-1.5 [border-radius:var(--radius-button)] border border-[var(--color-border-default)] bg-[var(--color-bg-raised)] text-[var(--color-text-muted)] shadow-[var(--shadow-card)] transition-colors hover:text-[var(--color-text-default)] hover:border-[var(--color-border-strong)]",
47
+ trigger?.label ? "h-8 px-3 text-label-sm" : "size-8",
48
+ )}
49
+ {...props}
50
+ >
51
+ {icon}
52
+ {trigger?.label}
53
+ </button>
54
+ )
55
+ }
56
+
57
+ // shared frosted card body
58
+ function CardBody({ title, children, docHref, docLabel, onClose }: {
59
+ title: string
60
+ children: React.ReactNode
61
+ docHref?: string
62
+ docLabel?: string
63
+ onClose: () => void
64
+ }) {
65
+ return (
66
+ <div
67
+ className="w-full [border-radius:var(--radius-card)] border border-[var(--color-border-default)] backdrop-blur-md shadow-[var(--shadow-popover)] p-4 [animation:help-in_.2s_cubic-bezier(0.16,1,0.3,1)]"
68
+ style={{ backgroundColor: "color-mix(in srgb, var(--color-bg-overlay) 70%, transparent)" }}
69
+ >
70
+ <style>{`@keyframes help-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }`}</style>
71
+ <div className="flex items-start justify-between gap-3">
72
+ <span className="text-heading-4-serif text-[var(--color-text-default)]">{title}</span>
73
+ <button
74
+ type="button"
75
+ aria-label="Close help"
76
+ onClick={onClose}
77
+ className="shrink-0 -mr-1 -mt-1 size-6 flex items-center justify-center rounded-[var(--radius-button)] text-[var(--color-text-muted)] hover:bg-[var(--color-action-ghost-hover)] hover:text-[var(--color-text-default)] transition-colors"
78
+ >
79
+ <X className="size-3.5" />
80
+ </button>
81
+ </div>
82
+ <div className="mt-1.5 text-caption text-[var(--color-text-muted)] leading-relaxed">{children}</div>
83
+ {docHref && (
84
+ <a
85
+ href={docHref}
86
+ target="_blank"
87
+ rel="noreferrer"
88
+ className="mt-2.5 inline-flex items-center gap-1 text-caption text-[var(--color-text-link)] hover:text-[var(--color-text-link-hover)]"
89
+ >
90
+ {docLabel ?? "Learn more"}
91
+ <ExternalLink className="size-3" />
92
+ </a>
93
+ )}
94
+ </div>
95
+ )
96
+ }
97
+
98
+ function HelpExpander({
99
+ title, children, mode = "popover", trigger, anchor = "bottom-right", docHref, docLabel, className,
100
+ }: HelpExpanderProps) {
101
+ const [open, setOpen] = React.useState(false)
102
+
103
+ // ── inset: expands inside the nearest positioned container ──
104
+ if (mode === "inset") {
105
+ return (
106
+ <div className={cn("absolute z-10 flex flex-col w-[calc(100%-1.5rem)]", ANCHOR_POS[anchor], className)}>
107
+ {open ? (
108
+ <CardBody title={title} docHref={docHref} docLabel={docLabel} onClose={() => setOpen(false)}>
109
+ {children}
110
+ </CardBody>
111
+ ) : (
112
+ <TriggerButton trigger={trigger} onClick={() => setOpen(true)} />
113
+ )}
114
+ </div>
115
+ )
116
+ }
117
+
118
+ // ── popover: floats next to the trigger (portaled) ──
119
+ return (
120
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
121
+ <PopoverPrimitive.Trigger asChild>
122
+ <TriggerButton trigger={trigger} className={className} />
123
+ </PopoverPrimitive.Trigger>
124
+ <PopoverPrimitive.Portal>
125
+ <PopoverPrimitive.Content
126
+ side="top"
127
+ align="end"
128
+ sideOffset={8}
129
+ className="z-[var(--z-popover)] w-72 outline-none"
130
+ >
131
+ <CardBody title={title} docHref={docHref} docLabel={docLabel} onClose={() => setOpen(false)}>
132
+ {children}
133
+ </CardBody>
134
+ </PopoverPrimitive.Content>
135
+ </PopoverPrimitive.Portal>
136
+ </PopoverPrimitive.Root>
137
+ )
138
+ }
139
+
140
+ export { HelpExpander }
package/src/index.ts CHANGED
@@ -43,6 +43,8 @@ export * from "./components/ui/tab-count"
43
43
  export * from "./components/ui/tabs"
44
44
  export * from "./components/ui/textarea"
45
45
  export * from "./components/ui/tooltip"
46
+ export * from "./components/ui/flow-diagram"
47
+ export * from "./components/ui/help-expander"
46
48
 
47
49
  // Brand textures + logo
48
50
  export * from "./textures"