@delightstack/components 0.1.0

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.
Files changed (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/SKILL.md +149 -0
  4. package/bin/agents.js +63 -0
  5. package/dist/actions/Alert.svelte +202 -0
  6. package/dist/actions/Alert.svelte.d.ts +36 -0
  7. package/dist/actions/Alert.svelte.d.ts.map +1 -0
  8. package/dist/actions/Button.svelte +1450 -0
  9. package/dist/actions/Button.svelte.d.ts +56 -0
  10. package/dist/actions/Button.svelte.d.ts.map +1 -0
  11. package/dist/actions/ButtonGroup.svelte +111 -0
  12. package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
  13. package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
  14. package/dist/actions/CommandPalette.svelte +939 -0
  15. package/dist/actions/CommandPalette.svelte.d.ts +37 -0
  16. package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
  17. package/dist/actions/ContextMenu.svelte +138 -0
  18. package/dist/actions/ContextMenu.svelte.d.ts +54 -0
  19. package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
  20. package/dist/actions/Modal.svelte +474 -0
  21. package/dist/actions/Modal.svelte.d.ts +28 -0
  22. package/dist/actions/Modal.svelte.d.ts.map +1 -0
  23. package/dist/actions/Popover.svelte +1214 -0
  24. package/dist/actions/Popover.svelte.d.ts +31 -0
  25. package/dist/actions/Popover.svelte.d.ts.map +1 -0
  26. package/dist/actions/Portal.svelte +80 -0
  27. package/dist/actions/Portal.svelte.d.ts +17 -0
  28. package/dist/actions/Portal.svelte.d.ts.map +1 -0
  29. package/dist/actions/ThemeToggle.svelte +345 -0
  30. package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
  31. package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
  32. package/dist/actions/index.d.ts +13 -0
  33. package/dist/actions/index.d.ts.map +1 -0
  34. package/dist/actions/index.js +10 -0
  35. package/dist/actions/scrollbar.d.ts +48 -0
  36. package/dist/actions/scrollbar.d.ts.map +1 -0
  37. package/dist/actions/scrollbar.js +404 -0
  38. package/dist/display/Accordion.svelte +586 -0
  39. package/dist/display/Accordion.svelte.d.ts +41 -0
  40. package/dist/display/Accordion.svelte.d.ts.map +1 -0
  41. package/dist/display/Avatar.svelte +527 -0
  42. package/dist/display/Avatar.svelte.d.ts +22 -0
  43. package/dist/display/Avatar.svelte.d.ts.map +1 -0
  44. package/dist/display/AvatarGroup.svelte +298 -0
  45. package/dist/display/AvatarGroup.svelte.d.ts +31 -0
  46. package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
  47. package/dist/display/Calendar.svelte +1366 -0
  48. package/dist/display/Calendar.svelte.d.ts +58 -0
  49. package/dist/display/Calendar.svelte.d.ts.map +1 -0
  50. package/dist/display/Chart.svelte +1426 -0
  51. package/dist/display/Chart.svelte.d.ts +35 -0
  52. package/dist/display/Chart.svelte.d.ts.map +1 -0
  53. package/dist/display/Code.svelte +780 -0
  54. package/dist/display/Code.svelte.d.ts +19 -0
  55. package/dist/display/Code.svelte.d.ts.map +1 -0
  56. package/dist/display/Comparison.svelte +686 -0
  57. package/dist/display/Comparison.svelte.d.ts +22 -0
  58. package/dist/display/Comparison.svelte.d.ts.map +1 -0
  59. package/dist/display/Counter.svelte +285 -0
  60. package/dist/display/Counter.svelte.d.ts +21 -0
  61. package/dist/display/Counter.svelte.d.ts.map +1 -0
  62. package/dist/display/Expand.svelte +48 -0
  63. package/dist/display/Expand.svelte.d.ts +9 -0
  64. package/dist/display/Expand.svelte.d.ts.map +1 -0
  65. package/dist/display/List.svelte +294 -0
  66. package/dist/display/List.svelte.d.ts +40 -0
  67. package/dist/display/List.svelte.d.ts.map +1 -0
  68. package/dist/display/ListContextReset.svelte +19 -0
  69. package/dist/display/ListContextReset.svelte.d.ts +7 -0
  70. package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
  71. package/dist/display/ListItem.svelte +834 -0
  72. package/dist/display/ListItem.svelte.d.ts +22 -0
  73. package/dist/display/ListItem.svelte.d.ts.map +1 -0
  74. package/dist/display/QR.svelte +1193 -0
  75. package/dist/display/QR.svelte.d.ts +23 -0
  76. package/dist/display/QR.svelte.d.ts.map +1 -0
  77. package/dist/display/SplitPane.svelte +744 -0
  78. package/dist/display/SplitPane.svelte.d.ts +25 -0
  79. package/dist/display/SplitPane.svelte.d.ts.map +1 -0
  80. package/dist/display/Stat.svelte +439 -0
  81. package/dist/display/Stat.svelte.d.ts +24 -0
  82. package/dist/display/Stat.svelte.d.ts.map +1 -0
  83. package/dist/display/Table.svelte +4654 -0
  84. package/dist/display/Table.svelte.d.ts +249 -0
  85. package/dist/display/Table.svelte.d.ts.map +1 -0
  86. package/dist/display/TableCellEditor.svelte +935 -0
  87. package/dist/display/TableCellEditor.svelte.d.ts +58 -0
  88. package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
  89. package/dist/display/Timeline.svelte +1258 -0
  90. package/dist/display/Timeline.svelte.d.ts +43 -0
  91. package/dist/display/Timeline.svelte.d.ts.map +1 -0
  92. package/dist/display/Tree.svelte +1740 -0
  93. package/dist/display/Tree.svelte.d.ts +74 -0
  94. package/dist/display/Tree.svelte.d.ts.map +1 -0
  95. package/dist/display/Typewriter.svelte +338 -0
  96. package/dist/display/Typewriter.svelte.d.ts +22 -0
  97. package/dist/display/Typewriter.svelte.d.ts.map +1 -0
  98. package/dist/display/index.d.ts +24 -0
  99. package/dist/display/index.d.ts.map +1 -0
  100. package/dist/display/index.js +18 -0
  101. package/dist/feedback/Callout.svelte +529 -0
  102. package/dist/feedback/Callout.svelte.d.ts +24 -0
  103. package/dist/feedback/Callout.svelte.d.ts.map +1 -0
  104. package/dist/feedback/Confetti.svelte +631 -0
  105. package/dist/feedback/Confetti.svelte.d.ts +90 -0
  106. package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
  107. package/dist/feedback/Progress.svelte +382 -0
  108. package/dist/feedback/Progress.svelte.d.ts +25 -0
  109. package/dist/feedback/Progress.svelte.d.ts.map +1 -0
  110. package/dist/feedback/Toast.svelte +967 -0
  111. package/dist/feedback/Toast.svelte.d.ts +54 -0
  112. package/dist/feedback/Toast.svelte.d.ts.map +1 -0
  113. package/dist/feedback/index.d.ts +7 -0
  114. package/dist/feedback/index.d.ts.map +1 -0
  115. package/dist/feedback/index.js +4 -0
  116. package/dist/form/Checkbox.svelte +449 -0
  117. package/dist/form/Checkbox.svelte.d.ts +27 -0
  118. package/dist/form/Checkbox.svelte.d.ts.map +1 -0
  119. package/dist/form/Fieldset.svelte +410 -0
  120. package/dist/form/Fieldset.svelte.d.ts +22 -0
  121. package/dist/form/Fieldset.svelte.d.ts.map +1 -0
  122. package/dist/form/FileUpload.svelte +934 -0
  123. package/dist/form/FileUpload.svelte.d.ts +41 -0
  124. package/dist/form/FileUpload.svelte.d.ts.map +1 -0
  125. package/dist/form/Form.svelte +530 -0
  126. package/dist/form/Form.svelte.d.ts +120 -0
  127. package/dist/form/Form.svelte.d.ts.map +1 -0
  128. package/dist/form/Input.svelte +2858 -0
  129. package/dist/form/Input.svelte.d.ts +66 -0
  130. package/dist/form/Input.svelte.d.ts.map +1 -0
  131. package/dist/form/Radio.svelte +507 -0
  132. package/dist/form/Radio.svelte.d.ts +39 -0
  133. package/dist/form/Radio.svelte.d.ts.map +1 -0
  134. package/dist/form/Range.svelte +912 -0
  135. package/dist/form/Range.svelte.d.ts +33 -0
  136. package/dist/form/Range.svelte.d.ts.map +1 -0
  137. package/dist/form/Rating.svelte +429 -0
  138. package/dist/form/Rating.svelte.d.ts +28 -0
  139. package/dist/form/Rating.svelte.d.ts.map +1 -0
  140. package/dist/form/Select.svelte +1933 -0
  141. package/dist/form/Select.svelte.d.ts +54 -0
  142. package/dist/form/Select.svelte.d.ts.map +1 -0
  143. package/dist/form/Toggle.svelte +645 -0
  144. package/dist/form/Toggle.svelte.d.ts +50 -0
  145. package/dist/form/Toggle.svelte.d.ts.map +1 -0
  146. package/dist/form/index.d.ts +15 -0
  147. package/dist/form/index.d.ts.map +1 -0
  148. package/dist/form/index.js +10 -0
  149. package/dist/index.d.ts +7 -0
  150. package/dist/index.d.ts.map +1 -0
  151. package/dist/index.js +6 -0
  152. package/dist/layout/README.md +172 -0
  153. package/dist/media/Carousel.svelte +2424 -0
  154. package/dist/media/Carousel.svelte.d.ts +47 -0
  155. package/dist/media/Carousel.svelte.d.ts.map +1 -0
  156. package/dist/media/Gallery.svelte +2881 -0
  157. package/dist/media/Gallery.svelte.d.ts +82 -0
  158. package/dist/media/Gallery.svelte.d.ts.map +1 -0
  159. package/dist/media/Image.svelte +389 -0
  160. package/dist/media/Image.svelte.d.ts +33 -0
  161. package/dist/media/Image.svelte.d.ts.map +1 -0
  162. package/dist/media/PDF.svelte +1793 -0
  163. package/dist/media/PDF.svelte.d.ts +44 -0
  164. package/dist/media/PDF.svelte.d.ts.map +1 -0
  165. package/dist/media/Panorama.svelte +1391 -0
  166. package/dist/media/Panorama.svelte.d.ts +47 -0
  167. package/dist/media/Panorama.svelte.d.ts.map +1 -0
  168. package/dist/media/Video.svelte +2501 -0
  169. package/dist/media/Video.svelte.d.ts +58 -0
  170. package/dist/media/Video.svelte.d.ts.map +1 -0
  171. package/dist/media/carousel.d.ts +211 -0
  172. package/dist/media/carousel.d.ts.map +1 -0
  173. package/dist/media/carousel.js +408 -0
  174. package/dist/media/index.d.ts +11 -0
  175. package/dist/media/index.d.ts.map +1 -0
  176. package/dist/media/index.js +5 -0
  177. package/dist/navigation/BottomSheet.svelte +636 -0
  178. package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
  179. package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
  180. package/dist/navigation/Breadcrumbs.svelte +611 -0
  181. package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
  182. package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
  183. package/dist/navigation/Pagination.svelte +641 -0
  184. package/dist/navigation/Pagination.svelte.d.ts +27 -0
  185. package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
  186. package/dist/navigation/Steps.svelte +965 -0
  187. package/dist/navigation/Steps.svelte.d.ts +43 -0
  188. package/dist/navigation/Steps.svelte.d.ts.map +1 -0
  189. package/dist/navigation/Tabs.svelte +698 -0
  190. package/dist/navigation/Tabs.svelte.d.ts +41 -0
  191. package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
  192. package/dist/navigation/index.d.ts +8 -0
  193. package/dist/navigation/index.d.ts.map +1 -0
  194. package/dist/navigation/index.js +5 -0
  195. package/package.json +139 -0
@@ -0,0 +1,1258 @@
1
+ <script lang="ts" module>
2
+ export { default as TimelineItem } from './Timeline.svelte';
3
+
4
+ export interface TimelineContext {
5
+ /** Whether the timeline flows horizontally instead of vertically */
6
+ horizontal: boolean;
7
+ /** Whether items alternate sides of the timeline axis */
8
+ alternate: boolean;
9
+ /** Whether the timeline uses dense (compact) spacing */
10
+ dense: boolean;
11
+ /** Whether the timeline uses comfortable (roomy) spacing */
12
+ comfortable: boolean;
13
+ /** Whether continuous motion (the active pulse) is allowed */
14
+ animate: boolean;
15
+ /** Whether items should play their entrance reveal as they scroll in */
16
+ reveal: boolean;
17
+ /** Registers a new item with the timeline and returns its index */
18
+ register: () => number;
19
+ }
20
+ </script>
21
+
22
+ <script lang="ts">
23
+ import { intersectionObserver, ripple } from '@delightstack/utilities';
24
+ import { scrollbar } from '../actions/scrollbar';
25
+ import { getContext, setContext, type Component, type Snippet } from 'svelte';
26
+ import { fade, type TransitionConfig } from 'svelte/transition';
27
+ import { backOut } from 'svelte/easing';
28
+ import Button from './../actions/Button.svelte';
29
+ import Progress from '../feedback/Progress.svelte';
30
+
31
+ const propId = $props.id();
32
+
33
+ let {
34
+ /* --- TimelineItem props --- */
35
+ /** Timestamp for this event */
36
+ date = undefined as Date | string | undefined,
37
+
38
+ /** Event title */
39
+ title = '',
40
+
41
+ /** Marker icon component */
42
+ icon = undefined as Component | undefined,
43
+
44
+ /** Marker color override */
45
+ color = '' as string,
46
+
47
+ /** Event status */
48
+ status = undefined as 'complete' | 'active' | 'pending' | undefined,
49
+
50
+ /** Makes the item clickable — turns it into a link. Mirrors `<Button>`. */
51
+ href = undefined as string | undefined,
52
+
53
+ /** Link target (only used with `href`). */
54
+ target = undefined as '_self' | '_blank' | '_parent' | '_top' | undefined,
55
+
56
+ /** Called when the item is clicked. Makes the item interactive (like
57
+ `href`). Return a promise to drive a loading spinner in the marker:
58
+ a spinner appears if the work outlasts ~100ms, then a brief success
59
+ check confirms a resolve (mirrors `<Button>`). */
60
+ onclick = undefined as
61
+ | undefined
62
+ | ((event: MouseEvent | KeyboardEvent) => void)
63
+ | ((event: MouseEvent | KeyboardEvent) => Promise<void>),
64
+
65
+ /* --- Timeline container props --- */
66
+ /** Horizontal layout */
67
+ horizontal = false,
68
+
69
+ /** Alternate sides */
70
+ alternate = false,
71
+
72
+ /** Show pending indicator at end */
73
+ pending = false,
74
+
75
+ /** Compact spacing */
76
+ dense = false,
77
+
78
+ /** Relaxed spacing */
79
+ comfortable = false,
80
+
81
+ /** Play the entrance + pulse animations. On by default. */
82
+ animate = true,
83
+
84
+ /** Loading skeleton */
85
+ skeleton = false,
86
+
87
+ /** Skeleton items count */
88
+ skeleton_count = 3,
89
+
90
+ /** Element ID */
91
+ id = propId,
92
+
93
+ /** Additional CSS classes */
94
+ class: class_name = '',
95
+
96
+ /** Child content snippet */
97
+ children = undefined as undefined | Snippet,
98
+
99
+ /** On-demand loading */
100
+ onloadmore = undefined as (() => void | Promise<void>) | undefined,
101
+ } = $props();
102
+
103
+ /* ------------------------------------------------------------------ */
104
+ /* Determine whether this instance is a container or an item */
105
+ /* ------------------------------------------------------------------ */
106
+ const parentContext = getContext<TimelineContext | undefined>('timeline');
107
+ const isItem = !!parentContext;
108
+
109
+ /* ------------------------------------------------------------------ */
110
+ /* Timeline container behaviour */
111
+ /* ------------------------------------------------------------------ */
112
+ let item_counter = 0;
113
+
114
+ if (!isItem) {
115
+ // Once a skeleton has been shown, the real content takes its place — the
116
+ // space was already occupied, so re-animating it in reads as a jump. Latch
117
+ // this and suppress the entrance reveal for everything that loads after.
118
+ let had_skeleton = $state(skeleton);
119
+
120
+ const ctx = $state<TimelineContext>({
121
+ horizontal,
122
+ alternate,
123
+ dense,
124
+ comfortable,
125
+ animate,
126
+ reveal: animate && !skeleton,
127
+ register() {
128
+ return item_counter++;
129
+ },
130
+ });
131
+ setContext<TimelineContext>('timeline', ctx);
132
+
133
+ $effect(() => {
134
+ if (skeleton) had_skeleton = true;
135
+ ctx.horizontal = horizontal;
136
+ ctx.alternate = alternate;
137
+ ctx.dense = dense;
138
+ ctx.comfortable = comfortable;
139
+ ctx.animate = animate;
140
+ ctx.reveal = animate && !had_skeleton;
141
+ });
142
+ }
143
+
144
+ /* ------------------------------------------------------------------ */
145
+ /* TimelineItem behaviour */
146
+ /* ------------------------------------------------------------------ */
147
+ const item_index = isItem ? parentContext.register() : -1;
148
+
149
+ const is_horizontal = $derived(isItem ? parentContext.horizontal : horizontal);
150
+ const is_alternate = $derived(isItem ? parentContext.alternate : alternate);
151
+ const is_dense = $derived(isItem ? parentContext.dense : dense);
152
+ const is_comfortable = $derived(isItem ? parentContext.comfortable : comfortable);
153
+ const is_even = $derived(item_index % 2 === 0);
154
+ const do_reveal = $derived(isItem ? parentContext.reveal : false);
155
+ const do_motion = $derived(isItem ? parentContext.animate : false);
156
+
157
+ /** An item is interactive when it has somewhere to go or something to do. */
158
+ const interactive = $derived(isItem && (!!onclick || !!href));
159
+
160
+ /* ------------------------------------------------------------------ */
161
+ /* Promise-aware onclick (mirrors <Button>) */
162
+ /* A returned promise drives a spinner in the marker: it appears only */
163
+ /* if the work outlasts SHOW_DELAY (sub-100ms work reads as instant), */
164
+ /* stays at least MIN_VISIBLE so it can't blink, then a success check */
165
+ /* confirms a resolve. */
166
+ /* ------------------------------------------------------------------ */
167
+ const SHOW_DELAY = 100;
168
+ const MIN_VISIBLE = 1000;
169
+ const CHECK_HOLD = 1000;
170
+
171
+ let in_flight = $state(false); // a returned promise is running
172
+ let spinner_visible = $state(false); // the spinner is actually rendered
173
+ let check_visible = $state(false); // the success checkmark is rendered
174
+ let show_timer: ReturnType<typeof setTimeout> | undefined;
175
+ let hide_timer: ReturnType<typeof setTimeout> | undefined;
176
+ let check_timer: ReturnType<typeof setTimeout> | undefined;
177
+ let spinner_shown_at = 0;
178
+
179
+ function clearTimers() {
180
+ clearTimeout(show_timer);
181
+ clearTimeout(hide_timer);
182
+ clearTimeout(check_timer);
183
+ show_timer = hide_timer = check_timer = undefined;
184
+ }
185
+ $effect(() => clearTimers); // tear down pending timers on destroy
186
+
187
+ function flashCheck() {
188
+ clearTimeout(check_timer);
189
+ check_visible = true;
190
+ check_timer = setTimeout(() => (check_visible = false), CHECK_HOLD);
191
+ }
192
+
193
+ function settle(success: boolean) {
194
+ // Settled before the spinner appeared → treat as instant, no spinner.
195
+ if (show_timer) {
196
+ clearTimeout(show_timer);
197
+ show_timer = undefined;
198
+ in_flight = false;
199
+ return;
200
+ }
201
+ // Keep the spinner for the rest of its minimum-visible window so it
202
+ // doesn't blink away the instant the promise resolves.
203
+ const remaining = Math.max(0, MIN_VISIBLE - (performance.now() - spinner_shown_at));
204
+ clearTimeout(hide_timer);
205
+ hide_timer = setTimeout(() => {
206
+ spinner_visible = false;
207
+ in_flight = false;
208
+ if (success) flashCheck();
209
+ }, remaining);
210
+ }
211
+
212
+ function handleActivate(event: MouseEvent | KeyboardEvent) {
213
+ if (in_flight) return;
214
+ const result = onclick?.(event);
215
+ if (!(result instanceof Promise)) return;
216
+
217
+ clearTimers();
218
+ check_visible = false;
219
+ in_flight = true;
220
+ // Hold off on the spinner — work that settles within SHOW_DELAY was
221
+ // effectively instant and never needs one.
222
+ show_timer = setTimeout(() => {
223
+ show_timer = undefined;
224
+ spinner_visible = true;
225
+ spinner_shown_at = performance.now();
226
+ }, SHOW_DELAY);
227
+
228
+ result.then(
229
+ () => settle(true),
230
+ () => settle(false),
231
+ );
232
+ }
233
+
234
+ function handleKey(event: KeyboardEvent) {
235
+ if (event.key === 'Enter' || event.key === ' ') {
236
+ event.preventDefault();
237
+ handleActivate(event);
238
+ }
239
+ }
240
+
241
+ // The success check draws its stroke on as it spring-pops in.
242
+ function checkIn(_node: Element): TransitionConfig {
243
+ const reduce =
244
+ typeof matchMedia !== 'undefined' &&
245
+ matchMedia('(prefers-reduced-motion: reduce)').matches;
246
+ return {
247
+ duration: reduce ? 0 : 440,
248
+ easing: backOut,
249
+ css: (t: number) =>
250
+ `transform: scale(${0.3 + 0.7 * t}); opacity: ${Math.min(1, t * 2)}; --check-draw: ${24 * (1 - t)};`,
251
+ };
252
+ }
253
+
254
+ /* ------------------------------------------------------------------ */
255
+ /* Scroll-reveal for items */
256
+ /* ------------------------------------------------------------------ */
257
+ let visible = $state(false);
258
+
259
+ /* ------------------------------------------------------------------ */
260
+ /* Date formatting */
261
+ /* ------------------------------------------------------------------ */
262
+ const formatted_date = $derived.by(() => {
263
+ if (!date) return '';
264
+ const d = typeof date === 'string' ? new Date(date) : date;
265
+ if (isNaN(d.getTime())) return typeof date === 'string' ? date : '';
266
+ return d.toLocaleDateString(undefined, {
267
+ year: 'numeric',
268
+ month: 'short',
269
+ day: 'numeric',
270
+ });
271
+ });
272
+
273
+ const iso_date = $derived.by(() => {
274
+ if (!date) return '';
275
+ const d = typeof date === 'string' ? new Date(date) : date;
276
+ if (isNaN(d.getTime())) return '';
277
+ return d.toISOString();
278
+ });
279
+
280
+ /* ------------------------------------------------------------------ */
281
+ /* Load-more sentinel */
282
+ /* ------------------------------------------------------------------ */
283
+ function handleLoadMore() {
284
+ onloadmore?.();
285
+ }
286
+
287
+ /* ------------------------------------------------------------------ */
288
+ /* Horizontal scroll: chevron next/prev buttons */
289
+ /* ------------------------------------------------------------------ */
290
+ let scroll_el = $state<HTMLElement | undefined>(undefined);
291
+ let can_scroll_prev = $state(false);
292
+ let can_scroll_next = $state(false);
293
+
294
+ function updateScrollState() {
295
+ if (!scroll_el) return;
296
+ can_scroll_prev = scroll_el.scrollLeft > 4;
297
+ can_scroll_next =
298
+ scroll_el.scrollLeft + scroll_el.clientWidth < scroll_el.scrollWidth - 4;
299
+ }
300
+
301
+ function scrollNext() {
302
+ if (!scroll_el) return;
303
+ scroll_el.scrollBy({ left: scroll_el.clientWidth * 0.8, behavior: 'smooth' });
304
+ }
305
+ function scrollPrev() {
306
+ if (!scroll_el) return;
307
+ scroll_el.scrollBy({ left: -scroll_el.clientWidth * 0.8, behavior: 'smooth' });
308
+ }
309
+
310
+ $effect(() => {
311
+ if (!horizontal || !scroll_el) return;
312
+ updateScrollState();
313
+ const el = scroll_el;
314
+ const onScroll = () => updateScrollState();
315
+ el.addEventListener('scroll', onScroll, { passive: true });
316
+ const ro = new ResizeObserver(updateScrollState);
317
+ ro.observe(el);
318
+ return () => {
319
+ el.removeEventListener('scroll', onScroll);
320
+ ro.disconnect();
321
+ };
322
+ });
323
+ </script>
324
+
325
+ {#if isItem}
326
+ <!-- TimelineItem -->
327
+ <li
328
+ class={['item', class_name].filter(Boolean).join(' ')}
329
+ class:horizontal={is_horizontal}
330
+ class:vertical={!is_horizontal}
331
+ class:alternate={is_alternate}
332
+ class:even={is_alternate && !is_even}
333
+ class:odd={is_alternate && is_even}
334
+ class:dense={is_dense}
335
+ class:comfortable={is_comfortable}
336
+ class:reveal={do_reveal}
337
+ class:motion={do_motion}
338
+ class:interactive
339
+ class:visible
340
+ class:complete={status === 'complete'}
341
+ class:active={status === 'active'}
342
+ class:pending={status === 'pending'}
343
+ {id}
344
+ style:--marker-color={color || undefined}
345
+ {@attach intersectionObserver({ onintersectonce: () => (visible = true) })}>
346
+ <!-- The marker + content together form one clickable surface (`.lead`),
347
+ so the step circle is part of the touch target — not just the text.
348
+ The connector lives outside it (it's the rail, not the button). -->
349
+ <svelte:element
350
+ this={href ? 'a' : 'div'}
351
+ class="lead"
352
+ class:interactive
353
+ href={href || undefined}
354
+ target={href ? target : undefined}
355
+ rel={href && target === '_blank' ? 'noreferrer' : undefined}
356
+ role={interactive && !href ? 'button' : undefined}
357
+ tabindex={interactive && !href ? 0 : undefined}
358
+ aria-busy={in_flight ? 'true' : undefined}
359
+ onclick={interactive ? handleActivate : undefined}
360
+ onkeydown={interactive && !href ? handleKey : undefined}>
361
+ <div class="marker">
362
+ <span class="node" class:busy={spinner_visible || check_visible}>
363
+ {#if spinner_visible || check_visible}
364
+ <!-- Promise-aware feedback: a spinner while the work runs, then a
365
+ brief success check, both sitting in the step's circle. -->
366
+ <span class="feedback" transition:fade={{ duration: 150 }}>
367
+ {#if spinner_visible}
368
+ <span class="layer" out:fade={{ duration: 120 }}>
369
+ <Progress size="00" color="currentColor" />
370
+ </span>
371
+ {:else}
372
+ <span class="layer" in:checkIn>
373
+ <svg
374
+ class="check"
375
+ viewBox="0 0 24 24"
376
+ fill="none"
377
+ stroke="currentColor"
378
+ stroke-width="3.5"
379
+ stroke-linecap="round"
380
+ stroke-linejoin="round"
381
+ aria-hidden="true">
382
+ <polyline points="20 6 9 17 4 12" />
383
+ </svg>
384
+ </span>
385
+ {/if}
386
+ </span>
387
+ {/if}
388
+ {#if icon}
389
+ {@const Icon = icon}
390
+ <span class="glyph"><Icon /></span>
391
+ {:else if status === 'complete'}
392
+ <span class="glyph">
393
+ <svg
394
+ class="check"
395
+ viewBox="0 0 24 24"
396
+ fill="none"
397
+ stroke="currentColor"
398
+ stroke-width="3.5"
399
+ stroke-linecap="round"
400
+ stroke-linejoin="round"
401
+ aria-hidden="true">
402
+ <polyline points="20 6 9 17 4 12" />
403
+ </svg>
404
+ </span>
405
+ {/if}
406
+ </span>
407
+ </div>
408
+ <div class="content">
409
+ <!-- The ripple + hover tint share one rounded panel that hugs the text,
410
+ so the press reads as intentional (not the whole arbitrary row).
411
+ The panel sits over the text but the click still bubbles to the
412
+ `.lead`, so the marker stays part of the touch target. -->
413
+ {#if interactive}
414
+ <span class="surface" aria-hidden="true" {@attach ripple({ zIndex: 0 })}></span>
415
+ {/if}
416
+ {#if date}
417
+ <time datetime={iso_date}>{formatted_date}</time>
418
+ {/if}
419
+ {#if title}
420
+ <div class="title">{title}</div>
421
+ {/if}
422
+ {#if children}
423
+ <div class="body">
424
+ {@render children()}
425
+ </div>
426
+ {/if}
427
+ </div>
428
+ </svelte:element>
429
+ <div class="connector"></div>
430
+ </li>
431
+ {:else if skeleton}
432
+ <!-- Skeleton -->
433
+ <ol
434
+ class={['timeline skeleton', horizontal ? 'horizontal' : 'vertical', class_name]
435
+ .filter(Boolean)
436
+ .join(' ')}
437
+ class:dense
438
+ class:comfortable
439
+ {id}
440
+ aria-hidden="true">
441
+ {#each { length: skeleton_count } as _, i}
442
+ <li
443
+ class="item skeleton-item"
444
+ class:horizontal
445
+ class:vertical={!horizontal}
446
+ class:dense
447
+ class:comfortable>
448
+ <div class="lead">
449
+ <div class="marker">
450
+ <span class="skeleton-circle" style:--shimmer-delay="{i * 120}ms"></span>
451
+ </div>
452
+ <div class="content">
453
+ <div
454
+ class="skeleton-bar skeleton-date"
455
+ style:--shimmer-delay="{i * 120 + 60}ms">
456
+ </div>
457
+ <div
458
+ class="skeleton-bar skeleton-title-bar"
459
+ style:--shimmer-delay="{i * 120 + 120}ms">
460
+ </div>
461
+ <div
462
+ class="skeleton-bar skeleton-body-bar"
463
+ style:--shimmer-delay="{i * 120 + 180}ms">
464
+ </div>
465
+ </div>
466
+ </div>
467
+ <div class="connector"></div>
468
+ </li>
469
+ {/each}
470
+ </ol>
471
+ {:else if horizontal}
472
+ <!-- Horizontal timeline container with chevron next/prev controls -->
473
+ <div class={['wrap', class_name].filter(Boolean).join(' ')} {id}>
474
+ {#if can_scroll_prev}
475
+ <Button
476
+ icon
477
+ size="00"
478
+ class="timeline-nav timeline-nav-prev"
479
+ aria-label="Scroll back"
480
+ onclick={scrollPrev}>
481
+ <svg
482
+ viewBox="0 0 24 24"
483
+ fill="none"
484
+ stroke="currentColor"
485
+ stroke-width="2"
486
+ stroke-linecap="round"
487
+ stroke-linejoin="round"
488
+ aria-hidden="true">
489
+ <polyline points="15 18 9 12 15 6" />
490
+ </svg>
491
+ </Button>
492
+ {/if}
493
+ <ol
494
+ bind:this={scroll_el}
495
+ class="timeline horizontal"
496
+ class:alternate
497
+ class:dense
498
+ class:comfortable
499
+ role="list"
500
+ {@attach scrollbar()}>
501
+ {@render children?.()}
502
+ {#if pending}
503
+ <li class="item pending-item horizontal" class:motion={animate}>
504
+ <div class="lead">
505
+ <div class="marker">
506
+ <span class="node pending-node"></span>
507
+ </div>
508
+ </div>
509
+ </li>
510
+ {/if}
511
+ {#if onloadmore}
512
+ <li
513
+ class="sentinel"
514
+ aria-hidden="true"
515
+ {@attach intersectionObserver({ onintersectonce: () => handleLoadMore() })}>
516
+ </li>
517
+ {/if}
518
+ </ol>
519
+ {#if can_scroll_next}
520
+ <Button
521
+ icon
522
+ size="00"
523
+ class="timeline-nav timeline-nav-next"
524
+ aria-label="Scroll forward"
525
+ onclick={scrollNext}>
526
+ <svg
527
+ viewBox="0 0 24 24"
528
+ fill="none"
529
+ stroke="currentColor"
530
+ stroke-width="2"
531
+ stroke-linecap="round"
532
+ stroke-linejoin="round"
533
+ aria-hidden="true">
534
+ <polyline points="9 18 15 12 9 6" />
535
+ </svg>
536
+ </Button>
537
+ {/if}
538
+ </div>
539
+ {:else}
540
+ <!-- Timeline container -->
541
+ <ol
542
+ class={['timeline vertical', class_name].filter(Boolean).join(' ')}
543
+ class:alternate
544
+ class:dense
545
+ class:comfortable
546
+ {id}
547
+ role="list">
548
+ {@render children?.()}
549
+ {#if pending}
550
+ <li class="item pending-item vertical" class:motion={animate}>
551
+ <div class="lead">
552
+ <div class="marker">
553
+ <span class="node pending-node"></span>
554
+ </div>
555
+ </div>
556
+ </li>
557
+ {/if}
558
+ {#if onloadmore}
559
+ <li
560
+ class="sentinel"
561
+ aria-hidden="true"
562
+ {@attach intersectionObserver({ onintersectonce: () => handleLoadMore() })}>
563
+ </li>
564
+ {/if}
565
+ </ol>
566
+ {/if}
567
+
568
+ <style>
569
+ /* ============================================================
570
+ * Timeline
571
+ *
572
+ * Geometry is driven by a small set of custom properties set on
573
+ * each .item (and swapped by the dense/comfortable density flags),
574
+ * so the marker, rail and content stay in lockstep without any
575
+ * per-shape offset hacks:
576
+ *
577
+ * --node marker diameter --gap marker → content gap
578
+ * --rail rail thickness --run space below an item (= rail length)
579
+ * --node-gap breathing room between the node and the rail
580
+ *
581
+ * The rail is a real progress line: each item's connector is the
582
+ * segment that descends to the NEXT node, coloured by THIS item's
583
+ * status — completed segments read solid, the active segment fades
584
+ * into the muted "not yet" stretch ahead.
585
+ * ============================================================ */
586
+
587
+ /* ========== Container ========== */
588
+ .timeline {
589
+ list-style: none;
590
+ padding: 0;
591
+ margin: 0;
592
+ position: relative;
593
+ width: 100%;
594
+
595
+ &.vertical {
596
+ display: flex;
597
+ flex-direction: column;
598
+ }
599
+
600
+ &.horizontal {
601
+ display: flex;
602
+ flex-direction: row;
603
+ overflow-x: auto;
604
+ scroll-snap-type: x proximity;
605
+ -webkit-overflow-scrolling: touch;
606
+ gap: 0;
607
+ /* overflow-x clips the y-axis too, so leave room for the active node's
608
+ glow + pulse ring (which reach beyond the marker). */
609
+ padding-block: 1.4rem;
610
+ scrollbar-width: none;
611
+ }
612
+ &.horizontal::-webkit-scrollbar {
613
+ display: none;
614
+ }
615
+ }
616
+
617
+ /* ========== Horizontal Navigation ========== */
618
+ .wrap {
619
+ position: relative;
620
+ width: 100%;
621
+
622
+ /* The nav controls are <Button icon> instances (rendered by the Button
623
+ * component), so target their forwarded class names with :global, scoped
624
+ * inside the wrap. We only position + float them; Button owns appearance. */
625
+ :global(.timeline-nav) {
626
+ position: absolute;
627
+ top: 50%;
628
+ transform: translateY(-50%);
629
+ z-index: 3;
630
+ background: var(--color-surface, #fff);
631
+ box-shadow: var(--shadow-md, 0 3px 10px rgb(0 0 0 / 0.12));
632
+ opacity: 0;
633
+ animation: timeline-nav-fade 200ms var(--ease-out, ease) forwards;
634
+ }
635
+ :global(.timeline-nav-prev) {
636
+ left: -0.75rem;
637
+ }
638
+ :global(.timeline-nav-next) {
639
+ right: -0.75rem;
640
+ }
641
+ }
642
+ @keyframes timeline-nav-fade {
643
+ to {
644
+ opacity: 1;
645
+ }
646
+ }
647
+
648
+ /* ========== Item: geometry ========== */
649
+ .item {
650
+ /* geometry */
651
+ --node: 18px;
652
+ --rail: 2px;
653
+ --gap: 1rem;
654
+ --run: 1.9rem;
655
+ --node-gap: 4px;
656
+ --fs-date: 0.72rem;
657
+ --fs-title: 0.9rem;
658
+ --fs-body: 0.83rem;
659
+
660
+ /* the marker / rail accent — `color` prop wins, else the status hue */
661
+ --accent: var(--marker-color, var(--color-action, #2563eb));
662
+ --node-fg: var(--color-action-text, #fff);
663
+ /* rail segment colour (overridden per status below) */
664
+ --rail-color: var(--color-border, #e5e7eb);
665
+
666
+ position: relative;
667
+ display: block;
668
+
669
+ &.dense {
670
+ --node: 14px;
671
+ --gap: 0.7rem;
672
+ --run: 1.1rem;
673
+ --node-gap: 3px;
674
+ --fs-date: 0.68rem;
675
+ --fs-title: 0.83rem;
676
+ --fs-body: 0.78rem;
677
+ }
678
+
679
+ &.comfortable {
680
+ --node: 24px;
681
+ --rail: 2.5px;
682
+ --gap: 1.25rem;
683
+ --run: 2.6rem;
684
+ --node-gap: 5px;
685
+ --fs-date: 0.78rem;
686
+ --fs-title: 1rem;
687
+ --fs-body: 0.9rem;
688
+ }
689
+
690
+ /* Vertical layout — the rail trails below each item. */
691
+ &.vertical {
692
+ padding-bottom: var(--run);
693
+ }
694
+
695
+ /* Horizontal layout. Equal-width columns (min-width drives the spacing)
696
+ with the node centred, so adjacent nodes sit one item-width apart and
697
+ the rail can span cleanly from one to the next. */
698
+ &.horizontal {
699
+ min-width: 9rem;
700
+ scroll-snap-align: start;
701
+
702
+ &.dense {
703
+ min-width: 6.5rem;
704
+ }
705
+ &.comfortable {
706
+ min-width: 12.5rem;
707
+ }
708
+ }
709
+ }
710
+
711
+ /* ========== Lead: the marker + content row (one clickable surface) ========== */
712
+ .lead {
713
+ display: flex;
714
+ width: 100%;
715
+ box-sizing: border-box;
716
+ color: inherit;
717
+ text-decoration: none;
718
+
719
+ .item.vertical & {
720
+ flex-direction: row;
721
+ align-items: flex-start;
722
+ gap: var(--gap);
723
+ }
724
+
725
+ .item.horizontal & {
726
+ flex-direction: column;
727
+ align-items: center;
728
+ }
729
+ }
730
+
731
+ /* status → accent hue + rail-segment colour */
732
+ .item.complete {
733
+ --accent: var(--marker-color, var(--color-success, #16a34a));
734
+ --node-fg: var(--color-success-text, #fff);
735
+ --rail-color: var(--marker-color, var(--color-success, #16a34a));
736
+ }
737
+ .item.active {
738
+ --accent: var(--marker-color, var(--color-action, #2563eb));
739
+ }
740
+ .item.pending {
741
+ --accent: var(--marker-color, var(--color-text-muted, #9ca3af));
742
+ }
743
+
744
+ /* ========== Entrance reveal (only with the `reveal` class) ==========
745
+ Each node springs in while its rail draws toward the next one. Gated on
746
+ `.reveal` so a timeline with `animate={false}` — or one revealed after a
747
+ skeleton — simply appears, fully formed. */
748
+ .item.reveal {
749
+ opacity: 0;
750
+ transform: translateY(14px);
751
+ transition:
752
+ opacity 520ms var(--ease-out, ease),
753
+ transform 520ms var(--ease-out, ease);
754
+ }
755
+ .item.reveal.visible {
756
+ opacity: 1;
757
+ transform: none;
758
+ }
759
+ .item.reveal.horizontal {
760
+ transform: translateX(18px);
761
+ }
762
+ .item.reveal.horizontal.visible {
763
+ transform: none;
764
+ }
765
+
766
+ /* ========== Alternate Mode (vertical) ==========
767
+ Each item is a half-width column whose node lands exactly on the central
768
+ axis, so the rail stays a single straight line with events fanning out
769
+ left/right. */
770
+ .item.vertical.alternate {
771
+ width: calc(50% + var(--node) / 2);
772
+ }
773
+
774
+ .item.vertical.alternate.odd {
775
+ margin-left: calc(50% - var(--node) / 2);
776
+ }
777
+
778
+ .item.vertical.alternate.even {
779
+ text-align: right;
780
+ }
781
+ .item.vertical.alternate.even .lead {
782
+ flex-direction: row-reverse;
783
+ }
784
+
785
+ /* ========== Marker / node ========== */
786
+ .marker {
787
+ position: relative;
788
+ z-index: 1;
789
+ flex-shrink: 0;
790
+ display: flex;
791
+ align-items: flex-start;
792
+ justify-content: center;
793
+ }
794
+ .item.horizontal .marker {
795
+ align-items: center;
796
+ }
797
+
798
+ .node {
799
+ position: relative;
800
+ width: var(--node);
801
+ height: var(--node);
802
+ border-radius: var(--radius-full, 1e5px);
803
+ display: grid;
804
+ place-items: center;
805
+ color: var(--node-fg);
806
+ background: var(--accent);
807
+ /* soft halo so a filled marker reads as lit, not flat */
808
+ box-shadow: 0 0 0 4px rgb(from var(--accent) r g b / 0.12);
809
+ /* reveal: spring-pop; hover: gentle grow. Colour/shadow snap in (see below). */
810
+ scale: var(--node-scale, 1);
811
+ transition:
812
+ scale 360ms var(--ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1)),
813
+ background-color 240ms ease,
814
+ box-shadow 240ms ease;
815
+ }
816
+ /* start collapsed only while a reveal is pending */
817
+ .item.reveal .node {
818
+ --node-scale: 0;
819
+ }
820
+ .item.reveal.visible .node {
821
+ --node-scale: 1;
822
+ }
823
+
824
+ /* Resting glyph (a custom icon, or the complete-status check). The wrapper is
825
+ sized to the node and its svg fills it — so it works whatever the icon
826
+ component renders, and stays separate from the `.feedback` layers. */
827
+ .node > .glyph {
828
+ display: grid;
829
+ place-items: center;
830
+ width: 63%;
831
+ height: 63%;
832
+ }
833
+ .node > .glyph :global(svg) {
834
+ width: 100%;
835
+ height: 100%;
836
+ }
837
+ /* The resting glyph fades away while the promise feedback occupies the node. */
838
+ .node.busy > .glyph {
839
+ opacity: 0;
840
+ }
841
+
842
+ /* ========== Promise-aware feedback (spinner → success check) ========== */
843
+ .feedback {
844
+ position: absolute;
845
+ inset: 0;
846
+ display: grid;
847
+ place-items: center;
848
+ z-index: 1;
849
+ }
850
+ .feedback .layer {
851
+ grid-area: 1 / 1; /* stack the spinner and the check in one cell */
852
+ display: grid;
853
+ place-items: center;
854
+ }
855
+ /* Fit the (fixed 16px) spinner to the node and give it a faint same-colour
856
+ track so it reads as one ring inside the marker. */
857
+ .feedback :global(.progress) {
858
+ scale: calc(var(--node) / 20);
859
+ }
860
+ .feedback :global(circle.track) {
861
+ stroke: rgb(from currentColor r g b / 0.25);
862
+ }
863
+ .feedback :global(circle.arc) {
864
+ stroke: currentColor;
865
+ }
866
+ .feedback .check {
867
+ width: 64%;
868
+ height: 64%;
869
+ }
870
+ .feedback .check polyline {
871
+ stroke-dasharray: 24;
872
+ stroke-dashoffset: var(--check-draw, 0);
873
+ }
874
+ /* Hold the active ping while the step is working — the spinner is the focus. */
875
+ .item.active.motion .node.busy::before {
876
+ animation: none;
877
+ }
878
+
879
+ /* Active — the "you are here" node: a steady glow plus an expanding ping. */
880
+ .item.active .node {
881
+ box-shadow:
882
+ 0 0 0 4px rgb(from var(--accent) r g b / 0.18),
883
+ 0 0 14px 1px rgb(from var(--accent) r g b / 0.45);
884
+ }
885
+ .item.active.motion .node::before {
886
+ content: '';
887
+ position: absolute;
888
+ inset: 0;
889
+ border-radius: inherit;
890
+ background: var(--accent);
891
+ z-index: -1;
892
+ animation: timeline-ping 2.4s var(--ease-out, ease-out) infinite;
893
+ }
894
+
895
+ /* Pending — a clean hollow ring with a faint fill. */
896
+ .item.pending .node,
897
+ .pending-node {
898
+ background: rgb(from var(--accent) r g b / 0.1);
899
+ box-shadow: inset 0 0 0 var(--rail) var(--accent);
900
+ color: var(--accent);
901
+ }
902
+ .pending-node {
903
+ --accent: var(--marker-color, var(--color-text-muted, #9ca3af));
904
+ width: var(--node, 18px);
905
+ height: var(--node, 18px);
906
+ border-radius: var(--radius-full, 1e5px);
907
+ }
908
+ .pending-item.motion .pending-node {
909
+ animation: timeline-breathe 2.4s ease-in-out infinite;
910
+ }
911
+
912
+ /* ========== Connector (the progress rail) ========== */
913
+ .connector {
914
+ position: absolute;
915
+ border-radius: var(--radius-full, 1e5px);
916
+ background: var(--rail-color);
917
+ z-index: 0;
918
+ }
919
+
920
+ .item.vertical > .connector {
921
+ left: calc(var(--node) / 2 - var(--rail) / 2);
922
+ top: calc(var(--node) + var(--node-gap));
923
+ bottom: var(--node-gap);
924
+ width: var(--rail);
925
+ }
926
+
927
+ /* The node is centred (50%); the next item's node sits one full item-width
928
+ away, so the segment runs from this node's right edge to the next node's
929
+ left edge (minus the breathing gap at each end). */
930
+ .item.horizontal > .connector {
931
+ top: calc(var(--node) / 2 - var(--rail) / 2);
932
+ left: calc(50% + var(--node) / 2 + var(--node-gap));
933
+ width: calc(100% - var(--node) - 2 * var(--node-gap));
934
+ height: var(--rail);
935
+ }
936
+
937
+ /* draw-in: the rail grows toward the next node after this one pops */
938
+ .item.reveal.vertical > .connector {
939
+ transform: scaleY(0);
940
+ transform-origin: top center;
941
+ transition: transform 560ms var(--ease-out, ease) 120ms;
942
+ }
943
+ .item.reveal.vertical.visible > .connector {
944
+ transform: scaleY(1);
945
+ }
946
+ .item.reveal.horizontal > .connector {
947
+ transform: scaleX(0);
948
+ transform-origin: left center;
949
+ transition: transform 560ms var(--ease-out, ease) 120ms;
950
+ }
951
+ .item.reveal.horizontal.visible > .connector {
952
+ transform: scaleX(1);
953
+ }
954
+
955
+ /* The active segment fades from "done" into the muted road ahead. */
956
+ .item.active.vertical > .connector {
957
+ background: linear-gradient(to bottom, var(--accent), var(--color-border, #e5e7eb));
958
+ }
959
+ .item.active.horizontal > .connector {
960
+ background: linear-gradient(to right, var(--accent), var(--color-border, #e5e7eb));
961
+ }
962
+
963
+ /* Hide the trailing connector — the last node has nowhere to go. The
964
+ pending-item carries the `.item` class, so an item followed by the
965
+ pending node still draws its rail; only the genuine last node drops it. */
966
+ .item:not(:has(~ .item)) > .connector {
967
+ display: none;
968
+ }
969
+
970
+ /* Alternate mode: the rail hugs the central axis on both sides. */
971
+ .item.vertical.alternate.even > .connector {
972
+ left: auto;
973
+ right: calc(var(--node) / 2 - var(--rail) / 2);
974
+ }
975
+
976
+ /* ========== Interactive lead (clickable step) ==========
977
+ The whole marker + content surface is the touch target (mirrors Button:
978
+ pointer cursor, ripple, press scale, snap-in / ease-out hover). The hover
979
+ tint is painted only behind the text, by a pseudo-element, so an item
980
+ gaining an onclick/href never changes its layout. */
981
+ .lead.interactive {
982
+ position: relative;
983
+ cursor: pointer;
984
+ outline: none;
985
+ -webkit-tap-highlight-color: transparent;
986
+ transition: scale 180ms ease;
987
+ }
988
+ .lead.interactive:active {
989
+ scale: 0.985;
990
+ }
991
+ .lead.interactive:focus-visible {
992
+ outline: 2px solid var(--color-action, #2563eb);
993
+ outline-offset: 3px;
994
+ border-radius: var(--radius-md, 5px);
995
+ @supports (corner-shape: squircle) {
996
+ corner-shape: squircle;
997
+ border-radius: calc(var(--radius-md, 5px) * var(--squircle-ratio, 2));
998
+ }
999
+ }
1000
+
1001
+ /* Hovering the step leans its marker in and deepens its title. */
1002
+ .lead.interactive:hover .node {
1003
+ --node-scale: 1.1;
1004
+ box-shadow: 0 0 0 6px rgb(from var(--accent) r g b / 0.18);
1005
+ transition: scale 320ms var(--ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
1006
+ }
1007
+ .item.active .lead.interactive:hover .node {
1008
+ box-shadow:
1009
+ 0 0 0 6px rgb(from var(--accent) r g b / 0.22),
1010
+ 0 0 16px 1px rgb(from var(--accent) r g b / 0.5);
1011
+ }
1012
+ .lead.interactive:hover .title {
1013
+ color: var(--color-text-active, var(--color-text, #1a1a1a));
1014
+ transition: none;
1015
+ }
1016
+
1017
+ /* ========== Content ========== */
1018
+ .content {
1019
+ flex: 1;
1020
+ min-width: 0;
1021
+ position: relative;
1022
+ /* own stacking context so the tint (::before, z-index -1) tucks behind the
1023
+ text without escaping behind the whole item */
1024
+ isolation: isolate;
1025
+ /* nudge the first text line so it sits centred against the node */
1026
+ padding-top: calc(var(--node) / 2 - 0.5em);
1027
+
1028
+ .item.horizontal & {
1029
+ margin-top: 0.85rem;
1030
+ padding-top: 0;
1031
+ text-align: center;
1032
+ }
1033
+ }
1034
+ /* The hover tint — a rounded panel behind the text only. Absolutely
1035
+ positioned, so it adds no layout (interactive and plain items match). */
1036
+ .content::before {
1037
+ content: '';
1038
+ position: absolute;
1039
+ inset: -0.3rem -0.6rem;
1040
+ z-index: -1;
1041
+ border-radius: var(--radius-md, 5px);
1042
+ background: transparent;
1043
+ transition: background-color 240ms ease;
1044
+ @supports (corner-shape: squircle) {
1045
+ corner-shape: squircle;
1046
+ border-radius: calc(var(--radius-md, 5px) * var(--squircle-ratio, 2));
1047
+ }
1048
+ }
1049
+ .item.horizontal .content::before {
1050
+ inset: -0.3rem -0.7rem;
1051
+ }
1052
+ .lead.interactive:hover .content::before {
1053
+ background: rgb(from var(--color-text, #333) r g b / 0.06);
1054
+ transition: none;
1055
+ }
1056
+
1057
+ /* The ripple panel — the same rounded footprint as the tint, sitting on top
1058
+ so it catches the press and clips the ripple to a clean, intentional shape
1059
+ (not the whole arbitrary marker+content row). Transparent, so the tint
1060
+ behind the text stays the resting look. Clicks still bubble to `.lead`, so
1061
+ the marker remains part of the touch target. */
1062
+ .surface {
1063
+ position: absolute;
1064
+ inset: -0.3rem -0.6rem;
1065
+ z-index: 2;
1066
+ border-radius: var(--radius-md, 5px);
1067
+ cursor: pointer;
1068
+ -webkit-tap-highlight-color: transparent;
1069
+ @supports (corner-shape: squircle) {
1070
+ corner-shape: squircle;
1071
+ border-radius: calc(var(--radius-md, 5px) * var(--squircle-ratio, 2));
1072
+ }
1073
+ }
1074
+ .item.horizontal .surface {
1075
+ inset: -0.3rem -0.7rem;
1076
+ }
1077
+
1078
+ time {
1079
+ display: block;
1080
+ font-size: var(--fs-date);
1081
+ font-weight: var(--font-weight-semibold, 600);
1082
+ letter-spacing: 0.04em;
1083
+ text-transform: uppercase;
1084
+ color: var(--color-text-muted, #6b7280);
1085
+ margin-bottom: 0.3em;
1086
+ line-height: 1.2;
1087
+ }
1088
+
1089
+ .title {
1090
+ font-weight: var(--font-weight-semibold, 600);
1091
+ font-size: var(--fs-title);
1092
+ color: var(--color-text, #1a1a1a);
1093
+ line-height: 1.35;
1094
+ transition: color 240ms ease;
1095
+ }
1096
+
1097
+ .body {
1098
+ margin-top: 0.25em;
1099
+ font-size: var(--fs-body);
1100
+ color: var(--color-text-muted, #6b7280);
1101
+ line-height: 1.55;
1102
+ }
1103
+
1104
+ /* ========== Pending Indicator (trailing) ========== */
1105
+ .pending-item {
1106
+ padding-bottom: 0;
1107
+ }
1108
+
1109
+ /* ========== Load-more Sentinel ========== */
1110
+ .sentinel {
1111
+ height: 1px;
1112
+ width: 1px;
1113
+ overflow: hidden;
1114
+ position: absolute;
1115
+ bottom: 0;
1116
+ }
1117
+
1118
+ /* ========== Skeleton ========== */
1119
+ .timeline.skeleton {
1120
+ pointer-events: none;
1121
+ }
1122
+
1123
+ .skeleton-item > .connector {
1124
+ background: var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1));
1125
+ }
1126
+
1127
+ .skeleton-circle,
1128
+ .skeleton-bar {
1129
+ position: relative;
1130
+ overflow: hidden;
1131
+ background: var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1));
1132
+
1133
+ &::after {
1134
+ content: '';
1135
+ position: absolute;
1136
+ inset: 0;
1137
+ transform: translateX(-100%);
1138
+ background-image: linear-gradient(
1139
+ 105deg,
1140
+ transparent 25%,
1141
+ var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
1142
+ transparent 75%
1143
+ );
1144
+ animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
1145
+ infinite;
1146
+ animation-delay: var(--shimmer-delay, 0s);
1147
+ }
1148
+ }
1149
+
1150
+ /* Same footprint as the real .node. */
1151
+ .skeleton-circle {
1152
+ width: var(--node, 18px);
1153
+ height: var(--node, 18px);
1154
+ border-radius: var(--radius-full, 1e5px);
1155
+ }
1156
+
1157
+ /* Flex column so the bars' line-padding margins don't collapse — each bar's
1158
+ margins pad it out to its real text line's 1lh, keeping skeleton items
1159
+ exactly as tall as loaded ones. */
1160
+ .skeleton-item .content {
1161
+ display: flex;
1162
+ flex-direction: column;
1163
+ align-items: flex-start;
1164
+ }
1165
+
1166
+ .skeleton-item.horizontal .content {
1167
+ align-items: center;
1168
+ }
1169
+
1170
+ .skeleton-bar {
1171
+ height: 0.7em;
1172
+ border-radius: var(--radius-full, 1e5px);
1173
+ max-width: 100%;
1174
+
1175
+ &.skeleton-date {
1176
+ width: 5rem;
1177
+ font-size: var(--fs-date);
1178
+ line-height: 1.2;
1179
+ margin-block: calc((1lh - 0.7em) / 2) calc((1lh - 0.7em) / 2 + 0.3em);
1180
+ }
1181
+
1182
+ &.skeleton-title-bar {
1183
+ width: 8rem;
1184
+ font-size: var(--fs-title);
1185
+ line-height: 1.35;
1186
+ margin-block: calc((1lh - 0.7em) / 2);
1187
+ }
1188
+
1189
+ &.skeleton-body-bar {
1190
+ width: 12rem;
1191
+ font-size: var(--fs-body);
1192
+ line-height: 1.55;
1193
+ margin-block: calc((1lh - 0.7em) / 2 + 0.25em) calc((1lh - 0.7em) / 2);
1194
+ }
1195
+ }
1196
+
1197
+ /* ========== Animations ========== */
1198
+ @keyframes timeline-ping {
1199
+ 0% {
1200
+ transform: scale(1);
1201
+ opacity: 0.55;
1202
+ }
1203
+ 70%,
1204
+ 100% {
1205
+ transform: scale(2.6);
1206
+ opacity: 0;
1207
+ }
1208
+ }
1209
+
1210
+ @keyframes timeline-breathe {
1211
+ 0%,
1212
+ 100% {
1213
+ opacity: 0.55;
1214
+ }
1215
+ 50% {
1216
+ opacity: 1;
1217
+ }
1218
+ }
1219
+
1220
+ @keyframes -global-delight-skeleton-shimmer {
1221
+ 0% {
1222
+ transform: translateX(-100%);
1223
+ }
1224
+ 55%,
1225
+ 100% {
1226
+ transform: translateX(100%);
1227
+ }
1228
+ }
1229
+
1230
+ /* ========== Reduced Motion ========== */
1231
+ @media (prefers-reduced-motion: reduce) {
1232
+ .item.reveal {
1233
+ opacity: 1 !important;
1234
+ transform: none !important;
1235
+ transition: none !important;
1236
+ }
1237
+
1238
+ .node {
1239
+ scale: 1 !important;
1240
+ transition: none !important;
1241
+ }
1242
+
1243
+ .item.active .node::before,
1244
+ .pending-node {
1245
+ animation: none !important;
1246
+ }
1247
+
1248
+ .item.reveal > .connector {
1249
+ transform: none !important;
1250
+ transition: none !important;
1251
+ }
1252
+
1253
+ .skeleton-circle::after,
1254
+ .skeleton-bar::after {
1255
+ animation: none;
1256
+ }
1257
+ }
1258
+ </style>