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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minka-ds",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Minka product design system — tokenized component library",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -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, pop }: { node: FlowNode; active: boolean; format: FormatFn; pop?: boolean }) {
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
- <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
+ <DiagramNode filled={false}>
196
197
  <span className="text-body-sm text-[var(--color-text-hint)]">{node.name}</span>
197
- </div>
198
+ </DiagramNode>
198
199
  )
199
200
  }
200
201
 
201
- // State node (issue/destroy abstract end): pair-colored, no balance. Icon sits
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
- <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>
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
- <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 && (
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={`text-label-mono ${overdrawn ? "text-[var(--color-feedback-error)]" : "text-[var(--color-text-default)]"}`}
246
- 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
+ }`}
247
234
  >
248
- {format(node.after!)}
235
+ {showAfter ? `was ${format(node.current)}` : format(node.current)}
249
236
  </span>
250
- )}
251
- </div>
252
- )}
253
- </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>
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"
@@ -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
  }