@geminilight/mindos 0.5.28 → 0.5.29

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.
@@ -33,7 +33,7 @@ export interface SettingsData {
33
33
  envValues?: Record<string, string>;
34
34
  }
35
35
 
36
- export type Tab = 'ai' | 'appearance' | 'knowledge' | 'mcp' | 'sync';
36
+ export type Tab = 'ai' | 'appearance' | 'knowledge' | 'mcp' | 'sync' | 'update';
37
37
 
38
38
  export const CONTENT_WIDTHS = [
39
39
  { value: '680px', label: 'Narrow (680px)' },
@@ -0,0 +1,224 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useId } from 'react';
4
+ import { useLocale } from '@/lib/LocaleContext';
5
+ import { useWalkthrough } from './WalkthroughProvider';
6
+ import { walkthroughSteps } from './steps';
7
+ import WalkthroughTooltip from './WalkthroughTooltip';
8
+
9
+ /**
10
+ * Full-screen overlay with SVG spotlight mask.
11
+ * Finds the target element via data-walkthrough attribute, measures it,
12
+ * and cuts a transparent rect into the semi-transparent overlay.
13
+ */
14
+ export default function WalkthroughOverlay() {
15
+ const wt = useWalkthrough();
16
+ const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
17
+ const [isMobile, setIsMobile] = useState(false);
18
+ const maskId = useId();
19
+
20
+ const step = wt ? walkthroughSteps[wt.currentStep] : null;
21
+
22
+ const measureTarget = useCallback(() => {
23
+ if (!step) return;
24
+ const el = document.querySelector(`[data-walkthrough="${step.anchor}"]`);
25
+ if (el) {
26
+ setTargetRect(el.getBoundingClientRect());
27
+ } else {
28
+ setTargetRect(null);
29
+ }
30
+ }, [step]);
31
+
32
+ useEffect(() => {
33
+ setIsMobile(window.innerWidth < 768);
34
+ measureTarget();
35
+
36
+ const handleResize = () => {
37
+ setIsMobile(window.innerWidth < 768);
38
+ measureTarget();
39
+ };
40
+
41
+ window.addEventListener('resize', handleResize);
42
+ window.addEventListener('scroll', measureTarget, true);
43
+ return () => {
44
+ window.removeEventListener('resize', handleResize);
45
+ window.removeEventListener('scroll', measureTarget, true);
46
+ };
47
+ }, [measureTarget]);
48
+
49
+ // Re-measure when step changes
50
+ useEffect(() => {
51
+ measureTarget();
52
+ // Slight delay to account for animations
53
+ const timer = setTimeout(measureTarget, 100);
54
+ return () => clearTimeout(timer);
55
+ }, [wt?.currentStep, measureTarget]);
56
+
57
+ // ESC to dismiss — depend on skip only, not the entire context object
58
+ const skipFn = wt?.skip;
59
+ useEffect(() => {
60
+ if (!skipFn) return;
61
+ const handler = (e: KeyboardEvent) => {
62
+ if (e.key === 'Escape') {
63
+ e.stopPropagation();
64
+ skipFn();
65
+ }
66
+ };
67
+ window.addEventListener('keydown', handler, true);
68
+ return () => window.removeEventListener('keydown', handler, true);
69
+ }, [skipFn]);
70
+
71
+ if (!wt || !step) return null;
72
+
73
+ // Mobile: bottom sheet instead of spotlight
74
+ if (isMobile) {
75
+ return <MobileWalkthroughSheet />;
76
+ }
77
+
78
+ const PAD = 6; // padding around spotlight rect
79
+ const RADIUS = 8;
80
+
81
+ return (
82
+ <>
83
+ {/* SVG overlay with spotlight hole */}
84
+ <svg
85
+ className="fixed inset-0 z-[100] pointer-events-auto"
86
+ width="100%"
87
+ height="100%"
88
+ onClick={(e) => {
89
+ // Click outside spotlight → skip
90
+ if (targetRect) {
91
+ const x = e.clientX;
92
+ const y = e.clientY;
93
+ const inSpotlight =
94
+ x >= targetRect.left - PAD &&
95
+ x <= targetRect.right + PAD &&
96
+ y >= targetRect.top - PAD &&
97
+ y <= targetRect.bottom + PAD;
98
+ if (!inSpotlight) {
99
+ wt.skip();
100
+ }
101
+ }
102
+ }}
103
+ >
104
+ <defs>
105
+ <mask id={maskId}>
106
+ <rect width="100%" height="100%" fill="white" />
107
+ {targetRect && (
108
+ <rect
109
+ x={targetRect.left - PAD}
110
+ y={targetRect.top - PAD}
111
+ width={targetRect.width + PAD * 2}
112
+ height={targetRect.height + PAD * 2}
113
+ rx={RADIUS}
114
+ ry={RADIUS}
115
+ fill="black"
116
+ />
117
+ )}
118
+ </mask>
119
+ </defs>
120
+ <rect
121
+ width="100%"
122
+ height="100%"
123
+ fill="rgba(0, 0, 0, 0.5)"
124
+ mask={`url(#${maskId})`}
125
+ />
126
+ </svg>
127
+
128
+ {/* Tooltip */}
129
+ {targetRect && (
130
+ <WalkthroughTooltip
131
+ stepIndex={wt.currentStep}
132
+ rect={targetRect}
133
+ position={step.position}
134
+ />
135
+ )}
136
+ </>
137
+ );
138
+ }
139
+
140
+ /** Mobile fallback: bottom sheet card */
141
+ function MobileWalkthroughSheet() {
142
+ const wt = useWalkthrough();
143
+ const { t } = useLocale();
144
+
145
+ if (!wt) return null;
146
+
147
+ const stepData = t.walkthrough.steps[wt.currentStep] as { title: string; body: string } | undefined;
148
+ if (!stepData) return null;
149
+
150
+ return (
151
+ <>
152
+ {/* Backdrop */}
153
+ <div
154
+ className="fixed inset-0 z-[100] bg-black/40 backdrop-blur-sm"
155
+ onClick={wt.skip}
156
+ />
157
+ {/* Bottom sheet */}
158
+ <div
159
+ className="fixed bottom-0 left-0 right-0 z-[101] rounded-t-2xl border-t shadow-lg p-5 pb-8 animate-in slide-in-from-bottom duration-300"
160
+ style={{ background: 'var(--card)', borderColor: 'var(--border)' }}
161
+ >
162
+ {/* Step counter */}
163
+ <div className="flex items-center gap-2 mb-3">
164
+ <span
165
+ className="text-2xs font-mono px-1.5 py-0.5 rounded-full"
166
+ style={{ background: 'var(--amber-dim)', color: 'var(--amber)' }}
167
+ >
168
+ {t.walkthrough.step(wt.currentStep + 1, wt.totalSteps)}
169
+ </span>
170
+ {/* Progress dots */}
171
+ <div className="flex items-center gap-1 ml-auto">
172
+ {Array.from({ length: wt.totalSteps }, (_, i) => (
173
+ <div
174
+ key={i}
175
+ className="w-1.5 h-1.5 rounded-full"
176
+ style={{
177
+ background: i === wt.currentStep ? 'var(--amber)' : 'var(--border)',
178
+ }}
179
+ />
180
+ ))}
181
+ </div>
182
+ </div>
183
+
184
+ <h3
185
+ className="text-base font-semibold font-display mb-1"
186
+ style={{ color: 'var(--foreground)' }}
187
+ >
188
+ {stepData.title}
189
+ </h3>
190
+ <p className="text-sm leading-relaxed mb-5" style={{ color: 'var(--muted-foreground)' }}>
191
+ {stepData.body}
192
+ </p>
193
+
194
+ <div className="flex items-center justify-between">
195
+ <button
196
+ onClick={wt.skip}
197
+ className="text-sm transition-colors hover:opacity-80"
198
+ style={{ color: 'var(--muted-foreground)' }}
199
+ >
200
+ {t.walkthrough.skip}
201
+ </button>
202
+ <div className="flex items-center gap-2">
203
+ {wt.currentStep > 0 && (
204
+ <button
205
+ onClick={wt.back}
206
+ className="text-sm px-3 py-2 rounded-lg transition-colors hover:bg-muted"
207
+ style={{ color: 'var(--muted-foreground)' }}
208
+ >
209
+ {t.walkthrough.back}
210
+ </button>
211
+ )}
212
+ <button
213
+ onClick={wt.next}
214
+ className="text-sm px-4 py-2 rounded-lg font-medium transition-all hover:opacity-90"
215
+ style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
216
+ >
217
+ {wt.currentStep === wt.totalSteps - 1 ? t.walkthrough.done : t.walkthrough.next}
218
+ </button>
219
+ </div>
220
+ </div>
221
+ </div>
222
+ </>
223
+ );
224
+ }
@@ -0,0 +1,133 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
4
+ import { walkthroughSteps } from './steps';
5
+ import WalkthroughOverlay from './WalkthroughOverlay';
6
+
7
+ type WalkthroughStatus = 'idle' | 'active' | 'completed' | 'dismissed';
8
+
9
+ interface WalkthroughContextValue {
10
+ status: WalkthroughStatus;
11
+ currentStep: number;
12
+ totalSteps: number;
13
+ start: () => void;
14
+ next: () => void;
15
+ back: () => void;
16
+ skip: () => void;
17
+ }
18
+
19
+ const WalkthroughContext = createContext<WalkthroughContextValue | null>(null);
20
+
21
+ export function useWalkthrough() {
22
+ return useContext(WalkthroughContext);
23
+ }
24
+
25
+ interface WalkthroughProviderProps {
26
+ children: ReactNode;
27
+ }
28
+
29
+ export default function WalkthroughProvider({ children }: WalkthroughProviderProps) {
30
+ const [status, setStatus] = useState<WalkthroughStatus>('idle');
31
+ const [currentStep, setCurrentStep] = useState(0);
32
+ const totalSteps = walkthroughSteps.length;
33
+
34
+ // Persist to backend
35
+ const persistStep = useCallback((step: number, dismissed: boolean) => {
36
+ fetch('/api/setup', {
37
+ method: 'PATCH',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({
40
+ guideState: {
41
+ walkthroughStep: step,
42
+ walkthroughDismissed: dismissed,
43
+ },
44
+ }),
45
+ }).catch(() => {});
46
+ }, []);
47
+
48
+ // Check for auto-start via ?welcome=1 or guideState
49
+ useEffect(() => {
50
+ // Handle ?welcome=1 URL param — single owner, clean up immediately
51
+ const params = new URLSearchParams(window.location.search);
52
+ const isWelcome = params.get('welcome') === '1';
53
+ if (isWelcome) {
54
+ const url = new URL(window.location.href);
55
+ url.searchParams.delete('welcome');
56
+ window.history.replaceState({}, '', url.pathname + (url.search || ''));
57
+ // Notify GuideCard about first visit
58
+ window.dispatchEvent(new Event('mindos:first-visit'));
59
+ }
60
+
61
+ // Only auto-start walkthrough on desktop
62
+ if (window.innerWidth < 768) return;
63
+
64
+ fetch('/api/setup')
65
+ .then(r => r.json())
66
+ .then(data => {
67
+ const gs = data.guideState;
68
+ if (!gs) return;
69
+ // Already dismissed or completed
70
+ if (gs.walkthroughDismissed) return;
71
+ // Check if walkthrough should start
72
+ if (gs.active && !gs.dismissed && gs.walkthroughStep === undefined) {
73
+ // First time — only start if ?welcome=1 was present
74
+ if (isWelcome) {
75
+ setStatus('active');
76
+ setCurrentStep(0);
77
+ }
78
+ } else if (typeof gs.walkthroughStep === 'number' && gs.walkthroughStep >= 0 && gs.walkthroughStep < totalSteps && !gs.walkthroughDismissed) {
79
+ // Resume walkthrough
80
+ setStatus('active');
81
+ setCurrentStep(gs.walkthroughStep);
82
+ }
83
+ })
84
+ .catch(() => {});
85
+ }, [totalSteps]);
86
+
87
+ const start = useCallback(() => {
88
+ setCurrentStep(0);
89
+ setStatus('active');
90
+ persistStep(0, false);
91
+ }, [persistStep]);
92
+
93
+ const next = useCallback(() => {
94
+ const nextStep = currentStep + 1;
95
+ if (nextStep >= totalSteps) {
96
+ setStatus('completed');
97
+ persistStep(totalSteps, false);
98
+ } else {
99
+ setCurrentStep(nextStep);
100
+ persistStep(nextStep, false);
101
+ }
102
+ }, [currentStep, totalSteps, persistStep]);
103
+
104
+ const back = useCallback(() => {
105
+ if (currentStep > 0) {
106
+ const prevStep = currentStep - 1;
107
+ setCurrentStep(prevStep);
108
+ persistStep(prevStep, false);
109
+ }
110
+ }, [currentStep, persistStep]);
111
+
112
+ const skip = useCallback(() => {
113
+ setStatus('dismissed');
114
+ persistStep(currentStep, true);
115
+ }, [currentStep, persistStep]);
116
+
117
+ const value: WalkthroughContextValue = {
118
+ status,
119
+ currentStep,
120
+ totalSteps,
121
+ start,
122
+ next,
123
+ back,
124
+ skip,
125
+ };
126
+
127
+ return (
128
+ <WalkthroughContext.Provider value={value}>
129
+ {children}
130
+ {status === 'active' && <WalkthroughOverlay />}
131
+ </WalkthroughContext.Provider>
132
+ );
133
+ }
@@ -0,0 +1,129 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { useLocale } from '@/lib/LocaleContext';
5
+ import { useWalkthrough } from './WalkthroughProvider';
6
+
7
+ interface WalkthroughTooltipProps {
8
+ stepIndex: number;
9
+ rect: DOMRect;
10
+ position: 'right' | 'bottom';
11
+ }
12
+
13
+ export default function WalkthroughTooltip({ stepIndex, rect, position }: WalkthroughTooltipProps) {
14
+ const wt = useWalkthrough();
15
+ const { t } = useLocale();
16
+
17
+ // Track viewport dimensions in state to avoid stale values and SSR hazard
18
+ const [viewport, setViewport] = useState({ width: 1024, height: 768 });
19
+ useEffect(() => {
20
+ const update = () => setViewport({ width: window.innerWidth, height: window.innerHeight });
21
+ update();
22
+ window.addEventListener('resize', update);
23
+ return () => window.removeEventListener('resize', update);
24
+ }, []);
25
+
26
+ if (!wt) return null;
27
+
28
+ const stepData = t.walkthrough.steps[stepIndex] as { title: string; body: string } | undefined;
29
+ if (!stepData) return null;
30
+
31
+ // Calculate tooltip position
32
+ const GAP = 12;
33
+ let top: number;
34
+ let left: number;
35
+
36
+ if (position === 'right') {
37
+ top = rect.top + rect.height / 2;
38
+ left = rect.right + GAP;
39
+ } else {
40
+ top = rect.bottom + GAP;
41
+ left = rect.left + rect.width / 2;
42
+ }
43
+
44
+ // Clamp to viewport
45
+ const maxLeft = viewport.width - 320;
46
+ const maxTop = viewport.height - 200;
47
+ left = Math.min(left, maxLeft);
48
+ top = Math.min(top, maxTop);
49
+ top = Math.max(top, 8);
50
+
51
+ return (
52
+ <div
53
+ className="fixed z-[102] w-[280px] rounded-xl border shadow-lg animate-in fade-in slide-in-from-left-2 duration-200"
54
+ style={{
55
+ top: `${top}px`,
56
+ left: `${left}px`,
57
+ transform: position === 'right' ? 'translateY(-50%)' : 'translateX(-50%)',
58
+ background: 'var(--card)',
59
+ borderColor: 'var(--amber)',
60
+ }}
61
+ >
62
+ <div className="p-4">
63
+ {/* Step indicator */}
64
+ <div className="flex items-center gap-2 mb-2">
65
+ <span
66
+ className="text-2xs font-mono px-1.5 py-0.5 rounded-full"
67
+ style={{ background: 'var(--amber-dim)', color: 'var(--amber)' }}
68
+ >
69
+ {t.walkthrough.step(stepIndex + 1, wt.totalSteps)}
70
+ </span>
71
+ </div>
72
+
73
+ {/* Content */}
74
+ <h3
75
+ className="text-sm font-semibold font-display mb-1"
76
+ style={{ color: 'var(--foreground)' }}
77
+ >
78
+ {stepData.title}
79
+ </h3>
80
+ <p className="text-xs leading-relaxed" style={{ color: 'var(--muted-foreground)' }}>
81
+ {stepData.body}
82
+ </p>
83
+
84
+ {/* Actions */}
85
+ <div className="flex items-center justify-between mt-4 pt-3" style={{ borderTop: '1px solid var(--border)' }}>
86
+ <button
87
+ onClick={wt.skip}
88
+ className="text-xs transition-colors hover:opacity-80"
89
+ style={{ color: 'var(--muted-foreground)' }}
90
+ >
91
+ {t.walkthrough.skip}
92
+ </button>
93
+ <div className="flex items-center gap-2">
94
+ {stepIndex > 0 && (
95
+ <button
96
+ onClick={wt.back}
97
+ className="text-xs px-2.5 py-1 rounded-lg transition-colors hover:bg-muted"
98
+ style={{ color: 'var(--muted-foreground)' }}
99
+ >
100
+ {t.walkthrough.back}
101
+ </button>
102
+ )}
103
+ <button
104
+ onClick={wt.next}
105
+ className="text-xs px-3 py-1.5 rounded-lg font-medium transition-all hover:opacity-90"
106
+ style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
107
+ >
108
+ {stepIndex === wt.totalSteps - 1 ? t.walkthrough.done : t.walkthrough.next}
109
+ </button>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ {/* Progress dots */}
115
+ <div className="flex items-center justify-center gap-1.5 pb-3">
116
+ {Array.from({ length: wt.totalSteps }, (_, i) => (
117
+ <div
118
+ key={i}
119
+ className="w-1.5 h-1.5 rounded-full transition-all duration-200"
120
+ style={{
121
+ background: i === stepIndex ? 'var(--amber)' : 'var(--border)',
122
+ transform: i === stepIndex ? 'scale(1.3)' : undefined,
123
+ }}
124
+ />
125
+ ))}
126
+ </div>
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,3 @@
1
+ export { default as WalkthroughProvider, useWalkthrough } from './WalkthroughProvider';
2
+ export { walkthroughSteps } from './steps';
3
+ export type { WalkthroughAnchor } from './steps';
@@ -0,0 +1,21 @@
1
+ /** Walkthrough step anchors — these data-walkthrough attributes are added to target components */
2
+ export type WalkthroughAnchor =
3
+ | 'activity-bar'
4
+ | 'files-panel'
5
+ | 'ask-button'
6
+ | 'search-button'
7
+ | 'settings-button';
8
+
9
+ export interface WalkthroughStep {
10
+ anchor: WalkthroughAnchor;
11
+ /** Preferred tooltip position relative to anchor */
12
+ position: 'right' | 'bottom';
13
+ }
14
+
15
+ export const walkthroughSteps: WalkthroughStep[] = [
16
+ { anchor: 'activity-bar', position: 'right' },
17
+ { anchor: 'files-panel', position: 'right' },
18
+ { anchor: 'ask-button', position: 'right' },
19
+ { anchor: 'search-button', position: 'right' },
20
+ { anchor: 'settings-button', position: 'right' },
21
+ ];