aidevops 3.21.11 → 3.22.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 (34) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/aidevops.sh +3 -2
  4. package/bun.lock +573 -0
  5. package/package.json +12 -1
  6. package/packages/gui-api/package.json +9 -0
  7. package/packages/gui-api/src/app.ts +49 -0
  8. package/packages/gui-api/src/file-adapter.ts +207 -0
  9. package/packages/gui-api/src/server.ts +12 -0
  10. package/packages/gui-api/src/status-adapter-utils.ts +108 -0
  11. package/packages/gui-api/src/status-adapter.ts +484 -0
  12. package/packages/gui-api/src/status-local-repos.ts +239 -0
  13. package/packages/gui-desktop/package.json +6 -0
  14. package/packages/gui-desktop/scripts/install-macos-app.sh +1020 -0
  15. package/packages/gui-shared/package.json +9 -0
  16. package/packages/gui-shared/src/contracts.ts +341 -0
  17. package/packages/gui-shared/src/fixtures.ts +250 -0
  18. package/packages/gui-shared/src/index.ts +2 -0
  19. package/packages/gui-web/index.html +24 -0
  20. package/packages/gui-web/package.json +25 -0
  21. package/packages/gui-web/src/App.tsx +269 -0
  22. package/packages/gui-web/src/AppNavigation.tsx +379 -0
  23. package/packages/gui-web/src/AppWorkspace.tsx +114 -0
  24. package/packages/gui-web/src/FileExplorerSurface.tsx +153 -0
  25. package/packages/gui-web/src/InventorySurfaces.tsx +117 -0
  26. package/packages/gui-web/src/PathActions.tsx +17 -0
  27. package/packages/gui-web/src/StatusSurfaces.tsx +396 -0
  28. package/packages/gui-web/src/app-model.ts +546 -0
  29. package/packages/gui-web/src/dashboard.ts +79 -0
  30. package/packages/gui-web/src/main.tsx +24 -0
  31. package/packages/gui-web/src/status-client.ts +93 -0
  32. package/packages/gui-web/src/styles.css +1540 -0
  33. package/packages/gui-web/vite.config.ts +14 -0
  34. package/setup.sh +78 -3
