@cwygoda/service-catalog-ui 1.0.1 → 1.0.3

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.

Potentially problematic release.


This version of @cwygoda/service-catalog-ui might be problematic. Click here for more details.

@@ -22,6 +22,7 @@
22
22
  }
23
23
 
24
24
  const FIT_PADDING = 40; // pixels of padding around diagram when fitting
25
+ const FULLSCREEN_TRANSITION_MS = 100;
25
26
 
26
27
  interface BpmnViewer {
27
28
  importXML: (xml: string) => Promise<unknown>;
@@ -33,6 +34,7 @@
33
34
 
34
35
  let wrapper: HTMLDivElement;
35
36
  let container: HTMLDivElement;
37
+ let resizeObserver: ResizeObserver | null = null;
36
38
  let viewer: BpmnViewer | null = $state(null);
37
39
  let error: string | null = $state(null);
38
40
  let ready = $state(false);
@@ -53,7 +55,7 @@
53
55
  isFullscreen = !!document.fullscreenElement;
54
56
  // Re-fit diagram when entering/exiting fullscreen
55
57
  if (ready) {
56
- setTimeout(fitWithPadding, 100);
58
+ setTimeout(fitWithPadding, FULLSCREEN_TRANSITION_MS);
57
59
  }
58
60
  }
59
61
 
@@ -134,6 +136,12 @@
134
136
  // Fit diagram to container with padding
135
137
  fitWithPadding();
136
138
  ready = true;
139
+
140
+ // Re-fit on resize
141
+ resizeObserver = new ResizeObserver(() => {
142
+ if (ready) fitWithPadding();
143
+ });
144
+ resizeObserver.observe(container);
137
145
  } catch (e) {
138
146
  error = e instanceof Error ? e.message : 'Failed to render BPMN diagram';
139
147
  console.error('BPMN render error:', e);
@@ -144,6 +152,7 @@
144
152
  if (browser) {
145
153
  document.removeEventListener('fullscreenchange', handleFullscreenChange);
146
154
  }
155
+ resizeObserver?.disconnect();
147
156
  if (viewer?.destroy) {
148
157
  viewer.destroy();
149
158
  }
@@ -178,10 +187,12 @@
178
187
  >
179
188
  <div
180
189
  bind:this={container}
181
- class="bpmn-container w-full rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
190
+ class="bpmn-container w-full rounded-lg border border-gray-200 bg-white {isFullscreen
191
+ ? 'h-screen'
192
+ : 'h-64 sm:h-80 md:h-96'} dark:border-gray-700 dark:bg-gray-800"
182
193
  class:cursor-grab={interactive}
183
- class:h-96={!isFullscreen}
184
- class:h-screen={isFullscreen}
194
+ role="img"
195
+ aria-label="BPMN process diagram"
185
196
  ></div>
186
197
 
187
198
  <!-- Zoom controls -->
@@ -191,12 +202,12 @@
191
202
  >
192
203
  <button
193
204
  onclick={zoomIn}
194
- class="rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
205
+ class="flex min-h-11 min-w-11 items-center justify-center rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
195
206
  aria-label="Zoom in"
196
207
  title="Zoom in"
197
208
  >
198
209
  <svg
199
- class="h-4 w-4"
210
+ class="h-5 w-5"
200
211
  fill="none"
201
212
  viewBox="0 0 24 24"
202
213
  stroke="currentColor"
@@ -207,12 +218,12 @@
207
218
  </button>
208
219
  <button
209
220
  onclick={zoomOut}
210
- class="rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
221
+ class="flex min-h-11 min-w-11 items-center justify-center rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
211
222
  aria-label="Zoom out"
212
223
  title="Zoom out"
213
224
  >
214
225
  <svg
215
- class="h-4 w-4"
226
+ class="h-5 w-5"
216
227
  fill="none"
217
228
  viewBox="0 0 24 24"
218
229
  stroke="currentColor"
@@ -224,13 +235,13 @@
224
235
  <div class="my-0.5 border-t border-gray-200 dark:border-gray-600"></div>
225
236
  <button
226
237
  onclick={resetZoom}
227
- class="rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
238
+ class="flex min-h-11 min-w-11 items-center justify-center rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
228
239
  aria-label="Fit to view"
