@workflow/web-shared 5.0.0-beta.26 → 5.0.0-beta.28

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 (94) hide show
  1. package/dist/components/new-trace-viewer/components/detail-panel-width.d.ts +34 -0
  2. package/dist/components/new-trace-viewer/components/detail-panel-width.d.ts.map +1 -0
  3. package/dist/components/new-trace-viewer/components/detail-panel-width.js +67 -0
  4. package/dist/components/new-trace-viewer/components/detail-panel.d.ts +14 -0
  5. package/dist/components/new-trace-viewer/components/detail-panel.d.ts.map +1 -0
  6. package/dist/components/new-trace-viewer/components/detail-panel.js +122 -0
  7. package/dist/components/new-trace-viewer/components/draggable-border.d.ts +31 -0
  8. package/dist/components/new-trace-viewer/components/draggable-border.d.ts.map +1 -0
  9. package/dist/components/new-trace-viewer/components/draggable-border.js +140 -0
  10. package/dist/components/new-trace-viewer/components/event-list.d.ts +1 -1
  11. package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
  12. package/dist/components/new-trace-viewer/components/event-list.js +3 -5
  13. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts +1 -1
  14. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts.map +1 -1
  15. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js +2 -2
  16. package/dist/components/new-trace-viewer/components/minimap.d.ts +14 -0
  17. package/dist/components/new-trace-viewer/components/minimap.d.ts.map +1 -0
  18. package/dist/components/new-trace-viewer/components/minimap.js +337 -0
  19. package/dist/components/new-trace-viewer/components/pane-constants.d.ts +14 -0
  20. package/dist/components/new-trace-viewer/components/pane-constants.d.ts.map +1 -0
  21. package/dist/components/new-trace-viewer/components/pane-constants.js +16 -0
  22. package/dist/components/new-trace-viewer/components/split-pane.d.ts +1 -4
  23. package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
  24. package/dist/components/new-trace-viewer/components/split-pane.js +18 -16
  25. package/dist/components/new-trace-viewer/components/timeline.d.ts +2 -2
  26. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  27. package/dist/components/new-trace-viewer/components/timeline.js +5 -7
  28. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
  29. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +10 -3
  30. package/dist/components/new-trace-viewer/components/use-element-width.d.ts +8 -0
  31. package/dist/components/new-trace-viewer/components/use-element-width.d.ts.map +1 -0
  32. package/dist/components/new-trace-viewer/components/use-element-width.js +25 -0
  33. package/dist/components/new-trace-viewer/context.d.ts +2 -0
  34. package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
  35. package/dist/components/new-trace-viewer/context.js +3 -2
  36. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  37. package/dist/components/new-trace-viewer/trace-viewer.js +53 -181
  38. package/dist/components/new-trace-viewer/utils.d.ts +21 -0
  39. package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
  40. package/dist/components/new-trace-viewer/utils.js +44 -3
  41. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  42. package/dist/components/sidebar/attribute-panel.js +38 -35
  43. package/dist/components/sidebar/attributes-block.d.ts +18 -7
  44. package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
  45. package/dist/components/sidebar/attributes-block.js +76 -18
  46. package/dist/components/sidebar/events-list.d.ts +1 -1
  47. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  48. package/dist/components/sidebar/events-list.js +9 -10
  49. package/dist/components/trace-viewer/components/map.d.ts +2 -2
  50. package/dist/components/trace-viewer/components/map.d.ts.map +1 -1
  51. package/dist/components/trace-viewer/components/node.d.ts +7 -7
  52. package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
  53. package/dist/components/ui/collapsible.d.ts +46 -0
  54. package/dist/components/ui/collapsible.d.ts.map +1 -0
  55. package/dist/components/ui/collapsible.js +66 -0
  56. package/dist/components/ui/data-inspector.js +2 -7
  57. package/dist/index.d.ts +2 -2
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +3 -3
  60. package/dist/lib/hydration.d.ts +12 -2
  61. package/dist/lib/hydration.d.ts.map +1 -1
  62. package/dist/lib/hydration.js +41 -18
  63. package/dist/lib/utils.d.ts +8 -5
  64. package/dist/lib/utils.d.ts.map +1 -1
  65. package/dist/lib/utils.js +13 -16
  66. package/package.json +6 -6
  67. package/src/components/new-trace-viewer/components/detail-panel-width.test.ts +123 -0
  68. package/src/components/new-trace-viewer/components/detail-panel-width.ts +76 -0
  69. package/src/components/new-trace-viewer/components/detail-panel.tsx +272 -0
  70. package/src/components/new-trace-viewer/components/draggable-border.tsx +216 -0
  71. package/src/components/new-trace-viewer/components/event-list.tsx +3 -5
  72. package/src/components/new-trace-viewer/components/middle-truncate/truncate.ts +1 -1
  73. package/src/components/new-trace-viewer/components/minimap.tsx +559 -0
  74. package/src/components/new-trace-viewer/components/pane-constants.ts +19 -0
  75. package/src/components/new-trace-viewer/components/split-pane.tsx +44 -55
  76. package/src/components/new-trace-viewer/components/timeline.tsx +3 -5
  77. package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +21 -1
  78. package/src/components/new-trace-viewer/components/use-element-width.ts +29 -0
  79. package/src/components/new-trace-viewer/context.tsx +4 -1
  80. package/src/components/new-trace-viewer/trace-viewer.tsx +93 -312
  81. package/src/components/new-trace-viewer/utils.test.ts +65 -0
  82. package/src/components/new-trace-viewer/utils.ts +76 -2
  83. package/src/components/sidebar/attribute-panel.tsx +106 -105
  84. package/src/components/sidebar/attributes-block.tsx +170 -50
  85. package/src/components/sidebar/events-list.tsx +107 -92
  86. package/src/components/ui/collapsible.tsx +219 -0
  87. package/src/index.ts +8 -7
  88. package/src/lib/hydration.ts +48 -25
  89. package/src/lib/utils.test.ts +33 -0
  90. package/src/lib/utils.ts +12 -16
  91. package/dist/components/sidebar/detail-card.d.ts +0 -13
  92. package/dist/components/sidebar/detail-card.d.ts.map +0 -1
  93. package/dist/components/sidebar/detail-card.js +0 -36
  94. package/src/components/sidebar/detail-card.tsx +0 -143