@@ -0,0 +1,269 @@
1
+ import type { GuiResponseEnvelope, GuiStatusData } from "@aidevops/gui-shared";
2
+ import { type ReactElement, useEffect, useState } from "react";
3
+ import { MachineRail, Sidebar } from "./AppNavigation";
4
+ import { Workspace } from "./AppWorkspace";
5
+ import type { FontPreference, FontSizePreference, SurfaceId, ThemePreference } from "./app-model";
6
+ import { DEFAULT_ACCENT_HUE, DEFAULT_FONT, DEFAULT_FONT_SIZE, fileRootBySurface, findSurface, findSurfaceSectionLabel, fontFamilyForPreference, fontSizeForPreference, getSystemTheme, isFontPreference, isFontSizePreference } from "./app-model";
7
+ import { fetchStatus, mockedStatus } from "./status-client";
8
+
9
+ interface NavigationHistory {
10
+ entries: SurfaceId[];
11
+ index: number;
12
+ }
13
+
14
+ interface WebKitBridgeWindow extends Window {
15
+ webkit?: {
16
+ messageHandlers?: {
17
+ accentHue?: {
18
+ postMessage: (hue: number) => void;
19
+ };
20
+ };
21
+ };
22
+ }
23
+
24
+ type AppearanceStorage = Pick<Storage, "getItem" | "setItem">;
25
+ type ReadableAppearanceStorage = Pick<Storage, "getItem">;
26
+ type AppearanceStorageKey = (typeof appearanceStorageKeys)[keyof typeof appearanceStorageKeys];
27
+
28
+ export const appearanceStorageKeys = {
29
+ accentHue: "aidevops-gui-accent-hue",
30
+ font: "aidevops-gui-font",
31
+ fontSize: "aidevops-gui-font-size",
32
+ machineRail: "aidevops-gui-show-machine-rail",
33
+ showBorders: "aidevops-gui-show-borders",
34
+ showNavCounts: "aidevops-gui-show-nav-counts",
35
+ theme: "aidevops-gui-theme",
36
+ } as const;
37
+
38
+ export interface StoredAppearancePreferences {
39
+ accentHue: number;
40
+ fontPreference: FontPreference;
41
+ fontSizePreference: FontSizePreference;
42
+ machineRailVisible: boolean;
43
+ showBorders: boolean;
44
+ showNavCounts: boolean;
45
+ themePreference: ThemePreference;
46
+ }
47
+
48
+ export function readStoredAppearancePreferences(storage: ReadableAppearanceStorage | undefined = browserLocalStorage(), isDesktopShell = isMacosDesktopBrowser()): StoredAppearancePreferences {
49
+ const savedTheme = storage?.getItem(appearanceStorageKeys.theme);
50
+ const savedAccentHue = Number.parseInt(storage?.getItem(appearanceStorageKeys.accentHue) ?? "", 10);
51
+ const savedFont = storage?.getItem(appearanceStorageKeys.font) ?? null;
52
+ const savedFontSize = storage?.getItem(appearanceStorageKeys.fontSize) ?? null;
53
+
54
+ return {
55
+ accentHue: Number.isFinite(savedAccentHue) && savedAccentHue >= 0 && savedAccentHue <= 359 ? savedAccentHue : DEFAULT_ACCENT_HUE,
56
+ fontPreference: isFontPreference(savedFont) ? savedFont : DEFAULT_FONT,
57
+ fontSizePreference: isFontSizePreference(savedFontSize) ? savedFontSize : DEFAULT_FONT_SIZE,
58
+ machineRailVisible: isDesktopShell ? readStoredBoolean(storage, appearanceStorageKeys.machineRail, true) : true,
59
+ showBorders: readStoredBoolean(storage, appearanceStorageKeys.showBorders, true),
60
+ showNavCounts: readStoredBoolean(storage, appearanceStorageKeys.showNavCounts, true),
61
+ themePreference: savedTheme === "system" || savedTheme === "light" || savedTheme === "dark" ? savedTheme : "system",
62
+ };
63
+ }
64
+
65
+ export function App(): ReactElement {
66
+ const [storedAppearancePreferences] = useState<StoredAppearancePreferences>(() => readStoredAppearancePreferences());
67
+ const [status, setStatus] = useState<GuiResponseEnvelope<GuiStatusData>>(mockedStatus());
68
+ const [navigation, setNavigation] = useState<NavigationHistory>({ entries: ["overview"], index: 0 });
69
+ const [themePreference, setThemePreference] = useState<ThemePreference>(storedAppearancePreferences.themePreference);
70
+ const [accentHue, setAccentHue] = useState(storedAppearancePreferences.accentHue);
71
+ const [fontPreference, setFontPreference] = useState<FontPreference>(storedAppearancePreferences.fontPreference);
72
+ const [fontSizePreference, setFontSizePreference] = useState<FontSizePreference>(storedAppearancePreferences.fontSizePreference);
73
+ const [machineRailVisible, setMachineRailVisible] = useState(storedAppearancePreferences.machineRailVisible);
74
+ const [showNavCounts, setShowNavCounts] = useState(storedAppearancePreferences.showNavCounts);
75
+ const [showBorders, setShowBorders] = useState(storedAppearancePreferences.showBorders);
76
+ const [systemTheme, setSystemTheme] = useState<"light" | "dark">("light");
77
+ const resolvedTheme = themePreference === "system" ? systemTheme : themePreference;
78
+ const activeSurface: SurfaceId = navigation.entries[navigation.index] ?? "overview";
79
+ const activeItem = findSurface(activeSurface);
80
+ const activeSectionLabel = findSurfaceSectionLabel(activeSurface);
81
+ const canGoBack = navigation.index > 0;
82
+ const canGoForward = navigation.index < navigation.entries.length - 1;
83
+ const fileRoot = fileRootBySurface[activeSurface];
84
+
85
+ const setActiveSurface = (surface: SurfaceId) => {
86
+ setNavigation((current) => {
87
+ const currentSurface = current.entries[current.index] ?? "overview";
88
+ if (currentSurface === surface) {
89
+ return current;
90
+ }
91
+
92
+ const entries = current.entries.slice(0, current.index + 1);
93
+ entries.push(surface);
94
+ return { entries, index: entries.length - 1 };
95
+ });
96
+ };
97
+
98
+ const goBack = () => {
99
+ setNavigation((current) => ({ ...current, index: Math.max(0, current.index - 1) }));
100
+ };
101
+
102
+ const goForward = () => {
103
+ setNavigation((current) => ({ ...current, index: Math.min(current.entries.length - 1, current.index + 1) }));
104
+ };
105
+
106
+ useEffect(() => {
107
+ const isMacosDesktop = isMacosDesktopBrowser();
108
+ const toggleMachineRail = () => setMachineRailVisible((current) => !current);
109
+
110
+ if (isMacosDesktop) {
111
+ document.documentElement.dataset.desktopShell = "macos";
112
+ } else {
113
+ delete document.documentElement.dataset.desktopShell;
114
+ }
115
+
116
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
117
+ const updateSystemTheme = () => setSystemTheme(getSystemTheme());
118
+ updateSystemTheme();
119
+ mediaQuery.addEventListener("change", updateSystemTheme);
120
+ window.addEventListener("aidevops:toggle-machine-rail", toggleMachineRail);
121
+
122
+ return () => {
123
+ mediaQuery.removeEventListener("change", updateSystemTheme);
124
+ window.removeEventListener("aidevops:toggle-machine-rail", toggleMachineRail);
125
+ };
126
+ }, []);
127
+
128
+ useEffect(() => {
129
+ document.documentElement.dataset.theme = resolvedTheme;
130
+ document.documentElement.style.colorScheme = resolvedTheme;
131
+ persistAppearancePreference(appearanceStorageKeys.theme, themePreference);
132
+ }, [resolvedTheme, themePreference]);
133
+
134
+ useEffect(() => {
135
+ document.documentElement.style.setProperty("--accent-hue", String(accentHue));
136
+ persistAppearancePreference(appearanceStorageKeys.accentHue, String(accentHue));
137
+ sendNativeAccentHue(accentHue);
138
+ }, [accentHue]);
139
+
140
+ useEffect(() => {
141
+ document.documentElement.style.setProperty("--font-family-app", fontFamilyForPreference(fontPreference));
142
+ persistAppearancePreference(appearanceStorageKeys.font, fontPreference);
143
+ }, [fontPreference]);
144
+
145
+ useEffect(() => {
146
+ document.documentElement.style.setProperty("--font-size-app", fontSizeForPreference(fontSizePreference));
147
+ persistAppearancePreference(appearanceStorageKeys.fontSize, fontSizePreference);
148
+ }, [fontSizePreference]);
149
+
150
+ useEffect(() => {
151
+ fetchStatus()
152
+ .then(setStatus)
153
+ .catch(() => setStatus(mockedStatus()));
154
+ }, []);
155
+
156
+ useEffect(() => {
157
+ persistAppearancePreference(appearanceStorageKeys.showNavCounts, String(showNavCounts));
158
+ }, [showNavCounts]);
159
+
160
+ useEffect(() => {
161
+ document.documentElement.dataset.borders = showBorders ? "visible" : "hidden";
162
+ persistAppearancePreference(appearanceStorageKeys.showBorders, String(showBorders));
163
+ }, [showBorders]);
164
+
165
+ useEffect(() => {
166
+ persistAppearancePreference(appearanceStorageKeys.machineRail, String(machineRailVisible));
167
+ }, [machineRailVisible]);
168
+
169
+ return (
170
+ <main className={machineRailVisible ? "app-shell" : "app-shell machine-rail-collapsed"}>
171
+ <div className="desktop-titlebar-tagline" aria-hidden="true">Your data protected. Your systems managed. Your creations published.</div>
172
+ {machineRailVisible ? <MachineRail machine={status.data.machine} /> : null}
173
+ <Sidebar
174
+ activeSurface={activeSurface}
175
+ accentHue={accentHue}
176
+ canGoBack={canGoBack}
177
+ canGoForward={canGoForward}
178
+ fontSizePreference={fontSizePreference}
179
+ fontPreference={fontPreference}
180
+ goBack={goBack}
181
+ goForward={goForward}
182
+ setAccentHue={setAccentHue}
183
+ setActiveSurface={setActiveSurface}
184
+ setFontSizePreference={setFontSizePreference}
185
+ setFontPreference={setFontPreference}
186
+ setShowBorders={setShowBorders}
187
+ setShowNavCounts={setShowNavCounts}
188
+ setThemePreference={setThemePreference}
189
+ showBorders={showBorders}
190
+ showNavCounts={showNavCounts}
191
+ status={status.data}
192
+ themePreference={themePreference}
193
+ />
194
+ <Workspace activeItem={activeItem} activeSectionLabel={activeSectionLabel} activeSurface={activeSurface} fileRoot={fileRoot} status={status.data} />
195
+ <DesktopStatusBar status={status.data} />
196
+ </main>
197
+ );
198
+ }
199
+
200
+ function sendNativeAccentHue(hue: number): void {
201
+ const webkit = (window as WebKitBridgeWindow).webkit;
202
+ webkit?.messageHandlers?.accentHue?.postMessage(hue);
203
+ }
204
+
205
+ function browserLocalStorage(): AppearanceStorage | undefined {
206
+ if (typeof window === "undefined") {
207
+ return undefined;
208
+ }
209
+
210
+ try {
211
+ return window.localStorage;
212
+ } catch {
213
+ return undefined;
214
+ }
215
+ }
216
+
217
+ function isMacosDesktopBrowser(): boolean {
218
+ if (typeof window === "undefined") {
219
+ return false;
220
+ }
221
+
222
+ return new URLSearchParams(window.location.search).get("desktop") === "macos";
223
+ }
224
+
225
+ function persistAppearancePreference(key: AppearanceStorageKey, value: string): void {
226
+ try {
227
+ browserLocalStorage()?.setItem(key, value);
228
+ } catch {
229
+ // Ignore unavailable storage so the local GUI can still render.
230
+ }
231
+ }
232
+
233
+ function readStoredBoolean(storage: ReadableAppearanceStorage | undefined, key: AppearanceStorageKey, fallback: boolean): boolean {
234
+ const savedValue = storage?.getItem(key);
235
+
236
+ if (savedValue === "false") {
237
+ return false;
238
+ }
239
+
240
+ if (savedValue === "true") {
241
+ return true;
242
+ }
243
+
244
+ return fallback;
245
+ }
246
+
247
+ function DesktopStatusBar({ status }: { status: GuiStatusData }): ReactElement {
248
+ const version = status.update.installed_version !== "unknown" ? status.update.installed_version : status.aidevops_version;
249
+ const versionLabel = version === "unknown" || version.startsWith("v") ? version : `v${version}`;
250
+ const needsUpdate = status.setup_targets.filter((target) => target.needs_update).length + status.ai_apps.filter((app) => app.needs_update).length;
251
+ const oauthAccounts = status.oauth_pool.providers.reduce((total, provider) => total + provider.total, 0);
252
+ const localRepoCount = status.local_repos.total || status.local_repos.repos.length;
253
+ const remoteRepoCount = status.repos.total || status.repos.repos.length;
254
+ const statusLabel = status.update.restart_required ? "Restart required" : "Ready";
255
+
256
+ return (
257
+ <div className="desktop-status-bar" role="status">
258
+ <span className={status.update.restart_required ? "status-dot warn" : "status-dot"} aria-hidden="true" />
259
+ <strong>{statusLabel}</strong>
260
+ <span>Read-only local GUI</span>
261
+ <span>{versionLabel}</span>
262
+ <span>{localRepoCount} local repos</span>
263
+ <span>{remoteRepoCount} remote repos</span>
264
+ <span>{status.secrets.length} secrets</span>
265
+ <span>{oauthAccounts} provider accounts</span>
266
+ <span>{needsUpdate} need update</span>
267
+ </div>
268
+ );
269
+ }
@@ -0,0 +1,379 @@
1
+ /* jshint esversion: 11 */
2
+ import type { GuiMachineSummary, GuiStatusData } from "@aidevops/gui-shared";
3
+ import { useEffect, useState } from "react";
4
+ import type { IconType } from "react-icons";
5
+ import {
6
+ FiBookmark,
7
+ FiBox,
8
+ FiBriefcase,
9
+ FiCalendar,
10
+ FiChevronDown,
11
+ FiChevronLeft,
12
+ FiChevronRight,
13
+ FiChevronUp,
14
+ FiClock,
15
+ FiDownloadCloud,
16
+ FiFileText,
17
+ FiFolder,
18
+ FiGitBranch,
19
+ FiGlobe,
20
+ FiGrid,
21
+ FiHardDrive,
22
+ FiLink,
23
+ FiLink2,
24
+ FiList,
25
+ FiMail,
26
+ FiMessageSquare,
27
+ FiMonitor,
28
+ FiPackage,
29
+ FiRotateCcw,
30
+ FiServer,
31
+ FiSettings,
32
+ FiShield,
33
+ FiTerminal,
34
+ FiUsers,
35
+ } from "react-icons/fi";
36
+ import type { FontPreference, FontSizePreference, SidebarMode, SurfaceIconName, SurfaceId, SurfaceNavGroup, SurfaceNavItem, ThemePreference } from "./app-model";
37
+ import { DEFAULT_ACCENT_HUE, dashboardNavItem, fontFamilyForPreference, fontOptions, fontSizeOptions, navGroups, sidebarModeForSurface, surfaceRecordCounts, text } from "./app-model";
38
+
39
+ const surfaceIcons: Record<SurfaceIconName, IconType> = {
40
+ apps: FiBox,
41
+ bookmark: FiBookmark,
42
+ brand: FiBriefcase,
43
+ calendar: FiCalendar,
44
+ chain: FiLink2,
45
+ clock: FiClock,
46
+ device: FiMonitor,
47
+ document: FiFileText,
48
+ download: FiDownloadCloud,
49
+ folder: FiFolder,
50
+ git: FiGitBranch,
51
+ globe: FiGlobe,
52
+ grid: FiGrid,
53
+ hardDrive: FiHardDrive,
54
+ link: FiLink,
55
+ list: FiList,
56
+ mail: FiMail,
57
+ message: FiMessageSquare,
58
+ note: FiMessageSquare,
59
+ package: FiPackage,
60
+ server: FiServer,
61
+ settings: FiSettings,
62
+ shield: FiShield,
63
+ terminal: FiTerminal,
64
+ users: FiUsers,
65
+ };
66
+
67
+ export function SurfaceGlyph({ icon }: { icon: SurfaceIconName }) {
68
+ const Icon = surfaceIcons[icon];
69
+
70
+ return <Icon aria-hidden="true" focusable="false" />;
71
+ }
72
+
73
+ export function MachineRail({ machine }: { machine?: GuiMachineSummary }) {
74
+ const localIp = machine?.local_ips?.[0] ?? "127.0.0.1";
75
+ const publicIp = machine?.public_ip ?? "public IP not configured";
76
+ const username = machine?.username ?? "local";
77
+ const initials = machine?.initials ?? "LM";
78
+ const title = `${username}\n${localIp}\n${publicIp}`;
79
+
80
+ return (
81
+ <aside className="machine-rail" aria-label="aidevops machines and clients">
82
+ <button className="machine-orb active" title={title} type="button">
83
+ <span>{initials}</span>
84
+ </button>
85
+ <button className="client-folder-orb" title="Client groups planned" type="button">⌁</button>
86
+ </aside>
87
+ );
88
+ }
89
+
90
+ export function Sidebar({ activeSurface, accentHue, canGoBack, canGoForward, fontPreference, fontSizePreference, goBack, goForward, setAccentHue, setActiveSurface, setFontPreference, setFontSizePreference, setShowBorders, setShowNavCounts, setThemePreference, showBorders, showNavCounts, status, themePreference }: {
91
+ activeSurface: SurfaceId;
92
+ accentHue: number;
93
+ canGoBack: boolean;
94
+ canGoForward: boolean;
95
+ fontPreference: FontPreference;
96
+ fontSizePreference: FontSizePreference;
97
+ goBack: () => void;
98
+ goForward: () => void;
99
+ setAccentHue: (hue: number) => void;
100
+ setActiveSurface: (surface: SurfaceId) => void;
101
+ setFontPreference: (font: FontPreference) => void;
102
+ setFontSizePreference: (size: FontSizePreference) => void;
103
+ setShowBorders: (show: boolean) => void;
104
+ setShowNavCounts: (show: boolean) => void;
105
+ setThemePreference: (theme: ThemePreference) => void;
106
+ showBorders: boolean;
107
+ showNavCounts: boolean;
108
+ status: GuiStatusData;
109
+ themePreference: ThemePreference;
110
+ }) {
111
+ const [sidebarMode, setSidebarMode] = useState<SidebarMode>(() => sidebarModeForSurface(activeSurface));
112
+ const visibleGroups = navGroups.filter((group) => group.mode === sidebarMode);
113
+ const recordCounts = surfaceRecordCounts(status);
114
+
115
+ useEffect(() => {
116
+ setSidebarMode(sidebarModeForSurface(activeSurface));
117
+ }, [activeSurface]);
118
+
119
+ return (
120
+ <aside className="app-sidebar" aria-label={text.navigationLabel}>
121
+ <SidebarHeader canGoBack={canGoBack} canGoForward={canGoForward} goBack={goBack} goForward={goForward} />
122
+ <nav className="sidebar-content">
123
+ <SidebarModeTabs mode={sidebarMode} setMode={setSidebarMode} />
124
+ <ul className="sidebar-top-link">
125
+ <SidebarItem activeSurface={activeSurface} item={dashboardNavItem} setActiveSurface={setActiveSurface} showCount={false} />
126
+ </ul>
127
+ {visibleGroups.map((group) => <SidebarGroup activeSurface={activeSurface} group={group} key={group.label} recordCounts={recordCounts} setActiveSurface={setActiveSurface} showNavCounts={showNavCounts} />)}
128
+ </nav>
129
+ <SidebarFooter
130
+ accentHue={accentHue}
131
+ fontPreference={fontPreference}
132
+ fontSizePreference={fontSizePreference}
133
+ setAccentHue={setAccentHue}
134
+ setFontPreference={setFontPreference}
135
+ setFontSizePreference={setFontSizePreference}
136
+ setShowBorders={setShowBorders}
137
+ setShowNavCounts={setShowNavCounts}
138
+ setThemePreference={setThemePreference}
139
+ showBorders={showBorders}
140
+ showNavCounts={showNavCounts}
141
+ themePreference={themePreference}
142
+ />
143
+ </aside>
144
+ );
145
+ }
146
+
147
+ function SidebarModeTabs({ mode, setMode }: {
148
+ mode: SidebarMode;
149
+ setMode: (mode: SidebarMode) => void;
150
+ }) {
151
+ const modes: Array<{ label: string; value: SidebarMode }> = [
152
+ { label: text.devops, value: "devops" },
153
+ { label: text.comms, value: "comms" },
154
+ ];
155
+
156
+ return (
157
+ <div className="sidebar-mode-tabs" role="tablist" aria-label="Sidebar sections">
158
+ {modes.map((entry) => (
159
+ <button
160
+ aria-selected={mode === entry.value}
161
+ className={mode === entry.value ? "active" : ""}
162
+ key={entry.value}
163
+ onClick={() => setMode(entry.value)}
164
+ role="tab"
165
+ type="button"
166
+ >
167
+ {entry.label}
168
+ </button>
169
+ ))}
170
+ </div>
171
+ );
172
+ }
173
+
174
+ function SidebarGroup({ activeSurface, group, recordCounts, setActiveSurface, showNavCounts }: {
175
+ activeSurface: SurfaceId;
176
+ group: SurfaceNavGroup;
177
+ recordCounts: Partial<Record<SurfaceId, number>>;
178
+ setActiveSurface: (surface: SurfaceId) => void;
179
+ showNavCounts: boolean;
180
+ }) {
181
+ return (
182
+ <section className="sidebar-group">
183
+ <h2>{group.label}</h2>
184
+ <ul>
185
+ {group.items.map((item) => <SidebarItem activeSurface={activeSurface} item={item} key={item.id} recordCount={recordCounts[item.id]} setActiveSurface={setActiveSurface} showCount={showNavCounts} />)}
186
+ </ul>
187
+ </section>
188
+ );
189
+ }
190
+
191
+ function SidebarItem({ activeSurface, item, recordCount, setActiveSurface, showCount }: {
192
+ activeSurface: SurfaceId;
193
+ item: SurfaceNavItem;
194
+ recordCount?: number;
195
+ setActiveSurface: (surface: SurfaceId) => void;
196
+ showCount: boolean;
197
+ }) {
198
+ const isActive = activeSurface === item.id;
199
+ const tooltip = `${item.label}: ${item.description}`;
200
+ const shouldShowCount = showCount && recordCount !== undefined && recordCount > 0;
201
+
202
+ return (
203
+ <li>
204
+ <button
205
+ aria-label={tooltip}
206
+ aria-current={isActive ? "page" : undefined}
207
+ className={isActive ? "surface-link active" : "surface-link"}
208
+ onClick={() => setActiveSurface(item.id)}
209
+ title={tooltip}
210
+ type="button"
211
+ >
212
+ <span className="surface-icon" aria-hidden="true"><SurfaceGlyph icon={item.icon} /></span>
213
+ <span className="surface-copy">
214
+ <span className="surface-title-row">
215
+ <strong>{item.label}</strong>
216
+ {shouldShowCount ? <span className="surface-count">({recordCount})</span> : null}
217
+ </span>
218
+ </span>
219
+ {item.badge ? <em>{item.badge}</em> : null}
220
+ </button>
221
+ </li>
222
+ );
223
+ }
224
+
225
+ function SidebarHeader({ canGoBack, canGoForward, goBack, goForward }: {
226
+ canGoBack: boolean;
227
+ canGoForward: boolean;
228
+ goBack: () => void;
229
+ goForward: () => void;
230
+ }) {
231
+ return (
232
+ <header className="sidebar-header">
233
+ <div className="sidebar-titlebar">
234
+ <div className="brand-lockup">
235
+ <span className="terminal-mark" aria-hidden="true">›_</span>
236
+ <strong>{text.aidevops}</strong>
237
+ </div>
238
+ <div className="sidebar-history-controls">
239
+ <button aria-label="Previous surface" disabled={!canGoBack} onClick={goBack} type="button"><FiChevronLeft /></button>
240
+ <button aria-label="Next surface" disabled={!canGoForward} onClick={goForward} type="button"><FiChevronRight /></button>
241
+ </div>
242
+ </div>
243
+ </header>
244
+ );
245
+ }
246
+
247
+ function SidebarFooter({ accentHue, fontPreference, fontSizePreference, setAccentHue, setFontPreference, setFontSizePreference, setShowBorders, setShowNavCounts, setThemePreference, showBorders, showNavCounts, themePreference }: {
248
+ accentHue: number;
249
+ fontPreference: FontPreference;
250
+ fontSizePreference: FontSizePreference;
251
+ setAccentHue: (hue: number) => void;
252
+ setFontPreference: (font: FontPreference) => void;
253
+ setFontSizePreference: (size: FontSizePreference) => void;
254
+ setShowBorders: (show: boolean) => void;
255
+ setShowNavCounts: (show: boolean) => void;
256
+ setThemePreference: (theme: ThemePreference) => void;
257
+ showBorders: boolean;
258
+ showNavCounts: boolean;
259
+ themePreference: ThemePreference;
260
+ }) {
261
+ const [appearanceOpen, setAppearanceOpen] = useState(true);
262
+ const AppearanceChevron = appearanceOpen ? FiChevronDown : FiChevronUp;
263
+ const selectedFontFamily = fontFamilyForPreference(fontPreference);
264
+ const fontSizeIndex = Math.max(0, fontSizeOptions.findIndex((option) => option.value === fontSizePreference));
265
+ const [hueInput, setHueInput] = useState(() => String(accentHue));
266
+ const updateAccentHue = (value: number) => {
267
+ if (!Number.isFinite(value)) {
268
+ return;
269
+ }
270
+ setAccentHue(Math.min(359, Math.max(0, value)));
271
+ };
272
+ const updateHueInput = (value: string) => {
273
+ setHueInput(value);
274
+ if (value.trim().length === 0) {
275
+ return;
276
+ }
277
+ updateAccentHue(Number.parseInt(value, 10));
278
+ };
279
+
280
+ useEffect(() => {
281
+ setHueInput(String(accentHue));
282
+ }, [accentHue]);
283
+
284
+ return (
285
+ <footer className="sidebar-footer">
286
+ <section className={appearanceOpen ? "appearance-panel open" : "appearance-panel collapsed"}>
287
+ <button
288
+ aria-expanded={appearanceOpen}
289
+ className="appearance-panel-tab"
290
+ onClick={() => setAppearanceOpen((current) => !current)}
291
+ type="button"
292
+ >
293
+ {text.appearance}
294
+ <AppearanceChevron aria-hidden="true" className="appearance-chevron" />
295
+ </button>
296
+ {appearanceOpen ? <div className="appearance-panel-body">
297
+ <fieldset className="theme-control compact" aria-label={text.theme}>
298
+ {(["system", "light", "dark"] as const).map((theme) => (
299
+ <button
300
+ aria-pressed={themePreference === theme}
301
+ className={themePreference === theme ? "theme-option active" : "theme-option"}
302
+ key={theme}
303
+ onClick={() => setThemePreference(theme)}
304
+ type="button"
305
+ >
306
+ {theme}
307
+ </button>
308
+ ))}
309
+ </fieldset>
310
+ <div className="theme-hue-control">
311
+ <div className="theme-control-heading">
312
+ <div className="hue-label-row">
313
+ <label htmlFor="theme-hue-slider">{text.hue}</label>
314
+ <input
315
+ aria-label="Hue value"
316
+ className="hue-number-input"
317
+ max="359"
318
+ min="0"
319
+ onChange={(event) => updateHueInput(event.currentTarget.value)}
320
+ type="number"
321
+ value={hueInput}
322
+ />
323
+ </div>
324
+ <button aria-label="Reset hue to default" className="icon-reset-button" onClick={() => setAccentHue(DEFAULT_ACCENT_HUE)} title={text.reset} type="button"><FiRotateCcw aria-hidden="true" /></button>
325
+ </div>
326
+ <input
327
+ id="theme-hue-slider"
328
+ max="359"
329
+ min="0"
330
+ onChange={(event) => updateAccentHue(Number.parseInt(event.currentTarget.value, 10))}
331
+ type="range"
332
+ value={accentHue}
333
+ />
334
+ </div>
335
+ <label className="switch-control appearance-switch">
336
+ <strong>{text.showBorders}</strong>
337
+ <input checked={showBorders} onChange={(event) => setShowBorders(event.currentTarget.checked)} type="checkbox" />
338
+ <span aria-hidden="true" />
339
+ </label>
340
+ <label className="switch-control appearance-switch">
341
+ <strong>{text.showCounts}</strong>
342
+ <input checked={showNavCounts} onChange={(event) => setShowNavCounts(event.currentTarget.checked)} type="checkbox" />
343
+ <span aria-hidden="true" />
344
+ </label>
345
+ <div className="font-size-control">
346
+ <label htmlFor="font-size-slider">{text.fontSize}</label>
347
+ <input
348
+ id="font-size-slider"
349
+ max={fontSizeOptions.length - 1}
350
+ min="0"
351
+ onChange={(event) => setFontSizePreference(fontSizeOptions[Number.parseInt(event.currentTarget.value, 10)]?.value ?? "m")}
352
+ step="1"
353
+ type="range"
354
+ value={fontSizeIndex}
355
+ />
356
+ <div className="range-labels" aria-hidden="true">
357
+ {fontSizeOptions.map((option) => <span className={option.value === fontSizePreference ? "active" : ""} key={option.value}>{option.label}</span>)}
358
+ </div>
359
+ </div>
360
+ <label className="font-control">
361
+ <span>{text.font}</span>
362
+ <select
363
+ onChange={(event) => setFontPreference(event.currentTarget.value as FontPreference)}
364
+ style={{ fontFamily: selectedFontFamily }}
365
+ value={fontPreference}
366
+ >
367
+ {fontOptions.map((option) => (
368
+ <option key={option.value} style={{ fontFamily: option.fontFamily }} value={option.value}>
369
+ {option.label}
370
+ </option>
371
+ ))}
372
+ </select>
373
+ </label>
374
+ </div>
375
+ : null}
376
+ </section>
377
+ </footer>
378
+ );
379
+ }