@cryptiklemur/lattice 0.0.0 → 1.1.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 (41) hide show
  1. package/.github/workflows/release.yml +4 -4
  2. package/.releaserc.json +2 -1
  3. package/client/src/components/auth/PassphrasePrompt.tsx +70 -70
  4. package/client/src/components/mesh/NodeBadge.tsx +24 -24
  5. package/client/src/components/mesh/PairingDialog.tsx +281 -281
  6. package/client/src/components/panels/FileBrowser.tsx +241 -241
  7. package/client/src/components/panels/StickyNotes.tsx +187 -187
  8. package/client/src/components/settings/Appearance.tsx +151 -151
  9. package/client/src/components/settings/MeshStatus.tsx +145 -145
  10. package/client/src/components/settings/SettingsView.tsx +57 -57
  11. package/client/src/components/setup/SetupWizard.tsx +750 -750
  12. package/client/src/components/ui/ErrorBoundary.tsx +56 -56
  13. package/client/src/router.tsx +391 -391
  14. package/client/vite.config.ts +20 -20
  15. package/package.json +1 -1
  16. package/server/src/handlers/chat.ts +194 -194
  17. package/server/src/handlers/settings.ts +109 -109
  18. package/themes/amoled.json +20 -20
  19. package/themes/ayu-light.json +9 -9
  20. package/themes/catppuccin-latte.json +9 -9
  21. package/themes/catppuccin-mocha.json +9 -9
  22. package/themes/clay-light.json +10 -10
  23. package/themes/clay.json +10 -10
  24. package/themes/dracula.json +9 -9
  25. package/themes/everforest-light.json +9 -9
  26. package/themes/everforest.json +9 -9
  27. package/themes/github-light.json +9 -9
  28. package/themes/gruvbox-dark.json +9 -9
  29. package/themes/gruvbox-light.json +9 -9
  30. package/themes/monokai.json +9 -9
  31. package/themes/nord-light.json +9 -9
  32. package/themes/nord.json +9 -9
  33. package/themes/one-dark.json +9 -9
  34. package/themes/one-light.json +9 -9
  35. package/themes/rose-pine-dawn.json +9 -9
  36. package/themes/rose-pine.json +9 -9
  37. package/themes/solarized-dark.json +9 -9
  38. package/themes/solarized-light.json +9 -9
  39. package/themes/tokyo-night-light.json +9 -9
  40. package/themes/tokyo-night.json +9 -9
  41. package/.serena/project.yml +0 -138