@@ -0,0 +1,219 @@
1
+ 'use client';
2
+
3
+ import { ChevronDown, ChevronRight } from 'lucide-react';
4
+ import {
5
+ createContext,
6
+ type ReactNode,
7
+ type SyntheticEvent,
8
+ useContext,
9
+ useState,
10
+ } from 'react';
11
+ import { cn } from '../../lib/cn';
12
+
13
+ type CollapsibleVariant = 'section' | 'card';
14
+
15
+ type CollapsibleContextValue = {
16
+ variant: CollapsibleVariant;
17
+ disabled: boolean;
18
+ };
19
+
20
+ const CollapsibleContext = createContext<CollapsibleContextValue | null>(null);
21
+
22
+ function useCollapsibleContext(part: string): CollapsibleContextValue {
23
+ const context = useContext(CollapsibleContext);
24
+ if (!context) {
25
+ throw new Error(
26
+ `<Collapsible${part}> must be rendered inside <CollapsibleRoot>`
27
+ );
28
+ }
29
+ return context;
30
+ }
31
+
32
+ type CollapsibleRootProps = {
33
+ children: ReactNode;
34
+ variant?: CollapsibleVariant;
35
+ disabled?: boolean;
36
+ defaultOpen?: boolean;
37
+ onOpenChange?: (open: boolean) => void;
38
+ className?: string;
39
+ };
40
+
41
+ /**
42
+ * Low-level collapsible container. Use this together with `CollapsibleTrigger`
43
+ * and `CollapsibleContent` when you need to override the styling of individual
44
+ * parts. For the common case, prefer the all-in-one `Collapsible`.
45
+ */
46
+ export function CollapsibleRoot({
47
+ children,
48
+ variant = 'section',
49
+ disabled = false,
50
+ defaultOpen = false,
51
+ onOpenChange,
52
+ className,
53
+ }: CollapsibleRootProps) {
54
+ const [open, setOpen] = useState(defaultOpen);
55
+
56
+ const handleToggle = (event: SyntheticEvent<HTMLDetailsElement>) => {
57
+ // React bubbles the `toggle` event even though the native one doesn't, so
58
+ // a nested <details> (e.g. an event card inside the Events section)
59
+ // collapsing would otherwise flip this card. Only react to direct toggles.
60
+ if (event.target !== event.currentTarget) return;
61
+ const next = event.currentTarget.open;
62
+ setOpen(next);
63
+ onOpenChange?.(next);
64
+ };
65
+
66
+ const details = (
67
+ <details
68
+ data-slot="collapsible"
69
+ data-state={open ? 'open' : 'closed'}
70
+ open={open}
71
+ onToggle={disabled ? undefined : handleToggle}
72
+ className={cn(
73
+ variant === 'card'
74
+ ? 'group/card border-gray-alpha-400 last:border-b'
75
+ : 'group',
76
+ className
77
+ )}
78
+ >
79
+ <CollapsibleContext.Provider value={{ variant, disabled }}>
80
+ {children}
81
+ </CollapsibleContext.Provider>
82
+ </details>
83
+ );
84
+
85
+ if (variant === 'card') {
86
+ return details;
87
+ }
88
+
89
+ return (
90
+ <section className="-mx-4 border-t border-gray-alpha-400 px-4 py-2">
91
+ {details}
92
+ </section>
93
+ );
94
+ }
95
+
96
+ type CollapsibleTriggerProps = {
97
+ children: ReactNode;
98
+ className?: string;
99
+ };
100
+
101
+ /** The clickable header of a `CollapsibleRoot`. */
102
+ export function CollapsibleTrigger({
103
+ children,
104
+ className,
105
+ }: CollapsibleTriggerProps) {
106
+ const { variant, disabled } = useCollapsibleContext('Trigger');
107
+
108
+ if (variant === 'card') {
109
+ return (
110
+ <summary
111
+ data-slot="collapsible-trigger"
112
+ className={cn(
113
+ 'flex cursor-pointer list-none items-center gap-1.5 border-t border-gray-alpha-400 bg-background-200 px-3 py-4 hover:bg-gray-100 [&::-webkit-details-marker]:hidden',
114
+ className
115
+ )}
116
+ >
117
+ <ChevronRight
118
+ size={14}
119
+ className="shrink-0 text-gray-700 group-hover/card:text-gray-1000 group-open/card:rotate-90"
120
+ />
121
+ {children}
122
+ </summary>
123
+ );
124
+ }
125
+
126
+ // Shared row metrics keep every header the same height regardless of variant.
127
+ const row =
128
+ 'flex h-9 items-center gap-2 -mx-2 px-2 text-heading-14 font-medium list-none [&::-webkit-details-marker]:hidden';
129
+
130
+ if (disabled) {
131
+ return (
132
+ <summary
133
+ data-slot="collapsible-trigger"
134
+ className={cn(row, 'pointer-events-none text-gray-700', className)}
135
+ >
136
+ <span className="min-w-0 flex-1">{children}</span>
137
+ </summary>
138
+ );
139
+ }
140
+
141
+ return (
142
+ <summary
143
+ data-slot="collapsible-trigger"
144
+ className={cn(
145
+ row,
146
+ 'group/trigger cursor-pointer rounded hover:bg-gray-alpha-100',
147
+ className
148
+ )}
149
+ >
150
+ <span className="relative isolate h-3.5 w-3.5 shrink-0 text-gray-700 group-hover/trigger:text-gray-1000">
151
+ <ChevronRight
152
+ size={14}
153
+ className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-100 group-open:opacity-0"
154
+ />
155
+ <ChevronDown
156
+ size={14}
157
+ className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 group-open:opacity-100"
158
+ />
159
+ </span>
160
+ <span className="min-w-0 flex-1">{children}</span>
161
+ </summary>
162
+ );
163
+ }
164
+
165
+ type CollapsibleContentProps = {
166
+ children: ReactNode;
167
+ className?: string;
168
+ };
169
+
170
+ /** The collapsible body of a `CollapsibleRoot`. */
171
+ export function CollapsibleContent({
172
+ children,
173
+ className,
174
+ }: CollapsibleContentProps) {
175
+ const { variant } = useCollapsibleContext('Content');
176
+ return (
177
+ <div
178
+ data-slot="collapsible-content"
179
+ className={cn(variant === 'section' && 'mt-2 mb-3', className)}
180
+ >
181
+ {children}
182
+ </div>
183
+ );
184
+ }
185
+
186
+ type CollapsibleProps = {
187
+ /** Header content shown in the trigger row. */
188
+ label: ReactNode;
189
+ /** Body content. Omit for a header-only (e.g. disabled or skeleton) row. */
190
+ children?: ReactNode;
191
+ disabled?: boolean;
192
+ defaultOpen?: boolean;
193
+ onOpenChange?: (open: boolean) => void;
194
+ };
195
+
196
+ /**
197
+ * All-in-one collapsible section: a labeled header plus body content. This is
198
+ * the component to reach for in most cases. When you need to override the
199
+ * styling of a specific part, compose `CollapsibleRoot`, `CollapsibleTrigger`,
200
+ * and `CollapsibleContent` directly instead.
201
+ */
202
+ export function Collapsible({
203
+ label,
204
+ children,
205
+ disabled,
206
+ defaultOpen,
207
+ onOpenChange,
208
+ }: CollapsibleProps) {
209
+ return (
210
+ <CollapsibleRoot
211
+ disabled={disabled}
212
+ defaultOpen={defaultOpen}
213
+ onOpenChange={onOpenChange}
214
+ >
215
+ <CollapsibleTrigger>{label}</CollapsibleTrigger>
216
+ {children != null && <CollapsibleContent>{children}</CollapsibleContent>}
217
+ </CollapsibleRoot>
218
+ );
219
+ }
package/src/index.ts CHANGED
@@ -14,13 +14,6 @@ export {
14
14
  waitEventsToWaitEntity,
15
15
  } from './components/workflow-traces/trace-span-construction';
