@bhooai/nexus-cli 2.0.6 → 2.0.7

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 (61) hide show
  1. package/package.json +1 -1
  2. package/src/commands/add.ts +8 -3
  3. package/src/commands/dev.ts +18 -2
  4. package/src/commands/init.ts +3 -0
  5. package/src/devPanel.ts +138 -11
  6. package/src/devServiceManager.ts +210 -11
  7. package/src/index.ts +19 -0
  8. package/templates/base/apps/admin/package.json.ejs +1 -0
  9. package/templates/base/apps/admin/src/App.tsx +38 -4127
  10. package/templates/base/apps/admin/src/alertCenter.tsx +3 -2
  11. package/templates/base/apps/admin/src/api.ts +11 -11
  12. package/templates/base/apps/admin/src/components/ThemeCentre.tsx +201 -0
  13. package/templates/base/apps/admin/src/components/shell/AppearancePanel.tsx +37 -0
  14. package/templates/base/apps/admin/src/components/shell/Dashboard.tsx +441 -0
  15. package/templates/base/apps/admin/src/components/shell/Login.tsx +69 -0
  16. package/templates/base/apps/admin/src/components/shell/NotifyPanel.tsx +102 -0
  17. package/templates/base/apps/admin/src/components/shell/RailPanel.tsx +133 -0
  18. package/templates/base/apps/admin/src/components/shell/SettingsDialog.tsx +217 -0
  19. package/templates/base/apps/admin/src/components/ui/ConfirmDialog.tsx +38 -0
  20. package/templates/base/apps/admin/src/components/ui/LintChecks.tsx +54 -0
  21. package/templates/base/apps/admin/src/components/ui/PageHead.tsx +13 -0
  22. package/templates/base/apps/admin/src/components/ui/PageHero.tsx +15 -0
  23. package/templates/base/apps/admin/src/icons.tsx +104 -0
  24. package/templates/base/apps/admin/src/index.css +77 -19
  25. package/templates/base/apps/admin/src/lib/constants.ts +94 -0
  26. package/templates/base/apps/admin/src/lib/theme.ts +80 -0
  27. package/templates/base/apps/admin/src/lib/types.ts +39 -0
  28. package/templates/base/apps/admin/src/lib/utils.ts +147 -0
  29. package/templates/base/apps/admin/src/main.tsx.ejs +3 -2
  30. package/templates/base/apps/admin/src/pages/Config.tsx +108 -0
  31. package/templates/base/apps/admin/src/pages/Databases.tsx +122 -0
  32. package/templates/base/apps/admin/src/pages/Environment.tsx +69 -0
  33. package/templates/base/apps/admin/src/pages/Logs.tsx +270 -0
  34. package/templates/base/apps/admin/src/pages/Monitoring.tsx +88 -0
  35. package/templates/base/apps/admin/src/pages/Overview.tsx +108 -0
  36. package/templates/base/apps/admin/src/pages/Payments.tsx +68 -0
  37. package/templates/base/apps/admin/src/pages/Plugins.tsx +36 -0
  38. package/templates/base/apps/admin/src/pages/Processes.tsx +67 -0
  39. package/templates/base/apps/admin/src/pages/Schema.tsx +130 -0
  40. package/templates/base/apps/admin/src/pages/Users.tsx +144 -0
  41. package/templates/base/apps/admin/src/pages/ai/AddProviderDialog.tsx +150 -0
  42. package/templates/base/apps/admin/src/pages/ai/AiAgents.tsx +31 -0
  43. package/templates/base/apps/admin/src/pages/ai/AiChatPlayground.tsx +208 -0
  44. package/templates/base/apps/admin/src/pages/ai/AiProviders.tsx +276 -0
  45. package/templates/base/apps/admin/src/pages/ai/AiSettings.tsx +84 -0
  46. package/templates/base/apps/admin/src/pages/ai/ProviderCard.tsx +145 -0
  47. package/templates/base/apps/admin/src/pages/ai/ProviderGroup.tsx +50 -0
  48. package/templates/base/apps/admin/src/pages/ai/ProviderKeyDialog.tsx +66 -0
  49. package/templates/base/apps/admin/src/pages/monitoring/RequestSeriesChart.tsx +70 -0
  50. package/templates/base/apps/admin/src/pages/overview/TrafficChart.tsx +32 -0
  51. package/templates/base/apps/admin/src/pages/payments/OrdersTable.tsx +27 -0
  52. package/templates/base/apps/admin/src/pages/payments/PaymentKeysDialog.tsx +74 -0
  53. package/templates/base/apps/admin/src/pages/payments/TestConsole.tsx +173 -0
  54. package/templates/base/apps/admin/src/pages/payments/TransactionsTable.tsx +26 -0
  55. package/templates/base/apps/admin/src/style.css +6517 -0
  56. package/templates/base/apps/admin/vite.config.ts.ejs +13 -8
  57. package/templates/base/apps/ai-server/main.py.ejs +2 -2
  58. package/templates/base/apps/backend/src/routes/index.ts.ejs +1 -1
  59. package/templates/base/apps/frontend/src/App.tsx.ejs +10 -0
  60. package/templates/base/apps/frontend/src/main.tsx.ejs +2 -10
  61. package/templates/base/apps/frontend/vite.config.ts.ejs +9 -4
