minka-ds 0.4.3 → 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.3",
3
+ "version": "0.4.4",
4
4
  "description": "Minka product design system — tokenized component library",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -83,7 +83,7 @@ function DiagramNode({
83
83
  return (
84
84
  <div
85
85
  data-slot="diagram-node"
86
- className={cn("relative [border-radius:var(--radius-card)]", className)}
86
+ className={cn("relative inline-flex [border-radius:var(--radius-card)]", className)}
87
87
  style={filled ? { animation: "diagram-node-pop .18s cubic-bezier(0.16,1,0.3,1) both" } : undefined}
88
88
  >
89
89
  <style>{`@keyframes diagram-node-pop { from { opacity:0; transform: scale(.96) } to { opacity:1; transform: scale(1) } }`}</style>
@@ -94,10 +94,12 @@ function DiagramNode({
94
94
  style={{ boxShadow: filled ? `inset 0 0 0 4px ${stroke}` : "inset 0 0 0 0px transparent" }}
95
95
  />
96
96
 
97
- {/* top: surface — inset so the ring shows; bg + lift fade in on fill */}
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. */}
98
100
  <div
99
101
  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",
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",
101
103
  sizeClass,
102
104
  )}
103
105
  style={{
@@ -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 }
@@ -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
@@ -46,6 +46,8 @@ export * from "./components/ui/tooltip"
46
46
  export * from "./components/ui/flow-diagram"
47
47
  export * from "./components/ui/diagram-node"
48
48
  export * from "./components/ui/help-expander"
49
+ export * from "./components/ui/timeline"
50
+ export * from "./components/ui/expandable-panel"
49
51
 
50
52
  // Brand textures + logo
51
53
  export * from "./textures"