229
240
  title="Fit to view"
230
241
  >
231
242
  <!-- Viewfinder/target icon for fit-to-view -->
232
243
  <svg
233
- class="h-4 w-4"
244
+ class="h-5 w-5"
234
245
  fill="none"
235
246
  viewBox="0 0 24 24"
236
247
  stroke="currentColor"
@@ -246,13 +257,13 @@
246
257
  <div class="my-0.5 border-t border-gray-200 dark:border-gray-600"></div>
247
258
  <button
248
259
  onclick={toggleFullscreen}
249
- class="rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
260
+ class="flex min-h-11 min-w-11 items-center justify-center rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
250
261
  aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
251
262
  title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
252
263
  >
253
264
  {#if isFullscreen}
254
265
  <svg
255
- class="h-4 w-4"
266
+ class="h-5 w-5"
256
267
  fill="none"
257
268
  viewBox="0 0 24 24"
258
269
  stroke="currentColor"
@@ -266,7 +277,7 @@
266
277
  </svg>
267
278
  {:else}
268
279
  <svg
269
- class="h-4 w-4"
280
+ class="h-5 w-5"
270
281
  fill="none"
271
282
  viewBox="0 0 24 24"
272
283
  stroke="currentColor"
@@ -13,7 +13,7 @@
13
13
  </script>
14
14
 
15
15
  <nav aria-label="Breadcrumb" class="mb-6">
16
- <ol class="flex flex-wrap items-center gap-1 text-sm">
16
+ <ol class="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
17
17
  {#each items as item (item.href)}
18
18
  <li class="flex items-center">
19
19
  <a
@@ -22,10 +22,10 @@
22
22
  >
23
23
  {item.label}
24
24
  </a>
25
- <span class="mx-2 text-gray-400" aria-hidden="true">/</span>
25
+ <span class="mx-1 sm:mx-2 text-gray-400" aria-hidden="true">/</span>
26
26
  </li>
27
27
  {/each}
28
- <li class="text-gray-600 dark:text-gray-400" aria-current="page">
28
+ <li class="text-gray-700 dark:text-gray-300" aria-current="page">
29
29
  {current}
30
30
  </li>
31
31
  </ol>
@@ -12,6 +12,7 @@
12
12
 
13
13
  <a
14
14
  href="/domains/{domain.id}"
15
+ aria-label="View {domain.name} domain"
15
16
  class="block rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
16
17
  >
17
18
  <div class="mb-2">
@@ -39,7 +39,9 @@
39
39
  {#each navLinks as link (link.href)}
40
40
  <a
41
41
  href={link.href}
42
- class="rounded-md px-3 py-2 text-sm font-medium transition-colors {isActive(link.href)
42
+ class="rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 {isActive(
43
+ link.href
44
+ )
43
45
  ? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-200'
44
46
  : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white'}"
45
47
  >
@@ -10,6 +10,7 @@
10
10
  aria-label={navModeStore.mode === 'flat' ? 'Switch to tree view' : 'Switch to flat view'}
11
11
  title={navModeStore.mode === 'flat' ? 'Switch to tree view' : 'Switch to flat view'}
12
12
  >
13
+ <span class="sr-only" aria-live="polite">Navigation: {navModeStore.mode} view</span>
13
14
  {#if navModeStore.mode === 'flat'}
14
15
  <!-- List icon -->
15
16
  <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { SvelteSet } from 'svelte/reactivity';
2
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
3
3
  import type { Domain, UseCase, Service } from '@cwygoda/service-catalog-core/domain';
4
4
 
5
5
  interface Props {
@@ -30,28 +30,62 @@
30
30
  }
31
31
  }
32
32
 
33
- // Get root domains (no parent)
33
+ // Pre-computed lookup maps — O(1) per query instead of O(n) filter scans
34
34
  const rootDomains = $derived(domains.filter((d) => !d.parent));
35
35
 
36
- // Helper to get child domains
36
+ const childDomainMap = $derived.by(() => {
37
+ const map = new SvelteMap<string, Domain[]>();
38
+ for (const d of domains) {
39
+ if (d.parent) {
40
+ const arr = map.get(d.parent);
41
+ if (arr) arr.push(d);
42
+ else map.set(d.parent, [d]);
43
+ }
44
+ }
45
+ return map;
46
+ });
47
+
48
+ const domainUseCaseMap = $derived.by(() => {
49
+ const map = new SvelteMap<string, UseCase[]>();
50
+ for (const uc of useCases) {
51
+ if (!uc.domain) continue;
52
+ const arr = map.get(uc.domain);
53
+ if (arr) arr.push(uc);
54
+ else map.set(uc.domain, [uc]);
55
+ }
56
+ return map;
57
+ });
58
+
59
+ const domainServiceMap = $derived.by(() => {
60
+ const map = new SvelteMap<string, Service[]>();
61
+ for (const s of services) {
62
+ if (!s.domain) continue;
63
+ const arr = map.get(s.domain);
64
+ if (arr) arr.push(s);
65
+ else map.set(s.domain, [s]);
66
+ }
67
+ return map;
68
+ });
69
+
70
+ const serviceById = $derived.by(() => new SvelteMap(services.map((s) => [s.id, s] as const)));
71
+
72
+ // Thin wrappers — keep template call sites unchanged
37
73
  function getChildDomains(parentId: string): Domain[] {
38
- return domains.filter((d) => d.parent === parentId);
74
+ return childDomainMap.get(parentId) ?? [];
39
75
  }
40
76
 
41
- // Helper to get use cases for a domain
42
77
  function getDomainUseCases(domainId: string): UseCase[] {
43
- return useCases.filter((uc) => uc.domain === domainId);
78
+ return domainUseCaseMap.get(domainId) ?? [];
44
79
  }
45
80
 
46
- // Helper to get services for a domain
47
81
  function getDomainServices(domainId: string): Service[] {
48
- return services.filter((s) => s.domain === domainId);
82
+ return domainServiceMap.get(domainId) ?? [];
49
83
  }
50
84
 
51
- // Helper to get services for a use case
52
85
  function getUseCaseServices(useCase: UseCase): Service[] {
53
- const serviceIds = useCase.participants.map((p) => p.service);
54
- return services.filter((s) => serviceIds.includes(s.id));
86
+ return useCase.participants
87
+ .map((p) => serviceById.get(p.service))
88
+ .filter((s): s is Service => s !== undefined);
55
89
  }
56
90
  </script>
57
91
 
@@ -65,12 +99,12 @@
65
99
  onclick={() => {
66
100
  toggleDomain(domain.id);
67
101
  }}
68
- class="flex h-5 w-5 items-center justify-center rounded text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
102
+ class="flex min-h-11 min-w-11 items-center justify-center rounded text-gray-500 hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary-600 dark:text-gray-400 dark:hover:bg-gray-700"
69
103
  aria-expanded={expandedDomains.has(domain.id)}
70
104
  aria-label={expandedDomains.has(domain.id) ? 'Collapse' : 'Expand'}
71
105
  >
72
106
  <svg
73
- class="h-3 w-3 transition-transform {expandedDomains.has(domain.id)
107
+ class="h-4 w-4 transition-transform {expandedDomains.has(domain.id)
74
108
  ? 'rotate-90'
75
109
  : ''}"
76
110
  fill="currentColor"
@@ -115,12 +149,12 @@
115
149
  onclick={() => {
116
150
  toggleUseCase(useCase.id);
117
151
  }}
118
- class="flex h-5 w-5 items-center justify-center rounded text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
152
+ class="flex min-h-11 min-w-11 items-center justify-center rounded text-gray-400 hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary-600 dark:hover:bg-gray-700"
119
153
  aria-expanded={expandedUseCases.has(useCase.id)}
120
154
  aria-label={expandedUseCases.has(useCase.id) ? 'Collapse' : 'Expand'}
121
155
  >
122
156
  <svg
123
- class="h-3 w-3 transition-transform {expandedUseCases.has(useCase.id)
157
+ class="h-4 w-4 transition-transform {expandedUseCases.has(useCase.id)
124
158
  ? 'rotate-90'
125
159
  : ''}"