@@ -1,4 +1,5 @@
1
1
  import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
2
+ import { Icon, type IconName } from './icons.js';
2
3
 
3
4
  export type AlertKind = 'ok' | 'err' | 'warn';
4
5
 
@@ -127,7 +128,7 @@ export function useAdminAlert(source: string): [
127
128
  return [msg, set];
128
129
  }
129
130
 
130
- const TOAST_ICONS: Record<AlertKind, string> = { ok: '', err: '', warn: '!' };
131
+ const TOAST_ICONS: Record<AlertKind, IconName> = { ok: 'check', err: 'x', warn: 'alertCircle' };
131
132
 
132
133
  /** Floating alert popups, top-right, themed via the surrounding admin shell. */
133
134
  export function ToastStack() {
@@ -137,7 +138,7 @@ export function ToastStack() {
137
138
  <div className="admin-toasts" role="region" aria-label="Notifications">
138
139
  {toasts.map((t) => (
139
140
  <div key={t.id} className={`admin-toast is-${t.kind}`} role="status">
140
- <span className="admin-toast-icon">{TOAST_ICONS[t.kind]}</span>
141
+ <span className="admin-toast-icon"><Icon name={TOAST_ICONS[t.kind]} size={14} /></span>
141
142
  <span className="admin-toast-body">
142
143
  <b>{t.message}</b>
143
144
  <small>{t.source} · {t.at}</small>
@@ -1,6 +1,6 @@
1
1
  // Admin API client. Auth/admin/plugin calls go to the backend (proxied by Vite
2
2
  // in dev); process-control calls go to the supervisor control API
3
- // (default :7474, auto-allotted upward when busy — the backend reports the real port).
3
+ // (default :7474, auto-allotted upward when busy the backend reports the real port).
4
4
 
5
5
  const SUPERVISOR_FALLBACK = (import.meta.env.VITE_SUPERVISOR_URL as string | undefined) ?? 'http://localhost:7474';
6
6
 
@@ -33,7 +33,7 @@ export function getAccessToken() { return accessToken; }
33
33
 
34
34
  async function refreshCsrf(): Promise<void> {
35
35
  // The server rotates the CSRF cookie on EVERY safe request, so a cached
36
- // token goes stale the moment any other GET runs (status polling, tables…).
36
+ // token goes stale the moment any other GET runs (status polling, tables).
37
37
  // Always re-fetch so the header matches the current cookie.
38
38
  const r = await fetch('/csrf-token', { credentials: 'include' });
39
39
  csrfToken = (await r.json()).token ?? '';
@@ -59,15 +59,15 @@ async function authErrorMessage(r: Response, fallback: string): Promise<string>
59
59
  const body = await r.json();
60
60
  const msg = body?.error?.message ?? body?.message ?? body?.detail;
61
61
  if (typeof msg === 'string' && msg.trim()) return msg;
62
- } catch { /* non-JSON body — fall through */ }
62
+ } catch { /* non-JSON body fall through */ }
63
63
  switch (r.status) {
64
- case 400: return 'Invalid request — please check the form.';
64
+ case 400: return 'Invalid request please check the form.';
65
65
  case 401: return 'Invalid email or password.';
66
66
  case 403: return 'Access denied.';
67
- case 404: return 'Auth endpoint not found — is the backend running?';
67
+ case 404: return 'Auth endpoint not found is the backend running?';
68
68
  case 409: return 'A user with that email already exists.';
69
- case 429: return 'Too many attempts — please wait a moment and retry.';
70
- case 500: return 'Server error — please try again later.';
69
+ case 429: return 'Too many attempts please wait a moment and retry.';
70
+ case 500: return 'Server error please try again later.';
71
71
  default: return `${fallback} (HTTP ${r.status})`;
72
72
  }
73
73
  }
@@ -83,7 +83,7 @@ export async function login(email: string, password: string): Promise<{ user: an
83
83
  body: JSON.stringify({ email, password }),
84
84
  });
85
85
  } catch {
86
- throw new Error('Cannot reach the backend — is the server running?');
86
+ throw new Error('Cannot reach the backend is the server running?');
87
87
  }
88
88
  if (!r.ok) throw new Error(await authErrorMessage(r, 'Login failed'));
89
89
  const data = await r.json();
@@ -103,7 +103,7 @@ export async function registerAndLogin(email: string, password: string, name: st
103
103
  body: JSON.stringify({ email, password, name }),
104
104
  });
