@genesislcap/ai-assistant 14.458.3 → 14.460.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/ai-assistant.api.json +38 -11
  2. package/dist/ai-assistant.d.ts +49 -5
  3. package/dist/dts/components/chat-driver/chat-driver.d.ts +3 -1
  4. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  5. package/dist/dts/components/flowing-waves-indicator.d.ts +32 -0
  6. package/dist/dts/components/flowing-waves-indicator.d.ts.map +1 -0
  7. package/dist/dts/components/plasma-orb-indicator.d.ts +22 -0
  8. package/dist/dts/components/plasma-orb-indicator.d.ts.map +1 -0
  9. package/dist/dts/components/waves-indicator.d.ts +30 -0
  10. package/dist/dts/components/waves-indicator.d.ts.map +1 -0
  11. package/dist/dts/main/main.d.ts.map +1 -1
  12. package/dist/dts/main/main.styles.d.ts.map +1 -1
  13. package/dist/dts/main/main.template.d.ts.map +1 -1
  14. package/dist/dts/main/main.types.d.ts +44 -4
  15. package/dist/dts/main/main.types.d.ts.map +1 -1
  16. package/dist/dts/utils/animation-exclusivity.d.ts +23 -0
  17. package/dist/dts/utils/animation-exclusivity.d.ts.map +1 -0
  18. package/dist/dts/utils/animation-exclusivity.test.d.ts +2 -0
  19. package/dist/dts/utils/animation-exclusivity.test.d.ts.map +1 -0
  20. package/dist/esm/components/chat-driver/chat-driver.js +7 -2
  21. package/dist/esm/components/chat-driver/chat-driver.test.js +24 -0
  22. package/dist/esm/components/flowing-waves-indicator.js +222 -0
  23. package/dist/esm/components/plasma-orb-indicator.js +280 -0
  24. package/dist/esm/components/waves-indicator.js +189 -0
  25. package/dist/esm/main/main.js +20 -9
  26. package/dist/esm/main/main.styles.js +62 -7
  27. package/dist/esm/main/main.template.js +75 -21
  28. package/dist/esm/main/main.types.js +46 -3
  29. package/dist/esm/utils/animation-exclusivity.js +33 -0
  30. package/dist/esm/utils/animation-exclusivity.test.js +52 -0
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +16 -16
  33. package/src/components/chat-driver/chat-driver.test.ts +36 -0
  34. package/src/components/chat-driver/chat-driver.ts +10 -2
  35. package/src/components/flowing-waves-indicator.ts +260 -0
  36. package/src/components/plasma-orb-indicator.ts +281 -0
  37. package/src/components/waves-indicator.ts +221 -0
  38. package/src/main/main.styles.ts +62 -7
  39. package/src/main/main.template.ts +88 -27
  40. package/src/main/main.ts +24 -8
  41. package/src/main/main.types.ts +56 -5
  42. package/src/utils/animation-exclusivity.test.ts +72 -0
  43. package/src/utils/animation-exclusivity.ts +40 -0