126
160
  fill="currentColor"
@@ -134,7 +168,7 @@
134
168
  </svg>
135
169
  </button>
136
170
  {:else}
137
- <span class="w-5"></span>
171
+ <span class="min-w-11"></span>
138
172
  {/if}
139
173
  <a
140
174
  href="/use-cases/{useCase.id}"
@@ -152,7 +186,7 @@
152
186
  <li>
153
187
  <a
154
188
  href="/services/{service.id}"
155
- class="block rounded px-2 py-1 text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
189
+ class="block rounded px-2 py-1 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
156
190
  >
157
191
  {service.name}
158
192
  </a>
@@ -168,7 +202,7 @@
168
202
  <li>
169
203
  <a
170
204
  href="/services/{service.id}"
171
- class="ml-5 block rounded px-2 py-1 text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
205
+ class="ml-5 block rounded px-2 py-1 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
172
206
  >
173
207
  {service.name}
174
208
  </a>
@@ -6,6 +6,7 @@
6
6
 
7
7
  <a
8
8
  href="/services/{service.id}"
9
+ aria-label="View {service.name} service"
9
10
  class="block rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
10
11
  >
11
12
  <div class="mb-2 flex items-center justify-between">
@@ -3,7 +3,9 @@
3
3
  import { browser } from '$app/environment';