@@ -1,750 +1,750 @@
1
- import { useState, useEffect } from "react";
2
- import { ArrowRight, ChevronRight, ChevronLeft, Server, Palette, Lock, Folder, Info, Moon, Sun, Check, CheckCircle } from "lucide-react";
3
- import { useTheme } from "../../hooks/useTheme";
4
- import { useWebSocket } from "../../hooks/useWebSocket";
5
- import { themes } from "../../themes/index";
6
- import type { ThemeEntry } from "../../themes/index";
7
- import { LatticeLogomark } from "../ui/LatticeLogomark";
8
-
9
- var POPULAR_DARK_THEMES = ["dracula", "catppuccin-mocha", "tokyo-night", "one-dark", "amoled"];
10
- var POPULAR_LIGHT_THEMES = ["ayu-light", "catppuccin-latte", "github-light", "one-light", "rose-pine-dawn"];
11
-
12
- var TOTAL_STEPS = 6;
13
-
14
- interface SetupWizardProps {
15
- onComplete: () => void;
16
- }
17
-
18
- export function SetupWizard(props: SetupWizardProps) {
19
- var [step, setStep] = useState(1);
20
- var [prevStep, setPrevStep] = useState(1);
21
- var [animating, setAnimating] = useState(false);
22
- var [nodeName, setNodeName] = useState("");
23
- var [passphrase, setPassphrase] = useState("");
24
- var [passphraseConfirm, setPassphraseConfirm] = useState("");
25
- var [passphraseError, setPassphraseError] = useState("");
26
- var [projectPath, setProjectPath] = useState("");
27
- var [projectTitle, setProjectTitle] = useState("");
28
- var [configured, setConfigured] = useState<string[]>([]);
29
-
30
- var theme = useTheme();
31
- var ws = useWebSocket();
32
-
33
- function navigateTo(next: number) {
34
- if (animating) return;
35
- setPrevStep(step);
36
- setAnimating(true);
37
- setTimeout(function () {
38
- setStep(next);
39
- setAnimating(false);
40
- }, 180);
41
- }
42
-
43
- function goNext() {
44
- navigateTo(Math.min(step + 1, TOTAL_STEPS));
45
- }
46
-
47
- function goBack() {
48
- navigateTo(Math.max(step - 1, 1));
49
- }
50
-
51
- function skipToNext() {
52
- goNext();
53
- }
54
-
55
- function handleNameNext() {
56
- var name = nodeName.trim();
57
- if (name.length > 0) {
58
- ws.send({ type: "settings:update", settings: { name } });
59
- setConfigured(function (c) { return [...c.filter(function (x) { return x !== "name"; }), "name: " + name]; });
60
- }
61
- goNext();
62
- }
63
-
64
- function handleAppearanceNext() {
65
- setConfigured(function (c) {
66
- var label = "theme: " + theme.currentThemeId + " (" + theme.mode + ")";
67
- return [...c.filter(function (x) { return !x.startsWith("theme:"); }), label];
68
- });
69
- goNext();
70
- }
71
-
72
- function handleSecurityNext() {
73
- if (passphrase.length === 0) {
74
- goNext();
75
- return;
76
- }
77
- if (passphrase !== passphraseConfirm) {
78
- setPassphraseError("Passphrases do not match");
79
- return;
80
- }
81
- ws.send({ type: "settings:update", settings: { passphraseHash: passphrase } });
82
- setConfigured(function (c) { return [...c.filter(function (x) { return x !== "security"; }), "security: passphrase set"]; });
83
- goNext();
84
- }
85
-
86
- function handleProjectNext() {
87
- var path = projectPath.trim();
88
- if (path.length > 0) {
89
- var derivedName = path.replace(/\/+$/, "").split("/").pop() || path;
90
- var title = projectTitle.trim() || derivedName;
91
- ws.send({ type: "settings:update", settings: { projects: [{ path: path, slug: "", title: title, env: {} }] } });
92
- setConfigured(function (c) { return [...c.filter(function (x) { return !x.startsWith("project:"); }), "project: " + title]; });
93
- }
94
- goNext();
95
- }
96
-
97
- function handleDone() {
98
- localStorage.setItem("lattice-setup-complete", "1");
99
- props.onComplete();
100
- }
101
-
102
- var darkQuickPicks = themes.filter(function (e: ThemeEntry) { return POPULAR_DARK_THEMES.includes(e.id); });
103
- var lightQuickPicks = themes.filter(function (e: ThemeEntry) { return POPULAR_LIGHT_THEMES.includes(e.id); });
104
- var isForward = step > prevStep;
105
-
106
- return (
107
- <div className="fixed inset-0 z-[9999] flex items-center justify-center bg-base-100 transition-colors duration-300">
108
- <style>{wizardCSS}</style>
109
-
110
- {step === 1 ? (
111
- <div className="relative w-full max-w-[520px] px-6 py-12 flex flex-col items-center text-center">
112
- <div
113
- className="fixed inset-0 pointer-events-none z-0"
114
- aria-hidden="true"
115
- style={{
116
- backgroundImage: "linear-gradient(oklch(from var(--color-primary) l c h / 0.07) 1px, transparent 1px), linear-gradient(90deg, oklch(from var(--color-primary) l c h / 0.07) 1px, transparent 1px)",
117
- backgroundSize: "40px 40px",
118
- animation: "wizard-grid-shift 8s linear infinite",
119
- }}
120
- />
121
- <div className="relative z-[1] flex flex-col items-center gap-0">
122
- <div className="wizard-fade-in text-primary" style={{ animationDelay: "0ms" }}>
123
- <LatticeLogomark size={64} />
124
- </div>
125
- <h1
126
- className="wizard-fade-in font-mono font-bold text-base-content leading-none mt-5 mb-0"
127
- style={{ fontSize: "clamp(48px, 10vw, 72px)", letterSpacing: "-0.04em", animationDelay: "80ms" }}
128
- >
129
- Lattice
130
- </h1>
131
- <p className="wizard-fade-in text-[17px] text-base-content/60 mt-3 mb-8 tracking-[0.01em]" style={{ animationDelay: "160ms" }}>
132
- One dashboard. Every machine.
133
- </p>
134
- <div className="wizard-fade-in" style={{ animationDelay: "240ms" }}>
135
- <TerminalPreview />
136
- </div>
137
- <div className="wizard-fade-in" style={{ animationDelay: "320ms" }}>
138
- <button
139
- onClick={goNext}
140
- className="wizard-btn-primary btn btn-primary inline-flex items-center gap-2 mt-7 h-[52px] px-8 text-[15px] font-semibold cursor-pointer"
141
- >
142
- Get Started
143
- <ArrowRight size={16} />
144
- </button>
145
- </div>
146
- <p className="wizard-fade-in text-[12px] text-base-content/30 mt-4" style={{ animationDelay: "380ms" }}>
147
- Takes about 2 minutes
148
- </p>
149
- </div>
150
- </div>
151
- ) : (
152
- <div className="w-[480px] max-w-[calc(100vw-24px)] bg-base-200 border border-base-300 rounded-xl flex flex-col overflow-hidden shadow-2xl">
153
- <div className="flex items-center justify-between px-6 py-4 border-b border-base-300 bg-base-100">
154
- <div className="flex items-center gap-1.5">
155
- {Array.from({ length: TOTAL_STEPS - 1 }, function (_, i) {
156
- var dotStep = i + 2;
157
- var isComplete = step > dotStep;
158
- var isActive = step === dotStep;
159
- return (
160
- <div
161
- key={i}
162
- className="h-1.5 rounded-full transition-all duration-[250ms]"
163
- style={{
164
- background: (isComplete || isActive) ? "var(--color-primary)" : "var(--color-base-300)",
165
- opacity: isActive ? 1 : isComplete ? 0.7 : 0.35,
166
- width: isActive ? "24px" : "8px",
167
- }}
168
- />
169
- );
170
- })}
171
- </div>
172
- <span className="text-[11px] text-base-content/40 font-mono tracking-[0.06em]">
173
- {step - 1} / {TOTAL_STEPS - 1}
174
- </span>
175
- </div>
176
-
177
- <div
178
- className={"px-7 pt-7 pb-2 flex-1 min-h-[320px] " + (animating ? (isForward ? "wizard-slide-out-left" : "wizard-slide-out-right") : (isForward ? "wizard-slide-in-right" : "wizard-slide-in-left"))}
179
- key={step}
180
- >
181
- {step === 2 && (
182
- <NameStep value={nodeName} onChange={setNodeName} />
183
- )}
184
- {step === 3 && (
185
- <AppearanceStep
186
- theme={theme}
187
- darkQuickPicks={darkQuickPicks}
188
- lightQuickPicks={lightQuickPicks}
189
- />
190
- )}
191
- {step === 4 && (
192
- <SecurityStep
193
- passphrase={passphrase}
194
- passphraseConfirm={passphraseConfirm}
195
- error={passphraseError}
196
- onPassphraseChange={function (v: string) {
197
- setPassphrase(v);
198
- setPassphraseError("");
199
- }}
200
- onConfirmChange={function (v: string) {
201
- setPassphraseConfirm(v);
202
- setPassphraseError("");
203
- }}
204
- />
205
- )}
206
- {step === 5 && (
207
- <ProjectStep
208
- path={projectPath}
209
- title={projectTitle}
210
- onPathChange={setProjectPath}
211
- onTitleChange={setProjectTitle}
212
- />
213
- )}
214
- {step === 6 && <DoneStep configured={configured} />}
215
- </div>
216
-
217
- <div className="flex items-center gap-2 px-6 py-4 border-t border-base-300 bg-base-100">
218
- {step >= 2 && step < TOTAL_STEPS && (
219
- <button onClick={goBack} className="wizard-btn-back btn btn-ghost btn-sm gap-1 text-base-content/40">
220
- <ChevronLeft size={14} />
221
- Back
222
- </button>
223
- )}
224
- <div className="flex items-center gap-2 ml-auto">
225
- {step === 2 && (
226
- <button onClick={skipToNext} className="wizard-btn-skip btn btn-ghost btn-sm text-base-content/40">Skip</button>
227
- )}
228
- {step === 3 && (
229
- <button onClick={handleAppearanceNext} className="wizard-btn-primary btn btn-primary btn-sm gap-1">
230
- Continue
231
- <ChevronRight size={14} />
232
- </button>
233
- )}
234
- {step === 4 && (
235
- <>
236
- <button onClick={skipToNext} className="wizard-btn-skip btn btn-ghost btn-sm text-base-content/40">Skip</button>
237
- <button onClick={handleSecurityNext} className="wizard-btn-primary btn btn-primary btn-sm gap-1">
238
- Continue
239
- <ChevronRight size={14} />
240
- </button>
241
- </>
242
- )}
243
- {step === 5 && (
244
- <>
245
- <button onClick={skipToNext} className="wizard-btn-skip btn btn-ghost btn-sm text-base-content/40">Skip</button>
246
- <button onClick={handleProjectNext} className="wizard-btn-primary btn btn-primary btn-sm gap-1">
247
- Add &amp; Continue
248
- <ChevronRight size={14} />
249
- </button>
250
- </>
251
- )}
252
- {step === 6 && (
253
- <button onClick={handleDone} className="wizard-btn-done btn btn-success btn-sm gap-2 font-bold">
254
- Open Dashboard
255
- <ArrowRight size={16} />
256
- </button>
257
- )}
258
- {step === 2 && (
259
- <button onClick={handleNameNext} className="wizard-btn-primary btn btn-primary btn-sm gap-1">
260
- Continue
261
- <ChevronRight size={14} />
262
- </button>
263
- )}
264
- </div>
265
- </div>
266
- </div>
267
- )}
268
- </div>
269
- );
270
- }
271
-
272
- function TerminalPreview() {
273
- var [visible, setVisible] = useState(0);
274
- var lines = [
275
- { prefix: "$ ", text: "lattice", color: "var(--color-base-content)" },
276
- { prefix: "", text: " [lattice] Daemon started (PID 4821)", color: "oklch(from var(--color-base-content) l c h / 0.5)" },
277
- { prefix: "", text: " [lattice] Listening on https://0.0.0.0:7654", color: "var(--color-success)" },
278
- { prefix: "", text: " [discovery] Found 2 nodes on mesh", color: "var(--color-primary)" },
279
- ];
280
-
281
- useEffect(function () {
282
- var timers: ReturnType<typeof setTimeout>[] = [];
283
- lines.forEach(function (_, i) {
284
- timers.push(setTimeout(function () { setVisible(i + 1); }, 600 + i * 600));
285
- });
286
- return function () { timers.forEach(clearTimeout); };
287
- }, []);
288
-
289
- return (
290
- <div className="w-[380px] max-w-[calc(100vw-48px)] bg-base-200 border border-base-300 rounded-xl overflow-hidden shadow-2xl">
291
- <div className="flex items-center gap-1.5 px-3.5 py-2.5 bg-base-300 border-b border-base-300">
292
- <span className="w-2.5 h-2.5 rounded-full bg-error opacity-80 flex-shrink-0" />
293
- <span className="w-2.5 h-2.5 rounded-full bg-warning opacity-80 flex-shrink-0" />
294
- <span className="w-2.5 h-2.5 rounded-full bg-success opacity-80 flex-shrink-0" />
295
- <span className="text-[11px] text-base-content/40 font-mono mx-auto tracking-[0.02em]">lattice — zsh</span>
296
- </div>
297
- <div className="px-4 py-3.5 flex flex-col gap-1 min-h-[96px]">
298
- {lines.map(function (line, i) {
299
- return (
300
- <div
301
- key={i}
302
- className="text-[12px] font-mono leading-relaxed whitespace-pre transition-all duration-200"
303
- style={{
304
- opacity: i < visible ? 1 : 0,
305
- transform: i < visible ? "translateY(0)" : "translateY(4px)",
306
- color: line.color,
307
- }}
308
- >
309
- {line.prefix && <span style={{ color: "var(--color-primary)" }}>{line.prefix}</span>}
310
- <span>{line.text}</span>
311
- </div>
312
- );
313
- })}
314
- <div className="flex items-center gap-0.5 text-[12px] font-mono mt-0.5">
315
- <span style={{ color: "var(--color-primary)" }}>$ </span>
316
- <span
317
- className="inline-block w-2 h-3.5 rounded-[1px] align-middle opacity-90"
318
- style={{ background: "var(--color-primary)", animation: "wizard-cursor-blink 1s step-end infinite" }}
319
- />
320
- </div>
321
- </div>
322
- </div>
323
- );
324
- }
325
-
326
- interface NameStepProps {
327
- value: string;
328
- onChange: (v: string) => void;
329
- }
330
-
331
- function NameStep(props: NameStepProps) {
332
- var displayName = props.value.trim() || "this-machine";
333
- return (
334
- <div className="flex flex-col">
335
- <div className="flex items-center mb-3.5">
336
- <Server size={22} className="text-primary" />
337
- </div>
338
- <h2 className="font-mono text-[22px] font-bold text-base-content tracking-tight mb-2 leading-tight">
339
- Name this machine
340
- </h2>
341
- <p className="text-[13px] text-base-content/60 leading-relaxed mb-5">
342
- Give this node a recognizable name. It appears in your mesh when you connect multiple computers.
343
- </p>
344
- <fieldset className="fieldset mb-3.5">
345
- <legend className="fieldset-legend text-[11px] font-semibold text-base-content/40 uppercase tracking-[0.08em]">
346
- Machine name
347
- </legend>
348
- <div className="flex items-center gap-2 bg-base-300 border border-base-content/20 rounded-md px-3 h-[44px] focus-within:border-primary transition-colors duration-[120ms]">
349
- <span className="text-primary font-mono font-bold text-[16px]">&gt;</span>
350
- <input
351
- type="text"
352
- value={props.value}
353
- onChange={function (e) { props.onChange(e.target.value); }}
354
- placeholder="my-laptop"
355
- className="flex-1 bg-transparent text-base-content font-mono text-[14px] outline-none"
356
- autoFocus
357
- spellCheck={false}
358
- />
359
- </div>
360
- </fieldset>
361
- <div className="flex items-center gap-2 px-3 py-2 bg-base-300 border border-base-content/10 rounded-md">
362
- <span className="text-[10px] uppercase tracking-[0.08em] text-base-content/40 font-semibold">Preview</span>
363
- <span className="font-mono text-[13px] font-semibold text-base-content">{displayName}</span>
364
- <span className="text-base-content/30 text-[12px]">will appear on your mesh</span>
365
- </div>
366
- </div>
367
- );
368
- }
369
-
370
- interface AppearanceStepProps {
371
- theme: ReturnType<typeof useTheme>;
372
- darkQuickPicks: ThemeEntry[];
373
- lightQuickPicks: ThemeEntry[];
374
- }
375
-
376
- function AppearanceStep(props: AppearanceStepProps) {
377
- var { theme, darkQuickPicks, lightQuickPicks } = props;
378
- var quickPicks = theme.mode === "dark" ? darkQuickPicks : lightQuickPicks;
379
-
380
- return (
381
- <div className="flex flex-col">
382
- <div className="flex items-center mb-3.5">
383
- <Palette size={22} className="text-primary" />
384
- </div>
385
- <h2 className="font-mono text-[22px] font-bold text-base-content tracking-tight mb-2 leading-tight">
386
- Choose appearance
387
- </h2>
388
- <p className="text-[13px] text-base-content/60 leading-relaxed mb-4">
389
- Pick a color theme. You can always change this in settings.
390
- </p>
391
-
392
- <div className="flex gap-1.5 mb-4 p-1 bg-base-300 rounded-lg w-fit">
393
- <button
394
- onClick={function () { if (theme.mode !== "dark") { theme.toggleMode(); } }}
395
- className={
396
- "flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[13px] font-medium transition-all duration-[120ms] cursor-pointer " +
397
- (theme.mode === "dark" ? "bg-primary text-primary-content" : "text-base-content/60 hover:text-base-content")
398
- }
399
- >
400
- <Moon size={13} />
401
- Dark
402
- </button>
403
- <button
404
- onClick={function () { if (theme.mode !== "light") { theme.toggleMode(); } }}
405
- className={
406
- "flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[13px] font-medium transition-all duration-[120ms] cursor-pointer " +
407
- (theme.mode === "light" ? "bg-primary text-primary-content" : "text-base-content/60 hover:text-base-content")
408
- }
409
- >
410
- <Sun size={13} />
411
- Light
412
- </button>
413
- </div>
414
-
415
- <div className="grid gap-2 mb-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(80px, 1fr))" }}>
416
- {quickPicks.map(function (entry: ThemeEntry) {
417
- var isActive = entry.id === theme.currentThemeId;
418
- var bg = "#" + entry.theme.base00;
419
- var accent = "#" + entry.theme.base0D;
420
- var text = "#" + entry.theme.base05;
421
- var red = "#" + entry.theme.base08;
422
- var green = "#" + entry.theme.base0B;
423
- return (
424
- <button
425
- key={entry.id}
426
- onClick={function () { theme.setTheme(entry.id); }}
427
- title={entry.theme.name}
428
- className={
429
- "relative flex flex-col gap-1.5 p-0 rounded-md overflow-hidden cursor-pointer border-2 transition-all duration-[120ms] " +
430
- (isActive ? "border-primary" : "border-transparent hover:border-base-content/20")
431
- }
432
- >
433
- <div className="w-full h-[48px] flex flex-col" style={{ background: bg }}>
434
- <div className="flex items-center gap-1 px-1.5 py-1" style={{ background: "#" + entry.theme.base01 }}>
435
- <span className="w-1.5 h-1.5 rounded-full" style={{ background: red }} />
436
- <span className="w-6 h-1 rounded" style={{ background: accent, opacity: 0.7 }} />
437
- </div>
438
- <div className="flex flex-col gap-[3px] px-1.5 py-1">
439
- <div className="h-[3px] rounded w-[80%]" style={{ background: accent, opacity: 0.8 }} />
440
- <div className="h-[3px] rounded w-[60%]" style={{ background: text, opacity: 0.4 }} />
441
- <div className="h-[3px] rounded w-[70%]" style={{ background: green, opacity: 0.6 }} />
442
- </div>
443
- </div>
444
- <span className="text-[10px] text-base-content/60 truncate px-1.5 pb-1">{entry.theme.name}</span>
445
- {isActive && (
446
- <div className="absolute top-1 right-1 w-3.5 h-3.5 rounded-full bg-primary flex items-center justify-center">
447
- <Check size={8} className="text-primary-content" />
448
- </div>
449
- )}
450
- </button>
451
- );
452
- })}
453
- </div>
454
-
455
- <div className="rounded-lg border border-base-300 overflow-hidden transition-all duration-300">
456
- <div className="text-[9px] font-mono tracking-[0.05em] uppercase text-base-content/40 px-2.5 py-1.5 bg-base-300 border-b border-base-300 transition-all duration-300">
457
- Preview
458
- </div>
459
- <div className="flex h-[100px] bg-base-100 transition-all duration-300">
460
- <div className="w-[72px] flex-shrink-0 border-r border-base-300 p-2 flex flex-col gap-1">
461
- <div className="text-[8px] font-mono text-base-content/30 uppercase tracking-[0.05em] mb-0.5">Projects</div>
462
- <div className="h-1.5 w-[90%] rounded bg-primary opacity-80" />
463
- <div className="h-1.5 w-[70%] rounded bg-base-300" />
464
- <div className="h-1.5 w-[80%] rounded bg-base-300" />
465
- </div>
466
- <div className="flex-1 p-2.5 flex flex-col gap-1.5">
467
- <div className="text-[9px] font-mono font-semibold text-base-content">New Session</div>
468
- <div className="flex-1 flex flex-col gap-[3px] justify-center">
469
- <div className="flex gap-1 items-start">
470
- <div className="w-3 h-3 rounded-full bg-primary flex-shrink-0 opacity-60" />
471
- <div className="flex flex-col gap-0.5">
472
- <div className="h-1 w-[120px] rounded bg-base-content/20" />
473
- <div className="h-1 w-[80px] rounded bg-base-content/15" />
474
- </div>
475
- </div>
476
- </div>
477
- <div className="h-[18px] rounded bg-base-200 border border-base-300" />
478
- </div>
479
- </div>
480
- </div>
481
- </div>
482
- );
483
- }
484
-
485
- interface SecurityStepProps {
486
- passphrase: string;
487
- passphraseConfirm: string;
488
- error: string;
489
- onPassphraseChange: (v: string) => void;
490
- onConfirmChange: (v: string) => void;
491
- }
492
-
493
- function SecurityStep(props: SecurityStepProps) {
494
- var strength = getPassphraseStrength(props.passphrase);
495
-
496
- return (
497
- <div className="flex flex-col">
498
- <div className="flex items-center mb-3.5">
499
- <Lock size={22} className="text-primary" />
500
- </div>
501
- <h2 className="font-mono text-[22px] font-bold text-base-content tracking-tight mb-2 leading-tight">
502
- Set a passphrase
503
- </h2>
504
-
505
- <div className="flex gap-2 p-3 bg-base-300 border border-base-300 rounded-md mb-4">
506
- <Info size={16} className="text-base-content/40 flex-shrink-0 mt-[1px]" />
507
- <p className="text-[12px] text-base-content/50 leading-relaxed">
508
- Optional. Protects your dashboard on shared networks. Node-to-node connections use separate key-based auth.
509
- </p>
510
- </div>
511
-
512
- <fieldset className="fieldset mb-3.5">
513
- <legend className="fieldset-legend text-[11px] font-semibold text-base-content/40 uppercase tracking-[0.08em]">
514
- Passphrase
515
- </legend>
516
- <input
517
- type="password"
518
- value={props.passphrase}
519
- onChange={function (e) { props.onPassphraseChange(e.target.value); }}
520
- placeholder="Leave blank to skip"
521
- className="input input-bordered w-full bg-base-300 text-base-content text-[14px] focus:border-primary"
522
- autoFocus
523
- />
524
- {props.passphrase.length > 0 && (
525
- <div className="flex items-center gap-2 mt-1.5">
526
- <div className="flex-1 h-1 bg-base-300 rounded-full overflow-hidden">
527
- <div
528
- className="h-full rounded-full transition-all duration-200"
529
- style={{ width: strength.pct + "%", background: strength.color }}
530
- />
531
- </div>
532
- <span className="text-[11px] font-semibold" style={{ color: strength.color }}>{strength.label}</span>
533
- </div>
534
- )}
535
- </fieldset>
536
-
537
- {props.passphrase.length > 0 && (
538
- <fieldset className="fieldset mb-3.5">
539
- <legend className="fieldset-legend text-[11px] font-semibold text-base-content/40 uppercase tracking-[0.08em]">
540
- Confirm passphrase
541
- </legend>
542
- <input
543
- type="password"
544
- value={props.passphraseConfirm}
545
- onChange={function (e) { props.onConfirmChange(e.target.value); }}
546
- placeholder="Repeat passphrase"
547
- className={
548
- "input input-bordered w-full bg-base-300 text-base-content text-[14px] focus:border-primary " +
549
- (props.error ? "border-error" : "")
550
- }
551
- />
552
- </fieldset>
553
- )}
554
-
555
- {props.error && (
556
- <p className="text-[12px] text-error mt-1">{props.error}</p>
557
- )}
558
- </div>
559
- );
560
- }
561
-
562
- function getPassphraseStrength(p: string) {
563
- if (p.length === 0) return { pct: 0, label: "", color: "oklch(from var(--color-base-content) l c h / 0.5)" };
564
- if (p.length < 8) return { pct: 25, label: "Weak", color: "var(--color-error)" };
565
- if (p.length < 14) return { pct: 55, label: "Fair", color: "var(--color-warning)" };
566
- if (p.length < 20) return { pct: 80, label: "Good", color: "var(--color-primary)" };
567
- return { pct: 100, label: "Strong", color: "var(--color-success)" };
568
- }
569
-
570
- interface ProjectStepProps {
571
- path: string;
572
- title: string;
573
- onPathChange: (v: string) => void;
574
- onTitleChange: (v: string) => void;
575
- }
576
-
577
- function ProjectStep(props: ProjectStepProps) {
578
- return (
579
- <div className="flex flex-col">
580
- <div className="flex items-center mb-3.5">
581
- <Folder size={22} className="text-primary" />
582
- </div>
583
- <h2 className="font-mono text-[22px] font-bold text-base-content tracking-tight mb-2 leading-tight">
584
- Add your first project
585
- </h2>
586
- <p className="text-[13px] text-base-content/60 leading-relaxed mb-5">
587
- Point Lattice at a local directory. Claude runs inside that workspace. Add more projects from the sidebar anytime.
588
- </p>
589
- <fieldset className="fieldset mb-3.5">
590
- <legend className="fieldset-legend text-[11px] font-semibold text-base-content/40 uppercase tracking-[0.08em]">
591
- Project path
592
- </legend>
593
- <input
594
- type="text"
595
- value={props.path}
596
- onChange={function (e) { props.onPathChange(e.target.value); }}
597
- placeholder="/home/you/projects/my-app"
598
- className="input input-bordered w-full bg-base-300 text-base-content font-mono text-[14px] focus:border-primary"
599
- autoFocus
600
- spellCheck={false}
601
- />
602
- <p className="fieldset-label text-[11px] text-base-content/30 mt-1">
603
- Absolute path to a local directory on this machine.
604
- </p>
605
- </fieldset>
606
- <fieldset className="fieldset mb-3.5">
607
- <legend className="fieldset-legend text-[11px] font-semibold text-base-content/40 uppercase tracking-[0.08em]">
608
- Display name <span className="font-normal normal-case tracking-normal">(optional)</span>
609
- </legend>
610
- <input
611
- type="text"
612
- value={props.title}
613
- onChange={function (e) { props.onTitleChange(e.target.value); }}
614
- placeholder={props.path ? (props.path.replace(/\/+$/, "").split("/").pop() || "My App") : "My App"}
615
- className="input input-bordered w-full bg-base-300 text-base-content text-[14px] focus:border-primary"
616
- />
617
- </fieldset>
618
- </div>
619
- );
620
- }
621
-
622
- interface DoneStepProps {
623
- configured: string[];
624
- }
625
-
626
- function DoneStep(props: DoneStepProps) {
627
- return (
628
- <div className="flex flex-col items-center text-center">
629
- <div className="wizard-check-pop mb-4">
630
- <CheckCircle size={36} className="text-success" strokeWidth={1.5} aria-hidden="true" />
631
- </div>
632
- <h2 className="font-mono text-[26px] font-bold text-base-content tracking-tight mb-2">
633
- You're all set
634
- </h2>
635
- <p className="text-[13px] text-base-content/60 leading-relaxed mb-5">
636
- Lattice is configured and ready to go.
637
- </p>
638
-
639
- {props.configured.length > 0 ? (
640
- <ul className="list-none p-0 m-0 flex flex-col gap-1.5 text-left w-full">
641
- {props.configured.map(function (item: string, i: number) {
642
- return (
643
- <li key={i} className="wizard-fade-in flex items-center gap-2 bg-base-300 px-3 py-2 rounded-md" data-delay={i * 60}>
644
- <span className="w-4 h-4 rounded-full bg-success/20 text-success flex items-center justify-center flex-shrink-0">
645
- <Check size={10} />
646
- </span>
647
- <span className="font-mono text-[12px] text-base-content/60">{item}</span>
648
- </li>
649
- );
650
- })}
651
- </ul>
652
- ) : (
653
- <p className="text-[11px] text-base-content/30">
654
- Everything was skipped — configure it from settings anytime.
655
- </p>
656
- )}
657
- </div>
658
- );
659
- }
660
-
661
- var wizardCSS = `
662
- @keyframes wizard-fade-in {
663
- from { opacity: 0; transform: translateY(12px); }
664
- to { opacity: 1; transform: translateY(0); }
665
- }
666
- @keyframes wizard-slide-in-right {
667
- from { opacity: 0; transform: translateX(24px); }
668
- to { opacity: 1; transform: translateX(0); }
669
- }
670
- @keyframes wizard-slide-in-left {
671
- from { opacity: 0; transform: translateX(-24px); }
672
- to { opacity: 1; transform: translateX(0); }
673
- }
674
- @keyframes wizard-slide-out-left {
675
- from { opacity: 1; transform: translateX(0); }
676
- to { opacity: 0; transform: translateX(-24px); }
677
- }
678
- @keyframes wizard-slide-out-right {
679
- from { opacity: 1; transform: translateX(0); }
680
- to { opacity: 0; transform: translateX(24px); }
681
- }
682
- @keyframes wizard-check-pop {
683
- 0% { transform: scale(0.5); opacity: 0; }
684
- 70% { transform: scale(1.15); }
685
- 100% { transform: scale(1); opacity: 1; }
686
- }
687
- @keyframes wizard-cursor-blink {
688
- 0%, 100% { opacity: 1; }
689
- 50% { opacity: 0; }
690
- }
691
- @keyframes wizard-grid-shift {
692
- 0% { background-position: 0 0; }
693
- 100% { background-position: 40px 40px; }
694
- }
695
- .wizard-fade-in {
696
- animation: wizard-fade-in 400ms ease both;
697
- }
698
- .wizard-slide-in-right {
699
- animation: wizard-slide-in-right 220ms ease both;
700
- }
701
- .wizard-slide-in-left {
702
- animation: wizard-slide-in-left 220ms ease both;
703
- }
704
- .wizard-slide-out-left {
705
- animation: wizard-slide-out-left 180ms ease both;
706
- }
707
- .wizard-slide-out-right {
708
- animation: wizard-slide-out-right 180ms ease both;
709
- }
710
- .wizard-check-pop {
711
- animation: wizard-check-pop 500ms cubic-bezier(0.175, 0.885, 0.32, 1.275) both;
712
- }
713
- .wizard-btn-primary {
714
- transition: background 150ms ease, transform 100ms ease, box-shadow 150ms ease !important;
715
- }
716
- .wizard-btn-primary:hover {
717
- transform: translateY(-1px);
718
- box-shadow: 0 4px 16px oklch(from var(--color-primary) l c h / 0.35);
719
- }
720
- .wizard-btn-primary:active {
721
- transform: translateY(0);
722
- }
723
- .wizard-btn-done {
724
- transition: background 150ms ease, transform 100ms ease, box-shadow 150ms ease !important;
725
- }
726
- .wizard-btn-done:hover {
727
- filter: brightness(1.08);
728
- transform: translateY(-1px);
729
- box-shadow: 0 4px 20px oklch(from var(--color-success) l c h / 0.4);
730
- }
731
- .wizard-btn-done:active {
732
- transform: translateY(0);
733
- }
734
- @media (prefers-reduced-motion: reduce) {
735
- .wizard-fade-in,
736
- .wizard-slide-in-right,
737
- .wizard-slide-in-left,
738
- .wizard-slide-out-left,
739
- .wizard-slide-out-right,
740
- .wizard-check-pop {
741
- animation: none !important;
742
- opacity: 1 !important;
743
- transform: none !important;
744
- }
745
- .wizard-btn-primary:hover,
746
- .wizard-btn-done:hover {
747
- transform: none !important;
748
- }
749
- }
750
- `;
1
+ import { useState, useEffect } from "react";
2
+ import { ArrowRight, ChevronRight, ChevronLeft, Server, Palette, Lock, Folder, Info, Moon, Sun, Check, CheckCircle } from "lucide-react";
3
+ import { useTheme } from "../../hooks/useTheme";
4
+ import { useWebSocket } from "../../hooks/useWebSocket";
5
+ import { themes } from "../../themes/index";
6
+ import type { ThemeEntry } from "../../themes/index";
7
+ import { LatticeLogomark } from "../ui/LatticeLogomark";
8
+
9
+ var POPULAR_DARK_THEMES = ["dracula", "catppuccin-mocha", "tokyo-night", "one-dark", "amoled"];
10
+ var POPULAR_LIGHT_THEMES = ["ayu-light", "catppuccin-latte", "github-light", "one-light", "rose-pine-dawn"];
11
+
12
+ var TOTAL_STEPS = 6;
13
+
14
+ interface SetupWizardProps {
15
+ onComplete: () => void;
16
+ }
17
+
18
+ export function SetupWizard(props: SetupWizardProps) {
19
+ var [step, setStep] = useState(1);
20
+ var [prevStep, setPrevStep] = useState(1);
21
+ var [animating, setAnimating] = useState(false);
22
+ var [nodeName, setNodeName] = useState("");
23
+ var [passphrase, setPassphrase] = useState("");
24
+ var [passphraseConfirm, setPassphraseConfirm] = useState("");
25
+ var [passphraseError, setPassphraseError] = useState("");
26
+ var [projectPath, setProjectPath] = useState("");
27
+ var [projectTitle, setProjectTitle] = useState("");
28
+ var [configured, setConfigured] = useState<string[]>([]);
29
+
30
+ var theme = useTheme();
31
+ var ws = useWebSocket();
32
+
33
+ function navigateTo(next: number) {
34
+ if (animating) return;
35
+ setPrevStep(step);
36
+ setAnimating(true);
37
+ setTimeout(function () {
38
+ setStep(next);
39
+ setAnimating(false);
40
+ }, 180);
41
+ }
42
+
43
+ function goNext() {
44
+ navigateTo(Math.min(step + 1, TOTAL_STEPS));
45
+ }
46
+
47
+ function goBack() {
48
+ navigateTo(Math.max(step - 1, 1));
49
+ }
50
+
51
+ function skipToNext() {
52
+ goNext();
53
+ }
54
+
55
+ function handleNameNext() {
56
+ var name = nodeName.trim();
57
+ if (name.length > 0) {
58
+ ws.send({ type: "settings:update", settings: { name } });
59
+ setConfigured(function (c) { return [...c.filter(function (x) { return x !== "name"; }), "name: " + name]; });
60
+ }
61
+ goNext();
62
+ }
63
+
64
+ function handleAppearanceNext() {
65
+ setConfigured(function (c) {
66
+ var label = "theme: " + theme.currentThemeId + " (" + theme.mode + ")";
67
+ return [...c.filter(function (x) { return !x.startsWith("theme:"); }), label];
68
+ });
69
+ goNext();
70
+ }
71
+
72
+ function handleSecurityNext() {
73
+ if (passphrase.length === 0) {
74
+ goNext();
75
+ return;
76
+ }
77
+ if (passphrase !== passphraseConfirm) {
78
+ setPassphraseError("Passphrases do not match");
79
+ return;
80
+ }
81
+ ws.send({ type: "settings:update", settings: { passphraseHash: passphrase } });
82
+ setConfigured(function (c) { return [...c.filter(function (x) { return x !== "security"; }), "security: passphrase set"]; });
83
+ goNext();
84
+ }
85
+
86
+ function handleProjectNext() {
87
+ var path = projectPath.trim();
88
+ if (path.length > 0) {
89
+ var derivedName = path.replace(/\/+$/, "").split("/").pop() || path;
90
+ var title = projectTitle.trim() || derivedName;
91
+ ws.send({ type: "settings:update", settings: { projects: [{ path: path, slug: "", title: title, env: {} }] } });
92
+ setConfigured(function (c) { return [...c.filter(function (x) { return !x.startsWith("project:"); }), "project: " + title]; });
93
+ }
94
+ goNext();
95
+ }
96
+
97
+ function handleDone() {
98
+ localStorage.setItem("lattice-setup-complete", "1");
99
+ props.onComplete();
100
+ }
101
+
102
+ var darkQuickPicks = themes.filter(function (e: ThemeEntry) { return POPULAR_DARK_THEMES.includes(e.id); });
103
+ var lightQuickPicks = themes.filter(function (e: ThemeEntry) { return POPULAR_LIGHT_THEMES.includes(e.id); });
104
+ var isForward = step > prevStep;
105
+
106
+ return (
107
+ <div className="fixed inset-0 z-[9999] flex items-center justify-center bg-base-100 transition-colors duration-300">
108
+ <style>{wizardCSS}</style>
109
+
110
+ {step === 1 ? (
111
+ <div className="relative w-full max-w-[520px] px-6 py-12 flex flex-col items-center text-center">
112
+ <div
113
+ className="fixed inset-0 pointer-events-none z-0"
114
+ aria-hidden="true"
115
+ style={{
116
+ backgroundImage: "linear-gradient(oklch(from var(--color-primary) l c h / 0.07) 1px, transparent 1px), linear-gradient(90deg, oklch(from var(--color-primary) l c h / 0.07) 1px, transparent 1px)",
117
+ backgroundSize: "40px 40px",
118
+ animation: "wizard-grid-shift 8s linear infinite",
119
+ }}
120
+ />
121
+ <div className="relative z-[1] flex flex-col items-center gap-0">
122
+ <div className="wizard-fade-in text-primary" style={{ animationDelay: "0ms" }}>
123
+ <LatticeLogomark size={64} />
124
+ </div>
125
+ <h1
126
+ className="wizard-fade-in font-mono font-bold text-base-content leading-none mt-5 mb-0"
127
+ style={{ fontSize: "clamp(48px, 10vw, 72px)", letterSpacing: "-0.04em", animationDelay: "80ms" }}
128
+ >
129
+ Lattice
130
+ </h1>
131
+ <p className="wizard-fade-in text-[17px] text-base-content/60 mt-3 mb-8 tracking-[0.01em]" style={{ animationDelay: "160ms" }}>
132
+ One dashboard. Every machine.
133
+ </p>
134
+ <div className="wizard-fade-in" style={{ animationDelay: "240ms" }}>
135
+ <TerminalPreview />
136
+ </div>
137
+ <div className="wizard-fade-in" style={{ animationDelay: "320ms" }}>
138
+ <button
139
+ onClick={goNext}
140
+ className="wizard-btn-primary btn btn-primary inline-flex items-center gap-2 mt-7 h-[52px] px-8 text-[15px] font-semibold cursor-pointer"
141
+ >
142
+ Get Started
143
+ <ArrowRight size={16} />
144
+ </button>
145
+ </div>
146
+ <p className="wizard-fade-in text-[12px] text-base-content/30 mt-4" style={{ animationDelay: "380ms" }}>
147
+ Takes about 2 minutes
148
+ </p>
149
+ </div>
150
+ </div>
151
+ ) : (
152
+ <div className="w-[480px] max-w-[calc(100vw-24px)] bg-base-200 border border-base-300 rounded-xl flex flex-col overflow-hidden shadow-2xl">
153
+ <div className="flex items-center justify-between px-6 py-4 border-b border-base-300 bg-base-100">
154
+ <div className="flex items-center gap-1.5">
155
+ {Array.from({ length: TOTAL_STEPS - 1 }, function (_, i) {
156
+ var dotStep = i + 2;
157
+ var isComplete = step > dotStep;
158
+ var isActive = step === dotStep;
159
+ return (
160
+ <div
161
+ key={i}
162
+ className="h-1.5 rounded-full transition-all duration-[250ms]"
163
+ style={{
164
+ background: (isComplete || isActive) ? "var(--color-primary)" : "var(--color-base-300)",
165
+ opacity: isActive ? 1 : isComplete ? 0.7 : 0.35,
166
+ width: isActive ? "24px" : "8px",
167
+ }}
168
+ />
169
+ );
170
+ })}
171
+ </div>
172
+ <span className="text-[11px] text-base-content/40 font-mono tracking-[0.06em]">
173
+ {step - 1} / {TOTAL_STEPS - 1}
174
+ </span>
175
+ </div>
176
+
177
+ <div
178
+ className={"px-7 pt-7 pb-2 flex-1 min-h-[320px] " + (animating ? (isForward ? "wizard-slide-out-left" : "wizard-slide-out-right") : (isForward ? "wizard-slide-in-right" : "wizard-slide-in-left"))}
179
+ key={step}
180
+ >
181
+ {step === 2 && (
182
+ <NameStep value={nodeName} onChange={setNodeName} />
183
+ )}
184
+ {step === 3 && (
185
+ <AppearanceStep
186
+ theme={theme}
187
+ darkQuickPicks={darkQuickPicks}
188
+ lightQuickPicks={lightQuickPicks}
189
+ />
190
+ )}
191
+ {step === 4 && (
192
+ <SecurityStep
193
+ passphrase={passphrase}
194
+ passphraseConfirm={passphraseConfirm}
195
+ error={passphraseError}
196
+ onPassphraseChange={function (v: string) {
197
+ setPassphrase(v);
198
+ setPassphraseError("");
199
+ }}
200
+ onConfirmChange={function (v: string) {
201
+ setPassphraseConfirm(v);
202
+ setPassphraseError("");
203
+ }}
204
+ />
205
+ )}
206
+ {step === 5 && (
207
+ <ProjectStep
208
+ path={projectPath}
209
+ title={projectTitle}
210
+ onPathChange={setProjectPath}
211
+ onTitleChange={setProjectTitle}
212
+ />
213
+ )}
214
+ {step === 6 && <DoneStep configured={configured} />}
215
+ </div>
216
+
217
+ <div className="flex items-center gap-2 px-6 py-4 border-t border-base-300 bg-base-100">
218
+ {step >= 2 && step < TOTAL_STEPS && (
219
+ <button onClick={goBack} className="wizard-btn-back btn btn-ghost btn-sm gap-1 text-base-content/40">
220
+ <ChevronLeft size={14} />
221
+ Back
222
+ </button>
223
+ )}
224
+ <div className="flex items-center gap-2 ml-auto">
225
+ {step === 2 && (
226
+ <button onClick={skipToNext} className="wizard-btn-skip btn btn-ghost btn-sm text-base-content/40">Skip</button>
227
+ )}
228
+ {step === 3 && (
229
+ <button onClick={handleAppearanceNext} className="wizard-btn-primary btn btn-primary btn-sm gap-1">
230
+ Continue
231
+ <ChevronRight size={14} />
232
+ </button>
233
+ )}
234
+ {step === 4 && (
235
+ <>
236
+ <button onClick={skipToNext} className="wizard-btn-skip btn btn-ghost btn-sm text-base-content/40">Skip</button>
237
+ <button onClick={handleSecurityNext} className="wizard-btn-primary btn btn-primary btn-sm gap-1">
238
+ Continue
239
+ <ChevronRight size={14} />
240
+ </button>
241
+ </>
242
+ )}
243
+ {step === 5 && (
244
+ <>
245
+ <button onClick={skipToNext} className="wizard-btn-skip btn btn-ghost btn-sm text-base-content/40">Skip</button>
246
+ <button onClick={handleProjectNext} className="wizard-btn-primary btn btn-primary btn-sm gap-1">
247
+ Add &amp; Continue
248
+ <ChevronRight size={14} />
249
+ </button>
250
+ </>
251
+ )}
252
+ {step === 6 && (
253
+ <button onClick={handleDone} className="wizard-btn-done btn btn-success btn-sm gap-2 font-bold">
254
+ Open Dashboard
255
+ <ArrowRight size={16} />
256
+ </button>
257
+ )}
258
+ {step === 2 && (
259
+ <button onClick={handleNameNext} className="wizard-btn-primary btn btn-primary btn-sm gap-1">
260
+ Continue
261
+ <ChevronRight size={14} />
262
+ </button>
263
+ )}
264
+ </div>
265
+ </div>
266
+ </div>
267
+ )}
268
+ </div>
269
+ );
270
+ }
271
+
272
+ function TerminalPreview() {
273
+ var [visible, setVisible] = useState(0);
274
+ var lines = [
275
+ { prefix: "$ ", text: "lattice", color: "var(--color-base-content)" },
276
+ { prefix: "", text: " [lattice] Daemon started (PID 4821)", color: "oklch(from var(--color-base-content) l c h / 0.5)" },
277
+ { prefix: "", text: " [lattice] Listening on https://0.0.0.0:7654", color: "var(--color-success)" },
278
+ { prefix: "", text: " [discovery] Found 2 nodes on mesh", color: "var(--color-primary)" },
279
+ ];
280
+
281
+ useEffect(function () {
282
+ var timers: ReturnType<typeof setTimeout>[] = [];
283
+ lines.forEach(function (_, i) {
284
+ timers.push(setTimeout(function () { setVisible(i + 1); }, 600 + i * 600));
285
+ });
286
+ return function () { timers.forEach(clearTimeout); };
287
+ }, []);
288
+
289
+ return (
290
+ <div className="w-[380px] max-w-[calc(100vw-48px)] bg-base-200 border border-base-300 rounded-xl overflow-hidden shadow-2xl">
291
+ <div className="flex items-center gap-1.5 px-3.5 py-2.5 bg-base-300 border-b border-base-300">
292
+ <span className="w-2.5 h-2.5 rounded-full bg-error opacity-80 flex-shrink-0" />
293
+ <span className="w-2.5 h-2.5 rounded-full bg-warning opacity-80 flex-shrink-0" />
294
+ <span className="w-2.5 h-2.5 rounded-full bg-success opacity-80 flex-shrink-0" />
295
+ <span className="text-[11px] text-base-content/40 font-mono mx-auto tracking-[0.02em]">lattice — zsh</span>
296
+ </div>
297
+ <div className="px-4 py-3.5 flex flex-col gap-1 min-h-[96px]">
298
+ {lines.map(function (line, i) {
299
+ return (
300
+ <div
301
+ key={i}
302
+ className="text-[12px] font-mono leading-relaxed whitespace-pre transition-all duration-200"
303
+ style={{
304
+ opacity: i < visible ? 1 : 0,
305
+ transform: i < visible ? "translateY(0)" : "translateY(4px)",
306
+ color: line.color,
307
+ }}
308
+ >
309
+ {line.prefix && <span style={{ color: "var(--color-primary)" }}>{line.prefix}</span>}
310
+ <span>{line.text}</span>
311
+ </div>
312
+ );
313
+ })}
314
+ <div className="flex items-center gap-0.5 text-[12px] font-mono mt-0.5">
315
+ <span style={{ color: "var(--color-primary)" }}>$ </span>
316
+ <span
317
+ className="inline-block w-2 h-3.5 rounded-[1px] align-middle opacity-90"
318
+ style={{ background: "var(--color-primary)", animation: "wizard-cursor-blink 1s step-end infinite" }}
319
+ />
320
+ </div>
321
+ </div>
322
+ </div>
323
+ );
324
+ }
325
+
326
+ interface NameStepProps {
327
+ value: string;
328
+ onChange: (v: string) => void;
329
+ }
330
+
331
+ function NameStep(props: NameStepProps) {
332
+ var displayName = props.value.trim() || "this-machine";
333
+ return (
334
+ <div className="flex flex-col">
335
+ <div className="flex items-center mb-3.5">
336
+ <Server size={22} className="text-primary" />
337
+ </div>
338
+ <h2 className="font-mono text-[22px] font-bold text-base-content tracking-tight mb-2 leading-tight">
339
+ Name this machine
340
+ </h2>
341
+ <p className="text-[13px] text-base-content/60 leading-relaxed mb-5">
342
+ Give this node a recognizable name. It appears in your mesh when you connect multiple computers.
343
+ </p>
344
+ <fieldset className="fieldset mb-3.5">
345
+ <legend className="fieldset-legend text-[11px] font-semibold text-base-content/40 uppercase tracking-[0.08em]">
346
+ Machine name
347
+ </legend>
348
+ <div className="flex items-center gap-2 bg-base-300 border border-base-content/20 rounded-md px-3 h-[44px] focus-within:border-primary transition-colors duration-[120ms]">
349
+ <span className="text-primary font-mono font-bold text-[16px]">&gt;</span>
350
+ <input
351
+ type="text"
352
+ value={props.value}
353
+ onChange={function (e) { props.onChange(e.target.value); }}
354
+ placeholder="my-laptop"
355
+ className="flex-1 bg-transparent text-base-content font-mono text-[14px] outline-none"
356
+ autoFocus
357
+ spellCheck={false}
358
+ />
359
+ </div>
360
+ </fieldset>
361
+ <div className="flex items-center gap-2 px-3 py-2 bg-base-300 border border-base-content/10 rounded-md">
362
+ <span className="text-[10px] uppercase tracking-[0.08em] text-base-content/40 font-semibold">Preview</span>
363
+ <span className="font-mono text-[13px] font-semibold text-base-content">{displayName}</span>
364
+ <span className="text-base-content/30 text-[12px]">will appear on your mesh</span>
365
+ </div>
366
+ </div>
367
+ );
368
+ }
369
+
370
+ interface AppearanceStepProps {
371
+ theme: ReturnType<typeof useTheme>;
372
+ darkQuickPicks: ThemeEntry[];
373
+ lightQuickPicks: ThemeEntry[];
374
+ }
375
+
376
+ function AppearanceStep(props: AppearanceStepProps) {
377
+ var { theme, darkQuickPicks, lightQuickPicks } = props;
378
+ var quickPicks = theme.mode === "dark" ? darkQuickPicks : lightQuickPicks;
379
+
380
+ return (
381
+ <div className="flex flex-col">
382
+ <div className="flex items-center mb-3.5">
383
+ <Palette size={22} className="text-primary" />
384
+ </div>
385
+ <h2 className="font-mono text-[22px] font-bold text-base-content tracking-tight mb-2 leading-tight">
386
+ Choose appearance
387
+ </h2>
388
+ <p className="text-[13px] text-base-content/60 leading-relaxed mb-4">
389
+ Pick a color theme. You can always change this in settings.
390
+ </p>
391
+
392
+ <div className="flex gap-1.5 mb-4 p-1 bg-base-300 rounded-lg w-fit">
393
+ <button
394
+ onClick={function () { if (theme.mode !== "dark") { theme.toggleMode(); } }}
395
+ className={
396
+ "flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[13px] font-medium transition-all duration-[120ms] cursor-pointer " +
397
+ (theme.mode === "dark" ? "bg-primary text-primary-content" : "text-base-content/60 hover:text-base-content")
398
+ }
399
+ >
400
+ <Moon size={13} />
401
+ Dark
402
+ </button>
403
+ <button
404
+ onClick={function () { if (theme.mode !== "light") { theme.toggleMode(); } }}
405
+ className={
406
+ "flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[13px] font-medium transition-all duration-[120ms] cursor-pointer " +
407
+ (theme.mode === "light" ? "bg-primary text-primary-content" : "text-base-content/60 hover:text-base-content")
408
+ }
409
+ >
410
+ <Sun size={13} />
411
+ Light
412
+ </button>
413
+ </div>
414
+
415
+ <div className="grid gap-2 mb-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(80px, 1fr))" }}>
416
+ {quickPicks.map(function (entry: ThemeEntry) {
417
+ var isActive = entry.id === theme.currentThemeId;
418
+ var bg = "#" + entry.theme.base00;
419
+ var accent = "#" + entry.theme.base0D;
420
+ var text = "#" + entry.theme.base05;
421
+ var red = "#" + entry.theme.base08;
422
+ var green = "#" + entry.theme.base0B;
423
+ return (
424
+ <button
425
+ key={entry.id}
426
+ onClick={function () { theme.setTheme(entry.id); }}
427
+ title={entry.theme.name}
428
+ className={
429
+ "relative flex flex-col gap-1.5 p-0 rounded-md overflow-hidden cursor-pointer border-2 transition-all duration-[120ms] " +
430
+ (isActive ? "border-primary" : "border-transparent hover:border-base-content/20")
431
+ }
432
+ >
433
+ <div className="w-full h-[48px] flex flex-col" style={{ background: bg }}>
434
+ <div className="flex items-center gap-1 px-1.5 py-1" style={{ background: "#" + entry.theme.base01 }}>
435
+ <span className="w-1.5 h-1.5 rounded-full" style={{ background: red }} />
436
+ <span className="w-6 h-1 rounded" style={{ background: accent, opacity: 0.7 }} />
437
+ </div>
438
+ <div className="flex flex-col gap-[3px] px-1.5 py-1">
439
+ <div className="h-[3px] rounded w-[80%]" style={{ background: accent, opacity: 0.8 }} />
440
+ <div className="h-[3px] rounded w-[60%]" style={{ background: text, opacity: 0.4 }} />
441
+ <div className="h-[3px] rounded w-[70%]" style={{ background: green, opacity: 0.6 }} />
442
+ </div>
443
+ </div>
444
+ <span className="text-[10px] text-base-content/60 truncate px-1.5 pb-1">{entry.theme.name}</span>
445
+ {isActive && (
446
+ <div className="absolute top-1 right-1 w-3.5 h-3.5 rounded-full bg-primary flex items-center justify-center">
447
+ <Check size={8} className="text-primary-content" />
448
+ </div>
449
+ )}
450
+ </button>
451
+ );
452
+ })}
453
+ </div>
454
+
455
+ <div className="rounded-lg border border-base-300 overflow-hidden transition-all duration-300">
456
+ <div className="text-[9px] font-mono tracking-[0.05em] uppercase text-base-content/40 px-2.5 py-1.5 bg-base-300 border-b border-base-300 transition-all duration-300">
457
+ Preview
458
+ </div>
459
+ <div className="flex h-[100px] bg-base-100 transition-all duration-300">
460
+ <div className="w-[72px] flex-shrink-0 border-r border-base-300 p-2 flex flex-col gap-1">
461
+ <div className="text-[8px] font-mono text-base-content/30 uppercase tracking-[0.05em] mb-0.5">Projects</div>
462
+ <div className="h-1.5 w-[90%] rounded bg-primary opacity-80" />
463
+ <div className="h-1.5 w-[70%] rounded bg-base-300" />
464
+ <div className="h-1.5 w-[80%] rounded bg-base-300" />
465
+ </div>
466
+ <div className="flex-1 p-2.5 flex flex-col gap-1.5">
467
+ <div className="text-[9px] font-mono font-semibold text-base-content">New Session</div>
468
+ <div className="flex-1 flex flex-col gap-[3px] justify-center">
469
+ <div className="flex gap-1 items-start">
470
+ <div className="w-3 h-3 rounded-full bg-primary flex-shrink-0 opacity-60" />
471
+ <div className="flex flex-col gap-0.5">
472
+ <div className="h-1 w-[120px] rounded bg-base-content/20" />
473
+ <div className="h-1 w-[80px] rounded bg-base-content/15" />
474
+ </div>
475
+ </div>
476
+ </div>
477
+ <div className="h-[18px] rounded bg-base-200 border border-base-300" />
478
+ </div>
479
+ </div>
480
+ </div>
481
+ </div>
482
+ );
483
+ }
484
+
485
+ interface SecurityStepProps {
486
+ passphrase: string;
487
+ passphraseConfirm: string;
488
+ error: string;
489
+ onPassphraseChange: (v: string) => void;
490
+ onConfirmChange: (v: string) => void;
491
+ }
492
+
493
+ function SecurityStep(props: SecurityStepProps) {
494
+ var strength = getPassphraseStrength(props.passphrase);
495
+
496
+ return (
497
+ <div className="flex flex-col">
498
+ <div className="flex items-center mb-3.5">
499
+ <Lock size={22} className="text-primary" />
500
+ </div>
501
+ <h2 className="font-mono text-[22px] font-bold text-base-content tracking-tight mb-2 leading-tight">
502
+ Set a passphrase
503
+ </h2>
504
+
505
+ <div className="flex gap-2 p-3 bg-base-300 border border-base-300 rounded-md mb-4">
506
+ <Info size={16} className="text-base-content/40 flex-shrink-0 mt-[1px]" />
507
+ <p className="text-[12px] text-base-content/50 leading-relaxed">
508
+ Optional. Protects your dashboard on shared networks. Node-to-node connections use separate key-based auth.
509
+ </p>
510
+ </div>
511
+
512
+ <fieldset className="fieldset mb-3.5">
513
+ <legend className="fieldset-legend text-[11px] font-semibold text-base-content/40 uppercase tracking-[0.08em]">
514
+ Passphrase
515
+ </legend>
516
+ <input
517
+ type="password"
518
+ value={props.passphrase}
519
+ onChange={function (e) { props.onPassphraseChange(e.target.value); }}
520
+ placeholder="Leave blank to skip"
521
+ className="input input-bordered w-full bg-base-300 text-base-content text-[14px] focus:border-primary"
522
+ autoFocus
523
+ />
524
+ {props.passphrase.length > 0 && (
525
+ <div className="flex items-center gap-2 mt-1.5">
526
+ <div className="flex-1 h-1 bg-base-300 rounded-full overflow-hidden">
527
+ <div
528
+ className="h-full rounded-full transition-all duration-200"
529
+ style={{ width: strength.pct + "%", background: strength.color }}
530
+ />
531
+ </div>
532
+ <span className="text-[11px] font-semibold" style={{ color: strength.color }}>{strength.label}</span>
533
+ </div>
534
+ )}
535
+ </fieldset>
536
+
537
+ {props.passphrase.length > 0 && (
538
+ <fieldset className="fieldset mb-3.5">
539
+ <legend className="fieldset-legend text-[11px] font-semibold text-base-content/40 uppercase tracking-[0.08em]">
540
+ Confirm passphrase
541
+ </legend>
542
+ <input
543
+ type="password"
544
+ value={props.passphraseConfirm}
545
+ onChange={function (e) { props.onConfirmChange(e.target.value); }}
546
+ placeholder="Repeat passphrase"
547
+ className={
548
+ "input input-bordered w-full bg-base-300 text-base-content text-[14px] focus:border-primary " +
549
+ (props.error ? "border-error" : "")
550
+ }
551
+ />
552
+ </fieldset>
553
+ )}
554
+
555
+ {props.error && (
556
+ <p className="text-[12px] text-error mt-1">{props.error}</p>
557
+ )}
558
+ </div>
559
+ );
560
+ }
561
+
562
+ function getPassphraseStrength(p: string) {
563
+ if (p.length === 0) return { pct: 0, label: "", color: "oklch(from var(--color-base-content) l c h / 0.5)" };
564
+ if (p.length < 8) return { pct: 25, label: "Weak", color: "var(--color-error)" };
565
+ if (p.length < 14) return { pct: 55, label: "Fair", color: "var(--color-warning)" };
566
+ if (p.length < 20) return { pct: 80, label: "Good", color: "var(--color-primary)" };
567
+ return { pct: 100, label: "Strong", color: "var(--color-success)" };
568
+ }
569
+
570
+ interface ProjectStepProps {
571
+ path: string;
572
+ title: string;
573
+ onPathChange: (v: string) => void;
574
+ onTitleChange: (v: string) => void;
575
+ }
576
+
577
+ function ProjectStep(props: ProjectStepProps) {
578
+ return (
579
+ <div className="flex flex-col">
580
+ <div className="flex items-center mb-3.5">
581
+ <Folder size={22} className="text-primary" />
582
+ </div>
583
+ <h2 className="font-mono text-[22px] font-bold text-base-content tracking-tight mb-2 leading-tight">
584
+ Add your first project
585
+ </h2>
586
+ <p className="text-[13px] text-base-content/60 leading-relaxed mb-5">
587
+ Point Lattice at a local directory. Claude runs inside that workspace. Add more projects from the sidebar anytime.
588
+ </p>
589
+ <fieldset className="fieldset mb-3.5">
590
+ <legend className="fieldset-legend text-[11px] font-semibold text-base-content/40 uppercase tracking-[0.08em]">
591
+ Project path
592
+ </legend>
593
+ <input
594
+ type="text"
595
+ value={props.path}
596
+ onChange={function (e) { props.onPathChange(e.target.value); }}
597
+ placeholder="/home/you/projects/my-app"
598
+ className="input input-bordered w-full bg-base-300 text-base-content font-mono text-[14px] focus:border-primary"
599
+ autoFocus
600
+ spellCheck={false}
601
+ />
602
+ <p className="fieldset-label text-[11px] text-base-content/30 mt-1">
603
+ Absolute path to a local directory on this machine.
604
+ </p>
605
+ </fieldset>
606
+ <fieldset className="fieldset mb-3.5">
607
+ <legend className="fieldset-legend text-[11px] font-semibold text-base-content/40 uppercase tracking-[0.08em]">
608
+ Display name <span className="font-normal normal-case tracking-normal">(optional)</span>
609
+ </legend>
610
+ <input
611
+ type="text"
612
+ value={props.title}
613
+ onChange={function (e) { props.onTitleChange(e.target.value); }}
614
+ placeholder={props.path ? (props.path.replace(/\/+$/, "").split("/").pop() || "My App") : "My App"}
615
+ className="input input-bordered w-full bg-base-300 text-base-content text-[14px] focus:border-primary"
616
+ />
617
+ </fieldset>
618
+ </div>
619
+ );
620
+ }
621
+
622
+ interface DoneStepProps {
623
+ configured: string[];
624
+ }
625
+
626
+ function DoneStep(props: DoneStepProps) {
627
+ return (
628
+ <div className="flex flex-col items-center text-center">
629
+ <div className="wizard-check-pop mb-4">
630
+ <CheckCircle size={36} className="text-success" strokeWidth={1.5} aria-hidden="true" />
631
+ </div>
632
+ <h2 className="font-mono text-[26px] font-bold text-base-content tracking-tight mb-2">
633
+ You're all set
634
+ </h2>
635
+ <p className="text-[13px] text-base-content/60 leading-relaxed mb-5">
636
+ Lattice is configured and ready to go.
637
+ </p>
638
+
639
+ {props.configured.length > 0 ? (
640
+ <ul className="list-none p-0 m-0 flex flex-col gap-1.5 text-left w-full">
641
+ {props.configured.map(function (item: string, i: number) {
642
+ return (
643
+ <li key={i} className="wizard-fade-in flex items-center gap-2 bg-base-300 px-3 py-2 rounded-md" data-delay={i * 60}>
644
+ <span className="w-4 h-4 rounded-full bg-success/20 text-success flex items-center justify-center flex-shrink-0">
645
+ <Check size={10} />
646
+ </span>
647
+ <span className="font-mono text-[12px] text-base-content/60">{item}</span>
648
+ </li>
649
+ );
650
+ })}
651
+ </ul>
652
+ ) : (
653
+ <p className="text-[11px] text-base-content/30">
654
+ Everything was skipped — configure it from settings anytime.
655
+ </p>
656
+ )}
657
+ </div>
658
+ );
659
+ }
660
+
661
+ var wizardCSS = `
662
+ @keyframes wizard-fade-in {
663
+ from { opacity: 0; transform: translateY(12px); }
664
+ to { opacity: 1; transform: translateY(0); }
665
+ }
666
+ @keyframes wizard-slide-in-right {
667
+ from { opacity: 0; transform: translateX(24px); }
668
+ to { opacity: 1; transform: translateX(0); }
669
+ }
670
+ @keyframes wizard-slide-in-left {
671
+ from { opacity: 0; transform: translateX(-24px); }
672
+ to { opacity: 1; transform: translateX(0); }
673
+ }
674
+ @keyframes wizard-slide-out-left {
675
+ from { opacity: 1; transform: translateX(0); }
676
+ to { opacity: 0; transform: translateX(-24px); }
677
+ }
678
+ @keyframes wizard-slide-out-right {
679
+ from { opacity: 1; transform: translateX(0); }
680
+ to { opacity: 0; transform: translateX(24px); }
681
+ }
682
+ @keyframes wizard-check-pop {
683
+ 0% { transform: scale(0.5); opacity: 0; }
684
+ 70% { transform: scale(1.15); }
685
+ 100% { transform: scale(1); opacity: 1; }
686
+ }
687
+ @keyframes wizard-cursor-blink {
688
+ 0%, 100% { opacity: 1; }
689
+ 50% { opacity: 0; }
690
+ }
691
+ @keyframes wizard-grid-shift {
692
+ 0% { background-position: 0 0; }
693
+ 100% { background-position: 40px 40px; }
694
+ }
695
+ .wizard-fade-in {
696
+ animation: wizard-fade-in 400ms ease both;
697
+ }
698
+ .wizard-slide-in-right {
699
+ animation: wizard-slide-in-right 220ms ease both;
700
+ }
701
+ .wizard-slide-in-left {
702
+ animation: wizard-slide-in-left 220ms ease both;
703
+ }
704
+ .wizard-slide-out-left {
705
+ animation: wizard-slide-out-left 180ms ease both;
706
+ }
707
+ .wizard-slide-out-right {
708
+ animation: wizard-slide-out-right 180ms ease both;
709
+ }
710
+ .wizard-check-pop {
711
+ animation: wizard-check-pop 500ms cubic-bezier(0.175, 0.885, 0.32, 1.275) both;
712
+ }
713
+ .wizard-btn-primary {
714
+ transition: background 150ms ease, transform 100ms ease, box-shadow 150ms ease !important;
715
+ }
716
+ .wizard-btn-primary:hover {
717
+ transform: translateY(-1px);
718
+ box-shadow: 0 4px 16px oklch(from var(--color-primary) l c h / 0.35);
719
+ }
720
+ .wizard-btn-primary:active {
721
+ transform: translateY(0);
722
+ }
723
+ .wizard-btn-done {
724
+ transition: background 150ms ease, transform 100ms ease, box-shadow 150ms ease !important;
725
+ }
726
+ .wizard-btn-done:hover {
727
+ filter: brightness(1.08);
728
+ transform: translateY(-1px);
729
+ box-shadow: 0 4px 20px oklch(from var(--color-success) l c h / 0.4);
730
+ }
731
+ .wizard-btn-done:active {
732
+ transform: translateY(0);
733
+ }
734
+ @media (prefers-reduced-motion: reduce) {
735
+ .wizard-fade-in,
736
+ .wizard-slide-in-right,
737
+ .wizard-slide-in-left,
738
+ .wizard-slide-out-left,
739
+ .wizard-slide-out-right,
740
+ .wizard-check-pop {
741
+ animation: none !important;
742
+ opacity: 1 !important;
743
+ transform: none !important;
744
+ }
745
+ .wizard-btn-primary:hover,
746
+ .wizard-btn-done:hover {
747
+ transform: none !important;
748
+ }
749
+ }
750
+ `;