minka-ds 0.4.1 → 0.4.3

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.1",
3
+ "version": "0.4.3",
4
4
  "description": "Minka product design system — tokenized component library",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -0,0 +1,116 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "../../lib/utils"
5
+
6
+ export type DiagramNodeVariant = "wallet" | "anchor"
7
+
8
+ /** Brand pair for state nodes (issue/destroy). */
9
+ export type DiagramAccent =
10
+ | "yellow-darkforest"
11
+ | "rose-coral"
12
+ | "blue-navy"
13
+ | "beige-bronze"
14
+ | "gray-black"
15
+
16
+ export interface DiagramNodeProps {
17
+ /** Node type — drives default colors. wallet = white/raised, anchor = inverted slate. */
18
+ variant?: DiagramNodeVariant
19
+ /** When false, renders a dashed empty slot. */
20
+ filled?: boolean
21
+ /** Pair-colored "state" node (issue/destroy). Overrides variant colors. */
22
+ accent?: DiagramAccent
23
+ /** Invert the accent fill (light bg + dark text). */
24
+ accentInverted?: boolean
25
+ /** Compact sizing (used by anchor/alias nodes). */
26
+ compact?: boolean
27
+ className?: string
28
+ children?: React.ReactNode
29
+ }
30
+
31
+ // exact-dash rounded border for the empty state
32
+ function DashedBorder({ radius = 10 }: { radius?: number }) {
33
+ return (
34
+ <svg className="pointer-events-none absolute inset-0 size-full" aria-hidden>
35
+ <rect
36
+ x="0.75" y="0.75"
37
+ rx={radius} ry={radius}
38
+ fill="none"
39
+ stroke="var(--color-border-default)"
40
+ strokeWidth="1.5"
41
+ strokeDasharray="6 6"
42
+ style={{ width: "calc(100% - 1.5px)", height: "calc(100% - 1.5px)" }}
43
+ />
44
+ </svg>
45
+ )
46
+ }
47
+
48
+ /**
49
+ * A node card for the diagram family (FlowDiagram, alias resolve, …). Raised
50
+ * two-layer treatment: a 4px stroke ring underneath + a surface on top whose
51
+ * lift shadow falls OVER the stroke (button-like). The stroke grows 0→4px as
52
+ * the node fills (smooth, no layout shift). Empty nodes render a dashed slot.
53
+ *
54
+ * Node type is encoded by color: wallet = white/raised, anchor = inverted slate,
55
+ * accent = pair-colored state (issue/destroy).
56
+ */
57
+ function DiagramNode({
58
+ variant = "wallet",
59
+ filled = true,
60
+ accent,
61
+ accentInverted = false,
62
+ compact = false,
63
+ className,
64
+ children,
65
+ }: DiagramNodeProps) {
66
+ const sizeClass = compact ? "px-4 py-2 min-h-[48px] min-w-[140px]" : "px-5 py-3 min-h-[64px] min-w-[180px]"
67
+
68
+ // resolve fill / stroke / text per type
69
+ let fill = "var(--color-bg-raised)"
70
+ let stroke = "var(--color-border-default)"
71
+ let ink: string | undefined
72
+
73
+ if (accent) {
74
+ fill = accentInverted ? `var(--color-pair-${accent}-light)` : `var(--color-pair-${accent}-dark)`
75
+ stroke = accentInverted ? `var(--color-pair-${accent}-dark)` : `var(--color-pair-${accent}-light)`
76
+ ink = accentInverted ? "var(--color-text-default)" : "var(--color-text-inverse)"
77
+ } else if (variant === "anchor") {
78
+ fill = "var(--color-pair-blue-navy-dark)"
79
+ stroke = "var(--color-pair-blue-navy-light)"
80
+ ink = "var(--color-text-inverse)"
81
+ }
82
+
83
+ return (
84
+ <div
85
+ data-slot="diagram-node"
86
+ className={cn("relative [border-radius:var(--radius-card)]", className)}
87
+ style={filled ? { animation: "diagram-node-pop .18s cubic-bezier(0.16,1,0.3,1) both" } : undefined}
88
+ >
89
+ <style>{`@keyframes diagram-node-pop { from { opacity:0; transform: scale(.96) } to { opacity:1; transform: scale(1) } }`}</style>
90
+
91
+ {/* bottom: stroke ring that grows 0→4px on fill */}
92
+ <div
93
+ className="absolute inset-0 [border-radius:var(--radius-card)] transition-[box-shadow] duration-300 ease-out"
94
+ style={{ boxShadow: filled ? `inset 0 0 0 4px ${stroke}` : "inset 0 0 0 0px transparent" }}
95
+ />
96
+
97
+ {/* top: surface — inset so the ring shows; bg + lift fade in on fill */}
98
+ <div
99
+ className={cn(
100
+ "relative m-1 flex items-center justify-center [border-radius:calc(var(--radius-card)-4px)] text-center transition-[background-color,box-shadow] duration-300 ease-out",
101
+ sizeClass,
102
+ )}
103
+ style={{
104
+ backgroundColor: filled ? fill : "var(--color-bg-base)",
105
+ color: filled ? ink : undefined,
106
+ boxShadow: filled ? "var(--shadow-raised)" : "none",
107
+ }}
108
+ >
109
+ {!filled && <DashedBorder radius={6} />}
110
+ {children}
111
+ </div>
112
+ </div>
113
+ )
114
+ }
115
+
116
+ export { DiagramNode }
@@ -1,6 +1,15 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
+ import { DiagramNode } from "./diagram-node"
5
+
6
+ /** Brand pair used to give a node / the flow a semantic accent. */
7
+ export type FlowAccent =
8
+ | "yellow-darkforest"
9
+ | "rose-coral"
10
+ | "blue-navy"
11
+ | "beige-bronze"
12
+ | "gray-black"
4
13
 