105
105
  } catch {
106
- throw new Error('Cannot reach the backend — is the server running?');
106
+ throw new Error('Cannot reach the backend is the server running?');
107
107
  }
108
108
  if (!r.ok) throw new Error(await authErrorMessage(r, 'Registration failed'));
109
109
  const data = await r.json();
@@ -118,7 +118,7 @@ let refreshInFlight: Promise<boolean> | null = null;
118
118
  * Restore the admin session from the HttpOnly refresh cookie (app boot).
119
119
  * /auth/refresh returns a fresh access token; the user is then fetched via
120
120
  * /auth/me. Single-flight so React StrictMode's double effect-run issues one
121
- * network request — the backend rotates refresh tokens and revokes a session
121
+ * network request the backend rotates refresh tokens and revokes a session
122
122
  * family on reuse, so concurrent refreshes would log the user out.
123
123
  */
124
124
  export async function refresh(): Promise<boolean> {
@@ -394,7 +394,7 @@ export interface AiProviderTestResult {
394
394
  checkedAt: string;
395
395
  }
396
396
 
397
- /** POST /admin/ai/providers/:id/test — probe a single provider (lists models). */
397
+ /** POST /admin/ai/providers/:id/test probe a single provider (lists models). */
398
398
  export async function testAiProvider(id: string): Promise<AiProviderTestResult> {
399
399
  await refreshCsrf();
400
400
  return adminFetch(`/admin/ai/providers/${encodeURIComponent(id)}/test`, {
@@ -0,0 +1,201 @@
1
+ import React from 'react';
2
+ import { useState } from 'react';
3
+ import type { AdminTheme, UiMode, CustomTheme, ParticleConfig } from '../lib/types.js';
4
+ import { SWATCH_CPLAETTE, THEME_PRESETS, DEFAULT_PARTICLES, CUSTOM_THEME_KEY, PARTICLES_KEY, TOGGLE_STYLES } from '../lib/constants.js';
5
+ import { randomAccentPalette, randomParticleColors } from '../lib/theme.js';
6
+ import { Icon } from '../icons.js';
7
+ import { PageHead } from './ui/PageHead.js';
8
+ import { PageHero } from './ui/PageHero.js';
9
+
10
+ export function ThemeCentre({
11
+ theme,
12
+ onThemeChange,
13
+ uiMode,
14
+ onUiModeChange,
15
+ custom,
16
+ onCustom,
17
+ particles,
18
+ onParticles,
19
+ }: {
20
+ theme: AdminTheme;
21
+ onThemeChange: (theme: AdminTheme) => void;
22
+ uiMode: UiMode;
23
+ onUiModeChange: (mode: UiMode) => void;
24
+ custom: CustomTheme;
25
+ onCustom: (partial: Partial<CustomTheme>) => void;
26
+ particles: ParticleConfig;
27
+ onParticles: (partial: Partial<ParticleConfig>) => void;
28
+ }) {
29
+ const [copied, setCopied] = useState(false);
30
+ const colors: { key: keyof Omit<CustomTheme, 'active'>; label: string; detail: string }[] = [
31
+ { key: 'blue', label: 'Primary blue', detail: 'Buttons, focus rings & links' },
32
+ { key: 'violet', label: 'Accent violet', detail: 'Highlights & gradients' },
33
+ { key: 'pink', label: 'Accent pink', detail: 'Special surfaces & marks' },
34
+ { key: 'bg', label: 'Background', detail: 'Base canvas of the shell' },
35
+ { key: 'ink', label: 'Foreground text', detail: 'Headings & body ink' },
36
+ { key: 'muted', label: 'Muted text', detail: 'Captions & secondary labels' },
37
+ ];
38
+ const setColor = (key: keyof Omit<CustomTheme, 'active'>, value: string) => onCustom({ [key]: value });
39
+ const setParticleColor = (index: number, value: string) => {
40
+ const next = [...particles.colors];
41
+ next[index] = value;
42
+ onParticles({ colors: next });
43
+ };
44
+ const addParticleColor = () => {
45
+ const next = [...particles.colors, SWATCH_CPLAETTE[particles.colors.length % SWATCH_CPLAETTE.length]];
46
+ onParticles({ colors: next });
47
+ };
48
+ const removeParticleColor = (index: number) => {
49
+ if (particles.colors.length <= 1) return;
50
+ onParticles({ colors: particles.colors.filter((_, i) => i !== index) });
51
+ };
52
+ const exportJson = () => {
53
+ const payload = JSON.stringify({ version: 1, theme, uiMode, custom, particles }, null, 2);
54
+ const write = () => { navigator.clipboard.writeText(payload).then(() => setCopied(true)).catch(() => fallback(payload)); };
55
+ const fallback = (text: string) => {
56
+ const el = document.createElement('textarea');
57
+ el.value = text; document.body.appendChild(el); el.select();
58
+ try { document.execCommand('copy'); setCopied(true); } catch { /* ignore */ }
59
+ document.body.removeChild(el);
60
+ };
61
+ write();
62
+ window.setTimeout(() => setCopied(false), 1600);
63
+ };
64
+ const importJson = async () => {
65
+ let text = '';
66
+ try { text = await navigator.clipboard.readText(); } catch { text = window.prompt('Paste theme JSON to import:') ?? ''; }
67
+ if (!text) return;
68
+ try {
69
+ const parsed = JSON.parse(text) as { theme?: AdminTheme; uiMode?: UiMode; custom?: Partial<CustomTheme>; particles?: Partial<ParticleConfig> };
70
+ if (parsed.theme && ['aurora', 'midnight', 'violet'].includes(parsed.theme)) onThemeChange(parsed.theme);
71
+ if (parsed.uiMode && ['workspace', 'compact', 'focus'].includes(parsed.uiMode)) onUiModeChange(parsed.uiMode);
72
+ if (parsed.custom) onCustom(parsed.custom);
73
+ if (parsed.particles) onParticles(parsed.particles);
74
+ } catch { /* ignore bad import */ }
75
+ };
76
+ const resetAll = () => {
77
+ onCustom({ ...THEME_PRESETS[theme], active: false });
78
+ onParticles({ ...DEFAULT_PARTICLES });
79
+ try { localStorage.removeItem(CUSTOM_THEME_KEY); localStorage.removeItem(PARTICLES_KEY); } catch { /* ignore */ }
80
+ };
81
+ const slider = (label: string, detail: string, value: number, min: number, max: number, step: number, display: string, onChange: (v: number) => void) => (
82
+ <label className="surface-row">
83
+ <span className="surface-meta"><b>{label}</b><small>{detail}</small></span>
84
+ <input type="range" className="surface-range" min={min} max={max} step={step} value={value} onChange={(e) => onChange(Number(e.target.value))} />
85
+ <em className="surface-value">{display}</em>
86
+ </label>
87
+ );
88
+ return (
89
+ <div className="space-y-6">
90
+ <PageHead title="Theme Centre">
91
+ <div className="heading-actions">
92
+ <button type="button" className="workspace-action" onClick={() => { void importJson(); }}><Icon name="download" size={12} /> Import</button>
93
+ <button type="button" className="workspace-action" onClick={exportJson}>{copied ? 'Copied' : <><Icon name="upload" size={12} /> Export</>}</button>
94
+ <button type="button" className="workspace-action" onClick={resetAll}><Icon name="refresh" size={12} /> Reset all</button>
95
+ </div>
96
+ </PageHead>
97
+ <PageHero kicker="SURFACE STUDIO" title={<>Make it <em>yours.</em></>} desc="Curated themes, your own palette, particle fields and surface tuning — applied live to this workspace and remembered locally in your browser." glyph="palette" art="palette" />
98
+ <section className="glass-card space-y-4">
99
+ <div className="flex items-center justify-between">
100
+ <div><span className="admin-overline">CURATED THEMES</span><h3 className="glass-h3">Start from a surface</h3></div>
101
+ <span className="live-badge"><i className="status-dot good" /> LIVE</span>
102
+ </div>
103
+ <div className="theme-gallery">
104
+ {([['aurora', 'Aurora', 'Electric blue glow'], ['midnight', 'Midnight', 'Deep navy calm'], ['violet', 'Violet', 'Indigo shimmer']] as const).map(([id, label, detail]) => {
105
+ const preset = THEME_PRESETS[id];
106
+ const isActive = !custom.active && theme === id;
107
+ return (
108
+ <button key={id} type="button" className={`theme-card${isActive ? ' is-active' : ''}`} onClick={() => { onThemeChange(id); onCustom({ ...preset, active: false }); }}>
109
+ <span className={`theme-preview ${id}`} />
110
+ <span className="theme-card-body"><b>{label}</b><small>{detail}</small></span>
111
+ {isActive && <i className="theme-card-check">✓</i>}
112
+ </button>
113
+ );
114
+ })}
115
+ </div>
116
+ <div className="ui-mode-options ui-mode-row">
117
+ {([['workspace', 'Workspace', 'Inspector + tools'], ['compact', 'Compact', 'More content'], ['focus', 'Focus', 'Canvas first']] as const).map(([id, label, detail]) => (
118
+ <button key={id} onClick={() => onUiModeChange(id)} className={uiMode === id ? 'is-selected' : ''}><span>{label}</span><small>{detail}</small></button>
119
+ ))}
120
+ </div>
121
+ </section>
122
+ <section className="glass-card space-y-4">
123
+ <div className="flex items-center justify-between">
124
+ <div><span className="admin-overline">CUSTOM THEME</span><h3 className="glass-h3">Your own palette</h3></div>
125
+ <div className="flex items-center gap-2">
126
+ <button type="button" className="glass-chip-btn" onClick={() => { const p = randomAccentPalette(); onCustom({ ...p, active: !custom.active ? true : custom.active }); }}><Icon name="sparkles" size={12} /> Randomize</button>
127
+ <button type="button" className={`custom-toggle${custom.active ? ' is-active' : ''}`} onClick={() => onCustom({ active: !custom.active })}>{custom.active ? 'Applied · live' : 'Apply custom'}</button>
128
+ <button type="button" className="glass-chip-btn" onClick={() => onCustom({ ...THEME_PRESETS[theme], active: true })}>Seed from {theme}</button>
129
+ </div>
130
+ </div>
131
+ <p className="text-sm text-slate-400">Tune each color below — the workspace re-themes instantly while custom is applied. Stored locally in your browser.</p>
132
+ <div className="custom-grid">
133
+ {colors.map((c) => (
134
+ <label key={c.key} className="color-row">
135
+ <span className="color-swatch"><input type="color" value={custom[c.key]} disabled={!custom.active} onChange={(e) => setColor(c.key, e.target.value)} /></span>
136
+ <span className="color-meta"><b>{c.label}</b><small>{c.detail}</small></span>
137
+ <input className="glass-input color-hex" value={custom[c.key]} disabled={!custom.active} onChange={(e) => { const v = e.target.value.trim(); if (/^#[0-9a-fA-F]{3,8}$/.test(v)) setColor(c.key, v); }} />
138
+ </label>
139
+ ))}
140
+ </div>
141
+ <div className="theme-preview-tile" style={custom.active ? { ['--p-blue' as string]: custom.blue, ['--p-violet' as string]: custom.violet, ['--p-pink' as string]: custom.pink, ['--p-bg' as string]: custom.bg } : undefined}>
142
+ <div className="theme-preview-glow" />
143
+ <div className="theme-preview-card">
144
+ <span className="theme-preview-overline">LIVE PREVIEW</span>
145
+ <b className="theme-preview-title">Surface preview</b>
146
+ <div className="theme-preview-actions"><button className="glass-chip-btn-primary flex-1 justify-center">Primary</button><button className="glass-chip-btn flex-1 justify-center">chip</button></div>
147
+ <div className="theme-preview-dots"><i /><i /><i /></div>
148
+ </div>
149
+ </div>
150
+ </section>
151
+ <section className="glass-card space-y-4">
152
+ <div className="flex items-center justify-between">
153
+ <div><span className="admin-overline">SURFACE TUNING</span><h3 className="glass-h3">Glow, glass & shape</h3></div>
154
+ <span className="live-badge"><i className="status-dot good" /> LIVE</span>
155
+ </div>
156
+ {slider('Ambient glow', 'Mesh & aura strength behind the shell', custom.mesh, 0, 1, 0.02, `${Math.round(custom.mesh * 100)}%`, (v) => onCustom({ mesh: v }))}
157
+ {slider('Panel glass', 'Frosted blur behind cards & panels', custom.glass, 0, 1, 0.02, `${Math.round(custom.glass * 100)}%`, (v) => onCustom({ glass: v }))}
158
+ {slider('Corner radius', 'Roundness of cards, panels & buttons', custom.radius, 0, 24, 1, `${custom.radius}px`, (v) => onCustom({ radius: v }))}
159
+ <div>
160
+ <span className="admin-overline">TOGGLE STYLE</span>
161
+ <div className="toggle-style-row">
162
+ {TOGGLE_STYLES.map((t) => (
163
+ <button
164
+ key={t.id}
165
+ type="button"
166
+ className={`toggle-style-btn${custom.toggle === t.id ? ' is-selected' : ''}`}
167
+ onClick={() => onCustom({ toggle: t.id })}
168
+ >
169
+ <span className={`ai-toggle tg-${t.id} is-on toggle-style-preview`}><span className="ai-toggle-knob" /></span>
170
+ <span className="toggle-style-meta"><b>{t.label}</b><small>{t.hint}</small></span>
171
+ {custom.toggle === t.id && <i className="toggle-style-check">✓</i>}
172
+ </button>
173
+ ))}
174
+ </div>
175
+ </div>
176
+ </section>
177
+ <section className="glass-card space-y-4">
178
+ <div className="flex items-center justify-between">
179
+ <div><span className="admin-overline">PARTICLE FIELD</span><h3 className="glass-h3">Floating background dust</h3></div>
180
+ <div className="flex items-center gap-2">
181
+ <button type="button" className="glass-chip-btn" onClick={() => onParticles({ colors: randomParticleColors() })}><Icon name="sparkles" size={12} /> Randomize</button>
182
+ <button type="button" className={`custom-toggle${particles.amount > 0 ? ' is-active' : ''}`} onClick={() => onParticles({ amount: particles.amount > 0 ? 0 : 14 })}>{particles.amount > 0 ? 'On' : 'Off'}</button>
183
+ </div>
184
+ </div>
185
+ <p className="text-sm text-slate-400">Control how many particles drift behind the workspace, how fast they rise, and build a custom color list for them.</p>
186
+ {slider('Particle amount', 'Count of dots in the field', particles.amount, 0, 48, 1, String(particles.amount), (v) => onParticles({ amount: v }))}
187
+ {slider('Particle speed', 'Rise speed multiplier', particles.speed, 0.2, 3, 0.05, `${particles.speed.toFixed(2)}×`, (v) => onParticles({ speed: v }))}
188
+ <div className="palette-list">
189
+ {particles.colors.map((c, i) => (
190
+ <div className="palette-chip" key={i}>
191
+ <span className="color-swatch"><input type="color" value={c} onChange={(e) => setParticleColor(i, e.target.value)} /></span>
192
+ <input className="glass-input color-hex" value={c} onChange={(e) => { const v = e.target.value.trim(); if (/^#[0-9a-fA-F]{3,8}$/.test(v)) setParticleColor(i, v); }} />
193
+ <button type="button" className="row-delete" onClick={() => removeParticleColor(i)} aria-label="Remove color" disabled={particles.colors.length <= 1}>×</button>
194
+ </div>
195
+ ))}
196
+ <button type="button" className="add-color" onClick={addParticleColor}>+ Add color</button>
197
+ </div>
198
+ </section>
199
+ </div>
200
+ );
201
+ }
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ import type { AdminTheme, UiMode } from '../../lib/types.js';
3
+
4
+ export function AppearancePanel({
5
+ theme,
6
+ uiMode,
7
+ onThemeChange,
8
+ onUiModeChange,
9
+ }: {
10
+ theme: AdminTheme;
11
+ uiMode: UiMode;
12
+ onThemeChange: (theme: AdminTheme) => void;
13
+ onUiModeChange: (mode: UiMode) => void;
14
+ }) {
15
+ return (
16
+ <div className="appearance-panel">
17
+ <div className="appearance-heading"><span className="admin-overline">APPEARANCE</span><span>LOCAL</span></div>
18
+ <strong>Choose your surface</strong>
19
+ <div className="appearance-options">
20
+ {([['aurora', 'Aurora', 'Electric blue'], ['midnight', 'Midnight', 'Deep navy'], ['violet', 'Violet', 'Indigo glow']] as const).map(([id, label, detail]) => (
21
+ <button key={id} onClick={() => onThemeChange(id)} className={theme === id ? 'is-selected' : ''}>
22
+ <span className={`theme-preview ${id}`} />
23
+ <span><b>{label}</b><small>{detail}</small></span>
24
+ {theme === id && <i>✓</i>}
25
+ </button>
26
+ ))}
27
+ </div>
28
+ <div className="appearance-divider" />
29
+ <span className="admin-overline">INTERFACE MODE</span>
30
+ <div className="ui-mode-options">
31
+ {([['workspace', 'Workspace', 'Inspector + tools'], ['compact', 'Compact', 'More content'], ['focus', 'Focus', 'Canvas first']] as const).map(([id, label, detail]) => (
32
+ <button key={id} onClick={() => onUiModeChange(id)} className={uiMode === id ? 'is-selected' : ''}><span>{label}</span><small>{detail}</small></button>
33
+ ))}
34
+ </div>
35
+ </div>
36
+ );
37
+ }