4
4
  import { goto } from '$app/navigation';
5
5
  import type { GraphNode, GraphEdge } from '@cwygoda/service-catalog-core/domain';
6
- import type * as D3 from 'd3';
6
+ import type { Selection } from 'd3-selection';
7
+ import type { Simulation, SimulationNodeDatum } from 'd3-force';
8
+ import type { D3ZoomEvent } from 'd3-zoom';
7
9
 
8
10
  interface Props {
9
11
  nodes: GraphNode[];
@@ -14,22 +16,21 @@
14
16
 
15
17
  let { nodes, edges, height = 500, highlightedNodes }: Props = $props();
16
18
 
17
- // Domain color mapping
18
- const domainColors: Record<string, string> = {
19
- commerce: '#3b82f6', // blue
20
- platform: '#10b981', // green
21
- default: '#6b7280', // gray
22
- };
23
-
24
- function getDomainColor(domain?: string): string {
25
- const key = domain ?? 'default';
26
- return domainColors[key] ?? '#6b7280';
19
+ function getDomainFillClass(domain?: string): string {
20
+ switch (domain) {
21
+ case 'commerce':
22
+ return 'fill-blue-500 dark:fill-blue-400';
23
+ case 'platform':
24
+ return 'fill-green-500 dark:fill-green-400';
25
+ default:
26
+ return 'fill-gray-500 dark:fill-gray-400';
27
+ }
27
28
  }
28
29
 
29
30
  let wrapper: HTMLDivElement;
30
31
  let container: HTMLDivElement;
31
32
  let svg: SVGSVGElement | null = null;
32
- let simulation: D3.Simulation<D3.SimulationNodeDatum, undefined> | null = null;
33
+ let simulation: Simulation<SimulationNodeDatum, undefined> | null = null;
33
34
 
34
35
  // Simulation node type with position
35
36
  interface SimNode extends GraphNode {
@@ -47,12 +48,16 @@
47
48
  }
48
49
 
49
50
  // D3 selection refs for highlight updates
50
- let nodeSelection: D3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null = null;
51
- let linkSelection: D3.Selection<SVGLineElement, SimLink, SVGGElement, unknown> | null = null;
51
+ let nodeSelection: Selection<SVGGElement, SimNode, SVGGElement, unknown> | null = null;
52
+ let linkSelection: Selection<SVGLineElement, SimLink, SVGGElement, unknown> | null = null;
52
53
 
53
54
  // Tooltip state
54
55
  let tooltip = $state({ visible: false, x: 0, y: 0, content: '' });
55
56
 
57
+ // Responsive height — scales with container width on narrow screens
58
+ let responsiveHeight = $state(height);
59
+ let resizeObserver: ResizeObserver | null = null;
60
+
56
61
  // Fullscreen state
57
62
  let isFullscreen = $state(false);
58
63
 
@@ -73,16 +78,28 @@
73
78
  if (!browser) return;
74
79
 
75
80
  document.addEventListener('fullscreenchange', handleFullscreenChange);
76
-
77
- const d3 = await import('d3');
81
+ document.addEventListener('visibilitychange', handleVisibilityChange);
82
+
83
+ const [
84
+ { select },
85
+ { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide },
86
+ { zoom },
87
+ { drag },
88
+ ] = await Promise.all([
89
+ import('d3-selection'),
90
+ import('d3-force'),
91
+ import('d3-zoom'),
92
+ import('d3-drag'),
93
+ ]);
78
94
 
79
95
  const width = container.clientWidth;
96
+ responsiveHeight = Math.max(300, Math.min(width * 0.6, height));
80
97
 
81
98
  // Create simulation nodes with positions
82
99
  const simNodes: SimNode[] = nodes.map((n) => ({
83
100
  ...n,
84
101
  x: width / 2 + (Math.random() - 0.5) * 100,
85
- y: height / 2 + (Math.random() - 0.5) * 100,
102
+ y: responsiveHeight / 2 + (Math.random() - 0.5) * 100,
86
103
  }));
87
104
 
88
105
  // Create links referencing node objects
@@ -97,12 +114,16 @@
97
114
  }
98
115
 
99
116
  // Create SVG
100
- const svgEl = d3
101
- .select(container)
117
+ const svgEl = select(container)
102
118
  .append('svg')
103
119
  .attr('width', '100%')
104
- .attr('height', height)
105
- .attr('viewBox', `0 0 ${String(width)} ${String(height)}`);
120
+ .attr('height', responsiveHeight)
121
+ .attr('viewBox', `0 0 ${String(width)} ${String(responsiveHeight)}`)
122
+ .attr('role', 'img')
123
+ .attr(
124
+ 'aria-label',
125
+ `Service dependency graph with ${String(nodes.length)} services and ${String(edges.length)} connections`
126
+ );
106
127
 
107
128
  svg = svgEl.node();
108
129
 
@@ -125,14 +146,13 @@
125
146
  const g = svgEl.append('g');
126
147
 
127
148
  // Add zoom behavior
128
- const zoom = d3
129
- .zoom<SVGSVGElement, unknown>()
149
+ const zoomBehavior = zoom<SVGSVGElement, unknown>()
130
150
  .scaleExtent([0.25, 4])
131
- .on('zoom', (event: D3.D3ZoomEvent<SVGSVGElement, unknown>) => {
151
+ .on('zoom', (event: D3ZoomEvent<SVGSVGElement, unknown>) => {
132
152
  g.attr('transform', event.transform.toString());
133
153
  });
134
154
 
135
- svgEl.call(zoom);
155
+ svgEl.call(zoomBehavior);
136
156
 
137
157
  // Draw edges
138
158
  linkSelection = g
@@ -159,8 +179,7 @@
159
179
 
160
180
  // Add drag behavior
161
181
  nodeGroups.call(
162
- d3
163
- .drag<SVGGElement, SimNode>()
182
+ drag<SVGGElement, SimNode>()
164
183
  .on('start', (event, d) => {
165
184
  if (!event.active) simulation?.alphaTarget(0.3).restart();
166
185
  d.fx = d.x;
@@ -183,13 +202,16 @@
183
202
  nodeSelection
184
203
  .append('circle')
185
204
  .attr('r', 20)
186
- .attr('fill', (d) => getDomainColor(d.domain))
187
- .attr('class', 'stroke-2 stroke-white dark:stroke-gray-800 transition-opacity')
205
+ .attr(
206
+ 'class',
207
+ (d) =>
208
+ `${getDomainFillClass(d.domain)} stroke-2 stroke-white dark:stroke-gray-800 transition-opacity`
209
+ )
188
210
  .on('click', (_, d) => {
189
211
  void goto(`/services/${d.id}`);
190
212
  })
191
213
  .on('mouseenter', function (event: MouseEvent, d: SimNode) {
192
- d3.select(this).attr('r', 24);
214
+ select(this).attr('r', 24);
193
215
  tooltip = {
194
216
  visible: true,
195
217
  x: event.pageX,
@@ -202,7 +224,7 @@
202
224
  tooltip.y = event.pageY - 10;
203
225
  })
204
226
  .on('mouseleave', function () {
205
- d3.select(this).attr('r', 20);
227
+ select(this).attr('r', 20);
206
228
  tooltip.visible = false;
207
229
  });
208
230
 
@@ -218,31 +240,48 @@
218
240
  );
219
241
 
220
242
  // Force simulation - D3's forceSimulation has complex generics that don't match our SimNode
221
- simulation = d3
243
+ simulation =
222
244
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
223
- .forceSimulation(simNodes as any)
224
- .force(
225
- 'link',
226
- d3
227
- .forceLink(simLinks)
228
- .id((d) => (d as SimNode).id)
229
- .distance(120)
230
- )
231
- .force('charge', d3.forceManyBody().strength(-400))
232
- .force('center', d3.forceCenter(width / 2, height / 2))
233
- .force('collision', d3.forceCollide().radius(40))
234
- .on('tick', () => {
235
- linkSelection
236
- ?.attr('x1', (d) => d.source.x)
237
- .attr('y1', (d) => d.source.y)
238
- .attr('x2', (d) => d.target.x)
239
- .attr('y2', (d) => d.target.y);
240
-
241
- nodeSelection?.attr('transform', (d) => `translate(${String(d.x)},${String(d.y)})`);
242
- });
245
+ forceSimulation(simNodes as any)
246
+ .force(
247
+ 'link',
248
+ forceLink(simLinks)
249
+ .id((d) => (d as SimNode).id)
250
+ .distance(120)
251
+ )
252
+ .force('charge', forceManyBody().strength(-400))
253
+ .force('center', forceCenter(width / 2, responsiveHeight / 2))
254
+ .force('collision', forceCollide().radius(40))
255
+ .on('tick', () => {
256
+ linkSelection
257
+ ?.attr('x1', (d) => d.source.x)
258
+ .attr('y1', (d) => d.source.y)
259
+ .attr('x2', (d) => d.target.x)
260
+ .attr('y2', (d) => d.target.y);
261
+
262
+ nodeSelection?.attr('transform', (d) => `translate(${String(d.x)},${String(d.y)})`);
263
+ });
264
+
265
+ // Resize observer for responsive height
266
+ resizeObserver = new ResizeObserver((entries) => {
267
+ const entry = entries[0];
268
+ if (!entry) return;
269
+ const newWidth = entry.contentRect.width;
270
+ const newHeight = Math.max(300, Math.min(newWidth * 0.6, height));
271
+ if (Math.abs(newHeight - responsiveHeight) < 10) return;
272
+ responsiveHeight = newHeight;
273
+ svgEl
274
+ .attr('height', newHeight)
275
+ .attr('viewBox', `0 0 ${String(newWidth)} ${String(newHeight)}`);
276
+ simulation
277
+ ?.force('center', forceCenter(newWidth / 2, newHeight / 2))
278
+ .alpha(0.3)
279
+ .restart();
280
+ });
281
+ resizeObserver.observe(container);
243
282
  });
244
283
 
245
- // Effect to update highlighting when highlightedNodes changes
284
+ // Effect to update highlighting when highlightedNodes changes (CSS class-based)
246
285
  $effect(() => {
247
286
  if (!nodeSelection || !linkSelection) return;
248
287
 
@@ -252,24 +291,41 @@
252
291
  if (hasHighlight) {
253
292
  const highlightSet = new Set(highlighted);
254
293
 
255
- // Dim non-highlighted nodes
256
- nodeSelection.attr('opacity', (d) => (highlightSet.has(d.id) ? 1 : 0.2));
257
-
258
- // Dim edges not between highlighted nodes
259
- linkSelection.attr('opacity', (d) => {
260
- return highlightSet.has(d.source.id) && highlightSet.has(d.target.id) ? 1 : 0.1;
261
- });
294
+ nodeSelection
295
+ .classed('graph-node-dimmed', (d) => !highlightSet.has(d.id))
296
+ .classed('graph-full-opacity', (d) => highlightSet.has(d.id));
297
+
298
+ linkSelection
299
+ .classed(
300
+ 'graph-link-dimmed',
301
+ (d) => !(highlightSet.has(d.source.id) && highlightSet.has(d.target.id))
302
+ )
303
+ .classed(
304
+ 'graph-full-opacity',
305
+ (d) => highlightSet.has(d.source.id) && highlightSet.has(d.target.id)
306
+ );
262
307
  } else {
263
- // Reset all to full opacity
264
- nodeSelection.attr('opacity', 1);
265
- linkSelection.attr('opacity', 1);
308
+ nodeSelection.classed('graph-node-dimmed', false).classed('graph-full-opacity', false);
309
+ linkSelection.classed('graph-link-dimmed', false).classed('graph-full-opacity', false);
266
310
  }
267
311
  });
268
312
 
313
+ // Pause simulation when tab is hidden to save CPU
314
+ function handleVisibilityChange(): void {
315
+ if (!simulation) return;
316
+ if (document.hidden) {
317
+ simulation.stop();
318
+ } else if (simulation.alpha() > simulation.alphaMin()) {
319
+ simulation.restart();
320
+ }
321
+ }
322
+
269
323
  onDestroy(() => {
270
324
  if (browser) {
271
325
  document.removeEventListener('fullscreenchange', handleFullscreenChange);
326
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
272
327
  }
328
+ resizeObserver?.disconnect();
273
329
  simulation?.stop();
274
330
  svg?.remove();
275
331
  });
@@ -279,7 +335,7 @@
279
335
  <div
280
336
  bind:this={container}
281
337
  class="w-full rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
282
- style="height: {isFullscreen ? '100vh' : `${String(height)}px`}"
338
+ style="height: {isFullscreen ? '100vh' : `${String(responsiveHeight)}px`}"
283
339
  ></div>
284
340
 
285
341
  <!-- Tooltip -->
@@ -298,13 +354,13 @@
298
354
  >
299
355
  <button
300
356
  onclick={toggleFullscreen}
301
- class="rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
357
+ class="flex min-h-11 min-w-11 items-center justify-center rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
302
358
  aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
303
359
  title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
304
360
  >
305
361
  {#if isFullscreen}
306
362
  <!-- Exit fullscreen icon -->
307
- <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
363
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
308
364
  <path
309
365
  stroke-linecap="round"
310
366
  stroke-linejoin="round"
@@ -313,7 +369,7 @@
313
369
  </svg>
314
370
  {:else}
315
371
  <!-- Enter fullscreen icon -->
316
- <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
372
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
317
373
  <path
318
374
  stroke-linecap="round"
319
375
  stroke-linejoin="round"
@@ -329,20 +385,38 @@
329
385
  class="absolute bottom-3 left-3 flex flex-wrap gap-3 rounded-lg border border-gray-200 bg-white/90 px-3 py-2 text-xs backdrop-blur-sm dark:border-gray-600 dark:bg-gray-800/90"
330
386
  >
331
387
  <div class="flex items-center gap-1.5">
332
- <span class="h-3 w-3 rounded-full bg-blue-500"></span>
333
- <span class="text-gray-600 dark:text-gray-400">Commerce</span>
388
+ <span class="h-3 w-3 rounded-full bg-blue-500 dark:bg-blue-400"></span>
389
+ <span class="text-gray-700 dark:text-gray-300">Commerce</span>
334
390
  </div>
335
391
  <div class="flex items-center gap-1.5">
336
- <span class="h-3 w-3 rounded-full bg-green-500"></span>
337
- <span class="text-gray-600 dark:text-gray-400">Platform</span>
392
+ <span class="h-3 w-3 rounded-sm bg-green-500 dark:bg-green-400"></span>
393
+ <span class="text-gray-700 dark:text-gray-300">Platform</span>
338
394
  </div>
339
395
  <div class="flex items-center gap-1.5">
340
- <span class="mr-1 h-0.5 w-4 bg-gray-500"></span>
341
- <span class="text-gray-600 dark:text-gray-400">HTTP</span>
396
+ <span class="mr-1 h-0.5 w-4 bg-gray-500 dark:bg-gray-400"></span>
397
+ <span class="text-gray-700 dark:text-gray-300">HTTP</span>
342
398
  </div>
343
399
  <div class="flex items-center gap-1.5">
344
- <span class="mr-1 h-0.5 w-4 border-t-2 border-dashed border-gray-400"></span>
345
- <span class="text-gray-600 dark:text-gray-400">Event</span>
400
+ <span class="mr-1 h-0.5 w-4 border-t-2 border-dashed border-gray-400 dark:border-gray-500"
401
+ ></span>
402
+ <span class="text-gray-700 dark:text-gray-300">Event</span>
346
403
  </div>
347
404
  </div>
348
405
  </div>
406
+
407
+ <style>
408
+ :global(.graph-node-dimmed) {
409
+ opacity: 0.2;
410
+ transition: opacity 0.3s ease;
411
+ }
412
+
413
+ :global(.graph-link-dimmed) {
414
+ opacity: 0.1;
415
+ transition: opacity 0.3s ease;
416
+ }
417
+
418
+ :global(.graph-full-opacity) {
419
+ opacity: 1;
420
+ transition: opacity 0.3s ease;
421
+ }
422
+ </style>
@@ -9,6 +9,7 @@
9
9
  class="rounded-md p-2 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
10
10
  aria-label="Toggle theme"
11
11
  >
12
+ <span class="sr-only" aria-live="polite">Theme: {theme.resolved}</span>
12
13
  {#if theme.resolved === 'dark'}
13
14
  <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
14
15
  <path
@@ -11,6 +11,7 @@
11
11
 
12
12
  <a
13
13
  href="/use-cases/{useCase.id}"
14
+ aria-label="View {useCase.name} use case"
14
15
  class="block rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
15
16
  >
16
17
  <div class="mb-2 flex items-center justify-between">
@@ -19,7 +20,7 @@
19
20
  </h3>
20
21
  {#if useCase.bpmn}
21
22
  <span
22
- class="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200"
23
+ class="rounded-full bg-primary-100 px-2.5 py-0.5 text-xs font-medium text-primary-800 dark:bg-primary-900 dark:text-primary-200"
23
24
  >
24
25
  BPMN
25
26
  </span>
@@ -30,7 +31,7 @@
30
31
  {truncate(useCase.description, 120)}
31
32
  </p>
32
33
 
33
- <div class="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-500">
34
+ <div class="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
34
35
  <span class="flex items-center gap-1">
35
36
  <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
36
37
  <path
@@ -0,0 +1 @@
1
+ @source "./";
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@cwygoda/service-catalog-ui",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "svelte": "./dist/index.js",
6
6
  "exports": {
7
7
  ".": {
8
8
  "types": "./dist/index.d.ts",
9
+ "style": "./dist/source.css",
9
10
  "svelte": "./dist/index.js"
10
11
  },
11
12
  "./components": {
12
13
  "types": "./dist/components/index.d.ts",
14
+ "style": "./dist/source.css",
13
15
  "svelte": "./dist/components/index.js"
14
16
  },
15
17
  "./stores": {
16
18
  "types": "./dist/stores/index.d.ts",
19
+ "style": "./dist/source.css",
17
20
  "svelte": "./dist/stores/index.js"
18
21
  }
19
22
  },
@@ -22,12 +25,15 @@
22
25
  ],
23
26
  "dependencies": {
24
27
  "@sinclair/typebox": "^0.34.0",
25
- "@cwygoda/service-catalog-core": "1.0.1"
28
+ "@cwygoda/service-catalog-core": "1.0.3"
26
29
  },
27
30
  "peerDependencies": {
28
31
  "@sveltejs/kit": "^2.0.0",
29
32
  "bpmn-js": "^18.0.0",
30
- "d3": "^7.0.0",
33
+ "d3-drag": "^3.0.0",
34
+ "d3-force": "^3.0.0",
35
+ "d3-selection": "^3.0.0",
36
+ "d3-zoom": "^3.0.0",
31
37
  "svelte": "^5.0.0"
32
38
  },
33
39
  "devDependencies": {
@@ -37,7 +43,10 @@
37
43
  "@testing-library/jest-dom": "^6.9.1",
38
44
  "@testing-library/svelte": "^5.2.0",
39
45
  "@testing-library/user-event": "^14.6.1",
40
- "@types/d3": "^7.4.3",
46
+ "@types/d3-drag": "^3.0.0",
47
+ "@types/d3-force": "^3.0.0",
48
+ "@types/d3-selection": "^3.0.0",
49
+ "@types/d3-zoom": "^3.0.0",
41
50
  "jsdom": "^26.0.0",
42
51
  "svelte": "^5.49.2",
43
52
  "svelte-check": "^4.3.6",