5
14
  export interface FlowNode {
6
15
  /** Node label (party name). */
@@ -13,6 +22,18 @@ export interface FlowNode {
13
22
  subtitle?: string
14
23
  /** True when this node is an unfilled slot (e.g. nothing selected yet). */
15
24
  empty?: boolean
25
+ /** Optional leading icon (used by state nodes like issue/destroy). */
26
+ icon?: React.ReactNode
27
+ /**
28
+ * When set, this node is a "state" node (no balance) rendered in the pair's
29
+ * colors — used for the abstract create/destroy end of issue/destroy flows.
30
+ */
31
+ accent?: FlowAccent
32
+ /**
33
+ * Invert the accent fill: light member as background, dark as text/border
34
+ * (instead of dark bg + light text). Used to contrast destroy vs issue.
35
+ */
36
+ accentInverted?: boolean
16
37
  }
17
38
 
18
39
  type FormatFn = (n: number) => string
@@ -55,6 +76,11 @@ export interface FlowDiagramProps {
55
76
  format?: FormatFn
56
77
  /** Optional content rendered below the diagram (e.g. a reference pill). */
57
78
  footer?: React.ReactNode
79
+ /**
80
+ * Semantic accent (brand pair) for the flow — tints the arrow, sheen and
81
+ * amount chip. Used by issue/destroy to read as generative/destructive.
82
+ */
83
+ accent?: FlowAccent
58
84
  }
59
85
 
60
86
  /**
@@ -65,12 +91,12 @@ export interface FlowDiagramProps {
65
91
  * an amount is entered. Unfilled nodes render as a dashed empty slot. Neutral
66
92
  * styling — suited to neutral transfers.
67
93
  */
68
- function FlowDiagram({ top, bottom, amount, direction, currency = "COP", format = defaultFormat, footer }: FlowDiagramProps) {
94
+ function FlowDiagram({ top, bottom, amount, direction, currency = "COP", format = defaultFormat, footer, accent }: FlowDiagramProps) {
69
95
  const active = amount > 0
70
96
  const glintKey = active ? amount : 0
71
97
 
72
98
  return (
73
- <div data-slot="flow-diagram" className="flex flex-col items-center">
99
+ <div data-slot="flow-diagram" className="relative flex flex-col items-center">
74
100
  <style>{`
75
101
  @keyframes flow-slide-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
76
102
  @keyframes flow-pop { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
@@ -78,11 +104,15 @@ function FlowDiagram({ top, bottom, amount, direction, currency = "COP", format
78
104
 
79
105
  <FlowNodeCard key={top.empty ? "top-empty" : `top-${top.name}`} node={top} active={active} format={format} pop />
80
106
 
81
- <Connector active={active} direction={direction} amount={amount} currency={currency} format={format} glintKey={glintKey} />
107
+ <Connector active={active} direction={direction} amount={amount} currency={currency} format={format} glintKey={glintKey} accent={accent} />
82
108
 
83
109
  <FlowNodeCard key={bottom.empty ? "bottom-empty" : `bottom-${bottom.name}`} node={bottom} active={active} format={format} pop />
84
110
 
85
- {footer && <div className="mt-4">{footer}</div>}
111
+ {/* footer (e.g. reference pill) is absolutely positioned above the diagram
112
+ so it doesn't shift the diagram's vertical centering when it appears */}
113
+ {footer && (
114
+ <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-6">{footer}</div>
115
+ )}
86
116
  </div>
87
117
  )
88
118
  }
@@ -91,7 +121,7 @@ function FlowDiagram({ top, bottom, amount, direction, currency = "COP", format
91
121
  const CONNECTOR_H = 80
92
122
 
93
123
  function Connector({
94
- active, direction, amount, currency, format, glintKey,
124
+ active, direction, amount, currency, format, glintKey, accent,
95
125
  }: {
96
126
  active: boolean
97
127
  direction: "up" | "down"
@@ -99,11 +129,15 @@ function Connector({
99
129
  currency: string
100
130
  format: FormatFn
101
131
  glintKey: number
132
+ accent?: FlowAccent
102
133
  }) {
103
134
  const gid = React.useId()
104
135
  const H = CONNECTOR_H
105
136
  const headUp = direction === "up"
106
137
 
138
+ // active stroke: semantic pair-dark when accented, else default ink
139
+ const activeStroke = accent ? `var(--color-pair-${accent}-dark)` : "var(--color-text-default)"
140
+
107
141
  const head = headUp ? "M2 6 L7 0 L12 6" : `M2 ${H - 6} L7 ${H} L12 ${H - 6}`
108
142
  const shapes = (
109
143
  <>
@@ -115,7 +149,7 @@ function Connector({
115
149
  return (
116
150
  <div className="relative flex items-center justify-center my-1" style={{ height: H }}>
117
151
  <svg width="14" height={H} viewBox={`0 0 14 ${H}`} className="block overflow-visible" aria-hidden>
118
- <g stroke={active ? "var(--color-text-default)" : "var(--color-border-strong)"} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none">
152
+ <g stroke={active ? activeStroke : "var(--color-border-strong)"} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none">
119
153
  {shapes}
120
154
  </g>
121
155
  {active && (
@@ -152,50 +186,66 @@ function Connector({
152
186
  )
153
187
  }
154
188
 
155
- function FlowNodeCard({ node, active, format, pop }: { node: FlowNode; active: boolean; format: FormatFn; pop?: boolean }) {
189
+ function FlowNodeCard({ node, active, format }: { node: FlowNode; active: boolean; format: FormatFn; pop?: boolean }) {
156
190
  const showAfter = active && node.after !== null
157
191
  const overdrawn = node.after !== null && node.after < 0
158
192
 
193
+ // Empty slot
159
194
  if (node.empty) {
160
195
  return (
161
- <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]">
162
- <DashedBorder radius={10} />
196
+ <DiagramNode filled={false}>
163
197
  <span className="text-body-sm text-[var(--color-text-hint)]">{node.name}</span>
164
- </div>
198
+ </DiagramNode>
165
199
  )
166
200
  }
167
201
 
168
- return (
169
- <div
170
- 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]"
171
- style={pop ? { animation: "flow-pop .18s cubic-bezier(0.16,1,0.3,1) both" } : undefined}
172
- >
173
- <span className="text-body-sm text-[var(--color-text-default)]">{node.name}</span>
174
-
175
- {node.current === null ? (
176
- node.subtitle ? <span className="text-caption text-[var(--color-text-muted)]">{node.subtitle}</span> : null
177
- ) : (
178
- <div className="flex flex-col items-center leading-none">
179
- <span
180
- className={`origin-bottom transition-all duration-300 ${
181
- showAfter
182
- ? "scale-[0.78] text-caption text-[var(--color-text-muted)] mb-0.5"
183
- : "text-label-mono text-[var(--color-text-default)]"
184
- }`}
185
- >
186
- {showAfter ? `was ${format(node.current)}` : format(node.current)}
202
+ // State node (issue/destroy): pair-colored, no balance. Icon + label, subtext below.
203
+ if (node.accent) {
204
+ const subInk = node.accentInverted ? "var(--color-text-default)" : "var(--color-text-inverse)"
205
+ return (
206
+ <DiagramNode accent={node.accent} accentInverted={node.accentInverted}>
207
+ <div className="flex flex-col items-center gap-1">
208
+ <span className="flex items-center gap-1.5 text-body-sm">
209
+ {node.icon && <span className="flex items-center [&_svg]:size-4">{node.icon}</span>}
210
+ {node.name}
187
211
  </span>
188
- {showAfter && (
212
+ {node.subtitle && <span className="text-caption" style={{ color: subInk }}>{node.subtitle}</span>}
213
+ </div>
214
+ </DiagramNode>
215
+ )
216
+ }
217
+
218
+ // Wallet / balance node
219
+ return (
220
+ <DiagramNode variant="wallet">
221
+ <div className="flex flex-col items-center gap-1">
222
+ <span className="text-body-sm text-[var(--color-text-default)]">{node.name}</span>
223
+
224
+ {node.current === null ? (
225
+ node.subtitle ? <span className="text-caption text-[var(--color-text-muted)]">{node.subtitle}</span> : null
226
+ ) : (
227
+ <div className="flex flex-col items-center leading-none">
189
228
  <span
190
- className={`text-label-mono ${overdrawn ? "text-[var(--color-feedback-error)]" : "text-[var(--color-text-default)]"}`}
191
- style={{ animation: "flow-slide-in .3s ease both" }}
229
+ className={`origin-bottom transition-all duration-300 ${
230
+ showAfter
231
+ ? "scale-[0.78] text-caption text-[var(--color-text-muted)] mb-0.5"
232
+ : "text-label-mono text-[var(--color-text-default)]"
233
+ }`}
192
234
  >
193
- {format(node.after!)}
235
+ {showAfter ? `was ${format(node.current)}` : format(node.current)}
194
236
  </span>
195
- )}
196
- </div>
197
- )}
198
- </div>
237
+ {showAfter && (
238
+ <span
239
+ className={`text-label-mono ${overdrawn ? "text-[var(--color-feedback-error)]" : "text-[var(--color-text-default)]"}`}
240
+ style={{ animation: "flow-slide-in .3s ease both" }}
241
+ >
242
+ {format(node.after!)}
243
+ </span>
244
+ )}
245
+ </div>
246
+ )}
247
+ </div>
248
+ </DiagramNode>
199
249
  )
200
250
  }
201
251
 
@@ -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
@@ -44,6 +44,8 @@ export * from "./components/ui/tabs"
44
44
  export * from "./components/ui/textarea"
45
45
  export * from "./components/ui/tooltip"
46
46
  export * from "./components/ui/flow-diagram"
47
+ export * from "./components/ui/diagram-node"
48
+ export * from "./components/ui/help-expander"
47
49
 
48
50
  // Brand textures + logo
49
51
  export * from "./textures"
@@ -240,6 +240,7 @@
240
240
  --primitive-shadow-md: 0 4px 6px oklch(0 0 0 / 0.07), 0 2px 4px oklch(0 0 0 / 0.06);
241
241
  --primitive-shadow-lg: 0 10px 15px oklch(0 0 0 / 0.10), 0 4px 6px oklch(0 0 0 / 0.05);
242
242
  --primitive-shadow-xl: 0 20px 25px oklch(0 0 0 / 0.10), 0 8px 10px oklch(0 0 0 / 0.04);
243
+ --primitive-shadow-raised: 0 4px 12px -1px oklch(0 0 0 / 0.18), 0 2px 5px -1px oklch(0 0 0 / 0.12);
243
244
 
244
245
  /* --- Z-index scale --- */
245
246
  --primitive-z-base: 0;
@@ -192,6 +192,7 @@
192
192
  --shadow-card: var(--primitive-shadow-xs);
193
193
  --shadow-popover: var(--primitive-shadow-md);
194
194
  --shadow-modal: var(--primitive-shadow-lg);
195
+ --shadow-raised: var(--primitive-shadow-raised);
195
196
  }
196
197
 
197
198
  /* ── Dark mode ───────────────────────────────────────────────────── */
@@ -321,4 +322,5 @@
321
322
  --shadow-card: none;
322
323
  --shadow-popover: var(--primitive-shadow-lg);
323
324
  --shadow-modal: var(--primitive-shadow-xl);
325
+ --shadow-raised: var(--primitive-shadow-raised);
324
326
  }