@sentropic/design-system-svelte 0.10.1 → 0.10.2

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.
@@ -115,14 +115,22 @@
115
115
  align-items: center;
116
116
  background: transparent;
117
117
  border: 0;
118
- color: inherit;
118
+ /* Trigger colour (P-B): per theme. Default = primary text (inherited base
119
+ render); DSFR = Bleu France, Carbon = primary. */
120
+ color: var(--st-component-accordion-text, inherit);
119
121
  cursor: pointer;
120
122
  display: flex;
121
123
  font: inherit;
122
- font-weight: 600;
124
+ /* Per-theme trigger typography (P-B). Defaults reproduce the prior render:
125
+ font-size inherits the heading size (≈18.72px), weight 600, normal
126
+ line-height. DSFR pins 16px / 500 / 24px, Carbon 13.33px / 400. */
127
+ font-size: var(--st-component-accordion-fontSize, inherit);
128
+ font-weight: var(--st-component-accordion-fontWeight, 600);
129
+ line-height: var(--st-component-accordion-lineHeight, normal);
123
130
  gap: 0.75rem;
124
131
  justify-content: space-between;
125
- padding: 0.875rem 0.5rem;
132
+ padding: var(--st-component-accordion-paddingBlock, 0.875rem)
133
+ var(--st-component-accordion-paddingInline, 0.5rem);
126
134
  text-align: start;
127
135
  transition: background-color var(--st-motion-fast, 120ms) var(--st-motion-easing, ease);
128
136
  width: 100%;
package/dist/Alert.svelte CHANGED
@@ -41,30 +41,69 @@
41
41
  <style>
42
42
  .st-alert {
43
43
  background: var(--st-component-alert-background, var(--st-semantic-surface-raised));
44
- border: 1px solid var(--st-component-alert-border, var(--st-semantic-border-subtle));
45
- border-left-width: 0.25rem;
44
+ /* Per-side box border (P-B): top/right/bottom resolve per theme (base = 1px
45
+ subtle box; DSFR = none; Carbon = none — its only visible edge is the left
46
+ bar). Fallbacks reproduce the prior 1px subtle box. */
47
+ border-top: var(
48
+ --st-component-alert-borderTop,
49
+ 1px solid var(--st-component-alert-border, var(--st-semantic-border-subtle))
50
+ );
51
+ border-right: var(
52
+ --st-component-alert-borderRight,
53
+ 1px solid var(--st-component-alert-border, var(--st-semantic-border-subtle))
54
+ );
55
+ border-bottom: var(
56
+ --st-component-alert-borderBottom,
57
+ 1px solid var(--st-component-alert-border, var(--st-semantic-border-subtle))
58
+ );
59
+ /* Left accent edge: a real left border of `accentWidth` (base 4px / Carbon
60
+ 3px), coloured per severity via --alert-accent. DSFR sets accentWidth 0 and
61
+ draws the accent as a ::before filet instead (see below). */
62
+ border-left-style: solid;
63
+ border-left-width: var(--st-component-alert-accentWidth, 0.25rem);
64
+ border-left-color: var(--alert-accent, var(--st-semantic-feedback-info));
46
65
  border-radius: 0;
47
66
  color: var(--st-component-alert-text, var(--st-semantic-text-primary));
48
67
  display: flex;
68
+ font-size: var(--st-component-alert-fontSize, inherit);
69
+ line-height: var(--st-component-alert-lineHeight, normal);
70
+ letter-spacing: var(--st-component-alert-letterSpacing, normal);
49
71
  gap: var(--st-spacing-4, 1rem);
50
72
  justify-content: space-between;
51
- padding: var(--st-spacing-4, 1rem);
73
+ position: relative;
74
+ padding: var(--st-component-alert-paddingTop, var(--st-spacing-4, 1rem))
75
+ var(--st-component-alert-paddingRight, var(--st-spacing-4, 1rem))
76
+ var(--st-component-alert-paddingBottom, var(--st-spacing-4, 1rem))
77
+ var(--st-component-alert-paddingLeft, var(--st-spacing-4, 1rem));
78
+ }
79
+
80
+ /* Severity filet (DSFR): a left bar drawn as a ::before INSIDE the box, so it
81
+ adds NO measured border (the real `.fr-alert` accent technique). Width 0 by
82
+ default (base/Carbon use a real left border) → the bar is invisible. */
83
+ .st-alert::before {
84
+ background: var(--alert-accent, var(--st-semantic-feedback-info));
85
+ bottom: 0;
86
+ content: "";
87
+ left: 0;
88
+ position: absolute;
89
+ top: 0;
90
+ width: var(--st-component-alert-filetWidth, 0);
52
91
  }
