@styloviz/timeline 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 StyloViz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # @styloviz/timeline
2
+
3
+ > 9 premium variations: horizontal, alternate zigzag, numbered stepper, check stepper, activity feed, status tracker, rich card, date-grouped and interactive (clickable rows).
4
+
5
+ Part of the **StyloViz UI Kit** — a premium Angular 21 + Tailwind CSS 4 dashboard component library. Standalone, `OnPush`, strongly typed, dark-mode ready.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @styloviz/core @styloviz/timeline
11
+ ```
12
+
13
+ Requires `@angular/core` and `@angular/common` >= 21. `@styloviz/core` is a peer dependency shared by every component. Prefer everything at once? `@styloviz/all` installs the whole free tier in one command.
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { SvTimelineComponent } from '@styloviz/timeline';
19
+
20
+ @Component({
21
+ standalone: true,
22
+ imports: [SvTimelineComponent],
23
+ template: `
24
+ <sv-timeline [items]="events" />
25
+ `,
26
+ })
27
+ export class DemoComponent {}
28
+ ```
29
+
30
+ ## Inputs
31
+
32
+ | Input | Type | Default | Description |
33
+ | --- | --- | --- | --- |
34
+ | `items` | `TimelineItem[]` | `[]` | Array of timeline events to render. |
35
+ | `layout` | `TimelineLayout` | `'left'` | Structural layout direction. |
36
+ | `size` | `TimelineSize` | `'md'` | Component size preset. |
37
+ | `showConnector` | `boolean` | `true` | Show connector lines between dots. |
38
+ | `customClass` | `string` | `''` | Additional CSS classes on the root element. |
39
+ | `dotStyle` | `TimelineDotStyle` | `'dot'` | Dot rendering style. |
40
+ | `displayMode` | `TimelineDisplayMode` | `'default'` | Content display mode. |
41
+ | `activeIndex` | `number` | `-1` | Zero-based index of the currently active step. Items before → `complete`; this item → `active`; after → `pending`. Defaults to `-1` (uses each item's own `itemStatus`). |
42
+ | `groupByDate` | `boolean` | `false` | Auto-group items under date header separators using their `groupDate` field. |
43
+ | `horizontalAlign` | `TimelineHorizontalAlign` | `'alternate'` | Content placement for `layout='horizontal'`. |
44
+ | `selectedId` | `string \| number \| null (two-way)` | `null` | Currently selected item id (two-way). Use `[(selectedId)]` with `displayMode="interactive"` for a selectable timeline. |
45
+
46
+ ## Outputs
47
+
48
+ | Output | Type | Description |
49
+ | --- | --- | --- |
50
+ | `itemClick` | `TimelineItem` | Emitted when an interactive row is activated (click / Enter / Space). Only fires when `displayMode='interactive'`. |
51
+ | `actionClick` | `TimelineItem` | Emitted when a `rich`-card CTA button is activated. Fires alongside any `actionUrl` navigation so you can handle the action in code. |
52
+
53
+ ## Documentation
54
+
55
+ Full API reference and live demos: https://styloviz.dev/docs/timeline
56
+
57
+ ## License
58
+
59
+ MIT
@@ -0,0 +1,276 @@
1
+ import * as i0 from '@angular/core';
2
+ import { input, model, output, computed, ChangeDetectionStrategy, Component } from '@angular/core';
3
+ import { NgClass } from '@angular/common';
4
+
5
+ // ─── Constants ────────────────────────────────────────────────────────────────
6
+ const VARIANT_DOT = {
7
+ success: 'bg-emerald-500 ring-emerald-100 dark:ring-emerald-900/40',
8
+ error: 'bg-red-500 ring-red-100 dark:ring-red-900/40',
9
+ warning: 'bg-amber-400 ring-amber-100 dark:ring-amber-900/40',
10
+ info: 'bg-sky-500 ring-sky-100 dark:ring-sky-900/40',
11
+ neutral: 'bg-gray-400 ring-gray-100 dark:ring-gray-800',
12
+ primary: 'bg-indigo-600 ring-indigo-100 dark:ring-indigo-900/40',
13
+ };
14
+ const VARIANT_PULSE = {
15
+ success: 'bg-emerald-400',
16
+ error: 'bg-red-400',
17
+ warning: 'bg-amber-300',
18
+ info: 'bg-sky-400',
19
+ neutral: 'bg-gray-300',
20
+ primary: 'bg-indigo-400',
21
+ };
22
+ const TAG_COLORS = [
23
+ 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
24
+ 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
25
+ 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
26
+ 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
27
+ 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',
28
+ 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300',
29
+ ];
30
+ const AVATAR_COLORS = [
31
+ 'bg-blue-500', 'bg-violet-500', 'bg-emerald-500', 'bg-amber-500',
32
+ 'bg-pink-500', 'bg-cyan-500', 'bg-indigo-500', 'bg-rose-500',
33
+ ];
34
+ // ─── Component ────────────────────────────────────────────────────────────────
35
+ /**
36
+ * TimelineComponent — A versatile, multi-mode event sequence visualizer.
37
+ *
38
+ * ## Layouts
39
+ * - `left` (default): vertical, connector column on the left
40
+ * - `right`: vertical, connector column on the right, content on left
41
+ * - `alternate`: true zigzag — items alternate sides of a center line
42
+ * - `horizontal`: items along a horizontal connector; content alternates top/bottom
43
+ *
44
+ * ## Display Modes
45
+ * - `default`: standard dot / icon content card
46
+ * - `feed`: compact activity feed with actor avatars
47
+ * - `status`: order/progress tracker with complete / active / pending states
48
+ * - `rich`: card-based content with optional image, tags, and CTA button
49
+ *
50
+ * ## Dot Styles
51
+ * - `dot` (default): filled circle with optional icon
52
+ * - `number`: numbered step badges (1, 2, 3…)
53
+ * - `check`: checkmark for complete, pulsing ring for active, hollow for pending
54
+ *
55
+ * ## Other Features
56
+ * - `groupByDate=true`: groups items under date header separators
57
+ * - `activeIndex`: drives step state for `check` / `number` modes
58
+ * - Full dark-mode, 3 sizes (sm / md / lg), accessible ordered-list markup
59
+ *
60
+ * @example
61
+ * ```html
62
+ * <sv-timeline [items]="events" />
63
+ * <sv-timeline [items]="steps" dotStyle="number" [activeIndex]="2" />
64
+ * <sv-timeline [items]="steps" dotStyle="check" [activeIndex]="2" />
65
+ * <sv-timeline [items]="feedItems" displayMode="feed" />
66
+ * <sv-timeline [items]="statusItems" displayMode="status" />
67
+ * <sv-timeline [items]="richItems" displayMode="rich" />
68
+ * <sv-timeline [items]="events" layout="alternate" />
69
+ * <sv-timeline [items]="events" layout="right" />
70
+ * <sv-timeline [items]="events" layout="horizontal" horizontalAlign="alternate" />
71
+ * <sv-timeline [items]="groupedItems" [groupByDate]="true" />
72
+ * ```
73
+ */
74
+ class SvTimelineComponent {
75
+ // ── Inputs ────────────────────────────────────────────────────────────────
76
+ /** Array of timeline events to render. */
77
+ items = input([], ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ []));
78
+ /** Structural layout direction. @default 'left' */
79
+ layout = input('left', ...(ngDevMode ? [{ debugName: "layout" }] : /* istanbul ignore next */ []));
80
+ /** Component size preset. @default 'md' */
81
+ size = input('md', ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
82
+ /** Show connector lines between dots. @default true */
83
+ showConnector = input(true, ...(ngDevMode ? [{ debugName: "showConnector" }] : /* istanbul ignore next */ []));
84
+ /** Additional CSS classes on the root element. */
85
+ customClass = input('', ...(ngDevMode ? [{ debugName: "customClass" }] : /* istanbul ignore next */ []));
86
+ /** Dot rendering style. @default 'dot' */
87
+ dotStyle = input('dot', ...(ngDevMode ? [{ debugName: "dotStyle" }] : /* istanbul ignore next */ []));
88
+ /** Content display mode. @default 'default' */
89
+ displayMode = input('default', ...(ngDevMode ? [{ debugName: "displayMode" }] : /* istanbul ignore next */ []));
90
+ /**
91
+ * Zero-based index of the currently active step.
92
+ * Items before → `complete`; this item → `active`; after → `pending`.
93
+ * Defaults to `-1` (uses each item's own `itemStatus`).
94
+ */
95
+ activeIndex = input(-1, ...(ngDevMode ? [{ debugName: "activeIndex" }] : /* istanbul ignore next */ []));
96
+ /** Auto-group items under date header separators using their `groupDate` field. */
97
+ groupByDate = input(false, ...(ngDevMode ? [{ debugName: "groupByDate" }] : /* istanbul ignore next */ []));
98
+ /** Content placement for `layout='horizontal'`. @default 'alternate' */
99
+ horizontalAlign = input('alternate', ...(ngDevMode ? [{ debugName: "horizontalAlign" }] : /* istanbul ignore next */ []));
100
+ // ── Interaction (displayMode='interactive' / 'rich') ───────────────────────
101
+ /**
102
+ * Currently selected item id (two-way). Use `[(selectedId)]` with
103
+ * `displayMode="interactive"` for a selectable timeline. @default null
104
+ */
105
+ selectedId = model(null, ...(ngDevMode ? [{ debugName: "selectedId" }] : /* istanbul ignore next */ []));
106
+ /**
107
+ * Emitted when an interactive row is activated (click / Enter / Space).
108
+ * Only fires when `displayMode='interactive'`.
109
+ */
110
+ itemClick = output();
111
+ /**
112
+ * Emitted when a `rich`-card CTA button is activated. Fires alongside any
113
+ * `actionUrl` navigation so you can handle the action in code.
114
+ */
115
+ actionClick = output();
116
+ /** True when the given item is the selected one (interactive mode). */
117
+ isSelected(item) {
118
+ return this.selectedId() !== null && this.selectedId() === item.id;
119
+ }
120
+ /** Activate an interactive row: toggle selection + emit `itemClick`. */
121
+ onItemActivate(item) {
122
+ if (this.displayMode() !== 'interactive')
123
+ return;
124
+ this.selectedId.set(item.id);
125
+ this.itemClick.emit(item);
126
+ }
127
+ /** Enter/Space activates an interactive row (keyboard parity with click). */
128
+ onItemKeydown(event, item) {
129
+ if (this.displayMode() !== 'interactive')
130
+ return;
131
+ if (event.key === 'Enter' || event.key === ' ') {
132
+ event.preventDefault();
133
+ this.onItemActivate(item);
134
+ }
135
+ }
136
+ /** Rich-card CTA handler: emit `actionClick`; suppress the '#' jump when no `actionUrl`. */
137
+ onActionClick(item, event) {
138
+ if (!item.actionUrl)
139
+ event.preventDefault();
140
+ this.actionClick.emit(item);
141
+ }
142
+ // ── Computed size tokens ──────────────────────────────────────────────────
143
+ dotSize = computed(() => ({
144
+ sm: 'h-7 w-7',
145
+ md: 'h-9 w-9',
146
+ lg: 'h-11 w-11',
147
+ })[this.size()], ...(ngDevMode ? [{ debugName: "dotSize" }] : /* istanbul ignore next */ []));
148
+ iconSize = computed(() => ({
149
+ sm: 'h-3.5 w-3.5',
150
+ md: 'h-4 w-4',
151
+ lg: 'h-5 w-5',
152
+ })[this.size()], ...(ngDevMode ? [{ debugName: "iconSize" }] : /* istanbul ignore next */ []));
153
+ numberSize = computed(() => ({
154
+ sm: 'text-xs font-bold',
155
+ md: 'text-sm font-bold',
156
+ lg: 'text-base font-bold',
157
+ })[this.size()], ...(ngDevMode ? [{ debugName: "numberSize" }] : /* istanbul ignore next */ []));
158
+ titleClass = computed(() => ({
159
+ sm: 'text-sm font-semibold',
160
+ md: 'text-base font-semibold',
161
+ lg: 'text-lg font-semibold',
162
+ })[this.size()], ...(ngDevMode ? [{ debugName: "titleClass" }] : /* istanbul ignore next */ []));
163
+ descClass = computed(() => ({
164
+ sm: 'text-xs',
165
+ md: 'text-sm',
166
+ lg: 'text-sm',
167
+ })[this.size()], ...(ngDevMode ? [{ debugName: "descClass" }] : /* istanbul ignore next */ []));
168
+ timeClass = computed(() => ({
169
+ sm: 'text-xs',
170
+ md: 'text-xs',
171
+ lg: 'text-sm',
172
+ })[this.size()], ...(ngDevMode ? [{ debugName: "timeClass" }] : /* istanbul ignore next */ []));
173
+ // ── Grouped items ─────────────────────────────────────────────────────────
174
+ groupedItems = computed(() => {
175
+ const result = [];
176
+ let currentDate = null;
177
+ for (const item of this.items()) {
178
+ const date = item.groupDate ?? '';
179
+ if (date !== currentDate) {
180
+ result.push({ date, items: [] });
181
+ currentDate = date;
182
+ }
183
+ result[result.length - 1].items.push(item);
184
+ }
185
+ return result;
186
+ }, ...(ngDevMode ? [{ debugName: "groupedItems" }] : /* istanbul ignore next */ []));
187
+ // ── Per-item helpers ──────────────────────────────────────────────────────
188
+ dotClass(item) {
189
+ return VARIANT_DOT[item.variant ?? 'neutral'];
190
+ }
191
+ pulseClass(item) {
192
+ return VARIANT_PULSE[item.variant ?? 'primary'];
193
+ }
194
+ isLast(index) {
195
+ return index === this.items().length - 1;
196
+ }
197
+ isFirst(index) {
198
+ return index === 0;
199
+ }
200
+ isLastInGroup(groupIndex, itemIndex) {
201
+ return itemIndex === this.groupedItems()[groupIndex].items.length - 1;
202
+ }
203
+ isLastGroup(groupIndex) {
204
+ return groupIndex === this.groupedItems().length - 1;
205
+ }
206
+ trackById(_, item) {
207
+ return item.id;
208
+ }
209
+ trackByDate(_, group) {
210
+ return group.date;
211
+ }
212
+ // ── Stepper helpers ───────────────────────────────────────────────────────
213
+ stepStatus(index, item) {
214
+ const active = this.activeIndex();
215
+ if (active >= 0) {
216
+ if (index < active)
217
+ return 'complete';
218
+ if (index === active)
219
+ return 'active';
220
+ return 'pending';
221
+ }
222
+ return item.itemStatus ?? 'pending';
223
+ }
224
+ stepConnectorClass(status) {
225
+ if (status === 'complete')
226
+ return 'bg-emerald-400 dark:bg-emerald-700';
227
+ if (status === 'active')
228
+ return 'bg-indigo-300 dark:bg-indigo-700';
229
+ return 'bg-gray-200 dark:bg-gray-700 opacity-50';
230
+ }
231
+ // ── Horizontal layout helpers ─────────────────────────────────────────────
232
+ showTopContent(index) {
233
+ const align = this.horizontalAlign();
234
+ if (align === 'top')
235
+ return true;
236
+ if (align === 'bottom')
237
+ return false;
238
+ return index % 2 === 0;
239
+ }
240
+ showBottomContent(index) {
241
+ return !this.showTopContent(index);
242
+ }
243
+ // ── Tag / avatar color helpers ────────────────────────────────────────────
244
+ tagColor(tag) {
245
+ const hash = tag.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
246
+ return TAG_COLORS[hash % TAG_COLORS.length];
247
+ }
248
+ avatarBg(name) {
249
+ if (!name)
250
+ return AVATAR_COLORS[0];
251
+ const hash = name.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
252
+ return AVATAR_COLORS[hash % AVATAR_COLORS.length];
253
+ }
254
+ avatarInitials(name) {
255
+ if (!name)
256
+ return '?';
257
+ const parts = name.trim().split(' ').filter(Boolean);
258
+ if (parts.length >= 2) {
259
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
260
+ }
261
+ return parts[0].slice(0, 2).toUpperCase();
262
+ }
263
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: SvTimelineComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
264
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.13", type: SvTimelineComponent, isStandalone: true, selector: "sv-timeline", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, showConnector: { classPropertyName: "showConnector", publicName: "showConnector", isSignal: true, isRequired: false, transformFunction: null }, customClass: { classPropertyName: "customClass", publicName: "customClass", isSignal: true, isRequired: false, transformFunction: null }, dotStyle: { classPropertyName: "dotStyle", publicName: "dotStyle", isSignal: true, isRequired: false, transformFunction: null }, displayMode: { classPropertyName: "displayMode", publicName: "displayMode", isSignal: true, isRequired: false, transformFunction: null }, activeIndex: { classPropertyName: "activeIndex", publicName: "activeIndex", isSignal: true, isRequired: false, transformFunction: null }, groupByDate: { classPropertyName: "groupByDate", publicName: "groupByDate", isSignal: true, isRequired: false, transformFunction: null }, horizontalAlign: { classPropertyName: "horizontalAlign", publicName: "horizontalAlign", isSignal: true, isRequired: false, transformFunction: null }, selectedId: { classPropertyName: "selectedId", publicName: "selectedId", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectedId: "selectedIdChange", itemClick: "itemClick", actionClick: "actionClick" }, ngImport: i0, template: "<!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n Root wrapper\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n<div [ngClass]=\"['relative', customClass()]\">\n\n\n <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n VARIATION 1 \u2014 HORIZONTAL LAYOUT\n Items flow left-to-right along a horizontal connector line.\n Content alternates top/bottom based on horizontalAlign input.\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n @if (layout() === 'horizontal') {\n <div class=\"overflow-x-auto pb-2 -mx-1 px-1\">\n <ol class=\"flex min-w-max\" aria-label=\"Timeline\">\n\n @for (item of items(); track trackById($index, item); let i = $index) {\n <li class=\"flex flex-col items-center\" style=\"min-width:160px; max-width:200px;\">\n\n <!-- Top content slot (shown when horizontalAlign dictates top) -->\n <div class=\"h-28 flex flex-col justify-end pb-3 text-center px-3 w-full\">\n @if (showTopContent(i)) {\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums block mb-1', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-0.5 text-gray-500 dark:text-gray-400']\">\n {{ item.description }}\n </p>\n }\n }\n </div>\n\n <!-- Connector row: left half-line + dot + right half-line -->\n <div class=\"flex items-center w-full\">\n <!-- Left half-line (invisible for first item) -->\n <div class=\"flex-1 h-0.5\"\n [ngClass]=\"isFirst(i) ? 'invisible' : 'bg-gray-200 dark:bg-gray-700'\">\n </div>\n\n <!-- Dot -->\n <div [ngClass]=\"[\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n\n <!-- Right half-line (invisible for last item) -->\n <div class=\"flex-1 h-0.5\"\n [ngClass]=\"isLast(i) ? 'invisible' : 'bg-gray-200 dark:bg-gray-700'\">\n </div>\n </div>\n\n <!-- Bottom content slot -->\n <div class=\"h-28 flex flex-col justify-start pt-3 text-center px-3 w-full\">\n @if (showBottomContent(i)) {\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums block mb-1', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-0.5 text-gray-500 dark:text-gray-400']\">\n {{ item.description }}\n </p>\n }\n }\n </div>\n\n </li>\n }\n\n </ol>\n </div>\n }\n\n\n <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n VARIATION 2 \u2014 ALTERNATE (ZIGZAG) LAYOUT\n True center-line with items alternating left and right.\n Collapses to single-column on small screens.\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n @else if (layout() === 'alternate') {\n <div class=\"relative\">\n\n <!-- Vertical center line -->\n @if (showConnector()) {\n <div class=\"absolute left-1/2 -translate-x-1/2 top-4 bottom-4 w-0.5\n bg-gray-200 dark:bg-gray-700 hidden sm:block\">\n </div>\n }\n\n <ol class=\"space-y-2\" aria-label=\"Timeline\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n\n <!-- On mobile: single-column (left layout). On sm+: zigzag. -->\n <li class=\"flex items-start gap-3 sm:gap-0\">\n\n <!-- \u2500\u2500 LEFT half (content for even, empty for odd) \u2500\u2500 -->\n <div class=\"hidden sm:flex sm:w-[calc(50%-1.25rem)] sm:justify-end sm:pr-8\">\n @if (i % 2 === 0) {\n <div class=\"text-right max-w-xs w-full pb-8\">\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center justify-end gap-2 mb-1\">\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n </div>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n </div>\n }\n </div>\n\n <!-- \u2500\u2500 CENTER dot (always visible) \u2500\u2500 -->\n <div class=\"flex flex-col items-center shrink-0 sm:w-10 sm:relative sm:z-10\">\n <div [ngClass]=\"[\n 'flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n <!-- Mobile-only vertical connector -->\n @if (showConnector() && !isLast(i)) {\n <div class=\"w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-1 min-h-8 sm:hidden\"></div>\n }\n </div>\n\n <!-- \u2500\u2500 RIGHT half (content for odd, empty for even \u2014 desktop; all items on mobile) \u2500\u2500 -->\n <div [ngClass]=\"i % 2 !== 0 ? 'sm:w-[calc(50%-1.25rem)] sm:pl-8 flex-1 min-w-0' : 'hidden sm:block sm:w-[calc(50%-1.25rem)] sm:pl-8'\">\n @if (i % 2 !== 0) {\n <div class=\"pb-8 sm:max-w-xs\">\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-1\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n </div>\n }\n <!-- Mobile fallback for even items -->\n <div class=\"sm:hidden pb-6 flex-1\">\n @if (i % 2 === 0) {\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-1\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n }\n </div>\n </div>\n\n </li>\n }\n </ol>\n </div>\n }\n\n\n <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n VARIATION 4 \u2014 ACTIVITY FEED MODE\n Compact avatar-based feed. Ideal for audit logs, notifications, commits.\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n @else if (displayMode() === 'feed') {\n <ol class=\"space-y-0\" aria-label=\"Activity feed\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n <li class=\"flex gap-x-3\">\n\n <!-- Avatar column -->\n <div class=\"flex flex-col items-center\">\n <!-- Avatar circle -->\n <div class=\"relative shrink-0 h-8 w-8 rounded-full overflow-hidden ring-2 ring-white dark:ring-gray-900\">\n @if (item.avatarUrl) {\n <img [src]=\"item.avatarUrl\" [alt]=\"item.actorName ?? item.title\"\n class=\"h-full w-full object-cover\" loading=\"lazy\" />\n } @else {\n <div [ngClass]=\"['h-full w-full flex items-center justify-center text-white text-xs font-bold', avatarBg(item.actorName)]\">\n {{ avatarInitials(item.actorName) }}\n </div>\n }\n </div>\n <!-- Connector -->\n @if (showConnector() && !isLast(i)) {\n <div class=\"w-px flex-1 bg-gray-200 dark:bg-gray-700 mt-1 mb-1 min-h-4\"></div>\n }\n </div>\n\n <!-- Feed content -->\n <div class=\"flex-1 min-w-0 pb-4\">\n <div class=\"flex items-baseline justify-between gap-2\">\n <p class=\"text-sm text-gray-700 dark:text-gray-200 leading-relaxed\">\n @if (item.actorName) {\n <span class=\"font-semibold text-gray-900 dark:text-white\">{{ item.actorName }}</span>\n <span class=\"text-gray-500 dark:text-gray-400\"> {{ item.title }}</span>\n } @else {\n <span class=\"font-medium\">{{ item.title }}</span>\n }\n </p>\n @if (item.timestamp) {\n <time [ngClass]=\"['shrink-0 text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n </div>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-0.5 text-gray-500 dark:text-gray-400']\">\n {{ item.description }}\n </p>\n }\n @if (item.badge) {\n <span class=\"mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n\n </li>\n }\n </ol>\n }\n\n\n <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n VARIATION 5 \u2014 STATUS / PROGRESS TRACKER MODE\n Each item carries complete / active / pending state.\n Active item has a pulsing ring. Pending connector is dimmed.\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n @else if (displayMode() === 'status') {\n <ol class=\"space-y-0\" aria-label=\"Progress tracker\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n\n @let status = stepStatus(i, item);\n\n <li class=\"flex gap-x-4\">\n\n <!-- Status dot column -->\n <div class=\"flex flex-col items-center\">\n\n <!-- Dot -->\n <div class=\"relative flex shrink-0 items-center justify-center\">\n\n @if (status === 'active') {\n <!-- Pulsing ring for active step -->\n <span [ngClass]=\"['absolute inset-0 rounded-full animate-ping opacity-40', pulseClass(item)]\"></span>\n }\n\n @if (status === 'complete') {\n <!-- Complete: filled with checkmark -->\n <div [ngClass]=\"['relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-emerald-500 ring-emerald-100 dark:ring-emerald-900/40', dotSize()]\">\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path d=\"M5 13l4 4L19 7\" />\n </svg>\n </div>\n } @else if (status === 'active') {\n <!-- Active: filled primary with dot icon -->\n <div [ngClass]=\"['relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-indigo-600 ring-indigo-200 dark:ring-indigo-900/60', dotSize()]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n } @else {\n <span class=\"h-2.5 w-2.5 rounded-full bg-white\"></span>\n }\n </div>\n } @else {\n <!-- Pending: hollow dot -->\n <div [ngClass]=\"['relative flex shrink-0 items-center justify-center rounded-full ring-2 bg-white dark:bg-gray-900 ring-gray-300 dark:ring-gray-600', dotSize()]\">\n <span class=\"h-2 w-2 rounded-full bg-gray-300 dark:bg-gray-600\"></span>\n </div>\n }\n\n </div>\n\n <!-- Connector -->\n @if (showConnector() && !isLast(i)) {\n <div [ngClass]=\"['w-0.5 flex-1 mt-1 mb-1 min-h-6', stepConnectorClass(status)]\"></div>\n }\n\n </div>\n\n <!-- Content -->\n <div class=\"flex-1 min-w-0 pb-6\">\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-1\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n <p [ngClass]=\"[\n titleClass(),\n status === 'pending'\n ? 'text-gray-500 dark:text-gray-400'\n : 'text-gray-900 dark:text-white',\n 'leading-snug'\n ]\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[\n descClass(),\n status === 'pending'\n ? 'text-gray-300 dark:text-gray-600'\n : 'text-gray-500 dark:text-gray-400',\n 'mt-1 leading-relaxed'\n ]\">\n {{ item.description }}\n </p>\n }\n </div>\n\n </li>\n }\n </ol>\n }\n\n\n <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n VARIATION 6 \u2014 RICH CARD MODE\n Each item renders inside a content card with optional image, tags, CTA.\n Works with left and right layouts.\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n @else if (displayMode() === 'rich') {\n <ol class=\"space-y-0\" aria-label=\"Timeline\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n\n <li [ngClass]=\"[\n 'flex gap-x-4',\n layout() === 'right' ? 'flex-row-reverse' : 'flex-row'\n ]\">\n\n <!-- Dot column -->\n <div class=\"flex flex-col items-center\">\n <div [ngClass]=\"[\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n @if (showConnector() && !isLast(i)) {\n <div class=\"w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-2 mb-2 min-h-6\"></div>\n }\n </div>\n\n <!-- Rich card content -->\n <div [ngClass]=\"['flex-1 min-w-0 pb-6', layout() === 'right' ? 'flex justify-end' : '']\">\n <div class=\"w-full max-w-sm rounded-2xl border border-gray-200 dark:border-gray-700\n bg-white dark:bg-gray-900 shadow-sm overflow-hidden\n transition-shadow hover:shadow-md\">\n\n <!-- Optional card image -->\n @if (item.imageUrl) {\n <img [src]=\"item.imageUrl\" [alt]=\"item.title\"\n class=\"w-full h-36 object-cover\" loading=\"lazy\" />\n }\n\n <div class=\"p-4\">\n <!-- Timestamp + badge -->\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-2\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n\n <!-- Title -->\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n\n <!-- Description -->\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1.5 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n\n <!-- Tags -->\n @if (item.tags && item.tags.length > 0) {\n <div class=\"flex flex-wrap gap-1.5 mt-3\">\n @for (tag of item.tags; track tag) {\n <span [ngClass]=\"['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', tagColor(tag)]\">\n {{ tag }}\n </span>\n }\n </div>\n }\n\n <!-- CTA button -->\n @if (item.actionLabel) {\n <a [href]=\"item.actionUrl ?? '#'\"\n (click)=\"onActionClick(item, $event)\"\n class=\"mt-3 inline-flex items-center gap-1 text-sm font-medium\n text-indigo-600 dark:text-indigo-400 hover:text-indigo-800\n dark:hover:text-indigo-300 transition-colors group\">\n {{ item.actionLabel }}\n <svg class=\"h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path d=\"M9 5l7 7-7 7\" />\n </svg>\n </a>\n }\n </div>\n </div>\n </div>\n\n </li>\n }\n </ol>\n }\n\n\n <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n VARIATION 7 \u2014 DATE-GROUPED LAYOUT\n Items grouped under date header separators using their groupDate field.\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n @else if (groupByDate()) {\n <div>\n @for (group of groupedItems(); track trackByDate($index, group); let gi = $index) {\n\n <!-- Date header separator -->\n <div class=\"flex items-center gap-3 mb-4\" [ngClass]=\"gi > 0 ? 'mt-8' : ''\">\n <div class=\"h-px flex-1 bg-gray-200 dark:bg-gray-700\"></div>\n <span class=\"shrink-0 text-xs font-semibold uppercase tracking-wider\n text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-950 px-2\">\n {{ group.date }}\n </span>\n <div class=\"h-px flex-1 bg-gray-200 dark:bg-gray-700\"></div>\n </div>\n\n <!-- Items within this group -->\n <ol aria-label=\"Events for {{ group.date }}\">\n @for (item of group.items; track item.id; let ii = $index) {\n\n <li [ngClass]=\"[\n 'flex gap-x-4',\n layout() === 'right' ? 'flex-row-reverse' : 'flex-row'\n ]\">\n\n <!-- Dot column -->\n <div class=\"flex flex-col items-center\">\n <div [ngClass]=\"[\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n @if (showConnector() && !isLastInGroup(gi, ii)) {\n <div class=\"w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-1 mb-1 min-h-6\"></div>\n }\n </div>\n\n <!-- Content -->\n <div class=\"flex-1 min-w-0 pb-5\">\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-1\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n </div>\n\n </li>\n }\n </ol>\n }\n </div>\n }\n\n\n <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n VARIATIONS 3 + 8 \u2014 DEFAULT / LEFT / RIGHT LAYOUT\n Handles:\n \u2022 dotStyle='dot' \u2192 standard colored dot with optional icon\n \u2022 dotStyle='number' \u2192 numbered step badge (Variation 3a)\n \u2022 dotStyle='check' \u2192 check/pending stepper (Variation 3b)\n \u2022 layout='left' \u2192 connector column on left (default)\n \u2022 layout='right' \u2192 connector column on right (Variation 8)\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n @else {\n <ol class=\"space-y-0\" aria-label=\"Timeline\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n\n @let status = stepStatus(i, item);\n\n <li [ngClass]=\"[\n 'flex gap-x-4',\n layout() === 'right' ? 'flex-row-reverse' : 'flex-row'\n ]\">\n\n <!-- \u2500\u2500 Connector column (dot + line) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"flex flex-col items-center\">\n\n <!-- \u2500 dotStyle: NUMBER \u2500 -->\n @if (dotStyle() === 'number') {\n @if (status === 'complete') {\n <div [ngClass]=\"[\n 'relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-emerald-500 ring-emerald-100 dark:ring-emerald-900/40',\n dotSize()\n ]\">\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path d=\"M5 13l4 4L19 7\" />\n </svg>\n </div>\n } @else if (status === 'active') {\n <div class=\"relative flex shrink-0 items-center justify-center\">\n <span [ngClass]=\"['absolute inset-0 rounded-full animate-ping opacity-40', pulseClass(item)]\"></span>\n <div [ngClass]=\"[\n 'relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-indigo-600 ring-indigo-200 dark:ring-indigo-900/60',\n dotSize()\n ]\">\n <span [ngClass]=\"['text-white', numberSize()]\">{{ i + 1 }}</span>\n </div>\n </div>\n } @else {\n <div [ngClass]=\"[\n 'flex shrink-0 items-center justify-center rounded-full ring-2 bg-gray-100 dark:bg-gray-800 ring-gray-300 dark:ring-gray-600',\n dotSize()\n ]\">\n <span [ngClass]=\"['text-gray-500 dark:text-gray-400', numberSize()]\">{{ i + 1 }}</span>\n </div>\n }\n }\n\n <!-- \u2500 dotStyle: CHECK \u2500 -->\n @else if (dotStyle() === 'check') {\n @if (status === 'complete') {\n <div [ngClass]=\"[\n 'flex shrink-0 items-center justify-center rounded-full ring-4 bg-emerald-500 ring-emerald-100 dark:ring-emerald-900/40',\n dotSize()\n ]\">\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path d=\"M5 13l4 4L19 7\" />\n </svg>\n </div>\n } @else if (status === 'active') {\n <div class=\"relative flex shrink-0 items-center justify-center\">\n <span [ngClass]=\"['absolute inset-0 rounded-full animate-ping opacity-40 bg-indigo-400']\"></span>\n <div [ngClass]=\"[\n 'relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-indigo-600 ring-indigo-200 dark:ring-indigo-900/60',\n dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n } @else {\n <span class=\"h-2.5 w-2.5 rounded-full bg-white\"></span>\n }\n </div>\n </div>\n } @else {\n <div [ngClass]=\"[\n 'flex shrink-0 items-center justify-center rounded-full ring-2 bg-white dark:bg-gray-900 ring-gray-300 dark:ring-gray-600',\n dotSize()\n ]\">\n <span class=\"h-2 w-2 rounded-full bg-gray-300 dark:bg-gray-600\"></span>\n </div>\n }\n }\n\n <!-- \u2500 dotStyle: DOT (default) \u2500 -->\n @else {\n <div [ngClass]=\"[\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n }\n\n <!-- Connector line -->\n @if (showConnector() && !isLast(i)) {\n @if (dotStyle() === 'check' || dotStyle() === 'number') {\n <div [ngClass]=\"['w-0.5 flex-1 mt-1 mb-1 min-h-6', stepConnectorClass(status)]\"></div>\n } @else {\n <div class=\"w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-1 mb-1 min-h-6\"></div>\n }\n }\n\n </div>\n\n <!-- \u2500\u2500 Content column \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div [ngClass]=\"[\n 'flex-1 min-w-0 pb-6',\n layout() === 'right' ? 'text-right' : '',\n displayMode() === 'interactive'\n ? 'cursor-pointer rounded-lg -mx-2 px-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-400 ' +\n (isSelected(item)\n ? 'bg-indigo-50 dark:bg-indigo-900/25'\n : 'hover:bg-gray-50 dark:hover:bg-gray-800/50')\n : ''\n ]\"\n [attr.role]=\"displayMode() === 'interactive' ? 'button' : null\"\n [attr.tabindex]=\"displayMode() === 'interactive' ? 0 : null\"\n [attr.aria-pressed]=\"displayMode() === 'interactive' ? isSelected(item) : null\"\n (click)=\"onItemActivate(item)\"\n (keydown)=\"onItemKeydown($event, item)\">\n\n <!-- Timestamp + badge row -->\n @if (item.timestamp || item.badge) {\n <div [ngClass]=\"[\n 'flex flex-wrap items-center gap-2 mb-1',\n layout() === 'right' ? 'justify-end' : ''\n ]\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n\n <!-- Title -->\n <p [ngClass]=\"[\n titleClass(),\n (dotStyle() === 'check' || dotStyle() === 'number') && status === 'pending'\n ? 'text-gray-500 dark:text-gray-400'\n : 'text-gray-900 dark:text-white',\n 'leading-snug'\n ]\">\n {{ item.title }}\n </p>\n\n <!-- Description -->\n @if (item.description) {\n <p [ngClass]=\"[\n descClass(),\n (dotStyle() === 'check' || dotStyle() === 'number') && status === 'pending'\n ? 'text-gray-300 dark:text-gray-600'\n : 'text-gray-500 dark:text-gray-400',\n 'mt-1 leading-relaxed'\n ]\">\n {{ item.description }}\n </p>\n }\n\n </div>\n\n </li>\n }\n </ol>\n }\n\n\n</div>\n", dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
265
+ }
266
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: SvTimelineComponent, decorators: [{
267
+ type: Component,
268
+ args: [{ selector: 'sv-timeline', standalone: true, imports: [NgClass], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n Root wrapper\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n<div [ngClass]=\"['relative', customClass()]\">\n\n\n <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n VARIATION 1 \u2014 HORIZONTAL LAYOUT\n Items flow left-to-right along a horizontal connector line.\n Content alternates top/bottom based on horizontalAlign input.\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n @if (layout() === 'horizontal') {\n <div class=\"overflow-x-auto pb-2 -mx-1 px-1\">\n <ol class=\"flex min-w-max\" aria-label=\"Timeline\">\n\n @for (item of items(); track trackById($index, item); let i = $index) {\n <li class=\"flex flex-col items-center\" style=\"min-width:160px; max-width:200px;\">\n\n <!-- Top content slot (shown when horizontalAlign dictates top) -->\n <div class=\"h-28 flex flex-col justify-end pb-3 text-center px-3 w-full\">\n @if (showTopContent(i)) {\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums block mb-1', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-0.5 text-gray-500 dark:text-gray-400']\">\n {{ item.description }}\n </p>\n }\n }\n </div>\n\n <!-- Connector row: left half-line + dot + right half-line -->\n <div class=\"flex items-center w-full\">\n <!-- Left half-line (invisible for first item) -->\n <div class=\"flex-1 h-0.5\"\n [ngClass]=\"isFirst(i) ? 'invisible' : 'bg-gray-200 dark:bg-gray-700'\">\n </div>\n\n <!-- Dot -->\n <div [ngClass]=\"[\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n\n <!-- Right half-line (invisible for last item) -->\n <div class=\"flex-1 h-0.5\"\n [ngClass]=\"isLast(i) ? 'invisible' : 'bg-gray-200 dark:bg-gray-700'\">\n </div>\n </div>\n\n <!-- Bottom content slot -->\n <div class=\"h-28 flex flex-col justify-start pt-3 text-center px-3 w-full\">\n @if (showBottomContent(i)) {\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums block mb-1', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-0.5 text-gray-500 dark:text-gray-400']\">\n {{ item.description }}\n </p>\n }\n }\n </div>\n\n </li>\n }\n\n </ol>\n </div>\n }\n\n\n <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n VARIATION 2 \u2014 ALTERNATE (ZIGZAG) LAYOUT\n True center-line with items alternating left and right.\n Collapses to single-column on small screens.\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n @else if (layout() === 'alternate') {\n <div class=\"relative\">\n\n <!-- Vertical center line -->\n @if (showConnector()) {\n <div class=\"absolute left-1/2 -translate-x-1/2 top-4 bottom-4 w-0.5\n bg-gray-200 dark:bg-gray-700 hidden sm:block\">\n </div>\n }\n\n <ol class=\"space-y-2\" aria-label=\"Timeline\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n\n <!-- On mobile: single-column (left layout). On sm+: zigzag. -->\n <li class=\"flex items-start gap-3 sm:gap-0\">\n\n <!-- \u2500\u2500 LEFT half (content for even, empty for odd) \u2500\u2500 -->\n <div class=\"hidden sm:flex sm:w-[calc(50%-1.25rem)] sm:justify-end sm:pr-8\">\n @if (i % 2 === 0) {\n <div class=\"text-right max-w-xs w-full pb-8\">\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center justify-end gap-2 mb-1\">\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n </div>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n </div>\n }\n </div>\n\n <!-- \u2500\u2500 CENTER dot (always visible) \u2500\u2500 -->\n <div class=\"flex flex-col items-center shrink-0 sm:w-10 sm:relative sm:z-10\">\n <div [ngClass]=\"[\n 'flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n <!-- Mobile-only vertical connector -->\n @if (showConnector() && !isLast(i)) {\n <div class=\"w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-1 min-h-8 sm:hidden\"></div>\n }\n </div>\n\n <!-- \u2500\u2500 RIGHT half (content for odd, empty for even \u2014 desktop; all items on mobile) \u2500\u2500 -->\n <div [ngClass]=\"i % 2 !== 0 ? 'sm:w-[calc(50%-1.25rem)] sm:pl-8 flex-1 min-w-0' : 'hidden sm:block sm:w-[calc(50%-1.25rem)] sm:pl-8'\">\n @if (i % 2 !== 0) {\n <div class=\"pb-8 sm:max-w-xs\">\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-1\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n </div>\n }\n <!-- Mobile fallback for even items -->\n <div class=\"sm:hidden pb-6 flex-1\">\n @if (i % 2 === 0) {\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-1\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n }\n </div>\n </div>\n\n </li>\n }\n </ol>\n </div>\n }\n\n\n <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n VARIATION 4 \u2014 ACTIVITY FEED MODE\n Compact avatar-based feed. Ideal for audit logs, notifications, commits.\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n @else if (displayMode() === 'feed') {\n <ol class=\"space-y-0\" aria-label=\"Activity feed\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n <li class=\"flex gap-x-3\">\n\n <!-- Avatar column -->\n <div class=\"flex flex-col items-center\">\n <!-- Avatar circle -->\n <div class=\"relative shrink-0 h-8 w-8 rounded-full overflow-hidden ring-2 ring-white dark:ring-gray-900\">\n @if (item.avatarUrl) {\n <img [src]=\"item.avatarUrl\" [alt]=\"item.actorName ?? item.title\"\n class=\"h-full w-full object-cover\" loading=\"lazy\" />\n } @else {\n <div [ngClass]=\"['h-full w-full flex items-center justify-center text-white text-xs font-bold', avatarBg(item.actorName)]\">\n {{ avatarInitials(item.actorName) }}\n </div>\n }\n </div>\n <!-- Connector -->\n @if (showConnector() && !isLast(i)) {\n <div class=\"w-px flex-1 bg-gray-200 dark:bg-gray-700 mt-1 mb-1 min-h-4\"></div>\n }\n </div>\n\n <!-- Feed content -->\n <div class=\"flex-1 min-w-0 pb-4\">\n <div class=\"flex items-baseline justify-between gap-2\">\n <p class=\"text-sm text-gray-700 dark:text-gray-200 leading-relaxed\">\n @if (item.actorName) {\n <span class=\"font-semibold text-gray-900 dark:text-white\">{{ item.actorName }}</span>\n <span class=\"text-gray-500 dark:text-gray-400\"> {{ item.title }}</span>\n } @else {\n <span class=\"font-medium\">{{ item.title }}</span>\n }\n </p>\n @if (item.timestamp) {\n <time [ngClass]=\"['shrink-0 text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n </div>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-0.5 text-gray-500 dark:text-gray-400']\">\n {{ item.description }}\n </p>\n }\n @if (item.badge) {\n <span class=\"mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n\n </li>\n }\n </ol>\n }\n\n\n <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n VARIATION 5 \u2014 STATUS / PROGRESS TRACKER MODE\n Each item carries complete / active / pending state.\n Active item has a pulsing ring. Pending connector is dimmed.\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n @else if (displayMode() === 'status') {\n <ol class=\"space-y-0\" aria-label=\"Progress tracker\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n\n @let status = stepStatus(i, item);\n\n <li class=\"flex gap-x-4\">\n\n <!-- Status dot column -->\n <div class=\"flex flex-col items-center\">\n\n <!-- Dot -->\n <div class=\"relative flex shrink-0 items-center justify-center\">\n\n @if (status === 'active') {\n <!-- Pulsing ring for active step -->\n <span [ngClass]=\"['absolute inset-0 rounded-full animate-ping opacity-40', pulseClass(item)]\"></span>\n }\n\n @if (status === 'complete') {\n <!-- Complete: filled with checkmark -->\n <div [ngClass]=\"['relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-emerald-500 ring-emerald-100 dark:ring-emerald-900/40', dotSize()]\">\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path d=\"M5 13l4 4L19 7\" />\n </svg>\n </div>\n } @else if (status === 'active') {\n <!-- Active: filled primary with dot icon -->\n <div [ngClass]=\"['relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-indigo-600 ring-indigo-200 dark:ring-indigo-900/60', dotSize()]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n } @else {\n <span class=\"h-2.5 w-2.5 rounded-full bg-white\"></span>\n }\n </div>\n } @else {\n <!-- Pending: hollow dot -->\n <div [ngClass]=\"['relative flex shrink-0 items-center justify-center rounded-full ring-2 bg-white dark:bg-gray-900 ring-gray-300 dark:ring-gray-600', dotSize()]\">\n <span class=\"h-2 w-2 rounded-full bg-gray-300 dark:bg-gray-600\"></span>\n </div>\n }\n\n </div>\n\n <!-- Connector -->\n @if (showConnector() && !isLast(i)) {\n <div [ngClass]=\"['w-0.5 flex-1 mt-1 mb-1 min-h-6', stepConnectorClass(status)]\"></div>\n }\n\n </div>\n\n <!-- Content -->\n <div class=\"flex-1 min-w-0 pb-6\">\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-1\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n <p [ngClass]=\"[\n titleClass(),\n status === 'pending'\n ? 'text-gray-500 dark:text-gray-400'\n : 'text-gray-900 dark:text-white',\n 'leading-snug'\n ]\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[\n descClass(),\n status === 'pending'\n ? 'text-gray-300 dark:text-gray-600'\n : 'text-gray-500 dark:text-gray-400',\n 'mt-1 leading-relaxed'\n ]\">\n {{ item.description }}\n </p>\n }\n </div>\n\n </li>\n }\n </ol>\n }\n\n\n <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n VARIATION 6 \u2014 RICH CARD MODE\n Each item renders inside a content card with optional image, tags, CTA.\n Works with left and right layouts.\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n @else if (displayMode() === 'rich') {\n <ol class=\"space-y-0\" aria-label=\"Timeline\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n\n <li [ngClass]=\"[\n 'flex gap-x-4',\n layout() === 'right' ? 'flex-row-reverse' : 'flex-row'\n ]\">\n\n <!-- Dot column -->\n <div class=\"flex flex-col items-center\">\n <div [ngClass]=\"[\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n @if (showConnector() && !isLast(i)) {\n <div class=\"w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-2 mb-2 min-h-6\"></div>\n }\n </div>\n\n <!-- Rich card content -->\n <div [ngClass]=\"['flex-1 min-w-0 pb-6', layout() === 'right' ? 'flex justify-end' : '']\">\n <div class=\"w-full max-w-sm rounded-2xl border border-gray-200 dark:border-gray-700\n bg-white dark:bg-gray-900 shadow-sm overflow-hidden\n transition-shadow hover:shadow-md\">\n\n <!-- Optional card image -->\n @if (item.imageUrl) {\n <img [src]=\"item.imageUrl\" [alt]=\"item.title\"\n class=\"w-full h-36 object-cover\" loading=\"lazy\" />\n }\n\n <div class=\"p-4\">\n <!-- Timestamp + badge -->\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-2\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n\n <!-- Title -->\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n\n <!-- Description -->\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1.5 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n\n <!-- Tags -->\n @if (item.tags && item.tags.length > 0) {\n <div class=\"flex flex-wrap gap-1.5 mt-3\">\n @for (tag of item.tags; track tag) {\n <span [ngClass]=\"['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', tagColor(tag)]\">\n {{ tag }}\n </span>\n }\n </div>\n }\n\n <!-- CTA button -->\n @if (item.actionLabel) {\n <a [href]=\"item.actionUrl ?? '#'\"\n (click)=\"onActionClick(item, $event)\"\n class=\"mt-3 inline-flex items-center gap-1 text-sm font-medium\n text-indigo-600 dark:text-indigo-400 hover:text-indigo-800\n dark:hover:text-indigo-300 transition-colors group\">\n {{ item.actionLabel }}\n <svg class=\"h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path d=\"M9 5l7 7-7 7\" />\n </svg>\n </a>\n }\n </div>\n </div>\n </div>\n\n </li>\n }\n </ol>\n }\n\n\n <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n VARIATION 7 \u2014 DATE-GROUPED LAYOUT\n Items grouped under date header separators using their groupDate field.\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n @else if (groupByDate()) {\n <div>\n @for (group of groupedItems(); track trackByDate($index, group); let gi = $index) {\n\n <!-- Date header separator -->\n <div class=\"flex items-center gap-3 mb-4\" [ngClass]=\"gi > 0 ? 'mt-8' : ''\">\n <div class=\"h-px flex-1 bg-gray-200 dark:bg-gray-700\"></div>\n <span class=\"shrink-0 text-xs font-semibold uppercase tracking-wider\n text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-950 px-2\">\n {{ group.date }}\n </span>\n <div class=\"h-px flex-1 bg-gray-200 dark:bg-gray-700\"></div>\n </div>\n\n <!-- Items within this group -->\n <ol aria-label=\"Events for {{ group.date }}\">\n @for (item of group.items; track item.id; let ii = $index) {\n\n <li [ngClass]=\"[\n 'flex gap-x-4',\n layout() === 'right' ? 'flex-row-reverse' : 'flex-row'\n ]\">\n\n <!-- Dot column -->\n <div class=\"flex flex-col items-center\">\n <div [ngClass]=\"[\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n @if (showConnector() && !isLastInGroup(gi, ii)) {\n <div class=\"w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-1 mb-1 min-h-6\"></div>\n }\n </div>\n\n <!-- Content -->\n <div class=\"flex-1 min-w-0 pb-5\">\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-1\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n </div>\n\n </li>\n }\n </ol>\n }\n </div>\n }\n\n\n <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n VARIATIONS 3 + 8 \u2014 DEFAULT / LEFT / RIGHT LAYOUT\n Handles:\n \u2022 dotStyle='dot' \u2192 standard colored dot with optional icon\n \u2022 dotStyle='number' \u2192 numbered step badge (Variation 3a)\n \u2022 dotStyle='check' \u2192 check/pending stepper (Variation 3b)\n \u2022 layout='left' \u2192 connector column on left (default)\n \u2022 layout='right' \u2192 connector column on right (Variation 8)\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->\n @else {\n <ol class=\"space-y-0\" aria-label=\"Timeline\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n\n @let status = stepStatus(i, item);\n\n <li [ngClass]=\"[\n 'flex gap-x-4',\n layout() === 'right' ? 'flex-row-reverse' : 'flex-row'\n ]\">\n\n <!-- \u2500\u2500 Connector column (dot + line) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"flex flex-col items-center\">\n\n <!-- \u2500 dotStyle: NUMBER \u2500 -->\n @if (dotStyle() === 'number') {\n @if (status === 'complete') {\n <div [ngClass]=\"[\n 'relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-emerald-500 ring-emerald-100 dark:ring-emerald-900/40',\n dotSize()\n ]\">\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path d=\"M5 13l4 4L19 7\" />\n </svg>\n </div>\n } @else if (status === 'active') {\n <div class=\"relative flex shrink-0 items-center justify-center\">\n <span [ngClass]=\"['absolute inset-0 rounded-full animate-ping opacity-40', pulseClass(item)]\"></span>\n <div [ngClass]=\"[\n 'relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-indigo-600 ring-indigo-200 dark:ring-indigo-900/60',\n dotSize()\n ]\">\n <span [ngClass]=\"['text-white', numberSize()]\">{{ i + 1 }}</span>\n </div>\n </div>\n } @else {\n <div [ngClass]=\"[\n 'flex shrink-0 items-center justify-center rounded-full ring-2 bg-gray-100 dark:bg-gray-800 ring-gray-300 dark:ring-gray-600',\n dotSize()\n ]\">\n <span [ngClass]=\"['text-gray-500 dark:text-gray-400', numberSize()]\">{{ i + 1 }}</span>\n </div>\n }\n }\n\n <!-- \u2500 dotStyle: CHECK \u2500 -->\n @else if (dotStyle() === 'check') {\n @if (status === 'complete') {\n <div [ngClass]=\"[\n 'flex shrink-0 items-center justify-center rounded-full ring-4 bg-emerald-500 ring-emerald-100 dark:ring-emerald-900/40',\n dotSize()\n ]\">\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path d=\"M5 13l4 4L19 7\" />\n </svg>\n </div>\n } @else if (status === 'active') {\n <div class=\"relative flex shrink-0 items-center justify-center\">\n <span [ngClass]=\"['absolute inset-0 rounded-full animate-ping opacity-40 bg-indigo-400']\"></span>\n <div [ngClass]=\"[\n 'relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-indigo-600 ring-indigo-200 dark:ring-indigo-900/60',\n dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n } @else {\n <span class=\"h-2.5 w-2.5 rounded-full bg-white\"></span>\n }\n </div>\n </div>\n } @else {\n <div [ngClass]=\"[\n 'flex shrink-0 items-center justify-center rounded-full ring-2 bg-white dark:bg-gray-900 ring-gray-300 dark:ring-gray-600',\n dotSize()\n ]\">\n <span class=\"h-2 w-2 rounded-full bg-gray-300 dark:bg-gray-600\"></span>\n </div>\n }\n }\n\n <!-- \u2500 dotStyle: DOT (default) \u2500 -->\n @else {\n <div [ngClass]=\"[\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n }\n\n <!-- Connector line -->\n @if (showConnector() && !isLast(i)) {\n @if (dotStyle() === 'check' || dotStyle() === 'number') {\n <div [ngClass]=\"['w-0.5 flex-1 mt-1 mb-1 min-h-6', stepConnectorClass(status)]\"></div>\n } @else {\n <div class=\"w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-1 mb-1 min-h-6\"></div>\n }\n }\n\n </div>\n\n <!-- \u2500\u2500 Content column \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div [ngClass]=\"[\n 'flex-1 min-w-0 pb-6',\n layout() === 'right' ? 'text-right' : '',\n displayMode() === 'interactive'\n ? 'cursor-pointer rounded-lg -mx-2 px-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-400 ' +\n (isSelected(item)\n ? 'bg-indigo-50 dark:bg-indigo-900/25'\n : 'hover:bg-gray-50 dark:hover:bg-gray-800/50')\n : ''\n ]\"\n [attr.role]=\"displayMode() === 'interactive' ? 'button' : null\"\n [attr.tabindex]=\"displayMode() === 'interactive' ? 0 : null\"\n [attr.aria-pressed]=\"displayMode() === 'interactive' ? isSelected(item) : null\"\n (click)=\"onItemActivate(item)\"\n (keydown)=\"onItemKeydown($event, item)\">\n\n <!-- Timestamp + badge row -->\n @if (item.timestamp || item.badge) {\n <div [ngClass]=\"[\n 'flex flex-wrap items-center gap-2 mb-1',\n layout() === 'right' ? 'justify-end' : ''\n ]\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n\n <!-- Title -->\n <p [ngClass]=\"[\n titleClass(),\n (dotStyle() === 'check' || dotStyle() === 'number') && status === 'pending'\n ? 'text-gray-500 dark:text-gray-400'\n : 'text-gray-900 dark:text-white',\n 'leading-snug'\n ]\">\n {{ item.title }}\n </p>\n\n <!-- Description -->\n @if (item.description) {\n <p [ngClass]=\"[\n descClass(),\n (dotStyle() === 'check' || dotStyle() === 'number') && status === 'pending'\n ? 'text-gray-300 dark:text-gray-600'\n : 'text-gray-500 dark:text-gray-400',\n 'mt-1 leading-relaxed'\n ]\">\n {{ item.description }}\n </p>\n }\n\n </div>\n\n </li>\n }\n </ol>\n }\n\n\n</div>\n" }]
269
+ }], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], showConnector: [{ type: i0.Input, args: [{ isSignal: true, alias: "showConnector", required: false }] }], customClass: [{ type: i0.Input, args: [{ isSignal: true, alias: "customClass", required: false }] }], dotStyle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dotStyle", required: false }] }], displayMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "displayMode", required: false }] }], activeIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeIndex", required: false }] }], groupByDate: [{ type: i0.Input, args: [{ isSignal: true, alias: "groupByDate", required: false }] }], horizontalAlign: [{ type: i0.Input, args: [{ isSignal: true, alias: "horizontalAlign", required: false }] }], selectedId: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectedId", required: false }] }, { type: i0.Output, args: ["selectedIdChange"] }], itemClick: [{ type: i0.Output, args: ["itemClick"] }], actionClick: [{ type: i0.Output, args: ["actionClick"] }] } });
270
+
271
+ /**
272
+ * Generated bundle index. Do not edit.
273
+ */
274
+
275
+ export { SvTimelineComponent };
276
+ //# sourceMappingURL=styloviz-timeline.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"styloviz-timeline.mjs","sources":["../../../../projects/timeline-free/src/lib/timeline.component.ts","../../../../projects/timeline-free/src/lib/timeline.component.html","../../../../projects/timeline-free/src/styloviz-timeline.ts"],"sourcesContent":["import {\n ChangeDetectionStrategy,\n Component,\n computed,\n input,\n model,\n output,\n} from '@angular/core';\nimport { NgClass } from '@angular/common';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport type TimelineVariant =\n | 'success'\n | 'error'\n | 'warning'\n | 'info'\n | 'neutral'\n | 'primary';\n\nexport type TimelineLayout = 'left' | 'right' | 'alternate' | 'horizontal';\nexport type TimelineSize = 'sm' | 'md' | 'lg';\nexport type TimelineDotStyle = 'dot' | 'number' | 'check';\nexport type TimelineDisplayMode = 'default' | 'feed' | 'status' | 'rich' | 'interactive';\nexport type TimelineItemStatus = 'complete' | 'active' | 'pending';\nexport type TimelineHorizontalAlign = 'top' | 'bottom' | 'alternate';\n\nexport interface TimelineItem {\n /** Unique identifier. */\n id: string | number;\n /** Main heading for this event. */\n title: string;\n /** Optional supporting text or description. */\n description?: string;\n /** ISO date string or human-readable timestamp label. */\n timestamp?: string;\n /** Color/status variant for the connector dot. @default 'neutral' */\n variant?: TimelineVariant;\n /**\n * SVG path `d` attribute for an icon inside the dot.\n * Omit for a solid circle.\n */\n iconPath?: string;\n /** Badge chip label rendered next to the timestamp. */\n badge?: string;\n /**\n * Step status for `displayMode='status'` or `dotStyle='check'`.\n * Also driven automatically when `activeIndex` is set on the component.\n */\n itemStatus?: TimelineItemStatus;\n /** Avatar image URL — used in `displayMode='feed'`. */\n avatarUrl?: string;\n /** Actor/author name — used in `displayMode='feed'`. */\n actorName?: string;\n /** Optional card image URL — used in `displayMode='rich'`. */\n imageUrl?: string;\n /** Tag chips — used in `displayMode='rich'`. */\n tags?: string[];\n /** CTA button label — used in `displayMode='rich'`. */\n actionLabel?: string;\n /** CTA href — used in `displayMode='rich'`. */\n actionUrl?: string;\n /**\n * Date group header for `groupByDate=true`.\n * Consecutive items sharing the same value are grouped under one header.\n */\n groupDate?: string;\n}\n\nexport interface TimelineGroup {\n date: string;\n items: TimelineItem[];\n}\n\n// ─── Constants ────────────────────────────────────────────────────────────────\n\nconst VARIANT_DOT: Record<TimelineVariant, string> = {\n success: 'bg-emerald-500 ring-emerald-100 dark:ring-emerald-900/40',\n error: 'bg-red-500 ring-red-100 dark:ring-red-900/40',\n warning: 'bg-amber-400 ring-amber-100 dark:ring-amber-900/40',\n info: 'bg-sky-500 ring-sky-100 dark:ring-sky-900/40',\n neutral: 'bg-gray-400 ring-gray-100 dark:ring-gray-800',\n primary: 'bg-indigo-600 ring-indigo-100 dark:ring-indigo-900/40',\n};\n\nconst VARIANT_PULSE: Record<TimelineVariant, string> = {\n success: 'bg-emerald-400',\n error: 'bg-red-400',\n warning: 'bg-amber-300',\n info: 'bg-sky-400',\n neutral: 'bg-gray-300',\n primary: 'bg-indigo-400',\n};\n\nconst TAG_COLORS = [\n 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',\n 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',\n 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',\n 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',\n 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',\n 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300',\n];\n\nconst AVATAR_COLORS = [\n 'bg-blue-500', 'bg-violet-500', 'bg-emerald-500', 'bg-amber-500',\n 'bg-pink-500', 'bg-cyan-500', 'bg-indigo-500', 'bg-rose-500',\n];\n\n// ─── Component ────────────────────────────────────────────────────────────────\n\n/**\n * TimelineComponent — A versatile, multi-mode event sequence visualizer.\n *\n * ## Layouts\n * - `left` (default): vertical, connector column on the left\n * - `right`: vertical, connector column on the right, content on left\n * - `alternate`: true zigzag — items alternate sides of a center line\n * - `horizontal`: items along a horizontal connector; content alternates top/bottom\n *\n * ## Display Modes\n * - `default`: standard dot / icon content card\n * - `feed`: compact activity feed with actor avatars\n * - `status`: order/progress tracker with complete / active / pending states\n * - `rich`: card-based content with optional image, tags, and CTA button\n *\n * ## Dot Styles\n * - `dot` (default): filled circle with optional icon\n * - `number`: numbered step badges (1, 2, 3…)\n * - `check`: checkmark for complete, pulsing ring for active, hollow for pending\n *\n * ## Other Features\n * - `groupByDate=true`: groups items under date header separators\n * - `activeIndex`: drives step state for `check` / `number` modes\n * - Full dark-mode, 3 sizes (sm / md / lg), accessible ordered-list markup\n *\n * @example\n * ```html\n * <sv-timeline [items]=\"events\" />\n * <sv-timeline [items]=\"steps\" dotStyle=\"number\" [activeIndex]=\"2\" />\n * <sv-timeline [items]=\"steps\" dotStyle=\"check\" [activeIndex]=\"2\" />\n * <sv-timeline [items]=\"feedItems\" displayMode=\"feed\" />\n * <sv-timeline [items]=\"statusItems\" displayMode=\"status\" />\n * <sv-timeline [items]=\"richItems\" displayMode=\"rich\" />\n * <sv-timeline [items]=\"events\" layout=\"alternate\" />\n * <sv-timeline [items]=\"events\" layout=\"right\" />\n * <sv-timeline [items]=\"events\" layout=\"horizontal\" horizontalAlign=\"alternate\" />\n * <sv-timeline [items]=\"groupedItems\" [groupByDate]=\"true\" />\n * ```\n */\n@Component({\n selector: 'sv-timeline',\n standalone: true,\n imports: [NgClass],\n templateUrl: './timeline.component.html',\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SvTimelineComponent {\n\n // ── Inputs ────────────────────────────────────────────────────────────────\n\n /** Array of timeline events to render. */\n items = input<TimelineItem[]>([]);\n\n /** Structural layout direction. @default 'left' */\n layout = input<TimelineLayout>('left');\n\n /** Component size preset. @default 'md' */\n size = input<TimelineSize>('md');\n\n /** Show connector lines between dots. @default true */\n showConnector = input<boolean>(true);\n\n /** Additional CSS classes on the root element. */\n customClass = input<string>('');\n\n /** Dot rendering style. @default 'dot' */\n dotStyle = input<TimelineDotStyle>('dot');\n\n /** Content display mode. @default 'default' */\n displayMode = input<TimelineDisplayMode>('default');\n\n /**\n * Zero-based index of the currently active step.\n * Items before → `complete`; this item → `active`; after → `pending`.\n * Defaults to `-1` (uses each item's own `itemStatus`).\n */\n activeIndex = input<number>(-1);\n\n /** Auto-group items under date header separators using their `groupDate` field. */\n groupByDate = input<boolean>(false);\n\n /** Content placement for `layout='horizontal'`. @default 'alternate' */\n horizontalAlign = input<TimelineHorizontalAlign>('alternate');\n\n // ── Interaction (displayMode='interactive' / 'rich') ───────────────────────\n\n /**\n * Currently selected item id (two-way). Use `[(selectedId)]` with\n * `displayMode=\"interactive\"` for a selectable timeline. @default null\n */\n selectedId = model<string | number | null>(null);\n\n /**\n * Emitted when an interactive row is activated (click / Enter / Space).\n * Only fires when `displayMode='interactive'`.\n */\n itemClick = output<TimelineItem>();\n\n /**\n * Emitted when a `rich`-card CTA button is activated. Fires alongside any\n * `actionUrl` navigation so you can handle the action in code.\n */\n actionClick = output<TimelineItem>();\n\n /** True when the given item is the selected one (interactive mode). */\n isSelected(item: TimelineItem): boolean {\n return this.selectedId() !== null && this.selectedId() === item.id;\n }\n\n /** Activate an interactive row: toggle selection + emit `itemClick`. */\n onItemActivate(item: TimelineItem): void {\n if (this.displayMode() !== 'interactive') return;\n this.selectedId.set(item.id);\n this.itemClick.emit(item);\n }\n\n /** Enter/Space activates an interactive row (keyboard parity with click). */\n onItemKeydown(event: KeyboardEvent, item: TimelineItem): void {\n if (this.displayMode() !== 'interactive') return;\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n this.onItemActivate(item);\n }\n }\n\n /** Rich-card CTA handler: emit `actionClick`; suppress the '#' jump when no `actionUrl`. */\n onActionClick(item: TimelineItem, event: MouseEvent): void {\n if (!item.actionUrl) event.preventDefault();\n this.actionClick.emit(item);\n }\n\n // ── Computed size tokens ──────────────────────────────────────────────────\n\n dotSize = computed<string>(() => ({\n sm: 'h-7 w-7',\n md: 'h-9 w-9',\n lg: 'h-11 w-11',\n })[this.size()]);\n\n iconSize = computed<string>(() => ({\n sm: 'h-3.5 w-3.5',\n md: 'h-4 w-4',\n lg: 'h-5 w-5',\n })[this.size()]);\n\n numberSize = computed<string>(() => ({\n sm: 'text-xs font-bold',\n md: 'text-sm font-bold',\n lg: 'text-base font-bold',\n })[this.size()]);\n\n titleClass = computed<string>(() => ({\n sm: 'text-sm font-semibold',\n md: 'text-base font-semibold',\n lg: 'text-lg font-semibold',\n })[this.size()]);\n\n descClass = computed<string>(() => ({\n sm: 'text-xs',\n md: 'text-sm',\n lg: 'text-sm',\n })[this.size()]);\n\n timeClass = computed<string>(() => ({\n sm: 'text-xs',\n md: 'text-xs',\n lg: 'text-sm',\n })[this.size()]);\n\n // ── Grouped items ─────────────────────────────────────────────────────────\n\n groupedItems = computed<TimelineGroup[]>(() => {\n const result: TimelineGroup[] = [];\n let currentDate: string | null = null;\n for (const item of this.items()) {\n const date = item.groupDate ?? '';\n if (date !== currentDate) {\n result.push({ date, items: [] });\n currentDate = date;\n }\n result[result.length - 1].items.push(item);\n }\n return result;\n });\n\n // ── Per-item helpers ──────────────────────────────────────────────────────\n\n dotClass(item: TimelineItem): string {\n return VARIANT_DOT[item.variant ?? 'neutral'];\n }\n\n pulseClass(item: TimelineItem): string {\n return VARIANT_PULSE[item.variant ?? 'primary'];\n }\n\n isLast(index: number): boolean {\n return index === this.items().length - 1;\n }\n\n isFirst(index: number): boolean {\n return index === 0;\n }\n\n isLastInGroup(groupIndex: number, itemIndex: number): boolean {\n return itemIndex === this.groupedItems()[groupIndex].items.length - 1;\n }\n\n isLastGroup(groupIndex: number): boolean {\n return groupIndex === this.groupedItems().length - 1;\n }\n\n trackById(_: number, item: TimelineItem): string | number {\n return item.id;\n }\n\n trackByDate(_: number, group: TimelineGroup): string {\n return group.date;\n }\n\n // ── Stepper helpers ───────────────────────────────────────────────────────\n\n stepStatus(index: number, item: TimelineItem): TimelineItemStatus {\n const active = this.activeIndex();\n if (active >= 0) {\n if (index < active) return 'complete';\n if (index === active) return 'active';\n return 'pending';\n }\n return item.itemStatus ?? 'pending';\n }\n\n stepConnectorClass(status: TimelineItemStatus): string {\n if (status === 'complete') return 'bg-emerald-400 dark:bg-emerald-700';\n if (status === 'active') return 'bg-indigo-300 dark:bg-indigo-700';\n return 'bg-gray-200 dark:bg-gray-700 opacity-50';\n }\n\n // ── Horizontal layout helpers ─────────────────────────────────────────────\n\n showTopContent(index: number): boolean {\n const align = this.horizontalAlign();\n if (align === 'top') return true;\n if (align === 'bottom') return false;\n return index % 2 === 0;\n }\n\n showBottomContent(index: number): boolean {\n return !this.showTopContent(index);\n }\n\n // ── Tag / avatar color helpers ────────────────────────────────────────────\n\n tagColor(tag: string): string {\n const hash = tag.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);\n return TAG_COLORS[hash % TAG_COLORS.length];\n }\n\n avatarBg(name?: string): string {\n if (!name) return AVATAR_COLORS[0];\n const hash = name.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);\n return AVATAR_COLORS[hash % AVATAR_COLORS.length];\n }\n\n avatarInitials(name?: string): string {\n if (!name) return '?';\n const parts = name.trim().split(' ').filter(Boolean);\n if (parts.length >= 2) {\n return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();\n }\n return parts[0].slice(0, 2).toUpperCase();\n }\n}\n","<!-- ══════════════════════════════════════════════════════════════════════════\n Root wrapper\n ══════════════════════════════════════════════════════════════════════════ -->\n<div [ngClass]=\"['relative', customClass()]\">\n\n\n <!-- ═══════════════════════════════════════════════════════════════════════\n VARIATION 1 — HORIZONTAL LAYOUT\n Items flow left-to-right along a horizontal connector line.\n Content alternates top/bottom based on horizontalAlign input.\n ═══════════════════════════════════════════════════════════════════════ -->\n @if (layout() === 'horizontal') {\n <div class=\"overflow-x-auto pb-2 -mx-1 px-1\">\n <ol class=\"flex min-w-max\" aria-label=\"Timeline\">\n\n @for (item of items(); track trackById($index, item); let i = $index) {\n <li class=\"flex flex-col items-center\" style=\"min-width:160px; max-width:200px;\">\n\n <!-- Top content slot (shown when horizontalAlign dictates top) -->\n <div class=\"h-28 flex flex-col justify-end pb-3 text-center px-3 w-full\">\n @if (showTopContent(i)) {\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums block mb-1', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-0.5 text-gray-500 dark:text-gray-400']\">\n {{ item.description }}\n </p>\n }\n }\n </div>\n\n <!-- Connector row: left half-line + dot + right half-line -->\n <div class=\"flex items-center w-full\">\n <!-- Left half-line (invisible for first item) -->\n <div class=\"flex-1 h-0.5\"\n [ngClass]=\"isFirst(i) ? 'invisible' : 'bg-gray-200 dark:bg-gray-700'\">\n </div>\n\n <!-- Dot -->\n <div [ngClass]=\"[\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n\n <!-- Right half-line (invisible for last item) -->\n <div class=\"flex-1 h-0.5\"\n [ngClass]=\"isLast(i) ? 'invisible' : 'bg-gray-200 dark:bg-gray-700'\">\n </div>\n </div>\n\n <!-- Bottom content slot -->\n <div class=\"h-28 flex flex-col justify-start pt-3 text-center px-3 w-full\">\n @if (showBottomContent(i)) {\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums block mb-1', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-0.5 text-gray-500 dark:text-gray-400']\">\n {{ item.description }}\n </p>\n }\n }\n </div>\n\n </li>\n }\n\n </ol>\n </div>\n }\n\n\n <!-- ═══════════════════════════════════════════════════════════════════════\n VARIATION 2 — ALTERNATE (ZIGZAG) LAYOUT\n True center-line with items alternating left and right.\n Collapses to single-column on small screens.\n ═══════════════════════════════════════════════════════════════════════ -->\n @else if (layout() === 'alternate') {\n <div class=\"relative\">\n\n <!-- Vertical center line -->\n @if (showConnector()) {\n <div class=\"absolute left-1/2 -translate-x-1/2 top-4 bottom-4 w-0.5\n bg-gray-200 dark:bg-gray-700 hidden sm:block\">\n </div>\n }\n\n <ol class=\"space-y-2\" aria-label=\"Timeline\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n\n <!-- On mobile: single-column (left layout). On sm+: zigzag. -->\n <li class=\"flex items-start gap-3 sm:gap-0\">\n\n <!-- ── LEFT half (content for even, empty for odd) ── -->\n <div class=\"hidden sm:flex sm:w-[calc(50%-1.25rem)] sm:justify-end sm:pr-8\">\n @if (i % 2 === 0) {\n <div class=\"text-right max-w-xs w-full pb-8\">\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center justify-end gap-2 mb-1\">\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n </div>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n </div>\n }\n </div>\n\n <!-- ── CENTER dot (always visible) ── -->\n <div class=\"flex flex-col items-center shrink-0 sm:w-10 sm:relative sm:z-10\">\n <div [ngClass]=\"[\n 'flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n <!-- Mobile-only vertical connector -->\n @if (showConnector() && !isLast(i)) {\n <div class=\"w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-1 min-h-8 sm:hidden\"></div>\n }\n </div>\n\n <!-- ── RIGHT half (content for odd, empty for even — desktop; all items on mobile) ── -->\n <div [ngClass]=\"i % 2 !== 0 ? 'sm:w-[calc(50%-1.25rem)] sm:pl-8 flex-1 min-w-0' : 'hidden sm:block sm:w-[calc(50%-1.25rem)] sm:pl-8'\">\n @if (i % 2 !== 0) {\n <div class=\"pb-8 sm:max-w-xs\">\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-1\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n </div>\n }\n <!-- Mobile fallback for even items -->\n <div class=\"sm:hidden pb-6 flex-1\">\n @if (i % 2 === 0) {\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-1\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n }\n </div>\n </div>\n\n </li>\n }\n </ol>\n </div>\n }\n\n\n <!-- ═══════════════════════════════════════════════════════════════════════\n VARIATION 4 — ACTIVITY FEED MODE\n Compact avatar-based feed. Ideal for audit logs, notifications, commits.\n ═══════════════════════════════════════════════════════════════════════ -->\n @else if (displayMode() === 'feed') {\n <ol class=\"space-y-0\" aria-label=\"Activity feed\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n <li class=\"flex gap-x-3\">\n\n <!-- Avatar column -->\n <div class=\"flex flex-col items-center\">\n <!-- Avatar circle -->\n <div class=\"relative shrink-0 h-8 w-8 rounded-full overflow-hidden ring-2 ring-white dark:ring-gray-900\">\n @if (item.avatarUrl) {\n <img [src]=\"item.avatarUrl\" [alt]=\"item.actorName ?? item.title\"\n class=\"h-full w-full object-cover\" loading=\"lazy\" />\n } @else {\n <div [ngClass]=\"['h-full w-full flex items-center justify-center text-white text-xs font-bold', avatarBg(item.actorName)]\">\n {{ avatarInitials(item.actorName) }}\n </div>\n }\n </div>\n <!-- Connector -->\n @if (showConnector() && !isLast(i)) {\n <div class=\"w-px flex-1 bg-gray-200 dark:bg-gray-700 mt-1 mb-1 min-h-4\"></div>\n }\n </div>\n\n <!-- Feed content -->\n <div class=\"flex-1 min-w-0 pb-4\">\n <div class=\"flex items-baseline justify-between gap-2\">\n <p class=\"text-sm text-gray-700 dark:text-gray-200 leading-relaxed\">\n @if (item.actorName) {\n <span class=\"font-semibold text-gray-900 dark:text-white\">{{ item.actorName }}</span>\n <span class=\"text-gray-500 dark:text-gray-400\"> {{ item.title }}</span>\n } @else {\n <span class=\"font-medium\">{{ item.title }}</span>\n }\n </p>\n @if (item.timestamp) {\n <time [ngClass]=\"['shrink-0 text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n </div>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-0.5 text-gray-500 dark:text-gray-400']\">\n {{ item.description }}\n </p>\n }\n @if (item.badge) {\n <span class=\"mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n\n </li>\n }\n </ol>\n }\n\n\n <!-- ═══════════════════════════════════════════════════════════════════════\n VARIATION 5 — STATUS / PROGRESS TRACKER MODE\n Each item carries complete / active / pending state.\n Active item has a pulsing ring. Pending connector is dimmed.\n ═══════════════════════════════════════════════════════════════════════ -->\n @else if (displayMode() === 'status') {\n <ol class=\"space-y-0\" aria-label=\"Progress tracker\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n\n @let status = stepStatus(i, item);\n\n <li class=\"flex gap-x-4\">\n\n <!-- Status dot column -->\n <div class=\"flex flex-col items-center\">\n\n <!-- Dot -->\n <div class=\"relative flex shrink-0 items-center justify-center\">\n\n @if (status === 'active') {\n <!-- Pulsing ring for active step -->\n <span [ngClass]=\"['absolute inset-0 rounded-full animate-ping opacity-40', pulseClass(item)]\"></span>\n }\n\n @if (status === 'complete') {\n <!-- Complete: filled with checkmark -->\n <div [ngClass]=\"['relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-emerald-500 ring-emerald-100 dark:ring-emerald-900/40', dotSize()]\">\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path d=\"M5 13l4 4L19 7\" />\n </svg>\n </div>\n } @else if (status === 'active') {\n <!-- Active: filled primary with dot icon -->\n <div [ngClass]=\"['relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-indigo-600 ring-indigo-200 dark:ring-indigo-900/60', dotSize()]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n } @else {\n <span class=\"h-2.5 w-2.5 rounded-full bg-white\"></span>\n }\n </div>\n } @else {\n <!-- Pending: hollow dot -->\n <div [ngClass]=\"['relative flex shrink-0 items-center justify-center rounded-full ring-2 bg-white dark:bg-gray-900 ring-gray-300 dark:ring-gray-600', dotSize()]\">\n <span class=\"h-2 w-2 rounded-full bg-gray-300 dark:bg-gray-600\"></span>\n </div>\n }\n\n </div>\n\n <!-- Connector -->\n @if (showConnector() && !isLast(i)) {\n <div [ngClass]=\"['w-0.5 flex-1 mt-1 mb-1 min-h-6', stepConnectorClass(status)]\"></div>\n }\n\n </div>\n\n <!-- Content -->\n <div class=\"flex-1 min-w-0 pb-6\">\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-1\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n <p [ngClass]=\"[\n titleClass(),\n status === 'pending'\n ? 'text-gray-500 dark:text-gray-400'\n : 'text-gray-900 dark:text-white',\n 'leading-snug'\n ]\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[\n descClass(),\n status === 'pending'\n ? 'text-gray-300 dark:text-gray-600'\n : 'text-gray-500 dark:text-gray-400',\n 'mt-1 leading-relaxed'\n ]\">\n {{ item.description }}\n </p>\n }\n </div>\n\n </li>\n }\n </ol>\n }\n\n\n <!-- ═══════════════════════════════════════════════════════════════════════\n VARIATION 6 — RICH CARD MODE\n Each item renders inside a content card with optional image, tags, CTA.\n Works with left and right layouts.\n ═══════════════════════════════════════════════════════════════════════ -->\n @else if (displayMode() === 'rich') {\n <ol class=\"space-y-0\" aria-label=\"Timeline\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n\n <li [ngClass]=\"[\n 'flex gap-x-4',\n layout() === 'right' ? 'flex-row-reverse' : 'flex-row'\n ]\">\n\n <!-- Dot column -->\n <div class=\"flex flex-col items-center\">\n <div [ngClass]=\"[\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n @if (showConnector() && !isLast(i)) {\n <div class=\"w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-2 mb-2 min-h-6\"></div>\n }\n </div>\n\n <!-- Rich card content -->\n <div [ngClass]=\"['flex-1 min-w-0 pb-6', layout() === 'right' ? 'flex justify-end' : '']\">\n <div class=\"w-full max-w-sm rounded-2xl border border-gray-200 dark:border-gray-700\n bg-white dark:bg-gray-900 shadow-sm overflow-hidden\n transition-shadow hover:shadow-md\">\n\n <!-- Optional card image -->\n @if (item.imageUrl) {\n <img [src]=\"item.imageUrl\" [alt]=\"item.title\"\n class=\"w-full h-36 object-cover\" loading=\"lazy\" />\n }\n\n <div class=\"p-4\">\n <!-- Timestamp + badge -->\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-2\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n\n <!-- Title -->\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n\n <!-- Description -->\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1.5 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n\n <!-- Tags -->\n @if (item.tags && item.tags.length > 0) {\n <div class=\"flex flex-wrap gap-1.5 mt-3\">\n @for (tag of item.tags; track tag) {\n <span [ngClass]=\"['inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', tagColor(tag)]\">\n {{ tag }}\n </span>\n }\n </div>\n }\n\n <!-- CTA button -->\n @if (item.actionLabel) {\n <a [href]=\"item.actionUrl ?? '#'\"\n (click)=\"onActionClick(item, $event)\"\n class=\"mt-3 inline-flex items-center gap-1 text-sm font-medium\n text-indigo-600 dark:text-indigo-400 hover:text-indigo-800\n dark:hover:text-indigo-300 transition-colors group\">\n {{ item.actionLabel }}\n <svg class=\"h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path d=\"M9 5l7 7-7 7\" />\n </svg>\n </a>\n }\n </div>\n </div>\n </div>\n\n </li>\n }\n </ol>\n }\n\n\n <!-- ═══════════════════════════════════════════════════════════════════════\n VARIATION 7 — DATE-GROUPED LAYOUT\n Items grouped under date header separators using their groupDate field.\n ═══════════════════════════════════════════════════════════════════════ -->\n @else if (groupByDate()) {\n <div>\n @for (group of groupedItems(); track trackByDate($index, group); let gi = $index) {\n\n <!-- Date header separator -->\n <div class=\"flex items-center gap-3 mb-4\" [ngClass]=\"gi > 0 ? 'mt-8' : ''\">\n <div class=\"h-px flex-1 bg-gray-200 dark:bg-gray-700\"></div>\n <span class=\"shrink-0 text-xs font-semibold uppercase tracking-wider\n text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-950 px-2\">\n {{ group.date }}\n </span>\n <div class=\"h-px flex-1 bg-gray-200 dark:bg-gray-700\"></div>\n </div>\n\n <!-- Items within this group -->\n <ol aria-label=\"Events for {{ group.date }}\">\n @for (item of group.items; track item.id; let ii = $index) {\n\n <li [ngClass]=\"[\n 'flex gap-x-4',\n layout() === 'right' ? 'flex-row-reverse' : 'flex-row'\n ]\">\n\n <!-- Dot column -->\n <div class=\"flex flex-col items-center\">\n <div [ngClass]=\"[\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n @if (showConnector() && !isLastInGroup(gi, ii)) {\n <div class=\"w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-1 mb-1 min-h-6\"></div>\n }\n </div>\n\n <!-- Content -->\n <div class=\"flex-1 min-w-0 pb-5\">\n @if (item.timestamp || item.badge) {\n <div class=\"flex flex-wrap items-center gap-2 mb-1\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n <p [ngClass]=\"[titleClass(), 'text-gray-900 dark:text-white leading-snug']\">\n {{ item.title }}\n </p>\n @if (item.description) {\n <p [ngClass]=\"[descClass(), 'mt-1 text-gray-500 dark:text-gray-400 leading-relaxed']\">\n {{ item.description }}\n </p>\n }\n </div>\n\n </li>\n }\n </ol>\n }\n </div>\n }\n\n\n <!-- ═══════════════════════════════════════════════════════════════════════\n VARIATIONS 3 + 8 — DEFAULT / LEFT / RIGHT LAYOUT\n Handles:\n • dotStyle='dot' → standard colored dot with optional icon\n • dotStyle='number' → numbered step badge (Variation 3a)\n • dotStyle='check' → check/pending stepper (Variation 3b)\n • layout='left' → connector column on left (default)\n • layout='right' → connector column on right (Variation 8)\n ═══════════════════════════════════════════════════════════════════════ -->\n @else {\n <ol class=\"space-y-0\" aria-label=\"Timeline\">\n @for (item of items(); track trackById($index, item); let i = $index) {\n\n @let status = stepStatus(i, item);\n\n <li [ngClass]=\"[\n 'flex gap-x-4',\n layout() === 'right' ? 'flex-row-reverse' : 'flex-row'\n ]\">\n\n <!-- ── Connector column (dot + line) ──────────────────────── -->\n <div class=\"flex flex-col items-center\">\n\n <!-- ─ dotStyle: NUMBER ─ -->\n @if (dotStyle() === 'number') {\n @if (status === 'complete') {\n <div [ngClass]=\"[\n 'relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-emerald-500 ring-emerald-100 dark:ring-emerald-900/40',\n dotSize()\n ]\">\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path d=\"M5 13l4 4L19 7\" />\n </svg>\n </div>\n } @else if (status === 'active') {\n <div class=\"relative flex shrink-0 items-center justify-center\">\n <span [ngClass]=\"['absolute inset-0 rounded-full animate-ping opacity-40', pulseClass(item)]\"></span>\n <div [ngClass]=\"[\n 'relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-indigo-600 ring-indigo-200 dark:ring-indigo-900/60',\n dotSize()\n ]\">\n <span [ngClass]=\"['text-white', numberSize()]\">{{ i + 1 }}</span>\n </div>\n </div>\n } @else {\n <div [ngClass]=\"[\n 'flex shrink-0 items-center justify-center rounded-full ring-2 bg-gray-100 dark:bg-gray-800 ring-gray-300 dark:ring-gray-600',\n dotSize()\n ]\">\n <span [ngClass]=\"['text-gray-500 dark:text-gray-400', numberSize()]\">{{ i + 1 }}</span>\n </div>\n }\n }\n\n <!-- ─ dotStyle: CHECK ─ -->\n @else if (dotStyle() === 'check') {\n @if (status === 'complete') {\n <div [ngClass]=\"[\n 'flex shrink-0 items-center justify-center rounded-full ring-4 bg-emerald-500 ring-emerald-100 dark:ring-emerald-900/40',\n dotSize()\n ]\">\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path d=\"M5 13l4 4L19 7\" />\n </svg>\n </div>\n } @else if (status === 'active') {\n <div class=\"relative flex shrink-0 items-center justify-center\">\n <span [ngClass]=\"['absolute inset-0 rounded-full animate-ping opacity-40 bg-indigo-400']\"></span>\n <div [ngClass]=\"[\n 'relative flex shrink-0 items-center justify-center rounded-full ring-4 bg-indigo-600 ring-indigo-200 dark:ring-indigo-900/60',\n dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['text-white', iconSize()]\" viewBox=\"0 0 24 24\" fill=\"none\"\n stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n } @else {\n <span class=\"h-2.5 w-2.5 rounded-full bg-white\"></span>\n }\n </div>\n </div>\n } @else {\n <div [ngClass]=\"[\n 'flex shrink-0 items-center justify-center rounded-full ring-2 bg-white dark:bg-gray-900 ring-gray-300 dark:ring-gray-600',\n dotSize()\n ]\">\n <span class=\"h-2 w-2 rounded-full bg-gray-300 dark:bg-gray-600\"></span>\n </div>\n }\n }\n\n <!-- ─ dotStyle: DOT (default) ─ -->\n @else {\n <div [ngClass]=\"[\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full ring-4',\n dotClass(item), dotSize()\n ]\">\n @if (item.iconPath) {\n <svg [ngClass]=\"['shrink-0 text-white', iconSize()]\"\n viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n aria-hidden=\"true\">\n <path [attr.d]=\"item.iconPath\" />\n </svg>\n }\n </div>\n }\n\n <!-- Connector line -->\n @if (showConnector() && !isLast(i)) {\n @if (dotStyle() === 'check' || dotStyle() === 'number') {\n <div [ngClass]=\"['w-0.5 flex-1 mt-1 mb-1 min-h-6', stepConnectorClass(status)]\"></div>\n } @else {\n <div class=\"w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-1 mb-1 min-h-6\"></div>\n }\n }\n\n </div>\n\n <!-- ── Content column ──────────────────────────────────────── -->\n <div [ngClass]=\"[\n 'flex-1 min-w-0 pb-6',\n layout() === 'right' ? 'text-right' : '',\n displayMode() === 'interactive'\n ? 'cursor-pointer rounded-lg -mx-2 px-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-400 ' +\n (isSelected(item)\n ? 'bg-indigo-50 dark:bg-indigo-900/25'\n : 'hover:bg-gray-50 dark:hover:bg-gray-800/50')\n : ''\n ]\"\n [attr.role]=\"displayMode() === 'interactive' ? 'button' : null\"\n [attr.tabindex]=\"displayMode() === 'interactive' ? 0 : null\"\n [attr.aria-pressed]=\"displayMode() === 'interactive' ? isSelected(item) : null\"\n (click)=\"onItemActivate(item)\"\n (keydown)=\"onItemKeydown($event, item)\">\n\n <!-- Timestamp + badge row -->\n @if (item.timestamp || item.badge) {\n <div [ngClass]=\"[\n 'flex flex-wrap items-center gap-2 mb-1',\n layout() === 'right' ? 'justify-end' : ''\n ]\">\n @if (item.timestamp) {\n <time [ngClass]=\"['text-gray-500 dark:text-gray-400 tabular-nums', timeClass()]\">\n {{ item.timestamp }}\n </time>\n }\n @if (item.badge) {\n <span class=\"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium\n bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300\">\n {{ item.badge }}\n </span>\n }\n </div>\n }\n\n <!-- Title -->\n <p [ngClass]=\"[\n titleClass(),\n (dotStyle() === 'check' || dotStyle() === 'number') && status === 'pending'\n ? 'text-gray-500 dark:text-gray-400'\n : 'text-gray-900 dark:text-white',\n 'leading-snug'\n ]\">\n {{ item.title }}\n </p>\n\n <!-- Description -->\n @if (item.description) {\n <p [ngClass]=\"[\n descClass(),\n (dotStyle() === 'check' || dotStyle() === 'number') && status === 'pending'\n ? 'text-gray-300 dark:text-gray-600'\n : 'text-gray-500 dark:text-gray-400',\n 'mt-1 leading-relaxed'\n ]\">\n {{ item.description }}\n </p>\n }\n\n </div>\n\n </li>\n }\n </ol>\n }\n\n\n</div>\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":";;;;AA0EA;AAEA,MAAM,WAAW,GAAoC;AACnD,IAAA,OAAO,EAAE,0DAA0D;AACnE,IAAA,KAAK,EAAI,kDAAkD;AAC3D,IAAA,OAAO,EAAE,oDAAoD;AAC7D,IAAA,IAAI,EAAK,kDAAkD;AAC3D,IAAA,OAAO,EAAE,gDAAgD;AACzD,IAAA,OAAO,EAAE,uDAAuD;CACjE;AAED,MAAM,aAAa,GAAoC;AACrD,IAAA,OAAO,EAAE,gBAAgB;AACzB,IAAA,KAAK,EAAI,YAAY;AACrB,IAAA,OAAO,EAAE,cAAc;AACvB,IAAA,IAAI,EAAK,YAAY;AACrB,IAAA,OAAO,EAAE,aAAa;AACtB,IAAA,OAAO,EAAE,eAAe;CACzB;AAED,MAAM,UAAU,GAAG;IACjB,wEAAwE;IACxE,0EAA0E;IAC1E,8EAA8E;IAC9E,yEAAyE;IACzE,wEAAwE;IACxE,wEAAwE;CACzE;AAED,MAAM,aAAa,GAAG;AACpB,IAAA,aAAa,EAAE,eAAe,EAAE,gBAAgB,EAAE,cAAc;AAChE,IAAA,aAAa,EAAG,aAAa,EAAG,eAAe,EAAG,aAAa;CAChE;AAED;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsCG;MAQU,mBAAmB,CAAA;;;AAK9B,IAAA,KAAK,GAAG,KAAK,CAAiB,EAAE,4EAAC;;AAGjC,IAAA,MAAM,GAAG,KAAK,CAAiB,MAAM,6EAAC;;AAGtC,IAAA,IAAI,GAAG,KAAK,CAAe,IAAI,2EAAC;;AAGhC,IAAA,aAAa,GAAG,KAAK,CAAU,IAAI,oFAAC;;AAGpC,IAAA,WAAW,GAAG,KAAK,CAAS,EAAE,kFAAC;;AAG/B,IAAA,QAAQ,GAAG,KAAK,CAAmB,KAAK,+EAAC;;AAGzC,IAAA,WAAW,GAAG,KAAK,CAAsB,SAAS,kFAAC;AAEnD;;;;AAIG;AACH,IAAA,WAAW,GAAG,KAAK,CAAS,CAAC,CAAC,kFAAC;;AAG/B,IAAA,WAAW,GAAG,KAAK,CAAU,KAAK,kFAAC;;AAGnC,IAAA,eAAe,GAAG,KAAK,CAA0B,WAAW,sFAAC;;AAI7D;;;AAGG;AACH,IAAA,UAAU,GAAG,KAAK,CAAyB,IAAI,iFAAC;AAEhD;;;AAGG;IACH,SAAS,GAAG,MAAM,EAAgB;AAElC;;;AAGG;IACH,WAAW,GAAG,MAAM,EAAgB;;AAGpC,IAAA,UAAU,CAAC,IAAkB,EAAA;AAC3B,QAAA,OAAO,IAAI,CAAC,UAAU,EAAE,KAAK,IAAI,IAAI,IAAI,CAAC,UAAU,EAAE,KAAK,IAAI,CAAC,EAAE;IACpE;;AAGA,IAAA,cAAc,CAAC,IAAkB,EAAA;AAC/B,QAAA,IAAI,IAAI,CAAC,WAAW,EAAE,KAAK,aAAa;YAAE;QAC1C,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;AAC5B,QAAA,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC;IAC3B;;IAGA,aAAa,CAAC,KAAoB,EAAE,IAAkB,EAAA;AACpD,QAAA,IAAI,IAAI,CAAC,WAAW,EAAE,KAAK,aAAa;YAAE;AAC1C,QAAA,IAAI,KAAK,CAAC,GAAG,KAAK,OAAO,IAAI,KAAK,CAAC,GAAG,KAAK,GAAG,EAAE;YAC9C,KAAK,CAAC,cAAc,EAAE;AACtB,YAAA,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC;QAC3B;IACF;;IAGA,aAAa,CAAC,IAAkB,EAAE,KAAiB,EAAA;QACjD,IAAI,CAAC,IAAI,CAAC,SAAS;YAAE,KAAK,CAAC,cAAc,EAAE;AAC3C,QAAA,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC;IAC7B;;AAIA,IAAA,OAAO,GAAG,QAAQ,CAAS,MAAM,CAAC;AAChC,QAAA,EAAE,EAAE,SAAS;AACb,QAAA,EAAE,EAAE,SAAS;AACb,QAAA,EAAE,EAAE,WAAW;AAChB,KAAA,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,8EAAC;AAEhB,IAAA,QAAQ,GAAG,QAAQ,CAAS,MAAM,CAAC;AACjC,QAAA,EAAE,EAAE,aAAa;AACjB,QAAA,EAAE,EAAE,WAAW;AACf,QAAA,EAAE,EAAE,WAAW;AAChB,KAAA,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,+EAAC;AAEhB,IAAA,UAAU,GAAG,QAAQ,CAAS,MAAM,CAAC;AACnC,QAAA,EAAE,EAAE,oBAAoB;AACxB,QAAA,EAAE,EAAE,oBAAoB;AACxB,QAAA,EAAE,EAAE,qBAAqB;AAC1B,KAAA,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,iFAAC;AAEhB,IAAA,UAAU,GAAG,QAAQ,CAAS,MAAM,CAAC;AACnC,QAAA,EAAE,EAAE,yBAAyB;AAC7B,QAAA,EAAE,EAAE,yBAAyB;AAC7B,QAAA,EAAE,EAAE,yBAAyB;AAC9B,KAAA,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,iFAAC;AAEhB,IAAA,SAAS,GAAG,QAAQ,CAAS,MAAM,CAAC;AAClC,QAAA,EAAE,EAAE,SAAS;AACb,QAAA,EAAE,EAAE,SAAS;AACb,QAAA,EAAE,EAAE,SAAS;AACd,KAAA,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,gFAAC;AAEhB,IAAA,SAAS,GAAG,QAAQ,CAAS,MAAM,CAAC;AAClC,QAAA,EAAE,EAAE,SAAS;AACb,QAAA,EAAE,EAAE,SAAS;AACb,QAAA,EAAE,EAAE,SAAS;AACd,KAAA,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,gFAAC;;AAIhB,IAAA,YAAY,GAAG,QAAQ,CAAkB,MAAK;QAC5C,MAAM,MAAM,GAAoB,EAAE;QAClC,IAAI,WAAW,GAAkB,IAAI;QACrC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,EAAE;AAC/B,YAAA,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,IAAI,EAAE;AACjC,YAAA,IAAI,IAAI,KAAK,WAAW,EAAE;gBACxB,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;gBAChC,WAAW,GAAG,IAAI;YACpB;AACA,YAAA,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC;QAC5C;AACA,QAAA,OAAO,MAAM;AACf,IAAA,CAAC,mFAAC;;AAIF,IAAA,QAAQ,CAAC,IAAkB,EAAA;QACzB,OAAO,WAAW,CAAC,IAAI,CAAC,OAAO,IAAI,SAAS,CAAC;IAC/C;AAEA,IAAA,UAAU,CAAC,IAAkB,EAAA;QAC3B,OAAO,aAAa,CAAC,IAAI,CAAC,OAAO,IAAI,SAAS,CAAC;IACjD;AAEA,IAAA,MAAM,CAAC,KAAa,EAAA;QAClB,OAAO,KAAK,KAAK,IAAI,CAAC,KAAK,EAAE,CAAC,MAAM,GAAG,CAAC;IAC1C;AAEA,IAAA,OAAO,CAAC,KAAa,EAAA;QACnB,OAAO,KAAK,KAAK,CAAC;IACpB;IAEA,aAAa,CAAC,UAAkB,EAAE,SAAiB,EAAA;AACjD,QAAA,OAAO,SAAS,KAAK,IAAI,CAAC,YAAY,EAAE,CAAC,UAAU,CAAC,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC;IACvE;AAEA,IAAA,WAAW,CAAC,UAAkB,EAAA;QAC5B,OAAO,UAAU,KAAK,IAAI,CAAC,YAAY,EAAE,CAAC,MAAM,GAAG,CAAC;IACtD;IAEA,SAAS,CAAC,CAAS,EAAE,IAAkB,EAAA;QACrC,OAAO,IAAI,CAAC,EAAE;IAChB;IAEA,WAAW,CAAC,CAAS,EAAE,KAAoB,EAAA;QACzC,OAAO,KAAK,CAAC,IAAI;IACnB;;IAIA,UAAU,CAAC,KAAa,EAAE,IAAkB,EAAA;AAC1C,QAAA,MAAM,MAAM,GAAG,IAAI,CAAC,WAAW,EAAE;AACjC,QAAA,IAAI,MAAM,IAAI,CAAC,EAAE;YACf,IAAI,KAAK,GAAG,MAAM;AAAI,gBAAA,OAAO,UAAU;YACvC,IAAI,KAAK,KAAK,MAAM;AAAE,gBAAA,OAAO,QAAQ;AACrC,YAAA,OAAO,SAAS;QAClB;AACA,QAAA,OAAO,IAAI,CAAC,UAAU,IAAI,SAAS;IACrC;AAEA,IAAA,kBAAkB,CAAC,MAA0B,EAAA;QAC3C,IAAI,MAAM,KAAK,UAAU;AAAE,YAAA,OAAO,oCAAoC;QACtE,IAAI,MAAM,KAAK,QAAQ;AAAI,YAAA,OAAO,kCAAkC;AACpE,QAAA,OAAO,yCAAyC;IAClD;;AAIA,IAAA,cAAc,CAAC,KAAa,EAAA;AAC1B,QAAA,MAAM,KAAK,GAAG,IAAI,CAAC,eAAe,EAAE;QACpC,IAAI,KAAK,KAAK,KAAK;AAAK,YAAA,OAAO,IAAI;QACnC,IAAI,KAAK,KAAK,QAAQ;AAAE,YAAA,OAAO,KAAK;AACpC,QAAA,OAAO,KAAK,GAAG,CAAC,KAAK,CAAC;IACxB;AAEA,IAAA,iBAAiB,CAAC,KAAa,EAAA;AAC7B,QAAA,OAAO,CAAC,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC;IACpC;;AAIA,IAAA,QAAQ,CAAC,GAAW,EAAA;AAClB,QAAA,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,KAAK,GAAG,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACvE,OAAO,UAAU,CAAC,IAAI,GAAG,UAAU,CAAC,MAAM,CAAC;IAC7C;AAEA,IAAA,QAAQ,CAAC,IAAa,EAAA;AACpB,QAAA,IAAI,CAAC,IAAI;AAAE,YAAA,OAAO,aAAa,CAAC,CAAC,CAAC;AAClC,QAAA,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,KAAK,GAAG,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACxE,OAAO,aAAa,CAAC,IAAI,GAAG,aAAa,CAAC,MAAM,CAAC;IACnD;AAEA,IAAA,cAAc,CAAC,IAAa,EAAA;AAC1B,QAAA,IAAI,CAAC,IAAI;AAAE,YAAA,OAAO,GAAG;AACrB,QAAA,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC;AACpD,QAAA,IAAI,KAAK,CAAC,MAAM,IAAI,CAAC,EAAE;YACrB,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE;QACjE;AACA,QAAA,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE;IAC3C;wGAhOW,mBAAmB,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,SAAA,EAAA,CAAA;4FAAnB,mBAAmB,EAAA,YAAA,EAAA,IAAA,EAAA,QAAA,EAAA,aAAA,EAAA,MAAA,EAAA,EAAA,KAAA,EAAA,EAAA,iBAAA,EAAA,OAAA,EAAA,UAAA,EAAA,OAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,MAAA,EAAA,EAAA,iBAAA,EAAA,QAAA,EAAA,UAAA,EAAA,QAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,IAAA,EAAA,EAAA,iBAAA,EAAA,MAAA,EAAA,UAAA,EAAA,MAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,aAAA,EAAA,EAAA,iBAAA,EAAA,eAAA,EAAA,UAAA,EAAA,eAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,WAAA,EAAA,EAAA,iBAAA,EAAA,aAAA,EAAA,UAAA,EAAA,aAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,QAAA,EAAA,EAAA,iBAAA,EAAA,UAAA,EAAA,UAAA,EAAA,UAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,WAAA,EAAA,EAAA,iBAAA,EAAA,aAAA,EAAA,UAAA,EAAA,aAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,WAAA,EAAA,EAAA,iBAAA,EAAA,aAAA,EAAA,UAAA,EAAA,aAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,WAAA,EAAA,EAAA,iBAAA,EAAA,aAAA,EAAA,UAAA,EAAA,aAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,eAAA,EAAA,EAAA,iBAAA,EAAA,iBAAA,EAAA,UAAA,EAAA,iBAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,UAAA,EAAA,EAAA,iBAAA,EAAA,YAAA,EAAA,UAAA,EAAA,YAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,EAAA,OAAA,EAAA,EAAA,UAAA,EAAA,kBAAA,EAAA,SAAA,EAAA,WAAA,EAAA,WAAA,EAAA,aAAA,EAAA,EAAA,QAAA,EAAA,EAAA,EAAA,QAAA,EC5JhC,23yCAwxBA,EAAA,YAAA,EAAA,CAAA,EAAA,IAAA,EAAA,WAAA,EAAA,IAAA,EDhoBY,OAAO,EAAA,QAAA,EAAA,WAAA,EAAA,MAAA,EAAA,CAAA,OAAA,EAAA,SAAA,CAAA,EAAA,CAAA,EAAA,eAAA,EAAA,EAAA,CAAA,uBAAA,CAAA,MAAA,EAAA,CAAA;;4FAIN,mBAAmB,EAAA,UAAA,EAAA,CAAA;kBAP/B,SAAS;+BACE,aAAa,EAAA,UAAA,EACX,IAAI,EAAA,OAAA,EACP,CAAC,OAAO,CAAC,EAAA,eAAA,EAED,uBAAuB,CAAC,MAAM,EAAA,QAAA,EAAA,23yCAAA,EAAA;;;AE1JjD;;AAEG;;;;"}
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@styloviz/timeline",
3
+ "version": "0.1.1",
4
+ "description": "9 premium variations: horizontal, alternate zigzag, numbered stepper, check stepper, activity feed, status tracker, rich card, date-grouped and interactive (clickable rows).",
5
+ "license": "MIT",
6
+ "author": "sazzad-bs23",
7
+ "homepage": "https://styloviz.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/rhossain/angular-tailwind-dashboard-components.git",
11
+ "directory": "projects/timeline-free"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/rhossain/angular-tailwind-dashboard-components/issues"
15
+ },
16
+ "peerDependencies": {
17
+ "@angular/common": ">=21.0.0",
18
+ "@angular/core": ">=21.0.0"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "engines": {
24
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
25
+ },
26
+ "sideEffects": false,
27
+ "module": "fesm2022/styloviz-timeline.mjs",
28
+ "typings": "types/styloviz-timeline.d.ts",
29
+ "exports": {
30
+ "./package.json": {
31
+ "default": "./package.json"
32
+ },
33
+ ".": {
34
+ "types": "./types/styloviz-timeline.d.ts",
35
+ "default": "./fesm2022/styloviz-timeline.mjs"
36
+ }
37
+ },
38
+ "type": "module",
39
+ "dependencies": {
40
+ "tslib": "^2.3.0"
41
+ }
42
+ }
@@ -0,0 +1,169 @@
1
+ import * as _angular_core from '@angular/core';
2
+
3
+ type TimelineVariant = 'success' | 'error' | 'warning' | 'info' | 'neutral' | 'primary';
4
+ type TimelineLayout = 'left' | 'right' | 'alternate' | 'horizontal';
5
+ type TimelineSize = 'sm' | 'md' | 'lg';
6
+ type TimelineDotStyle = 'dot' | 'number' | 'check';
7
+ type TimelineDisplayMode = 'default' | 'feed' | 'status' | 'rich' | 'interactive';
8
+ type TimelineItemStatus = 'complete' | 'active' | 'pending';
9
+ type TimelineHorizontalAlign = 'top' | 'bottom' | 'alternate';
10
+ interface TimelineItem {
11
+ /** Unique identifier. */
12
+ id: string | number;
13
+ /** Main heading for this event. */
14
+ title: string;
15
+ /** Optional supporting text or description. */
16
+ description?: string;
17
+ /** ISO date string or human-readable timestamp label. */
18
+ timestamp?: string;
19
+ /** Color/status variant for the connector dot. @default 'neutral' */
20
+ variant?: TimelineVariant;
21
+ /**
22
+ * SVG path `d` attribute for an icon inside the dot.
23
+ * Omit for a solid circle.
24
+ */
25
+ iconPath?: string;
26
+ /** Badge chip label rendered next to the timestamp. */
27
+ badge?: string;
28
+ /**
29
+ * Step status for `displayMode='status'` or `dotStyle='check'`.
30
+ * Also driven automatically when `activeIndex` is set on the component.
31
+ */
32
+ itemStatus?: TimelineItemStatus;
33
+ /** Avatar image URL — used in `displayMode='feed'`. */
34
+ avatarUrl?: string;
35
+ /** Actor/author name — used in `displayMode='feed'`. */
36
+ actorName?: string;
37
+ /** Optional card image URL — used in `displayMode='rich'`. */
38
+ imageUrl?: string;
39
+ /** Tag chips — used in `displayMode='rich'`. */
40
+ tags?: string[];
41
+ /** CTA button label — used in `displayMode='rich'`. */
42
+ actionLabel?: string;
43
+ /** CTA href — used in `displayMode='rich'`. */
44
+ actionUrl?: string;
45
+ /**
46
+ * Date group header for `groupByDate=true`.
47
+ * Consecutive items sharing the same value are grouped under one header.
48
+ */
49
+ groupDate?: string;
50
+ }
51
+ interface TimelineGroup {
52
+ date: string;
53
+ items: TimelineItem[];
54
+ }
55
+ /**
56
+ * TimelineComponent — A versatile, multi-mode event sequence visualizer.
57
+ *
58
+ * ## Layouts
59
+ * - `left` (default): vertical, connector column on the left
60
+ * - `right`: vertical, connector column on the right, content on left
61
+ * - `alternate`: true zigzag — items alternate sides of a center line
62
+ * - `horizontal`: items along a horizontal connector; content alternates top/bottom
63
+ *
64
+ * ## Display Modes
65
+ * - `default`: standard dot / icon content card
66
+ * - `feed`: compact activity feed with actor avatars
67
+ * - `status`: order/progress tracker with complete / active / pending states
68
+ * - `rich`: card-based content with optional image, tags, and CTA button
69
+ *
70
+ * ## Dot Styles
71
+ * - `dot` (default): filled circle with optional icon
72
+ * - `number`: numbered step badges (1, 2, 3…)
73
+ * - `check`: checkmark for complete, pulsing ring for active, hollow for pending
74
+ *
75
+ * ## Other Features
76
+ * - `groupByDate=true`: groups items under date header separators
77
+ * - `activeIndex`: drives step state for `check` / `number` modes
78
+ * - Full dark-mode, 3 sizes (sm / md / lg), accessible ordered-list markup
79
+ *
80
+ * @example
81
+ * ```html
82
+ * <sv-timeline [items]="events" />
83
+ * <sv-timeline [items]="steps" dotStyle="number" [activeIndex]="2" />
84
+ * <sv-timeline [items]="steps" dotStyle="check" [activeIndex]="2" />
85
+ * <sv-timeline [items]="feedItems" displayMode="feed" />
86
+ * <sv-timeline [items]="statusItems" displayMode="status" />
87
+ * <sv-timeline [items]="richItems" displayMode="rich" />
88
+ * <sv-timeline [items]="events" layout="alternate" />
89
+ * <sv-timeline [items]="events" layout="right" />
90
+ * <sv-timeline [items]="events" layout="horizontal" horizontalAlign="alternate" />
91
+ * <sv-timeline [items]="groupedItems" [groupByDate]="true" />
92
+ * ```
93
+ */
94
+ declare class SvTimelineComponent {
95
+ /** Array of timeline events to render. */
96
+ items: _angular_core.InputSignal<TimelineItem[]>;
97
+ /** Structural layout direction. @default 'left' */
98
+ layout: _angular_core.InputSignal<TimelineLayout>;
99
+ /** Component size preset. @default 'md' */
100
+ size: _angular_core.InputSignal<TimelineSize>;
101
+ /** Show connector lines between dots. @default true */
102
+ showConnector: _angular_core.InputSignal<boolean>;
103
+ /** Additional CSS classes on the root element. */
104
+ customClass: _angular_core.InputSignal<string>;
105
+ /** Dot rendering style. @default 'dot' */
106
+ dotStyle: _angular_core.InputSignal<TimelineDotStyle>;
107
+ /** Content display mode. @default 'default' */
108
+ displayMode: _angular_core.InputSignal<TimelineDisplayMode>;
109
+ /**
110
+ * Zero-based index of the currently active step.
111
+ * Items before → `complete`; this item → `active`; after → `pending`.
112
+ * Defaults to `-1` (uses each item's own `itemStatus`).
113
+ */
114
+ activeIndex: _angular_core.InputSignal<number>;
115
+ /** Auto-group items under date header separators using their `groupDate` field. */
116
+ groupByDate: _angular_core.InputSignal<boolean>;
117
+ /** Content placement for `layout='horizontal'`. @default 'alternate' */
118
+ horizontalAlign: _angular_core.InputSignal<TimelineHorizontalAlign>;
119
+ /**
120
+ * Currently selected item id (two-way). Use `[(selectedId)]` with
121
+ * `displayMode="interactive"` for a selectable timeline. @default null
122
+ */
123
+ selectedId: _angular_core.ModelSignal<string | number | null>;
124
+ /**
125
+ * Emitted when an interactive row is activated (click / Enter / Space).
126
+ * Only fires when `displayMode='interactive'`.
127
+ */
128
+ itemClick: _angular_core.OutputEmitterRef<TimelineItem>;
129
+ /**
130
+ * Emitted when a `rich`-card CTA button is activated. Fires alongside any
131
+ * `actionUrl` navigation so you can handle the action in code.
132
+ */
133
+ actionClick: _angular_core.OutputEmitterRef<TimelineItem>;
134
+ /** True when the given item is the selected one (interactive mode). */
135
+ isSelected(item: TimelineItem): boolean;
136
+ /** Activate an interactive row: toggle selection + emit `itemClick`. */
137
+ onItemActivate(item: TimelineItem): void;
138
+ /** Enter/Space activates an interactive row (keyboard parity with click). */
139
+ onItemKeydown(event: KeyboardEvent, item: TimelineItem): void;
140
+ /** Rich-card CTA handler: emit `actionClick`; suppress the '#' jump when no `actionUrl`. */
141
+ onActionClick(item: TimelineItem, event: MouseEvent): void;
142
+ dotSize: _angular_core.Signal<string>;
143
+ iconSize: _angular_core.Signal<string>;
144
+ numberSize: _angular_core.Signal<string>;
145
+ titleClass: _angular_core.Signal<string>;
146
+ descClass: _angular_core.Signal<string>;
147
+ timeClass: _angular_core.Signal<string>;
148
+ groupedItems: _angular_core.Signal<TimelineGroup[]>;
149
+ dotClass(item: TimelineItem): string;
150
+ pulseClass(item: TimelineItem): string;
151
+ isLast(index: number): boolean;
152
+ isFirst(index: number): boolean;
153
+ isLastInGroup(groupIndex: number, itemIndex: number): boolean;
154
+ isLastGroup(groupIndex: number): boolean;
155
+ trackById(_: number, item: TimelineItem): string | number;
156
+ trackByDate(_: number, group: TimelineGroup): string;
157
+ stepStatus(index: number, item: TimelineItem): TimelineItemStatus;
158
+ stepConnectorClass(status: TimelineItemStatus): string;
159
+ showTopContent(index: number): boolean;
160
+ showBottomContent(index: number): boolean;
161
+ tagColor(tag: string): string;
162
+ avatarBg(name?: string): string;
163
+ avatarInitials(name?: string): string;
164
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<SvTimelineComponent, never>;
165
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<SvTimelineComponent, "sv-timeline", never, { "items": { "alias": "items"; "required": false; "isSignal": true; }; "layout": { "alias": "layout"; "required": false; "isSignal": true; }; "size": { "alias": "size"; "required": false; "isSignal": true; }; "showConnector": { "alias": "showConnector"; "required": false; "isSignal": true; }; "customClass": { "alias": "customClass"; "required": false; "isSignal": true; }; "dotStyle": { "alias": "dotStyle"; "required": false; "isSignal": true; }; "displayMode": { "alias": "displayMode"; "required": false; "isSignal": true; }; "activeIndex": { "alias": "activeIndex"; "required": false; "isSignal": true; }; "groupByDate": { "alias": "groupByDate"; "required": false; "isSignal": true; }; "horizontalAlign": { "alias": "horizontalAlign"; "required": false; "isSignal": true; }; "selectedId": { "alias": "selectedId"; "required": false; "isSignal": true; }; }, { "selectedId": "selectedIdChange"; "itemClick": "itemClick"; "actionClick": "actionClick"; }, never, never, true, never>;
166
+ }
167
+
168
+ export { SvTimelineComponent };
169
+ export type { TimelineDisplayMode, TimelineDotStyle, TimelineItem, TimelineItemStatus, TimelineLayout, TimelineSize, TimelineVariant };