minka-ds 0.4.2 → 0.4.4
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 +1 -1
- package/src/components/ui/diagram-node.tsx +118 -0
- package/src/components/ui/expandable-panel.tsx +130 -0
- package/src/components/ui/flow-diagram.tsx +42 -47
- package/src/components/ui/timeline.tsx +316 -0
- package/src/index.ts +3 -0
- package/tokens/primitives.css +1 -0
- package/tokens/semantic.css +2 -0
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
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 inline-flex [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
|
+
flex-1 so it fills the wrapper when an explicit width is imposed
|
|
99
|
+
(e.g. w-full), while still shrink-wrapping content otherwise. */}
|
|
100
|
+
<div
|
|
101
|
+
className={cn(
|
|
102
|
+
"relative m-1 flex flex-1 items-center justify-center [border-radius:calc(var(--radius-card)-4px)] text-center transition-[background-color,box-shadow] duration-300 ease-out",
|
|
103
|
+
sizeClass,
|
|
104
|
+
)}
|
|
105
|
+
style={{
|
|
106
|
+
backgroundColor: filled ? fill : "var(--color-bg-base)",
|
|
107
|
+
color: filled ? ink : undefined,
|
|
108
|
+
boxShadow: filled ? "var(--shadow-raised)" : "none",
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{!filled && <DashedBorder radius={6} />}
|
|
112
|
+
{children}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { DiagramNode }
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { ChevronDown } from "lucide-react"
|
|
5
|
+
import { Collapsible, CollapsibleContent } from "./collapsible"
|
|
6
|
+
import { Button } from "./button"
|
|
7
|
+
import { cn } from "../../lib/utils"
|
|
8
|
+
|
|
9
|
+
// A composed expand/collapse panel: a header row (title + optional subtitle on
|
|
10
|
+
// the left, optional meta + chevron on the right) over a styled body that
|
|
11
|
+
// expands. Built on the DS Collapsible primitive. Domain-agnostic — pass any
|
|
12
|
+
// content as children.
|
|
13
|
+
//
|
|
14
|
+
// When rendered inside an <ExpandablePanelGroup>, panels drop their own border +
|
|
15
|
+
// rounding (the Group owns the frame + shared dividers).
|
|
16
|
+
|
|
17
|
+
const GroupContext = React.createContext(false)
|
|
18
|
+
|
|
19
|
+
export interface ExpandablePanelProps {
|
|
20
|
+
title: React.ReactNode
|
|
21
|
+
/** Secondary line under the title (e.g. a timestamp). */
|
|
22
|
+
subtitle?: React.ReactNode
|
|
23
|
+
/** Right-aligned meta beside the chevron (e.g. "1 proof"). */
|
|
24
|
+
meta?: React.ReactNode
|
|
25
|
+
/** Uncontrolled initial open state. */
|
|
26
|
+
defaultOpen?: boolean
|
|
27
|
+
/** Controlled open state (pair with onOpenChange). */
|
|
28
|
+
open?: boolean
|
|
29
|
+
onOpenChange?: (open: boolean) => void
|
|
30
|
+
/** Body content, revealed when expanded. */
|
|
31
|
+
children?: React.ReactNode
|
|
32
|
+
className?: string
|
|
33
|
+
/** Extra classes for the body region. */
|
|
34
|
+
contentClassName?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ExpandablePanel({
|
|
38
|
+
title, subtitle, meta, defaultOpen = false, open, onOpenChange, children, className, contentClassName,
|
|
39
|
+
}: ExpandablePanelProps) {
|
|
40
|
+
// Controlled or uncontrolled open state. The whole header row toggles it; the
|
|
41
|
+
// chevron is the DS ghost icon Button (carries the hover), wired to the same state.
|
|
42
|
+
const [internal, setInternal] = React.useState(defaultOpen)
|
|
43
|
+
const isOpen = open ?? internal
|
|
44
|
+
const setOpen = (next: boolean) => {
|
|
45
|
+
if (open === undefined) setInternal(next)
|
|
46
|
+
onOpenChange?.(next)
|
|
47
|
+
}
|
|
48
|
+
const toggle = () => setOpen(!isOpen)
|
|
49
|
+
const inGroup = React.useContext(GroupContext)
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Collapsible
|
|
53
|
+
open={isOpen}
|
|
54
|
+
onOpenChange={setOpen}
|
|
55
|
+
data-slot="expandable-panel"
|
|
56
|
+
className={cn(
|
|
57
|
+
"overflow-hidden bg-[var(--color-bg-raised)]",
|
|
58
|
+
// standalone: own border + rounded frame. In a group, the Group owns the
|
|
59
|
+
// frame and the shared dividers, so the panel is borderless/square.
|
|
60
|
+
!inGroup && "[border-radius:var(--radius-card)] border border-[var(--color-border-default)]",
|
|
61
|
+
className,
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<style>{`
|
|
65
|
+
@keyframes expandable-down { from { height: 0 } to { height: var(--radix-collapsible-content-height) } }
|
|
66
|
+
@keyframes expandable-up { from { height: var(--radix-collapsible-content-height) } to { height: 0 } }
|
|
67
|
+
[data-slot="expandable-panel-content"][data-state="open"] { animation: expandable-down .22s ease-out }
|
|
68
|
+
[data-slot="expandable-panel-content"][data-state="closed"]{ animation: expandable-up .2s ease-in }
|
|
69
|
+
`}</style>
|
|
70
|
+
|
|
71
|
+
{/* Header — the whole row is clickable; only the chevron Button shows hover. */}
|
|
72
|
+
<div
|
|
73
|
+
data-slot="expandable-panel-header"
|
|
74
|
+
onClick={toggle}
|
|
75
|
+
className="flex w-full cursor-pointer items-center gap-4 px-5 py-3.5 text-left select-none"
|
|
76
|
+
>
|
|
77
|
+
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
|
78
|
+
<span className="text-label text-[var(--color-text-default)]">{title}</span>
|
|
79
|
+
{subtitle && <span className="truncate text-caption-light text-[var(--color-text-muted)]">{subtitle}</span>}
|
|
80
|
+
</div>
|
|
81
|
+
{meta && <span className="shrink-0 text-overline text-[var(--color-text-muted)]">{meta}</span>}
|
|
82
|
+
<Button
|
|
83
|
+
variant="ghost"
|
|
84
|
+
size="icon-sm"
|
|
85
|
+
aria-label={isOpen ? "Collapse" : "Expand"}
|
|
86
|
+
aria-expanded={isOpen}
|
|
87
|
+
onClick={(e) => { e.stopPropagation(); toggle() }}
|
|
88
|
+
>
|
|
89
|
+
<ChevronDown className={cn("size-4 transition-transform duration-200", isOpen && "rotate-180")} />
|
|
90
|
+
</Button>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Body — white like the header, no divider */}
|
|
94
|
+
<CollapsibleContent data-slot="expandable-panel-content" className="overflow-hidden">
|
|
95
|
+
<div className={cn("bg-[var(--color-bg-raised)] px-5 pb-4", contentClassName)}>
|
|
96
|
+
{children}
|
|
97
|
+
</div>
|
|
98
|
+
</CollapsibleContent>
|
|
99
|
+
</Collapsible>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A stacked group of ExpandablePanels that read as one connected unit: the
|
|
104
|
+
// group owns the outer border + rounded corners (top of first / bottom of last),
|
|
105
|
+
// inner edges are square, and adjacent panels share a single divider. Children
|
|
106
|
+
// drop their own border/rounding via context. `overflow-hidden` clips the inner
|
|
107
|
+
// panels' square corners to the group's rounded frame.
|
|
108
|
+
export interface ExpandablePanelGroupProps {
|
|
109
|
+
children: React.ReactNode
|
|
110
|
+
className?: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function ExpandablePanelGroup({ children, className }: ExpandablePanelGroupProps) {
|
|
114
|
+
return (
|
|
115
|
+
<GroupContext.Provider value={true}>
|
|
116
|
+
<div
|
|
117
|
+
data-slot="expandable-panel-group"
|
|
118
|
+
className={cn(
|
|
119
|
+
"overflow-hidden [border-radius:var(--radius-card)] border border-[var(--color-border-default)]",
|
|
120
|
+
"divide-y divide-[var(--color-border-default)]",
|
|
121
|
+
className,
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
{children}
|
|
125
|
+
</div>
|
|
126
|
+
</GroupContext.Provider>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export { ExpandablePanel, ExpandablePanelGroup }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
+
import { DiagramNode } from "./diagram-node"
|
|
4
5
|
|
|
5
6
|
/** Brand pair used to give a node / the flow a semantic accent. */
|
|
6
7
|
export type FlowAccent =
|
|
@@ -185,72 +186,66 @@ function Connector({
|
|
|
185
186
|
)
|
|
186
187
|
}
|
|
187
188
|
|
|
188
|
-
function FlowNodeCard({ node, active, format
|
|
189
|
+
function FlowNodeCard({ node, active, format }: { node: FlowNode; active: boolean; format: FormatFn; pop?: boolean }) {
|
|
189
190
|
const showAfter = active && node.after !== null
|
|
190
191
|
const overdrawn = node.after !== null && node.after < 0
|
|
191
192
|
|
|
193
|
+
// Empty slot
|
|
192
194
|
if (node.empty) {
|
|
193
195
|
return (
|
|
194
|
-
<
|
|
195
|
-
<DashedBorder radius={10} />
|
|
196
|
+
<DiagramNode filled={false}>
|
|
196
197
|
<span className="text-body-sm text-[var(--color-text-hint)]">{node.name}</span>
|
|
197
|
-
</
|
|
198
|
+
</DiagramNode>
|
|
198
199
|
)
|
|
199
200
|
}
|
|
200
201
|
|
|
201
|
-
// State node (issue/destroy
|
|
202
|
-
// inline next to the main label; subtext below.
|
|
202
|
+
// State node (issue/destroy): pair-colored, no balance. Icon + label, subtext below.
|
|
203
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
204
|
const subInk = node.accentInverted ? "var(--color-text-default)" : "var(--color-text-inverse)"
|
|
209
205
|
return (
|
|
210
|
-
<
|
|
211
|
-
className="flex flex-col items-center gap-1
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
{node.
|
|
217
|
-
</
|
|
218
|
-
|
|
219
|
-
</div>
|
|
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}
|
|
211
|
+
</span>
|
|
212
|
+
{node.subtitle && <span className="text-caption" style={{ color: subInk }}>{node.subtitle}</span>}
|
|
213
|
+
</div>
|
|
214
|
+
</DiagramNode>
|
|
220
215
|
)
|
|
221
216
|
}
|
|
222
217
|
|
|
218
|
+
// Wallet / balance node
|
|
223
219
|
return (
|
|
224
|
-
<
|
|
225
|
-
className="flex flex-col items-center gap-1
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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 && (
|
|
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">
|
|
244
228
|
<span
|
|
245
|
-
className={`
|
|
246
|
-
|
|
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
|
+
}`}
|
|
247
234
|
>
|
|
248
|
-
{format(node.
|
|
235
|
+
{showAfter ? `was ${format(node.current)}` : format(node.current)}
|
|
249
236
|
</span>
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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>
|
|
254
249
|
)
|
|
255
250
|
}
|
|
256
251
|
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Info } from "lucide-react"
|
|
5
|
+
import { Badge } from "./badge"
|
|
6
|
+
import { Alert, AlertTitle, AlertDescription } from "./alert"
|
|
7
|
+
import { cn } from "../../lib/utils"
|
|
8
|
+
|
|
9
|
+
// A vertical process timeline: ordered sections, each anchored by a milestone
|
|
10
|
+
// badge, with items (steps) connected by a two-layer rail — a 4px muted "route"
|
|
11
|
+
// always drawn, and a 2px dark "progress" line riding on it from the top down to
|
|
12
|
+
// the step in progress. Sections connect via rounded CSS elbows. Optional
|
|
13
|
+
// annotation rows (a DS Alert) mark boundaries. On failure the timeline can
|
|
14
|
+
// truncate at the failed item.
|
|
15
|
+
//
|
|
16
|
+
// Status vocabulary (fixed): done = success(green), current = warning(amber,
|
|
17
|
+
// pulsing), failed = error(red), upcoming = border-strong(grey).
|
|
18
|
+
|
|
19
|
+
export type TimelineItemStatus = "done" | "current" | "upcoming" | "failed"
|
|
20
|
+
|
|
21
|
+
export interface TimelineItem {
|
|
22
|
+
label: string
|
|
23
|
+
status: TimelineItemStatus
|
|
24
|
+
/** Secondary tag shown before the label (e.g. an actor: "Ledger · …"). */
|
|
25
|
+
meta?: string
|
|
26
|
+
/** Preformatted timestamp shown first in the row (mono, hint). */
|
|
27
|
+
timestamp?: string
|
|
28
|
+
/** Optional "+N" multi-day suffix on the timestamp. */
|
|
29
|
+
dayOffset?: number
|
|
30
|
+
/** Failure reason, shown red inline when status is "failed". */
|
|
31
|
+
detail?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TimelineMilestone {
|
|
35
|
+
label: string
|
|
36
|
+
variant: "warning" | "info" | "success" | "error"
|
|
37
|
+
/** Heading shown left of the badge (e.g. a ledger phase term). */
|
|
38
|
+
lead?: string
|
|
39
|
+
/** Full-color when true; dimmed when false (not yet reached). */
|
|
40
|
+
active?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TimelineAnnotation {
|
|
44
|
+
title: string
|
|
45
|
+
description: React.ReactNode
|
|
46
|
+
/** DS Alert variant; defaults to "info". */
|
|
47
|
+
variant?: "info" | "success" | "warning" | "error"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TimelineSection {
|
|
51
|
+
milestone: TimelineMilestone
|
|
52
|
+
/** Elbow direction: "top" leads into the items below, "end" closes them. */
|
|
53
|
+
position: "top" | "end"
|
|
54
|
+
items: TimelineItem[]
|
|
55
|
+
/** Optional Alert row rendered before this section's items. */
|
|
56
|
+
annotationBefore?: TimelineAnnotation
|
|
57
|
+
/** Open a stub gap above the first item (separates from the section above). */
|
|
58
|
+
gapBefore?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TimelineProps {
|
|
62
|
+
sections: TimelineSection[]
|
|
63
|
+
/** When true, render stops after the first failed item (and its section). */
|
|
64
|
+
truncateAtFailure?: boolean
|
|
65
|
+
className?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Geometry (shared so dots, lines, and elbows align on one axis) ────────────
|
|
69
|
+
const RAIL_W = 20
|
|
70
|
+
const RAIL_X = RAIL_W / 2
|
|
71
|
+
const RAIL_MUTED = "var(--color-border-default)"
|
|
72
|
+
const RAIL_DARK = "var(--color-text-default)"
|
|
73
|
+
// Dot center aligns to the vertical center of the first text line (text-body-sm
|
|
74
|
+
// = 14px × 1.5 = 21px line box → center at 10.5px). Connectors/elbows derive
|
|
75
|
+
// from DOT_TOP so they shift with it.
|
|
76
|
+
const DOT_TOP = 10.5
|
|
77
|
+
const STROKE = 4 // muted base stroke width — the structural rail
|
|
78
|
+
const STROKE_DARK = 2 // dark progress stroke — thinner, rides over the base
|
|
79
|
+
const STUB_LEN = 12 // partial stub length across a section gap
|
|
80
|
+
const MILESTONE_H = 30 // fixed milestone row height; title-center = H/2
|
|
81
|
+
const ELBOW_RADIUS = 8
|
|
82
|
+
const ELBOW_REACH = RAIL_W / 2 + 2
|
|
83
|
+
const ROW_PB = 12 // pb-3 below a milestone; the top elbow's arm spans it
|
|
84
|
+
const ANNOTATION_ANCHOR = 18 // px to the alert's title line (elbow lands here)
|
|
85
|
+
const ANNOTATION_PB = 16 // pb-4 below the annotation; arm spans it to next dot
|
|
86
|
+
|
|
87
|
+
type LineBelow = "none" | "muted" | "dark"
|
|
88
|
+
type Stub = "muted" | "dark" | null
|
|
89
|
+
|
|
90
|
+
// Proof-dot color reads status alone: green done, amber current (pulse), red
|
|
91
|
+
// failed, grey upcoming.
|
|
92
|
+
const DOT_CLASS: Record<TimelineItemStatus, string> = {
|
|
93
|
+
done: "bg-[var(--color-feedback-success)]",
|
|
94
|
+
current: "bg-[var(--color-feedback-warning)] [animation:tl-pulse_1.6s_ease-in-out_infinite]",
|
|
95
|
+
failed: "bg-[var(--color-feedback-error)]",
|
|
96
|
+
upcoming: "bg-[var(--color-border-strong)]",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Rail segment ──────────────────────────────────────────────────────────────
|
|
100
|
+
// The 4px muted base (the route) is ALWAYS drawn; a 2px dark line overlays it,
|
|
101
|
+
// centered, when `dark` (progress reached here). `bottom` xor `height` sets the
|
|
102
|
+
// lower end.
|
|
103
|
+
function RailSeg({ top, bottom, height, dark }: { top: number; bottom?: number; height?: number; dark: boolean }) {
|
|
104
|
+
const span: React.CSSProperties = { top, ...(height != null ? { height } : { bottom }) }
|
|
105
|
+
return (
|
|
106
|
+
<>
|
|
107
|
+
<span className="absolute rounded-full" style={{ left: RAIL_X, width: STROKE, backgroundColor: RAIL_MUTED, transform: `translateX(-${STROKE / 2}px)`, ...span }} />
|
|
108
|
+
{dark && <span className="absolute rounded-full" style={{ left: RAIL_X, width: STROKE_DARK, backgroundColor: RAIL_DARK, transform: `translateX(-${STROKE_DARK / 2}px)`, ...span }} />}
|
|
109
|
+
</>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Item row ──────────────────────────────────────────────────────────────────
|
|
114
|
+
// The dot sits at a fixed offset from the row top; the connector runs from this
|
|
115
|
+
// dot's center to the NEXT dot's center. Since every dot shares the same offset,
|
|
116
|
+
// `top: DOT_TOP` → `bottom: -DOT_TOP` lands exactly on the next dot regardless of
|
|
117
|
+
// row height. The dot is painted last, over the connector's start.
|
|
118
|
+
function ItemRow({ item, lineBelow, stubAbove = null, stubBelow = null }: { item: TimelineItem; lineBelow: LineBelow; stubAbove?: Stub; stubBelow?: Stub }) {
|
|
119
|
+
const upcoming = item.status === "upcoming"
|
|
120
|
+
const failed = item.status === "failed"
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex gap-2">
|
|
123
|
+
<div className="relative shrink-0" style={{ width: RAIL_W }}>
|
|
124
|
+
{lineBelow !== "none" && <RailSeg top={DOT_TOP} bottom={-DOT_TOP} dark={lineBelow === "dark"} />}
|
|
125
|
+
{stubAbove && <RailSeg top={DOT_TOP - STUB_LEN} height={STUB_LEN} dark={stubAbove === "dark"} />}
|
|
126
|
+
{stubBelow && <RailSeg top={DOT_TOP} height={STUB_LEN} dark={stubBelow === "dark"} />}
|
|
127
|
+
<span className={`absolute size-2.5 rounded-full ${DOT_CLASS[item.status]}`} style={{ left: RAIL_X, top: DOT_TOP, transform: "translate(-50%, -50%)" }} />
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* single line: timestamp (mono, hint) · meta · label */}
|
|
131
|
+
<div className={`flex flex-1 items-baseline gap-2 pb-4 text-body-sm ${upcoming ? "opacity-45" : ""}`}>
|
|
132
|
+
{item.timestamp && (
|
|
133
|
+
<span className="shrink-0 text-caption text-[var(--color-text-hint)] [font-family:var(--font-mono)]">
|
|
134
|
+
{item.timestamp}
|
|
135
|
+
{item.dayOffset != null && item.dayOffset > 0 && <span className="ml-0.5 align-super text-[0.7em]">+{item.dayOffset}</span>}
|
|
136
|
+
</span>
|
|
137
|
+
)}
|
|
138
|
+
{item.meta && <span className="text-[var(--color-text-muted)]">{item.meta} ·</span>}
|
|
139
|
+
<span className="text-[var(--color-text-default)]">{item.label}</span>
|
|
140
|
+
{failed && item.detail && <span className="text-[var(--color-feedback-error)]">· {item.detail}</span>}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Milestone elbow ───────────────────────────────────────────────────────────
|
|
147
|
+
// CSS elbow (bordered box, no SVG): a vertical arm + a horizontal arm into the
|
|
148
|
+
// badge, with a rounded corner. Two layers so the 2px dark rides centered on the
|
|
149
|
+
// 4px muted base. Both endpoints anchor to centers (badge-center = MILESTONE_H/2;
|
|
150
|
+
// the adjacent dot is DOT_TOP into the flush neighbour row).
|
|
151
|
+
function Elbow({ position, reached }: { position: "top" | "end"; reached: boolean }) {
|
|
152
|
+
const TITLE_C = MILESTONE_H / 2
|
|
153
|
+
const layer = (w: number, c: string): React.CSSProperties => {
|
|
154
|
+
const d = (STROKE - w) / 2
|
|
155
|
+
return {
|
|
156
|
+
position: "absolute",
|
|
157
|
+
left: RAIL_X,
|
|
158
|
+
width: ELBOW_REACH,
|
|
159
|
+
transform: `translateX(-${w / 2}px)`,
|
|
160
|
+
borderStyle: "solid",
|
|
161
|
+
borderColor: "transparent",
|
|
162
|
+
borderLeftWidth: w,
|
|
163
|
+
borderLeftColor: c,
|
|
164
|
+
...(position === "end"
|
|
165
|
+
? { top: -DOT_TOP, height: TITLE_C + DOT_TOP - d, borderBottomWidth: w, borderBottomColor: c, borderBottomLeftRadius: ELBOW_RADIUS - d }
|
|
166
|
+
: { top: TITLE_C + d, height: MILESTONE_H - TITLE_C + ROW_PB + DOT_TOP - d, borderTopWidth: w, borderTopColor: c, borderTopLeftRadius: ELBOW_RADIUS - d }),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return (
|
|
170
|
+
<>
|
|
171
|
+
<span style={layer(STROKE, RAIL_MUTED)} />
|
|
172
|
+
{reached && <span style={layer(STROKE_DARK, RAIL_DARK)} />}
|
|
173
|
+
</>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function MilestoneRow({ milestone, position, reached }: { milestone: TimelineMilestone; position: "top" | "end"; reached: boolean }) {
|
|
178
|
+
const active = milestone.active ?? true
|
|
179
|
+
return (
|
|
180
|
+
<div className="pb-3">
|
|
181
|
+
<div className="flex items-center gap-2" style={{ height: MILESTONE_H }}>
|
|
182
|
+
<div className="relative shrink-0 self-stretch" style={{ width: RAIL_W }}>
|
|
183
|
+
<Elbow position={position} reached={reached} />
|
|
184
|
+
</div>
|
|
185
|
+
<div className={`flex items-center gap-2.5 ${active ? "" : "opacity-55"}`}>
|
|
186
|
+
{milestone.lead && (
|
|
187
|
+
<span className="text-body-lg font-semibold text-[var(--color-text-default)]">{milestone.lead}</span>
|
|
188
|
+
)}
|
|
189
|
+
<Badge variant={milestone.variant} className="text-label px-2.5 py-1">{milestone.label}</Badge>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Annotation row ────────────────────────────────────────────────────────────
|
|
197
|
+
// A label (not an item — no dot): a DS Alert whose elbow corner sits beside it
|
|
198
|
+
// and whose arm runs DOWN into the next item's dot. Two-layer like the rail.
|
|
199
|
+
function AnnotationRow({ annotation, dark }: { annotation: TimelineAnnotation; dark: boolean }) {
|
|
200
|
+
const elbowLayer = (w: number, c: string): React.CSSProperties => {
|
|
201
|
+
const d = (STROKE - w) / 2
|
|
202
|
+
return {
|
|
203
|
+
position: "absolute",
|
|
204
|
+
left: RAIL_X,
|
|
205
|
+
width: ELBOW_REACH,
|
|
206
|
+
top: ANNOTATION_ANCHOR + d,
|
|
207
|
+
bottom: -(DOT_TOP + ANNOTATION_PB),
|
|
208
|
+
transform: `translateX(-${w / 2}px)`,
|
|
209
|
+
borderStyle: "solid",
|
|
210
|
+
borderColor: "transparent",
|
|
211
|
+
borderLeftWidth: w,
|
|
212
|
+
borderLeftColor: c,
|
|
213
|
+
borderTopWidth: w,
|
|
214
|
+
borderTopColor: c,
|
|
215
|
+
borderTopLeftRadius: ELBOW_RADIUS - d,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return (
|
|
219
|
+
<div className="flex items-start gap-2 pb-4">
|
|
220
|
+
<div className="relative shrink-0 self-stretch" style={{ width: RAIL_W }}>
|
|
221
|
+
<span style={elbowLayer(STROKE, RAIL_MUTED)} />
|
|
222
|
+
{dark && <span style={elbowLayer(STROKE_DARK, RAIL_DARK)} />}
|
|
223
|
+
</div>
|
|
224
|
+
<Alert variant={annotation.variant ?? "info"}>
|
|
225
|
+
<Info />
|
|
226
|
+
<AlertTitle>{annotation.title}</AlertTitle>
|
|
227
|
+
<AlertDescription>{annotation.description}</AlertDescription>
|
|
228
|
+
</Alert>
|
|
229
|
+
</div>
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
234
|
+
const isDone = (i?: TimelineItem) => i?.status === "done"
|
|
235
|
+
const reachedDot = (i?: TimelineItem) => i?.status === "done" || i?.status === "current"
|
|
236
|
+
|
|
237
|
+
// Connector below an item: none if nothing follows it in-section (gap/elbow
|
|
238
|
+
// joins); dark once flow passed it (done); muted otherwise.
|
|
239
|
+
const lineBelowFor = (i: TimelineItem, nextJoins: boolean): LineBelow =>
|
|
240
|
+
!nextJoins ? "none" : i.status === "done" ? "dark" : "muted"
|
|
241
|
+
const stubColor = (i?: TimelineItem): Stub => (reachedDot(i) ? "dark" : "muted")
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Generic vertical process timeline. See the file header for the model. The
|
|
245
|
+
* component owns all layout (rail, connectors, elbows, stubs, truncation); the
|
|
246
|
+
* consumer supplies the data and wraps it (e.g. in a Card) as needed.
|
|
247
|
+
*/
|
|
248
|
+
function Timeline({ sections, truncateAtFailure = false, className }: TimelineProps) {
|
|
249
|
+
// Find the failing item (across sections) to optionally truncate there.
|
|
250
|
+
let failedSectionIdx = -1
|
|
251
|
+
let failedItemIdx = -1
|
|
252
|
+
if (truncateAtFailure) {
|
|
253
|
+
for (let s = 0; s < sections.length && failedSectionIdx === -1; s++) {
|
|
254
|
+
const fi = sections[s].items.findIndex(i => i.status === "failed")
|
|
255
|
+
if (fi !== -1) { failedSectionIdx = s; failedItemIdx = fi }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const truncated = failedSectionIdx !== -1
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div className={cn("flex flex-col", className)}>
|
|
262
|
+
<style>{`@keyframes tl-pulse {
|
|
263
|
+
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-feedback-warning) 45%, transparent); }
|
|
264
|
+
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-feedback-warning) 0%, transparent); }
|
|
265
|
+
}`}</style>
|
|
266
|
+
|
|
267
|
+
{sections.map((section, s) => {
|
|
268
|
+
if (truncated && s > failedSectionIdx) return null
|
|
269
|
+
const items = truncated && s === failedSectionIdx
|
|
270
|
+
? section.items.slice(0, failedItemIdx + 1)
|
|
271
|
+
: section.items
|
|
272
|
+
const sectionFailed = truncated && s === failedSectionIdx
|
|
273
|
+
const lastItem = items[items.length - 1]
|
|
274
|
+
// A gap sits between two sections when the FOLLOWING section opts in via
|
|
275
|
+
// gapBefore: this section's last item gets a stub below, the next
|
|
276
|
+
// section's first item a stub above — together forming the broken gap.
|
|
277
|
+
const next = sections[s + 1]
|
|
278
|
+
const closeWithGap = section.position === "top" && !sectionFailed && !!next?.gapBefore
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<React.Fragment key={s}>
|
|
282
|
+
{section.position === "top" && (
|
|
283
|
+
<MilestoneRow milestone={section.milestone} position="top" reached={isDone(items[0])} />
|
|
284
|
+
)}
|
|
285
|
+
|
|
286
|
+
{section.annotationBefore && (
|
|
287
|
+
<AnnotationRow annotation={section.annotationBefore} dark={stubColor(items[0]) === "dark"} />
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{items.map((item, i) => {
|
|
291
|
+
const isLast = i === items.length - 1
|
|
292
|
+
return (
|
|
293
|
+
<ItemRow
|
|
294
|
+
key={i}
|
|
295
|
+
item={item}
|
|
296
|
+
lineBelow={lineBelowFor(item, !isLast)}
|
|
297
|
+
stubBelow={isLast && closeWithGap ? stubColor(item) : null}
|
|
298
|
+
// a stub above the first item only when this section opts into a
|
|
299
|
+
// gap AND it isn't introduced by an annotation (the annotation's
|
|
300
|
+
// elbow already connects down into the first item).
|
|
301
|
+
stubAbove={i === 0 && section.gapBefore && !section.annotationBefore ? stubColor(item) : null}
|
|
302
|
+
/>
|
|
303
|
+
)
|
|
304
|
+
})}
|
|
305
|
+
|
|
306
|
+
{section.position === "end" && !sectionFailed && (
|
|
307
|
+
<MilestoneRow milestone={section.milestone} position="end" reached={isDone(lastItem)} />
|
|
308
|
+
)}
|
|
309
|
+
</React.Fragment>
|
|
310
|
+
)
|
|
311
|
+
})}
|
|
312
|
+
</div>
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export { Timeline }
|
package/src/index.ts
CHANGED
|
@@ -44,7 +44,10 @@ 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"
|
|
47
48
|
export * from "./components/ui/help-expander"
|
|
49
|
+
export * from "./components/ui/timeline"
|
|
50
|
+
export * from "./components/ui/expandable-panel"
|
|
48
51
|
|
|
49
52
|
// Brand textures + logo
|
|
50
53
|
export * from "./textures"
|
package/tokens/primitives.css
CHANGED
|
@@ -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;
|
package/tokens/semantic.css
CHANGED
|
@@ -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
|
}
|