@ibgib/space-gib 0.0.34 → 0.0.36
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.
- package/dist/client/bootstrap.mjs +1 -1
- package/dist/client/{chunk-25PXC4HP.mjs → chunk-5DTAUY3E.mjs} +1586 -464
- package/dist/client/chunk-J7X3XTAI.mjs +53 -0
- package/dist/client/css/activity-bar.css +21 -3
- package/dist/client/css/layout.css +126 -5
- package/dist/client/css/tutorial.css +327 -0
- package/dist/client/index.html +6 -0
- package/dist/client/index.mjs +22 -45
- package/dist/client/privacy.html +22 -22
- package/dist/client/script.mjs +1 -1
- package/dist/client/style.css +1 -0
- package/dist/server/server.mjs +3 -3
- package/package.json +6 -6
- package/src/client/AUTO-GENERATED-version.mts +1 -1
- package/src/client/cosmos/seed-cosmos-workspace.mts +20 -6
- package/src/client/css/activity-bar.css +21 -3
- package/src/client/css/layout.css +126 -5
- package/src/client/css/tutorial.css +327 -0
- package/src/client/dev-tools/vcs-workspace.mts +4 -4
- package/src/client/index.html +6 -0
- package/src/client/privacy.html +22 -22
- package/src/client/style.css +1 -0
- package/src/client/ui/component/changes/changes.css +413 -0
- package/src/client/ui/component/changes/changes.html +37 -0
- package/src/client/ui/component/changes/changes.mts +766 -0
- package/src/client/ui/component/changes/index.mts +9 -0
- package/src/client/ui/component/chat-view/chat-view.html +6 -58
- package/src/client/ui/component/chat-view/chat-view.mts +13 -26
- package/src/client/ui/component/clone/clone.mts +67 -39
- package/src/client/ui/component/code-editor/code-editor.css +91 -9
- package/src/client/ui/component/code-editor/code-editor.html +58 -45
- package/src/client/ui/component/code-editor/code-editor.mts +265 -49
- package/src/client/ui/component/cosmos-navigator/cosmos-navigator.css +1 -1
- package/src/client/ui/component/cosmos-navigator/cosmos-navigator.html +5 -10
- package/src/client/ui/component/cosmos-navigator/cosmos-navigator.mts +328 -64
- package/src/client/ui/component/file-explorer/file-explorer.css +475 -11
- package/src/client/ui/component/file-explorer/file-explorer.html +45 -65
- package/src/client/ui/component/file-explorer/file-explorer.mts +1164 -98
- package/src/client/ui/component/nested-chat/chat/chat.mts +5 -90
- package/src/client/ui/component/nested-chat/nested-chat.mts +10 -8
- package/src/client/ui/component/timeline/timeline.css +16 -1
- package/src/client/ui/component/timeline/timeline.mts +37 -93
- package/src/client/ui/component/tutorial/index.mts +9 -0
- package/src/client/ui/component/tutorial/scripts/pitch-tour-script.mts +265 -0
- package/src/client/ui/component/tutorial/tutorial-controller.mts +622 -0
- package/src/client/ui/component/tutorial/tutorial-types.mts +45 -0
- package/src/client/ui/component/tutorial/tutorial.css +260 -0
- package/src/client/ui/router/space-gib-router-helpers.mts +33 -233
- package/src/client/ui/router/space-gib-router-helpers.respec.mts +298 -122
- package/src/client/ui/router/space-gib-router-service.mts +28 -151
- package/src/client/ui/router/space-gib-router-types.mts +32 -76
- package/src/client/ui/shell/cosmic-big-bang.mts +12 -3
- package/src/client/ui/shell/space-gib-shell-service.mts +715 -143
- package/dist/client/chunk-RXWLL2AI.mjs +0 -30
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module tutorial-controller
|
|
3
|
+
*
|
|
4
|
+
* Controller managing the interactive spotlight tour, virtual mouse cursor animations,
|
|
5
|
+
* and tutorial overlay in Space-Gib.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TutorialScript, TutorialStep, TutorialActionContext } from './tutorial-types.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Deeply traverses the DOM tree, recursing into all ShadowRoots to locate an element
|
|
12
|
+
* by ID or CSS selector.
|
|
13
|
+
*/
|
|
14
|
+
export function findElementDeep(
|
|
15
|
+
idOrSelector: string,
|
|
16
|
+
root: Document | Element | ShadowRoot = document
|
|
17
|
+
): HTMLElement | null {
|
|
18
|
+
if (!idOrSelector) return null;
|
|
19
|
+
|
|
20
|
+
const cleanId = idOrSelector.startsWith('#') ? idOrSelector.slice(1) : idOrSelector;
|
|
21
|
+
|
|
22
|
+
// 1. Direct getElementById if available on this root
|
|
23
|
+
if ('getElementById' in root) {
|
|
24
|
+
const el = (root as Document | ShadowRoot).getElementById(cleanId);
|
|
25
|
+
if (el) return el;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 2. Direct querySelector
|
|
29
|
+
try {
|
|
30
|
+
const selector = idOrSelector.startsWith('#') || idOrSelector.startsWith('.')
|
|
31
|
+
? idOrSelector
|
|
32
|
+
: `#${idOrSelector}`;
|
|
33
|
+
const el = root.querySelector(selector);
|
|
34
|
+
if (el) return el as HTMLElement;
|
|
35
|
+
} catch {
|
|
36
|
+
// Invalid selector, ignore
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 3. Search children with shadowRoots recursively
|
|
40
|
+
const searchRoot = root instanceof Document ? root.body || root.documentElement : root;
|
|
41
|
+
if (!searchRoot) return null;
|
|
42
|
+
|
|
43
|
+
const treeWalker = document.createTreeWalker(searchRoot, NodeFilter.SHOW_ELEMENT);
|
|
44
|
+
let currentNode = treeWalker.nextNode() as Element | null;
|
|
45
|
+
while (currentNode) {
|
|
46
|
+
if (currentNode.shadowRoot) {
|
|
47
|
+
const found = findElementDeep(idOrSelector, currentNode.shadowRoot);
|
|
48
|
+
if (found) return found;
|
|
49
|
+
}
|
|
50
|
+
currentNode = treeWalker.nextNode() as Element | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class TutorialController {
|
|
57
|
+
private lc = '[TutorialController]';
|
|
58
|
+
|
|
59
|
+
private script?: TutorialScript;
|
|
60
|
+
private currentStepIndex = 0;
|
|
61
|
+
private shell: any;
|
|
62
|
+
private active = false;
|
|
63
|
+
|
|
64
|
+
// DOM Elements
|
|
65
|
+
private overlayContainer?: HTMLDivElement;
|
|
66
|
+
private backdrop?: HTMLDivElement;
|
|
67
|
+
private spotlightRing?: HTMLDivElement;
|
|
68
|
+
private beacon?: HTMLButtonElement;
|
|
69
|
+
private card?: HTMLDivElement;
|
|
70
|
+
private cursor?: HTMLDivElement;
|
|
71
|
+
|
|
72
|
+
// Virtual cursor position tracker
|
|
73
|
+
private cursorX = -9999;
|
|
74
|
+
private cursorY = -9999;
|
|
75
|
+
|
|
76
|
+
// Bound listeners for cleanup
|
|
77
|
+
private boundHandleResize = this.handleResize.bind(this);
|
|
78
|
+
private boundHandleKeydown = this.handleKeydown.bind(this);
|
|
79
|
+
|
|
80
|
+
public get isActive(): boolean {
|
|
81
|
+
return this.active;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public get currentStep(): TutorialStep | undefined {
|
|
85
|
+
return this.script?.steps[this.currentStepIndex];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Starts the specified tutorial script.
|
|
90
|
+
*/
|
|
91
|
+
public async start(script: TutorialScript, shell: any): Promise<void> {
|
|
92
|
+
console.log(`${this.lc} [start] Starting tutorial script: "${script?.title}" with ${script?.steps?.length} steps.`);
|
|
93
|
+
if (!script || script.steps.length === 0) {
|
|
94
|
+
console.warn(`${this.lc} [start] Cannot start empty tutorial script.`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.script = script;
|
|
99
|
+
this.shell = shell;
|
|
100
|
+
this.currentStepIndex = 0;
|
|
101
|
+
this.active = true;
|
|
102
|
+
|
|
103
|
+
this.mountOverlay();
|
|
104
|
+
window.addEventListener('resize', this.boundHandleResize);
|
|
105
|
+
window.addEventListener('keydown', this.boundHandleKeydown);
|
|
106
|
+
|
|
107
|
+
await this.renderStep(this.currentStepIndex);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Advances to the next step, executing any in-between transition actions,
|
|
112
|
+
* or closes the tour if on the final step.
|
|
113
|
+
*/
|
|
114
|
+
public async next(): Promise<void> {
|
|
115
|
+
console.log(`${this.lc} [next] Current step index: ${this.currentStepIndex}, total: ${this.script?.steps.length}`);
|
|
116
|
+
if (!this.active || !this.script) return;
|
|
117
|
+
|
|
118
|
+
const currentStep = this.script.steps[this.currentStepIndex];
|
|
119
|
+
|
|
120
|
+
// 1. Check if current step has an in-between transition action
|
|
121
|
+
if (currentStep?.transitionAction) {
|
|
122
|
+
console.log(`${this.lc} [next] Executing transition action for step "${currentStep.id}"...`);
|
|
123
|
+
this.hideOverlayElements();
|
|
124
|
+
try {
|
|
125
|
+
const ctx: TutorialActionContext = {
|
|
126
|
+
shell: this.shell,
|
|
127
|
+
metaspace: this.shell?.metaspace,
|
|
128
|
+
galaxyName: this.shell?.fileExplorerInstance?.getGalaxyName?.() || 'code-1',
|
|
129
|
+
branchName: this.shell?.fileExplorerInstance?.getBranchName?.() || 'main',
|
|
130
|
+
};
|
|
131
|
+
await currentStep.transitionAction(ctx, this);
|
|
132
|
+
console.log(`${this.lc} [next] Transition action completed.`);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.warn(`${this.lc} [next] Transition action failed:`, err);
|
|
135
|
+
} finally {
|
|
136
|
+
this.hideCursor();
|
|
137
|
+
this.showOverlayElements();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (this.currentStepIndex < this.script.steps.length - 1) {
|
|
142
|
+
this.currentStepIndex++;
|
|
143
|
+
await this.renderStep(this.currentStepIndex);
|
|
144
|
+
} else {
|
|
145
|
+
console.log(`${this.lc} [next] Final step reached, stopping tour.`);
|
|
146
|
+
this.stop();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Goes back to the previous step.
|
|
152
|
+
*/
|
|
153
|
+
public async prev(): Promise<void> {
|
|
154
|
+
console.log(`${this.lc} [prev] Current step index: ${this.currentStepIndex}`);
|
|
155
|
+
if (!this.active || !this.script) return;
|
|
156
|
+
|
|
157
|
+
if (this.currentStepIndex > 0) {
|
|
158
|
+
this.currentStepIndex--;
|
|
159
|
+
await this.renderStep(this.currentStepIndex);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Jumps directly to a specific step index.
|
|
165
|
+
*/
|
|
166
|
+
public async goToStep(index: number): Promise<void> {
|
|
167
|
+
console.log(`${this.lc} [goToStep] Jumping to index: ${index}`);
|
|
168
|
+
if (!this.active || !this.script) return;
|
|
169
|
+
if (index >= 0 && index < this.script.steps.length) {
|
|
170
|
+
this.currentStepIndex = index;
|
|
171
|
+
await this.renderStep(this.currentStepIndex);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Stops and removes the tutorial overlay from the DOM.
|
|
177
|
+
*/
|
|
178
|
+
public stop(): void {
|
|
179
|
+
console.log(`${this.lc} [stop] Closing and unmounting tutorial overlay.`);
|
|
180
|
+
this.active = false;
|
|
181
|
+
window.removeEventListener('resize', this.boundHandleResize);
|
|
182
|
+
window.removeEventListener('keydown', this.boundHandleKeydown);
|
|
183
|
+
|
|
184
|
+
if (this.overlayContainer && this.overlayContainer.parentNode) {
|
|
185
|
+
this.overlayContainer.parentNode.removeChild(this.overlayContainer);
|
|
186
|
+
}
|
|
187
|
+
this.overlayContainer = undefined;
|
|
188
|
+
this.backdrop = undefined;
|
|
189
|
+
this.spotlightRing = undefined;
|
|
190
|
+
this.beacon = undefined;
|
|
191
|
+
this.card = undefined;
|
|
192
|
+
this.cursor = undefined;
|
|
193
|
+
this.script = undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Smoothly animates the virtual mouse cursor to a target element or coordinate,
|
|
198
|
+
* and performs a visual mouse-down / click-ripple effect.
|
|
199
|
+
*/
|
|
200
|
+
public async animateMouseClick(
|
|
201
|
+
target: HTMLElement | { x: number; y: number },
|
|
202
|
+
options: {
|
|
203
|
+
durationMs?: number;
|
|
204
|
+
clickHoldMs?: number;
|
|
205
|
+
startPos?: { x: number; y: number };
|
|
206
|
+
hideAfterMs?: number;
|
|
207
|
+
} = {}
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
if (!this.cursor) return;
|
|
210
|
+
|
|
211
|
+
const duration = options.durationMs ?? 750;
|
|
212
|
+
const clickHold = options.clickHoldMs ?? 250;
|
|
213
|
+
|
|
214
|
+
let toX: number;
|
|
215
|
+
let toY: number;
|
|
216
|
+
|
|
217
|
+
if (target instanceof HTMLElement) {
|
|
218
|
+
const rect = target.getBoundingClientRect();
|
|
219
|
+
toX = rect.left + rect.width / 2;
|
|
220
|
+
toY = rect.top + rect.height / 2;
|
|
221
|
+
} else {
|
|
222
|
+
toX = target.x;
|
|
223
|
+
toY = target.y;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Determine starting position
|
|
227
|
+
let fromX = this.cursorX;
|
|
228
|
+
let fromY = this.cursorY;
|
|
229
|
+
|
|
230
|
+
if (fromX < 0 || fromY < 0 || isNaN(fromX) || isNaN(fromY)) {
|
|
231
|
+
if (options.startPos) {
|
|
232
|
+
fromX = options.startPos.x;
|
|
233
|
+
fromY = options.startPos.y;
|
|
234
|
+
} else {
|
|
235
|
+
fromX = Math.min(window.innerWidth - 60, Math.max(20, toX + 160));
|
|
236
|
+
fromY = Math.min(window.innerHeight - 60, Math.max(20, toY + 140));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.cursor.style.transform = `translate(${fromX}px, ${fromY}px)`;
|
|
241
|
+
this.cursor.classList.add('visible');
|
|
242
|
+
this.cursor.classList.remove('clicking');
|
|
243
|
+
|
|
244
|
+
// Animate smooth gliding trajectory using Web Animations API
|
|
245
|
+
const anim = this.cursor.animate([
|
|
246
|
+
{ transform: `translate(${fromX}px, ${fromY}px)` },
|
|
247
|
+
{ transform: `translate(${toX}px, ${toY}px)` }
|
|
248
|
+
], {
|
|
249
|
+
duration,
|
|
250
|
+
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
|
251
|
+
fill: 'forwards'
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await new Promise<void>(resolve => {
|
|
255
|
+
anim.onfinish = () => resolve();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
this.cursorX = toX;
|
|
259
|
+
this.cursorY = toY;
|
|
260
|
+
this.cursor.style.transform = `translate(${toX}px, ${toY}px)`;
|
|
261
|
+
|
|
262
|
+
// Perform tactile click down animation (squash pointer + expand golden ripple)
|
|
263
|
+
this.cursor.classList.add('clicking');
|
|
264
|
+
await new Promise(r => setTimeout(r, clickHold));
|
|
265
|
+
this.cursor.classList.remove('clicking');
|
|
266
|
+
|
|
267
|
+
if (options.hideAfterMs !== undefined && options.hideAfterMs >= 0) {
|
|
268
|
+
setTimeout(() => {
|
|
269
|
+
this.hideCursor();
|
|
270
|
+
}, options.hideAfterMs);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Hides the virtual mouse cursor.
|
|
276
|
+
*/
|
|
277
|
+
public hideCursor(): void {
|
|
278
|
+
if (!this.cursor) return;
|
|
279
|
+
this.cursor.classList.remove('visible', 'clicking');
|
|
280
|
+
this.cursorX = -9999;
|
|
281
|
+
this.cursorY = -9999;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Temporarily fades out overlay cards, beacons, and spotlights so the underlying app
|
|
286
|
+
* can run animations without visual clutter.
|
|
287
|
+
*/
|
|
288
|
+
public hideOverlayElements(): void {
|
|
289
|
+
if (this.card) {
|
|
290
|
+
this.card.style.opacity = '0';
|
|
291
|
+
this.card.style.pointerEvents = 'none';
|
|
292
|
+
}
|
|
293
|
+
if (this.beacon) {
|
|
294
|
+
this.beacon.style.opacity = '0';
|
|
295
|
+
this.beacon.style.pointerEvents = 'none';
|
|
296
|
+
}
|
|
297
|
+
if (this.spotlightRing) {
|
|
298
|
+
this.spotlightRing.style.display = 'none';
|
|
299
|
+
}
|
|
300
|
+
if (this.backdrop) {
|
|
301
|
+
this.backdrop.classList.remove('backdrop-active');
|
|
302
|
+
this.backdrop.style.opacity = '0';
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Restores overlay card and beacon visibility after transition actions complete.
|
|
308
|
+
*/
|
|
309
|
+
public showOverlayElements(): void {
|
|
310
|
+
if (this.card) {
|
|
311
|
+
this.card.style.opacity = '1';
|
|
312
|
+
this.card.style.pointerEvents = 'auto';
|
|
313
|
+
}
|
|
314
|
+
if (this.beacon) {
|
|
315
|
+
this.beacon.style.opacity = '1';
|
|
316
|
+
this.beacon.style.pointerEvents = 'auto';
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private mountOverlay(): void {
|
|
321
|
+
if (this.overlayContainer) return;
|
|
322
|
+
console.log(`${this.lc} [mountOverlay] Mounting DOM elements and fallback styles...`);
|
|
323
|
+
|
|
324
|
+
// Inject inline fallback styles if not already present
|
|
325
|
+
if (!document.getElementById('tutorial-inline-styles')) {
|
|
326
|
+
const style = document.createElement('style');
|
|
327
|
+
style.id = 'tutorial-inline-styles';
|
|
328
|
+
style.textContent = `
|
|
329
|
+
.tutorial-overlay-container { position: fixed; inset: 0; z-index: 10000; pointer-events: none; overflow: hidden; }
|
|
330
|
+
.tutorial-backdrop { position: absolute; inset: 0; background: rgba(8, 10, 15, 0.82); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); pointer-events: auto; opacity: 0; transition: opacity 0.3s ease; }
|
|
331
|
+
.tutorial-backdrop.backdrop-active { opacity: 1; }
|
|
332
|
+
.tutorial-spotlight-ring { position: absolute; border-radius: 8px; background: transparent !important; box-shadow: 0 0 0 9999px rgba(8, 10, 15, 0.82), 0 0 0 3px rgba(255, 204, 0, 0.85), 0 0 25px 6px rgba(255, 180, 0, 0.45); pointer-events: none; z-index: 10001; }
|
|
333
|
+
.tutorial-cursor { position: fixed; top: 0; left: 0; width: 32px; height: 32px; z-index: 10006; pointer-events: none; opacity: 0; transform: translate(-9999px, -9999px); transition: opacity 0.25s ease; }
|
|
334
|
+
.tutorial-cursor.visible { opacity: 1; }
|
|
335
|
+
.tutorial-cursor-pointer { filter: drop-shadow(0 3px 8px rgba(0, 0, 0, 0.7)); transition: transform 0.12s ease; transform-origin: 0 0; }
|
|
336
|
+
.tutorial-cursor.clicking .tutorial-cursor-pointer { transform: scale(0.82); }
|
|
337
|
+
.tutorial-cursor-ripple { position: absolute; top: 0; left: 0; width: 36px; height: 36px; border-radius: 50%; border: 2px solid #ffd700; background: rgba(255, 215, 0, 0.25); box-shadow: 0 0 16px rgba(255, 215, 0, 0.85); transform: translate(-18px, -18px) scale(0); opacity: 0; pointer-events: none; }
|
|
338
|
+
.tutorial-cursor.clicking .tutorial-cursor-ripple { animation: cursor-ripple-anim 0.55s cubic-bezier(0.1, 0.8, 0.3, 1) forwards; }
|
|
339
|
+
@keyframes cursor-ripple-anim { 0% { transform: translate(-18px, -18px) scale(0.2); opacity: 1; } 100% { transform: translate(-18px, -18px) scale(2.5); opacity: 0; } }
|
|
340
|
+
.tutorial-beacon { position: absolute; display: flex; align-items: center; gap: 8px; padding: 8px 18px; background: linear-gradient(135deg, #ffd700 0%, #ff9900 100%); color: #121212; font-size: 0.95rem; font-weight: 700; border: 2px solid rgba(255, 255, 255, 0.8); border-radius: 9999px; box-shadow: 0 4px 20px rgba(255, 180, 0, 0.65); cursor: pointer; pointer-events: auto; z-index: 10002; transition: top 0.35s ease, left 0.35s ease, transform 0.2s ease, opacity 0.25s ease; }
|
|
341
|
+
.tutorial-card { position: absolute; width: 440px; max-width: calc(100vw - 32px); background: rgba(18, 22, 34, 0.95); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(255, 215, 0, 0.35); border-radius: 14px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.75); padding: 22px 24px; color: #e2e8f0; pointer-events: auto; z-index: 10002; transition: top 0.35s ease, left 0.35s ease, opacity 0.25s ease; }
|
|
342
|
+
.tutorial-card.placement-center { top: 50% !important; left: 50% !important; transform: translate(-50%, -50%); width: 620px; }
|
|
343
|
+
.tutorial-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
|
344
|
+
.tutorial-step-badge { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: #ffd700; background: rgba(255, 215, 0, 0.12); padding: 4px 10px; border-radius: 9999px; border: 1px solid rgba(255, 215, 0, 0.3); }
|
|
345
|
+
.tutorial-card-close { background: none; border: none; color: #94a3b8; font-size: 1.25rem; cursor: pointer; }
|
|
346
|
+
.tutorial-card-title { margin: 0 0 12px 0; font-size: 1.25rem; font-weight: 700; color: #ffffff; }
|
|
347
|
+
.tutorial-card-body { font-size: 0.92rem; line-height: 1.6; color: #cbd5e1; margin-bottom: 20px; max-height: 55vh; overflow-y: auto; }
|
|
348
|
+
.tutorial-card-footer { display: flex; align-items: center; justify-content: space-between; padding-top: 14px; border-top: 1px solid rgba(255, 255, 255, 0.08); }
|
|
349
|
+
.tutorial-progress-dots { display: flex; gap: 6px; }
|
|
350
|
+
.tutorial-dot { width: 8px; height: 8px; border-radius: 9999px; background: rgba(255, 255, 255, 0.2); }
|
|
351
|
+
.tutorial-dot.active { background: #ffd700; transform: scale(1.25); }
|
|
352
|
+
.tutorial-card-actions { display: flex; align-items: center; gap: 10px; }
|
|
353
|
+
.tutorial-btn-skip { background: none; border: none; color: #94a3b8; font-size: 0.85rem; cursor: pointer; }
|
|
354
|
+
.tutorial-btn-next { background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%); color: #0f172a; font-weight: 700; font-size: 0.9rem; padding: 8px 18px; border: none; border-radius: 8px; cursor: pointer; }
|
|
355
|
+
`;
|
|
356
|
+
document.head.appendChild(style);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const container = document.createElement('div');
|
|
360
|
+
container.className = 'tutorial-overlay-container';
|
|
361
|
+
|
|
362
|
+
const backdrop = document.createElement('div');
|
|
363
|
+
backdrop.className = 'tutorial-backdrop';
|
|
364
|
+
container.appendChild(backdrop);
|
|
365
|
+
this.backdrop = backdrop;
|
|
366
|
+
|
|
367
|
+
const spotlightRing = document.createElement('div');
|
|
368
|
+
spotlightRing.className = 'tutorial-spotlight-ring';
|
|
369
|
+
container.appendChild(spotlightRing);
|
|
370
|
+
this.spotlightRing = spotlightRing;
|
|
371
|
+
|
|
372
|
+
const beacon = document.createElement('button');
|
|
373
|
+
beacon.className = 'tutorial-beacon';
|
|
374
|
+
beacon.type = 'button';
|
|
375
|
+
beacon.addEventListener('click', (e) => {
|
|
376
|
+
e.stopPropagation();
|
|
377
|
+
console.log(`${this.lc} [beacon.click] Beacon clicked! Advancing...`);
|
|
378
|
+
this.next();
|
|
379
|
+
});
|
|
380
|
+
container.appendChild(beacon);
|
|
381
|
+
this.beacon = beacon;
|
|
382
|
+
|
|
383
|
+
const card = document.createElement('div');
|
|
384
|
+
card.className = 'tutorial-card';
|
|
385
|
+
container.appendChild(card);
|
|
386
|
+
this.card = card;
|
|
387
|
+
|
|
388
|
+
// Virtual mouse cursor element
|
|
389
|
+
const cursor = document.createElement('div');
|
|
390
|
+
cursor.className = 'tutorial-cursor';
|
|
391
|
+
cursor.id = 'tutorial-cursor';
|
|
392
|
+
cursor.innerHTML = `
|
|
393
|
+
<svg class="tutorial-cursor-pointer" viewBox="0 0 24 24" width="28" height="28" fill="none">
|
|
394
|
+
<path d="M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a.5.5 0 0 1 .35-.15h6.87a.5.5 0 0 0 .35-.85L6.35 2.86a.5.5 0 0 0-.85.35z" fill="#ffd700" stroke="#0f172a" stroke-width="1.5" stroke-linejoin="round"/>
|
|
395
|
+
</svg>
|
|
396
|
+
<div class="tutorial-cursor-ripple"></div>
|
|
397
|
+
`;
|
|
398
|
+
container.appendChild(cursor);
|
|
399
|
+
this.cursor = cursor;
|
|
400
|
+
|
|
401
|
+
document.body.appendChild(container);
|
|
402
|
+
this.overlayContainer = container;
|
|
403
|
+
console.log(`${this.lc} [mountOverlay] Tutorial overlay successfully mounted to document.body.`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private async renderStep(stepIndex: number): Promise<void> {
|
|
407
|
+
if (!this.script || !this.card || !this.beacon || !this.spotlightRing) return;
|
|
408
|
+
|
|
409
|
+
const step = this.script.steps[stepIndex];
|
|
410
|
+
const totalSteps = this.script.steps.length;
|
|
411
|
+
console.log(`${this.lc} [renderStep] Step ${stepIndex + 1}/${totalSteps}: "${step.title}", target=#${step.targetElementId || '(center)'}`);
|
|
412
|
+
|
|
413
|
+
// 1. Execute step programmatic action if defined
|
|
414
|
+
if (step.action) {
|
|
415
|
+
try {
|
|
416
|
+
console.log(`${this.lc} [renderStep] Executing step action...`);
|
|
417
|
+
const ctx: TutorialActionContext = {
|
|
418
|
+
shell: this.shell,
|
|
419
|
+
metaspace: this.shell?.metaspace,
|
|
420
|
+
galaxyName: this.shell?.fileExplorerInstance?.getGalaxyName?.() || 'code-1',
|
|
421
|
+
branchName: this.shell?.fileExplorerInstance?.getBranchName?.() || 'main',
|
|
422
|
+
};
|
|
423
|
+
await step.action(ctx);
|
|
424
|
+
console.log(`${this.lc} [renderStep] Step action completed.`);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
console.warn(`${this.lc} [renderStep] Step action failed:`, err);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// 2. Populate Card Content
|
|
431
|
+
const isFinalStep = stepIndex === totalSteps - 1;
|
|
432
|
+
const buttonText = step.buttonText || (isFinalStep ? 'Finish Tour ✨' : 'Next ➔');
|
|
433
|
+
|
|
434
|
+
const dotsHtml = this.script.steps
|
|
435
|
+
.map((_, i) => `<span class="tutorial-dot ${i === stepIndex ? 'active' : ''}"></span>`)
|
|
436
|
+
.join('');
|
|
437
|
+
|
|
438
|
+
this.card.innerHTML = `
|
|
439
|
+
<div class="tutorial-card-header">
|
|
440
|
+
<span class="tutorial-step-badge">Step ${stepIndex + 1} of ${totalSteps}</span>
|
|
441
|
+
<button class="tutorial-card-close" title="Close Tour (Esc)">✕</button>
|
|
442
|
+
</div>
|
|
443
|
+
<h3 class="tutorial-card-title">${step.title}</h3>
|
|
444
|
+
<div class="tutorial-card-body">${step.narration}</div>
|
|
445
|
+
<div class="tutorial-card-footer">
|
|
446
|
+
<div class="tutorial-progress-dots">${dotsHtml}</div>
|
|
447
|
+
<div class="tutorial-card-actions">
|
|
448
|
+
<button class="tutorial-btn-skip">Skip</button>
|
|
449
|
+
<button class="tutorial-btn-next">${buttonText}</button>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
`;
|
|
453
|
+
|
|
454
|
+
// Wire Card buttons
|
|
455
|
+
const btnClose = this.card.querySelector('.tutorial-card-close');
|
|
456
|
+
btnClose?.addEventListener('click', (e) => {
|
|
457
|
+
e.stopPropagation();
|
|
458
|
+
console.log(`${this.lc} [card.close] Close button clicked.`);
|
|
459
|
+
this.stop();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const btnSkip = this.card.querySelector('.tutorial-btn-skip');
|
|
463
|
+
btnSkip?.addEventListener('click', (e) => {
|
|
464
|
+
e.stopPropagation();
|
|
465
|
+
console.log(`${this.lc} [card.skip] Skip button clicked.`);
|
|
466
|
+
this.stop();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const btnNext = this.card.querySelector('.tutorial-btn-next');
|
|
470
|
+
btnNext?.addEventListener('click', (e) => {
|
|
471
|
+
e.stopPropagation();
|
|
472
|
+
console.log(`${this.lc} [card.next] Next button clicked.`);
|
|
473
|
+
this.next();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// 3. Populate Beacon Label
|
|
477
|
+
const beaconLabel = step.beaconLabel || (isFinalStep ? 'Invest 💰' : 'Next ✨');
|
|
478
|
+
this.beacon.innerHTML = `
|
|
479
|
+
<span class="tutorial-beacon-icon">✨</span>
|
|
480
|
+
<span class="tutorial-beacon-text">${beaconLabel}</span>
|
|
481
|
+
`;
|
|
482
|
+
|
|
483
|
+
// 4. Update Positions
|
|
484
|
+
this.showOverlayElements();
|
|
485
|
+
requestAnimationFrame(() => {
|
|
486
|
+
this.positionElements(step);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private positionElements(step: TutorialStep): void {
|
|
491
|
+
if (!this.card || !this.beacon || !this.spotlightRing || !this.backdrop) return;
|
|
492
|
+
|
|
493
|
+
const placement = step.placement || 'bottom';
|
|
494
|
+
console.log(`${this.lc} [positionElements] Calculating positions for target="#${step.targetElementId}", placement="${placement}"`);
|
|
495
|
+
|
|
496
|
+
if (placement === 'center' || !step.targetElementId) {
|
|
497
|
+
console.log(`${this.lc} [positionElements] Positioning in center of viewport.`);
|
|
498
|
+
this.card.className = 'tutorial-card placement-center';
|
|
499
|
+
this.card.style.top = '50%';
|
|
500
|
+
this.card.style.left = '50%';
|
|
501
|
+
this.card.style.transform = 'translate(-50%, -50%)';
|
|
502
|
+
|
|
503
|
+
this.spotlightRing.style.display = 'none';
|
|
504
|
+
this.backdrop.classList.add('backdrop-active');
|
|
505
|
+
|
|
506
|
+
this.beacon.style.top = 'calc(50% - 240px)';
|
|
507
|
+
this.beacon.style.left = '50%';
|
|
508
|
+
this.beacon.style.transform = 'translateX(-50%)';
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const targetEl = findElementDeep(step.targetElementId);
|
|
513
|
+
if (!targetEl) {
|
|
514
|
+
console.warn(`${this.lc} [positionElements] Target element #${step.targetElementId} not found in DOM! Falling back to center.`);
|
|
515
|
+
this.card.className = 'tutorial-card placement-center';
|
|
516
|
+
this.card.style.top = '50%';
|
|
517
|
+
this.card.style.left = '50%';
|
|
518
|
+
this.card.style.transform = 'translate(-50%, -50%)';
|
|
519
|
+
this.spotlightRing.style.display = 'none';
|
|
520
|
+
this.backdrop.classList.add('backdrop-active');
|
|
521
|
+
this.beacon.style.top = '30px';
|
|
522
|
+
this.beacon.style.left = '50%';
|
|
523
|
+
this.beacon.style.transform = 'translateX(-50%)';
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
this.card.className = `tutorial-card placement-${placement}`;
|
|
528
|
+
this.card.style.transform = 'none';
|
|
529
|
+
this.spotlightRing.style.display = 'block';
|
|
530
|
+
|
|
531
|
+
// When spotlight is active, the spotlight ring's huge box-shadow creates the dark veil,
|
|
532
|
+
// so we deactivate the backdrop blur to keep the inside 100% crystal-clear and crisp!
|
|
533
|
+
this.backdrop.classList.remove('backdrop-active');
|
|
534
|
+
|
|
535
|
+
const rect = targetEl.getBoundingClientRect();
|
|
536
|
+
console.log(`${this.lc} [positionElements] Target #${step.targetElementId} rect:`, rect);
|
|
537
|
+
const pad = 6;
|
|
538
|
+
|
|
539
|
+
// Position Spotlight Ring around Target with 100% transparent cutout
|
|
540
|
+
this.spotlightRing.style.top = `${Math.max(0, rect.top - pad)}px`;
|
|
541
|
+
this.spotlightRing.style.left = `${Math.max(0, rect.left - pad)}px`;
|
|
542
|
+
this.spotlightRing.style.width = `${rect.width + pad * 2}px`;
|
|
543
|
+
this.spotlightRing.style.height = `${rect.height + pad * 2}px`;
|
|
544
|
+
|
|
545
|
+
// Position Beacon adjacent to target
|
|
546
|
+
const beaconTop = Math.max(10, rect.top + 8);
|
|
547
|
+
const beaconLeft = Math.min(window.innerWidth - 180, Math.max(10, rect.right - 140));
|
|
548
|
+
this.beacon.style.top = `${beaconTop}px`;
|
|
549
|
+
this.beacon.style.left = `${beaconLeft}px`;
|
|
550
|
+
this.beacon.style.transform = 'none';
|
|
551
|
+
|
|
552
|
+
// Position Card according to placement
|
|
553
|
+
const cardWidth = 440;
|
|
554
|
+
const cardGap = 16;
|
|
555
|
+
let cardTop = 0;
|
|
556
|
+
let cardLeft = 0;
|
|
557
|
+
|
|
558
|
+
switch (placement) {
|
|
559
|
+
case 'bottom':
|
|
560
|
+
cardTop = rect.bottom + cardGap;
|
|
561
|
+
cardLeft = Math.max(16, Math.min(window.innerWidth - cardWidth - 16, rect.left + (rect.width / 2) - (cardWidth / 2)));
|
|
562
|
+
break;
|
|
563
|
+
case 'top':
|
|
564
|
+
cardTop = Math.max(16, rect.top - 280 - cardGap);
|
|
565
|
+
cardLeft = Math.max(16, Math.min(window.innerWidth - cardWidth - 16, rect.left + (rect.width / 2) - (cardWidth / 2)));
|
|
566
|
+
break;
|
|
567
|
+
case 'right':
|
|
568
|
+
cardTop = Math.max(16, Math.min(window.innerHeight - 360, rect.top));
|
|
569
|
+
cardLeft = Math.min(window.innerWidth - cardWidth - 16, rect.right + cardGap);
|
|
570
|
+
break;
|
|
571
|
+
case 'left':
|
|
572
|
+
cardTop = Math.max(16, Math.min(window.innerHeight - 360, rect.top));
|
|
573
|
+
cardLeft = Math.max(16, rect.left - cardWidth - cardGap);
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Clamp inside window boundaries
|
|
578
|
+
cardTop = Math.max(16, Math.min(window.innerHeight - 320, cardTop));
|
|
579
|
+
cardLeft = Math.max(16, Math.min(window.innerWidth - cardWidth - 16, cardLeft));
|
|
580
|
+
|
|
581
|
+
this.card.style.top = `${cardTop}px`;
|
|
582
|
+
this.card.style.left = `${cardLeft}px`;
|
|
583
|
+
|
|
584
|
+
console.log(`${this.lc} [positionElements] Successfully positioned: card=(${cardLeft}px, ${cardTop}px), beacon=(${beaconLeft}px, ${beaconTop}px)`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private handleResize(): void {
|
|
588
|
+
if (!this.active || !this.script) return;
|
|
589
|
+
const step = this.script.steps[this.currentStepIndex];
|
|
590
|
+
if (step) {
|
|
591
|
+
this.positionElements(step);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private handleKeydown(e: KeyboardEvent): void {
|
|
596
|
+
if (!this.active) return;
|
|
597
|
+
|
|
598
|
+
if (e.key === 'Escape') {
|
|
599
|
+
console.log(`${this.lc} [keydown] Escape pressed -> stop()`);
|
|
600
|
+
e.preventDefault();
|
|
601
|
+
this.stop();
|
|
602
|
+
} else if (e.key === 'ArrowRight' || e.key === 'Enter') {
|
|
603
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
604
|
+
console.log(`${this.lc} [keydown] ${e.key} pressed -> next()`);
|
|
605
|
+
e.preventDefault();
|
|
606
|
+
this.next();
|
|
607
|
+
} else if (e.key === 'ArrowLeft') {
|
|
608
|
+
console.log(`${this.lc} [keydown] ArrowLeft pressed -> prev()`);
|
|
609
|
+
e.preventDefault();
|
|
610
|
+
this.prev();
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Module-level singleton
|
|
616
|
+
let _tutorialController: TutorialController | undefined;
|
|
617
|
+
export function getTutorialController(): TutorialController {
|
|
618
|
+
if (!_tutorialController) {
|
|
619
|
+
_tutorialController = new TutorialController();
|
|
620
|
+
}
|
|
621
|
+
return _tutorialController;
|
|
622
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module tutorial-types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for the generic Space-Gib Tutorial Runner.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface TutorialActionContext {
|
|
8
|
+
shell: any;
|
|
9
|
+
metaspace?: any;
|
|
10
|
+
space?: any;
|
|
11
|
+
galaxyName: string;
|
|
12
|
+
branchName: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TutorialPlacement = 'top' | 'bottom' | 'left' | 'right' | 'center';
|
|
16
|
+
|
|
17
|
+
export interface TutorialStep {
|
|
18
|
+
/** Unique step identifier */
|
|
19
|
+
id: string;
|
|
20
|
+
/** Header title displayed on the narration card */
|
|
21
|
+
title: string;
|
|
22
|
+
/** Formatted HTML / Markdown narration content */
|
|
23
|
+
narration: string;
|
|
24
|
+
/** Target DOM element ID to spotlight and anchor to */
|
|
25
|
+
targetElementId?: string;
|
|
26
|
+
/** Placement of the card relative to the target element */
|
|
27
|
+
placement?: TutorialPlacement;
|
|
28
|
+
/** Text for the action / advance button */
|
|
29
|
+
buttonText?: string;
|
|
30
|
+
/** Label for the golden beacon */
|
|
31
|
+
beaconLabel?: string;
|
|
32
|
+
/** Optional async action executed when step becomes active */
|
|
33
|
+
action?: (context: TutorialActionContext) => Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Optional async in-between transition action executed when advancing from this step to the next.
|
|
36
|
+
* The overlay card, beacon, and spotlight are hidden while this action executes so the screen is clear.
|
|
37
|
+
*/
|
|
38
|
+
transitionAction?: (context: TutorialActionContext, controller: any) => Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TutorialScript {
|
|
42
|
+
id: string;
|
|
43
|
+
title: string;
|
|
44
|
+
steps: TutorialStep[];
|
|
45
|
+
}
|