Add NavMenu settings/presets UI and profile HTTP routes (ticket 011)

Adds thin HTTP routes for profile CRUD and colour-scheme updates
(GET/PATCH /api/settings, GET/POST /api/settings/profiles,
DELETE /api/settings/profiles/:name, POST /api/settings/profiles/:name/activate)
following the existing CSS-route pattern in api.rs, with activate hot-swapping
the running engine's declared params/metrics so switching profiles needs no
restart. Builds out the NavMenu frontend with Settings (colour scheme picker,
CSS import/list/delete) and Presets (list/create/switch/delete profiles with
declaration counts) panels, and a useSettingsPanel hook that applies the
active colour scheme and imported CSS to the document live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 13:21:31 +01:00
co-authored by Claude Sonnet 5
parent 57d23c62e9
commit 801ac36b78
6 changed files with 772 additions and 26 deletions
+83
View File
@@ -43,6 +43,29 @@ export interface Stats {
last_timestamp_ms: number | null
}
export type ColorScheme = 'light' | 'dark' | 'system'
export interface Settings {
color_scheme: ColorScheme
active_profile: string | null
}
export type CssEntryKind = 'imported' | 'linked'
export interface CssEntry {
id: string
kind: CssEntryKind
filename: string | null
path_or_url: string | null
}
export interface ProfileSummary {
name: string
param_count: number
metric_count: number
dashboard_count: number
}
async function getJson<T>(path: string): Promise<T> {
const res = await fetch(path)
if (!res.ok) {
@@ -51,6 +74,26 @@ async function getJson<T>(path: string): Promise<T> {
return res.json() as Promise<T>
}
async function sendJson<T>(path: string, method: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
let message = `${path} failed: ${res.status}`
try {
const data = await res.json()
if (data?.error) message = data.error
} catch {
// response body wasn't JSON (or was empty) — fall back to the generic message
}
throw new Error(message)
}
if (res.status === 204) return undefined as T
return res.json() as Promise<T>
}
export function fetchLogs(params: {
sinceSequence?: number
startMs?: number
@@ -84,3 +127,43 @@ export function fetchStats(): Promise<Stats> {
export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
return getJson('/api/params')
}
export function fetchSettings(): Promise<Settings> {
return getJson('/api/settings')
}
export function updateSettings(update: { colorScheme?: ColorScheme }): Promise<Settings> {
return sendJson('/api/settings', 'PATCH', { color_scheme: update.colorScheme })
}
export function fetchCssEntries(): Promise<CssEntry[]> {
return getJson('/api/settings/css')
}
export function importCss(filename: string, content: string): Promise<CssEntry> {
return sendJson('/api/settings/css', 'POST', { filename, content })
}
export function deleteCssEntry(id: string): Promise<void> {
return sendJson(`/api/settings/css/${encodeURIComponent(id)}`, 'DELETE')
}
export function cssContentUrl(id: string): string {
return `/api/settings/css/${encodeURIComponent(id)}/content`
}
export function fetchProfiles(): Promise<ProfileSummary[]> {
return getJson('/api/settings/profiles')
}
export function createProfile(name: string): Promise<ProfileSummary> {
return sendJson('/api/settings/profiles', 'POST', { name })
}
export function deleteProfile(name: string): Promise<void> {
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}`, 'DELETE')
}
export function activateProfile(name: string): Promise<Settings> {
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST')
}
+181
View File
@@ -0,0 +1,181 @@
import { useCallback, useEffect, useState } from 'react'
import {
activateProfile,
createProfile,
cssContentUrl,
deleteCssEntry,
deleteProfile,
fetchCssEntries,
fetchProfiles,
fetchSettings,
importCss,
updateSettings,
type ColorScheme,
type CssEntry,
type ProfileSummary,
type Settings,
} from './api'
/** `data-*` marker used to find/remove previously-injected CSS tags on re-apply. */
const INJECTED_CSS_ATTR = 'data-ottyl-css-id'
/**
* Resolves `colorScheme` to an effective `light`/`dark` value (following the
* OS preference for `system`) and reflects it onto `<html>` as both a class
* (`light`/`dark`) and a `data-color-scheme` attribute. The app's Tailwind
* classes are mostly hardcoded to a dark palette (see `index.css`) rather
* than using `dark:` variants, so "Light" mode is necessarily limited to
* whatever `index.css` explicitly overrides for `.light` — this is a known,
* documented limitation rather than a full theme system.
*/
function applyColorScheme(colorScheme: ColorScheme) {
const root = document.documentElement
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true
const effective = colorScheme === 'system' ? (prefersDark ? 'dark' : 'light') : colorScheme
root.classList.remove('light', 'dark')
root.classList.add(effective)
root.dataset.colorScheme = colorScheme
}
/**
* Injects a `<link>` tag per CSS entry into `<head>` (imported entries point
* at `GET /api/settings/css/:id/content`, linked entries point straight at
* their `path_or_url`), after removing any tags this hook previously
* injected — so the rendered app reflects the current entry list immediately
* on every change, without a page reload.
*/
function applyCssEntries(entries: CssEntry[]) {
document.querySelectorAll(`link[${INJECTED_CSS_ATTR}]`).forEach((el) => el.remove())
for (const entry of entries) {
const href = entry.kind === 'imported' ? cssContentUrl(entry.id) : entry.path_or_url
if (!href) continue
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = href
link.setAttribute(INJECTED_CSS_ATTR, entry.id)
document.head.appendChild(link)
}
}
/**
* Owns fetching + mutating settings/CSS/profile state for the NavMenu panels
* (ticket 011), and applies the colour scheme / imported CSS to the document
* as a side effect whenever that state changes — so toggling either is
* visible in the running app immediately, with no reload.
*/
export function useSettingsPanel() {
const [settings, setSettings] = useState<Settings | null>(null)
const [cssEntries, setCssEntries] = useState<CssEntry[]>([])
const [profiles, setProfiles] = useState<ProfileSummary[]>([])
const [error, setError] = useState<string | null>(null)
const refreshSettings = useCallback(async () => {
const next = await fetchSettings()
setSettings(next)
return next
}, [])
const refreshCss = useCallback(async () => {
const next = await fetchCssEntries()
setCssEntries(next)
return next
}, [])
const refreshProfiles = useCallback(async () => {
const next = await fetchProfiles()
setProfiles(next)
return next
}, [])
useEffect(() => {
refreshSettings().catch(() => {})
refreshCss().catch(() => {})
refreshProfiles().catch(() => {})
}, [refreshSettings, refreshCss, refreshProfiles])
useEffect(() => {
if (settings) applyColorScheme(settings.color_scheme)
}, [settings])
useEffect(() => {
applyCssEntries(cssEntries)
}, [cssEntries])
const guarded = useCallback((action: () => Promise<void>) => {
return async () => {
try {
setError(null)
await action()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}
}, [])
const setColorScheme = useCallback(
(colorScheme: ColorScheme) =>
guarded(async () => {
const next = await updateSettings({ colorScheme })
setSettings(next)
})(),
[guarded],
)
const importCssEntry = useCallback(
(filename: string, content: string) =>
guarded(async () => {
await importCss(filename, content)
await refreshCss()
})(),
[guarded, refreshCss],
)
const removeCssEntry = useCallback(
(id: string) =>
guarded(async () => {
await deleteCssEntry(id)
await refreshCss()
})(),
[guarded, refreshCss],
)
const addProfile = useCallback(
(name: string) =>
guarded(async () => {
await createProfile(name)
await refreshProfiles()
})(),
[guarded, refreshProfiles],
)
const removeProfile = useCallback(
(name: string) =>
guarded(async () => {
await deleteProfile(name)
await Promise.all([refreshProfiles(), refreshSettings()])
})(),
[guarded, refreshProfiles, refreshSettings],
)
const switchProfile = useCallback(
(name: string) =>
guarded(async () => {
const next = await activateProfile(name)
setSettings(next)
})(),
[guarded],
)
return {
settings,
cssEntries,
profiles,
error,
setColorScheme,
importCssEntry,
removeCssEntry,
addProfile,
removeProfile,
switchProfile,
}
}