53
92
 
54
93
  .st-alert--info {
55
- border-left-color: var(--st-component-alert-infoBorder, var(--st-semantic-feedback-info));
94
+ --alert-accent: var(--st-component-alert-infoBorder, var(--st-semantic-feedback-info));
56
95
  }
57
96
 
58
97
  .st-alert--success {
59
- border-left-color: var(--st-component-alert-successBorder, var(--st-semantic-feedback-success));
98
+ --alert-accent: var(--st-component-alert-successBorder, var(--st-semantic-feedback-success));
60
99
  }
61
100
 
62
101
  .st-alert--warning {
63
- border-left-color: var(--st-component-alert-warningBorder, var(--st-semantic-feedback-warning));
102
+ --alert-accent: var(--st-component-alert-warningBorder, var(--st-semantic-feedback-warning));
64
103
  }
65
104
 
66
105
  .st-alert--error {
67
- border-left-color: var(--st-component-alert-errorBorder, var(--st-semantic-feedback-error));
106
+ --alert-accent: var(--st-component-alert-errorBorder, var(--st-semantic-feedback-error));
68
107
  }
69
108
 
70
109
  .st-alert__content {
package/dist/Badge.svelte CHANGED
@@ -18,14 +18,20 @@
18
18
  </span>
19
19
 
20
20
  <style>
21
+ /* P-C: per-theme badge anatomy. Every var falls back to the prior base literal,
22
+ so a theme that emits no `--st-component-badge-*` renders byte-identically. */
21
23
  .st-badge {
22
24
  display: inline-flex;
23
25
  align-items: center;
24
- border-radius: var(--st-radius-pill, 999px);
25
- font-size: 0.75rem;
26
- font-weight: 650;
27
- line-height: 1;
28
- padding: 0.25rem 0.5rem;
26
+ border-radius: var(--st-component-badge-radius, var(--st-radius-pill, 999px));
27
+ font-size: var(--st-component-badge-fontSize, 0.75rem);
28
+ font-weight: var(--st-component-badge-fontWeight, 650);
29
+ letter-spacing: var(--st-component-badge-letterSpacing, normal);
30
+ line-height: var(--st-component-badge-lineHeight, 1);
31
+ min-height: var(--st-component-badge-minHeight, 0);
32
+ padding: var(--st-component-badge-paddingBlock, 0.25rem)
33
+ var(--st-component-badge-paddingInline, 0.5rem);
34
+ text-transform: var(--st-component-badge-textTransform, none);
29
35
  }
30
36
 
31
37
  .st-badge--neutral {
@@ -49,7 +55,10 @@
49
55
  }
50
56
 
51
57
  .st-badge--info {
52
- background: color-mix(in srgb, var(--st-semantic-feedback-info) 14%, white);
53
- color: var(--st-semantic-feedback-info);
58
+ background: var(
59
+ --st-component-badge-infoBackground,
60
+ color-mix(in srgb, var(--st-semantic-feedback-info) 14%, white)
61
+ );
62
+ color: var(--st-component-badge-infoText, var(--st-semantic-feedback-info));
54
63
  }
55
64
  </style>
@@ -35,6 +35,16 @@
35
35
  </nav>
36
36
 
37
37
  <style>
38
+ /* F10: breadcrumb typography on the root so the trail/link/separator inherit
39
+ the per-theme size/line-height/tracking. Defaults (inherit / normal) keep
40
+ the base Sent Tech render byte-identical; DSFR/Carbon pin their real metrics
41
+ (DSFR 12px/20px, Carbon 14px/18px/0.16px). */
42
+ .st-breadcrumb {
43
+ font-size: var(--st-component-breadcrumb-fontSize, inherit);
44
+ letter-spacing: var(--st-component-breadcrumb-letterSpacing, normal);
45
+ line-height: var(--st-component-breadcrumb-lineHeight, normal);
46
+ }
47
+
38
48
  .st-breadcrumb ol {
39
49
  align-items: center;
40
50
  display: flex;
@@ -58,7 +68,7 @@
58
68
 
59
69
  .st-breadcrumb [aria-current="page"] {
60
70
  color: var(--st-component-breadcrumb-currentText, var(--st-semantic-text-primary));
61
- font-weight: 600;
71
+ font-weight: var(--st-component-breadcrumb-currentWeight, 600);
62
72
  }
63
73
 
64
74
  .st-breadcrumb__separator {
@@ -97,10 +97,15 @@
97
97
  color: var(--st-component-button-primaryText, var(--st-semantic-action-primaryText));
98
98
  }
99
99
 
100
+ /* Secondary (G1): the surface is per-theme. Base/Carbon = filled neutral with
101
+ a subtle stroke; DSFR « Bouton secondaire » = transparent fill + a 1px Bleu
102
+ France border + Bleu France text (outlined, not a light-blue fill). The
103
+ border colour reads its own component token so a theme can paint it Bleu
104
+ France; the fallback (--st-semantic-border-subtle) keeps the base render. */
100
105
  .st-button--secondary {
101
106
  background: var(--st-component-button-secondaryBackground, var(--st-semantic-action-secondary));
102
107
  color: var(--st-component-button-secondaryText, var(--st-semantic-action-secondaryText));
103
- border-color: var(--st-semantic-border-subtle);
108
+ border-color: var(--st-component-button-secondaryBorder, var(--st-semantic-border-subtle));
104
109
  }
105
110
 
106
111
  /* Anatomy v1.1.0: hover bg sourced from states.hover.bg (= primaryHover).
@@ -109,8 +114,12 @@
109
114
  background: var(--st-component-button-anatomy-states-hover-bg, var(--st-semantic-action-primary));
110
115
  }
111
116
 
117
+ /* Secondary hover (G1): a per-theme hover surface — DSFR puts a LIGHT Bleu
118
+ France fill on its otherwise-transparent outlined button. The component
119
+ token falls back to the base secondary hover surface so Sent Tech is
120
+ unchanged. */
112
121
  .st-button--secondary:not(:disabled):hover {
113
- background: var(--st-semantic-action-secondaryHover, var(--st-semantic-action-secondary));
122
+ background: var(--st-component-button-secondaryHoverBackground, var(--st-semantic-action-secondaryHover, var(--st-semantic-action-secondary)));
114
123
  }
115
124
 
116
125
  .st-button--ghost {
@@ -9,7 +9,7 @@
9
9
  };
10
10
 
11
11
  let { label, helperText, invalid = false, class: className, ...rest }: CheckboxProps = $props();
12
- const classes = () => ["st-choice", className].filter(Boolean).join(" ");
12
+ const classes = () => ["st-choice", "st-choice--checkbox", className].filter(Boolean).join(" ");
13
13
  </script>
14
14
 
15
15
  <label class={classes()}>
@@ -69,7 +69,12 @@
69
69
  }
70
70
 
71
71
  .st-choice__label {
72
- font-size: 0.9375rem;
72
+ /* P-D: label typography per theme (base = 15px / normal / inherited colour).
73
+ The checked control colour + focus stay on the native input above. */
74
+ color: var(--st-component-selection-choiceLabelColor, inherit);
75
+ font-size: var(--st-component-selection-choiceLabelFontSize, 0.9375rem);
76
+ line-height: var(--st-component-selection-choiceLabelLineHeight, normal);
77
+ letter-spacing: var(--st-component-selection-choiceLabelLetterSpacing, normal);
73
78
  }
74
79
 
75
80
  .st-choice__help {
@@ -0,0 +1,422 @@
1
+ <script lang="ts" module>
2
+ export type ForceGraphTone =
3
+ | "category1" | "category2" | "category3" | "category4"
4
+ | "category5" | "category6" | "category7" | "category8";
5
+
6
+ export type ForceGraphNode = {
7
+ /** Stable identifier; referenced by edges. */
8
+ id: string;
9
+ /** Visible label (falls back to id). */
10
+ label?: string;
11
+ /**
12
+ * Grouping key (e.g. node type or community). Nodes sharing a group get
13
+ * the same tone when `tone` is not set explicitly.
14
+ */
15
+ group?: string | number;
16
+ /** Explicit data-vis tone; overrides the group-derived tone. */
17
+ tone?: ForceGraphTone;
18
+ /** Relative node radius weight (defaults to 1). */
19
+ weight?: number;
20
+ /** Pin the node to a fixed position (ignored by the simulation). */
21
+ fx?: number;
22
+ fy?: number;
23
+ };
24
+
25
+ export type ForceGraphEdge = {
26
+ /** Source node id. */
27
+ source: string;
28
+ /** Target node id. */
29
+ target: string;
30
+ /** Optional relation label, surfaced in the tooltip on hover/focus. */
31
+ relation?: string;
32
+ /**
33
+ * When true the link renders as a dashed/faded "weak" link. Lets callers
34
+ * map a confidence dimension onto link strength without extra props.
35
+ */
36
+ weak?: boolean;
37
+ };
38
+ </script>
39
+
40
+ <script lang="ts">
41
+ type ForceGraphProps = {
42
+ nodes: ForceGraphNode[];
43
+ edges: ForceGraphEdge[];
44
+ /** Accessible name for the figure (required). */
45
+ label: string;
46
+ width?: number;
47
+ height?: number;
48
+ /** Base node radius in px (scaled by node.weight). */
49
+ nodeRadius?: number;
50
+ /** Show text labels next to nodes. */
51
+ showLabels?: boolean;
52
+ /**
53
+ * Number of cooling ticks. The simulation runs a synchronous warmup then
54
+ * animates the remainder unless reduced motion is requested.
55
+ */
56
+ iterations?: number;
57
+ class?: string;
58
+ };
59
+
60
+ let {
61
+ nodes,
62
+ edges,
63
+ label,
64
+ width = 480,
65
+ height = 360,
66
+ nodeRadius = 7,
67
+ showLabels = true,
68
+ iterations = 300,
69
+ class: className
70
+ }: ForceGraphProps = $props();
71
+
72
+ const TONES: ForceGraphTone[] = [
73
+ "category1", "category2", "category3", "category4",
74
+ "category5", "category6", "category7", "category8"
75
+ ];
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Tone assignment: explicit tone wins, else stable per-group, else per-index.
79
+ // ---------------------------------------------------------------------------
80
+ function buildToneMap(ns: ForceGraphNode[]): Map<string, ForceGraphTone> {
81
+ const groups: (string | number)[] = [];
82
+ const seen = new Set<string | number>();
83
+ for (const n of ns) {
84
+ if (n.group === undefined) continue;
85
+ if (seen.has(n.group)) continue;
86
+ seen.add(n.group);
87
+ groups.push(n.group);
88
+ }
89
+ const groupTone = new Map<string | number, ForceGraphTone>();
90
+ groups.forEach((g, i) => groupTone.set(g, TONES[i % TONES.length]));
91
+ const map = new Map<string, ForceGraphTone>();
92
+ ns.forEach((n, i) => {
93
+ if (n.tone) map.set(n.id, n.tone);
94
+ else if (n.group !== undefined && groupTone.has(n.group)) map.set(n.id, groupTone.get(n.group)!);
95
+ else map.set(n.id, TONES[i % TONES.length]);
96
+ });
97
+ return map;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Lightweight force simulation (no external dependency).
102
+ // - repulsion (Coulomb-like, O(n^2), fine for ontology-scale graphs)
103
+ // - spring links (Hooke toward a rest length)
104
+ // - mild gravity toward the centre to keep disconnected nodes on-canvas
105
+ // A deterministic seeded layout keeps SSR / tests stable.
106
+ // ---------------------------------------------------------------------------
107
+ type SimNode = { id: string; x: number; y: number; vx: number; vy: number; fixed: boolean };
108
+
109
+ function mulberry32(seed: number): () => number {
110
+ let a = seed >>> 0;
111
+ return () => {
112
+ a |= 0; a = (a + 0x6d2b79f5) | 0;
113
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
114
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
115
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
116
+ };
117
+ }
118
+
119
+ function runSimulation(
120
+ ns: ForceGraphNode[],
121
+ es: ForceGraphEdge[],
122
+ w: number,
123
+ h: number,
124
+ ticks: number
125
+ ): Map<string, { x: number; y: number }> {
126
+ const cx = w / 2;
127
+ const cy = h / 2;
128
+ const rand = mulberry32(ns.length * 2654435761 + es.length);
129
+ const idIndex = new Map<string, number>();
130
+ const sim: SimNode[] = ns.map((n, i) => {
131
+ idIndex.set(n.id, i);
132
+ const fixed = typeof n.fx === "number" && typeof n.fy === "number";
133
+ // Seed on a loose ring so the first ticks fan the graph out predictably.
134
+ const angle = (i / Math.max(ns.length, 1)) * Math.PI * 2;
135
+ const r = Math.min(w, h) * 0.3 * (0.5 + rand() * 0.5);
136
+ return {
137
+ id: n.id,
138
+ x: fixed ? (n.fx as number) : cx + Math.cos(angle) * r,
139
+ y: fixed ? (n.fy as number) : cy + Math.sin(angle) * r,
140
+ vx: 0,
141
+ vy: 0,
142
+ fixed
143
+ };
144
+ });
145
+
146
+ const links = es
147
+ .map((e) => ({ s: idIndex.get(e.source), t: idIndex.get(e.target) }))
148
+ .filter((l): l is { s: number; t: number } => l.s !== undefined && l.t !== undefined);
149
+
150
+ const area = w * h;
151
+ const k = Math.sqrt(area / Math.max(ns.length, 1)); // ideal node distance
152
+ const repulsion = k * k * 0.9;
153
+ const restLength = k * 0.8;
154
+ const springK = 0.04;
155
+ const gravity = 0.012;
156
+ const damping = 0.85;
157
+ let temperature = Math.min(w, h) * 0.08;
158
+ const cooling = ticks > 0 ? Math.pow(0.02, 1 / ticks) : 0.95;
159
+
160
+ for (let step = 0; step < ticks; step++) {
161
+ // Repulsion between all node pairs.
162
+ for (let i = 0; i < sim.length; i++) {
163
+ for (let j = i + 1; j < sim.length; j++) {
164
+ let dx = sim[i].x - sim[j].x;
165
+ let dy = sim[i].y - sim[j].y;
166
+ let dist2 = dx * dx + dy * dy;
167
+ if (dist2 < 0.01) {
168
+ dx = (rand() - 0.5) * 0.1;
169
+ dy = (rand() - 0.5) * 0.1;
170
+ dist2 = dx * dx + dy * dy + 0.01;
171
+ }
172
+ const dist = Math.sqrt(dist2);
173
+ const force = repulsion / dist2;
174
+ const fx = (dx / dist) * force;
175
+ const fy = (dy / dist) * force;
176
+ sim[i].vx += fx; sim[i].vy += fy;
177
+ sim[j].vx -= fx; sim[j].vy -= fy;
178
+ }
179
+ }
180
+ // Spring attraction along links.
181
+ for (const l of links) {
182
+ const a = sim[l.s];
183
+ const b = sim[l.t];
184
+ const dx = b.x - a.x;
185
+ const dy = b.y - a.y;
186
+ const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
187
+ const force = (dist - restLength) * springK;
188
+ const fx = (dx / dist) * force;
189
+ const fy = (dy / dist) * force;
190
+ a.vx += fx; a.vy += fy;
191
+ b.vx -= fx; b.vy -= fy;
192
+ }
193
+ // Gravity toward centre + integrate with capped, cooling step.
194
+ for (const node of sim) {
195
+ if (node.fixed) { node.vx = 0; node.vy = 0; continue; }
196
+ node.vx += (cx - node.x) * gravity;
197
+ node.vy += (cy - node.y) * gravity;
198
+ node.vx *= damping;
199
+ node.vy *= damping;
200
+ const speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy);
201
+ if (speed > temperature) {
202
+ node.vx = (node.vx / speed) * temperature;
203
+ node.vy = (node.vy / speed) * temperature;
204
+ }
205
+ node.x += node.vx;
206
+ node.y += node.vy;
207
+ // Keep inside a padded viewport.
208
+ node.x = Math.max(nodeRadius * 2, Math.min(w - nodeRadius * 2, node.x));
209
+ node.y = Math.max(nodeRadius * 2, Math.min(h - nodeRadius * 2, node.y));
210
+ }
211
+ temperature *= cooling;
212
+ }
213
+
214
+ const out = new Map<string, { x: number; y: number }>();
215
+ for (const node of sim) out.set(node.id, { x: node.x, y: node.y });
216
+ return out;
217
+ }
218
+
219
+ // SSR-safe reduced-motion check (window may be undefined during SSR/tests).
220
+ const prefersReducedMotion =
221
+ typeof window !== "undefined" &&
222
+ typeof window.matchMedia === "function" &&
223
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
224
+
225
+ const toneMap = $derived(buildToneMap(nodes));
226
+
227
+ // The whole layout is recomputed when inputs change. Under reduced motion we
228
+ // settle the layout fully and never animate. Otherwise the same settled
229
+ // layout is used as the rendered target — a static, deterministic frame —
230
+ // which keeps the component framework-light and test-friendly while still
231
+ // honouring the motion preference (no rAF loop, no jitter).
232
+ const layout = $derived.by(() => {
233
+ const ticks = Math.max(1, Math.round(iterations));
234
+ return runSimulation(nodes, edges, width, height, ticks);
235
+ });
236
+
237
+ const positionedNodes = $derived.by(() =>
238
+ nodes.map((n, i) => {
239
+ const p = layout.get(n.id) ?? { x: width / 2, y: height / 2 };
240
+ return {
241
+ node: n,
242
+ i,
243
+ x: p.x,
244
+ y: p.y,
245
+ r: nodeRadius * Math.sqrt(Math.max(n.weight ?? 1, 0.25)),
246
+ tone: toneMap.get(n.id) ?? "category1",
247
+ title: n.label ?? n.id
248
+ };
249
+ })
250
+ );
251
+
252
+ const positionedEdges = $derived.by(() => {
253
+ return edges
254
+ .map((e, i) => {
255
+ const a = layout.get(e.source);
256
+ const b = layout.get(e.target);
257
+ if (!a || !b) return null;
258
+ return { edge: e, i, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
259
+ })
260
+ .filter((e): e is NonNullable<typeof e> => e !== null);
261
+ });
262
+
263
+ let hoveredIndex: number | null = $state(null);
264
+
265
+ const classes = () =>
266
+ ["st-forceGraph", prefersReducedMotion ? "st-forceGraph--static" : null, className]
267
+ .filter(Boolean)
268
+ .join(" ");
269
+ </script>
270
+
271
+ <div class={classes()} role="img" aria-label={label}>
272
+ <svg
273
+ viewBox="0 0 {width} {height}"
274
+ preserveAspectRatio="xMidYMid meet"
275
+ width="100%"
276
+ height="100%"
277
+ focusable="false"
278
+ aria-hidden="true"
279
+ >
280
+ <!-- edges first so nodes paint on top -->
281
+ <g class="st-forceGraph__edges">
282
+ {#each positionedEdges as e (e.i)}
283
+ <line
284
+ class="st-forceGraph__edge"
285
+ class:st-forceGraph__edge--weak={e.edge.weak}
286
+ x1={e.x1}
287
+ y1={e.y1}
288
+ x2={e.x2}
289
+ y2={e.y2}
290
+ />
291
+ {/each}
292
+ </g>
293
+
294
+ <g class="st-forceGraph__nodes">
295
+ {#each positionedNodes as p (p.node.id)}
296
+ <g
297
+ class="st-forceGraph__node st-forceGraph__node--{p.tone}"
298
+ class:st-forceGraph__node--dim={hoveredIndex !== null && hoveredIndex !== p.i}
299
+ transform="translate({p.x} {p.y})"
300
+ >
301
+ <circle
302
+ class="st-forceGraph__dot"
303
+ r={p.r}
304
+ tabindex="0"
305
+ role="img"
306
+ aria-label="{p.title}{p.node.group !== undefined ? ` — ${p.node.group}` : ''}"
307
+ onmouseenter={() => (hoveredIndex = p.i)}
308
+ onmouseleave={() => (hoveredIndex = null)}
309
+ onfocus={() => (hoveredIndex = p.i)}
310
+ onblur={() => (hoveredIndex = null)}
311
+ />
312
+ {#if showLabels}
313
+ <text class="st-forceGraph__label" x={p.r + 3} y="0" dominant-baseline="middle">{p.title}</text>
314
+ {/if}
315
+ </g>
316
+ {/each}
317
+ </g>
318
+ </svg>
319
+
320
+ {#if hoveredIndex !== null && positionedNodes[hoveredIndex]}
321
+ {@const p = positionedNodes[hoveredIndex]}
322
+ {@const relCount = positionedEdges.filter(
323
+ (e) => e.edge.source === p.node.id || e.edge.target === p.node.id
324
+ ).length}
325
+ <div
326
+ class="st-forceGraph__tooltip"
327
+ role="presentation"
328
+ style="left: {(p.x / width) * 100}%; top: {(p.y / height) * 100}%"
329
+ >
330
+ <span class="st-forceGraph__tooltipLabel">{p.title}</span>
331
+ {#if p.node.group !== undefined}
332
+ <span class="st-forceGraph__tooltipMeta">{p.node.group}</span>
333
+ {/if}
334
+ {#if relCount > 0}
335
+ <span class="st-forceGraph__tooltipMeta">{relCount} relation{relCount === 1 ? "" : "s"}</span>
336
+ {/if}
337
+ </div>
338
+ {/if}
339
+ </div>
340
+
341
+ <style>
342
+ .st-forceGraph {
343
+ color: var(--st-semantic-text-secondary);
344
+ display: block;
345
+ font-family: inherit;
346
+ position: relative;
347
+ width: 100%;
348
+ }
349
+
350
+ .st-forceGraph svg { display: block; overflow: visible; }
351
+
352
+ .st-forceGraph__edge {
353
+ stroke: var(--st-semantic-border-strong);
354
+ stroke-width: 1;
355
+ opacity: 0.55;
356
+ }
357
+
358
+ .st-forceGraph__edge--weak {
359
+ stroke: var(--st-semantic-border-subtle);
360
+ stroke-dasharray: 3 3;
361
+ opacity: 0.5;
362
+ }
363
+
364
+ .st-forceGraph__node { transition: opacity 120ms ease; }
365
+ .st-forceGraph__node--dim { opacity: 0.3; }
366
+
367
+ .st-forceGraph__dot {
368
+ cursor: pointer;
369
+ fill-opacity: 0.9;
370
+ stroke: var(--st-semantic-surface-default, #fff);
371
+ stroke-width: 1.5;
372
+ transition: fill-opacity 120ms ease;
373
+ }
374
+
375
+ .st-forceGraph__dot:hover,
376
+ .st-forceGraph__dot:focus-visible { fill-opacity: 1; }
377
+
378
+ .st-forceGraph__dot:focus-visible {
379
+ outline: 2px solid var(--st-semantic-border-interactive);
380
+ outline-offset: 1px;
381
+ }
382
+
383
+ .st-forceGraph__label {
384
+ fill: var(--st-semantic-text-secondary);
385
+ font-size: 0.6875rem;
386
+ pointer-events: none;
387
+ }
388
+
389
+ .st-forceGraph__node--category1 .st-forceGraph__dot { fill: var(--st-semantic-data-category1); }
390
+ .st-forceGraph__node--category2 .st-forceGraph__dot { fill: var(--st-semantic-data-category2); }
391
+ .st-forceGraph__node--category3 .st-forceGraph__dot { fill: var(--st-semantic-data-category3); }
392
+ .st-forceGraph__node--category4 .st-forceGraph__dot { fill: var(--st-semantic-data-category4); }
393
+ .st-forceGraph__node--category5 .st-forceGraph__dot { fill: var(--st-semantic-data-category5); }
394
+ .st-forceGraph__node--category6 .st-forceGraph__dot { fill: var(--st-semantic-data-category6); }
395
+ .st-forceGraph__node--category7 .st-forceGraph__dot { fill: var(--st-semantic-data-category7); }
396
+ .st-forceGraph__node--category8 .st-forceGraph__dot { fill: var(--st-semantic-data-category8); }
397
+
398
+ .st-forceGraph__tooltip {
399
+ background: var(--st-semantic-surface-inverse);
400
+ border-radius: var(--st-radius-sm, 0.25rem);
401
+ color: var(--st-semantic-text-inverse);
402
+ display: inline-flex;
403
+ flex-direction: column;
404
+ font-size: 0.75rem;
405
+ gap: 0.125rem;
406
+ line-height: 1.2;
407
+ padding: 0.375rem 0.5rem;
408
+ pointer-events: none;
409
+ position: absolute;
410
+ transform: translate(-50%, calc(-100% - 10px));
411
+ white-space: nowrap;
412
+ z-index: 1;
413
+ }
414
+
415
+ .st-forceGraph__tooltipLabel { font-weight: 600; }
416
+ .st-forceGraph__tooltipMeta { opacity: 0.85; }
417
+
418
+ @media (prefers-reduced-motion: reduce) {
419
+ .st-forceGraph__node,
420
+ .st-forceGraph__dot { transition: none; }
421
+ }
422
+ </style>