@@ -1058,6 +1058,42 @@ syntheticUser(
1058
1058
 
1059
1059
  syntheticUser.run();
1060
1060
 
1061
+ // ---------------------------------------------------------------------------
1062
+ // interaction presentation — the per-call `presentation` option is stamped
1063
+ // onto the appended interaction so the renderer can frame the widget, and is
1064
+ // absent (the historical default) when the option is omitted.
1065
+ // ---------------------------------------------------------------------------
1066
+
1067
+ const interactionPresentation = createLogicSuite('ChatDriver interaction presentation');
1068
+
1069
+ interactionPresentation(
1070
+ 'presentation option is stamped onto the appended interaction',
1071
+ async () => {
1072
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1073
+
1074
+ const pending = driver.requestInteraction('w', {}, { presentation: 'bubble' });
1075
+ const interaction = driver.getHistory().at(-1)!.interaction!;
1076
+ assert.is(interaction.presentation, 'bubble');
1077
+
1078
+ // Clean up the pending interaction so it does not dangle.
1079
+ driver.resolveInteraction(interaction.interactionId, { status: 'approved' });
1080
+ await pending;
1081
+ },
1082
+ );
1083
+
1084
+ interactionPresentation('presentation is absent when the option is omitted', async () => {
1085
+ const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
1086
+
1087
+ const pending = driver.requestInteraction('w', {});
1088
+ const interaction = driver.getHistory().at(-1)!.interaction!;
1089
+ assert.ok(!('presentation' in interaction));
1090
+
1091
+ driver.resolveInteraction(interaction.interactionId, { status: 'approved' });
1092
+ await pending;
1093
+ });
1094
+
1095
+ interactionPresentation.run();
1096
+
1061
1097
  // ---------------------------------------------------------------------------
1062
1098
  // interaction timeout — requestInteraction({ timeoutMs }) resolves with a
1063
1099
  // status:'timeout' result (never rejects) and closes the widget read-only.
@@ -874,7 +874,9 @@ export class ChatDriver extends EventTarget implements AiDriver {
874
874
  * @param data - Data to pass to the component.
875
875
  * @param options - Optional per-call overrides, including
876
876
  * `chatInputDuringExecution` to hide or disable the main chat input while
877
- * the widget is awaiting user input. Reverts when the interaction resolves.
877
+ * the widget is awaiting user input (reverts when the interaction resolves),
878
+ * and `presentation` to control whether the host wraps the widget in a chat
879
+ * bubble and/or shows the "Assistant" label.
878
880
  */
879
881
  public async requestInteraction<T>(
880
882
  componentName: string,
@@ -894,6 +896,7 @@ export class ChatDriver extends EventTarget implements AiDriver {
894
896
  const interactionId = crypto.randomUUID();
895
897
  const chatInputDuringExecution = options?.chatInputDuringExecution;
896
898
  const timeoutMs = options?.timeoutMs;
899
+ const presentation = options?.presentation;
897
900
  return new Promise((resolve, reject) => {
898
901
  this.pendingInteractions.set(interactionId, {
899
902
  resolve,
@@ -928,7 +931,12 @@ export class ChatDriver extends EventTarget implements AiDriver {
928
931
  this.appendToHistory({
929
932
  role: 'assistant',
930
933
  content: '',
931
- interaction: { interactionId, componentName, data },
934
+ interaction: {
935
+ interactionId,
936
+ componentName,
937
+ data,
938
+ ...(presentation ? { presentation } : {}),
939
+ },
932
940
  });
933
941
  });
934
942
  }
@@ -0,0 +1,260 @@
1
+ import { css, customElement, GenesisElement, html } from '@genesislcap/web-core';
2
+ import {
3
+ AI_COLOUR_AMBER,
4
+ AI_COLOUR_CYAN,
5
+ AI_COLOUR_PINK,
6
+ AI_COLOUR_VIOLET,
7
+ } from '../styles/ai-colours';
8
+
9
+ /** SVG coordinate space the bands are drawn in (aspect matches the host box). */
10
+ const VIEW_W = 225;
11
+ const VIEW_H = 90;
12
+ const CENTRE_Y = VIEW_H / 2;
13
+ /** Number of samples traced across each band. */
14
+ const POINTS = 90;
15
+ /** Fraction of the width over which thickness ramps from 0 up to full. */
16
+ const EDGE = 0.16;
17
+ /** Global multiplier on the harmonic drift speeds — raise to morph faster. */
18
+ const MORPH_SPEED = 3;
19
+ /** Global multiplier on the harmonic frequencies — raise for narrower peaks. */
20
+ const FREQ_SCALE = 2;
21
+ /**
22
+ * Half-thickness (viewBox units) each band keeps even between peaks, so the
23
+ * waves emerge from a continuous line rather than leaving gaps. The four bands
24
+ * overlap along this line and screen-blend to white.
25
+ */
26
+ const LINE_THICKNESS = 1.2;
27
+ /**
28
+ * Amplitude boost added toward the middle of the width (a hump that is 0 at the
29
+ * ends, max in the centre). Makes peaks more likely to pop up centrally, while
30
+ * still allowing tall crests to appear nearer the edges.
31
+ */
32
+ const CENTRE_BIAS = 4;
33
+
34
+ /** One sine component of a band's thickness profile. */
35
+ interface Harmonic {
36
+ /** Cycles across the band width. */
37
+ freq: number;
38
+ /** Contribution to the half-thickness, in viewBox units. */
39
+ amp: number;
40
+ /** Phase drift per frame (radians); differing speeds make the bumps morph. */
41
+ speed: number;
42
+ /** Starting phase offset. */
43
+ phase: number;
44
+ }
45
+
46
+ /**
47
+ * One band's thickness profile: a baseline plus a few sine harmonics. Because
48
+ * the harmonics drift at different speeds, their sum continually changes shape —
49
+ * bumps grow, shrink, merge and split — and where the sum dips below zero the
50
+ * band pinches to nothing, so a wave may have a single bump one moment and
51
+ * several the next. A different harmonic mix per band keeps the colours from
52
+ * lining up.
53
+ */
54
+ interface BandConfig {
55
+ /**
56
+ * Vertical offset the harmonics oscillate around. Negative values act as a
57
+ * threshold: only the taller crests clear zero, so the band shows fewer,
58
+ * narrower, isolated peaks (the small ripples between crests are clipped away).
59
+ */
60
+ baseline: number;
61
+ harmonics: Harmonic[];
62
+ }
63
+
64
+ const BANDS: BandConfig[] = [
65
+ {
66
+ baseline: -4,
67
+ harmonics: [
68
+ { freq: 1.0, amp: 11, speed: 0.03, phase: 0.0 },
69
+ { freq: 2.3, amp: 6, speed: -0.021, phase: 1.3 },
70
+ { freq: 3.4, amp: 3, speed: 0.041, phase: 2.7 },
71
+ ],
72
+ },
73
+ {
74
+ baseline: -4,
75
+ harmonics: [
76
+ { freq: 1.3, amp: 10, speed: -0.026, phase: 0.8 },
77
+ { freq: 2.0, amp: 7, speed: 0.034, phase: 2.1 },
78
+ { freq: 3.1, amp: 3, speed: -0.045, phase: 0.4 },
79
+ ],
80
+ },
81
+ {
82
+ baseline: -4,
83
+ harmonics: [
84
+ { freq: 0.9, amp: 9, speed: 0.038, phase: 1.9 },
85
+ { freq: 2.6, amp: 6, speed: 0.019, phase: 3.0 },
86
+ { freq: 3.7, amp: 3, speed: -0.03, phase: 0.6 },
87
+ ],
88
+ },
89
+ {
90
+ baseline: -4,
91
+ harmonics: [
92
+ { freq: 1.1, amp: 8, speed: -0.034, phase: 2.4 },
93
+ { freq: 2.4, amp: 5, speed: 0.027, phase: 0.2 },
94
+ { freq: 3.2, amp: 3, speed: 0.048, phase: 1.5 },
95
+ ],
96
+ },
97
+ ];
98
+
99
+ // Each band is its own stacked <svg> LAYER. The white-on-overlap effect uses
100
+ // `mix-blend-mode: screen`, which composites reliably between HTML-level layers
101
+ // but NOT between sibling SVG <path>/<g> elements in many browsers — hence one
102
+ // svg per band rather than four paths in a single svg.
103
+ const bandLayersMarkup = BANDS.map(
104
+ (_b, i) =>
105
+ `<svg class="layer band-layer" viewBox="0 0 ${VIEW_W} ${VIEW_H}">` +
106
+ `<path class="band b${i + 1}" data-band="${i}" />` +
107
+ `</svg>`,
108
+ ).join('');
109
+
110
+ /**
111
+ * Animated "flowing waves" loading indicator — coloured organic waves (in the
112
+ * four brand colours) whose bumps swell, shrink, merge and split across a line,
113
+ * tapering to nothing at both ends. Where the colours overlap they blend
114
+ * additively toward white.
115
+ *
116
+ * Visual sibling of the dots loading indicator; interchangeable with the other
117
+ * loading styles for the "assistant is working" state.
118
+ *
119
+ * @example
120
+ * ```html
121
+ * <ai-flowing-waves-indicator></ai-flowing-waves-indicator>
122
+ * ```
123
+ *
124
+ * @beta
125
+ */
126
+ @customElement({
127
+ name: 'ai-flowing-waves-indicator',
128
+ template: html<AiFlowingWavesIndicator>`
129
+ <div class="flow" role="img" aria-label="Assistant is working">${bandLayersMarkup}</div>
130
+ `,
131
+ styles: css`
132
+ :host {
133
+ display: inline-block;
134
+ width: 180px;
135
+ height: 72px;
136
+ }
137
+
138
+ /* Transparent — sits directly on the chat surface (no backdrop). 'isolation:
139
+ isolate' scopes the band layers' 'screen' blend to each other, so where
140
+ colours overlap they add toward white, while the group still composites
141
+ normally onto the page (works on light and dark, no wash-out). */
142
+ .flow {
143
+ position: relative;
144
+ width: 100%;
145
+ height: 100%;
146
+ isolation: isolate;
147
+ }
148
+
149
+ .layer {
150
+ position: absolute;
151
+ inset: 0;
152
+ width: 100%;
153
+ height: 100%;
154
+ overflow: hidden;
155
+ }
156
+
157
+ .band-layer {
158
+ mix-blend-mode: screen;
159
+ }
160
+
161
+ .band {
162
+ filter: drop-shadow(0 0 1.5px currentColor);
163
+ }
164
+ .band.b1 {
165
+ color: ${AI_COLOUR_AMBER};
166
+ fill: ${AI_COLOUR_AMBER};
167
+ }
168
+ .band.b2 {
169
+ color: ${AI_COLOUR_PINK};
170
+ fill: ${AI_COLOUR_PINK};
171
+ }
172
+ .band.b3 {
173
+ color: ${AI_COLOUR_CYAN};
174
+ fill: ${AI_COLOUR_CYAN};
175
+ }
176
+ .band.b4 {
177
+ color: ${AI_COLOUR_VIOLET};
178
+ fill: ${AI_COLOUR_VIOLET};
179
+ }
180
+ `,
181
+ })
182
+ export class AiFlowingWavesIndicator extends GenesisElement {
183
+ // A rAF loop morphs the band shapes: pure CSS can't continuously reshape the
184
+ // bumps (different sizes appearing at different times), and the per-frame sine
185
+ // sums are cheap. Same approach as the sine-tracing waves indicator.
186
+
187
+ private frame = 0;
188
+ private animFrame?: number;
189
+ private bandPaths?: SVGPathElement[];
190
+
191
+ connectedCallback() {
192
+ super.connectedCallback();
193
+ // Guard against a reconnect starting a second concurrent loop.
194
+ if (this.animFrame === undefined) {
195
+ this.tick();
196
+ }
197
+ }
198
+
199
+ disconnectedCallback() {
200
+ super.disconnectedCallback();
201
+ if (this.animFrame !== undefined) {
202
+ cancelAnimationFrame(this.animFrame);
203
+ this.animFrame = undefined;
204
+ }
205
+ }
206
+
207
+ private tick() {
208
+ // Stop ticking once disconnected (belt-and-braces alongside the cancel in
209
+ // disconnectedCallback) so no frames are scheduled while detached.
210
+ if (!this.isConnected) {
211
+ this.animFrame = undefined;
212
+ return;
213
+ }
214
+ if (!this.bandPaths) {
215
+ const paths = this.shadowRoot?.querySelectorAll<SVGPathElement>('.band');
216
+ if (paths?.length) this.bandPaths = Array.from(paths);
217
+ }
218
+ this.bandPaths?.forEach((path, i) => {
219
+ const cfg = BANDS[i];
220
+ if (cfg) path.setAttribute('d', AiFlowingWavesIndicator.buildBand(cfg, this.frame));
221
+ });
222
+ this.frame += 1;
223
+ this.animFrame = requestAnimationFrame(() => this.tick());
224
+ }
225
+
226
+ /**
227
+ * Trace one band for the given frame: a baseline-plus-harmonics half-thickness
228
+ * profile, clamped at 0 (so the band pinches to nothing between bumps) and
229
+ * tapered to 0 at both ends.
230
+ */
231
+ private static buildBand(cfg: BandConfig, frame: number): string {
232
+ const top: Array<[number, number]> = [];
233
+ const bottom: Array<[number, number]> = [];
234
+ for (let i = 0; i <= POINTS; i += 1) {
235
+ const u = i / POINTS;
236
+ const x = u * VIEW_W;
237
+ // Plateau edge taper: 0 at both ends, full across the middle.
238
+ const edge = Math.min(1, Math.min(u, 1 - u) / EDGE);
239
+ // Sum the drifting harmonics, then clamp so dips pinch the band to nothing.
240
+ let wave = 0;
241
+ for (const h of cfg.harmonics) {
242
+ wave +=
243
+ h.amp *
244
+ Math.sin(u * Math.PI * 2 * h.freq * FREQ_SCALE + frame * h.speed * MORPH_SPEED + h.phase);
245
+ }
246
+ // Hump that favours the middle (0 at the ends, 1 at the centre).
247
+ const centre = Math.sin(Math.PI * u);
248
+ const env = edge * (LINE_THICKNESS + Math.max(0, cfg.baseline + wave + CENTRE_BIAS * centre));
249
+ top.push([x, CENTRE_Y - env]);
250
+ bottom.push([x, CENTRE_Y + env]);
251
+ }
252
+ let d = `M${top[0][0].toFixed(1)} ${top[0][1].toFixed(1)}`;
253
+ for (let i = 1; i < top.length; i += 1)
254
+ d += ` L${top[i][0].toFixed(1)} ${top[i][1].toFixed(1)}`;
255
+ for (let i = bottom.length - 1; i >= 0; i -= 1) {
256
+ d += ` L${bottom[i][0].toFixed(1)} ${bottom[i][1].toFixed(1)}`;
257
+ }
258
+ return `${d} Z`;
259
+ }
260
+ }
@@ -0,0 +1,281 @@
1
+ import { css, customElement, GenesisElement, html } from '@genesislcap/web-core';
2
+ import {
3
+ AI_COLOUR_AMBER,
4
+ AI_COLOUR_CYAN,
5
+ AI_COLOUR_PINK,
6
+ AI_COLOUR_VIOLET,
7
+ } from '../styles/ai-colours';
8
+
9
+ /**
10
+ * Animated "plasma orb" loading indicator — a fixed glowing energy sphere whose
11
+ * luminous filaments and swirls drift and rotate inside it, like a plasma ball.
12
+ * The globe never resizes; only the energy moves.
13
+ *
14
+ * Brand-coloured: a violet energy core, a slow cyan/pink tint wash, and a warm
15
+ * amber hot-spot — covering all four brand colours.
16
+ *
17
+ * Visual sibling of the dots loading indicator; interchangeable with the other
18
+ * loading styles for the "assistant is working" state.
19
+ *
20
+ * @example
21
+ * ```html
22
+ * <ai-plasma-orb-indicator></ai-plasma-orb-indicator>
23
+ * ```
24
+ *
25
+ * @beta
26
+ */
27
+ @customElement({
28
+ name: 'ai-plasma-orb-indicator',
29
+ template: html<AiPlasmaOrbIndicator>`
30
+ <div class="pulse" role="img" aria-label="Assistant is working">
31
+ <div class="plasma">
32
+ <div class="swirl1"></div>
33
+ <div class="swirl2"></div>
34
+ <svg class="filaments" viewBox="0 0 100 100">
35
+ <g>
36
+ <path d="M8 50 Q50 18 92 50" />
37
+ <path d="M50 8 Q82 50 50 92" />
38
+ <path d="M18 22 Q52 54 82 80" />
39
+ <path d="M82 20 Q48 46 20 82" />
40
+ <path d="M12 64 Q50 38 88 34" />
41
+ <path d="M30 12 Q55 50 72 88" />
42
+ <path d="M14 38 Q50 60 86 62" />
43
+ <path d="M66 10 Q44 48 24 90" />
44
+ <path d="M10 28 Q56 44 90 72" />
45
+ <path d="M88 44 Q46 52 16 76" />
46
+ <path d="M40 6 Q58 50 88 86" />
47
+ <path d="M22 86 Q46 44 60 8" />
48
+ </g>
49
+ </svg>
50
+ <div class="tint"></div>
51
+ <div class="hot"></div>
52
+ </div>
53
+ <div class="ring-emit"></div>
54
+ <div class="ring-emit r2"></div>
55
+ </div>
56
+ `,
57
+ styles: css`
58
+ :host {
59
+ position: relative;
60
+ display: inline-block;
61
+ width: 56px;
62
+ height: 56px;
63
+ }
64
+
65
+ /* Authored at the gallery's native 100px size and scaled down so the px
66
+ glow/filament geometry keeps its proportions. Absolutely centred so the
67
+ overflowing glow/rings don't affect the host's layout box. */
68
+ .pulse {
69
+ position: absolute;
70
+ top: 50%;
71
+ left: 50%;
72
+ width: 100px;
73
+ height: 100px;
74
+ transform: translate(-50%, -50%) scale(0.56);
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ }
79
+
80
+ .plasma {
81
+ position: relative;
82
+ width: 86px;
83
+ height: 86px;
84
+ border-radius: 50%;
85
+ overflow: hidden;
86
+ isolation: isolate;
87
+ background: radial-gradient(
88
+ circle at 50% 40%,
89
+ #fdf2ff 0%,
90
+ #f3c7fd 14%,
91
+ #e362ff 38%,
92
+ ${AI_COLOUR_VIOLET} 62%,
93
+ #8a0a9c 84%,
94
+ #3a0044 100%
95
+ );
96
+ box-shadow:
97
+ 0 0 20px 5px color-mix(in srgb, ${AI_COLOUR_VIOLET}, transparent 20%),
98
+ 0 0 48px 12px color-mix(in srgb, ${AI_COLOUR_VIOLET}, transparent 58%),
99
+ inset 0 0 22px rgb(255 255 255 / 22%),
100
+ inset 0 0 8px rgb(255 255 255 / 50%);
101
+ }
102
+
103
+ /* bright rim light */
104
+ .plasma::after {
105
+ content: '';
106
+ position: absolute;
107
+ inset: 0;
108
+ border-radius: 50%;
109
+ z-index: 5;
110
+ pointer-events: none;
111
+ box-shadow:
112
+ inset 0 0 6px 1px rgb(245 220 255 / 90%),
113
+ inset 0 0 16px color-mix(in srgb, ${AI_COLOUR_VIOLET}, transparent 35%);
114
+ }
115
+
116
+ /* slow colour shift: washes the orb toward cyan/pink and back */
117
+ .tint {
118
+ position: absolute;
119
+ inset: 0;
120
+ border-radius: 50%;
121
+ z-index: 4;
122
+ pointer-events: none;
123
+ background: radial-gradient(
124
+ circle at 50% 40%,
125
+ color-mix(in srgb, ${AI_COLOUR_CYAN}, white 35%) 0%,
126
+ ${AI_COLOUR_CYAN} 45%,
127
+ ${AI_COLOUR_PINK} 100%
128
+ );
129
+ mix-blend-mode: color;
130
+ opacity: 0;
131
+ animation: plasma-tint 9s ease-in-out infinite;
132
+ }
133
+ @keyframes plasma-tint {
134
+ 0%,
135
+ 100% {
136
+ opacity: 0;
137
+ }
138
+ 50% {
139
+ opacity: 0.85;
140
+ }
141
+ }
142
+
143
+ /* warm white-hot core highlight (shifts, never resizes the orb) */
144
+ .hot {
145
+ position: absolute;
146
+ left: 50%;
147
+ top: 38%;
148
+ width: 42px;
149
+ height: 36px;
150
+ z-index: 4;
151
+ border-radius: 50%;
152
+ pointer-events: none;
153
+ filter: blur(3px);
154
+ background: radial-gradient(
155
+ circle,
156
+ rgb(255 255 255 / 90%) 0%,
157
+ color-mix(in srgb, ${AI_COLOUR_AMBER}, white 30%) 42%,
158
+ rgb(255 255 255 / 0%) 70%
159
+ );
160
+ animation: plasma-hot 5s ease-in-out infinite;
161
+ }
162
+ @keyframes plasma-hot {
163
+ 0%,
164
+ 100% {
165
+ opacity: 0.65;
166
+ transform: translate(-50%, -50%) translate(-2px, 1px);
167
+ }
168
+ 50% {
169
+ opacity: 1;
170
+ transform: translate(-50%, -50%) translate(3px, -2px);
171
+ }
172
+ }
173
+
174
+ /* swirling radial filaments */
175
+ .swirl1,
176
+ .swirl2 {
177
+ position: absolute;
178
+ inset: -25%;
179
+ border-radius: 50%;
180
+ z-index: 2;
181
+ mix-blend-mode: screen;
182
+ }
183
+ .swirl1 {
184
+ background: conic-gradient(
185
+ from 0deg,
186
+ transparent 0 4deg,
187
+ rgb(225 240 255 / 55%) 7deg,
188
+ transparent 11deg 58deg,
189
+ rgb(190 225 255 / 45%) 64deg,
190
+ transparent 70deg 138deg,
191
+ rgb(255 255 255 / 50%) 144deg,
192
+ transparent 150deg 220deg,
193
+ rgb(200 230 255 / 45%) 226deg,
194
+ transparent 232deg 300deg,
195
+ rgb(255 255 255 / 45%) 306deg,
196
+ transparent 312deg 360deg
197
+ );
198
+ filter: blur(3px);
199
+ animation: plasma-spin-cw 9s linear infinite;
200
+ }
201
+ .swirl2 {
202
+ background: conic-gradient(
203
+ from 40deg,
204
+ transparent 0 30deg,
205
+ rgb(180 215 255 / 40%) 36deg,
206
+ transparent 42deg 110deg,
207
+ rgb(255 255 255 / 40%) 116deg,
208
+ transparent 122deg 190deg,
209
+ rgb(190 225 255 / 40%) 196deg,
210
+ transparent 202deg 280deg,
211
+ rgb(255 255 255 / 40%) 286deg,
212
+ transparent 292deg 360deg
213
+ );
214
+ filter: blur(5px);
215
+ animation: plasma-spin-ccw 13s linear infinite;
216
+ }
217
+
218
+ /* curved web filaments */
219
+ .filaments {
220
+ position: absolute;
221
+ inset: 0;
222
+ width: 100%;
223
+ height: 100%;
224
+ z-index: 3;
225
+ overflow: visible;
226
+ mix-blend-mode: screen;
227
+ filter: blur(0.5px);
228
+ }
229
+ .filaments g {
230
+ transform-box: fill-box;
231
+ transform-origin: center;
232
+ animation: plasma-spin-ccw 18s linear infinite;
233
+ }
234
+ .filaments path {
235
+ fill: none;
236
+ stroke: rgb(230 245 255 / 50%);
237
+ stroke-width: 0.7;
238
+ }
239
+
240
+ /* soft light rings emitting outward from the orb */
241
+ .ring-emit {
242
+ position: absolute;
243
+ left: 50%;
244
+ top: 50%;
245
+ width: 86px;
246
+ height: 86px;
247
+ transform: translate(-50%, -50%);
248
+ border-radius: 50%;
249
+ border: 2px solid color-mix(in srgb, ${AI_COLOUR_VIOLET}, transparent 25%);
250
+ box-shadow: 0 0 8px color-mix(in srgb, ${AI_COLOUR_VIOLET}, transparent 35%);
251
+ pointer-events: none;
252
+ z-index: 0;
253
+ animation: plasma-emit 2.8s ease-out infinite;
254
+ }
255
+ .ring-emit.r2 {
256
+ animation-delay: 1.4s;
257
+ }
258
+ @keyframes plasma-emit {
259
+ 0% {
260
+ transform: translate(-50%, -50%) scale(1);
261
+ opacity: 0.7;
262
+ }
263
+ 100% {
264
+ transform: translate(-50%, -50%) scale(1.45);
265
+ opacity: 0;
266
+ }
267
+ }
268
+
269
+ @keyframes plasma-spin-cw {
270
+ to {
271
+ transform: rotate(360deg);
272
+ }
273
+ }
274
+ @keyframes plasma-spin-ccw {
275
+ to {
276
+ transform: rotate(-360deg);
277
+ }
278
+ }
279
+ `,
280
+ })
281
+ export class AiPlasmaOrbIndicator extends GenesisElement {}