16
16
  export type { EventAnalysis } from './lib/event-analysis';
17
- export {
18
- parseExactWorkflowSearchId,
19
- looksLikeWorkflowIdSearchInput,
20
- type ExactWorkflowSearchId,
21
- type ExactWorkflowSearchIdKind,
22
- type ExactIdSearchResult,
23
- } from './lib/exact-event-search-id';
24
17
  export {
25
18
  analyzeEvents,
26
19
  hasPendingHooksFromEvents,
@@ -40,6 +33,13 @@ export {
40
33
  materializeSteps,
41
34
  materializeWaits,
42
35
  } from './lib/event-materialization';
36
+ export {
37
+ type ExactIdSearchResult,
38
+ type ExactWorkflowSearchId,
39
+ type ExactWorkflowSearchIdKind,
40
+ looksLikeWorkflowIdSearchInput,
41
+ parseExactWorkflowSearchId,
42
+ } from './lib/exact-event-search-id';
43
43
  export type { Revivers, StreamRef } from './lib/hydration';
44
44
  export {
45
45
  CLASS_INSTANCE_REF_TYPE,
@@ -49,6 +49,7 @@ export {
49
49
  getWebRevivers,
50
50
  hasEncryptedFields,
51
51
  hydrateResourceIO,
52
+ hydrateResourceIOAsync,
52
53
  hydrateResourceIOWithKey,
53
54
  isClassInstanceRef,
54
55
  isEncryptedMarker,
@@ -18,6 +18,20 @@ import {
18
18
  } from '@workflow/core/serialization-format';
19
19
  import { EVENT_DATA_REF_FIELDS } from '@workflow/world';
20
20
 
21
+ const V4_EXTRA_EVENT_DATA_REF_FIELDS: Record<string, string[]> = {
22
+ run_started: ['input'],
23
+ step_started: ['input'],
24
+ };
25
+
26
+ export function getEventDataRefFields(eventType: string): string[] {
27
+ return [
28
+ ...new Set([
29
+ ...(EVENT_DATA_REF_FIELDS[eventType] ?? []),
30
+ ...(V4_EXTRA_EVENT_DATA_REF_FIELDS[eventType] ?? []),
31
+ ]),
32
+ ];
33
+ }
34
+
21
35
  // Re-export types and utilities that consumers need
22
36
  export {
23
37
  CLASS_INSTANCE_REF_TYPE,
@@ -446,7 +460,7 @@ function replaceEncryptedAndExpiredWithMarkers<T>(resource: T): T {
446
460
  if (result.eventData && typeof result.eventData === 'object') {
447
461
  const eventType =
448
462
  typeof result.eventType === 'string' ? result.eventType : '';
449
- const refKeys = EVENT_DATA_REF_FIELDS[eventType] ?? [];
463
+ const refKeys = getEventDataRefFields(eventType);
450
464
  const ed = { ...(result.eventData as Record<string, unknown>) };
451
465
  for (const key of refKeys) {
452
466
  if (key in ed) {
@@ -465,17 +479,31 @@ function replaceEncryptedAndExpiredWithMarkers<T>(resource: T): T {
465
479
  * When a key is provided, encrypted fields are decrypted before hydration.
466
480
  * This is the async version used when the user clicks "Decrypt" in the web UI.
467
481
  *
468
- * Handles both top-level fields (input, output, metadata) and nested
469
- * eventData subfields per `EVENT_DATA_REF_FIELDS` from `@workflow/world` for that event type.
482
+ * Handles both top-level fields (input, output, metadata) and nested eventData
483
+ * payload fields for that event type.
470
484
  */
471
485
  export async function hydrateResourceIOWithKey<T>(
472
486
  resource: T,
473
487
  key: Uint8Array
488
+ ): Promise<T> {
489
+ return hydrateResourceIOAsync(resource, key);
490
+ }
491
+
492
+ /**
493
+ * Async hydration for web display.
494
+ *
495
+ * This follows the same resource-field mapping as {@link hydrateResourceIO},
496
+ * but can also inflate compressed browser payloads through the registered
497
+ * zstd WASM decoder. When a key is provided, encrypted fields are decrypted
498
+ * first and then inflated/hydrated.
499
+ */
500
+ export async function hydrateResourceIOAsync<T>(
501
+ resource: T,
502
+ key?: Uint8Array
474
503
  ): Promise<T> {
475
504
  const { hydrateDataWithKey } = await import(
476
505
  '@workflow/core/serialization-format'
477
506
  );
478
- const { importKey } = await import('@workflow/core/encryption');
479
507
  // Payloads may be zstd-compressed (the Web DecompressionStream has no zstd);
480
508
  // register the WASM-backed browser decoder before hydrating. Idempotent and
481
509
  // lazy — the WASM is only compiled when a zstd payload is actually decoded.
@@ -483,25 +511,24 @@ export async function hydrateResourceIOWithKey<T>(
483
511
  './zstd-browser-decoder.js'
484
512
  );
485
513
  ensureZstdDecoderRegistered();
486
- const cryptoKey = await importKey(key);
514
+ const cryptoKey = key
515
+ ? await import('@workflow/core/encryption').then(({ importKey }) =>
516
+ importKey(key)
517
+ )
518
+ : undefined;
487
519
  const revivers = getRevivers();
488
520
 
489
- /** Extract original encrypted bytes from a marker or raw Uint8Array, then decrypt + hydrate */
490
- async function decryptField(
491
- value: unknown,
492
- rev: Revivers,
493
- k: Awaited<ReturnType<typeof importKey>>
494
- ): Promise<unknown> {
521
+ async function hydrateField(value: unknown): Promise<unknown> {
495
522
  // Already-hydrated: encrypted marker with stored bytes
496
523
  if (isEncryptedMarker(value)) {
497
524
  const raw = (value as any).__encryptedData as Uint8Array;
498
- return hydrateDataWithKey(raw, rev, k);
525
+ return cryptoKey ? hydrateDataWithKey(raw, revivers, cryptoKey) : value;
499
526
  }
500
- // Raw encrypted Uint8Array (not yet hydrated)
527
+ // Raw Uint8Array: may be encrypted, compressed, or plain devalue.
501
528
  if (value instanceof Uint8Array) {
502
- return hydrateDataWithKey(value, rev, k);
529
+ return hydrateDataWithKey(value, revivers, cryptoKey);
503
530
  }
504
- // Not encrypted — return as-is
531
+ // Not serialized — return as-is.
505
532
  return value;
506
533
  }
507
534
 
@@ -511,29 +538,25 @@ export async function hydrateResourceIOWithKey<T>(
511
538
  // Decrypt + hydrate top-level serialized fields (runs, steps, hooks)
512
539
  for (const field of ['input', 'output', 'metadata', 'error']) {
513
540
  if (field in result) {
514
- result[field] = await decryptField(result[field], revivers, cryptoKey);
541
+ result[field] = await hydrateField(result[field]);
515
542
  }
516
543
  }
517
544
 
518
- // Decrypt + hydrate eventData subfields (events)
545
+ // Hydrate eventData subfields (events)
519
546
  if (result.eventData && typeof result.eventData === 'object') {
520
547
  const eventType =
521
548
  typeof result.eventType === 'string' ? result.eventType : '';
522
- const refKeys = EVENT_DATA_REF_FIELDS[eventType] ?? [];
549
+ const refKeys = getEventDataRefFields(eventType);
523
550
  const eventData = { ...(result.eventData as Record<string, unknown>) };
524
551
  for (const field of refKeys) {
525
552
  if (field in eventData) {
526
- eventData[field] = await decryptField(
527
- eventData[field],
528
- revivers,
529
- cryptoKey
530
- );
553
+ eventData[field] = await hydrateField(eventData[field]);
531
554
  }
532
555
  }
533
556
  result.eventData = eventData;
534
557
  }
535
558
 
536
- return result as T;
559
+ return replaceEncryptedAndExpiredWithMarkers(result as T);
537
560
  }
538
561
 
539
562
  /**
@@ -552,7 +575,7 @@ export function hasEncryptedFields(resource: unknown): boolean {
552
575
 
553
576
  if (r.eventData && typeof r.eventData === 'object') {
554
577
  const eventType = typeof r.eventType === 'string' ? r.eventType : '';
555
- const refKeys = EVENT_DATA_REF_FIELDS[eventType] ?? [];
578
+ const refKeys = getEventDataRefFields(eventType);
556
579
  const ed = r.eventData as Record<string, unknown>;
557
580
  for (const key of refKeys) {
558
581
  if (key in ed && isEncryptedMarker(ed[key])) return true;
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatDurationPrecise } from './utils';
3
+
4
+ describe('formatDurationPrecise', () => {
5
+ it('shows whole milliseconds below 1s', () => {
6
+ expect(formatDurationPrecise(0)).toBe('0s');
7
+ expect(formatDurationPrecise(626)).toBe('626ms');
8
+ });
9
+
10
+ it('keeps sub-second precision instead of rounding up to a whole second', () => {
11
+ // The bug this guards: 1626ms must not read as "2s".
12
+ expect(formatDurationPrecise(1626)).toBe('1.63s');
13
+ });
14
+
15
+ it('trims trailing zeros so whole/half seconds read cleanly', () => {
16
+ expect(formatDurationPrecise(2000)).toBe('2s');
17
+ expect(formatDurationPrecise(1500)).toBe('1.5s');
18
+ expect(formatDurationPrecise(1600)).toBe('1.6s');
19
+ expect(formatDurationPrecise(45_000)).toBe('45s');
20
+ });
21
+
22
+ it('honors a custom fraction-digit count (used by the timeline ruler)', () => {
23
+ // Fewer digits also coarsen the rounding: 1 digit rounds to 100ms.
24
+ expect(formatDurationPrecise(1500, 1)).toBe('1.5s');
25
+ expect(formatDurationPrecise(1620, 1)).toBe('1.6s');
26
+ expect(formatDurationPrecise(2000, 1)).toBe('2s');
27
+ });
28
+
29
+ it('decomposes durations of a minute or more', () => {
30
+ expect(formatDurationPrecise(65_000)).toBe('1m 5s');
31
+ expect(formatDurationPrecise(63_450)).toBe('1m 3.45s');
32
+ });
33
+ });
package/src/lib/utils.ts CHANGED
@@ -74,25 +74,20 @@ export function formatDuration(ms: number, compact = false): string {
74
74
  return parts.join(' ');
75
75
  }
76
76
 
77
- // Locale-aware formatter that always renders exactly two fraction digits.
78
- // Used for the seconds component of precise durations so values are never
79
- // snapped to a whole second (and thousands separators stay correct).
80
- const preciseSecondsFormatter = new Intl.NumberFormat(undefined, {
81
- minimumFractionDigits: 2,
82
- maximumFractionDigits: 2,
83
- });
84
-
85
77
  /**
86
78
  * Formats a duration in milliseconds without snapping to whole seconds.
87
79
  *
88
- * Unlike {@link formatDuration}, this never rounds a sub-minute value up to
89
- * the next whole second (e.g. 1626ms renders as "1.63s", not "2s").
80
+ * Unlike {@link formatDuration}, this keeps sub-second detail, so 1626ms
81
+ * renders as "1.63s" rather than "2s". `fractionDigits` sets both the number of
82
+ * decimals on the seconds component (trailing zeros trimmed, so 2000ms is "2s"
83
+ * and 1500ms is "1.5s") and the rounding granularity — the default of 2 rounds
84
+ * to 10ms; the timeline ruler passes fewer digits to match its tick step.
90
85
  *
91
86
  * - < 1s: shows whole milliseconds (e.g. "626ms")
92
- * - 1s – 1m: shows seconds with two decimals (e.g. "1.63s", "45.20s")
93
- * - >= 1m: decomposes into d/h/m with two-decimal seconds (e.g. "1m 13.45s")
87
+ * - 1s – 1m: shows seconds with trimmed decimals (e.g. "1.63s", "1.5s", "45s")
88
+ * - >= 1m: decomposes into d/h/m with trimmed-decimal seconds (e.g. "1m 13.45s")
94
89
  */
95
- export function formatDurationPrecise(ms: number): string {
90
+ export function formatDurationPrecise(ms: number, fractionDigits = 2): string {
96
91
  if (ms === 0) {
97
92
  return '0s';
98
93
  }
@@ -104,10 +99,11 @@ export function formatDurationPrecise(ms: number): string {
104
99
  }
105
100
  }
106
101
 
107
- const normalizedMs = Math.round(ms / 10) * 10;
102
+ const granularityMs = MS_IN_SECOND / 10 ** fractionDigits;
103
+ const normalizedMs = Math.round(ms / granularityMs) * granularityMs;
108
104
 
109
105
  if (normalizedMs < MS_IN_MINUTE) {
110
- return `${preciseSecondsFormatter.format(normalizedMs / MS_IN_SECOND)}s`;
106
+ return `${Number((normalizedMs / MS_IN_SECOND).toFixed(fractionDigits))}s`;
111
107
  }
112
108
 
113
109
  const days = Math.floor(normalizedMs / MS_IN_DAY);
@@ -126,7 +122,7 @@ export function formatDurationPrecise(ms: number): string {
126
122
  if (minutes > 0) {
127
123
  parts.push(`${minutes}m`);
128
124
  }
129
- parts.push(`${preciseSecondsFormatter.format(seconds)}s`);
125
+ parts.push(`${Number(seconds.toFixed(fractionDigits))}s`);
130
126
 
131
127
  return parts.join(' ');
132
128
  }
@@ -1,13 +0,0 @@
1
- import { type ReactNode } from 'react';
2
- export declare function DetailCard({ summary, children, onToggle, disabled, defaultOpen, variant, trailing, summaryClassName, contentClassName, }: {
3
- summary: ReactNode;
4
- children?: ReactNode;
5
- onToggle?: (open: boolean) => void;
6
- disabled?: boolean;
7
- defaultOpen?: boolean;
8
- variant?: 'section' | 'card';
9
- trailing?: ReactNode;
10
- summaryClassName?: string;
11
- contentClassName?: string;
12
- }): import("react/jsx-runtime").JSX.Element;
13
- //# sourceMappingURL=detail-card.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"detail-card.d.ts","sourceRoot":"","sources":["../../../src/components/sidebar/detail-card.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,SAAS,EAAY,MAAM,OAAO,CAAC;AAGjD,wBAAgB,UAAU,CAAC,EACzB,OAAO,EACP,QAAQ,EACR,QAAQ,EACR,QAAgB,EAChB,WAAmB,EACnB,OAAmB,EACnB,QAAQ,EACR,gBAAgB,EAChB,gBAAgB,GACjB,EAAE;IACD,OAAO,EAAE,SAAS,CAAC;IACnB,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,OAAO,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC;IAC7B,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,2CAsHA"}
@@ -1,36 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ChevronDown, ChevronRight } from 'lucide-react';
3
- import { useState } from 'react';
4
- import { cn } from '../../lib/cn';
5
- export function DetailCard({ summary, children, onToggle, disabled = false, defaultOpen = false, variant = 'section', trailing, summaryClassName, contentClassName, }) {
6
- const [open, setOpen] = useState(defaultOpen);
7
- const handleToggle = (e) => {
8
- // React surfaces `toggle` as a bubbling synthetic event even though the
9
- // native event doesn't bubble. Without this guard, a nested <details>
10
- // (e.g. an event card inside the Events section) collapsing would flip
11
- // the outer DetailCard's state. Only react to direct toggles.
12
- if (e.target !== e.currentTarget)
13
- return;
14
- const next = e.currentTarget.open;
15
- setOpen(next);
16
- onToggle?.(next);
17
- };
18
- if (variant === 'card') {
19
- if (disabled) {
20
- return (_jsx("div", { className: cn('list-none px-3 py-4 bg-background-200 [&::-webkit-details-marker]:hidden', summaryClassName), style: { cursor: 'not-allowed', opacity: 0.8 }, children: summary }));
21
- }
22
- return (_jsxs("details", { className: "group/card last:border-b border-gray-alpha-400", open: open, onToggle: handleToggle, children: [_jsx("summary", { className: cn('list-none cursor-pointer px-3 py-4 border-t border-gray-alpha-400 bg-background-200 hover:bg-gray-100 [&::-webkit-details-marker]:hidden', summaryClassName), children: _jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx(ChevronRight, { size: 14, className: cn('shrink-0 text-gray-700 group-hover/card:text-gray-1000', open && 'rotate-90') }), summary] }) }), _jsx("div", { className: contentClassName, children: children })] }));
23
- }
24
- // Shared height with the expandable summary row. Keeps the trailing /
25
- // disabled / chevron variants visually identical in height regardless of
26
- // what's in the trailing slot.
27
- const rowClasses = 'flex h-9 items-center gap-2 px-2 -mx-2 text-heading-14 font-medium my-2';
28
- if (trailing) {
29
- return (_jsx("section", { className: "-mx-4 border-t px-4 border-gray-alpha-400", children: _jsxs("div", { className: cn(rowClasses, summaryClassName), children: [_jsx("div", { className: "isolate relative shrink-0 w-3.5 h-3.5 text-gray-700", children: _jsx(ChevronRight, { size: 14, className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" }) }), _jsx("span", { className: "min-w-0 flex-1", children: summary }), _jsx("div", { className: "shrink-0 pr-1", children: trailing })] }) }));
30
- }
31
- if (disabled) {
32
- return (_jsx("section", { className: "-mx-4 border-t px-4 border-gray-alpha-400", children: _jsx("div", { className: cn(rowClasses, summaryClassName), style: { color: 'var(--ds-gray-700)', cursor: 'not-allowed' }, children: _jsx("span", { className: "min-w-0 flex-1", children: summary }) }) }));
33
- }
34
- return (_jsx("section", { className: "-mx-4 border-t px-4 border-gray-alpha-400", children: _jsxs("details", { className: "group", open: open, onToggle: handleToggle, children: [_jsxs("summary", { className: cn('group/trigger list-none cursor-pointer rounded hover:bg-gray-alpha-100 [&::-webkit-details-marker]:hidden', rowClasses, summaryClassName), children: [_jsxs("div", { className: "isolate relative shrink-0 text-gray-700 group-hover/trigger:text-gray-1000 w-3.5 h-3.5", children: [_jsx(ChevronRight, { size: 14, className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-100 group-open:opacity-0" }), _jsx(ChevronDown, { size: 14, className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 group-open:opacity-100" })] }), _jsx("span", { className: "min-w-0 flex-1", children: summary })] }), _jsx("div", { className: cn('mb-3', contentClassName), children: children })] }) }));
35
- }
36
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGV0YWlsLWNhcmQuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi9zcmMvY29tcG9uZW50cy9zaWRlYmFyL2RldGFpbC1jYXJkLnRzeCJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiO0FBQUEsT0FBTyxFQUFFLFdBQVcsRUFBRSxZQUFZLEVBQUUsTUFBTSxjQUFjLENBQUM7QUFDekQsT0FBTyxFQUFrQixRQUFRLEVBQUUsTUFBTSxPQUFPLENBQUM7QUFDakQsT0FBTyxFQUFFLEVBQUUsRUFBRSxNQUFNLGNBQWMsQ0FBQztBQUVsQyxNQUFNLFVBQVUsVUFBVSxDQUFDLEVBQ3pCLE9BQU8sRUFDUCxRQUFRLEVBQ1IsUUFBUSxFQUNSLFFBQVEsR0FBRyxLQUFLLEVBQ2hCLFdBQVcsR0FBRyxLQUFLLEVBQ25CLE9BQU8sR0FBRyxTQUFTLEVBQ25CLFFBQVEsRUFDUixnQkFBZ0IsRUFDaEIsZ0JBQWdCLEdBV2pCO0lBQ0MsTUFBTSxDQUFDLElBQUksRUFBRSxPQUFPLENBQUMsR0FBRyxRQUFRLENBQUMsV0FBVyxDQUFDLENBQUM7SUFFOUMsTUFBTSxZQUFZLEdBQUcsQ0FBQyxDQUEyQyxFQUFFLEVBQUU7UUFDbkUsd0VBQXdFO1FBQ3hFLHNFQUFzRTtRQUN0RSx1RUFBdUU7UUFDdkUsOERBQThEO1FBQzlELElBQUksQ0FBQyxDQUFDLE1BQU0sS0FBSyxDQUFDLENBQUMsYUFBYTtZQUFFLE9BQU87UUFDekMsTUFBTSxJQUFJLEdBQUcsQ0FBQyxDQUFDLGFBQWEsQ0FBQyxJQUFJLENBQUM7UUFDbEMsT0FBTyxDQUFDLElBQUksQ0FBQyxDQUFDO1FBQ2QsUUFBUSxFQUFFLENBQUMsSUFBSSxDQUFDLENBQUM7SUFDbkIsQ0FBQyxDQUFDO0lBRUYsSUFBSSxPQUFPLEtBQUssTUFBTSxFQUFFLENBQUM7UUFDdkIsSUFBSSxRQUFRLEVBQUUsQ0FBQztZQUNiLE9BQU8sQ0FDTCxjQUNFLFNBQVMsRUFBRSxFQUFFLENBQ1gsMEVBQTBFLEVBQzFFLGdCQUFnQixDQUNqQixFQUNELEtBQUssRUFBRSxFQUFFLE1BQU0sRUFBRSxhQUFhLEVBQUUsT0FBTyxFQUFFLEdBQUcsRUFBRSxZQUU3QyxPQUFPLEdBQ0osQ0FDUCxDQUFDO1FBQ0osQ0FBQztRQUNELE9BQU8sQ0FDTCxtQkFDRSxTQUFTLEVBQUMsZ0RBQWdELEVBQzFELElBQUksRUFBRSxJQUFJLEVBQ1YsUUFBUSxFQUFFLFlBQVksYUFFdEIsa0JBQ0UsU0FBUyxFQUFFLEVBQUUsQ0FDWCwwSUFBMEksRUFDMUksZ0JBQWdCLENBQ2pCLFlBRUQsZ0JBQU0sU0FBUyxFQUFDLDJCQUEyQixhQUN6QyxLQUFDLFlBQVksSUFDWCxJQUFJLEVBQUUsRUFBRSxFQUNSLFNBQVMsRUFBRSxFQUFFLENBQ1gsd0RBQXdELEVBQ3hELElBQUksSUFBSSxXQUFXLENBQ3BCLEdBQ0QsRUFDRCxPQUFPLElBQ0gsR0FDQyxFQUNWLGNBQUssU0FBUyxFQUFFLGdCQUFnQixZQUFHLFFBQVEsR0FBTyxJQUMxQyxDQUNYLENBQUM7SUFDSixDQUFDO0lBRUQsc0VBQXNFO0lBQ3RFLHlFQUF5RTtJQUN6RSwrQkFBK0I7SUFDL0IsTUFBTSxVQUFVLEdBQ2QseUVBQXlFLENBQUM7SUFFNUUsSUFBSSxRQUFRLEVBQUUsQ0FBQztRQUNiLE9BQU8sQ0FDTCxrQkFBUyxTQUFTLEVBQUMsMkNBQTJDLFlBQzVELGVBQUssU0FBUyxFQUFFLEVBQUUsQ0FBQyxVQUFVLEVBQUUsZ0JBQWdCLENBQUMsYUFDOUMsY0FBSyxTQUFTLEVBQUMscURBQXFELFlBQ2xFLEtBQUMsWUFBWSxJQUNYLElBQUksRUFBRSxFQUFFLEVBQ1IsU0FBUyxFQUFDLDZEQUE2RCxHQUN2RSxHQUNFLEVBQ04sZUFBTSxTQUFTLEVBQUMsZ0JBQWdCLFlBQUUsT0FBTyxHQUFRLEVBQ2pELGNBQUssU0FBUyxFQUFDLGVBQWUsWUFBRSxRQUFRLEdBQU8sSUFDM0MsR0FDRSxDQUNYLENBQUM7SUFDSixDQUFDO0lBRUQsSUFBSSxRQUFRLEVBQUUsQ0FBQztRQUNiLE9BQU8sQ0FDTCxrQkFBUyxTQUFTLEVBQUMsMkNBQTJDLFlBQzVELGNBQ0UsU0FBUyxFQUFFLEVBQUUsQ0FBQyxVQUFVLEVBQUUsZ0JBQWdCLENBQUMsRUFDM0MsS0FBSyxFQUFFLEVBQUUsS0FBSyxFQUFFLG9CQUFvQixFQUFFLE1BQU0sRUFBRSxhQUFhLEVBQUUsWUFFN0QsZUFBTSxTQUFTLEVBQUMsZ0JBQWdCLFlBQUUsT0FBTyxHQUFRLEdBQzdDLEdBQ0UsQ0FDWCxDQUFDO0lBQ0osQ0FBQztJQUVELE9BQU8sQ0FDTCxrQkFBUyxTQUFTLEVBQUMsMkNBQTJDLFlBQzVELG1CQUFTLFNBQVMsRUFBQyxPQUFPLEVBQUMsSUFBSSxFQUFFLElBQUksRUFBRSxRQUFRLEVBQUUsWUFBWSxhQUMzRCxtQkFDRSxTQUFTLEVBQUUsRUFBRSxDQUNYLDJHQUEyRyxFQUMzRyxVQUFVLEVBQ1YsZ0JBQWdCLENBQ2pCLGFBRUQsZUFBSyxTQUFTLEVBQUMsd0ZBQXdGLGFBQ3JHLEtBQUMsWUFBWSxJQUNYLElBQUksRUFBRSxFQUFFLEVBQ1IsU0FBUyxFQUFDLDhGQUE4RixHQUN4RyxFQUNGLEtBQUMsV0FBVyxJQUNWLElBQUksRUFBRSxFQUFFLEVBQ1IsU0FBUyxFQUFDLDhGQUE4RixHQUN4RyxJQUNFLEVBQ04sZUFBTSxTQUFTLEVBQUMsZ0JBQWdCLFlBQUUsT0FBTyxHQUFRLElBQ3pDLEVBQ1YsY0FBSyxTQUFTLEVBQUUsRUFBRSxDQUFDLE1BQU0sRUFBRSxnQkFBZ0IsQ0FBQyxZQUFHLFFBQVEsR0FBTyxJQUN0RCxHQUNGLENBQ1gsQ0FBQztBQUNKLENBQUMiLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgeyBDaGV2cm9uRG93biwgQ2hldnJvblJpZ2h0IH0gZnJvbSAnbHVjaWRlLXJlYWN0JztcbmltcG9ydCB7IHR5cGUgUmVhY3ROb2RlLCB1c2VTdGF0ZSB9IGZyb20gJ3JlYWN0JztcbmltcG9ydCB7IGNuIH0gZnJvbSAnLi4vLi4vbGliL2NuJztcblxuZXhwb3J0IGZ1bmN0aW9uIERldGFpbENhcmQoe1xuICBzdW1tYXJ5LFxuICBjaGlsZHJlbixcbiAgb25Ub2dnbGUsXG4gIGRpc2FibGVkID0gZmFsc2UsXG4gIGRlZmF1bHRPcGVuID0gZmFsc2UsXG4gIHZhcmlhbnQgPSAnc2VjdGlvbicsXG4gIHRyYWlsaW5nLFxuICBzdW1tYXJ5Q2xhc3NOYW1lLFxuICBjb250ZW50Q2xhc3NOYW1lLFxufToge1xuICBzdW1tYXJ5OiBSZWFjdE5vZGU7XG4gIGNoaWxkcmVuPzogUmVhY3ROb2RlO1xuICBvblRvZ2dsZT86IChvcGVuOiBib29sZWFuKSA9PiB2b2lkO1xuICBkaXNhYmxlZD86IGJvb2xlYW47XG4gIGRlZmF1bHRPcGVuPzogYm9vbGVhbjtcbiAgdmFyaWFudD86ICdzZWN0aW9uJyB8ICdjYXJkJztcbiAgdHJhaWxpbmc/OiBSZWFjdE5vZGU7XG4gIHN1bW1hcnlDbGFzc05hbWU/OiBzdHJpbmc7XG4gIGNvbnRlbnRDbGFzc05hbWU/OiBzdHJpbmc7XG59KSB7XG4gIGNvbnN0IFtvcGVuLCBzZXRPcGVuXSA9IHVzZVN0YXRlKGRlZmF1bHRPcGVuKTtcblxuICBjb25zdCBoYW5kbGVUb2dnbGUgPSAoZTogUmVhY3QuU3ludGhldGljRXZlbnQ8SFRNTERldGFpbHNFbGVtZW50PikgPT4ge1xuICAgIC8vIFJlYWN0IHN1cmZhY2VzIGB0b2dnbGVgIGFzIGEgYnViYmxpbmcgc3ludGhldGljIGV2ZW50IGV2ZW4gdGhvdWdoIHRoZVxuICAgIC8vIG5hdGl2ZSBldmVudCBkb2Vzbid0IGJ1YmJsZS4gV2l0aG91dCB0aGlzIGd1YXJkLCBhIG5lc3RlZCA8ZGV0YWlscz5cbiAgICAvLyAoZS5nLiBhbiBldmVudCBjYXJkIGluc2lkZSB0aGUgRXZlbnRzIHNlY3Rpb24pIGNvbGxhcHNpbmcgd291bGQgZmxpcFxuICAgIC8vIHRoZSBvdXRlciBEZXRhaWxDYXJkJ3Mgc3RhdGUuIE9ubHkgcmVhY3QgdG8gZGlyZWN0IHRvZ2dsZXMuXG4gICAgaWYgKGUudGFyZ2V0ICE9PSBlLmN1cnJlbnRUYXJnZXQpIHJldHVybjtcbiAgICBjb25zdCBuZXh0ID0gZS5jdXJyZW50VGFyZ2V0Lm9wZW47XG4gICAgc2V0T3BlbihuZXh0KTtcbiAgICBvblRvZ2dsZT8uKG5leHQpO1xuICB9O1xuXG4gIGlmICh2YXJpYW50ID09PSAnY2FyZCcpIHtcbiAgICBpZiAoZGlzYWJsZWQpIHtcbiAgICAgIHJldHVybiAoXG4gICAgICAgIDxkaXZcbiAgICAgICAgICBjbGFzc05hbWU9e2NuKFxuICAgICAgICAgICAgJ2xpc3Qtbm9uZSBweC0zIHB5LTQgYmctYmFja2dyb3VuZC0yMDAgWyY6Oi13ZWJraXQtZGV0YWlscy1tYXJrZXJdOmhpZGRlbicsXG4gICAgICAgICAgICBzdW1tYXJ5Q2xhc3NOYW1lXG4gICAgICAgICAgKX1cbiAgICAgICAgICBzdHlsZT17eyBjdXJzb3I6ICdub3QtYWxsb3dlZCcsIG9wYWNpdHk6IDAuOCB9fVxuICAgICAgICA+XG4gICAgICAgICAge3N1bW1hcnl9XG4gICAgICAgIDwvZGl2PlxuICAgICAgKTtcbiAgICB9XG4gICAgcmV0dXJuIChcbiAgICAgIDxkZXRhaWxzXG4gICAgICAgIGNsYXNzTmFtZT1cImdyb3VwL2NhcmQgbGFzdDpib3JkZXItYiBib3JkZXItZ3JheS1hbHBoYS00MDBcIlxuICAgICAgICBvcGVuPXtvcGVufVxuICAgICAgICBvblRvZ2dsZT17aGFuZGxlVG9nZ2xlfVxuICAgICAgPlxuICAgICAgICA8c3VtbWFyeVxuICAgICAgICAgIGNsYXNzTmFtZT17Y24oXG4gICAgICAgICAgICAnbGlzdC1ub25lIGN1cnNvci1wb2ludGVyIHB4LTMgcHktNCBib3JkZXItdCBib3JkZXItZ3JheS1hbHBoYS00MDAgYmctYmFja2dyb3VuZC0yMDAgaG92ZXI6YmctZ3JheS0xMDAgWyY6Oi13ZWJraXQtZGV0YWlscy1tYXJrZXJdOmhpZGRlbicsXG4gICAgICAgICAgICBzdW1tYXJ5Q2xhc3NOYW1lXG4gICAgICAgICAgKX1cbiAgICAgICAgPlxuICAgICAgICAgIDxzcGFuIGNsYXNzTmFtZT1cImZsZXggaXRlbXMtY2VudGVyIGdhcC0xLjVcIj5cbiAgICAgICAgICAgIDxDaGV2cm9uUmlnaHRcbiAgICAgICAgICAgICAgc2l6ZT17MTR9XG4gICAgICAgICAgICAgIGNsYXNzTmFtZT17Y24oXG4gICAgICAgICAgICAgICAgJ3Nocmluay0wIHRleHQtZ3JheS03MDAgZ3JvdXAtaG92ZXIvY2FyZDp0ZXh0LWdyYXktMTAwMCcsXG4gICAgICAgICAgICAgICAgb3BlbiAmJiAncm90YXRlLTkwJ1xuICAgICAgICAgICAgICApfVxuICAgICAgICAgICAgLz5cbiAgICAgICAgICAgIHtzdW1tYXJ5fVxuICAgICAgICAgIDwvc3Bhbj5cbiAgICAgICAgPC9zdW1tYXJ5PlxuICAgICAgICA8ZGl2IGNsYXNzTmFtZT17Y29udGVudENsYXNzTmFtZX0+e2NoaWxkcmVufTwvZGl2PlxuICAgICAgPC9kZXRhaWxzPlxuICAgICk7XG4gIH1cblxuICAvLyBTaGFyZWQgaGVpZ2h0IHdpdGggdGhlIGV4cGFuZGFibGUgc3VtbWFyeSByb3cuIEtlZXBzIHRoZSB0cmFpbGluZyAvXG4gIC8vIGRpc2FibGVkIC8gY2hldnJvbiB2YXJpYW50cyB2aXN1YWxseSBpZGVudGljYWwgaW4gaGVpZ2h0IHJlZ2FyZGxlc3Mgb2ZcbiAgLy8gd2hhdCdzIGluIHRoZSB0cmFpbGluZyBzbG90LlxuICBjb25zdCByb3dDbGFzc2VzID1cbiAgICAnZmxleCBoLTkgaXRlbXMtY2VudGVyIGdhcC0yIHB4LTIgLW14LTIgdGV4dC1oZWFkaW5nLTE0IGZvbnQtbWVkaXVtIG15LTInO1xuXG4gIGlmICh0cmFpbGluZykge1xuICAgIHJldHVybiAoXG4gICAgICA8c2VjdGlvbiBjbGFzc05hbWU9XCItbXgtNCBib3JkZXItdCBweC00IGJvcmRlci1ncmF5LWFscGhhLTQwMFwiPlxuICAgICAgICA8ZGl2IGNsYXNzTmFtZT17Y24ocm93Q2xhc3Nlcywgc3VtbWFyeUNsYXNzTmFtZSl9PlxuICAgICAgICAgIDxkaXYgY2xhc3NOYW1lPVwiaXNvbGF0ZSByZWxhdGl2ZSBzaHJpbmstMCB3LTMuNSBoLTMuNSB0ZXh0LWdyYXktNzAwXCI+XG4gICAgICAgICAgICA8Q2hldnJvblJpZ2h0XG4gICAgICAgICAgICAgIHNpemU9ezE0fVxuICAgICAgICAgICAgICBjbGFzc05hbWU9XCJhYnNvbHV0ZSB0b3AtMS8yIGxlZnQtMS8yIC10cmFuc2xhdGUteC0xLzIgLXRyYW5zbGF0ZS15LTEvMlwiXG4gICAgICAgICAgICAvPlxuICAgICAgICAgIDwvZGl2PlxuICAgICAgICAgIDxzcGFuIGNsYXNzTmFtZT1cIm1pbi13LTAgZmxleC0xXCI+e3N1bW1hcnl9PC9zcGFuPlxuICAgICAgICAgIDxkaXYgY2xhc3NOYW1lPVwic2hyaW5rLTAgcHItMVwiPnt0cmFpbGluZ308L2Rpdj5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L3NlY3Rpb24+XG4gICAgKTtcbiAgfVxuXG4gIGlmIChkaXNhYmxlZCkge1xuICAgIHJldHVybiAoXG4gICAgICA8c2VjdGlvbiBjbGFzc05hbWU9XCItbXgtNCBib3JkZXItdCBweC00IGJvcmRlci1ncmF5LWFscGhhLTQwMFwiPlxuICAgICAgICA8ZGl2XG4gICAgICAgICAgY2xhc3NOYW1lPXtjbihyb3dDbGFzc2VzLCBzdW1tYXJ5Q2xhc3NOYW1lKX1cbiAgICAgICAgICBzdHlsZT17eyBjb2xvcjogJ3ZhcigtLWRzLWdyYXktNzAwKScsIGN1cnNvcjogJ25vdC1hbGxvd2VkJyB9fVxuICAgICAgICA+XG4gICAgICAgICAgPHNwYW4gY2xhc3NOYW1lPVwibWluLXctMCBmbGV4LTFcIj57c3VtbWFyeX08L3NwYW4+XG4gICAgICAgIDwvZGl2PlxuICAgICAgPC9zZWN0aW9uPlxuICAgICk7XG4gIH1cblxuICByZXR1cm4gKFxuICAgIDxzZWN0aW9uIGNsYXNzTmFtZT1cIi1teC00IGJvcmRlci10IHB4LTQgYm9yZGVyLWdyYXktYWxwaGEtNDAwXCI+XG4gICAgICA8ZGV0YWlscyBjbGFzc05hbWU9XCJncm91cFwiIG9wZW49e29wZW59IG9uVG9nZ2xlPXtoYW5kbGVUb2dnbGV9PlxuICAgICAgICA8c3VtbWFyeVxuICAgICAgICAgIGNsYXNzTmFtZT17Y24oXG4gICAgICAgICAgICAnZ3JvdXAvdHJpZ2dlciBsaXN0LW5vbmUgY3Vyc29yLXBvaW50ZXIgcm91bmRlZCBob3ZlcjpiZy1ncmF5LWFscGhhLTEwMCBbJjo6LXdlYmtpdC1kZXRhaWxzLW1hcmtlcl06aGlkZGVuJyxcbiAgICAgICAgICAgIHJvd0NsYXNzZXMsXG4gICAgICAgICAgICBzdW1tYXJ5Q2xhc3NOYW1lXG4gICAgICAgICAgKX1cbiAgICAgICAgPlxuICAgICAgICAgIDxkaXYgY2xhc3NOYW1lPVwiaXNvbGF0ZSByZWxhdGl2ZSBzaHJpbmstMCB0ZXh0LWdyYXktNzAwIGdyb3VwLWhvdmVyL3RyaWdnZXI6dGV4dC1ncmF5LTEwMDAgdy0zLjUgaC0zLjVcIj5cbiAgICAgICAgICAgIDxDaGV2cm9uUmlnaHRcbiAgICAgICAgICAgICAgc2l6ZT17MTR9XG4gICAgICAgICAgICAgIGNsYXNzTmFtZT1cImFic29sdXRlIHRvcC0xLzIgbGVmdC0xLzIgLXRyYW5zbGF0ZS14LTEvMiAtdHJhbnNsYXRlLXktMS8yIG9wYWNpdHktMTAwIGdyb3VwLW9wZW46b3BhY2l0eS0wXCJcbiAgICAgICAgICAgIC8+XG4gICAgICAgICAgICA8Q2hldnJvbkRvd25cbiAgICAgICAgICAgICAgc2l6ZT17MTR9XG4gICAgICAgICAgICAgIGNsYXNzTmFtZT1cImFic29sdXRlIHRvcC0xLzIgbGVmdC0xLzIgLXRyYW5zbGF0ZS14LTEvMiAtdHJhbnNsYXRlLXktMS8yIG9wYWNpdHktMCBncm91cC1vcGVuOm9wYWNpdHktMTAwXCJcbiAgICAgICAgICAgIC8+XG4gICAgICAgICAgPC9kaXY+XG4gICAgICAgICAgPHNwYW4gY2xhc3NOYW1lPVwibWluLXctMCBmbGV4LTFcIj57c3VtbWFyeX08L3NwYW4+XG4gICAgICAgIDwvc3VtbWFyeT5cbiAgICAgICAgPGRpdiBjbGFzc05hbWU9e2NuKCdtYi0zJywgY29udGVudENsYXNzTmFtZSl9PntjaGlsZHJlbn08L2Rpdj5cbiAgICAgIDwvZGV0YWlscz5cbiAgICA8L3NlY3Rpb24+XG4gICk7XG59XG4iXX0=